KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
05 · 组件边界上的四种传参 — keel 龙骨
这一章回答:给一个 antd 组件塞样式,一共有四种渠道(theme.token / styles / classNames / 自己写一条选择器),为什么有的生效、有的不生效?它们各自把改动落在了文档的哪一层,代价又分别是什么?
这一章回答:给一个 antd 组件塞样式,一共有四种渠道(theme.token / styles / classNames / 自己写一条选择器),为什么有的生效、有的不生效?它们各自把改动落在了文档的哪一层,代价又分别是什么?
一、现场:同一个 Button,四种改法,两个结果
实验页面 theme.html 上摆了五块面板——dflt(默认)、tok(ConfigProvider theme.token 改主色)、dkm(theme.darkAlgorithm)、fight(自己写一条选择器)、slot(走语义化插槽)。这五块面板一共注入了 30 个 <style>,文本总量 177543 B。每一块里都有一个长得一模一样的 <Button type="primary">。
然后量 computed background-color,结果是这样(原始数字与逐条候选规则见 E4.2):
| 手段 | 落在了哪里 | computed background-color |
元素上的 --ant-color-primary |
|---|---|---|---|
| 默认 | — | rgb(22, 119, 255) |
#1677ff |
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,另 --ant-color-bg-container=#141414、--ant-color-text=rgba(255,255,255,0.85) |
button.ant-btn[data-fight] { background-color: #dc2626 } |
未分层,权重 0,2,1 | rgb(220, 38, 38) |
不变(#1677ff) |
styles={{ root: { backgroundColor: "#dc2626" } }} |
元素内联 style | rgb(220, 38, 38) |
不变 |
classNames={{ root: "bg-red-600" }} |
类名落进 @layer utilities |
rgb(22, 119, 255) |
不变 |
六行里五行要么改对了、要么本来就对。只有最后一行最扎眼:你明明写了 bg-red-600,量出来却仍然是默认的那个蓝色。它不是"颜色写错了",是这条类名根本没参与进决定胜负的那一层。
先别急着记结论。你先判断一下:这六行里,哪几行"改了颜色",哪几行"改了 antd 认为主色是什么"? 答案不一样——这正是这一章要分开的两件事。token 那一行连元素上的 --ant-color-primary 都跟着变成了 #dc2626;而 fight、styles、classNames 三行,元素上的 --ant-color-primary 全是原封不动的 #1677ff,颜色之所以变了,靠的是别的手段。
二、四种渠道各自的落点
把上表的"落在了哪里"一列拆开看,四种渠道的落点完全不同。
ConfigProvider theme.token:新生成一张 381 项的变量表,不动选择器。
E4.1 量到:默认的 .css-var-root 表里有 381 个 --ant-* 变量(头几个是 --ant-blue=#1677FF、--ant-purple=#722ED1、--ant-cyan=#13C2C2);改一个 token 之后,多出来的不是"被覆盖的旧表",而是一张新的、同样 381 项的表,类名是 css-var-_r_1_。暗色算法也一样,换出来的是 css-var-_r_2_。
这一点在类名上看得最清楚(E1.2 与 E4.2)。三个按钮的类名逐字对照:
默认:ant-btn css-oc1rc0 css-var-root ant-btn-primary ant-btn-color-primary ant-btn-variant-solid
token: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
结构完全一样,只有变量表那个类名不同。 选择器没动、组件规则没动,动的只是这些规则里 var() 引用的那份表。所以 token 是"正道":它不与任何人抢 background-color 这条属性,它换的是 background-color 背后那个变量的值——而抢属性就是跟影子打架。
className / classNames:落成类名,走 Tailwind 的 @layer utilities。
slot-classes 面板量到的 class 原文是:
ant-btn css-oc1rc0 css-var-root ant-btn-primary ant-btn-color-primary ant-btn-variant-solid bg-red-600
末尾多出来的 bg-red-600 就是 classNames={{ root: "bg-red-600" }} 加上去的。它既不是新变量表、也不是内联样式,它就是一个普通的 Tailwind 原子类,落在 @layer utilities 里,和所有其它 Tailwind 类一起参与层叠。
styles:落成元素内联 style。
styles={{ root: { backgroundColor: "#dc2626" } }} 的结果非常直白——去读那个按钮元素的属性,上面直接写着 style="background-color: rgb(220, 38, 38);"(E4.2 实测原文)。它没有生成任何 CSS 规则,改动直接贴在元素身上。E2 的探针把它记成单独一档:[(内联 style)] spec=0,0,0 order=-1,位置在所有层之外、所有规则之上。
自己写一条更高权重的选择器:赢,但把变量体系绕过去了。
实验台的 src/app-theme.css 里额外写了一条:
button.ant-btn[data-fight] { background-color: #dc2626 }
fight-primary 的三条候选规则原文(E4.2):
[(未分层)] :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) }
那条自写规则的权重是 0,2,1(一个元素选择器 button 加一个类 .ant-btn 加一个属性 [data-fight]),比 antd 的 :where(.css-oc1rc0).ant-btn(0,1,0)高,又在未分层(未分层高于任何层),于是它赢了,computed 是 rgb(220, 38, 38)。
但它赢得很"局部"。同一行的最后一列:元素上的 --ant-color-primary 仍然是 #1677ff。 也就是说,只有这一个按钮的这一条 background-color 被改了,antd 的整套变量还在说"主色是蓝色"。今天你给主按钮改了底色,明天这个按钮的 hover 态、禁用态、以及它内部所有引用主色的地方,仍然是蓝色——因为你没有动那个"主色",你只是盖住了它的一次使用。
(顺带一个细节:这条规则靠 [data-fight] 属性选择器命中。它能落上去,是因为 Button 允许 [key: \data-${string}`]: string这类自定义data-*` 属性透传,见 E4.3。)
三、为什么 classNames 会输:不在权重,在层与顺序
slot-classes 那三条候选规则的原文(E4.2):
[(未分层)] :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 }
[utilities] spec=0,1,0 order=1323 .bg-red-600 { var(--color-red-600) }
把中间那列抠出来看:.bg-red-600 的权重是 0,1,0,antd 那条 :where(.css-oc1rc0).ant-btn 的权重也是 0,1,0——两者一模一样。:where() 的全部作用就是把里面那层选择器的权重归零,让 antd 愿意接受"被别人覆盖"。
所以 classNames 输不是因为它权重低。它输在层与顺序:未分层的规则优先于任何层;分层之间,后声明的层优先于先声明的层。antd 那条在未分层,.bg-red-600 在 [utilities] 层,顺序上排最后(order=1323 那个大数字救不了它,层账先于出现序)。完整的层序推演在 04 章——那一章会给出四页对照,其中 layer-first.html 上同样这两条规则一颠倒,胜负立刻反过来。
这也解释了一个常见的错误动作:看到类名不生效,就去加 !important 或者 Tailwind 的 ! 前缀。E2 量到 !bg-red-600 确实能赢(.\\!bg-red-600 带着 !important,胜出),但那是用强制手段跨过层序,代价是这条规则从此谁也盖不动了。先弄清谁在谁后面,比加感叹号值钱得多。
四、别把 hash 写进覆盖规则
上面 antd 的类名里有个 css-oc1rc0。它容易让人产生一个念头:"那我把 .css-oc1rc0.ant-btn 写进我的覆盖规则里,权重不就够了吗?"
先看这个 hash 的稳定性:同一主题、同一组件,这个 hash 在任何上下文里都一样。 E1.2 的判据是——index.html 上"没有 Provider 的 plain 面板"和"带 StyleProvider layer 的 layered 面板",两个按钮的类名字符串完全相同,所以两套注入出来的规则选择器也一样,互为对方的候选规则。
但它在 SSR 下会换一身衣服。E5.3 的两行对照:
| 环境 | renderToString HTML 里首个元素的 class |
|---|---|
| 开发态 | css-dev-only-do-not-override-oc1rc0 |
| 生产态 | css-oc1rc0 |
同一份源码、同一个 oc1rc0,前缀不同。所以你按生产态写死的 .css-oc1rc0.ant-btn { ... },在开发环境必然不命中;按开发态写死的规则,在生产环境必然不命中。hash 本身是"主题 + 组件"的函数:改一次 token、换一个组件版本,它都可能变。任何把 css-oc1rc0 这类 hash 抄进覆盖规则的做法,都是把一条随时会失效的约束焊进了代码里。 SSR 那两行是怎么量出来的,见 07 章。
五、语义化插槽:该往哪个渠道塞
antd 给 Button 开了一组"语义化插槽",node_modules/antd/es/button/button.d.ts 里的定义是(E4.3):
export type ButtonSemanticType = {
classNames?: { root?: string; icon?: string; content?: string };
styles?: { root?: React.CSSProperties; icon?: React.CSSProperties; content?: React.CSSProperties };
};
它把组件内部拆成 root / icon / content 三个可寻址的部位,你既可以只给 styles.root 一个内联样式,也可以只给 classNames.content 一个类名——而不是被迫在整个组件外面套一层容器去 !important。除了这组插槽,Button 还有 color / variant / shape / iconPlacement / rootClassName 等一等公民 props;它同时接受 [key: \data-${string}`]: string,也就是自定义 data-*属性可以直接透传到元素上——本节前面那条data-fight` 靠的就是这条。
据此,"该往哪个渠道塞"可以收成一张表:
| 你想做的事 | 往哪个渠道塞 | 理由 |
|---|---|---|
| 整站换主色 / 暗色模式 | ConfigProvider theme.token 或 theme.algorithm |
换的是变量表本身,所有引用主色的地方一起变 |
| 某一个按钮改底色、改内边距 | styles 插槽 |
落成内联样式,必赢,且只影响这一个实例 |
| 给组件加布局类、给内容加工具类 | classNames / className |
落成类名,参与层叠;布局类通常不和 antd 抢同一条属性 |
| 传业务自定义数据 | data-* |
一等公民,可直接透传 |
| 组件内部某个局部 | styles.icon / classNames.content |
语义化插槽,不必从外面套壳 |
判断表里最关键的一列是第二列"理由":先问这次改动是想换一个"值",还是想换一条"属性声明"。 想换值,走 token;想给一个具体实例加一条声明,走 styles 插槽。这两条能覆盖绝大多数场景,而且都不需要你关心层序。
六、改 antd 外观的判断顺序
把前面几节收成一条可执行的顺序——先 token,再 styles 插槽,然后才是自己写高权重选择器,最后才轮到用 classNames 传工具类:
theme.token/theme.algorithm。 代价:改动是全局的,且会多生成一张变量表(css-var-_r_1_或css-var-_r_2_),体积随之增加。适合"这就是我想要的新主题"。styles插槽。 代价:落成内联样式,改不动伪类状态(hover/disabled这些),也压不住子元素,只能管当前这个元素。- 自己写一条高权重选择器。 代价:得算权重、得确认它落在哪一层(见 04 章),而且它绕过变量体系——元素上的
--ant-color-primary不会跟着变,其它引用主色的地方仍是旧值。它只适合"我明确只要这一个属性、就这一次"。 classNames传 Tailwind 工具类。 排在最后,因为它落成的是@layer utilities里的类名,会参与层叠、会被未分层规则压住;它适合表达"不跟 antd 抢同一属性"的东西(布局、间距),而不是用来覆盖组件自己的颜色。
这条顺序背后的统一判据只有一句:你在改的是一个"值"(token),还是"某一条属性声明"(styles / 选择器 / 类名)? 前者和 antd 合作,后者和 antd 竞争——竞争就得先明白层与顺序,否则你遇到的就是本章开头那个"写了类名却没变色"的现场。把这四种渠道的落点摆在一张图上,就是下面这张。
本章脉络
flowchart TD
A["同一个 Button type=primary"] --> B["四种传参渠道"]
B --> C["① theme.token<br/>新生成 381 项变量表 css-var-_r_1_"]
B --> D["② styles.root<br/>落成元素内联 style"]
B --> E["③ 自写选择器<br/>button.ant-btn data-fight 权重 0,2,1"]
B --> F["④ classNames.root<br/>落成类名 bg-red-600"]
C --> G["不动选择器<br/>只换 var 的值 元素上主色也变"]
D --> H["内联样式最高档<br/>computed 生效 只此一个元素"]
E --> I["未分层加高权重<br/>computed 生效 但主色仍是 #1677ff"]
F --> J["落进 utilities 层<br/>权重同为 0,1,0 却排在最后"]
J --> K["computed 仍是 rgb 22 119 255 失败"]
F -.-> L["写死 hash css-oc1rc0<br/>开发态与生产态前缀不同 必失效"]
图里在说什么。 从上往下:四条渠道都从同一个 Button 出发,但落点各不相同——左边两条(token 与自写选择器)改完之后元素上的"主色"这个观念还在,右边两条(styles 与 classNames)只是给这一个元素加了或想加一条声明。①③④ 都是"改动生效但含义不同":① 换了整张变量表、③ 盖住了一条属性、② 贴了一条内联。只有一个出口是红的——④ 落成类名之后,虽然权重和 antd 相同,却因为进了 utilities 层而排在了未分层规则后面,computed 没变。右下那条虚线是另一个陷阱:把 css-oc1rc0 写死进覆盖规则,在开发态与生产态之间必然有一边对不上。
生产边界
- 本课的坐标是 antd 6.6.5 + tailwindcss 4.3.3 + React 19.3.0,浏览器是 Playwright 自带的 HeadlessChrome 151。
css-oc1rc0、css-var-_r_1_这些类名与变量表条数(381 / 58 / 18 / 37)是这个组合上的事实,换版本可能变。 styles落成内联样式,classNames落成类名 这个形状跨版本成立;ButtonSemanticType的键名(root/icon/content)是 6.6.5 的button.d.ts原文,版本升级前值得重读一遍类型定义。- token 不是"覆盖 381 个变量",是"多生成一张表":E4.1 量到默认表 381 项、token 面板 381 项、暗色面板 381 项,三套主题同页共存、互不影响(
theme.html五块面板共注入 30 个<style>、177543 B)。所以"多主题共存"是这套模型的天然结果,不是需要额外配置的能力。 - 本课没有单独验证的:
color/variant/shape/iconPlacement/rootClassName这几个 props 各自的产出(它们出现在button.d.ts里,但本章只实测了styles/classNames/token/ 自写选择器四条路径)。要用它们之前,请在自己的版本上量一次computed。 - 实验全部是本机小页面、无网络:凡涉及字节数的地方请只看比值和形状,不要抄绝对值。
动手:可观察结果
自己复现本章那张六行表,每一步都要拿到 computed background-color 和元素上的 --ant-color-primary 两个值。
| 产出 | 判断标准 |
|---|---|
一个 <Button type="primary"> 的默认 computed |
你能量出 rgb(22, 119, 255),且元素上 --ant-color-primary 是 #1677ff |
换成 theme.token 后的两个值 |
background-color 变成 rgb(220, 38, 38),并且元素上的 --ant-color-primary 也变成 #dc2626——两个都变才算走对了 token |
换成 styles.root / classNames.root 后的两个值 |
background-color 一个生效一个不生效,而两者的 --ant-color-primary 都不变 |
| 一条自写选择器 | 你能说出它的权重是几位数、落在哪一层、为什么能赢过 antd |
| 一份"该往哪个渠道塞"的判断 | 随便给你一个改动需求,你能先说出它改的是"值"还是"一条属性声明" |
完成标志:面对"我的类名加上去了没生效",你能不看文档就说清它是落在哪一层、被谁压住,而不是下意识去加 !important。给你一个具体需求(比如"只把提交按钮改成红色、别的按钮不动、hover 也要跟着红"),你能立刻指出:这个需求单靠 styles 插槽够不够——hover 是伪类,内联样式管不到它,所以你得回到 token 或层序上来。
故障注入
| 注入方式 | 观察 |
|---|---|
把 classNames={{ root: "bg-red-600" }} 换成 className="bg-red-600" |
落在同一个 @layer utilities,结果是否一样——验证"两条入口、同一个落点" |
给类名加 Tailwind 的 ! 前缀(!bg-red-600) |
结合 E2 的实测,这条强制规则是否赢过未分层的 antd,代价是什么 |
把 styles.root 里的 backgroundColor 换成一个伪类上才生效的写法 |
内联样式能不能表达 hover / disabled——验证 styles 插槽的边界 |
在覆盖规则里写死 .css-oc1rc0.ant-btn,然后切到开发态(或切到生产态) |
类名前缀变了之后,这条规则还命中吗 |
用 token 把主色改成红色,再 computed 一次按钮的 hover 背景与边框色 |
这些引用主色的地方有没有一起变——验证"改的是值还是属性" |
自测题
- 同样一个
<Button type="primary">,theme.token和styles.root都让background-color变成了rgb(220, 38, 38)。请说出这两个"成功"有什么本质不同,并用元素上的--ant-color-primary来证明。 classNames={{ root: "bg-red-600" }}里的.bg-red-600权重是 0,1,0,antd 的:where(.css-oc1rc0).ant-btn也是 0,1,0。既然如此,为什么前者会输?如果你只盯着权重,会得出什么错误的修法?- 有人告诉你"把
.css-oc1rc0抄进覆盖规则就能提高命中率"。按 E5.3 的实测,这句话在什么环境下会失效?为什么说这个 hash 是"主题加组件的函数"? styles落成元素内联样式,所以它必赢。那为什么它仍然是"最后才考虑局部覆盖"的手段而不是首选?请至少说出两个它管不到的东西。- 你要给一个按钮加
margin(间距),会用哪个渠道?要改它的悬停底色,又会回到哪个渠道?两次回答的差别是什么? Button的classNames和styles都支持root/icon/content三个键。这个设计把组件拆开的目的是什么?和"在组件外面套一个div再写!important"相比,它少付出了什么代价?
现在能解释什么
- 为什么同一个
Button的四种改法里,classNames是唯一静默失败的那个——它落成的是@layer utilities里的类名,权重和 antd 相同,但排在了未分层规则后面; - 为什么
theme.token是"正道"——它新生成一张 381 项的变量表(css-var-_r_1_),换的是var()的值,不跟任何人抢属性; - 为什么
styles一定赢、却一定只影响一个元素——它落成元素内联样式,处在所有层之外,同时也够不到伪类状态; - 为什么自写高权重选择器能赢、代价却最大——它绕过变量体系,
--ant-color-primary不会跟着变,其余引用主色的地方仍是旧色; - 为什么不能把
css-oc1rc0写死进覆盖规则——开发态是css-dev-only-do-not-override-oc1rc0、生产态是css-oc1rc0,写死就必然有一边失效; - 以及一条可以随身带的判断:这次改动改的是一个"值"还是"一条属性声明"? 改值走 token,加声明走
styles插槽,其余的都要先过一遍层与顺序(04 章)。
下一步:06 章 · 暗色与响应式:一个开关还是两个 —— 现在你知道四种渠道各自落在哪里。接下来把"状态"叠上去:给 <html> 加一个 class="dark",为什么页面变了一半、另一半纹丝不动。