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"),它同时决定两件事——
<html>上有没有dark这个 class(Tailwind 侧要的选择器条件);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 的消费者)。它们不是冗余,是因为两家根本不共享同一套机制(一个匹配选择器、一个重算变量表)。而"统一"不是把两个开关合成一个,是让同一个状态源同时喂给两边。
把这个模型往外推,同一个页面里还有别的"有两套系统"的状态:
- 暗色:Tailwind 的
dark:变体 vs antd 的theme.darkAlgorithm。本课的主角。 - 响应式:Tailwind 的
md:等断点 vs antd 组件自己的一些响应行为。上表说明,Tailwind 侧这一维只能靠媒体查询,不归 class 管。 - 写作方向(
dir/ 左右镜像):文字方向、逻辑属性(margin-inline-start之类)也常常是"工具类一套、组件一套",同样会出现"我给<html>设了dir,为什么只有一部分元素跟着翻转"。 - 品牌色:Tailwind 的
--color-*token vs antd 的--ant-color-*变量表。改一处不会自动同步另一处——这正是 05 章讲的"改的是值还是属性"在主题级的延伸。
共同的规律是每个状态都要先问一遍:"这套系统是靠选择器、靠媒体查询、还是靠一次重新计算?" 靠选择器的(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 媒体查询这条完全不同的路,所以它和暗色不是"同一个开关的两种接法",而是两个各自独立的维度。
生产边界
- 坐标是 antd 6.6.5 + tailwindcss 4.3.3 + React 19.3.0,HeadlessChrome 151。
oklch(0.208 0.042 265.755)、#1668dc、#141414这些数值是这个组合上的事实,换版本可能变;class="dark"驱动不了 antd 这个形状跨版本成立。 - "两套开关必须同源"是本章的可迁移结论,但具体怎么接要看你的框架:本课是手写 React 状态,真实项目里可能来自
useTheme之类的上下文、也可能来自路由或localStorage。关键不是用哪个 API,而是它有没有同时喂到两边。 @custom-variant dark那一行写在哪,决定了哪些类受影响:它是 Tailwind v4 的 CSS 内配置(不是tailwind.config.js),写在被@import "tailwindcss"的那个入口里。产物里还有没有 media query,是"有没有生效"的直接判据。- 本课没有验证的:SSR 首屏的暗色会不会闪(服务端不知道该给
<html>加什么 class,这属于 07 章的交付面);以及 antd 组件自身的响应行为(Table的scroll、Grid的断点)和 Tailwind 断点是否对齐——本章只量了hidden md:flex这一个探针。 - 实验全部是本机小页面、无网络:视口宽度用 Playwright 设定(500 / 1100),真实设备上的断点行为请以你的浏览器为准。
动手:可观察结果
自己造一个同时有 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 驱动 |
自测题
- 给
<html>加class="dark"之后,页面肤色变了、两个 antd 按钮没变。请分别说出 Tailwind 侧和 antd 侧各自是被什么驱动的,以及为什么这两件事不是同一个开关。 - 不写
@custom-variant dark时,dark:bg-slate-900产出什么选择器?写完之后又产出什么?"产物里有没有 media query"为什么能当判据? - 用户在页面里点"切暗色",但你的
dark:还是默认的媒体查询版。为什么这个按钮"一点用都没有"?你要改哪一行才能让它有用? - 一个页面同时有暗色和响应式两种状态。它们各自该用什么机制?(提示:一个是选择器,一个是媒体查询。)为什么响应式不能改成 class 驱动?
- "统一暗色开关"的正确定义是什么?请说明"把两个开关合并成一个"这个说法错在哪。
#dk-primary在状态 ① 里的--ant-color-bg-container本就是#141414。这说明了什么——antd 的暗色是"跟着<html>变"的,还是"跟着 React 状态变"的?- 把"暗色"这个思路套到"写作方向
dir"或"品牌色"上:你会先问自己哪三个问题来确定"要改几个地方"?
现在能解释什么
- 为什么给
<html>加class="dark"只会让页面变一半——Tailwind 的dark:是选择器(看祖先有没有dark),antd 的暗色是 React 用算法算出来的变量表,两者不共享机制; - 为什么"切换暗色"按钮会失灵——默认的
dark:绑的是prefers-color-scheme,而系统偏好不会被 class 改变;要 class 驱动必须写@custom-variant dark (&:where(.dark, .dark *));,写完产物里没有任何 media query; - 为什么"统一开关"不是合并开关——是让同一个状态源同时喂给两边(
<html>的 class 与ConfigProvider的algorithm),漏喂一半就亮一半; - 为什么响应式和暗色是两件不同的事——
hidden md:flex在 500 / 1100 下是none/flex,它靠的是媒体查询;而暗色可以(也应该)从媒体查询改成选择器; - 以及一条可以随身带的方法:面对任何一个"有两套系统"的状态,先问它靠选择器、靠媒体查询、还是靠一次重新计算——分清了这三类,就知道每一次切换要改几处。
上一章:05 章 · 组件边界上的四种传参。下一章:07 章 · 交付面:体积、首屏与 SSR —— 暗色与响应式都定下来之后,接下来是这些样式怎么进到首屏:renderToString 出来的 HTML 里一个 <style> 都没有,得靠 extractStyle 补上。