随着用户对视觉舒适度和个性化需求的提升,暗黑模式已经从可选特性变成了应用的标配。在 uni-app 这类跨端框架中实现暗黑模式,需要面对 H5、小程序、App 不同的渲染机制与限制,仅仅依靠传统的 CSS 类切换往往难以完美覆盖。本文将带你从底层原理出发,结合 CSS 自定义属性 和 Pinia 状态管理,构建一套可维护、可扩展的跨端主题系统,并提供一个完整的动态切换案例,让你的 uni-app 项目轻松融入暗黑模式。
一、跨端暗黑模式的核心挑战
在单一平台中实现暗黑模式并不复杂:H5 可以使用 CSS 变量配合 :root 或 class 切换;小程序则受限于无法直接操作 :root 且 CSS 变量支持度不一;App 端渲染层介于两者之间。主要挑战集中在:
- 样式抽离:需要一套统一的颜色定义,而非在每个组件里硬编码颜色值。
- 动态切换:用户应能在运行时切换主题,无需重启应用。
- 平台兼容:小程序中不支持
:root,必须通过页面级 class 或行内 style 传递变量。 - 持久化:用户选择的主题需要在下次启动时恢复。
我们选择 CSS 自定义属性(变量) 作为颜色载体,因为它们可以在运行时通过 JavaScript 修改,且现代浏览器和 uni-app 运行的 WebView 均已支持。配合 Pinia 全局状态管理,我们能够优雅地控制主题的切换与持久化。
二、项目初始化与基础配置
使用 HBuilderX 或 CLI 创建一个基于 Vue 3 的 uni-app 项目。安装 Pinia 用于状态管理:
npm install pinia
在 main.js 中注册 Pinia:
import App from './App'
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
export function createApp() {
const app = createSSRApp(App)
app.use(createPinia())
return {
app
}
}
接下来构建项目目录结构,我们将主题相关的文件集中放在 theme 文件夹下:
src/
├── theme/
│ ├── light.css # 亮色主题变量
│ ├── dark.css # 暗色主题变量
│ └── index.js # 主题工具函数(可选)
├── store/
│ └── theme.js # Pinia 主题 Store
├── pages/
│ └── index/
│ └── index.vue # 首页示例
├── App.vue
└── main.js
三、定义主题变量与CSS文件
为了最大化兼容性,我们不使用 :root(小程序不支持),而是将主题变量写在普通类选择器中,通过给页面根元素动态添加 class 来切换。首先编写亮色主题 theme/light.css:
/* theme/light.css */
.theme-light {
--bg-color: #ffffff;
--text-color: #333333;
--card-bg: #f5f5f5;
--border-color: #e0e0e0;
--primary-color: #2b6ef0;
--nav-bg: #ffffff;
--tab-text: #666666;
--tab-active: #2b6ef0;
}
暗色主题 theme/dark.css:
/* theme/dark.css */
.theme-dark {
--bg-color: #1a1a2e;
--text-color: #e0e0e0;
--card-bg: #16213e;
--border-color: #2a2a4a;
--primary-color: #5b8def;
--nav-bg: #0f3460;
--tab-text: #aaaaaa;
--tab-active: #5b8def;
}
关键在于每个主题都包裹在一个特定的 class 下(.theme-light 和 .theme-dark),这样我们可以在 App.vue 的最外层容器上切换该 class,所有后代元素都能继承这些变量。
四、创建 Pinia 主题 Store
在 store/theme.js 中编写状态管理逻辑,负责主题的读取、切换和持久化:
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useThemeStore = defineStore('theme', () => {
// 从本地存储读取上次设置的主题,默认为 'light'
const currentTheme = ref(uni.getStorageSync('APP_THEME') || 'light')
// 切换主题
function toggleTheme() {
currentTheme.value = currentTheme.value === 'light' ? 'dark' : 'light'
uni.setStorageSync('APP_THEME', currentTheme.value)
applyTheme(currentTheme.value)
}
// 初始化主题(在App.vue中调用)
function initTheme() {
applyTheme(currentTheme.value)
}
// 内部方法:将主题类名应用到页面根元素
function applyTheme(theme) {
// H5 和 App 端可以通过 document 操作
// #ifdef H5 || APP-PLUS
const root = document.documentElement
root.className = theme === 'dark' ? 'theme-dark' : 'theme-light'
// #endif
// 小程序端需要借助页面栈逐个设置,或者通过全局样式变量传递
// #ifdef MP
// 小程序无法直接操作 document,采用事件通知或全局变量方式
uni.$emit('themeChanged', theme)
// #endif
}
return {
currentTheme,
toggleTheme,
initTheme
}
})
需要注意的是,小程序没有 document 对象,无法动态设置根元素 class。我们将在页面组件中通过计算属性绑定 class 来兼容。
五、App.vue 中初始化主题样式引入
在 App.vue 中,我们要确保主题 CSS 文件被全局引入,并且在应用启动时调用 initTheme:
<template>
<!-- 在最外层容器上绑定主题class -->
<view :class="['app-container', themeClass]">
<router-view />
</view>
</template>
<script setup>
import { computed } from 'vue'
import { useThemeStore } from '@/store/theme'
const themeStore = useThemeStore()
themeStore.initTheme()
const themeClass = computed(() => {
return themeStore.currentTheme === 'dark' ? 'theme-dark' : 'theme-light'
})
</script>
<style>
/* 引入主题文件 */
@import '@/theme/light.css';
@import '@/theme/dark.css';
.app-container {
min-height: 100vh;
background-color: var(--bg-color);
color: var(--text-color);
transition: background-color 0.3s, color 0.3s;
}
</style>
这样,无论是 H5、App 还是小程序,App.vue 的最外层 view 都会被赋予正确的主题类名,CSS 变量随之切换。小程序中不支持在 style 中使用 @import 引入相对路径的 CSS 文件,但 HBuilderX 在编译时会将这些样式合并到全局样式中,因此本方案在多端均可生效。
六、页面组件中使用主题变量
我们在一个首页示例中展示如何消费这些变量。创建 pages/index/index.vue:
<template>
<view class="home">
<view class="header">
<text class="title">主题演示</text>
<button @click="themeStore.toggleTheme()" class="toggle-btn">
切换为{{ themeStore.currentTheme === 'light' ? '暗色' : '亮色' }}模式
</button>
</view>
<view class="card">
<text class="card-title">动态卡片</text>
<text class="card-content">我是一张卡片,背景和文字颜色会随主题变化。</text>
</view>
<view class="nav-bar">
<text class="nav-item active">首页</text>
<text class="nav-item">发现</text>
<text class="nav-item">我的</text>
</view>
</view>
</template>
<script setup>
import { useThemeStore } from '@/store/theme'
const themeStore = useThemeStore()
</script>
<style scoped>
.header {
padding: 30rpx;
background-color: var(--nav-bg);
border-bottom: 1px solid var(--border-color);
}
.title {
font-size: 36rpx;
font-weight: bold;
color: var(--text-color);
}
.toggle-btn {
margin-top: 20rpx;
background-color: var(--primary-color);
color: #fff;
border: none;
border-radius: 16rpx;
padding: 20rpx 30rpx;
}
.card {
margin: 30rpx;
padding: 30rpx;
background-color: var(--card-bg);
border-radius: 16rpx;
border: 1px solid var(--border-color);
}
.card-title {
font-size: 30rpx;
font-weight: bold;
color: var(--text-color);
}
.card-content {
font-size: 26rpx;
color: var(--text-color);
margin-top: 10rpx;
}
.nav-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-around;
padding: 20rpx;
background-color: var(--nav-bg);
border-top: 1px solid var(--border-color);
}
.nav-item {
font-size: 24rpx;
color: var(--tab-text);
}
.nav-item.active {
color: var(--tab-active);
}
</style>
所有颜色值均通过 var(--xxx) 引用,当 .theme-light 或 .theme-dark 作用在根容器上时,这些变量值会自动切换,无需修改组件代码。
七、处理小程序特殊兼容逻辑
小程序中无法通过 document 修改根元素 class,但由于我们在 App.vue 中通过 computed 计算了 themeClass 并绑定到最外层 view,实际上小程序也会获得正确的类名。因此上述方案已覆盖小程序场景。唯一需要注意的是,如果某些页面需要在 onShow 时强制刷新主题(例如用户从设置页返回),可以监听全局事件:
// 在需要刷新的页面中
import { onShow } from '@dcloudio/uni-app'
import { useThemeStore } from '@/store/theme'
const themeStore = useThemeStore()
onShow(() => {
// 强制同步主题(理论上不需要,因为Store是响应式的)
// 但可以在此处理一些特殊平台的微调
})
另外,部分小程序平台对 CSS 变量在组件中的穿透性支持不一,如果遇到组件内部样式不生效,可考虑在 style 中显式使用 var() 并确保该样式未被 scoped 过度隔离(uni-app 的 scoped 一般不影响变量继承)。
八、原生App端的额外优化:状态栏与导航栏适配
在 App 端,暗黑模式下通常还希望原生导航栏和状态栏也跟随变化。我们可以在 toggleTheme 方法中通过条件编译调用原生 API:
// store/theme.js 的 toggleTheme 内增加以下代码(需导入 uni)
// #ifdef APP-PLUS
const systemInfo = uni.getSystemInfoSync()
if (systemInfo.platform === 'android') {
// 安卓状态栏文字颜色
plus.nativeObj.View.setStatusBarBackground(theme === 'dark' ? '#1a1a2e' : '#ffffff')
plus.nativeObj.View.setStatusBarStyle(theme === 'dark' ? 'light' : 'dark')
}
// #endif
对于 iOS,由于状态栏样式由系统控制,通常跟随应用内主题变化,但可调整导航栏背景色。
九、持久化与首次加载闪烁处理
通过 uni.getStorageSync 恢复主题后,在 App.vue 的 initTheme 中已经设置根容器 class,因此页面首次渲染时就会使用正确的主题,基本避免了白屏闪烁。但如果在极慢的设备上,仍可能出现短暂亮色样式闪现。可以在 index.html(H5)中预先执行一段脚本读取 localStorage 并设置根样式,但对于小程序和 App 则无需额外处理。
完整主题持久化已由 Pinia Store 处理,用户切换后立即写入 Storage,下次启动无缝恢复。
十、总结与扩展
本文构建的暗黑模式方案具备以下优点:
- 一处定义,全局生效:通过 CSS 变量实现颜色的集中管理,修改主题只需增减变量。
- 运行时动态切换:Pinia 驱动类名变化,响应式框架自动更新所有绑定样式。
- 全端兼容:利用条件编译和
computed绑定根 class,同时适配 H5、小程序和 App。 - 易于扩展:增加新主题只需添加新的 CSS 文件和对应的 Store 逻辑。
你可以在此基础上进一步丰富:支持跟随系统主题自动切换(使用 uni.getSystemInfo 获取系统主题)、将主题变量扩展到组件库(如 uni-ui)的定制、或添加用户自定义主题配色。暗黑模式并不是终点,而是建立一套灵活的主题架构的起点。掌握这些方法后,你的 uni-app 应用将能够自信应对任何视觉风格需求。

