把 Tailwind v4 理解成「v3 换了个更快的 JIT」,会在主题、变体和 @apply 上立刻失效。v4 的内核是 Oxide(Rust)+ Lightning CSS:扫描源码、生成原子类、做 @import / 嵌套 / 前缀,是一条 CSS 管线,不是「先用 JS 配一份 theme 对象再吐 CSS」。设计 token 进 @theme,变成原生 CSS 变量;工具类仍是一个类一条声明。约束来自变量和约定,不来自运行时 CSS-in-JS。

这篇文章只讲这条管线:token 怎样变成 bg-brand-500、层和变体怎样叠优先级、自定义为什么改走 @utility / @custom-variant、和组件边界怎么切。读完你应该能独立回答三个问题:为什么动态拼接的类名仍然不生效、暗色主题为什么不该复制两套颜色类、@apply 在 v4 里为什么要 @reference。

1. 原子类还在,变的是谁生成它们

v3:@tailwind base/components/utilities,tailwind.config.js 的 content 数组告诉扫描器去哪找类名。v4:一行 @import "tailwindcss",扫描器自己从项目里找,尊重 .gitignore。配置可以没有 JS 文件。

@import "tailwindcss";

@theme {
  --color-brand-500: oklch(0.63 0.2 250);
  --font-sans: "Inter", ui-sans-serif, system-ui;
  --spacing-18: 4.5rem;
  --breakpoint-3xl: 120rem;
}

--color-brand-500 不是随便起的变量名。命名空间决定工具类:color → bg-brand-500 / text-brand-500 / border-brand-500;font → font-sans;spacing → p-18 / m-18 / gap-18;breakpoint → 3xl:。写错命名空间,变量存在,类不存在。这是类型系统那种「非法状态不可表示」在 CSS 里的弱版:约定即生成规则。

Oxide 扫的是源码文本,不是 AST 语义。clsx('bg-' + color)、styles[dynamic]、从 CMS 回来的类字符串,扫描器看不见完整 token,就不会生成那条规则。生产里类丢失,先问是不是动态拼接,再问是不是文件在 node_modules 里没被扫到。后者用 @source:

@source "../node_modules/@acme/ui/dist";
@source not "../fixtures";

自动扫描不是「什么都能变」。它只是少写 content: []。静态完整性这条不变量从 v3 带到 v4,没有放松。

2. Oxide 和 Lightning CSS:构建期发生了什么

一次开发保存大致是:文件变更 → Oxide 扫类名差量 → 生成对应工具类 → Lightning CSS 做嵌套展开、@import 内联、语法降级和压缩。增量经常是个位数毫秒。所以「CSS 构建太慢」在 v4 项目里多半不是 Tailwind,是别的插件或整份 JS 打包。

它仍然是构建期。浏览器拿到的是 CSS 文件,不是一个运行时去 insertRule。主题切换能很快,是因为变量在运行时改,不是因为引擎在运行时编译新的工具类。hover:bg-brand-500 在产物里已经存在;暗色只是让 --color-brand-500 指向另一个值,或让 .dark 变体那条规则生效。

Lightning CSS 的浏览器基线比 PostCSS + Autoprefixer 的「尽力兼容 IE」更硬。v4 官方基线大约是 Safari 16.4+、Chrome 111+、Firefox 128+。要古董浏览器,留在 v3。这不是口味,是 @property、cascade layers、color-mix() 这些特性的地板。

3. @theme:token 进 CSS 变量

@theme 块里的变量会注册进 Tailwind 的主题,并默认暴露为 :root 上的 CSS 变量。组件里可以 background: var(--color-brand-500),和 bg-brand-500 指向同一份。设计系统和工具类终于共用一个真源。

@theme {
  --color-fg: var(--color-neutral-900);
  --color-bg: var(--color-neutral-50);
}

@layer base {
  .dark {
    --color-fg: var(--color-neutral-50);
    --color-bg: var(--color-neutral-950);
  }
}

暗色模式改变量,页面上 text-fg / bg-bg 跟着变。不要写 dark:bg-neutral-950 dark:text-neutral-50 复制每一个组件。复制是 v3 时期的权宜:那时 token 和类的对应要经过 JS config,运行时改 theme 不自然。v4 把权宜收回去。

@theme inline 一类写法让某些 token 只参与生成、少一层 var 间接。颜色透明度在 v4 走 color-mix():bg-brand-500/50 不再依赖独立的 --tw-bg-opacity 那套。读生成 CSS 时看到 color-mix,不要改回旧的 opacity 变量黑魔法。

间距、字体、圆角同样是变量。p-[13px] 这种任意值仍可用,但会把设计系统凿出洞。洞多了,页面上出现 13、14、15px 三种「差不多的空隙」,原子化的约束就死了。任意值是逃生口,不是默认。

4. 层、变体、优先级

v4 用原生 @layer:theme、base、components、utilities。层之间的优先级由层决定,不由源码顺序猜。utilities 层高于 base,所以 p-4 能盖掉 base 里的 p { margin: ... }。你自己写的未分层 CSS 会跑到层外,比所有层都高——这是「我加了一行全局 CSS,Tailwind 全失效」的常见根因。把它放进 @layer base 或 @layer components。

变体从左到右组合:dark:md:hover:bg-brand-500。v3 有些版本是右到左,迁移时选择器会换含义。group-、peer-、has-、not-、in- 可组合。@custom-variant 用来登记项目自己的模式,而不是再写一堆 plugin:

@custom-variant theme-midnight (&:where([data-theme="midnight"] *));

之后 theme-midnight:bg-black 就是合法工具类。变体是选择器模板,不是 JS 回调。能用选择器表达的,不要写插件。

容器查询成为一等变体:@container 标记容器,@md:p-8 按容器宽度而不是视口。组件在侧栏和主列要不同 padding,这是正确的轴。继续用 md: 视口变体给「本来是组件级」的布局,是把页面宽度误当成组件宽度。

5. @utility:自定义从插件回到 CSS

v3 自定义工具类:plugin(({ addUtilities }) => ...)。v4:

@utility tab-4 {
  tab-size: 4;
}

@utility tab-* {
  tab-size: --value(integer);
}

tab-4 从此能跟 hover:、md: 走,因为它登记进 utilities 层。写在随便一个 CSS 文件里的 .tab-4 { tab-size: 4 } 没有变体,也没有层,行为不同。

旧 theme() 函数不再是主路。要用 token,写 var(--color-brand-500) 或 --spacing(4) 这类 v4 提供的函数。@apply 仍能用,但在 CSS Modules / Vue SFC 的 <style> 里必须先 @reference 一份主题,否则 @apply flex 找不到 flex 是什么:

@reference "../../app.css";

.card {
  @apply rounded-lg p-4 bg-bg text-fg;
}

@apply 把原子类又糊回一坨组件类,等于放弃「在 JSX 里能看见布局」的优点,换来「这个 class 到底生成了什么」的间接。少量重复(flex items-center gap-2)抽 React 组件,不要抽成 @apply 工具。重复三次以上再抽。抽早了,你的 btn-primary 会变成第二套设计系统,和 @theme 抢真源。

6. 和组件的边界:类名爆炸在哪消化

原子化的成本是 JSX 上的类字符串。消化它的地方是 React 组件边界,不是 CSS 抽象层。<Button variant="primary" size="sm"> 内部写死那一串工具类;调用方不拼类。

const variants = {
  primary: "bg-brand-500 text-white hover:bg-brand-600",
  ghost: "bg-transparent text-fg hover:bg-neutral-100",
} as const;

type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
  variant?: keyof typeof variants;
};

export function Button({ variant = "primary", className = "", ...rest }: ButtonProps) {
  return <button className={`inline-flex items-center gap-2 rounded-md px-3 py-1.5 ${variants[variant]} ${className}`} {...rest} />;
}

className 逃生口要有,但调用方每用一次逃生口就是一次设计系统泄漏。ESLint 可以限制某些包路径不准出现 className=。这比再上 CSS-in-JS 便宜。

cva / tailwind-variants 这类库仍能用,它们只是字符串拼盘。不要在运行时根据 theme 对象生成新类名——扫描器看不见运行时。变体表必须是静态字面量。

7. 主题切换:改变量,不要分叉整棵树

运行时切主题:在 html 上改 class="dark" 或 data-theme,CSS 变量变,所有 bg-bg / text-fg 跟着变。React 不必为切主题重渲整棵树。闪白是另一件事:首屏 HTML 必须已经带对主题 class,否则第一帧用 :root 的亮色,水合后再切。把主题写进 cookie / localStorage 的内联脚本放 <head>,在 React 之前跑。

export function ThemeScript() {
  const js = `document.documentElement.dataset.theme = localStorage.getItem("theme") || "light";`;
  return <script dangerouslySetInnerHTML={{ __html: js }} />;
}

不要用 useEffect 在水合后才读 localStorage 设 dark——那正好制造 CLS 和闪白。性能那篇会再碰到:主题闪是 LCP/CLS 问题,不是 Tailwind 问题。

多主题(品牌白标)同样走变量集。@theme 给默认,[data-brand=acme] 覆盖一组 --color-*。不要为每个品牌复制一份组件。

8. 和 CSS Modules、原生 CSS、CSS-in-JS 混用

混用时必须规定谁赢。推荐:

  • 布局、间距、颜色、排版:Tailwind 工具类 + @theme。
  • 真正的图形(复杂 clip-path、关键帧插值):原生 CSS,放 @layer components。
  • 第三方库自带的 class:不要 @apply 去「统一」,用包装组件在外层补间距。

CSS-in-JS(runtime)和 Tailwind 叠在同一节点上,优先级靠生成顺序和 !important,排障成本高于任何一边单独用。新代码不要再为「这个按钮要动态颜色」上 runtime CSS-in-JS——动态颜色改变量。编译期 CSS-in-JS(zero-runtime)可以和原子类共存,但仍有两套 token 的风险。能删就删。

v3 的 tailwind.config.js 用 @config "./tailwind.config.js" 还能挂一段时间。新 token 不要再往 JS 里加。迁移是单向的:JS 只出不进。

9. 取舍

  • 原子类 + 变量: 约束强、产物可预测、主题便宜。代价是 JSX 嘈杂、动态类名不可见。
  • CSS Modules: 局部作用域清爽。代价是 token 漂移、变体(hover/dark/container)要手写。
  • runtime CSS-in-JS: 动态极强。代价是运行时、缓存失效、和 RSC 不熟——样式生成跑在客户端,RSC 的零 bundle 被打穿。
  • @apply 组件类: 看起来整洁。代价是间接、和原子类双真源。

默认:Tailwind 做设计系统,React 做组合,原生 CSS 做图形。三层都去「抽象按钮」,就会有三个 Button。

10. 排障

  1. 类写了没样式。 动态拼接;或文件没扫到(@source);或类名打错命名空间(--color-brand 写成 --brand-color)。看生成 CSS 里有没有这条规则,比看 JSX 快。
  2. 我的全局样式被盖 / 我盖不住 Tailwind。 层。未分层的文件在层外;@layer utilities 里的东西盖 base。用 DevTools 看规则属于哪一层。
  3. @apply 报 unknown utility。 缺 @reference。或那个类本就是动态任意值,不存在于主题。
  4. 暗色不生效。 自定义了 @custom-variant dark 却和 prefers-color-scheme 那套叠错选择器;或只改了 class 没改变量。先看 html 的 class / data,再看 --color-bg 的计算值。
  5. 包体积暴涨。 扫到了 node_modules 里的故事书或测试夹具。@source not。任意值爆炸(每个组件一个 w-[347px])也会堆规则。
  6. HMR 丢样式。 多份 CSS 入口重复 @import "tailwindcss",各生成一套。全应用一个 CSS 入口。
  7. 旧浏览器花了。 基线不够。不要给 v4 加一串 Autoprefixer 幻想。

11. 设计系统怎么进仓库

一份 app.css(或 packages/ui/theme.css)是 token 的唯一入口。颜色用 OKLCH,避免 HSL 调亮度时色相发灰。间距用一条尺度(4px 网格),不要 3、4、5 混用。断点与容器查询分开命名,避免 md 既是视口又是容器。

组件库(packages/ui)依赖这份 CSS,不在每个组件再声明 --color-brand-500。应用可以覆盖变量,不能覆盖组件内部的任意值。PR 检查:新增 bg-[#...] 要说明为什么 token 不够。不够就加 token,不要加任意值。

和 Next 的关系:把 app.css 引进根 layout。不要在每个 page 再 import 一次 Tailwind。RSC 不执行 CSS-in-JS;原子类是字符串,对 RSC 友好。这是它在 App Router 里仍占上风的物理原因,不是社区口味。

12. 小结

v4 的原理是「扫描源码生成原子类 + 用 @theme 把 token 变成 CSS 变量」。Oxide 加快的是这条构建管线,没有取消静态扫描这条不变量。主题走变量,组合走 React 组件,图形走原生 CSS。动态类名、层外 CSS、@apply 双真源,是三类独立的事故。下一篇把「非法状态」从 CSS 命名空间换到 TypeScript:条件类型和映射类型是编译期函数,不是注释。


7. Rust 编译器 Oxide 与 Lightning CSS 极致加速内幕

Tailwind CSS v4 放弃了基于 JavaScript/PostCSS 的旧式解析引擎,完全用 Rust 重新编写了名为 Oxide 的专用核心编译器,并深度集成了 Lightning CSS 语法树转换器。

编译管线重构对比:
[Tailwind v3 (PostCSS)]:
模板扫描 (JS 正则) -> 匹配配置 (tailwind.config.js) -> PostCSS AST 遍历 -> 生成 CSS (耗时: 300ms ~ 1500ms)

[Tailwind v4 (Rust Oxide)]:
SIMD 硬件加速模板扫描 -> 原生 Rust 内存状态机解析 -> Lightning CSS 直接吐出字节流 (耗时: 15ms ~ 35ms!)

7.1 原生 CSS 级联层级(@layer)与特异性重构

在 v4 中,所有的工具类样式被直接编译进现代浏览器原生的 CSS 级联层(Cascade Layers)中:

@layer theme, base, components, utilities;

@layer utilities {
  .flex { display: flex; }
  .p-4 { padding: calc(var(--spacing) * 4); }
}

关键工程优势:

  • 特异性(Specificity)扁平化:传统项目中,组件库自定义样式常常因为特异性权重不足,不得不滥用 !important;在 @layer 的规范下,高优先级的层级天然胜出,样式覆盖清晰可预测;
  • CSS 原生变量驱动:整个设计令牌(Design Tokens)直接暴露为浏览器原生 --color-* 变量,支持在运行期直接通过 DevTools 或 element.style.setProperty 毫秒级动态切换主题配色,无需任何 CSS 重新生成。

8. 微前端与 Shadow DOM 隔离环境下的实战指南

在微前端(Microfrontends)或 Web Components 宿主环境中,不同子应用常常运行不同版本的 Tailwind。通过声明独特的 CSS 前缀与局部样式作用域,能够确保子应用样式与主基座彻底物理隔离,避免样式污染和全局变量覆盖。