什么时候该用 light-dark() 替换手写的暗色模式方案

要不要把项目里的暗色模式方案换成 light-dark(),我现在的判断标准很具体,不是"新特性就该用"这种态度。第一个数字是暗色相关的 CSS 变量有多少个:如果一个项目的 --color-* 变量控制着几十个组件的配色,而且每个变量都要写一遍浅色值、一遍暗色值,这种规模值得换,因为省下来的模板代码是真金白银。如果一个项目只有三五个变量在管暗色,换不换收益都不大,折腾一趟不如维持现状。第二个数字更关键:项目里有没有第三方组件库靠读取 data-theme 或者某个 class 来决定自己的配色。像 Ant Design、Element Plus 这类组件库切换主题都有自己的一套机制,通常是往某个祖先元素挂 class 或者 data 属性,组件内部再用 CSS 变量或者 JS 逻辑响应。如果这类依赖存在,light-dark() 没法完全替代它——组件库认的是那个属性,不是浏览器原生的配色偏好,这部分逻辑必须保留,light-dark() 顶多接管你自己业务代码那一层的变量定义。

上个月手上有个内部管理后台,暗色变量刚好卡在这两个判断点的中间:变量有二十来个,值得换;但表格组件用的是一个内部维护的旧组件库,它读的是 data-theme="dark",换不掉。所以这次实践下来的结论也很具体:light-dark() 接管了页面级的配色变量,data-theme 那层没删,两套机制并存了一段时间。中间踩的一些细节,比较值得记一下。

媒体查询套变量的方案,卡在"只能跟系统走"

这个后台最早的暗色实现是最朴素的写法::root 定义一批浅色变量,再用 prefers-color-scheme: dark 的媒体查询整体覆盖一遍。

1:root {
2  --bg-primary: #ffffff;
3  --bg-secondary: #f5f5f5;
4  --text-primary: #1a1a1a;
5  --text-secondary: #666666;
6  --border-color: #e0e0e0;
7}
8
9@media (prefers-color-scheme: dark) {
10  :root {
11    --bg-primary: #1a1a1a;
12    --bg-secondary: #242424;
13    --text-primary: #f0f0f0;
14    --text-secondary: #a0a0a0;
15    --border-color: #3a3a3a;
16  }
17}

这套写法的问题不在语法,在于它只能回答"系统现在是什么模式",回答不了"用户想要什么模式"。系统跟浏览器设置走,用户没有任何办法在页面里单独把这个后台切成暗色,哪怕他系统整体是浅色、只是不想被这一个页面的强光晃眼。做管理后台的都清楚,这个诉求几乎必然会被提出来——用户在深夜盯着报表,系统主题懒得切,就想单独给这一个页面来个暗色。媒体查询这条路走到头就是"跟随系统",没有第二个选项。

data-theme 类名方案:多一层 DOM 属性和一次首屏闪烁

要支持手动切换,过去的常规做法是维护一个独立于系统偏好的开关:JS 往 htmlbody 上挂一个 data-theme 属性,CSS 里对应写一批覆盖规则。

1:root {
2  --bg-primary: #ffffff;
3  --text-primary: #1a1a1a;
4}
5
6[data-theme="dark"] {
7  --bg-primary: #1a1a1a;
8  --text-primary: #f0f0f0;
9}
1function applyTheme(theme) {
2  document.documentElement.setAttribute('data-theme', theme);
3  localStorage.setItem('theme', theme);
4}
5
6const saved = localStorage.getItem('theme') || 'system';
7applyTheme(saved === 'system' ? getSystemPreference() : saved);

这套方案能用,但代价是两层。第一层是维护成本:变量表要写两份,一份默认、一份 [data-theme="dark"] 覆盖,新增一个配色变量就得两处同时改,改漏一处的结果是某个组件在暗色下露出刺眼的白底,这种漏改在代码评审里很难一眼看出来,往往是测试或者用户反馈才发现。第二层是首屏闪烁,业内一般叫 FOUC(flash of unstyled content)——浏览器先按 data-theme 属性不存在时的默认样式画出第一帧,等 JS 执行完读到 localStorage 里存的用户选择、再把属性挂上去,页面会在极短时间内闪一下颜色反转的画面。用户设置的是暗色,但页面先亮一下再暗下去,肉眼可见的跳变。

避免这个闪烁的常规手段是在 <head> 里塞一段内联的、阻塞渲染的脚本,在任何样式表加载之前就把属性写好:

1<head>
2  <script>
3    (function () {
4      var theme = localStorage.getItem('theme') || 'system';
5      if (theme === 'system') {
6        theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
7      }
8      document.documentElement.setAttribute('data-theme', theme);
9    })();
10  </script>
11</head>

这段脚本能压住闪烁,但它是要花代价的:内联脚本会阻塞 HTML 解析,是一段每个页面都要重复执行的同步逻辑,而且这只是解决了首屏这一个症状,维护两份变量表、改漏一处的风险照样在。

light-dark() 到底怎么工作

light-dark() 是 CSS Color 5 里新增的一个函数,接受两个参数,第一个是浅色模式下用的值,第二个是暗色模式下用的值,浏览器会自己判断当前该用哪一个:

1.card {
2  background: light-dark(#ffffff, #1a1a1a);
3  color: light-dark(#1a1a1a, #f0f0f0);
4  border-color: light-dark(#e0e0e0, #3a3a3a);
5}

单看这一段,它像是把媒体查询覆盖的两行压成了一行,但真正决定它按什么规则挑值的,不是这个函数本身,是另一个属性——color-scheme

light-dark() 生效的前提:color-scheme 属性

color-scheme 描述的是"这个元素支持哪些配色方案",取值可以是 normallightdark,也可以是 light dark 同时声明两者都支持。light-dark() 函数在解析时,看的是它所在元素(或者最近声明了 color-scheme 的祖先元素)这个属性的计算值:

  • 如果计算值只有 lightlight-dark() 无论如何都取第一个参数,暗色值永远不会生效。
  • 如果计算值只有 dark,永远取第二个参数。
  • 如果计算值是 light dark(两者都声明),浏览器就会去看用户的实际偏好——通常来自 prefers-color-scheme 对应的系统或浏览器设置——来决定用哪一个。
  • 如果整个文档树上都没有任何元素声明过 color-scheme(保持默认的 normal),light-dark() 的行为等同于只支持 light,也就是说暗色参数永远不会被用到。

这一条最容易被忽略:光在样式里写 light-dark() 函数,如果忘了在某个祖先元素上声明 color-scheme: light dark,页面看起来会一直停在浅色,函数像是没生效,其实是它严格按规则取了浅色分支,规则本身没有问题。这次实践里我最初就漏了这一步,.card 那段样式怎么调都不变色,查了一圈规范才想起 color-scheme 还没声明。

所以最基础的接入方式,是先在 :root 上把两种配色方案都声明出来:

1:root {
2  color-scheme: light dark;
3}

有了这一句,light-dark() 才会真正跟着系统偏好走。

color-scheme: light dark 顺带改掉的东西:表单控件和滚动条

color-scheme 不只是给 light-dark() 铺路,它本身也会直接影响浏览器怎么渲染原生控件。声明了 light dark 之后,checkbox、radio、select 下拉框、日期选择器这些没有自定义样式的原生表单元素,会自动跟着系统偏好切换成对应配色的版本;滚动条也是同样待遇,系统是暗色时滚动条会变成深色轨道配浅色滑块,不用额外写 ::-webkit-scrollbar 之类的规则。

这个后台里恰好有一批没做自定义样式的原生 <select> 和日期输入框,之前系统切到暗色后,这些控件还顶着浅色底色杵在暗色背景里,非常突兀,因为它们不认 data-theme 那个 class,那套方案压根管不到浏览器自己渲染的原生控件内部。声明了 color-scheme: light dark 之后,这批控件跟着系统偏好自动换了配色,没有额外写一行样式。

变量层怎么改:前后代码对比

把之前那套媒体查询方案改造成 light-dark(),变量定义从"两份表、覆盖一次"变成"一份表、每行两个值":

1/* 改造前 */
2:root {
3  --bg-primary: #ffffff;
4  --bg-secondary: #f5f5f5;
5  --text-primary: #1a1a1a;
6  --text-secondary: #666666;
7  --border-color: #e0e0e0;
8}
9
10@media (prefers-color-scheme: dark) {
11  :root {
12    --bg-primary: #1a1a1a;
13    --bg-secondary: #242424;
14    --text-primary: #f0f0f0;
15    --text-secondary: #a0a0a0;
16    --border-color: #3a3a3a;
17  }
18}
1/* 改造后 */
2:root {
3  color-scheme: light dark;
4
5  --bg-primary: light-dark(#ffffff, #1a1a1a);
6  --bg-secondary: light-dark(#f5f5f5, #242424);
7  --text-primary: light-dark(#1a1a1a, #f0f0f0);
8  --text-secondary: light-dark(#666666, #a0a0a0);
9  --border-color: light-dark(#e0e0e0, #3a3a3a);
10}

用的地方完全不变:

1.card {
2  background: var(--bg-primary);
3  color: var(--text-primary);
4  border: 1px solid var(--border-color);
5}

好处是每个配色只在一处定义,浅色值和暗色值紧挨着写在同一行,改一个颜色不会漏改另一半。这个后台原来的变量表分散在两个代码块里,改造后压成了一个,二十多个变量的差异一眼就能扫完,评审的时候也好核对。

需要用户手动切换主题时,怎么处理

只接 prefers-color-scheme 系统偏好不够用,前面说过管理后台这类场景用户经常想脱离系统单独切一次。light-dark() 本身不提供"跟系统偏好脱钩、由用户手动决定"的机制,它读的始终是 color-scheme 计算出来的那个值。但反过来想,color-scheme 是一个普通的 CSS 属性,可以被更具体的选择器覆盖,所以手动切换的做法是用一个 class 去覆盖 :root 上的 color-scheme,而不是像过去那样去覆盖几十个变量:

1:root {
2  color-scheme: light dark; /* 默认跟随系统 */
3}
4
5:root.theme-light {
6  color-scheme: light; /* 用户手动选了浅色,强制只用浅色分支 */
7}
8
9:root.theme-dark {
10  color-scheme: dark; /* 用户手动选了暗色,强制只用暗色分支 */
11}
1function setTheme(theme) {
2  const root = document.documentElement;
3  root.classList.remove('theme-light', 'theme-dark');
4  if (theme === 'light') root.classList.add('theme-light');
5  if (theme === 'dark') root.classList.add('theme-dark');
6  localStorage.setItem('theme', theme);
7}

这段代码看起来和之前 data-theme 那套很像,都是挂 class、读 localStorage,但分量完全不同:过去每新增一个配色变量都要多写一条 [data-theme="dark"] 覆盖规则,现在这个 class 只做一件事——改一个属性的值,剩下的浅色/暗色切换全部由 light-dark() 自己根据这个属性的计算结果去分发,不需要再为每个变量单独写覆盖规则。首屏闪烁的问题也还在,因为浏览器第一次渲染时同样不知道用户在 localStorage 里存了什么选择,还是需要一段内联脚本在样式表加载前把 class 挂上去,这一点 light-dark() 没有替你省掉。

这也是目前这个方案的局限:它没有内置"浅色 / 暗色 / 跟随系统"三态切换器,那部分状态管理逻辑还是要自己写,light-dark() 只是把"根据配色方案分发两个值"这件事从每个变量身上收回到了 color-scheme 这一个属性上。真要做一个带图标的主题切换按钮,UI 状态、持久化、首屏脚本,一样都不能少,只是要维护的 CSS 覆盖规则从"每个变量一条"变成了"整体一条"。

兼容性现状

light-dark()color-scheme 这两年是分两步落地的。color-scheme 属性本身支持得早,Chrome、Firefox、Safari 都已经跟进多年。light-dark() 函数是后到的:Chrome 123、Firefox 120、Safari 17.5 先后补齐,到这个后台立项的这个时间点,我关心的这几款浏览器的近版本都已经覆盖,Baseline 上也从"较新可用"逐步走到了"广泛可用"这一档。这个后台本来就只面向公司内部、要求用较新版本的 Chrome 访问,直接裸用没有顾虑。

如果项目的用户盘子里还压着旧版本浏览器,light-dark() 目前没有特别顺手的降级手段,比较务实的做法是用 @supports 探测,不支持的走回媒体查询那条老路:

1:root {
2  --bg-primary: #ffffff;
3}
4
5@media (prefers-color-scheme: dark) {
6  :root {
7    --bg-primary: #1a1a1a;
8  }
9}
10
11@supports (background: light-dark(#fff, #000)) {
12  :root {
13    color-scheme: light dark;
14    --bg-primary: light-dark(#ffffff, #1a1a1a);
15  }
16}

支持的浏览器会命中 @supports 块里更精简的写法,旧浏览器留在前面那套媒体查询兜底,两套规则同时存在,靠 @supports 分流,不需要额外的构建工具介入。

调试时容易踩的一个坑:系统偏好和 DevTools 模拟不是一回事

改造过程里还遇到一个排查起来比较费时间的问题。本机系统一直是浅色模式,但样式改完想快速看一眼暗色效果,很自然会想到用 Chrome DevTools 里"渲染"面板下的 prefers-color-scheme 模拟开关,把它切成 dark 看效果。这个开关模拟的是媒体查询的匹配结果,对页面里原来那套 @media (prefers-color-scheme: dark) 的旧代码是有效的,但对 light-dark() 这条新路径,只有当 color-scheme 的计算值是 light dark(也就是没有被 .theme-light.theme-dark 这类 class 强制锁死)时才会跟着联动。如果当时页面刚好带着上次调试遗留的 theme-light class,DevTools 里把系统偏好切成暗色,light-dark() 那一层完全没反应,会让人怀疑是不是哪里改错了。后来养成的习惯是调试前先在 Elements 面板里确认 <html> 标签上没有残留的主题 class,确保 color-scheme 处在跟随系统的默认状态,再去切模拟开关。DevTools 现在的"渲染"面板里也能直接看到当前元素计算出来的 color-scheme 值,比读 CSS 源码猜结果要直接。

另外浏览器自身的界面主题设置(比如系统偏好之外,Chrome 自己也能在设置里强制整个浏览器界面用暗色)不会影响页面的 prefers-color-scheme 结果,这两者容易混为一谈。真正决定 light-dark() 走哪条分支的,只有操作系统级别的外观设置、或者页面自己声明的 color-scheme 覆盖,跟浏览器主题皮肤无关。

和 Tailwind v4 的 dark: 变体,放一起用还是二选一

这个后台的组件层用的是 Tailwind v4,避不开一个问题:Tailwind 的 dark: 变体和 light-dark() 该怎么摆放关系。Tailwind v4 默认按 prefers-color-scheme 生成 dark: 变体,也可以配置成跟着某个 class 走,两种模式和前面说的"媒体查询"“手动切换 class”其实是同一类思路,只是包了一层 Tailwind 的语法糖。

真正要想清楚的是分工,不是二选一。业务代码里散落的 dark:bg-black dark:text-white 这类写法,本质上是把配色决策权撒在了每一个用到的元素上,用的地方越多,维护成本和过去手写 [data-theme="dark"] 覆盖规则是一回事,只是语法更短。这次改造里我把颜色相关的 Tailwind 工具类全部换成了引用 CSS 变量的写法:

1/* tailwind 配置里把设计令牌指向 CSS 变量 */
2@theme {
3  --color-surface: var(--bg-primary);
4  --color-ink: var(--text-primary);
5}
1<div class="bg-surface text-ink">...</div>

组件里只写 bg-surfacetext-ink 这类语义类名,颜色本身在哪个模式下该是什么值,交给 --bg-primary 那层 light-dark() 变量去决定,Tailwind 的 dark: 变体在这一层完全不需要出现。dark: 变体我留给了少数确实需要在暗色下改变布局、而不只是改变颜色的地方,比如某个图标在暗色下要换一张对比度更高的图,这种"不只是换色、换的是资源或者结构"的场景,light-dark() 管不了,还是 dark: 变体或者 JS 判断更直接。颜色交给 CSS 变量这一层统一决策,结构和资源交给 dark: 变体做局部例外,两边各管一段,不会出现同一个颜色在两个地方各定义一遍、改起来对不上的情况。

那批还挂着旧组件库的 data-theme 逻辑,这次没有强行合并进来。组件库内部读 data-theme 的代码不是我能改的,继续让它按自己的机制走,我只是保证外部切换按钮触发的时候,data-theme 属性和 :root 上的 color-scheme class 同时更新,两套机制各自生效,互不干扰。这个后台目前就停在"页面级配色交给 light-dark(),组件库内部配色维持原样"这个状态,等那批组件库有机会升级、不再依赖 data-theme 的时候,再看要不要把这条线也收掉。