KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

01 · 两套样式是两套生成模型 — keel 龙骨

这一章回答:一个按钮上,你写的 bg-red-600 和 antd 自己的蓝色,为什么可以同时存在、同时"生效"、而屏幕上只有一个结果——以及为什么第一直觉"再加狠一点"通常是错的方向。

这一章回答:一个按钮上,你写的 bg-red-600 和 antd 自己的蓝色,为什么可以同时存在、同时"生效"、而屏幕上只有一个结果——以及为什么第一直觉"再加狠一点"通常是错的方向。

现场:一个按钮,颜色改不动

一个再普通不过的页面:

import { Button } from "antd";

<Button type="primary" className="bg-red-600">主按钮</Button>

意图非常明确:bg-red-600 是 Tailwind 的红,把它加在 antd 的主按钮上,按钮应该变红。打开页面,量一下它实际的背景色:

computed background-color = rgb(22, 119, 255)

antd 的蓝,一点没变。 再仔细看这个按钮身上挂了什么类:

<button type="button"
  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 挂上去了。Tailwind 的样式表也确实加载了——同一个页面上,旁边那个 <span class="text-blue-500"> 的颜色是 oklch(0.623 0.214 259.815),正是 Tailwind 的 blue-500。也就是说:Tailwind 是好用的,类名是加上了的,红的那个类就是没赢。

先停一下,别往下翻。你先给出自己的判断:为什么 bg-red-600 没生效? 是类名没挂上、是 Tailwind 没编译这个类、还是挂上了被别的规则压掉了?三种可能,你选哪一种,决定了后面要看哪里。

如果你第一反应是"antd 的选择器权重更高"——这一章要纠正的正是这个直觉。看下一条:

:where(.css-oc1rc0).ant-btn   ← antd 这条规则的权重是 0,1,0
.bg-red-600                    ← Tailwind 这条规则的权重也是 0,1,0

权重一模一样。 所以"加 !important 或者加更多类名"这条路,从一开始就不是在解决真问题。真正的问题在别处:这两套样式不是以同一种方式、在同一个时刻、进入同一套优先级规则的。

一、同一页上住着两条时间线

把两套方案的产出方式并排看,差别立刻显出来:

antd Tailwind
样式在什么时候产生 组件渲染的那一刻(npm start 之后,浏览器里) 构建时(npm run build 那一刻)
产生者是谁 @ant-design/cssinjs 运行时 Tailwind CLI / 打包器的 CSS 插件
用什么形式进文档 往 head 里插入 <style> 标签 一个 <link rel="stylesheet">
选择器长什么样 :where(.css-oc1rc0).ant-btn .bg-red-600
一个组件要多少条 一个组件一张表,最大的那张 198 条规则 一个类一条
能不能在源码里搜到 搜不到,它在 node_modules 里被算出来的 能,源码里写了什么就是什么

一句话概括:antd 的样式是"用的时候才生成",Tailwind 的样式是"写的时候就已经生成"。 这两句话听起来只是顺序不同,但它决定了后面所有的事情——包括你在浏览器 DevTools 里能搜到什么、包括覆盖时的先后、包括服务端渲染时样式要不要单独搬一次。

二、antd 那条线:组件渲染时,18 个 <style> 插进 head

做一个只有三个组件(Button、Input、Table)的最小页面,起来之后数一下 head 里的东西:

head 子元素顺序:
STYLE ×18   META   TITLE   LINK[./tailwind.css]

18 个 <style>,全部插在 <link> 之前。 注意这个位置不是随机的——antd 主动把自己放到你引的样式表前面,意思是"我的东西你先看到,后面有更靠后的规则就能盖住我"。它是在给你让路。

这 18 张表不是"一张大表切 18 份",而是按组件与用途分表。逐张列出来(规则数 / 首条规则):

# 规则数 首条规则
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 / 12 1 / 1 @keyframes css-oc1rc0-antRotate / @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

三件事值得先记住,它们在后面每一章都会被用到:

把这三件事加起来,就是"为什么 DevTools 里搜不到 antd 的样式"的答案:它在你的源码包里不存在,它是运行时被算出来、再塞进 head 的。 你 grep 自己的项目永远搜不到 .ant-btn,因为它从来就不在你的仓库里。

三、Tailwind 那条线:构建时扫一遍源码,产出四层

另一边。Tailwind v4 的输入可以短到两行:

@import "tailwindcss";
@source "./f/base.jsx";

产出 5096 字节(minify 后),里面有四个 @layer 块和一句悬空的层语句:

@layer properties{
@layer theme{
@layer base{
@layer components;
@layer utilities{

这里最要紧的是:它产出的是真正的 CSS @layer,不是注释、也不是打包器模拟的。这是第 04 章整个故事的支点。

而且产出什么完全由"扫到了什么"决定。扫到 className="bg-red-600" 才产出 .bg-red-600;扫不到就一个字节都不产。所以 Tailwind 的这一侧其实很好推理——难的地方在于"扫描"这件事的边界,第 02 章整章都在划这条边界。

四、两条线在同一个元素上相遇

回到开头的按钮。现在把两条线放到一起看,这个按钮身上其实有两组候选规则在抢 background-color:

候选 1(antd,未分层,权重 0,1,0,出现序号 79)
  :where(.css-oc1rc0).ant-btn   { background: var(--ant-btn-bg-color) }

候选 2(Tailwind,@layer base,权重 0,1,6,出现序号 579)
  button, input, select, optgroup, textarea, ::file-selector-button   { background-color: transparent }

候选 3(Tailwind,@layer utilities,权重 0,1,0,出现序号 641)
  .bg-red-600   { background-color: var(--color-red-600) }

三个候选。如果按"权重优先"来判,候选 2 的 0,1,6 最大——可它恰恰是实际最不起眼的一条:它只是 preflight 里"按钮默认透明"那句话,本来就是要被覆盖的。而真正的赢家是 候选 1,antd 的那条,权重只有 0,1,0。

为什么?因为 候选 2 和 候选 3 都在层里,候选 1 不在层里。 在 CSS 的层叠规则里,未分层的规则优先级高于任何层里的规则——层里的规则不管权重多高、写得多晚,都排在不分层的规则后面。所以:

这一条是整门课的地基:"谁赢"的第一判据不是权重,是"在不在层里"。 后面第 04 章会把它展开成一张完整的判定清单。

五、还有一个更隐蔽的层次:抢的不是同一样东西

到这儿你可能觉得已经懂了:不要用 Tailwind 的类去抢 antd 的属性,要么加层序、要么用别的手段。但还有一层,比"层"更容易被忽略。

看看 antd 那条规则引用的是什么:

:where(.css-oc1rc0).ant-btn   { background: var(--ant-btn-bg-color) }

--ant-btn-bg-color 这个变量,antd 在另一条规则里赋值:

:where(.css-oc1rc0).ant-btn.ant-btn-variant-solid
      { --ant-btn-bg-color: var(--ant-btn-solid-bg-color) }

而 --ant-btn-solid-bg-color 又指向 --ant-color-primary,最终落到 .css-var-root 里那 381 个变量之一。

换句话说:antd 的颜色是一条"变量链",规则本身只是个壳。 你去抢 background-color 这条属性,等于绕开整条链、在最后一跳上动手;而 antd 每换一次主题(比如切暗色),它换的是链头的 381 个变量,那条壳规则一个字都不改。

这解释了一个很实际的现象:为什么"我明明覆盖了,一切主题又变回去了"——你覆盖的是最后一跳,主题切换改的是链头,两者各改各的,谁在层叠里赢由第 04 章的规则决定。也顺带解释了为什么"改 token"是正道:它改的是链头,整条链上所有组件同时跟着变,而且不需要和任何人抢选择器。

本章脉络

flowchart TD
  S["源码:Button + className='bg-red-600'"] --> B["构建期<br/>Tailwind CLI 扫源码"]
  S --> R["运行期<br/>组件渲染"]
  B --> B1["产出四层<br/>theme / base / components / utilities"]
  B1 --> BL["<link rel=stylesheet>"]
  R --> R1["cssinjs 算类名<br/>ant-btn + css-oc1rc0"]
  R1 --> R2["向 head 插入 <style><br/>18 张表 / 每组件一张"]
  R2 --> RS["变量表 381 项<br/>--ant-* 系列"]
  BL --> M["同一个 button 元素"]
  RS --> M
  M --> W["未分层的 antd 规则胜出<br/>computed = rgb(22,119,255)"]
  W --> N["权重不是判据<br/>是否分层才是"]

图里在说什么。 左边一条是构建期,右边一条是运行期,两条线在同一个 DOM 元素上汇合。汇合点上比的是"谁的规则最后落在层叠顺序里更靠后"——图里特意把 变量表 381 项 单独画出来,因为 antd 真正改颜色的位置在那儿,而不是在那条 :where(...).ant-btn 规则里。最后那个 权重不是判据 的方框是这一章最想留下的东西:你按权重推导出的答案,和浏览器给出的答案不是一回事。

生产边界

本课的替身是最小页面,不是真实应用。 实验里每个主题只用三个组件(Button / Input / Table),一个页面最多五块面板;真实应用里还有:几十个组件同时在页面上、路由切换时组件挂载与卸载、弹窗与抽屉把样式表持续追加、微前端里多个 React 根各带一套 cssinjs 缓存、以及 SSR 与 CSR 两套注入路径。

所以这一章给出的不是"某个具体数字",是"两条时间线"这个模型:

动手:可观察结果

打开任何一个用了 antd + Tailwind 的页面(你自己的项目也行),在 DevTools Console 里跑下面三行,把结果记下来:

// 1) 运行时注入的样式有多少、来自谁
[...document.styleSheets].map(s => [s.href || "<style>", s.cssRules ? s.cssRules.length : -1])

// 2) antd 的变量表有多少项
[...document.styleSheets].flatMap(s => { try { return [...s.cssRules] } catch { return [] } })
  .filter(r => r.selectorText && r.selectorText.startsWith(".css-var-root"))
  .map(r => [r.selectorText, [...r.style].filter(p => p.startsWith("--ant-")).length])

// 3) 某个元素身上,所有声明了 background-color 的规则(含层名与出现序号)
(() => {
  const el = document.querySelector(".ant-btn");
  let n = 0, out = [];
  for (const s of document.styleSheets) {
    let list; try { list = s.cssRules } catch { continue }
    const walk = (rs, layer) => { for (const r of rs) {
      if (r.selectorText && r.style) {
        const v = r.style.getPropertyValue("background-color") || r.style.getPropertyValue("background");
        if (v) { try { if (el.matches(r.selectorText)) out.push([layer || "(未分层)", n, r.selectorText, v]) } catch {} }
      }
      n++;
      let kids; try { kids = r.cssRules } catch {}
      if (kids && kids.length) walk(kids, r.constructor.name === "CSSLayerBlockRule" && r.name ? r.name : layer);
    } }
    if (list) walk(list, null);
  }
  console.log(out);
  console.log("computed =", getComputedStyle(el).backgroundColor);
})()

完成标志:三行都能跑出结果,并且你能对第 3 行的输出说出一句"按层叠规则,赢的应该是第几条,因为什么"。如果第 3 行打印的最后一条和 computed 对不上,那正是第 04 章要解决的事。

故障注入

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

注入 怎么做 观察什么 期望行为
把 className 换成 style 同一个按钮,className="bg-red-600" 改成 style={{ backgroundColor: "#dc2626" }} getComputedStyle 的 background-color 变成 rgb(220, 38, 38)。内联样式的优先级高于任何选择器,跟层没关系——这是"局部救急"能用的原因
给类名加 ! 改成 className="!bg-red-600" 同上,再看产物里那条规则 变成 oklch(0.577 0.245 27.325)。产物里是 .\!bg-red-600 { background-color: var(--color-red-600) !important } —— !important 跨过了层的边界
抄一份 antd 的选择器自己写 在你的 CSS 里照抄 :where(.css-oc1rc0).ant-btn { background-color: #dc2626 },放在自己的样式表里(未分层) 有没有生效、下次主题切换后还在不在 看起来"能生效",但它是最脆的一种写法:hash 变了(换主题、换环境、甚至开发/生产)它就废了。这是第 05 章的引子

第三种注入之后别再改回去,保留这个状态,然后回答一个问题:这段代码在什么情况下会静默失效,而你会以为是"样式没加载"?

自测题

  1. 一个页面上既有 antd 又有 Tailwind。你被告知"我们的 CSS 是有层的"。请判断:@layer 是谁生成的?antd 的规则有没有在层里?说出你的判断依据(用哪一行代码能验证)。
  2. 同事说"我给按钮加了 bg-red-600 但没生效,所以 Tailwind 和 antd 不能一起用"。请指出这句话里至少两处不成立的地方。
  3. .bg-red-600 和 :where(.css-oc1rc0).ant-btn 的权重都是 0,1,0。如果它们的规则都未分层,谁赢?为什么?如果 bg-red-600 在 @layer utilities 里而 antd 的未分层,又谁赢?
  4. antd 的颜色写在 .css-var-root 的 381 个变量里,规则里只是 var(--ant-btn-bg-color)。请说明这个设计带来两个好处和一个代价。
  5. 你在 DevTools 的 Styles 面板里搜索 .ant-btn,什么也搜不到(或者在 node_modules 里搜不到)。请解释这个现象,并说出用什么方法能看到真实生效的那条规则。
  6. 为什么 antd 要把自己的 <style> 插到 <link> 前面,而不是后面?这个选择和"未分层 > 分层"的规则放在一起,会得出什么结论?

现在能解释什么

回到开头那个按钮。"类名挂上了、权重也一样、颜色就是不变" ——现在你能把它拆成四句话:

第一句:是有两条时间线。 antd 的样式在组件渲染时才算出来,往 head 里插 18 张 <style>;Tailwind 的样式在构建时扫源码产出,来的时候是一个 <link>。所以"我搜不到 .ant-btn"不是你的搜索方式错了,它压根不在你的仓库里。

第二句:胜负的第一判据是层,不是权重。 你手上这个按钮的 background-color 有三个候选:antd 的(权重 0,1,0,未分层)、Tailwind preflight 的(0,1,6,在 @layer base)、Tailwind 的 .bg-red-600(0,1,0,在 @layer utilities)。权重最高的那条反而最不重要,未分层的那条全赢。这也解释了为什么 !important 和 style 能救急——它们跳出了层这套规则。

第三句:antd 真正改颜色的地方在变量表里。 规则只是壳,颜色是 --ant-btn-bg-color → --ant-btn-solid-bg-color → --ant-color-primary 这条链。抢 background-color 是在跟最后一跳较劲,而主题切换动的是链头。这也直接给出了正确的做法顺序:先改 token,再谈覆盖。

第四句:这两条线里,有一条的产出是"看你扫到了什么"决定的。 Tailwind 那一侧的一切麻烦都来自扫描边界,而不是来自层叠——第 02 章会把这条边界量出来。

一个提醒收尾。这一行的工具换得比大多数框架都快:Tailwind v4 把配置从 JS 搬进了 CSS、antd 6 把类名结构换成了 color × variant 两段、StyleProvider layer 这种开关还在演进。所以真正值钱的不是本章那些字节数和 hash,而是这两条时间线加"先看层、再看权重"这个顺序。 换一个组件库、换一个原子化方案,这个模型照样能用。

进入 keel 阅读