一次前端换肤体系的技术债清理,演变成对 Material 3 HCT 色彩科学的完整实践。本文记录方案设计、三次印象深刻的踩坑(其中一个是「注释把编译器搞炸」),以及最后用「2 个文件、14 行改动」完成 8 套皮肤扩到 12 套的架构收益验证。
一、痛点:手工配色表的维护噩梦
我们的 Quasar 2.27 项目有一套传统的换肤体系:8 套皮肤 × 明暗两态 = 16 套手工配色。每套配色要维护 primary、渐变三色、图标底色、文字色……堆在 store 的一个百余行大对象里。更糟的是消费侧——全局 173 处 var(--skin-*) 变量引用分布在 29 个文件(渐变、图标底、阴影、文字色……),其中文字色每处都要人工判断「这块文字在浅色底还是深色底?暗黑模式下该用什么色?」
新增一套皮肤的完整成本是:设计 6 个颜色 → 改配色表 → 全局走查消费侧 → 暗黑模式逐处核对。这是一个典型的「随皮肤数量线性增长」的维护模型。
二、方案:源色化——让算法接管配色
Material Design 3 的 HCT(Hue-Chroma-Tone)色彩空间提供了一种出路:每套皮肤只需要一个源色(source color),整套配色(37 个语义角色)由算法派生。Google 官方的 @material/material-color-utilities 库实现了这套算法。
版本锁定:本方案基于
@material/material-color-utilities@0.3.0精确钉版——0.3.0 与 0.4.0+ 的 surfaceContainer 字段集不同,代码需特性检测('surfaceContainer' in scheme)实现前向兼容,实测以 0.3.0 为准。
2.1 数据流
源色(12 套预设 / 自定义 HEX / 图片提取)
↓ @material/material-color-utilities
(themeFromSourceColor;灰系走 SchemeCarbon,见坑三续章。Map 缓存 FIFO 上限 20)
HCT 调色板 → light/dark 双 scheme(37 语义角色)
↓ settings.apply() 单头输出
三族 CSS 变量 → 消费侧零感知替换
2.2 三族 CSS 变量架构
落地的关键是理清三族变量的职责边界:
| 变量族 | 挂载点 | 职责 |
|---|---|---|
--md-sys-color-* |
:root |
M3 标准 37 角色,随明暗切换 scheme |
--skin-* |
:root |
项目既有换肤变量名,恒由 light scheme 派生(保持「皮肤系浅色」场景不回归);其中 --skin-primary-text 例外——明暗自适应,专供文字色 |
--q-primary 等 |
body inline | Quasar 桥接,必须写 body inline(:root 会被 Quasar 就近覆盖) |
:root body inline(Quasar 桥接)
├── --md-sys-color-* 37 角色 ├── --q-primary (= scheme.primary)
├── --skin-* 恒 light ├── --q-secondary (= scheme.secondary)
└── --skin-primary-text └── --q-accent (= scheme.tertiary)
明暗自适应文字色 随明暗切换 scheme
两个反直觉的设计决策:
① --q-negative 不桥接(所有源色统一)。 M3 的 error 色几乎不随源色变化——任何源色下都派生为深红 #ba1a1a 附近,与红系 primary(#b91a24)ΔE < 3,与 Quasar 默认 #C10015 也视觉相近。桥接收益趋零、还得为红系源色转判,不如统一保持 Quasar 默认,守住「主操作 / 危险操作」的可区分性。
② 文字色换肤走判定树而非全局替换。 文字色消费点(实测约 10 处)不能无脑换成明暗自适应变量——背景是恒浅色(白色卡片、皮肤系图标底)的场景保持原样,只有 surface 系背景(随明暗变化的页面/卡片底)和深色背景才替换为 --skin-primary-text。这是「浅色零回归」红线。
2.3 状态管理:三函数分离
换肤 store 的初始化逻辑拆成三个职责单一的函数,避免循环触发:
function syncDarkMode() {
// 只负责 Quasar Dark 状态
Dark.set(themeMode.value === 'auto' ? 'auto' : themeMode.value === 'dark')
}
let lastAppliedKey = '' // 模块级,跨调用缓存
function apply() {
// 只负责输出颜色(幂等)
const key = `${
source}|${
Dark.isActive}`
if (key === lastAppliedKey) return // 源色+明暗没变就跳过 DOM 重算
lastAppliedKey = key
applyMd3Theme(source, Dark.isActive)
}
function setThemeMode(mode) {
// 串联入口
themeMode.value = mode
syncDarkMode()
apply()
}
auto 模式下系统明暗变化用 watch(() => Dark.isActive) 响应(Quasar 2.27 没有 Dark.setChangeCallback API,响应式 getter 是等效方案)。
三、踩坑实录
坑一:pinia persist 的恢复时序——「同步恢复」≠「早于你的初始化」
现象:优雅紫 + 暗黑模式下刷新页面,localStorage 里 themeMode: "dark" 分毫未动,DOM 却渲染成 light 配色。更诡异的是明暗切换按钮要点两次才能进暗黑——第一次点击落在 light(按钮显示的却是「切换明亮」,说明 store 认为自己在 dark)。
根因:方案设计时的时序假设是「pinia-plugin-persistedstate 在 store 首次实例化时同步恢复,早于 store body 尾部初始化」。运行时实测推翻了它——persist 的 $patch 恢复发生在 store setup body 执行完之后:
store setup body 执行:
...
syncDarkMode() ← 此时 themeMode 还是默认值 'light' → Dark.set(false)
apply() ← 输出 light 配色
persist 插件 $patch:themeMode = 'dark' 被恢复
← 但没有任何代码再触发 Dark 重同步!
而 boot 兜底只调了 apply(),apply() 以 Dark.isActive(false)为明暗真值,输出 light 配色。存储正确、DOM 错位,这类「半静默」缺陷比直接报错隐蔽得多。
修复(双保险)——关键在于补上 §2.3 三函数设计里缺失的一环:syncDarkMode 职责本是「只设 Dark」,但它在 store 尾部执行时 persist 尚未恢复,需要在 boot 层(mount 前)再调一次,拿到的才是已恢复的真值:
// boot/theme.ts —— Quasar boot 标准签名,store 由框架注入
import {
boot } from 'quasar/wrappers'
export default boot(({
store }) => {
const settings = useSettingsStore(store)
settings.syncDarkMode() // ← 先用已恢复的 themeMode 重同步 Dark 真值
settings.apply() // ← 再应用配色
})
// 第二层:store 内 —— 兜 themeMode 任何被动变化
watch(() => themeMode.value, syncDarkMode, {
flush: 'post' })
Dark.set() 幂等,两层重复调用无副作用。修复后浏览器自动化复测五步场景(建立 dark / 刷新 / 切回 light / 再刷新 / 再切 dark)全过。
需要说清的是:防闪烁不单靠 boot。首屏其实有两层防线——quasar.config.ts 的静态 brand 用 getQuasarBrandDefaults() 预计算的源色 light 值,先兜「JS 未执行 / 持久化未恢复」的首帧;boot 执行后再由运行时值就近覆盖。两层合力把 FOUC 压到不可感知——但严格说不是「零」:深色用户首帧仍有极短的 light→dark 色差,属可接受范围。
教训:「persist 在首次实例化时同步恢复」这句话是对的,但「恢复早于 setup body 的副作用」是错的——恢复时机在 body 之后。凡是在 store 初始化尾部依赖持久化值执行副作用(同步外部状态类操作),必须在 boot 层显式重做一次。时序假设必须运行时实测,纸面推理会骗人。
坑二:一行注释搞炸 Sass 编译——而且报错位置在千里之外
现象:新增的 md3-bridge.scss(用 @each 循环生成 37 个 M3 角色的工具类)让 vite 启动直接报错:
[sass] Error: expected "{".
╷
16 │ surface-container-lowest, surface-container-low, ...;
│ ^
报错指向一个完全合法的多行逗号列表的行尾分号。vue-tsc 零错、@vue/compiler-sfc parse 零错——它们都不覆盖 sass 编译层,这个问题只有 dev server 启动时才暴露。
根因:二分法定位——逐段删除可疑代码,发现删掉文件头部注释块后编译即通过,保留则失败;再缩小到注释块中的一行。文件头部注释里写了这样一句:
/**
* .bg-md-* / .text-md-* 与 Quasar bg-*/text-* 同构……
*/
看到问题了吗?bg-*/text-*——星号后面紧跟斜杠,这正是块注释的结束符。Sass 词法层不区分「注释内容」和「注释结束」,注释在 bg-*/ 处提前终止,后半段注释变成裸 SCSS 层层错乱,最终报错落在了几十行之外的合法代码上。
更有戏剧性的是:我修复时在注释里写「注意:块注释内勿写『/』序列」——**警示文字自身又包含了 `/`,二次触雷**。最终改成纯文字描述「星号紧接斜杠的序列」才彻底解决。
教训:Sass 报 expected "{" 但指向正常声明语句时,先检查注释块是否被截断(grep -n '\*/' file.scss 直接定位注释内的星斜杠序列,比 cat -A 更对症);写踩坑警示时不得写出实际触发序列本身。更工程化的防线是在 CI 或 pre-commit 中加一条 lint,检测 SCSS 注释内是否包含「星号斜杠」序列(写在 lint 规则的代码里是安全的——不经 Sass 编译)。同类困境还出现在中文文案里——同一时期,踩坑文档中「兜底」一词被写成码点相邻的形近字(U+5151/U+5155/U+515C)多达 16 处,人工校对几乎全部漏过,修正时新写的警示句里又错了一次——证实目视校验对这类问题近乎无效。「描述问题」和「写出问题」是两回事。
坑三:HCT 的 chroma 下限——炭灰皮肤变鲜蓝
扩展到 12 套皮肤时,产品给了一款「炭灰」(源色 #475569,灰蓝色)。派生结果让人意外:
#475569 (炭灰) → primary #1360a5 (鲜蓝!) dark 态 #a2c9ff (浅蓝)
这不是 bug。M3 的 primary palette 要求 chroma ≥ 48——HCT 色彩空间中低饱和度的灰色会被结构性提饱和到下限值,色相保留(实测 HCT 色相 256°;注意 CAM16 与 HSV 数值体系不同——该色 HSV 色相约 215°,视觉同为蓝相),饱和度被强行拉高。换了几个灰系替代源色(#575e72、#64748b)全部蓝化,这是算法的固有行为。
顺带拆解一下「拉蓝」的机理——跨色相对照实验表明,chroma 下限只拉饱和、不拉色相(Δhue ≤ 0.7°):
| 低饱和源色 | 源 hue | 派生 primary | Δhue |
|---|---|---|---|
灰红 #76696a |
10° | #9b4052 玫红 |
-0.2° |
灰黄 #787465 |
102° | #6b5f00 橄榄 |
-0.1° |
灰绿 #6b7566 |
143° | #2d6b27 绿 |
-0.1° |
灰蓝 #6a6e7a |
266° | #315da8 蓝 |
+0.1° |
灰紫 #72687c |
307° | #714d9f 紫 |
+0.2° |
所以并不存在「把灰拉成蓝」——是灰色里隐藏的色相基因被提饱和「显影」了。#475569 的 RGB 差(71,85,105)在人眼感知阈下是「灰」,CAM16 却能分辨出 c18.4 的明确蓝相;饱和度一拉,蓝就显形。而「灰总是变蓝」的观感偏差来自选样——设计师偏爱的灰常是冷调灰(slate/steel 系自带蓝基因),真正的无偏灰见下文(噪声抽奖)。
更极端的验证是把源色设成纯中性灰 #808080(chroma ≈ 1.9,色相理论上无定义)——派生出的是青色 primary(light #006874 / dark #4fd8eb),而且色相由数值噪声决定:
| 源色 | HCT hue | 派生 primary | 色系 |
|---|---|---|---|
#808080 |
209.5° | #006874 |
青 |
#808081(B+1) |
233.2° | #006689 |
蓝 |
#818080(R+1) |
218.2° | #00687b |
青 |
#80817f(G+1B-1) |
169.0° | #006c4f |
绿 |
RGB ±1 的微扰就能让派生色相漂移 ±30° 以上,在青/蓝/绿之间跳变——chroma 趋 0 时色相角的计算像罗盘在磁极附近乱转。想靠「选个灰源色」得到灰主题,此路不通;#475569 之所以能「稳定蓝化」,是因为它尚有真实色度(chroma 18.4),灰蓝倾向明确。
启示:Material 3 的色彩生成是「有主见的」——它假设品牌色应该是彩色的。想要真正的无彩灰主色,需要自定义 TonalPalette 的 chroma 参数(0.3.0 为 TonalPalette.fromHueAndChroma(hue, 0),0.4.x 起提供 TonalPalette.of 简写)并构造自定义 Scheme——注意不是复用 neutral palette:那是 surface 系表面色角色(chroma 本就 ≤ 4),对比度校准口径与 primary 不同,拿来当主色属角色误用。这已超出 themeFromSourceColor 的标准用法。本案当时保留了蓝化效果(记入方案风险条目)。选型时要把算法的审美假设当作约束条件评估。
续章:真灰的正确实现(补记)
风险条目里的「真灰需求」两天后真的来了。第一反应是查官方预制——0.3.0 自带 SchemeMonochrome,看似完美,实测却是陷阱:MONOCHROME variant 把 primary 的 tone 强制到 0/100 极值(light 全黑 #000000 / dark 全白 #ffffff),且与 palette chroma 无关——这是 Google 的设计哲学(单色主题即无彩极值),按钮变纯黑,不可用。
正确姿势是继承 DynamicScheme 用标准 variant + 低 chroma 调色板组,让 primary 走正常的 tone 40/80:
class SchemeCarbon extends DynamicScheme {
constructor(hct: Hct, isDark: boolean) {
super({
sourceColorArgb: hct.toInt(),
variant: 2, // Variant.TONAL_SPOT(0.3.0 未导出枚举,字面量钉住)
contrastLevel: 0, isDark,
primaryPalette: TonalPalette.fromHueAndChroma(hct.hue, 4),
secondaryPalette: TonalPalette.fromHueAndChroma(hct.hue, 2),
tertiaryPalette: TonalPalette.fromHueAndChroma(hct.hue, 8),
neutralPalette: TonalPalette.fromHueAndChroma(hct.hue, 2),
neutralVariantPalette: TonalPalette.fromHueAndChroma(hct.hue, 4),
// error 不覆盖:保持默认红(hue25/chroma84),错误可见性不丢
})
}
}
实测(#808080 源色):light primary #5c5f5f(中灰冷调)/ dark #c4c7c7 / error #ba1a1a 保留——「炭灰」终于是炭灰。接入侧只需一个 DynamicScheme → 旧 Scheme 的适配层(MaterialDynamicColors[name].getArgb(scheme) 逐角色取 37 键)+ 皮肤预设加 scheme: 'carbon' 标志,其余 11 套零影响。至此坑三闭环:从「灰变蓝」到「纯灰抽奖」再到「真灰落地」,Material 3 不支持灰色品牌色——但支持你亲手进去改。
四、架构收益实证:8 套 → 12 套,2 文件 14 行
方案落地后做了一次直接验证:产品要求皮肤从 8 套扩到 12 套(新增琥珀金/蒂芙尼/暖棕/炭灰 4 套,另调整 2 套既有源色:翡翠绿 #10b981→#22c55e、靛蓝 #6366f1→#4f46e5,含 4 个名称更新)。
实际改动:
// ① 类型联合加 4 个 key
export type SkinKey = 'red' | 'orange' | 'amber' | ... | 'slate'
// ② 预设表加 4 行数据
amber: {
label: '琥珀金', source: '#f59e0b' },
teal: {
label: '蒂芙尼', source: '#14b8a6' },
brown: {
label: '暖棕', source: '#92400e' },
slate: {
label: '炭灰', source: '#475569' },
注意:其中 slate 的 #475569 即坑三所述「炭灰变鲜蓝」的源色——当时实际效果为蓝系主题(后已改为 #808080 + 真灰方案,见坑三续章)。此处保留原值以忠实记录当时的扩皮肤操作。
总计 2 个文件、+14/-8 行。 三处换肤入口(登录页圆点、导航栏圆点、设置页色板)全部 v-for 遍历预设表,自动扩展到 12 项,零组件改动。每套新皮肤的渐变、明暗双色、图标底色、文字自适应色全部运行时派生。
浏览器自动化验证 12 套逐一切换,实测 --q-primary 与预计算表 12/12 全对;布局在 680px 严苛视口下单行不溢出。对比重构前「新增一套皮肤要全局走查 173 处 --skin-* 消费侧」的成本——标题口径「1 天 → 1 分钟」的量化依据:重构前一次完整新增约一人天(6 色设计 + 173 处走查 + 明暗逐处核对);重构后 1 行数据录入、切换即见效果,这是架构升级最直接的收益证明。
五、验证方法论:把手工回归变成自动化清单
换肤体系的回归验证天然适合自动化——所有断言都是 DOM 可测的。我们把验证固化为清单交给浏览器自动化代理执行(脚本化导航 + DOM 断言,所有断言均为属性/样式值比较):
- 三族变量联动(
--q-primarybody inline /--md-sys-*computed /--skin-*派生值与预计算表逐一对照) - 隔离性验证(切肤期间
body--dark恒定——证明 setSkin 不触碰 Dark) - 持久化五步场景(建立 → 刷新 → 切换 → 再刷新 → 再切换)
- 四方面一致性(CSS 变量值 / class 状态 / 明暗切换按钮显示文案与当前态一致 / localStorage 值)
这份清单在首次运行时就抓到了坑一(dark 刷新丢失)——自动化验证清单的价值不在「证明它对」,而在「用机器的耐心暴露时序类人眼难察的错」。
六、总结
| 重构前 | 重构后 | |
|---|---|---|
| 新增一套皮肤 | 补 6 个手工色值 + 全局走查消费侧 | 1 行源色数据(灰系另加 scheme 标志,见坑三续章) |
| 明暗适配 | 每处文字人工判断 | tone 40/80/90 算法派生 |
| 配色一致性 | 靠设计师自觉 | HCT 同源保证和谐 |
| 回归验证 | 人工目视 | DOM 断言自动化 |
对业务侧的直接影响:新增品牌色从「设计师出 6 个色值 + 前端适配」变成「设计师给一个品牌色,前端一行录入」——协作链路缩短一半以上。
三条最想传递的经验:
- 时序假设必须运行时实测——「persist 同步恢复」的官方描述没错,错的是我们对「恢复 vs 副作用执行顺序」的推断。boot 层显式初始化 +
flush: 'post'watch 兜底的双保险模式值得复制。 - 描述问题的文字本身不要复现问题——无论是不小心截断 Sass 注释的
*/,还是踩坑文档里的形近字,元层面的复现防不胜防,机械校验(grep定位星斜杠序列 + 码点检查)比目视可靠。 - 算法的审美假设是隐性约束——HCT 的 chroma 下限意味着标准用法内「Material 3 不支持灰色品牌色」,真灰需自定义 Scheme(坑三续章的落地路径),这类特性要在选型阶段就挖出来。
附录:核心实现索引
完整方案共四个核心文件,本文按叙事需要分散引用,此处给出索引供拼装参考:
| 文件 | 行数 | 职责 | 对应章节 |
|---|---|---|---|
utils/md3-theme.ts |
275 | 算法核心:theme 缓存、三族变量输出、静态 brand 预计算、真灰方案(SchemeCarbon) | §2.2 / 坑三及续章 |
boot/theme.ts |
19 | mount 前应用持久化皮肤/明暗(防闪烁首道防线之二) | 坑一 |
stores/settings.store.ts |
158 | 换肤 store:三函数分离 + persist + scheme 标志 | §2.3 / 坑一 |
assets/styles/md3-bridge.scss |
26 | @each 生成 37 角色的 bg/text 工具类 |
坑二 |
读者最关心的三族变量输出主函数全文如下(生产代码原样摘录;依赖的常量表 SCHEME_ROLES(30 键)/CONTAINER_ROLES(7 键)与工具函数 camelToKebab/hexToRgb 按命名即可复原;真灰方案相关 SchemeCarbon/适配层见坑三续章):
/** 主入口:输出全部 CSS 变量(settings.apply() 调用;dark = 真实激活态 Dark.isActive;
* carbon = 真灰方案(灰系源色专用,见 SchemeCarbon 注释)) */
export function applyMd3Theme(sourceHex: string, dark: boolean, carbon = false): void {
if (!isBrowser) return // SSR 防护(persist 水合等服务端场景)
try {
const t = getMd3Theme(sourceHex, carbon)
// 断言为 SCHEME_ROLES 键的映射类型:具名键推断 number(noUncheckedIndexedAccess
// 下 Record 索引签名会给出 number | undefined,六个 TS2345 根因——A1 修复)
const scheme = (dark ? t.schemes.dark : t.schemes.light) as unknown as {
[K in (typeof SCHEME_ROLES)[number]]: number
}
const root = document.documentElement
// ① M3 语义变量(随明暗切换 scheme)
for (const role of SCHEME_ROLES) {
root.style.setProperty(`--md-sys-color-${
camelToKebab(role)}`, hexFromArgb(scheme[role]))
}
// surface 补齐角色:0.4.0+ Scheme 自带 surfaceContainer 字段则直用(基线或微调),
// 0.3.0 无则按 M3 基线 tone 从 neutral 调色板手工补(版本钉版外的双保险)
const schemeRecord = scheme as Record<string, number>
const neutral = t.palettes.neutral
for (const {
cssVar, lightTone, darkTone } of CONTAINER_ROLES) {
const field = cssVar.replace('--md-sys-color-', '').replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())
const hex = field in schemeRecord
// 动态 string 键走索引签名,in 检查的收窄对索引签名不生效,保留非空断言
? hexFromArgb(schemeRecord[field]!)
: hexFromArgb(neutral.tone(dark ? darkTone : lightTone))
root.style.setProperty(cssVar, hex)
}
// ② 皮肤业务变量(恒 light scheme 派生,明暗共用——值同族稳定):
// 全部走标准 tone(90/80/40/30),非标准插值值易偏离 M3 规范
const primary = hexFromArgb(t.schemes.light.primary) // tone 40
const primaryDark = hexFromArgb(t.palettes.primary.tone(30))
const primaryBright = hexFromArgb(t.palettes.primary.tone(80))
const iconBg = hexFromArgb(t.schemes.light.primaryContainer) // tone 90 浅底(接替手工配色表)
root.style.setProperty('--skin-primary', primary)
root.style.setProperty('--skin-primary-dark', primaryDark)
root.style.setProperty('--skin-primary-bright', primaryBright)
// --skin-primary-text : 普通文字,明暗自适应(浅色=tone40 同 primary,深色=tone80 提对比)
// --skin-primary-bright: 恒亮 tone80,专用于深色背景上的图标/边框/徽章等强调装饰,不应用于大段文字
// 两者勿互换:bright 在浅底上对比反降,不可用于浅底文字(P1-9/P1-12)
root.style.setProperty('--skin-primary-text', hexFromArgb(scheme.primary))
root.style.setProperty('--skin-primary-rgb', hexToRgb(primary))
root.style.setProperty('--skin-icon-bg', iconBg)
root.style.setProperty(
'--skin-gradient',
// 三色渐变(亮 → 主 → 深,标准 tone 90/40/30)
`linear-gradient(135deg, ${
hexFromArgb(t.palettes.primary.tone(90))} 0%, ${
primary} 45%, ${
primaryDark} 100%)`,
)
root.style.setProperty(
'--skin-gradient-hover',
`linear-gradient(135deg, color-mix(in srgb, ${
primary} 55%, #ffffff) 0%, ${
primary} 100%)`,
)
root.style.setProperty('--skin-shadow', `0 4px 12px rgba(${
hexToRgb(primary)}, 0.3)`)
// ③ Quasar brand 桥接(body inline,随当前 scheme——暗黑下取亮调角色)
// negative 不桥接(P1-11):M3 error 与红系 primary ΔE<3 视觉雷同,
// 保持 Quasar 默认 #C10015 保主/危险操作可区分性;quasar.config 静态 brand 同不含
const body = document.body
body.style.setProperty('--q-primary', hexFromArgb(scheme.primary))
body.style.setProperty('--q-secondary', hexFromArgb(scheme.secondary))
body.style.setProperty('--q-accent', hexFromArgb(scheme.tertiary))
} catch (e) {
// 无效源色(自定义输入 '#ggg'/空串等)兜底:回退经典红源色(默认方案,
// 不透传 carbon——回退色是彩色,语义上就不该走灰分支)
console.warn('[md3] invalid source color:', sourceHex, e)
if (normalizeSource(sourceHex) !== '#ef4444') applyMd3Theme('#ef4444', dark)
}
}
本文实践基于 Quasar 2.27 + Vue 3 + @material/material-color-utilities@0.3.0(版本锁定说明见 §2 开头)。你们的换肤方案踩过时序或配色派生的坑吗?欢迎评论区交流。