KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
04 · 层序:谁赢是有先后规则的 — keel 龙骨
这一章回答:当两套样式都想改同一个属性,"谁赢"到底是怎么算出来的——以及为什么加上那个号称"解决冲突"的开关之后,量出来的颜色可能一个像素都没变。
这一章回答:当两套样式都想改同一个属性,"谁赢"到底是怎么算出来的——以及为什么加上那个号称"解决冲突"的开关之后,量出来的颜色可能一个像素都没变。
现场:加上了 StyleProvider layer,然后什么都没变
上一章结尾留下的按钮,你已经知道 antd 是未分层的、Tailwind 的类在 @layer utilities 里,所以 antd 赢。那么"标准答案"似乎唾手可得——antd 自己提供了分层的开关:
import { StyleProvider } from "@ant-design/cssinjs";
<StyleProvider layer>
<Button type="primary" className="bg-red-600">主按钮</Button>
</StyleProvider>
加上它,再量一次:
computed background-color = rgb(22, 119, 255)
还是 antd 的蓝。一个像素都没变。
这就麻烦了。"加开关"是最容易想到的修法,而它没有效果——如果你在这里停下,很可能会得出"这个开关没用"的结论,然后回去加 !important。这一章要做的,是把"为什么没用"和"怎么才算用对了"两件事都量清楚。
先看一眼加了 layer 之后,页面上到底发生了什么变化。把 @layer 的声明顺序打出来:
@layer 声明顺序 = ["properties","theme","base","components","utilities","antd"]
antd 排在最后。 也就是说,layer 这个开关确实把 antd 的样式放进层里了(@layer antd { … }),但它放进的是层序最末的位置——而"层序越靠后,普通声明越强"。这个开关不是把 antd 关进笼子,而是给 antd 发了张最后出场的牌。
要理解为什么是这个结果、以及怎么才能真正控制它,得先把层叠这套规则完整地摆出来。
一、层叠的完整判定顺序
浏览器在决定"某条声明到底用不用"时,是按固定顺序逐级比较的。把它列成一张表,从强到弱:
| 级别 | 比什么 | 说明 |
|---|---|---|
| 1 | 声明的来源与重要性 | 用户代理 < 用户 < 作者;但带 !important 的一律排在普通声明前面(包括上面这些相对顺序也会反转) |
| 2 | 层:未分层 > 任何层 | 只要一个是未分层、一个在层里,未分层无条件赢——权重、位置都不看 |
| 3 | 层之间:后声明的层 > 先声明的层 | 层的先后由它第一次出现的位置决定,不是由块的位置决定 |
| 4 | 权重 | 就是 #id .class element 那一套 [a,b,c] |
| 5 | 出现顺序 | 同权重同层,谁写在后面谁赢 |
这张表里有两处在"权重"上方,第 2 级和第 3 级都跟层有关。 这就是为什么"加 !important"和"把选择器写狠一点"这两条常见直觉,在这个场景里都是在下游发力。
而第 1 级那句话还有一个反转规则,很多资料只讲前半句:
!important不只是"排到最前面",它还会把第 2、3 级的层序整个反转。
反转是什么效果,本课用两个对照元素量过(都在层序为 properties < theme < base < components < antd < utilities 的页面上):
| 探针 | 挂的两条规则 | 谁赢 | computed |
|---|---|---|---|
未分层 !important vs @layer utilities 的 !important |
权重 0,1,0 vs 0,0,1 | @layer utilities |
oklch(0.577 0.245 27.325) |
@layer base 的 !important vs @layer utilities 的 !important |
权重 0,1,0 vs 0,0,1 | @layer base |
rgb(14, 165, 233) |
两行合起来看:
- 普通声明里未分层最大;带
!important之后,层里的大。 所以"未分层 > 任何层"这条在 important 世界里是反过来的。 - 普通声明里后声明的层更大;带
!important之后,先声明的层大。 上表第二行里base(更早)赢了utilities(更晚)。 - 而且整个过程中权重都被压在后面:第一行里
0,0,1赢了0,1,0。
这三条合起来,就是"加了 ! 就管用"的真实原因——它跳过的不只是权重,是整张表的第 2、3 级。
二、layer 开关到底把什么放进了层
回到那个"没变化"的实验。既然 antd 层排在最后(层间最强),按第 3 级规则,antd 应该赢才对——为什么加不加 layer 都是同一个结果?
因为还没排层序。@layer antd 这个名字是 antd 在运行时第一次注入时才出现的,而 utilities 是构建时就写在你的 CSS 里的。两者谁先"第一次出现",取决于样式表在文档里的先后:
| 页面 | @layer 顺序 |
说明 |
|---|---|---|
只挂 @import "tailwindcss" 的页面(真值:至少还有 101 条规则) |
["properties","theme","base","components","utilities"] |
antd 全部未分层(560 条未分层规则里包含 .ant-btn / .ant-input / .css-var-root / .anticon) |
加上 StyleProvider layer 的页面 |
["properties","theme","base","components","utilities","antd"] |
antd 是第一次出现就有的名字,排在 utilities 之后 → 层间最强 |
结论:layer 开关本身只决定"要不要进层",不决定"进哪一格"。 名字排在第几格,是"谁先声明"决定的——而 antd 是在运行时注入的,天然就晚于你构建出来的 CSS。所以只加 layer 的结果是:antd 从"未分层(全场最强)"变成了"层序最末(层间最强)",位置上没变。
但这里面藏着一个更值得记住的事实。把加了 layer 的页面里还没进层的规则列出来:
.css-var-root
.css-var-root.ant-btn
.css-var-root.ant-input
.css-var-root.ant-input-css-var
.css-var-root.ant-spin
.css-var-root.ant-table-css-var
.anticon .anticon > * .anticon svg
.anticon::before .anticon[tabindex] .anticon .anticon-icon
.anticon-spin
一共 13 条。这是本课最值得单独记住的一条:
🔴 StyleProvider layer 只把「组件样式」放进 @layer antd;--ant-* 的变量表和 .anticon 这一组基础样式,永远留在未分层。
这条事实有两个方向的实际后果:
- 好的那一面:变量表不受层序影响 → 用
ConfigProvider theme.token改颜色,永远可靠,不管你怎么排层。这从层叠规则上解释了"改 token 是正道"。 - 麻烦的那一面:
.anticon那 6 条(display: inline-flex/align-items: center/line-height: 0/vertical-align: -0.125em…)也在未分层。所以你想用 Tailwind 的inline-block、h-4之类的类去改一个图标容器的这些属性,那 6 条未分层的规则会稳稳压住你——加多少类名都没用。
三、三页对照:把层序排对
既然"名字排第几"是由第一次声明的位置决定的,那修法就很明确了:在你自己的 CSS 里,抢在 antd 注入之前,把 antd 这个名字声明到正确的格子里。
关键是"正确的格子"在哪。先看两个极端做法的实测结果:
做法一:把 antd 声明在最前。 CSS 写成
@layer antd;
@import "tailwindcss";
@source "./src/**/*.{jsx,js,html}";
产出里的层语句顺序变成 @layer properties; → @layer antd; → @layer theme, base, components, utilities;,于是层序是:
["properties","antd","theme","base","components","utilities"]
antd 排在 base 之前 → 层间最弱。量一下:
| 探针 | 期望 | 实测 computed |
|---|---|---|
| 主按钮(不带任何 Tailwind 类) | ? | rgba(0, 0, 0, 0) |
主按钮 + bg-red-600 |
? | oklch(0.577 0.245 27.325) |
输入框 + w-40 |
? | 160px |
Tailwind 全赢了——代价是 antd 的按钮被打成了透明。 看胜出的那条规则就明白了:
1. ★ [base] spec=0,1,6 order=32 <tailwind-layerfirst.css>
button, input, select, optgroup, textarea, ::file-selector-button { transparent }
2. [antd] spec=0,1,0 order=192 <antd-cssinjs>
:where(.css-oc1rc0).ant-btn { var(--ant-btn-bg-color) }
赢的是 Tailwind 的 preflight(@layer base,权重 0,1,6),输的是 antd 的按钮背景。因为层序里 base 排在 antd 后面,而"后声明的层赢"。preflight 那句 transparent 本来是"把浏览器默认的按钮灰底清掉"用的,现在它把 antd 精心算出来的背景色也一起清掉了。
这就是"把 antd 往下压"的代价:它自己也被别人压住了。 层序不是"让谁赢"的开关,它是一个全序——你动了 antd 的位置,同时也动了所有排在它前后的人。
做法二:把 antd 夹在中间。 既然 antd 必须赢过 preflight(否则组件就坏了),又不该赢过工具类(否则你没法用它做局部调整),那它就该待在 base 和 utilities 之间:
@layer properties, theme, base, components, antd, utilities;
@import "tailwindcss";
@source "./src/**/*.{jsx,js,html}";
层序变成:
["properties","theme","base","components","antd","utilities"]
同样三个探针:
| 探针 | 层序改动前(未排层) | 排层后 |
|---|---|---|
| 主按钮 | rgb(22, 119, 255)(antd 蓝,靠"未分层"赢的) |
rgb(22, 119, 255)(antd 蓝,靠层序赢的) |
主按钮 + bg-red-600 |
rgb(22, 119, 255)(输) |
oklch(0.577 0.245 27.325)(赢) |
输入框 + w-40 |
1230px(输) |
160px(赢) |
表格(font-size) |
14px |
14px(antd 自己的两条规则仍在 @layer antd 内部按权重决出) |
两全。 组件完好,工具类可用,而且这次 antd 是靠"层序"赢的,不再依赖"未分层"这个不可控的默认状态。
把三种排法放在一起,就是这一章的答案:
| 排法 | 层序 | antd 组件自身 | Tailwind 工具类能否覆盖 |
|---|---|---|---|
不排(只加 layer) |
… utilities, antd |
完好 | 不能(antd 层最强) |
@layer antd; 放最前 |
properties, antd, theme, base, … utilities |
被 preflight 打穿(按钮透明) | 能 |
@layer properties, theme, base, components, antd, utilities; |
… base, components, antd, utilities |
完好 | 能 |
一句话记住:你要排的不是"antd 在最前还是最后",是"antd 在 preflight 之后、工具类之前"。
四、怎么证明"是哪条规则赢了"
上面每一条结论,都不是推出来的,是量出来的。这套量法本身比结论更值钱,因为它可以搬到任何一个"样式不对"的现场去。
方法只有三步:
第一步:把所有规则连"层名 + 来源 + 出现序号"一起收下来。
const rules = []; let seq = 0;
function walk(list, layer, src) {
for (const r of list) {
if (r.selectorText && r.style) rules.push({ layer, sel: r.selectorText, src, order: seq++, decl: r.style });
seq++;
let kids = null; try { kids = r.cssRules } catch { kids = null }
if (kids && kids.length) {
// 关键:只有 CSSLayerBlockRule 的 .name 才是层名
const nm = r.constructor.name === "CSSLayerBlockRule" && r.name ? r.name : null;
walk(kids, nm ? (layer ? layer + "." + nm : nm) : layer, src);
}
}
}
for (const s of document.styleSheets) {
let list = null; try { list = s.cssRules } catch { continue }
const href = s.href || "";
if (list) walk(list, null, href ? href.split("/").pop() : "antd-cssinjs(<style>)");
}
第二步:用 el.matches(sel) 筛出真正命中这个元素的候选,按层叠顺序排序。
排序键就是第一章那张表的实现:[是否 important, 层秩, 权重 a, b, c, 出现序号],其中"未分层"给一个最大秩;!important 时层秩取负(反转)。
第三步:把胜出规则的声明值代换 var(),和目标元素的 getComputedStyle 比对。
第三步是这套方法里最容易做错的地方。两个坑,本课都踩过:
坑一:
if (r.cssRules)会把所有普通规则漏掉。 现代浏览器里,一条普通的CSSStyleRule的cssRules属性是存在且为真的——它是一个空的CSSRuleList(因为 CSS 支持嵌套)。所以用它判"这条规则有没有子规则",会把所有规则当成"容器"递归进去、一条都不收上来。现象很有迷惑性:探针报"匹配规则 0 条",而computed明明是对的。 判据应该是"先看selectorText是不是存在",而不是"看有没有cssRules"。坑二:
@layer不能用type号判。CSSRule这个全局对象上根本没有任何LAYER_*常量(Object.keys(CSSRule).filter(k => /LAYER/i.test(k))返回空数组),而且CSSLayerBlockRule、CSSLayerStatementRule、CSSPropertyRule的.type全是0(普通CSSStyleRule反而是1,CSSMediaRule是4)。只能认构造器名。顺带一个陷阱:@keyframes和@property规则上也有.name,直接拿.name当层名,会把css-oc1rc0-loadingCircle、--tw-rotate-x这些当成"层",把层序表污染掉。还有一个必须承认的边界:如果胜出声明里带
var(),代换必须在目标元素自己的上下文里取值(getComputedStyle(el).getPropertyValue("--x")),因为自定义属性是沿祖先链继承的。而像width: 100%这种百分比值,替身元素量出来的结果和真实元素不一样(实测:替身 1280px、真实 1230px)——这类值不参与比对,别硬凑一个"✓"。
五、这套修法在 minify 和 SSR 下还成立吗
两条必须验的边界。
minify 会重写层语句。 同一份输入,不加 --minify 时产物里有 @layer properties, theme, base, components, antd, utilities;;加了 --minify 之后,那句话被压成了:
@layer properties;
@layer theme{
@layer base{
@layer components,antd;
@layer utilities{
@layer components,antd; 被插在 base 与 utilities 之间 —— 层序仍然是 properties < theme < base < components < antd < utilities,antd 依然被夹在 preflight 之后、工具类之前,修法成立。但要注意它把"声明位置"从"文件最前面"搬到了"块出现的地方":你不能指望原句被原样保留,只能指望层序被保住。
SSR 下同样成立。 服务端用 StyleProvider cache={cache} layer 渲染之后再 extractStyle(cache),抽出来的 CSS 里带着 @layer antd(实测抽出的样式里 @layer 的名字列表就是 ["@layer antd"],layer 版比非 layer 版多 151 字节)。也就是说,"排层"这件事不是客户端专属的,SSR 抽出来的那份 CSS 也需要同样的层序前提——否则你在服务端辛苦抽出来的样式,会在浏览器里被 preflight 或工具类按同一套规则重新排一遍。
本章脉络
flowchart TD
A["两套样式都要改同一个属性"] --> B{"是否有一条未分层?"}
B -->|是| C["未分层那条赢<br/>权重与位置都不看"]
B -->|否| D{"是否带 !important?"}
D -->|都没带| E["层之间: 后声明的层赢"]
D -->|都带了| F["层之间: 先声明的层赢<br/>未分层的最弱"]
E --> G["同层同权: 看权重"]
F --> G
G --> H["同权同层: 看出现顺序"]
C --> Z["结果"]
H --> Z
Z --> P["排层修法<br/>properties, theme, base, components, antd, utilities"]
P --> Q["antd 赢过 preflight<br/>工具类赢过 antd"]
图里在说什么。 这棵树的形状就是第一章那张判定表的执行版:第一刀切的是"在不在层里",第二刀切的是"有没有 !important",权重和出现顺序排在最后两级。注意从 B 出去的两条上边分支——它们都不经过权重那一级,这正是"类名权重一样、结果却不同"的根源。右下角那个方框是修法的落点:它不改变这棵树,只改变"谁站在哪一层",让结论变成你要的那个。
生产边界
本课量的是一张静态页面,不是活的应用。 真实项目里还有:运行时切换主题会持续往 head 追加新的样式表(本课量到三套主题共存时注入 31 张表)、路由切换让组件挂载卸载、微前端里多个 cssinjs 缓存各插各的、以及构建链上可能还有 PostCSS、CSS Modules、别的 UI 库各自往 head 里塞东西。每多一个"往 head 里插东西"的角色,层序就多一个变量。
所以这一章的结论要按这个范围用:
- 机制("未分层 > 层"、"important 反转层序"、"层序看第一次声明")是 CSS 规范级的,跨版本跨库成立。 这是本课真正要留下的东西。
- "antd 的 13 条未分层规则"是 antd 6.6.5 的事实,而且实测显示它取决于
layer开关。 换版本、换开关组合,这个数字会变——但"哪些东西进了层、哪些没进"这个形状要自己重量,不能照抄。 - 具体字节数(151 字节的差值、order=192 这些)请只看形状。 它们和你项目里组件的用量直接相关。
- 本课没有测:多个
StyleProvider嵌套不同layer值的组合、!important与@layer同时作用在@keyframes上、以及浏览器之外的 CSS 处理链(PostCSS 插件会不会重排层语句)。需要时请自己构造最小对照。
动手:可观察结果
挑一个你手上真实的项目,回答下面五个问题。每个答案都要来自命令或浏览器输出。
- 我的 CSS 里到底有几层、顺序是什么? —— 用第一章"动手"里的第 3 行代码把
@layer名字按出现顺序打出来。把这张表抄下来,它就是你这个项目所有覆盖行为的底图。 - 我要覆盖的那些组件的样式,进层了吗? —— 在页面上选中一个组件元素,用探针列出它命中的候选规则,看
[层名]那一列里有多少是(未分层)。只要还有未分层的,你的排层就没做完。 - 我写的覆盖规则落在哪一层? —— 看它在候选列表里的层名。如果是
utilities,那它要不要赢取决于你排的层序,而不是它写得多狠。 - 我有没有依赖"未分层"在赢? —— 如果某条覆盖之所以生效是因为它是未分层的,那它就是一个隐式依赖:任何一次引入新样式表的动作都可能改变结果。把它找出来。
- 我把 antd 夹在
base和utilities之间了吗? —— 如果排层之后主按钮变透明了,就是夹错了格子。
完成标志:五个问题都有输出;并且你能指着第 1 步那张层序表说出"如果我把 X 层挪到 Y 后面,哪些东西会翻面"。
故障注入
| 注入 | 怎么做 | 观察什么 | 期望行为 |
|---|---|---|---|
只加 layer 不排层 |
给 antd 套上 <StyleProvider layer>,CSS 一个字不改 |
层序表 + 主按钮的 computed |
层序多了个排在最后的 antd;主按钮的 computed 不变。这个开关单独用没有效果 |
| 把 antd 声明到最前 | CSS 首行写 @layer antd;,其他不动 |
主按钮、bg-red-600 按钮、w-40 输入框 |
工具类全赢,主按钮背景变成 rgba(0, 0, 0, 0) —— 组件被打穿 |
| 把 antd 夹在 base 与 utilities 之间 | 首行写 @layer properties, theme, base, components, antd, utilities; |
同上三项 | 主按钮恢复 antd 蓝,工具类依然生效 |
在两个层里各写一条 !important |
@layer base { .a { background: #0ea5e9 !important } } + @layer utilities { .b { background: #dc2626 !important } },同一个元素挂两个类 |
谁赢、权重有没有起作用 | base(更早声明的层)赢,且权重完全不起作用 |
第三种注入之后保留这个状态,然后问自己:我项目里现在有多少条覆盖,是"因为没排层所以碰巧赢了"?把它们列出来——这份清单就是你下次升级 antd 或 Tailwind 时最可能炸的地方。
自测题
- 有人告诉你"加
!important就能覆盖组件库样式"。请按本章的判定表,说出这句话对的部分和不完整的部分。 @layer antd这个名字在文件里第一次出现的位置,和@layer antd { … }这个块出现的位置,哪一个决定层序?如果只在文件后面写块、不在前面声明名字,会怎样?- 一个页面上,某条规则权重 0,3,0,在
@layer utilities里;另一条权重 0,1,0,未分层。谁赢?如果两条都带!important呢? - 为什么加了
StyleProvider layer之后,"改 token 仍然可靠"这件事不会受影响?请用"哪些规则进了层"来解释。 - 你排好层之后主按钮变透明了。请按本章的层序表说出至少两种可能的原因,以及各自怎么验证。
- 本章的探针踩了两个坑(
CSSStyleRule.cssRules是空真值、@layer不能用type号判)。如果把这段探针包装成一个团队内部的排查工具,你会在这两处各加什么断言?
现在能解释什么
回到开头那个按钮。"加了 layer,颜色一个像素都没变" ——现在你能把它讲成一个完整的故事:
第一层解释:开关没错,位置错了。 StyleProvider layer 确实把 antd 的组件样式放进了 @layer antd;但 antd 这个名字是运行时第一次注入时才出现的,只能排在 utilities 后面。而从"未分层"到"层序最末",在优先级上是同一种位置——都是最强的那个位置。所以结果不变。
第二层解释:layer 只包了一半。 那 13 条 .css-var-root* 与 .anticon* 从来没进层。这有好的一面(改 token 永远可靠),也有麻烦的一面(想用工具类改 .anticon 的基础属性是白费功夫)。"用了 layer 就一切都在层里了"是一个不成立的假设。
第三层解释:修法是排一个全序,不是给 antd 排个名次。 把 antd 放最前,Tailwind 全赢但组件被打穿(主按钮 rgba(0, 0, 0, 0));把 antd 夹在 base 和 utilities 之间,两边都要到了。你要控制的不是"谁最强",而是"谁在谁前面"。
第四层解释:结论可以量出来。 遍历 document.styleSheets、把候选规则连层名和出现序号一起列出来、代换 var()、和 computed 对账——这套方法能把任何"样式不对"的现场变成一个可以指着说话的清单。它也是这一章唯一能搬去别的技术栈的东西。
一句提醒收尾:本课量的 @layer 顺序、13 条未分层规则、151 字节差值,都是 antd 6.6.5 + tailwindcss 4.3.3 上的事实。 版本一动,"谁进了层、名字排第几"就可能变。要留下的是这张判定表和那三步探针——它们跟版本无关。