KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

06 · 暗色与响应式:一个开关还是两个 — keel 龙骨

这一章回答:给 <html> 加一个 class="dark",为什么页面肤色变了、两个 antd 按钮却纹丝不动?暗色、响应式、方向、品牌色这些"状态",各自到底有几个开关,它们又该由谁统一驱动?

这一章回答:给 <html> 加一个 class="dark",为什么页面肤色变了、两个 antd 按钮却纹丝不动?暗色、响应式、方向、品牌色这些"状态",各自到底有几个开关,它们又该由谁统一驱动?

一、现场:给 <html> 加一个 class,页面只变了一半

实验页面 dark.html 上有三样东西:一个 Tailwind 面板 #tw-panel(类名是 bg-white dark:bg-slate-900)、两个 antd 主按钮(#pl-primary 用默认主题,#dk-primary 用 theme.darkAlgorithm)、一个响应式探针 #rs(类名 hidden md:flex)。CSS 里写了 @custom-variant dark (&:where(.dark, .dark *));。

然后在"改不改 <html> 的 class"和"视口多宽"这两个维度上,把四个状态各量一遍(E6 原文):

状态 #tw-panel 的 bg / color #pl-primary bg 与 --ant-color-bg-container #dk-primary bg 与 --ant-color-bg-container #rs 的 display
① 无 .dark、宽 1100 bg rgb(255,255,255),color rgb(0,0,0) rgb(22,119,255) / #ffffff rgb(22,104,220) / #141414 flex
② 加 class="dark"、宽 1100 bg oklch(0.208 0.042 265.755),color rgb(255,255,255) rgb(22,119,255) / #ffffff(没变) rgb(22,104,220) / #141414(没变) flex
③ 去掉 dark、宽 500 rgb(255,255,255) 同① 同① none
④ 宽 1100 rgb(255,255,255) 同① 同① flex

先看 ① 与 ② 的对照。给 <html> 加上 class="dark" 之后,Tailwind 面板从白色 rgb(255,255,255) 变成了 oklch(0.208 0.042 265.755),文字色从黑变成白——Tailwind 侧确实听话了。可是两个 antd 按钮的值,一个都没动:#pl-primary 还是 rgb(22,119,255),#dk-primary 还是 rgb(22,104,220)。

这里有个很容易被忽略的细节:#dk-primary 本来就比 #pl-primary 暗一点(它走的是暗色算法),而且它的 --ant-color-bg-container 本就是 #141414。也就是说,antd 的暗色早就已经生效了——它不由 <html> 上的 class 决定,而是由 React 侧那个 theme.darkAlgorithm 决定。给 <html> 加 class 之所以"管不到它",不是因为暗色没做好,而是这两件事根本不是同一个开关。

先停一下,判断一个问题:接下来如果我在页面里点一个"切换暗色"的按钮,要把页面变暗,我需要动几样东西?下面的 ③④ 两行先给出一半答案——它们只切了视口宽度,#rs 就在 none 和 flex 之间跳了一次,而这一跳和暗色完全无关。所以这一页上至少住着两套独立的状态:肤色状态、宽度状态。 问题变成了:它们各自有几个开关。

二、两家的暗色,根本不是同一个开关

Tailwind 的 dark: 默认绑的是系统偏好。

E3.6 量到:什么都不写的情况下,dark:bg-slate-900 产出的是

@media (prefers-color-scheme:dark){ .dark\:bg-slate-900 { background-color: var(--color-slate-900) } }

也就是说,默认的 dark: 变体是一个媒体查询,它问的是"操作系统的深色模式开了没有",跟你页面里给了哪个 class 没有任何关系。

要用 class 驱动,必须显式改写变体的定义——本课实验台在 CSS 里写了这一行:

@custom-variant dark (&:where(.dark, .dark *));

写完之后的产物就变了。同一份 dark:bg-slate-900,产出的选择器变成:

.dark\:bg-slate-900:where(.dark, .dark *) { background-color: var(--color-slate-900) }

产物里没有任何 media query。 :where(.dark, .dark *) 的意思是"这个元素自己或它的某个祖先带着 dark 类"。这就是为什么状态 ② 里给 <html> 加一个 class,Tailwind 面板立刻变色——选择器从"问系统"变成了"看祖先链上有没有 dark"。

antd 的暗色,是一套 React 状态下的主题算法。

E4.2 量到:theme={{algorithm: theme.darkAlgorithm}} 之后,按钮换上了一张新的变量表 css-var-_r_2_,其中 --ant-color-primary=#1668dc、--ant-color-bg-container=#141414、--ant-color-text=rgba(255,255,255,0.85),computed background-color 是 rgb(22, 104, 220)。

注意这张表是怎么来的:它由 React 在这次渲染里算出来,然后作为新的 --ant-* 变量值注入到文档。它跟 <html> 上有没有 dark 这个类毫无关系——antd 根本不看这个 class。给 <html> 加 class 不会让 React 重新算一遍主题;React 不重算,css-var-_r_2_ 就不会出现(如果 ConfigProvider 传的是 defaultAlgorithm)。

一句判据收住这一节:Tailwind 的暗色是"选择器问题"(谁来匹配 dark),antd 的暗色是"计算问题"(React 用哪个算法算变量表)。 一个可以靠给元素挂 class 解决,另一个必须让 React 重新渲染。把两者当成一个开关,你就会得到状态 ② 那一幕:页面变了一半。

三、统一开关的落地形状,和两个容易踩的坑

既然两家的开关不一样,一个页面里"切暗色"这件事就必须由一个状态源同时喂给两边。落地形状大致是这样:React 里存一个 mode(比如 "light" / "dark"),它同时决定两件事——

  1. <html> 上有没有 dark 这个 class(Tailwind 侧要的选择器条件);
  2. ConfigProvider 的 theme.algorithm 传的是 theme.defaultAlgorithm 还是 theme.darkAlgorithm(antd 侧要的计算输入)。

两件事都从同一个 mode 派生,切换时一起变,页面才会整体进入暗色。这样做有两个很容易踩的坑,正好对应上面两条各漏一半:

坑一:只在 React 里切主题,忘了同步 <html> 上的 class。 结果就是 ConfigProvider 换了算法、antd 按钮变暗了,但 #tw-panel 仍是白色——因为 dark:bg-slate-900 那个选择器 .dark\:bg-slate-900:where(.dark, .dark *) 根本没找到祖先上的 dark,它老老实实匹配的是 bg-white。这是状态 ② 的镜像:那次是 Tailwind 变了、antd 没变。

坑二:只在 <html> 上加 class,却没有把 dark: 变体改成 class 驱动。 这个坑更隐蔽,因为 Tailwind 面板看起来"确实变色了"。原因是:如果你的 CSS 里没有那句 @custom-variant dark (&:where(.dark, .dark *));,dark: 变体走的是默认的 @media (prefers-color-scheme: dark)——prefers-color-scheme 是操作系统/浏览器的偏好,你给元素加 class 改变不了它。 所以此时页面变色,其实是因为你的操作系统本来就是深色,而不是因为那个 class。用户在页面里点"切换暗色"按钮、你给 <html> 加上了 dark,prefers-color-scheme 一动不动,你的 dark:bg-* 自然也跟着一动不动——切换按钮一点用都没有。 要验证自己是不是踩了这个坑:给 <html> 加 dark 类,然后看页面有没有跟着变(E6 的实测前提就是 CSS 里写了 @custom-variant dark;没有那一行时,产物里是 media query,不是 .dark 选择器)。

两个坑指向同一个判断:一个状态,两个消费者,必须由同一个地方同时喂。 只喂一半,就只会亮一半。

四、响应式:改 class 是选择器,改断点是媒体查询

再看 #rs 那一列。它的类名是 hidden md:flex——小屏隐藏、中等及以上用 flex。E6 量到:视口宽 500 时 display=none,宽 1100 时 display=flex。这是真响应式,和暗色无关。

这里要分清一件容易混淆的事:Tailwind v4 的响应式仍然是媒体查询。 theme 里的断点(md 对应哪个宽度)确实是变量,但 md:flex 生成出来的还是 @media——它和 E3.6 里那个默认的 dark: 变体是同一种机制(都是媒体查询),只不过一个问的是宽度、一个问的是系统偏好。

这正好反过来说明上一节那句话的分量:把 dark: 改成 class 驱动,改的是"靠选择器匹配",而响应式改不了也不需要改——它必须靠媒体查询。 两者是两件不同的事:

维度 默认靠什么 能不能改成 class 驱动 本课怎么做
暗色 @media (prefers-color-scheme: dark) 能,写 @custom-variant dark (&:where(.dark, .dark *)); 之后变成 .dark 选择器 用 class 驱动,由 mode 同时喂给 <html> 与 ConfigProvider
响应式 @media 里的断点 不能(也不需要)——窗口宽度不是 class 能表达的东西 保持媒体查询,hidden md:flex 在 500 / 1100 下分别是 none / flex

所以"我这一页有几个主题开关"这个问题,答案取决于你有几个需要独立驱动的状态维度。暗色可以(也应该)用 class 驱动,因为它要由页面内的按钮控制;响应式只能跟着视口走,因为"用户把窗口拉窄了"这件事必须由浏览器通过媒体查询告诉你。

一个顺带要说的点:既然暗色已经改用 class 驱动,那你的 CSS 里就不该再指望 dark: 去看系统偏好了。E3.6 的对照摆得很清楚——改之前产物里是 @media (prefers-color-scheme:dark){ .dark\:bg-slate-900 { … } },改之后是 .dark\:bg-slate-900:where(.dark, .dark *) { … },产物里没有任何 media query。有 media query 就说明你还在看系统,没有才是真的交给了 class。

五、收口:页面有几个状态,就有几个开关,但它们必须由同一个地方驱动

回到本章标题那个问题:"暗色到底是一个开关还是两个?"

答案是:暗色这一个用户感知的状态,背后有两个技术开关——一个是 <html> 上的 dark class(Tailwind 的消费者),一个是 ConfigProvider 的 algorithm(antd 的消费者)。它们不是冗余,是因为两家根本不共享同一套机制(一个匹配选择器、一个重算变量表)。而"统一"不是把两个开关合成一个,是让同一个状态源同时喂给两边。

把这个模型往外推,同一个页面里还有别的"有两套系统"的状态:

共同的规律是每个状态都要先问一遍:"这套系统是靠选择器、靠媒体查询、还是靠一次重新计算?" 靠选择器的(dark class)可以被 class 驱动;靠媒体查询的(视口宽度)只能跟着环境走;靠重新计算的(antd 的算法)必须由状态变化触发。把这三类分清楚,"切了一半"的问题就不会再出现——因为你会知道每一次切换,究竟需要改动几个地方。

本章脉络

flowchart TD
    A["一个状态源 mode"] --> B["html 上有没有 dark class"]
    A --> C["ConfigProvider 传 defaultAlgorithm 还是 darkAlgorithm"]
    B --> D["Tailwind dark 变体<br/>选择器 .dark 命中"]
    C --> E["antd 重新算主题<br/>新变量表 css-var-_r_2_"]
    D --> F["页面肤色跟着变"]
    E --> G["按钮颜色跟着变"]
    B -.-> H["只切 html class<br/>没有喂 antd 按钮纹丝不动"]
    C -.-> I["只在 React 里切<br/>没同步 class 面板仍是白"]
    D -.-> J["没改 class 驱动<br/>dark 仍跟系统偏好走 点击无效"]
    K["hidden md:flex"] --> L["@media 媒体查询<br/>500 隐藏 1100 显示"]
    L -.-> M["与 dark 不同<br/>它靠媒体查询不靠选择器"]

图里在说什么。 最上面是唯一的开关——状态源 mode。它往下分两条:一条决定 <html> 上有没有 dark(喂给 Tailwind 的选择器),一条决定 ConfigProvider 的算法(喂给 antd 的计算)。两条都走到,才会得到右边那两个"变"。三条虚线是三种半成品:只切 class 不喂 antd、只在 React 里切不同步 class、以及压根没把 dark: 改成 class 驱动(那样它还在看系统偏好,页面里点切换毫无反应)。右下角单独挂出来的是响应式——它同样会变,但走的是 @media 媒体查询这条完全不同的路,所以它和暗色不是"同一个开关的两种接法",而是两个各自独立的维度。

生产边界

动手:可观察结果

自己造一个同时有 Tailwind 面板、一个 antd 按钮、一个响应式探针的页面,把四状态各量一遍。

产出 判断标准
一份记录表,四个状态各一行 每行都有 #tw-panel 的 bg 与 color、按钮的 bg 与 --ant-color-bg-container、探针的 display(照抄本章那张表的列)
一个"切暗色"的动作 切换之后 Tailwind 面板和 antd 按钮同时变——只变一边就是没接好
一句"我踩了哪个坑"的说明 能指出自己是"忘了同步 class"还是"没把 dark: 改成 class 驱动"
一份产物检查 去产物 CSS 里搜 prefers-color-scheme 和 .dark:产物里还有 media query,就说明 @custom-variant dark 那行没生效

完成标志:你能不看文档就说清"切暗色要动几个地方、每个地方为什么动",并且能预见"如果我漏掉其中一处,哪个元素不会变"。再进一步:把视口从 1100 拉到 500,你能说清探针的 display 变了、而两个按钮的暗色值本就不该因为宽度变化而改变——因为宽度和暗色是两个独立维度。

故障注入

注入方式 观察
删掉 CSS 里的 @custom-variant dark (&:where(.dark, .dark *));,保留 class="dark" Tailwind 面板还变色吗——验证"没有这一行时,dark: 回退到系统偏好,class 白加了"
系统改成深色,但页面不加 dark class 面板是不是已经变暗了——验证默认 dark: 跟的是 prefers-color-scheme
只改 ConfigProvider theme.algorithm,不动 <html> 按钮变暗、面板不变——状态 ② 的镜像
只给 <html> 加 dark,不动 algorithm 面板变暗、按钮不变——状态 ② 原样
把 #rs 的 hidden md:flex 换成固定 hidden 或 flex 拉宽拉窄视口时 display 还跳不跳——验证响应式靠的是媒体查询而不是 class
产物里 grep prefers-color-scheme 有命中说明还在看系统;无命中才是真的 class 驱动

自测题

  1. 给 <html> 加 class="dark" 之后,页面肤色变了、两个 antd 按钮没变。请分别说出 Tailwind 侧和 antd 侧各自是被什么驱动的,以及为什么这两件事不是同一个开关。
  2. 不写 @custom-variant dark 时,dark:bg-slate-900 产出什么选择器?写完之后又产出什么?"产物里有没有 media query"为什么能当判据?
  3. 用户在页面里点"切暗色",但你的 dark: 还是默认的媒体查询版。为什么这个按钮"一点用都没有"?你要改哪一行才能让它有用?
  4. 一个页面同时有暗色和响应式两种状态。它们各自该用什么机制?(提示:一个是选择器,一个是媒体查询。)为什么响应式不能改成 class 驱动?
  5. "统一暗色开关"的正确定义是什么?请说明"把两个开关合并成一个"这个说法错在哪。
  6. #dk-primary 在状态 ① 里的 --ant-color-bg-container 本就是 #141414。这说明了什么——antd 的暗色是"跟着 <html> 变"的,还是"跟着 React 状态变"的?
  7. 把"暗色"这个思路套到"写作方向 dir"或"品牌色"上:你会先问自己哪三个问题来确定"要改几个地方"?

现在能解释什么

上一章:05 章 · 组件边界上的四种传参。下一章:07 章 · 交付面:体积、首屏与 SSR —— 暗色与响应式都定下来之后,接下来是这些样式怎么进到首屏:renderToString 出来的 HTML 里一个 <style> 都没有,得靠 extractStyle 补上。

进入 keel 阅读