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 传工具类:

  1. theme.token / theme.algorithm。 代价:改动是全局的,且会多生成一张变量表(css-var-_r_1_ 或 css-var-_r_2_),体积随之增加。适合"这就是我想要的新主题"。
  2. styles 插槽。 代价:落成内联样式,改不动伪类状态(hover / disabled 这些),也压不住子元素,只能管当前这个元素。
  3. 自己写一条高权重选择器。 代价:得算权重、得确认它落在哪一层(见 04 章),而且它绕过变量体系——元素上的 --ant-color-primary 不会跟着变,其它引用主色的地方仍是旧值。它只适合"我明确只要这一个属性、就这一次"。
  4. 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 写死进覆盖规则,在开发态与生产态之间必然有一边对不上。

生产边界

动手:可观察结果

自己复现本章那张六行表,每一步都要拿到 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 背景与边框色 这些引用主色的地方有没有一起变——验证"改的是值还是属性"

自测题

  1. 同样一个 <Button type="primary">,theme.token 和 styles.root 都让 background-color 变成了 rgb(220, 38, 38)。请说出这两个"成功"有什么本质不同,并用元素上的 --ant-color-primary 来证明。
  2. classNames={{ root: "bg-red-600" }} 里的 .bg-red-600 权重是 0,1,0,antd 的 :where(.css-oc1rc0).ant-btn 也是 0,1,0。既然如此,为什么前者会输?如果你只盯着权重,会得出什么错误的修法?
  3. 有人告诉你"把 .css-oc1rc0 抄进覆盖规则就能提高命中率"。按 E5.3 的实测,这句话在什么环境下会失效?为什么说这个 hash 是"主题加组件的函数"?
  4. styles 落成元素内联样式,所以它必赢。那为什么它仍然是"最后才考虑局部覆盖"的手段而不是首选?请至少说出两个它管不到的东西。
  5. 你要给一个按钮加 margin(间距),会用哪个渠道?要改它的悬停底色,又会回到哪个渠道?两次回答的差别是什么?
  6. Button 的 classNames 和 styles 都支持 root / icon / content 三个键。这个设计把组件拆开的目的是什么?和"在组件外面套一个 div 再写 !important"相比,它少付出了什么代价?

现在能解释什么

下一步:06 章 · 暗色与响应式:一个开关还是两个 —— 现在你知道四种渠道各自落在哪里。接下来把"状态"叠上去:给 <html> 加一个 class="dark",为什么页面变了一半、另一半纹丝不动。

进入 keel 阅读