Custom Properties实战:提升CSS可维护性的现代技巧
为什么这次选了 CSS Custom Properties
上个月接了个主题切换需求,客户说要支持深色、浅色、还有个“品牌蓝”三套皮肤,而且得实时切换——不能刷新页面。一开始我第一反应是用 CSS-in-JS 或者直接换 class,但考虑到项目里已经有不少静态样式,硬塞 JS 控制颜色变量太容易翻车。后来一想,不如试试原生 CSS 的 Custom Properties(也就是 CSS 变量),毕竟浏览器支持早就不是问题了。
其实之前也用过几次,但都是简单地定义几个颜色值,比如 --primary-color: #3498db; 这种。这次不一样,要整套主题动态替换,还涉及大量组件的状态色、边框、阴影、甚至动画时长。所以这次算是第一次在复杂场景下“认真”用它。
核心代码就这几行
先说基础结构。我在 :root 里定义了一堆默认变量:
:root {
--color-bg: #ffffff;
--color-text: #333333;
--color-primary: #3498db;
--border-radius: 8px;
--shadow: 0 2px 10px rgba(0,0,0,0.1);
}
然后所有组件都直接引用这些变量:
.card {
background: var(--color-bg);
color: var(--color-text);
border-radius: var(--border-radius);
box-shadow: var(--shadow);
}
切换主题的时候,只需要用 JS 动态改 document.documentElement.style.setProperty 就行:
function setTheme(themeName) {
if (themeName === 'dark') {
document.documentElement.style.setProperty('--color-bg', '#1a1a1a');
document.documentElement.style.setProperty('--color-text', '#e0e0e0');
document.documentElement.style.setProperty('--color-primary', '#5dade2');
// ...其他变量
} else if (themeName === 'brand-blue') {
document.documentElement.style.setProperty('--color-bg', '#f0f8ff');
document.documentElement.style.setProperty('--color-text', '#2c3e50');
document.documentElement.style.setProperty('--color-primary', '#2980b9');
}
// 存到 localStorage 以便下次加载
localStorage.setItem('theme', themeName);
}
看起来是不是挺简单的?但实际跑起来才发现,坑比想象中多。
最大的坑:性能问题和闪烁
上线前测试时发现一个问题:切换主题的瞬间,页面会“闪一下”——旧颜色先消失,新颜色稍后才出现。虽然只有几十毫秒,但在高端机上都能感觉到,更别说低端安卓机了。
一开始我以为是 JS 执行慢,加了 performance.now() 打点,发现 setProperty 调用本身很快,不到 1ms。那问题出在哪?后来用 Chrome DevTools 的 Performance 面板录了一下,发现每次调用 setProperty 都会触发整个页面的样式重计算(Recalculate Style),而我们页面有上千个 DOM 节点,每个都用了 var(),结果就是主线程卡顿。
折腾了半天,想到一个土办法:把所有变量更新打包成一次操作。别一个个 setProperty,而是拼成一个 style 字符串一次性插入:
function setThemeBatch(themeData) {
const styleText = Object.entries(themeData)
.map(([key, value]) => --${key}: ${value};)
.join(' ');
document.documentElement.setAttribute('style', styleText);
}
但这样有个副作用:会覆盖掉 html 标签上原有的内联样式(比如用户手动设置的 zoom 或 font-size)。于是又改成了创建一个 <style> 标签动态注入:
let themeStyleEl = null;
function setThemeViaStyleTag(themeData) {
if (!themeStyleEl) {
themeStyleEl = document.createElement('style');
document.head.appendChild(themeStyleEl);
}
const cssText = :root { ${Object.entries(themeData).map(([k, v]) => --${k}: ${v};).join(' ')} };
themeStyleEl.textContent = cssText;
}
实测下来,这种方式在低端机上切换几乎无感,Recalculate Style 时间从 30ms 降到了 5ms 左右。亲测有效。
另一个头疼的问题:变量作用域混乱
项目里有些组件是第三方库引入的(比如一个图表库),它们内部也用了自己的 CSS 变量,比如 --chart-color。结果我们的全局变量不小心覆盖了它们的,导致图表颜色错乱。
开始没想到这点,以为变量名加个前缀就行,比如 --myapp-color-bg。但后来发现维护成本太高,每个变量都要手动加前缀,写样式时特别累。最后妥协了:只对可能冲突的变量加前缀,其他保持简洁。同时,在第三方组件的容器上重置掉我们不需要继承的变量:
.third-party-chart {
/* 隔离我们的变量,避免污染 */
--color-bg: initial;
--color-text: initial;
}
虽然不完美,但至少保证了核心功能不出错。这个方案上线后没再出过颜色错乱的问题。
还有一点小遗憾:媒体查询里的变量无法动态响应
本来想做“根据系统偏好自动切深色模式”,就写了:
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #1a1a1a;
--color-text: #e0e0e0;
}
}
但问题来了:如果用户先手动切到浅色模式,再切回“自动”,这段媒体查询不会重新生效——因为 JS 设置的内联变量优先级更高。查了 MDN 才知道,CSS 变量一旦被 JS 设置为具体值,就不会再响应媒体查询的变化了。
最后只能放弃纯 CSS 方案,改成 JS 监听 matchMedia 事件:
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', (e) => {
if (localStorage.getItem('theme') === 'auto') {
setTheme(e.matches ? 'dark' : 'light');
}
});
虽然多写了几行 JS,但逻辑更可控了。只是有点不甘心,本来想少写 JS 的……
回顾与反思
总的来说,这次用 Custom Properties 实现主题切换,效果还是不错的:
- 开发体验好:样式和逻辑分离清晰,设计师给一套色值表,前端直接填变量就行
- 维护成本低:改一个变量,全站生效,不用到处找 class
- 动态能力够用:配合 JS,能做到实时切换,还能存本地
但也暴露了一些局限:
- 变量太多时,命名和管理容易混乱(后来我们搞了个 tokens.json 统一维护)
- 不支持 fallback 的复杂逻辑(比如“如果 A 不存在就用 B”这种还得靠 JS)
- SSR 场景下需要额外处理(我们项目是 CSR,所以没碰这问题)
最让我满意的是那个 style 标签注入方案,虽然有点 hack,但解决了性能痛点。现在回头看,Custom Properties 不是银弹,但在“动态主题”这种场景下,它确实是最轻量、最贴近 CSS 原生能力的解法。
当然,如果你的项目连 IE11 都要支持,那还是洗洗睡吧——不过现在应该没人这么干了吧?
以上是我的踩坑总结
这次实战让我对 Custom Properties 的边界有了更清楚的认识:它适合做“运行时可变的常量”,不适合做复杂状态管理。用好了能提升开发效率,用不好反而增加调试成本。
代码我已经整理了一份精简版放在 GitHub Gist(链接就不贴了,大家自己搜关键词),核心逻辑和上面差不多。
以上是我个人在项目中使用 Custom Properties 的完整经验,有更优的实现方式或者踩过类似坑的朋友,欢迎评论区交流!这个技巧的拓展用法还有很多(比如做动态间距系统、动画参数控制),后续有时间再分享。

暂无评论