1. 为什么主题切换值得单独拿出来讲一遍
1.1 需求从哪里来,比方案本身更重要
一开始接到需求的时候,我通常先问三句话:主题是要换颜色,还是要换布局;主题数量是两套还是二十套;切换主题是用户手动触发,还是跟随系统自动变化。这三个问题的答案直接决定选用哪种实现,也决定后面会不会返工。
最常见的需求是夜间模式;其次是企业客户希望更换品牌色;第三种是运营活动换装,比如节日、大促期间整体换成红金色调,活动结束后再切回来;还有一类容易被忽略,就是无障碍场景的高对比度模式。前三种需求本质上都属于“同一套结构,不同套配色”,第四种则是“同一套结构,不同套对比度规则”。
分清这些需求之后你会发现,绝大多数情况不需要更换 DOM 结构,也不需要重新加载页面;真正要做的只是让颜色、阴影、边框这些视觉属性变成运行时可替换的值。这是整个方案设计的起点,也是后面所有取舍的依据。如果把这件事当成“写两套 CSS 文件然后切来切去”,最后往往会在样式覆盖顺序、首屏闪烁、组件库不听话这些地方耗掉大量时间。
1.2 两种方式的取舍逻辑
我把主流做法归成两类。第一类是变量驱动:把颜色抽成 CSS 自定义属性,切换时只改文档根节点上的一个属性值,浏览器自己重新计算所有引用该变量的样式。第二类是样式表驱动:准备多份完整的样式文件,或者运行时生成样式规则,切换时替换或注入样式表。
变量驱动的核心优势是“切换成本几乎为零”。一次属性赋值,所有使用该变量的地方同步刷新,不需要遍历 DOM,不需要重新解析几万行 CSS。代价是样式必须提前组织成变量体系,历史代码里写死的十六进制颜色要清理干净,否则会出现“切了主题,但有几个地方没变”的情况。
样式表驱动的优势是“自由度高”。两套主题完全可以有不同的圆角、不同的间距乃至不同的布局细节,因为它们是两套独立的规则集。代价是切换瞬间需要加载和解析样式表,处理不好会闪一下;主题数量线性增长时,维护成本也跟着涨。
实际项目里我几乎都是先用变量驱动打底,把 90% 的换肤需求吃掉,剩下 10% 差异实在太大的场景,再用样式表驱动兜底。两者并不互斥,后面会讲怎么混合。
1.3 二十行代码内的最小闭环
先给一个能跑起来的最小版本,方便建立直观印象。HTML 根元素上挂一个属性:
<html lang="zh-CN">:root { --color-bg: #ffffff; --color-surface: #f5f6f8; --color-text: #1f2329; --color-border: #e3e6eb; } :root[data-theme="dark"] { --color-bg: #14161a; --color-surface: #1d2026; --color-text: #e8eaed; --color-border: #2c313a; } body { background: var(--color-bg); color: var(--color-text); transition: background-color 0.25s ease, color 0.25s ease; } .card { background: var(--color-surface); border: 1px solid var(--color-border); }JS 只做一件事,改属性并记住选择:
const root = document.documentElement; const KEY = 'app-theme'; function applyTheme(theme) { root.setAttribute('data-theme', theme); try { localStorage.setItem(KEY, theme); } catch (err) { // 隐私模式下写入可能失败,忽略即可 } } document.getElementById('toggle').addEventListener('click', () => { const current = root.getAttribute('data-theme'); applyTheme(current === 'dark' ? 'light' : 'dark'); });这个版本不到三十行,已经能覆盖大部分单页应用的基本需求。但真正上线前,还有几个坎:首屏闪烁、跟随系统、组件库颜色不跟着变、切换动画卡顿。这些问题才是把“能用”变成“好用”的分水岭,下面逐个拆。
2. 方式一:CSS 变量配合根节点属性切换
2.1 为什么是 CSS 自定义属性,而不是 Sass 变量
很多人第一反应是用 Sass 或 Less 的变量来做主题。这里有个根本区别:预处理器变量在编译期就被替换成字面量,编译完成后就消失了,运行时根本改不了。你写$primary: #2f6bff,产物 CSS 里就是#2f6bff,浏览器运行时没有任何办法把它换成别的值。
CSS 自定义属性不一样,它是运行时存在于浏览器里的真实属性,可以通过document.documentElement.style.setProperty修改,也可以通过选择器重新声明。它在 CSS 级联体系里参与计算,具有继承性,会随着 DOM 树往下传。这意味着某个子树可以拥有自己的主题局部覆盖,这一点对于“页面整体是深色,但某块代码预览区域固定浅色”这类需求非常顺手。
还有一个实际好处:CSS 变量可以被研发调试工具直接看到和修改。审查元素时能在计算样式面板里看到当前生效的所有变量值,临时改一个数值立刻能看到效果,排查配色问题比翻源码快得多。
变量也不是没有代价。它不能被@media之外的媒体查询以外的方式重新求值,不能参与calc()之外的复杂运算(比如取反、取余都很别扭),也不能用在某些需要编译期常量的地方。但对于颜色、阴影、圆角、间距这类值,完全够用。
2.2 变量分层设计:原始色板和语义色要分开
直接按“深色下背景是 #14161a”这样写,主题少的时候没问题,主题一多就会失控。我习惯分两层:底层是原始色板,只描述颜色本身的名词;上层是语义色,描述这个颜色用在什么地方。组件样式只允许引用语义色。
:root { /* 第一层:原始色板,只做记录,组件不直接用 */ --gray-0: #ffffff; --gray-50: #f5f6f8; --gray-900: #14161a; --blue-500: #2f6bff; --blue-600: #1f55d6; /* 第二层:语义色,组件只认这一层 */ --color-bg: var(--gray-0); --color-surface: var(--gray-50); --color-text: var(--gray-900); --color-primary: var(--blue-500); --color-primary-hover: var(--blue-600); } :root[data-theme="dark"] { --color-bg: var(--gray-900); --color-surface: #1d2026; --color-text: #e8eaed; --color-primary: #5b8cff; --color-primary-hover: #7aa2ff; } :root[data-theme="brand-red"] { --color-primary: #d94a3d; --color-primary-hover: #bf3a2e; }分层的价值在于扩展。新增一套品牌主题时,只需要重新映射语义色,原始色板不动;调整某个基础色值时,所有引用它的语义变量自动跟着变。如果项目里接了组件库,把组件库暴露的主题变量指向你自己的语义色,就能实现“一处改,全局变”。
命名上我建议统一前缀,比如--color-、--shadow-、--radius-、--space-,方便检索和做批量检查。变量名用连字符分隔的小写单词,别用驼峰,因为 CSS 自定义属性区分大小写,团队里混用大小写迟早出问题。
2.3 切换逻辑、持久化与系统偏好跟随
主题状态的来源通常有三个:用户手动选择、系统偏好、默认值。优先级我一般定成“用户显式选择 > 系统偏好 > 默认值”。用户选过之后就不该再跟随系统变化,否则用户会觉得自己的设置被篡改了。
const THEME_KEY = 'app-theme'; const root = document.documentElement; const media = window.matchMedia('(prefers-color-scheme: dark)'); function getStored() { try { return localStorage.getItem(THEME_KEY); } catch (e) { return null; } } function resolveTheme(prefer) { if (prefer === 'light' || prefer === 'dark') return prefer; return media.matches ? 'dark' : 'light'; } function applyTheme(prefer, persist) { const theme = resolveTheme(prefer); root.setAttribute('data-theme', theme); if (persist) { try { localStorage.setItem(THEME_KEY, prefer); } catch (e) {} } // 移动端地址栏配色 const meta = document.querySelector('meta[name="theme-color"]'); if (meta) { meta.setAttribute('content', theme === 'dark' ? '#14161a' : '#ffffff'); } return theme; } media.addEventListener('change', () => { const stored = getStored(); if (stored !== 'light' && stored !== 'dark') { applyTheme('auto', false); } }); applyTheme(getStored() ?? 'auto', false);这里有个细节值得说:media.addEventListener('change')在某些比较旧的移动端浏览器上需要降级成media.addListener。如果你的目标环境包含老版本,写个兼容分支,二十行以内就能解决。
另一处是theme-color这个 meta 标签。它控制移动端浏览器地址栏和任务栏区域的配色,不跟着主题走的话,深色模式下顶部会有一条刺眼的白条,观感很差。这个坑踩过一次之后,我所有的项目模板里都会提前放好这个标签并在切换函数里同步更新。
2.4 首屏闪烁:那个白屏一闪到底怎么来的
这是变量方案里出现频率最高也最影响体验的问题。现象是:用户上次选的是深色,刷新页面后先看到一片白,稍等片刻才变成深色。原因很直白:HTML 解析时><head> <script> (function () { try { var saved = localStorage.getItem('app-theme'); var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; var theme = (saved === 'light' || saved === 'dark') ? saved : (prefersDark ? 'dark' : 'light'); document.documentElement.setAttribute('data-theme', theme); } catch (e) {} })(); </script> <link rel="stylesheet" href="/assets/main.css"> </head>
这段脚本是同步执行的,浏览器在解析到这里时会停下来先跑完,再去加载后面的样式表。所以 CSS 计算第一帧时属性已经就位,不会出现闪烁。代价是阻塞解析几十毫秒,而且要注意异常不能被抛出去,否则后面的页面渲染直接挂掉,所以整段包在 try/catch 里。
第二种是服务端注入。如果是 SSR 或者静态生成,可以在模板里直接输出<html><!DOCTYPE html> <html lang="zh-CN"><link rel="stylesheet" id="theme-sheet" href="/themes/light.css">
const link = document.getElementById('theme-sheet'); link.href = '/themes/dark.css';简单,但有两个坑。
第一个是闪烁。改href之后,浏览器要去请求新文件,请求回来之前页面上还是旧样式;如果旧样式被移除了,就会短暂变成无样式状态,整页变成白底黑字的默认样式,非常难看。稳妥的做法是先创建新的 link,等它onload之后再移除旧的那个:
function swapThemeSheet(href) { return new Promise((resolve) => { const next = document.createElement('link'); next.rel = 'stylesheet'; next.href = href; next.dataset.themeSheet = 'next'; next.onload = () => { document.querySelectorAll('link[data-theme-sheet="old"]').forEach(el => el.remove()); next.dataset.themeSheet = 'old'; resolve(); }; next.onerror = () => { next.remove(); resolve(); }; document.head.appendChild(next); }); }新旧样式表短暂共存的时候,浏览器会按文档顺序决定优先级:后插入的 link 规则在同等特异性下会覆盖前面的。所以新表必须追加在后面,不能插到前面。
第二个是重复请求。用户来回切几次,同一份文件会被请求多次。虽然强缓存的场景下成本不高,但我一般会加一层预加载:在主题切换按钮 hover 或者页面空闲时,用rel="prefetch"把其他主题文件先拉下来。
<link rel="prefetch" href="/themes/dark.css" as="style">或者用代码在requestIdleCallback里提前请求,这样用户真正点击时基本是瞬间生效。
3.3 做法二:构造样式表对象直接注入
现代浏览器支持CSSStyleSheet的构造函数和document.adoptedStyleSheets,可以完全绕过 link 标签,把样式规则以对象形式挂到文档上。这个方式对“运行时生成主题”特别友好,比如让用户自己选主色,你根据主色算出一整套配色,生成规则后注入。
function buildTheme(primary, isDark) { const bg = isDark ? '#14161a' : '#ffffff'; const text = isDark ? '#e8eaed' : '#1f2329'; return ` :root { --color-bg: ${bg}; --color-text: ${text}; --color-primary: ${primary}; } `; } let sheet = null; function applyGeneratedTheme(primary, isDark) { if (sheet) { const idx = document.adoptedStyleSheets.indexOf(sheet); if (idx > -1) document.adoptedStyleSheets.splice(idx, 1); } sheet = new CSSStyleSheet(); sheet.replaceSync(buildTheme(primary, isDark)); document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet]; }注意这里生成的仍然是变量声明,等于把变量方案的赋值入口换了个位置。也可以生成完整规则,比如.btn { background: ... },效果一样,只是覆盖范围更广。
兼容性上,adoptedStyleSheets在主流浏览器的较新版本里都支持了,但如果项目要覆盖比较老的移动端浏览器,需要降级到document.createElement('style')然后设置textContent。降级分支我一般这样写:
function applyStyleText(css) { if ('adoptedStyleSheets' in Document.prototype) { const s = new CSSStyleSheet(); s.replaceSync(css); document.adoptedStyleSheets = [...document.adoptedStyleSheets, s]; return s; } const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); return style; }用style标签的方式要注意清理,切多了会积累一堆无用的 style 节点,每次切换前把带标记的旧节点删掉就行。
3.4 案例:根据品牌色动态算出整套配色
这是实际项目里最常见的动态换肤场景:后台管理系统中,客户可以上传自己的品牌色,要求整个界面跟着变。做法是从一个主色出发,算出一组派生色:浅色背景、悬停色、边框色、文字对比色。
颜色运算我推荐先转成 HSL 空间,比直接操作 RGB 直观得多。调整亮度就是调 L 通道,降低饱和度就是调 S 通道。
function hexToHsl(hex) { let h = hex.replace('#', ''); if (h.length === 3) h = h.split('').map(c => c + c).join(''); const r = parseInt(h.slice(0, 2), 16) / 255; const g = parseInt(h.slice(2, 4), 16) / 255; const b = parseInt(h.slice(4, 6), 16) / 255; const max = Math.max(r, g, b), min = Math.min(r, g, b); let hue = 0, sat = 0; const lig = (max + min) / 2; const d = max - min; if (d !== 0) { sat = d / (1 - Math.abs(2 * lig - 1)); if (max === r) hue = ((g - b) / d) % 6; else if (max === g) hue = (b - r) / d + 2; else hue = (r - g) / d + 4; hue *= 60; if (hue < 0) hue += 360; } return { h: hue, s: sat * 100, l: lig * 100 }; } function hslToHex({ h, s, l }) { s /= 100; l /= 100; const k = n => (n + h / 30) % 12; const a = s * Math.min(l, 1 - l); const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); const toHex = x => Math.round(x * 255).toString(16).padStart(2, '0'); return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`; } function derivePalette(baseHex) { const base = hexToHsl(baseHex); const clamp = v => Math.max(0, Math.min(100, v)); return { primary: baseHex, primaryHover: hslToHex({ ...base, l: clamp(base.l - 8) }), primaryActive: hslToHex({ ...base, l: clamp(base.l - 16) }), primarySoft: hslToHex({ ...base, s: clamp(base.s - 20), l: clamp(base.l + 32) }), primaryBorder: hslToHex({ ...base, s: clamp(base.s - 10), l: clamp(base.l + 18) }) }; }拿到这组颜色之后,用setProperty往根节点写变量就行:
function applyBrandColor(hex) { const p = derivePalette(hex); const root = document.documentElement; root.style.setProperty('--color-primary', p.primary); root.style.setProperty('--color-primary-hover', p.primaryHover); root.style.setProperty('--color-primary-active', p.primaryActive); root.style.setProperty('--color-primary-soft', p.primarySoft); root.style.setProperty('--color-primary-border', p.primaryBorder); }这里必须提醒一点:行内样式设置的变量优先级最高。一旦用setProperty写在documentElement.style上,它就会压过所有样式表里的:root声明。所以如果同时有深色模式和品牌色两套逻辑,而深色模式是通过[data-theme="dark"]覆盖变量的,这两者会打架。深色模式那套会被行内变量压掉。
我的处理办法是:把品牌色也写成一套选择器规则,而不是行内样式。用前面提到的CSSStyleSheet注入一段:root[data-brand="custom"] { ... },或者干脆预先声明一组--brand-*层级变量,让语义色去引用--brand-*,这样深色模式只覆盖语义色,品牌色只覆盖--brand-*,互不干扰。
:root { --brand-primary: #2f6bff; --color-primary: var(--brand-primary); } :root[data-theme="dark"] { --color-primary: color-mix(in srgb, var(--brand-primary) 80%, #ffffff 20%); }color-mix()这类函数省掉了大量 JS 计算,但兼容性需要确认目标浏览器版本,做好降级。
3.5 对比度校验:别让动态配色变成不可读
动态换肤最容易被忽略的是可读性。用户选了一个亮黄色当主色,按钮上的白字直接看不见了。这个问题的判断标准不是“看着差不多”,而是有明确公式的。
WCAG 定义的对比度算法:先计算相对亮度,公式是每个通道做 sRGB 归一化后,小于等于 0.03928 时除以 12.92,否则取((c + 0.055) / 1.055) ^ 2.4,然后按 0.2126、0.7152、0.0722 加权求和。两个颜色的对比度是(L1 + 0.05) / (L2 + 0.05),L1 是较亮的那个。正文文本建议不低于 4.5:1,大字号标题不低于 3:1。
function luminance(hex) { let h = hex.replace('#', ''); if (h.length === 3) h = h.split('').map(c => c + c).join(''); const rgb = [0, 2, 4].map(i => { const c = parseInt(h.slice(i, i + 2), 16) / 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }); return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]; } function contrastRatio(a, b) { const l1 = luminance(a), l2 = luminance(b); const light = Math.max(l1, l2), dark = Math.min(l1, l2); return (light + 0.05) / (dark + 0.05); } function pickTextColor(bgHex, light = '#ffffff', dark = '#1f2329') { return contrastRatio(bgHex, light) >= contrastRatio(bgHex, dark) ? light : dark; }把这个函数接到按钮的文字色上,用户选什么颜色都不会出现读不清的情况。这段逻辑不到二十行,但能省掉大量客诉。
4. 两套方案怎么选:一份可直接对照的决策表
4.1 关键维度横向对比
我把选型时会看的几个维度列成表,实际评审时直接对着填。
| 维度 | 变量驱动 | 样式表驱动 |
|---|---|---|
| 切换速度 | 一次属性赋值,即时生效 | 需加载解析,提前预载后接近即时 |
| 首屏闪烁风险 | 有,需内联脚本或服务端注入解决 | 有,需先加载新表再移除旧表 |
| 主题数量扩展 | 线性增长,但只增变量定义 | 线性增长,每套是完整文件 |
| 样式体积 | 小,共用一套规则 | 大,规则重复 |
| 表达自由度 | 仅限值层面差异 | 可换布局、字体、图片、任意规则 |
| 调试难度 | 低,审查元素即可改 | 中,需在多个文件间比对 |
| 与组件库配合 | 需对齐变量命名 | 需保证加载顺序 |
| 运行时动态生成 | 容易,算好值写进去即可 | 容易,生成规则字符串注入 |
从表里能看出一个明显倾向:如果主题差异集中在颜色和阴影这类视觉值上,变量驱动几乎没有劣势。只有当差异上升到布局、字体、图形资源这个层面,样式表驱动才有必要性。
4.2 我实际项目里的混合用法
真实项目很少只用一种。我通常这样分层:
基础层用变量,负责所有颜色、阴影、圆角、字号这些值类的切换;覆盖层用样式表,专门处理少数需要结构性变化的主题。比如默认主题和深色主题完全靠变量搞定,而“活动主题”除了配色,还需要顶部加一条装饰带、按钮改成圆角胶囊形,这些用一段独立的小 CSS 文件加载进来。
关键在于命名隔离。变量层的选择器统一用[data-theme],样式表层的规则统一挂在一个类名前缀下,比如.theme-festival .hero、.theme-festival .btn,这样两份样式互不交叉,切换时也不会互相覆盖。
顺序上,主题覆盖样式表要在主样式表之后加载,保证优先级。如果用的是构建工具,可以在入口动态 import:
async function loadAccentTheme(name) { const mod = await import(`./themes/${name}.css`); return mod; }打包器会把它切成独立的 chunk,只在需要时才请求。这比一开始就把所有主题打进主包要合理得多。
4.3 参数层面的几个经验值
切换动画时长我一般定在 200 到 300 毫秒之间,低于 150 毫秒用户感知不到过渡,高于 400 毫秒会显得拖沓。缓动曲线用ease或cubic-bezier(0.4, 0, 0.2, 1),比linear自然。
不要给*加过渡。* { transition: all .3s }会让所有属性变化都参与动画,包括布局属性,页面一交互就卡。正确做法是明确列出要过渡的属性,或者只在需要的地方加:
body, .card, .btn { transition: background-color .25s ease, color .25s ease, border-color .25s ease; }主题预加载的时机也有讲究。我一般在首屏渲染完成、主线程空闲时用requestIdleCallback预取其他主题资源,超时设 2000 毫秒兜底。这样既不影响首屏性能,用户点击时又基本命中缓存。
5. 实操中踩过的坑与排查清单
5.1 症状对照表
这些问题我在不同项目里反复遇到,整理成速查表,遇到症状直接对号入座。
| 症状 | 大概率原因 | 处理方式 |
|---|---|---|
| 切换后部分区域颜色不变 | 代码里写死了十六进制色值 | 全局搜索#开头的颜色字面量,替换为变量 |
| 刷新页面先白后黑 | 属性设置晚于首帧渲染 | head 内联同步脚本,或服务端注入属性 |
| 切换瞬间整页样式丢失 | 先删旧 link 再加载新 link | 新表 onload 后再移除旧表 |
| 切换动画卡顿掉帧 | 过渡属性范围过大 | 收敛 transition 属性列表 |
| 图表颜色不跟着变 | Canvas 拿不到 CSS 变量 | 用 getComputedStyle 读值后传给图表配置 |
| 行内设置的品牌色覆盖了深色模式 | 行内样式优先级最高 | 改用选择器规则注入,或分层变量 |
| 移动端顶部有一条白边 | theme-color 未同步 | 切换时更新 meta 内容 |
| 隐私模式下主题不记忆 | localStorage 写入抛异常 | 全部读写包 try/catch |
表里第一条是最常见的。我的做法是在 CI 里加一条检查:扫描所有样式文件,如果某个颜色字面量出现在组件样式里而不是变量定义区,就报出来。用简单的正则加白名单就能实现,前期成本低,后期收益很大。
5.2 几个特别难查的细节
Canvas 和 SVG 里的颜色。这些地方不参与 CSS 级联,fill="currentColor"对 SVG 有效,但 Canvas 里的fillStyle必须用 JS 拿实际色值。写法是:
const styles = getComputedStyle(document.documentElement); const color = styles.getPropertyValue('--color-primary').trim();注意getPropertyValue返回的字符串可能带前后空格,记得trim()。另外这个调用会触发样式计算,不要在动画循环里每帧调用,切主题时读一次缓存起来就行。
图片资源在深色模式下的表现。简单粗暴地加filter: invert(1)会把品牌 logo 也反过来,颜色完全失真。我的建议是图标类资源用 SVG 加currentColor,照片类资源不做处理,最多在深色模式下降低一点亮度或者加一层半透明遮罩。真要准备两套图,就用<picture>配合媒体查询,或者切主题时换src。
iframe 内部的主题同步。如果页面里嵌了同源 iframe,主页面切主题时子页面不会自动跟着变。需要在切换函数里遍历 iframe,把属性同步过去:
document.querySelectorAll('iframe').forEach(frame => { try { frame.contentDocument.documentElement.setAttribute('data-theme', theme); } catch (e) { // 跨域访问会抛异常,跳过 } });跨域的话只能在子页面里监听postMessage,由子页面自己处理。这属于架构层面的事,最好在拆微前端的时候就把主题协议定下来。
服务端渲染时的水合不一致。服务端按 cookie 渲染成深色,客户端脚本按 localStorage 渲染成浅色,React 之类框架会报警告,页面也可能闪一下。解决思路是统一数据源:要么服务端也读 localStorage 对应的值(通过 cookie 传递),要么客户端首帧不覆盖属性,等水合完成再应用。我倾向前者,把主题选择同时写进 cookie 和 localStorage,服务端优先读 cookie。
系统偏好变化时的边界。用户没手动选过,系统在日落时自动切到深色,页面应该跟着变。但如果用户手动选过浅色,系统切深色时页面不应该变。这个逻辑前面写过,但要注意matchMedia的 change 事件在部分浏览器上切换标签页后才触发,测试时别以为是代码写错了。
6. 工程化落地时值得补的几件事
6.1 把主题变量做成可导出的规范
变量定义散落在各个文件里,是后期维护最头疼的问题。我的做法是单独建一个tokens目录,用一份 JSON 或 JS 对象描述所有语义色,然后写个小脚本生成 CSS 变量文件。好处是这份数据可以被多处消费:生成 CSS、生成设计稿标注、生成 TypeScript 类型提示。
// tokens.js export const themes = { light: { 'color-bg': '#ffffff', 'color-text': '#1f2329', 'color-primary': '#2f6bff' }, dark: { 'color-bg': '#14161a', 'color-text': '#e8eaed', 'color-primary': '#5b8cff' } };生成脚本遍历对象输出:root[data-theme="xxx"] { --xxx: value; }。这样新增一套主题只需要改数据,不用手写 CSS,也不会漏掉某个变量。类型提示还能在写var(--color-xxx)的时候给出补全,减少手误。
6.2 给用户留一个可控的开关
主题切换按钮的位置和交互也有讲究。三态(浅色、深色、跟随系统)比两态更符合用户预期,但按钮形态会变复杂。我的取舍是:主入口放一个图标按钮,点击直接切换;旁边加一个下拉或者设置项,允许选择“跟随系统”。设置项里最好加一个实时预览的小色块,让用户点之前就知道效果。
切换过程中要防止连点。加一个 300 毫秒的节流,或者切换动画期间禁用按钮,避免快速点击导致状态和存储不一致。这个小细节在用户手快的时候特别明显。
6.3 灰度与回滚
主题改版上线时,不要一次性全量。通过配置中心下发一个开关,先放开小比例用户,观察是否有明显的可读性投诉和性能问题。变量方案的灰度成本很低,一个布尔值就能控制是否启用新主题的映射规则;样式表方案则需要控制新文件的加载逻辑。
回滚也要提前想好。用户本地已经存了>const REGISTERED = ['light', 'dark', 'brand-red']; function safeTheme(value) { return REGISTERED.includes(value) ? value : 'light'; }
这十行代码在版本回滚、配置下发出错的时候能救一次线上事故。我在一个项目里就是因为漏了这层校验,回滚后部分用户看到的是完全没有样式的页面,排查了半小时才发现是存储里有个已经不存在的主题名。