KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

02 · Tailwind v4:一次扫描决定产出什么 — keel 龙骨

这一章回答:为什么我写了类名却没生成、生成的又比我写的多——以及 @import "tailwindcss" 这一行字,到底让编译器读了哪些文件、又写下了哪些规则。

这一章回答:为什么我写了类名却没生成、生成的又比我写的多——以及 @import "tailwindcss" 这一行字,到底让编译器读了哪些文件、又写下了哪些规则。

现场:页面"像有样式",原子类却一个都没生成

一个前端项目,样式入口放在 styles/ 下,组件源码放在它的上一层 shared/ 里:

/* styles/a.css */
@import "tailwindcss";
// shared/base.jsx
export const P = () => <div className="bg-red-600 text-blue-500 w-40">x</div>;

编译,然后 grep 类名:

$ node node_modules/@tailwindcss/cli/dist/index.mjs -i styles/a.css -o dist/a.css
产物 4165 B   .bg-red-600 出现 0 次   .w-40 出现 0 次

页面打开是"能看"的:正文颜色对、行高对、标题的字重也对。这正是最难查的一种状态——它"像有样式"。 可你明明在 JSX 里写了 bg-red-600、text-blue-500、w-40,这三条规则一条都没进产物。

现在只加一行 @source:

/* styles/b.css */
@import "tailwindcss";
@source "../shared/base.jsx";
产物 4405 B   .bg-red-600 出现 1 次   .w-40 出现 1 次

两份 CSS 的差别只有一行,产物体积只差 240 B,你写下的那几个类名的命运却完全反过来。

先别往下翻,给出你的预测: @import "tailwindcss" 默认扫的,是整个项目,还是只有这个 CSS 文件所在的那棵目录树?如果是后者,那 ../shared/base.jsx 正好在树外——这就是 a.css 那 0 次的原因。你要赌哪一个?

答案是后者,而且这不是配置写漏了,是 v4 的默认行为:只写 @import "tailwindcss" 时,它自动扫的是 CSS 文件所在目录树;目录树之外的源码,必须显式 @source。 注意那 4165 B 并不小——里面是 theme 与 base,也就是重置与设计变量;出问题时页面"像有样式"、组件却全是裸的,这份体积就是证据。(同一实验里,源码本来就在目录树内时(f/base.jsx),写不写 @source 的产物完全一样,都是 5096 B——所以 @source 不是装饰,是目录树边界的开关。)

这一章就顺着这条线往下:一次扫描到底怎么决定产物里有什么、没有什么。

一、@import "tailwindcss" 展开成什么

@import "tailwindcss" 是所有内容进来的那一行。它加一句 @source "./f/base.jsx" 之后,产物 5096 B、69 个声明块。把 minify 后 @layer 出现的位置挑出来,长这样:

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

读它有三件事值得记住。

第一,三个层各有一个块。 theme 块里是 :root, :host { --font-sans: … } 这样的自定义属性——v4 的设计变量全在这里;base 块是 preflight(那一套元素重置);utilities 块是原子类。也就是说,你写的每一个 bg-red-600,最终都住在 utilities 这一层里。

第二,components 没有块,只剩一句悬空的 @layer components;。 它不生成任何规则,唯一作用是把层序钉住。如果不留这一句,你后面自己写的 @layer components { … } 会因为"层是在代码里第一次被提到时才排位"而被扔到所有层的最后。Tailwind 先用一句空声明替你把位子占了。

第三,@layer properties 是按需出现的。 同一套工具,只用一部分原子类时产物里没有 properties 层(E3.1 记录的对照是 e12 default);换用 shadow-*、leading-* 这类依赖 @property 注册的原子类时(e13),它才冒出来。原因很直接:@property 是给那些需要"可动画、有初始值"的变量用的,只有用到它们的原子类才会把注册表带进来。所以你在自己的产物里看没看到 properties 层,取决于你用了哪些类,而不是 Tailwind 版本不同。

还有一处细节值得提前说:未 minify 时产物里会有 @layer properties; 与 @layer theme, base, components, utilities; 这两句显式的层语句;minify 之后它们被合并或省掉,改由"块的先后顺序 + 一句 @layer components;"来表达。 这句话现在先记着,第六节还会用到。

二、颜色是 oklch,间距靠一个 --spacing 变量

产物里抽几条原子类的原文出来:

--color-red-600: oklch(57.7% .245 27.325)
.bg-red-600      { background-color: var(--color-red-600) }
.text-blue-500   { color: var(--color-blue-500) }
.w-40            { width: calc(var(--spacing) * 40) }
--spacing: 0.25rem        (写在 @layer theme 的 :root 里)

两条结论:

记一个判据:看到 oklch() 不要去翻译成 hex 再比。第一步是先确认"这个值到底是不是我期望的那个原色",第二步才是换算。而要判断"是哪条规则给了它这个值",靠的是遍历 document.styleSheets 把候选规则连层名、权重、出现序号一起列出来——那套方法在 01 章 与 04 层序 里会反复用。

三、扫描是文本级的:写下来过就算数

第二节的 b.css 之所以能生成那三个类,是因为扫描器真的读到了 shared/base.jsx。那它是怎么"读"的?看一个专门构造的 fixture:

// f/dyn.jsx
const a = "bg-" + c + "-600";
const b = `text-${c}-500`;
const whole = "bg-red-600";

产物里(4264 B、56 个声明块):

原因一句话:扫描器把源码当纯文本抽 token,不执行它。写下来过就算数,拼出来的不算。

这条规则同时带来两个方向相反的后果,都值得记住:

① 漏生成(静默失效)。 bg-${c}-600 这种拼法,人类一眼知道想干什么,扫描器不知道。产物里没有这条规则,浏览器也不报错——页面上那个元素就是没颜色,而你去 DevTools 里看,类名明明在。这就是"类名写对了、样式没来"的一种标准成因。

② 多生成(多写也产出)。 反过来,只要完整类名在文本里出现过——哪怕在注释里、在字符串里、在被删掉的死代码里——它就会被生成。你在 JSX 里留一句 // 别再加 bg-red-600 了,产物里就真的多一条 .bg-red-600。所以"产物比我写的多"不是 bug,是同一套文本规则的必然结果。

顺带记一个排除写法:@source not "./f/skipped"; 是 v4 里可用的"不要扫这里"。它处理的是"某个目录不该进扫描范围",跟"类名拼接不出来"是两码事——前者是范围问题,后者是表达能力问题,混起来会白排查半天。

四、@theme 命名空间决定生成哪个工具类

v4 把原来 tailwind.config.js 里的设计令牌搬进了 CSS,写在 @theme 里。你写下的 token 名字里的"命名空间"决定它会生成哪个工具类、会落成哪个自定义属性。

写下的 token 落进产物的自定义属性 生成的工具类
--color-brand: #0ea5e9 --color-brand: #0ea5e9 .bg-brand { background-color: var(--color-brand) }
--color-ink-soft: oklch(55% .02 250) --color-ink-soft: oklch(55% .02 250) .text-ink-soft { color: var(--color-ink-soft) }
--spacing-huge: 3.5rem --spacing-huge: 3.5rem .p-huge { padding: var(--spacing-huge) }
--font-display: "Georgia", serif --font-display: "Georgia", serif .font-display { font-family: var(--font-display) }
--radius-brand: 14px --radius-brand: 14px .rounded-brand { border-radius: var(--radius-brand) }
--leading-loose: 2.2 --leading-loose: 2.2 .leading-loose { --tw-leading: var(--leading-loose); line-height: var(--leading-loose) }
--shadow-brand: 0 2px 8px rgb(0 0 0 / 0.2) 没落成变量 .shadow-brand { --tw-shadow: 0 2px 8px var(--tw-shadow-color,#0003); … }(值被内联)

这张表要横着读,也要竖着读。

横着读:--color-* 生成颜色类(bg- / text- 都吃它),--spacing-* 生成间距类,--font-* 生成字体族类,--radius-* 生成圆角类,--leading-* 生成行高类。命名空间不是命名习惯,是"这个 token 属于哪一类工具类"的声明。

竖着读,只有最后一行不一样:--leading-loose 落成了 --leading-loose: 2.2,而 --shadow-brand 在产物里没有对应的自定义属性,值被内联进了 .shadow-brand。为什么同样是 token,一个留下变量、一个被就地展开?因为阴影的值要参与 var(--tw-shadow-color, …) 这套运行时计算,把它当固定变量挂着没有意义。这条差别很重要:不是所有 @theme token 都会变成可复用的 CSS 变量。 你若打算在别处 var(--shadow-brand) 引用它,会引用不到。

对照一下第 ② 类事实的产物:这一组实验(e13)在 theme 层里真正落下来的自定义属性只有六个——--leading-loose: 2.2、--color-brand: #0ea5e9、--color-ink-soft: oklch(55% .02 250)、--spacing-huge: 3.5rem、--font-display: "Georgia", serif、--radius-brand: 14px。第七个(--shadow-brand)不在名单里。

五、@utility 与 @apply:定义不等于产出

@utility 是 v4 里替代 v3 plugin 的那条路:你用它定义自己的工具类。

@utility tab-4 { tab-size: 4 }
@utility content-auto { content-visibility: auto }
@layer components { .card-x { border: 1px solid #ddd; border-radius: 8px; } }

产物里三条对应的规则是 .tab-4 { tab-size: 4 }、.content-auto { content-visibility: auto },以及被原样保留的 @layer components { .card-x { … } }。

但这里有一条容易踩空的规则:@utility 定义的类,如果源码里没人用,不会出现在产物里。 同一套定义、换一份不引用它们的 fixture(e10),产物 4264 B 里 .tab-4 出现 0 次(而同一份产物里 .bg-red-600 出现 1 次)。上一节那份能产出 .tab-4 的 fixture(e05)之所以有它,是因为那里真的写了 className="tab-4 content-auto card-x"。

一句话:@utility 是"可被按需生成的候选",不是"写下来就上线的规则"。 这跟第三节的文本扫描是同一条纪律的两个面——来源只有一处:源码里出现过。

@apply 的规则更值得先记形状。写下:

@utility btn-x {
  @apply bg-red-600 px-4 py-2 rounded-md text-white;
  font-weight: 600;
}

产物里 .btn-x 变成:

.btn-x { border-radius: var(--radius-md); background-color: var(--color-red-600);
         padding-inline: calc(var(--spacing) * 4); … }

声明被内联展开,被 @apply 的 .bg-red-600 本身不会出现在产物里。 产物里只有一个 --color-red-600,没有独立的 .bg-red-600 { … }。也就是说:@apply 是"把那些类的声明搬过来",不是"把那些类引用过来"。这跟"给元素加 className="bg-red-600""是两种完全不同的产物形态——前者不依赖 .bg-red-600 存在,后者必须它存在。(@layer components; 那句悬空语句仍会为 @apply 出现。)

再补两条同族的语法:v4 里 ! 前缀写在类名之前,.\!bg-red-600 { background-color: var(--color-red-600) !important }(注意产物里那个转义是 .\!);以及 @source not "..." 那种排除写法(上一节提过)。这两条都是"写在源码里就能查到产物里的形状"的小规则,值得在自己的项目里验一次。

六、dark: 变体与 minify 重写的层语句

dark: 变体的默认开关是系统偏好,不是某个 class:

默认:      @media (prefers-color-scheme:dark){ .dark\:bg-slate-900 { background-color: var(--color-slate-900) } }

一旦在 CSS 里加上这句:

@custom-variant dark (&:where(.dark, .dark *));

同一个类的产物就变成:

.dark\:bg-slate-900:where(.dark, .dark *) { background-color: var(--color-slate-900) }

没有任何 media query。 开关从"操作系统的配色偏好"换成了"祖先链上有没有 .dark"。这个差别在 06 暗色与响应式 里会展开——记住形状就够了:变体是"多生成一层选择器/at-rule",不是"在运行时判断"。

最后回到第一节埋下的那个伏笔:minify 会重写层语句。 三种输入各有不 minify 与 minify 两版:

输入 不 minify minify
@import "tailwindcss"; @source 4912 B,含 @layer theme, base, components, utilities; 4405 B,含 @layer components;
前置 @layer antd; 4925 B,含 @layer antd; 与整条层语句 4417 B,含 @layer antd;
前置 @layer properties, theme, base, components, antd, utilities; 4973 B,两句层语句都在 4428 B,压成 @layer components,antd;

看第三行:那句显式排层的声明,被压成了 @layer components,antd;,插在 base 与 utilities 之间。这意味着两件事:

  1. 层序仍然成立——顺序还是 properties < theme < base < components < antd < utilities,antd 被夹在 base 与 utilities 之间,所以"把 antd 排在 utilities 之前"这个修法依然有效;
  2. 但别指望你写下的原句被保留——它把 @layer antd 的"声明位置"从"文件最前"搬到了"块出现的地方"。任何依赖"我文件第一行写了 @layer antd;"来判断行为的推理,都要改成去看产物。

这也是本课的一条通用纪律:产物才是唯一不会骗人的东西(04 章 会把这条落到"哪条规则赢了"的探针上)。

本章脉络

flowchart TD
  A["一次扫描决定产物里有什么"] --> B["@import tailwindcss<br/>展开成 theme / base / utilities<br/>外加一句悬空的 component 层语句"]
  A --> C["扫描:把源码当纯文本抽 token"]
  C -->|"写下来过"| D["生成:.bg-red-600"]
  C -->|"拼出来的"| E["不生成:.text-blue-500<br/>静默失效"]
  C -->|"出现在注释或字符串里"| F["照样生成:产物比你写的多"]
  A --> G["@theme 命名空间<br/>token 同时决定变量与工具类"]
  G -->|"shadow 这一类"| H["值被内联<br/>不落成变量"]
  A --> I["@utility 定义"]
  I -->|"源码里没人用"| J["tab-4 产出 0 次"]
  A --> K["@apply"]
  K -->|"声明内联展开"| L["被 apply 的 .bg-red-600 本身不产出"]
  A --> M["dark 变体与 minify"]
  M -->|"默认"| N["media prefers-color-scheme"]
  M -->|"加 custom-variant"| O["dark 选择器,无 media query"]
  M -->|"minify"| P["重写层语句为 components,antd"]

图里在说什么。 顶上那个方框是本章唯一的前提——产物里有什么,全由"扫描到了哪些文本 + 哪些规则"决定,没有任何运行时介入。左边一列是扫描的两个后果:写下来过就生成(绿),拼出来的不生成(红),注释里的也算数。中间是三种"写在 CSS 里"的东西:@theme 决定变量与工具类的对应(shadow 这一类例外,值被内联)、@utility 按需产出、@apply 把声明搬过来。右边一列是变体与压缩:dark: 默认跟系统走,改成 class 开关只是"换了一层选择器",而 minify 会把层语句压成 components,antd。整张图的关键是:所有分叉都发生在构建期,页面打开时已经无法改变任何一支。

生产边界

动手:可观察结果

挑一个你手上真实的项目(或者照第 a.css / b.css 造一个最小对照),把下面五件事各做一遍。每一步的答案都必须来自命令输出,不能来自记忆。

  1. 数一数产物里你有没有写过的类。 在源码里挑三个你确定没用的原子类名(例如某个你已经删掉的样式),去产物里 grep。如果它们在,说明"注释/死代码里的类名也会产出"这条在你项目里成立——顺手把那几处注释清掉,看产物体积有没有变化。
  2. 造一次 @source 边界。 把源码挪到 CSS 文件所在目录树之外,先只写 @import "tailwindcss" 编译一次,记下产物体积与某个类的出现次数;再加 @source 编译一次,对比。两次之间只改这一行。
  3. 看 properties 层在不在。 在产物里 grep @layer properties。它在不在,取决于你用没用 shadow-* / leading-* 这类原子类——换一个页面再编译一次,看它是否随用法出现或消失。
  4. 比较 @theme 落成的变量。 在你的 @theme 里写一条 --shadow-* 和一条 --color-*,编译后在 theme 层里 grep 这两个名字。哪个留下了变量、哪个被内联?
  5. 把 minify 开一次。 同一份输入,--minify 前后各 grep 一次 @layer。你原来写的那句显式排层语句还在吗?被压成了什么形状?

完成标志:你能对着产物,逐条说出这个类为什么在(或为什么不在),而不是"它应该生效吧"。特别是能说出一句——"我那个类没生成,是因为它被拼出来的,不是被写下来的"。

故障注入

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

注入 怎么做 观察什么 期望行为
把源码挪到目录树外 只删掉那行 @source,其余不动 产物体积、目标类的出现次数 体积不会塌成零(theme + base 还在),但你写的原子类全部为 0——页面"像有样式"、组件全裸
把类名改成拼接 把 className="bg-red-600" 改成 className={"bg-" + c + "-600"} 产物里还有没有那条规则 产物里没有该规则;浏览器不报错;元素上没有该样式——静默失效
在注释里写下类名 在源码里加一行注释 // bg-red-600 产物里会不会多出这条规则 会——注释也进了文本扫描范围;这就是"产物比我写的多"
给 @utility 定义的类"断掉引用" 把 className="tab-4" 删掉,@utility tab-4 留着 产物里 .tab-4 出现几次 0 次——定义不等于产出
把 @apply 换成 className 同一个 bg-red-600,一次用 @apply 写进 @utility,一次作为 class 挂在元素上 产物里 .bg-red-600 在不在 用 @apply 的那次没有独立 .bg-red-600;挂 class 的那次有

第三种注入之后别急着改回去。留着这行注释跑一次完整的构建与上线流程,然后问自己:从"我在注释里写了个类名"到"产物里多了一条没人用的 CSS",这中间有没有任何环节本该发现它?答案通常是"没有"——这就是为什么这类冗余只能靠事后 grep 产物来找。

自测题

  1. 一份 CSS 只写 @import "tailwindcss";,源码在它的上一层目录,产物 4165 B 里没有你写的任何原子类。请说出两条能解释"体积不小但类没生成"的事实。
  2. @import "tailwindcss" 的产物里,components 层为什么没有块、只留一句 @layer components;?删掉这句会发生什么?
  3. .bg-red-600 和 .text-blue-500 都出现在源码里,但产物里只有前者。请按"文本扫描"给出至少两种能让后者失败、却让前者成功的源码写法。
  4. @theme 里同写的两条 token,--color-brand 落成了变量、--shadow-brand 没有。这条差别对"我在别处 var(--shadow-brand) 引用它"意味着什么?
  5. @apply bg-red-600 的产物里没有 .bg-red-600。请说明 @apply 到底做了哪件事;再说明"给元素挂 className="bg-red-600""的产物为什么必须包含它。
  6. dark: 默认产物里有 @media (prefers-color-scheme:dark);加上 @custom-variant dark (&:where(.dark, .dark *)); 之后 media query 消失。开关是从哪里搬到哪里的? 两种写法在同一个页面上能同时存在吗?
  7. minify 之后你写的 @layer properties, theme, base, components, antd, utilities; 被压成 @layer components,antd;。层序变了吗?为什么这个修法仍然成立?
  8. 有同事说"我在注释里写了个类名,产物居然多了条规则,这是 bug"。请按本章的规则解释它为什么不是 bug,并说明这条性质的正面用途(提示:你希望保留一段 HTML 里的类名时)。

现在能解释什么

下一步:03 章 · antd:运行时生成与一张变量表——这一章讲的是构建期决定什么进文档,下一章把镜头切到运行期:同一个页面里三套主题为什么能共存,而它们的按钮类名结构一模一样。

进入 keel 阅读