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 里)
两条结论:
- v4 的颜色默认是
oklch(),不是 hex。 你在别处看到一个红色#dc2626,和这里的--color-red-600不是一回事——#dc2626是本课另一处实验里token.colorPrimary用的 hex 值,两者并不等价。所以在浏览器里量bg-red-600的 computed 值,拿到的会是oklch(0.577 0.245 27.325)这种形状,不是rgb(...)。这不是样式没生效,是颜色空间换了。 - 原子类本身不含魔法数字,它只是往变量上挂。
.w-40的宽度是calc(var(--spacing) * 40),而--spacing是0.25rem。想改整套间距的"基准步长",你改的是那个变量,不是四十个类。这一点和下一章 antd 的"颜色都藏在变量里"是同一个思路:框架把可调项收进自定义属性,选择器只负责引用。
记一个判据:看到 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 个声明块):
.bg-red-600有——但它来自const whole = "bg-red-600"这句字面量,而这个字面量只被塞进了data-x,根本不是 class;.text-blue-500没有——源码里只有模板串碎片text-和-500,完整类名从没被写出来过。
原因一句话:扫描器把源码当纯文本抽 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 之间。这意味着两件事:
- 层序仍然成立——顺序还是
properties < theme < base < components < antd < utilities,antd 被夹在 base 与 utilities 之间,所以"把 antd 排在 utilities 之前"这个修法依然有效; - 但别指望你写下的原句被保留——它把
@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。整张图的关键是:所有分叉都发生在构建期,页面打开时已经无法改变任何一支。
生产边界
- 本课的替身是"最小编译对照",不是真实构建链。 实验里只有一两个源文件、几份 CSS,直接用
@tailwindcss/cli跑。真实项目里源码常有几百上千个文件、目录层级更深,还叠着框架自己的扫描配置(打包器插件、monorepo 的工作区路径、被.gitignore覆盖的目录)。@source的边界结论不变,但"要写几条@source"取决于你的目录形状——别照抄本课的相对路径。 - 这份 4165 B / 4405 B / 5096 B 只用来读形状。 产品体积随你用了多少原子类、主题里有几条 token 变化。要记的是"没扫到源码时体积不会塌成零(因为 theme + base 还在)"这个形状,而不是绝对值。别把 240 B 的差值当成自己项目里的预期。
- 本课记的是 v4.3.3 的行为。 v4 把配置从
tailwind.config.js搬进 CSS,@theme/@utility/@source/@custom-variant这套写法在 v3 里都不是同一条路——照着 v3 教程排查 v4 项目,会一条都对不上。请在你自己的版本上复核上表里的每一个形状。 - "按需产出"与"文本扫描"会一起影响你的排序与命名。 因为扫描只看文本、
@utility只看有没有人用,动态拼类名是这类项目里最隐蔽的失效来源:它既不会报错,也不会留下痕迹,只有"某个元素没样式"这一个症状。上线前值得专门搜一遍"bg-" +这类拼接。 - 本课没有测运行时分包与增量编译。 上面的产物都来自一次性全量编译。真实的 watch / HMR 模式下"改了一个文件之后产物怎么变"是另一件事,本章的判据(文本、按需、层语句)仍然成立,但形状要你自己在 watch 模式里再量一次。
动手:可观察结果
挑一个你手上真实的项目(或者照第 a.css / b.css 造一个最小对照),把下面五件事各做一遍。每一步的答案都必须来自命令输出,不能来自记忆。
- 数一数产物里你有没有写过的类。 在源码里挑三个你确定没用的原子类名(例如某个你已经删掉的样式),去产物里 grep。如果它们在,说明"注释/死代码里的类名也会产出"这条在你项目里成立——顺手把那几处注释清掉,看产物体积有没有变化。
- 造一次
@source边界。 把源码挪到 CSS 文件所在目录树之外,先只写@import "tailwindcss"编译一次,记下产物体积与某个类的出现次数;再加@source编译一次,对比。两次之间只改这一行。 - 看 properties 层在不在。 在产物里 grep
@layer properties。它在不在,取决于你用没用shadow-*/leading-*这类原子类——换一个页面再编译一次,看它是否随用法出现或消失。 - 比较
@theme落成的变量。 在你的@theme里写一条--shadow-*和一条--color-*,编译后在theme层里 grep 这两个名字。哪个留下了变量、哪个被内联? - 把 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 产物来找。
自测题
- 一份 CSS 只写
@import "tailwindcss";,源码在它的上一层目录,产物 4165 B 里没有你写的任何原子类。请说出两条能解释"体积不小但类没生成"的事实。 @import "tailwindcss"的产物里,components层为什么没有块、只留一句@layer components;?删掉这句会发生什么?.bg-red-600和.text-blue-500都出现在源码里,但产物里只有前者。请按"文本扫描"给出至少两种能让后者失败、却让前者成功的源码写法。@theme里同写的两条 token,--color-brand落成了变量、--shadow-brand没有。这条差别对"我在别处var(--shadow-brand)引用它"意味着什么?@apply bg-red-600的产物里没有.bg-red-600。请说明@apply到底做了哪件事;再说明"给元素挂className="bg-red-600""的产物为什么必须包含它。dark:默认产物里有@media (prefers-color-scheme:dark);加上@custom-variant dark (&:where(.dark, .dark *));之后 media query 消失。开关是从哪里搬到哪里的? 两种写法在同一个页面上能同时存在吗?- minify 之后你写的
@layer properties, theme, base, components, antd, utilities;被压成@layer components,antd;。层序变了吗?为什么这个修法仍然成立? - 有同事说"我在注释里写了个类名,产物居然多了条规则,这是 bug"。请按本章的规则解释它为什么不是 bug,并说明这条性质的正面用途(提示:你希望保留一段 HTML 里的类名时)。
现在能解释什么
- 为什么"我写了类名却没生成"——它很可能是拼出来的(文本扫描抓不到),或者源码在扫描范围之外(
@source的边界); - 为什么"生成的比我写的多"——出现在注释、字符串、死代码里的完整类名,照样会被当 token 抽出来;
- 为什么"页面像有样式、组件却全裸"——那 4165 B 里是 theme 与 base,重置和变量都到位了,只有你的原子类没到;
- 为什么
@utility定义了却没产出、@apply用了却没留下源类——定义不是产出,内联不是引用; - 为什么颜色是
oklch()而不是 hex、.w-40里没有魔法数字——v4 把可调项收进了自定义属性,你改的是变量; - 为什么 minify 之后层语句变了形、修法却没失效——层序来自块的先后位置,不来自那句声明文本。
下一步:03 章 · antd:运行时生成与一张变量表——这一章讲的是构建期决定什么进文档,下一章把镜头切到运行期:同一个页面里三套主题为什么能共存,而它们的按钮类名结构一模一样。