深色模式(Dark Mode)在 iOS 13 与 Android 10 之后成为系统级能力,微信小程序也从基础库 2.11.0 起提供了官方适配方案。但大多数项目的「深色模式」只做到了把 page 背景改成 #1a1a1a,结果是一打开就发现:卡片还是白的、图标还是黑的、输入框的 placeholder 看不清、第三方 UI 库整个亮着。
问题不在于难,而在于没有建立「主题令牌(Design Token)」这层抽象。硬编码的颜色散落在几十个 WXSS 文件里,改一处漏十处。本文按「官方机制 → 令牌体系 → 运行时切换 → 适配盲区」的顺序,把一套可维护的主题系统搭起来。
一、深色模式的三层适配机制
微信小程序对深色模式的支持分三层,逐层递进。
1.1 第一层:app.json 开启 darkmode
{
"darkmode": true,
"themeLocation": "theme.json"
}
darkmode: true 是总开关。开启后,小程序会在系统切换深色模式时触发 wx.onThemeChange,并且 wx.getSystemInfoSync().theme 会返回 'dark' 或 'light'。不开这个开关,后面所有能力都不生效。
1.2 第二层:theme.json 声明导航栏与标签栏
theme.json 用来让原生 UI(导航栏、TabBar、下拉刷新背景)跟随主题变化。它只支持 light / dark 两个键:
{
"light": {
"navBackgroundColor": "#ffffff",
"navTextStyle": "black",
"tabBackgroundColor": "#ffffff",
"tabColor": "#999999",
"tabSelectedColor": "#07c160"
},
"dark": {
"navBackgroundColor": "#1a1a1a",
"navTextStyle": "white",
"tabBackgroundColor": "#1a1a1a",
"tabColor": "#666666",
"tabSelectedColor": "#07c160"
}
}
然后在 app.json 里用 @ 引用这些变量:
{
"window": {
"navigationBarBackgroundColor": "@navBackgroundColor",
"navigationBarTextStyle": "@navTextStyle"
},
"tabBar": {
"backgroundColor": "@tabBackgroundColor",
"color": "@tabColor",
"selectedColor": "@tabSelectedColor"
}
}
theme.json 的能力边界很明确:它只管原生渲染的那部分 UI,页面内 WXML 的颜色它一概管不了。
1.3 第三层:页面内 CSS 变量
页面内的适配全靠 CSS 变量 + 媒体查询,这是工作量最大也最关键的一层,下一节展开。
二、CSS 变量与 prefers-color-scheme
2.1 在 app.wxss 定义全局变量
/* app.wxss */
page {
--bg-primary: #ffffff;
--bg-secondary: #f7f7f7;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--text-placeholder: #999999;
--border-color: #e5e5e5;
--brand: #07c160;
--danger: #fa5151;
}
@media (prefers-color-scheme: dark) {
page {
--bg-primary: #1a1a1a;
--bg-secondary: #262626;
--text-primary: #ededed;
--text-secondary: #a3a3a3;
--text-placeholder: #6b6b6b;
--border-color: #333333;
--brand: #07c160;
--danger: #ff6b6b;
}
}
注意几个细节:
- 变量定义在
page选择器上,而不是:root(小程序没有:root) prefers-color-scheme的生效依赖darkmode: true- 品牌色在深色下通常要提亮,因为深背景上的同等亮度感知更低
2.2 组件内使用变量
.card {
background: var(--bg-primary);
border: 1rpx solid var(--border-color);
color: var(--text-primary);
}
.card__desc {
color: var(--text-secondary);
}
所有硬编码的颜色都必须替换成变量。CSS 变量本身的工程化用法(作用域、层叠、回退值)在 现代 CSS 工程实践 里有系统讲解。替换这一步可以用脚本批量扫描:
# 找出所有硬编码的十六进制颜色(排除变量定义行)
python3 - <<'PY'
import re, glob
for f in glob.glob('**/*.wxss', recursive=True):
for i, line in enumerate(open(f, encoding='utf-8'), 1):
if '--' in line:
continue
for m in re.findall(r'#[0-9a-fA-F]{3,8}\b', line):
print(f'{f}:{i}: {m}')
PY
2.3 自定义导航栏的特殊处理
自定义导航栏(navigationStyle: custom)不受 theme.json 控制,必须手动读主题:
Page({
data: { theme: 'light' },
onLoad() {
const { theme } = wx.getSystemInfoSync()
this.setData({ theme })
},
onThemeChange(res) {
this.setData({ theme: res.theme })
}
})
<view class="nav {{theme === 'dark' ? 'nav--dark' : ''}}">
<text class="nav__title">首页</text>
</view>
三、语义化设计令牌体系
直接把 --bg-primary 这类变量铺到业务代码里,短期够用,长期会失控:一旦要加第三套主题(比如品牌联名皮肤),所有变量都得再复制一遍。正确的做法是分三层。
3.1 三层令牌模型
| 层级 | 命名示例 | 职责 | 是否随主题变化 |
|---|---|---|---|
| 基础令牌(Primitive) | --gray-900: #1a1a1a | 原始色板,不承载语义 | 否 |
| 语义令牌(Semantic) | --color-text-primary | 描述用途 | 是 |
| 组件令牌(Component) | --card-bg | 绑定到具体组件 | 是(引用语义令牌) |
page {
/* 第一层:色板,主题无关 */
--gray-50: #f7f7f7;
--gray-200: #e5e5e5;
--gray-500: #999999;
--gray-700: #666666;
--gray-900: #1a1a1a;
--green-500: #07c160;
/* 第二层:语义,主题相关 */
--color-bg: var(--gray-50);
--color-surface: #ffffff;
--color-text-primary: var(--gray-900);
--color-text-secondary: var(--gray-700);
--color-border: var(--gray-200);
--color-brand: var(--green-500);
/* 第三层:组件 */
--card-bg: var(--color-surface);
--card-border: var(--color-border);
}
@media (prefers-color-scheme: dark) {
page {
--color-bg: #121212;
--color-surface: #1e1e1e;
--color-text-primary: #ededed;
--color-text-secondary: #a0a0a0;
--color-border: #2e2e2e;
/* 基础色板不动,只覆盖语义层 */
}
}
核心收益:加新主题时只覆盖第二层,基础色板与组件令牌完全复用。这套思路与 /miniprogram-design-system/ 里的组件规范是同一套语言,令牌是设计系统在代码层的落点。
3.2 令牌与设计工具同步
令牌不应该手写两遍。推荐用 tokens.json(结构为 { "light": {...}, "dark": {...} })作为单一事实来源,构建时生成 WXSS 变量:
// build-tokens.js —— 生成 theme.wxss
const tokens = require('./tokens.json')
let out = 'page {\n'
for (const [k, v] of Object.entries(tokens.light)) out += ` --${k}: ${v};\n`
out += '}\n@media (prefers-color-scheme: dark) {\n page {\n'
for (const [k, v] of Object.entries(tokens.dark)) out += ` --${k}: ${v};\n`
out += ' }\n}\n'
require('fs').writeFileSync('styles/theme.wxss', out)
把这一步挂进构建流水线,设计侧改一个色值,代码侧自动同步,避免「设计稿改了但代码没跟上」。
四、运行时主题切换与持久化
跟随系统只是基础需求,很多产品还要支持「手动切换」(跟随系统 / 强制浅色 / 强制深色三态)。
4.1 三态主题模型
// theme-manager.js
const KEY = 'theme_preference' // 'system' | 'light' | 'dark'
class ThemeManager {
constructor() {
this.mode = wx.getStorageSync(KEY) || 'system'
this.systemTheme = wx.getSystemInfoSync().theme || 'light'
this.listeners = new Set()
wx.onThemeChange(({ theme }) => {
this.systemTheme = theme
this._notify()
})
}
get resolved() {
return this.mode === 'system' ? this.systemTheme : this.mode
}
setMode(mode) {
this.mode = mode
wx.setStorageSync(KEY, mode)
this._notify()
}
subscribe(fn) {
this.listeners.add(fn)
return () => this.listeners.delete(fn)
}
_notify() {
this.listeners.forEach(fn => fn(this.resolved))
}
}
export const themeManager = new ThemeManager()
4.2 强制主题的实现
当用户选择「强制浅色」而系统是深色时,prefers-color-scheme 依然会命中深色分支。这时需要用一个 class 覆盖:
/* 强制浅色:优先级高于媒体查询 */
page.force-light {
--color-bg: #f7f7f7;
--color-surface: #ffffff;
--color-text-primary: #1a1a1a;
}
// 在 app.js 中把 resolved 写到 page 的 class 上
applyTheme(resolved) {
const pages = getCurrentPages()
pages.forEach(p => {
p.setData({ themeClass: `force-${resolved}` })
})
}
每个页面的根节点绑定 class="{{themeClass}}"。这里要注意:媒体查询和 class 覆盖会同时命中,靠 CSS 优先级(class 选择器权重高于元素选择器)决定胜出,所以 page.force-light 必须写在媒体查询之后。
4.3 状态管理的接入
主题是典型的全局状态,接入统一的状态管理能让跨页面同步变得简单,具体方案可参考 /miniprogram-state-management/。要点是把 resolved 主题作为 store 的一个字段,页面通过订阅拿到,而不是各自 wx.getSystemInfoSync()。
4.4 持久化与首屏闪烁
深色模式最常见的体验问题是「首屏白闪」:页面先按浅色渲染,读到 storage 后再切深色。解决办法是把主题判定提前到 app.js 的 onLaunch 之前——小程序的 app.js 执行时机早于页面渲染,只要在 App({}) 调用前同步读取 storage,就能避免闪烁:
// app.js 顶部,App() 之前
const saved = wx.getStorageSync('theme_preference')
const systemTheme = wx.getSystemInfoSync().theme
const initialTheme = saved === 'system' ? systemTheme : (saved || systemTheme)
App({
globalData: { initialTheme },
onLaunch() { /* ... */ }
})
五、适配盲区
深色模式下最容易出问题的不是主流程,而是那些「不在 WXSS 变量覆盖范围内」的地方。
5.1 图片与图标
- 位图图标:黑白图标在深色下不可见。方案是改用 SVG 内联(通过
<image>的src传 data URI)或字体图标,用currentColor跟随文字色 - 插图与 Banner:需要准备深浅两套资源,按
theme条件切换 - 用户上传的图片:不要加白色边框或阴影,深色下会很突兀
<image src="{{theme === 'dark' ? darkIcon : lightIcon}}" class="icon" />
5.2 半透明遮罩与阴影
浅色下的 rgba(0,0,0,0.5) 遮罩在深色下会变成「黑上加黑」,失去层次。阴影同理——深色背景上的黑色阴影几乎不可见,应该改用更亮的边框或轻微的背景提亮来表达层级。
.modal__mask {
background: rgba(0, 0, 0, 0.5);
}
@media (prefers-color-scheme: dark) {
.modal__mask {
background: rgba(0, 0, 0, 0.7);
}
}
5.3 第三方组件库
引入的 UI 库(Vant Weapp、TDesign 等)如果自身不支持深色模式,会整块亮着。三条路:优先选官方支持深色的库;次之用外层容器覆盖其 CSS 变量;最后是 fork 后改造。评估第三方库的主题能力时,应该和评估其他能力一样纳入准入清单。
5.4 WebView 内嵌页
web-view 加载的 H5 页面运行在独立 WebView 里,不继承小程序的 CSS 变量。需要把主题通过 URL 参数传给 H5:
const url = `https://example.com/page?theme=${resolved}&t=${Date.now()}`
H5 侧再按参数切换自己的主题,把主题通过 URL 参数传进 WebView 是唯一可行的链路。深色模式的配色本身也要满足对比度要求,这部分可参考 数据可视化中的无障碍配色 中关于对比度的计算方法。
六、测试与验收
6.1 必测矩阵
| 维度 | 取值 | 说明 |
|---|---|---|
| 系统主题 | light / dark | 真机切换,不能只靠开发者工具 |
| 应用主题 | system / light / dark | 三态组合共 6 种 |
| 平台 | iOS / Android | Android 定制 ROM 有差异 |
| 场景 | 冷启动 / 后台切回 | 切回时要重新读主题 |
6.2 验收清单
- 冷启动无白闪
- 原生导航栏、TabBar、下拉刷新背景跟随
- 所有文本对比度满足 WCAG AA(正文 4.5:1,大字 3:1)
- 无硬编码颜色残留(用上面的扫描脚本验证)
- 弹窗遮罩、分割线、禁用态、加载态在深色下都可见
- 状态栏文字颜色正确(
navigationBarTextStyle)
把 2.2 节的颜色扫描脚本接入 CI,任何新增的硬编码颜色直接让流水线失败,比人工 review 可靠得多。
小结
小程序深色模式的关键不是「把颜色改深」,而是建立一层从基础色板到语义令牌再到组件令牌的抽象。官方机制(darkmode + theme.json + CSS 变量)只解决了原生 UI 和基础变量,剩下的可维护性完全取决于令牌体系设计得好不好。
落地建议:先用构建脚本把令牌从 JSON 生成成 WXSS,消灭所有硬编码颜色;再实现三态主题管理器并接入全局状态,把首屏主题判定提前到 app.js;最后逐个清理图片、遮罩、第三方库和 WebView 这些盲区。做完这三步,加第三套主题的成本会从「改一百个文件」降到「改一个 JSON」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。