开发者都喜欢快速见效的方案。当任务单写着“添加深色模式”时,最省力的路径显而易见:编写 light.css,编写 dark.css,然后在它们之间进行切换。这看起来很干净,交付很快。对于只有三个组件的小型侧边项目,这甚至可能行得通。但一旦你的应用程序增长到超过几个模块,第二个文件就不再是资产,而变成了需要重复维护的负担。
双文件陷阱
逻辑乍看之下很合理。关注点分离,对吧?浅色放在这边,深色放在那边。你在编辑器中打开两个缓冲区。你把浅色文件中的卡片样式复制到深色文件中,把 #ffffff 换成 #1a1a1a,然后收工。
问题不在第一周。问题在于第六个月,当设计师要求在主按钮上使用稍微不同的圆角,或者产品团队想在结账表单上增加一个新的警告状态时。你更新了浅色样式表。你凭感觉查看深色样式表。也许你记得把更改复制过去,也许你没记得。这就是质量开始崩塌的地方。你不再是在维护一个界面,而是在维护两个共享相同 HTML 骨架的并行界面。
主题漂移不可避免
这种差距有一个前端团队开始意识到的名称:主题漂移(theme drift)。当你的两个样式表以不同的速度演进时,就会发生这种情况。这里调整一下内边距,那里微调一下阴影。深色文件变成了被忽视的兄弟。或者更糟,它变成了一个令人恐惧的来源。开发者开始避免更改,因为改动一个主题意味着必须在另一个文件中寻找并重复同样的工作。
认知负荷会迅速累积。你本想只写一次 CSS。结果你写了两遍,现在每次设计系统发生变化时,你都在为这笔债务支付利息。图标在深色模式下对不齐,因为有人更新了浅色文件中的 flex gap 却忘了同步。焦点环消失了,因为新的无障碍规则只应用到了其中一个样式表中。UI 不仅仅是看起来不对劲,它开始让人感觉“坏掉了”。
使用语义化 Token
解决方法不是更好的 diff 工具或更严格的代码审查。解决方法是改变对颜色的思考方式。停止按字面外观组织样式,开始按用途组织样式。这就是语义化 Token 发挥作用的地方。
不要给卡片分配白色背景,而是分配一个“表面背景”(surface background)。不要在黑色和米白色之间为文本做选择,而是选择一个“文本颜色”(text color)。组件不需要知道也不关心用户偏好浅色还是深色。它只需请求与其职责相匹配的 Token。
想想一个标准的按钮。在双文件世界中,.btn 存在于浅色样式表中,具有白色背景和深色边框。它的孪生兄弟存在于深色样式表中,具有近乎黑色的背景和较浅的边框。这相当于一个按钮写了两倍的代码。使用 Token 后,.btn 只有一个声明:背景是 var(--color-surface-secondary),边框是 var(--color-border-default)。值本身存在于根节点。当网站处于浅色模式时,--color-surface-secondary 解析为类似 #f8f9fa 的值。在深色模式下,同一个 Token 解析为 #2d2d2d。按钮组件从未改变,改变的只是底层的数值。
这种结构与数据之间的区别很微妙但非常强大。你的卡片组件只需定义一次布局、间距、排版和层级感(elevation)。你的主题层定义调色板。这种分离正是 CSS 自定义属性的设计初衷。
架构如何变化
这种方法从根本上重构了你编写样式的方式。
旧方法通常如下所示:
- 一个浅色卡片样式表,定义了内边距、圆角、背景、文本颜色和阴影。
- 一个深色卡片样式表,重新定义了大部分相同的属性,仅仅是为了翻转颜色。
- 一个逻辑层,决定加载哪个样式表或在
body上切换哪个类。
新方法如下所示:
- 一个卡片样式表,定义布局并分配语义化 Token。
- 一个主题文件,定义这些 Token 在浅色语境下的含义。
- 一个主题文件(或者仅仅是同一文件中的一个代码块),定义这些 Token 在深色语境下的含义。
- 通过单一的属性切换来改变数值层,而无需触碰组件层。
你保持架构稳定,只更改数据。当设计师想要引入第三种主题时——比如高对比度模式或午夜蓝变体——你不需要重写卡片。你只需在 token 映射中增加一个赋值。组件保持简单且“无脑”运行。它仍然需要一个表面颜色,而主题会告诉它该使用哪种颜色。
使用 Data 属性进行切换
实现方式可以保持简单且易读。在你的 HTML 标签上应用一个 data 属性,例如 data-theme="dark",然后让你的 token 定义在该属性的作用域下。
在 :root 上设置浅色体验的默认值,以便在 JavaScript 运行之前页面能正确渲染。然后,在 [data-theme="dark"] 下覆盖 token 值。一个微小的脚本监听切换点击,更新该属性,页面上的每个组件都会立即响应。无需在单个元素上频繁变动 class,也无需在渲染过程中导入一个完全独立的样式表。浏览器已经在内存中拥有了这些变量,它只需使用新值进行重绘即可。
从非常实际的角度来看,这能让你的代码保持整洁。你不需要在两个目录中通过 grep 搜索来查找每一个 .card 的实例。你也不必担心堆叠在同一个节点上的竞争主题类之间会发生权重之争。你的 HTML 保持了可读性,你的 CSS 保持了集中化且易于搜索。
关乎数值,而非版本
深色模式关乎数值。它不是 UI 的第二个版本。卡片的圆角在晚上不会变得更圆,你的网格也不会塌陷成另一种形状,你的字阶也不需要新的节奏。改变的只有颜色,有时阴影会显得更深邃一些。将深色模式视为一次全面的“换肤”是一种过度工程,会制造维护噩梦。
那些做得正确的团队会将他们的设计系统视为一个数据库。组件通过名称查询属性,而主题则提供记录。从浅色切换到深色只是查询参数的改变,而不是 Schema 的重写。
这种思维方式能让你免于“主题漂移”。一个卡片。一个按钮。间距和尺寸都有单一的事实来源。调色板存在于一个地方,经过逻辑映射,随时准备应对用户偏好的任何环境。
核心启示
如果你正在为浅色和深色模式维护两个 CSS 文件,那你不是在做主题化,而是在做克隆。转向语义化 token,通过根级 data 属性来限定它们的作用域,并让你的组件请求“角色”而不是硬编码外观。最初的重构需要付出努力,但如果不这样做,你将陷入在并行样式表中玩“打地鼠”游戏的无尽循环。人生苦短,没必要把同一个卡片写两遍。
