KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

03 · antd:运行时生成与一张变量表 — keel 龙骨

这一章回答:同一个页面里三套主题为什么能共存、改一个 token 到底改了什么——以及 antd 的按钮明明和 Tailwind 的类名一样重,凭什么压得住它。

这一章回答:同一个页面里三套主题为什么能共存、改一个 token 到底改了什么——以及 antd 的按钮明明和 Tailwind 的类名一样重,凭什么压得住它。

现场:三块面板、三套主题、一模一样的类名

一个页面里并排放三块面板,每块里都有一个主按钮:

三套主题在同一页里共存,互不影响:第一块是蓝的,第二块按你改的那个色走,第三块是暗色的。看起来天经地义。现在把三个按钮的 class 字符串并排抄下来:

ant-btn css-oc1rc0 css-var-root   ant-btn-primary ant-btn-color-primary ant-btn-variant-solid
ant-btn css-oc1rc0 css-var-_r_1_  ant-btn-primary ant-btn-color-primary ant-btn-variant-solid
ant-btn css-oc1rc0 css-var-_r_2_  ant-btn-primary ant-btn-color-primary ant-btn-variant-solid

除了中间那一个 css-var-* 类名,三行一模一样。

先别往下翻,给出你的预测: 类名结构相同,意味着它们命中同一批规则;那"三套主题不打架"是靠什么实现的?是本块面板被套了某种作用域隔离(比如 iframe、Shadow DOM)?还是每个按钮被单独写了一套选择器?还是……它们其实共用同一套规则,只有变量值不同?

答案是第三种。这一章就把这条"共用规则、只换变量"的机制拆开——它同时也是这门课里所有"类名加对了却没生效"的前提。

一、样式在组件渲染时被注入 <style>

先看"什么时候变成 CSS"。Tailwind 那条线你已经在 02 章 见过:构建期编译出一份 .css,页面用一份 <link> 引进来。antd 不是。它没有静态的样式文件,样式是在组件渲染的那一刻被注入 <style> 的。

在一个只有一个默认按钮的极简页面上(plain-only.html,没有任何 StyleProvider),head 的子元素顺序是这样的:

STYLE ×18   META   TITLE   LINK[./tailwind.css]

十八个 <style>,全部 prepend 到 head 开头,位置在 <link> 之前。这不是"顺手插进去",而是刻意的顺序选择——先来的在文档更前,层叠里更早;至于它对这个顺序的依赖到底有多深,是 04 层序 那一章的主线。

这十八个 <style> 不是"一个组件一份",也不完全对得上组件个数。把每个的开头抄下来,能看出它的组织方式:

# rules 首条规则
0 1 .css-var-root { --ant-blue: #1677FF; … }
1 21 a:where(.css-oc1rc0) { color: var(--ant-color-link); … }
2 1 @keyframes css-oc1rc0-loadingCircle
3 8 :where(.css-oc1rc0).ant-wave { … }
4 99 :where(.css-oc1rc0).ant-btn { … }
5 1 .css-var-root.ant-btn { --ant-button-blue-shadow-color: … }
6 103 :where(.css-oc1rc0).ant-input { box-sizing: border-box; }
7 1 .css-var-root.ant-input { … }
8 84 :where(.css-oc1rc0).ant-input-css-var { … }
9 1 .css-var-root.ant-input-css-var { … }
10 34 :where(.css-oc1rc0).ant-spin { … }
11 1 @keyframes css-oc1rc0-antRotate
12 1 @keyframes css-oc1rc0-antSpinMove
13 1 .css-var-root.ant-spin { … }
14 198 :where(.css-oc1rc0).ant-table-css-var { … }
15 1 .css-var-root.ant-table-css-var { … }
16 7 .anticon { … }
17 1 @keyframes loadingCircle

读三条出来:

再看类名那一侧。同一主题、同一组件,那个 css-oc1rc0 的 hash 在任何上下文里都一样;index.html 上"无 Provider 的 plain 面板"与"StyleProvider layer 的 layered 面板"两个按钮的类名字符串完全相同。这是一件必须提前知道的事:类名相同,就意味着它们的那批规则选择器也一样——两个面板互为对方的候选规则。 所以"两套配置能不能互不影响",从来不是靠"类名不同"保证的。

二、改 token 不是覆盖变量,是多生成一张表

现在看那类 .css-var-* 的 <style>。先说它的规模:默认主题下,.css-var-root 里有 381 个 --ant-* 变量(头几个是 --ant-blue: #1677FF、--ant-purple: #722ED1、--ant-cyan: #13C2C2)。这 381 个就是 antd 一整套设计令牌的默认值。

真正反直觉的是"改一个 token 之后发生什么"。把三块面板的变量表列出来:

变量表选择器 变量条数 什么时候出现
.css-var-root 381 默认主题
.css-var-root.ant-btn 58 用到按钮时
.css-var-root.ant-input 18 用到输入框时
.css-var-root.ant-input-css-var 18 同上
.css-var-root.ant-spin 4 用到 Spin 时
.css-var-root.ant-table-css-var 37 用到表格时
.css-var-_r_1_(token 面板) 381 ConfigProvider theme.token 生效后
.css-var-_r_1_.ant-btn 58 同上
.css-var-_r_2_(暗色算法面板) 381 darkAlgorithm 生效后

看 .css-var-root 是 381,.css-var-_r_1_ 也是 381。这就是这一章最该记住的一句话:

改 token 不是"覆盖那 381 个变量",而是"多生成一整张 381 个变量的表",并且给它换一个类名。

对应到类名上就顺理成章了:改了 token 的按钮多一个类 css-var-_r_1_,用了 darkAlgorithm 的多一个类 css-var-_r_2_。两块面板的按钮吃到的是两张不同的变量表,规则却共用同一批。 于是三套主题能在同一页里共存、互不影响——它们不是三份 CSS,是同一批规则 + 三张变量表。

把这条对照回第一节的"每个组件一张表",可以得到一个可以直接用的判断:

所以"我页面上 antd 注入的样式特别大"有两种完全不同的成因,查法也不同:是组件多(组件表多),还是主题配置多(变量表多)。 数一下 <style> 里 .css-var- 开头的有几张,就能分开。

补一个容易踩的点:新生成的变量表并不覆盖旧表,旧表也还在页面上。 也就是说,同一个页面上同时存在 .css-var-root、.css-var-_r_1_、.css-var-_r_2_ 三张 381 项的表——这也是为什么"多主题"在那个页面上会明显变重。它不是"借用",是"并存"。

三、颜色真正住在变量里:抢 background-color 是在跟影子打架

第二节说的是变量表本身。这一节说这些变量怎么作用到按钮颜色上。

第三块面板(darkAlgorithm)的主按钮,元素上量出来的值是:

computed background-color = rgb(22, 104, 220)
元素上 --ant-color-primary = #1668dc
元素上 --ant-color-bg-container = #141414
元素上 --ant-color-text = rgba(255, 255, 255, 0.85)

第一块(默认)是 rgb(22, 119, 255)、--ant-color-primary = #1677ff。也就是说:"按钮变暗色了"在数据上是"这些 --ant-* 变量的值换了",不是"多了一条 background-color 规则"。

再往里一层。一份只想知道"按钮背景是哪条规则给的"的记录里,默认主按钮命中背景声明的规则只有两条:

[(未分层)] :where spec=0,1,0 order=161  :where(.css-oc1rc0).ant-btn   { var(--ant-btn-bg-color) }
[base] spec=0,1,6 order=1195  button, input, select, …   { transparent }

看第一条:它写的不是颜色,是 var(--ant-btn-bg-color)。而 --ant-btn-bg-color 这个变量自己的来源,又是元素自身上的另一条规则:

[(未分层)] spec=0,2,0 order=82  :where(.css-oc1rc0).ant-btn.ant-btn-variant-solid
      { --ant-btn-bg-color: var(--ant-btn-solid-bg-color) }
[(未分层)] spec=0,1,0 order=79  :where(.css-oc1rc0).ant-btn   { --ant-btn-bg-color: #ddd }

一层套一层。 你在 DevTools 里"抓按钮的 background-color",抓到的是一个指向变量的声明;那个变量自己又指向另一个变量。所以这条经验值得记死:

antd 的颜色真正存在 CSS 变量里。你去抢 background-color,是在跟一个影子打架——就算你赢了那条 background-color 声明,也只能改这一次,改不动那 381 个变量背后的整套语义(hover、active、disabled、暗色……都是从同一批变量推出来的)。

这也解释了第四节的判断顺序为什么是"先改 token":改 token 改的是源头,抢选择器改的是末端。

四、:where() 把权重压到 0,1,0——那 antd 靠什么赢

上一节那条 :where(.css-oc1rc0).ant-btn,它的权重是 0,1,0。这不是笔误::where() 的语义就是"里面的选择器不贡献权重",所以整条只剩 .ant-btn 那一个类撑着的 0,1,0。

而 Tailwind 的 .bg-red-600 是 0,1,0,:where(.css-oc1rc0).ant-btn 也是 0,1,0——完全一样。

那次专门量过的对照里,一个既带 antd 按钮类、又加了 bg-red-600 的元素,命中的两条规则是:

1. ★ [(未分层)] antd-cssinjs   :where(.css-oc1rc0).ant-btn   { var(--ant-btn-bg-color) }
2.   [utilities] spec=0,1,0 order=641 <tailwind.css>   .bg-red-600   { var(--color-red-600) }
computed 仍是 rgb(22, 119, 255) —— Tailwind 输了

两条规则权重一样,order 一个是 79 一个是 641。赢了的是 order 更小的那条——也就是排在文档更前面的那条。而 04 章 会给出更根本的那条规则:未分层的规则优先级高于任何层,所以住在 utilities 层里的 .bg-red-600 天然输给未分层的 antd 规则。

所以这道题的答案不是"antd 选择器更狠",而是:antd 主动把自己的权重归零了,赢是靠"未分层 + 位置在前"这两件事。(第一块面板那条 [base] 的 button, input, select, … { transparent } 权重反而是 0,1,6,但它住在层里、又输给未分层——同样是"层与顺序"在起作用。)

那 antd 为什么要主动把自己的权重压到 0,1,0?因为这是它给使用者的一个承诺:"我不靠权重压你,你可以用层和顺序来覆盖我。" 如果 antd 用 button.ant-btn 这种 0,2,1 的选择器,你想覆盖它就得写一条更狠的——可当所有人都被迫往上加权重时,样式表最终会变成"比谁 !important 更多"的军备竞赛。:where() 把这把钥匙交了出来:想覆盖,去排层,别去加权重。 这也是 04 章 那个"排层修法"能成立的前提。

五、五种改样式的手段,五种落点

把"改一个按钮颜色"这件事能用的手段全试一遍,落在页面上的位置完全不同:

手段 落在了哪里 computed background-color 元素上的 --ant-color-primary
默认 — rgb(22, 119, 255) #1677ff
ConfigProvider theme={{token:{colorPrimary:"#dc2626"}}} 新变量表 css-var-_r_1_ rgb(220, 38, 38) #dc2626
theme={{algorithm: theme.darkAlgorithm}} 新变量表 css-var-_r_2_ rgb(22, 104, 220) #1668dc
button.ant-btn[data-fight] { background-color: #dc2626 }(未分层,权重 0,2,1) 未分层,order=1385 rgb(220, 38, 38) ✅ 赢过 antd 的 0,1,0 不变(#1677ff)
styles={{ root: { backgroundColor: "#dc2626" } }} 变成元素内联 style rgb(220, 38, 38) ✅ 不变
classNames={{ root: "bg-red-600" }} 类名加上 bg-red-600,落在 @layer utilities order=1323 rgb(22, 119, 255) ❌ 被 antd 压掉 不变

六行里最该分别记的是后三行。

第四行(提权重):硬写一条 button.ant-btn[data-fight],权重 0,2,1、又未分层——它赢了。但注意它改的只是这一个属性:--ant-color-primary 没变(还是 #1677ff)。也就是说,颜色是变红了,可 hover、active、disabled 那一整套仍然从旧变量推——你只赢了表面上那一下。它命中的三条候选原文是:

[(未分层)] :where spec=0,1,0 order=161  :where(.css-oc1rc0).ant-btn   { var(--ant-btn-bg-color) }
[base] spec=0,1,6 order=1195  button, input, select, …   { transparent }
[(未分层)] spec=0,2,1 order=1385  button.ant-btn[data-fight]   { rgb(220, 38, 38) }

第五行(styles 插槽):styles={{ root: … }} 的结果是元素上多了一句内联 style background-color: rgb(220, 38, 38);,于是它赢了(内联样式高于任何选择器)。同样,--ant-color-primary 不变——内联是"这一处生效",不是"这套语义改掉了"。

第六行(classNames 插槽):classNames={{ root: "bg-red-600" }} 的结果是把 bg-red-600 加进了类名列表,那条规则落在 @layer utilities、order=1323,然后输了——computed 还是 rgb(22, 119, 255)。为什么?因为 classNames 传的是一批 Tailwind 类名,而 Tailwind 的原子类住在 utilities 层里,层打不过"未分层"。这条特别值得记住:一样是"给按钮传个东西",styles 落成内联(能赢),classNames 落成类名(要受层序管辖,可能输)。 这也直接呼应了 05 组件边界 里"四种传参各自什么时候生效"。

于是有一条可执行的判断顺序:

  1. 改 token(正道)——ConfigProvider theme.token 或 theme.algorithm。你改的是那 381 个变量里的源头,hover / active / disabled / 暗色一起跟着变。要"整体换一套观感",只有这条路是对的。
  2. 局部覆盖用 styles 插槽——只针对某个组件的某个部位做一次性调整,落成内联样式,不受层序影响。代价是它只覆盖你写的那一条声明,改不到语义。
  3. classNames 传 Tailwind 类名是最后才考虑的——因为它要受层序管辖,赢不赢取决于你排了层没有(04 章 的四页对照就是量这件事的)。在你没排层之前,别指望它。

顺带把传参的键名记下来。button.d.ts 里给的语义化插槽是:

export type ButtonSemanticType = {
  classNames?: { root?: string; icon?: string; content?: string };
  styles?: { root?: React.CSSProperties; icon?: React.CSSProperties; content?: React.CSSProperties };
};

也就是 root / icon / content 三个部位,classNames 与 styles 各有一套。另外它还有 color / variant / shape / iconPlacement / rootClassName 这些一等公民 props,以及 [key: `data-${string}`]: string——自定义 data-* 属性可以直接透传(上面那行 data-fight 就是靠这条挂上去的)。知道这一条,你就能给组件挂稳定的钩子、又不必去赌 css-oc1rc0 这种会随环境变的 hash。

本章脉络

flowchart TD
  A["antd 组件渲染"] --> B["cssinjs 注入 style"]
  B --> C["prepend 到 head 开头<br/>位置在 link 之前"]
  C --> D["每个组件一张表<br/>.ant-btn 99 条规则<br/>.ant-input 103 条"]
  B --> E["变量表 .css-var-root<br/>381 个 --ant-*"]
  A --> F["ConfigProvider"]
  F -->|"改 token"| G["多生成一张 381 项的表<br/>css-var-_r_1_"]
  F -->|"darkAlgorithm"| H["新表 css-var-_r_2_"]
  G -.-> I["三套主题共存<br/>互不影响"]
  H -.-> I
  E -.-> I
  B --> J[":where 把权重压到 0,1,0"]
  J --> K["和 .bg-red-600 一样重"]
  K -.-> L["antd 赢不靠权重<br/>靠未分层与位置"]
  A --> M["改样式的手段"]
  M -->|"token"| N["正道,整套语义都变"]
  M -->|"styles 插槽"| O["落成内联,赢"]
  M -->|"classNames 插槽"| P["落成类名<br/>在 utilities 层,输"]

图里在说什么。 顶上那个方框是这一章的起点——antd 的样式不是构建期生成的,是组件渲染时注入的。往下一路分两支:左边是"注入了什么"(组件规则表 + 变量表),右边是"这些规则凭什么赢"。关键在于中间那三条虚线都汇到同一个结论:三套主题能共存,靠的是"同一批规则 + 多张变量表";而它们对 Tailwind 能赢,靠的是未分层与位置,不是权重。最下面那支是三种改样式手段的落点——同一句"给按钮传个东西",落成内联的能赢,落成类名的要排队。

生产边界

动手:可观察结果

挑一个你手上真实的 antd 页面,把下面五件事各做一遍。每一步的答案都要来自浏览器里的量测,不能来自推断。

  1. 数一数 <style> 与变量表。 打开 DevTools,在 <head> 的子元素里数 STYLE 的个数,并确认它们是不是都在 <link> 之前。再数一下其中 .css-var- 开头的有几张——组件表几张、变量表几张,分开报数。
  2. 抄两个按钮的 class。 找一个默认主题的按钮、一个套了 ConfigProvider 的按钮,把两串 class 并排抄下来。除了 css-var-* 那一个类,还有别的差别吗?
  3. 看颜色到底存在哪。 对着一个按钮量 getComputedStyle(el).getPropertyValue('--ant-color-primary'),再改一次 token.colorPrimary 重新量。变的到底是 background-color 那条规则,还是这个变量?
  4. 比一次"权重相同、结果不同"。 给一个按钮加上 className="bg-red-600",在 DevTools 里看它命中了哪两条规则、各自的权重与来源。再加同板块的对比:给它改成内联 style,结果一样吗?
  5. 按判断顺序走一遍。 想把某个按钮改成红色,依次用(a)token.colorPrimary、(b)styles.root、(c)classNames.root 三种做法各改一次,各自量 computed background-color 与元素上的 --ant-color-primary,把三组结果记下来。

完成标志:拿到"按钮颜色改不动"的反馈时,你能先问出那个正确的问题——我要改的是"这一处的颜色",还是"这套语义里的主色"? 前者用 styles,后者只能改 token;而"我加了个 Tailwind 类名却没生效"这句话,能立刻接上"它进的是 utilities 层,得先看层序"。

故障注入

三种注入方式,每一种都有明确的观察点。先写预测,再跑。

注入 怎么做 观察什么 期望行为
把 token 改掉 给一块面板套 ConfigProvider theme={{token:{colorPrimary:"#dc2626"}}} 注入的 <style> 数量、按钮的 class 多出一整张 381 项的变量表(新类名 css-var-_r_1_),旧表还在——不是覆盖,是并存
用 classNames 传 Tailwind 类名 给按钮加 classNames={{ root: "bg-red-600" }} computed background-color 不变(仍是 antd 的颜色)——类名落进 utilities 层,被未分层的 antd 规则压住
同样的 bg-red-600 换成立即生效的写法 改成 styles={{ root: { backgroundColor: "#dc2626" } }} 元素上有没有内联 style、computed 元素上出现 background-color: rgb(220, 38, 38);,computed 变成 rgb(220, 38, 38)——内联能赢
提权重硬盖 在 CSS 里写 button.ant-btn[data-fight] { background-color: #dc2626 }(未分层) 谁赢、--ant-color-primary 变没变 赢了,但**--ant-color-primary 不变**——你只改了表面那一条声明
改一套主题配置 再加一块面板用 theme.darkAlgorithm 变量表张数、三块面板是否互相影响 再多一张表(css-var-_r_2_),三块互不影响——这就是"三套共存"的机制

最后一种注入之后别急着收。让三块面板同时留在页面上,然后问自己一个成本问题:如果换成"每个用户可切换的四套主题",页面上会有几张变量表?"共存"的代价是可以数出来的。

自测题

  1. 同一页里三套主题的按钮,class 只有中间那一个 css-var-* 不同。请用一句话说明"三套主题互不影响"是靠什么实现的。
  2. plain-only.html 的 <head> 顺序是 STYLE ×18 META TITLE LINK[./tailwind.css]。为什么这些 <style> 出现在 <link> 之前,而不是之后?
  3. .ant-btn 那张表里有多少条规则?这个数字跟"你渲染了几个按钮"有关,还是跟"你渲染了几种组件"有关?同理,.css-var-root 是 381 项——它跟什么有关?
  4. 改一次 token.colorPrimary,页面多了一张 .css-var-_r_1_(也是 381 项)。请说明"覆盖"与"多生成一张"这两件事在观测上的区别,以及各自会带来什么后果。
  5. :where(.css-oc1rc0).ant-btn 的权重和 .bg-red-600 一样都是 0,1,0。那 antd 到底凭什么赢?请再补一句——"提权重去盖它"为什么是个坏主意。
  6. 同为"给按钮传个东西",styles={{ root: … }} 赢了、classNames={{ root: "bg-red-600" }} 输了。请说明两者的落点差异,以及各自受不受"层序"影响。
  7. 一个同事写了一条 .css-oc1rc0.ant-btn { background-color: red } 的覆盖样式。请说出它至少两处会出问题的地方(提示:环境差异 + 变量语义)。
  8. 想在按钮上挂一个自己的选择器钩子,又不想依赖会变的 hash。button.d.ts 给了哪两条路?

现在能解释什么

下一步:04 章 · 层序:谁赢是有先后规则的——这一章反复提到"未分层与位置",但没给出完整的规则;下一章用四页对照把"谁赢"这件事变成一套可以逆推的判据,也顺手回答本章留下的那个悬念:classNames 传的类名,到底要满足什么条件才能赢。

进入 keel 阅读