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)

两行合起来看:

这三条合起来,就是"加了 ! 就管用"的真实原因——它跳过的不只是权重,是整张表的第 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 这一组基础样式,永远留在未分层。

这条事实有两个方向的实际后果:

三、三页对照:把层序排对

既然"名字排第几"是由第一次声明的位置决定的,那修法就很明确了:在你自己的 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 比对。

第三步是这套方法里最容易做错的地方。两个坑,本课都踩过:

五、这套修法在 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 里插东西"的角色,层序就多一个变量。

所以这一章的结论要按这个范围用:

动手:可观察结果

挑一个你手上真实的项目,回答下面五个问题。每个答案都要来自命令或浏览器输出。

  1. 我的 CSS 里到底有几层、顺序是什么? —— 用第一章"动手"里的第 3 行代码把 @layer 名字按出现顺序打出来。把这张表抄下来,它就是你这个项目所有覆盖行为的底图。
  2. 我要覆盖的那些组件的样式,进层了吗? —— 在页面上选中一个组件元素,用探针列出它命中的候选规则,看 [层名] 那一列里有多少是 (未分层)。只要还有未分层的,你的排层就没做完。
  3. 我写的覆盖规则落在哪一层? —— 看它在候选列表里的层名。如果是 utilities,那它要不要赢取决于你排的层序,而不是它写得多狠。
  4. 我有没有依赖"未分层"在赢? —— 如果某条覆盖之所以生效是因为它是未分层的,那它就是一个隐式依赖:任何一次引入新样式表的动作都可能改变结果。把它找出来。
  5. 我把 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 时最可能炸的地方。

自测题

  1. 有人告诉你"加 !important 就能覆盖组件库样式"。请按本章的判定表,说出这句话对的部分和不完整的部分。
  2. @layer antd 这个名字在文件里第一次出现的位置,和 @layer antd { … } 这个块出现的位置,哪一个决定层序?如果只在文件后面写块、不在前面声明名字,会怎样?
  3. 一个页面上,某条规则权重 0,3,0,在 @layer utilities 里;另一条权重 0,1,0,未分层。谁赢?如果两条都带 !important 呢?
  4. 为什么加了 StyleProvider layer 之后,"改 token 仍然可靠"这件事不会受影响?请用"哪些规则进了层"来解释。
  5. 你排好层之后主按钮变透明了。请按本章的层序表说出至少两种可能的原因,以及各自怎么验证。
  6. 本章的探针踩了两个坑(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 上的事实。 版本一动,"谁进了层、名字排第几"就可能变。要留下的是这张判定表和那三步探针——它们跟版本无关。

进入 keel 阅读