摘要:「设计令牌」听起来是大厂设计系统里的重装备,但它的最小可用形态只有三行:一个色值源头、一组派生纯函数、一堆 CSS 变量引用。本文用「品牌主色 + 阅读进度条」这对最小组合,讲清令牌体系为什么值钱、hex 解析有哪些必须兜住的边界(写错会让全站 var() 一起静默失效)、以及一个 124 行的进度条里藏着的 4 个工程决定。
适合阅读:做主题 / 组件库的前端与全栈开发,以及想给项目引入设计令牌但不想上重装备的人。文末附零依赖交付代码(纯函数 29 项 + 浏览器 27 项断言)。
一、颜色抄 19 遍的代价
一个真实的起点:站点主色 #2e7cf6 在主题代码里出现了 19 次 —— 按钮、链接、进度条、徽章、通知高亮、邮件模板各一份。
于是「换个品牌色」这件事变成了:
- 打开 19 个文件,Ctrl+H 替换 —— 换一次要半小时,还容易漏;
- 更麻烦的是那些派生出来的颜色:hover 加深色、阴影用的半透明色、浅底强调色 —— 它们是算出来的,也被人肉抄成了字面量; n
- 邮件模板是特例:邮件客户端不支持 CSS 变量,只能在 PHP 侧注入实际色值 —— 于是它天然是「另一份真值」。
这就是令牌体系要解决的问题:让颜色变成「一个源头 + 一堆派生」,而不是一堆字面量。
二、最小可用形态:三层
| 层 | 做什么 | 放在哪 | 为什么 |
|---|---|---|---|
| ① 源头 | 后台一个 color 字段(品牌主色) | 配置模块 | 需要一个「可改」的入口,这是唯一应该存值的地方 |
| ② 派生 | hex → rgba / darken 纯函数 | core 层 | 派生是计算,不是配置;放模块里会让「关掉模块」连带失去派生能力 |
| ③ 消费 | 组件引用 var(--brand) | 各组件 | 组件只认变量名,不认色值 —— 这才是「改一处全站生效」的关键 |
第二层的位置是这套体系里最容易做错的地方,值得展开讲。
2.1 为什么令牌与派生必须放在 core,不能放业务模块
我在这上面踩过一次真实的坑:早期把「令牌输出」放进了一个可关闭的业务模块里。结果模块一被关掉(或被删掉),「后台改品牌色 → 全站生效」的能力就静默消失了 —— 全站 CSS 都写了 fallback,页面不破版,只是改色不再生效,没有任何报错。
更严重的一次是「删模块没查引用」:那个模块里定义了 zhiji_token_color(),而它在别处被调用了 19 次(含邮件渲染路径)。模块一删,任何一次发信都会 500。静态扫描查不出来(函数名是动态拼的),只有运行时探针才暴露。
所以现在的位置约定是:
- 纯函数与令牌输出 → core(随主题常驻,不受任何开关影响);
- 配置入口 → 模块(只负责渲染那个 color 字段 + 「关开关回落默认色」);
- 模块与 core 之间用
apply_filters解耦:模块在就注入,不在就回落默认值 —— core 永远不会因为某个模块被关掉而崩。
三、hex 解析:必须先说清「非法值怎么办」
这是整套代码里最不起眼、却最容易静默破版的一段。看派生函数的输入容忍度:
| 输入 | 期望结果 | 说明 |
|---|---|---|
#2e7cf6 | #2e7cf6 | 标准 6 位 |
#abc | #aabbcc | 3 位缩写要展开 |
abc | #aabbcc | 没写 # 也要认 |
#AABBCC | #aabbcc | 空格 + 大写要归一 |
xyz / #abcd / #12g456 | 空串 | 非法一律返回空串 —— 这一条最关键 |
最后一行为什么关键?如果你对非法值「硬转」(比如强行当 6 位截断),就会往 CSS 里写出一个残缺色值;如果你返回空串却仍然拼接,会得到这样的输出:
:root{ --brand:; --brand-deep:; }
空值变量的后果不是「这个颜色不对」,而是所有引用它的地方一起失效 —— 按钮变透明、进度条消失、边框不见。所以正确做法是两层防御:解析层对非法值返回空串;汇总层发现空令牌表就整块不输出,让组件里的 fallback 生效。
顺带两个格式化细节:alpha 要夹取到 0~1(越界会写出非法 rgba);数字格式化后要去掉尾随零(0.300 → 0.3,1.000 → 1),否则 CSS 里会出现一堆 rgba(46,124,246,1.000) 这种能跑但难看的值。
四、消费方示例:一条 3px 的阅读进度条
令牌有没有价值,要看消费方是不是真的「只认变量」。拿阅读进度条做例子:
4.1 颜色只写变量 —— 令牌的收益在这里兑现
.zdw-progress-bar{
background: var(--zdw-brand, #2e7cf6);
box-shadow: 0 0 8px var(--zdw-glow, transparent);
}
这样「改品牌色」这一个动作,自动改掉了进度条、它的发光、以及所有后续接进来的组件。而且注意每个 var() 都带了 fallback —— 令牌没输出时组件依然可用,只是回到默认蓝。
4.2 用 rAF 节流,不是直接改样式
滚动时 scroll 事件在移动端每秒可以触发上百次。如果每次都直接读 scrollHeight 再写 style.width,就会在同一个帧里交替读写布局属性,触发强制同步布局,滚动会明显发涩。
正确姿势是把「读」和「写」都放进 requestAnimationFrame 里,并且用一个标志位做节流:
var ticking = false;
function onScroll() {
if (!ticking) { ticking = true; window.requestAnimationFrame(render); }
}
4.3 页面不足一屏:一个必然出现的 NaN
百分比是这样算的:(scrollTop / (scrollHeight - innerHeight)) * 100。当页面内容比视口还短时,分母是 0 或负数 —— 0/0 得到 NaN,写进样式就是 width: NaN%,整条进度条行为未定义。
这个 case 在「文章很短」或「移动端横屏」时会真实出现。必须显式挡掉:
var h = document.documentElement.scrollHeight - window.innerHeight;
if (!(h > 0)) return 0; // 🧨 不足一屏 → 直接 0,别让除零产生 NaN
4.4 内联还是外链?
进度条样式 + 脚本合计不到 1KB,且只在页面上出现一次 —— 这种情况内联更划算(省一次请求,还能避免样式到达前的短暂无样式)。(内联 vs 外链的判定阈值与实测,我在另一篇里专门做过测量。)
五、三层实测
| 层 | 测什么 | 规模 | 结果 |
|---|---|---|---|
| ① 纯函数单测(PHP CLI) | hex 归一化 9 种输入(含 4 类非法)/ rgba 的 alpha 夹取与格式化 / darken 的边界(0、1、越界、非法)/ 令牌派生与空令牌表 | 29 项 | 29/29 通过 |
| ② 浏览器测试(无头 + 真实输出) | 令牌取色链路(组件背景色 == 品牌色)/ 初始 0% / 中段 50% / 到底 100% / 不足一屏恒 0% 且无 NaN / scroll→rAF→写入链路 / 文字开关 | 3 场景 × 9 断言 | 27/27 通过 |
| ③ 线上注入 | 令牌变量在文章页/首页/归档都输出;进度条前台有、后台无;卸载后无残留 | 5 次抓取 | 全绿 |
第 ② 层有两处「测试环境的坑」,也会在别的地方遇到,我记在这:
| 现象 | 根因 | 正确做法 |
|---|---|---|
| 派发 scroll 事件后宽度不变 | headless 里 window.scrollTo 不真的改变 pageYOffset(没有合成器帧) | stub pageYOffset + 手动 dispatchEvent(new Event('scroll')),测接线而不是测浏览器 |
| stub 设成 0 却不生效 | 代码里是 pageYOffset || scrollTop || 0,0 是 falsy 会落到第二分支 | stub 用非零值(10% / 50%)绕开兜底;真实浏览器里两者同步为 0 只在顶部,所以无害 |
| 断言结果时有时无 | rAF 回调是异步的,--dump-dom 可能在它之前就抓取 | 把 requestAnimationFrame stub 成同步执行,链路即可确定性断言 |
六、令牌体系的三条纪律
- 组件里不出现色值字面量(
#2e7cf6/rgb(...))—— 只允许var(--token);出现字面量就说明还差一个令牌。 - 派生色一律算,不抄:hover 加深、半透明底、发光都由纯函数派生;抄出来的派生色是「改色时必然漏掉的那一份」。
- 例外要显式标注:邮件模板不支持 CSS 变量,必须在 PHP 侧取色值 —— 这种例外要在代码注释里写明原因,否则下一个人会以为你忘了用令牌。
附:测试方法
这套代码的测试分两层做,思路可以直接搬走:
① 纯函数层:定义几个桩函数(add_action / apply_filters / esc_attr / is_admin),
用 php CLI 直接 require 交付文件 → 断言归一化、派生、边界值。
⚠️ 非法输入必须写成断言(空串 / 4 位 / 含非 hex 字符),这类边界才是 bug 高发区。
② 浏览器层:先用本地 PHP 生成**真实输出片段**,再注入无头浏览器 —— 不手抄 CSS/JS。
断言令牌取色链路(getComputedStyle 拿到的是否真是品牌色)、
以及把 requestAnimationFrame stub 成同步后再派发事件,让异步链路也可确定性断言。
最后一个提醒:不要用「页面能打开、颜色看起来对」来验收令牌体系。令牌出问题的表现是「某个颜色不对」,很容易被当成设计问题而放过;用 getComputedStyle 把实际取到的值断言出来,才算测过。
📚 zibll 子主题开发实录(共 22 篇) · 系列总目录与阅读路径 →
① 前端性能与加载
② 排版与样式系统
- 用 alt 后缀驱动的 WordPress 密集墙排版:从 float 到 flex-wrap 与纵向叠列
- 当主题模板没有过滤器:把「字数+阅读时间」并进它的 meta 行
- 设计令牌的最小实现:一个颜色变量驱动全站(附阅读进度条) (本文)
③ 功能实现与边界
- 一个 81 行的朗读按钮改了三轮:三个静默失效的坑
- 删数据类功能怎么写才不闯祸:一个「删文清附件」的六条边界
- 令牌模型与 lost update:一个只在并发时出现的计数 bug
- 「我填了 1 天,它却按 7 天算」:文章过期提示的四个边界
- 「历史上的今天」为什么永远是空的:三个不报错的死写法,与两个时区坑
- 文章系列卡片:一个 PHP 8 致命错误,和三处「不报错但白干」的写法
- 友链申请表单:一次越权写入,和一处「写了但不发」的死角
④ 系统与运维
- 404 监控的四个坑:计数翻倍、键名推导、点路径取值、与「自己看不到数据」
- 评论反垃圾的四个坑:先被自己拦住、节流伪装失败、空内容归属、与「WP 原生背锅」
- 页面缓存一次都没命中:六个静默失效,与一条 nonce 结构性保证
- 维护模式:五个「看起来在工作、其实开了后门」的写法
- WebP 自动转换:三个「上传成功、图片没变」的静默失效
- 安全扫描器:五个「扫了但没扫到」的坑,和一个实测确认的路径逃逸
⑤ 成就与活动系统










暂无评论内容