Tailwind CSS v4 迁移记:新引擎、CSS 变量主题和那些不再需要的配置

我们一个中型管理后台用 Tailwind v3 用了快两年,tailwind.config.js 滚到了三百多行——一堆 extend 出来的颜色、间距、字号,还有几个 safelistcontent 的 glob 配置。每次新人想加个主题色,都得先翻这个文件半天搞清楚改哪。v4 出来一段时间、生态插件也陆续跟上之后,我趁着一个迭代的空档把它升了。

这篇记一下整个迁移过程:新引擎实际快了多少、配置怎么从 JS 搬进 CSS、@theme 和 CSS 变量怎么用,还有几个升级时实打实踩到的坑。

Oxide 引擎,到底快在哪

v4 最大的底层变化是换了引擎,新引擎叫 Oxide,核心部分用 Rust 重写了。v3 时代的 Tailwind 是纯 JS + PostCSS 管线,扫描文件、生成工具类、做内容匹配全在 Node 里跑。v4 把最吃性能的部分搬到了 Rust,配合更高效的增量机制。

实际体感:我们项目全量构建 CSS 这一步,v3 大概要两秒多,v4 跑下来稳定在四五百毫秒,热更新(改一个 class 重新生成)几乎是瞬时的,以前那种偶尔卡个几百毫秒的感觉没了。对大项目这个差距会更明显,因为 v4 的增量构建只重新处理变化的部分。

引擎换了之后,还有个连带好处是不用再手动配 content 了。

安装方式变了,content 配置可以扔了

v3 装 Tailwind 是 tailwindcss + postcss + autoprefixer 三件套,然后在 tailwind.config.js 里写 content: ['./src/**/*.{js,ts,jsx,tsx}'] 告诉它去哪扫 class。v4 把这套简化了。

现在官方拆出了独立的集成包。如果你用 PostCSS:

1npm install tailwindcss @tailwindcss/postcss
1// postcss.config.mjs
2export default {
3  plugins: {
4    '@tailwindcss/postcss': {},
5  },
6}

注意 autoprefixerpostcss-import 现在都不用单独装了,v4 内置处理了厂商前缀和 @import

我们项目是 Vite,直接用专门的 Vite 插件,连 PostCSS 配置都省了,更快:

1npm install tailwindcss @tailwindcss/vite
1// vite.config.ts
2import { defineConfig } from 'vite'
3import tailwindcss from '@tailwindcss/vite'
4
5export default defineConfig({
6  plugins: [tailwindcss()],
7})

最舒服的是 content 配置没了。v4 内置了自动内容检测,它会自己去扫项目里的源文件找 class,自动忽略 .gitignore 里的东西和二进制文件。我们 v3 那套 glob 配置直接删了,迁移到现在没出过"某个 class 没生成"的问题。

如果有特殊目录确实需要手动指定来源(比如某个被 gitignore 但又含 class 的目录),可以在 CSS 里用 @source 显式加:

1@source "../node_modules/some-ui-lib/dist";

三条 @tailwind 指令变成一行 @import

v3 的入口 CSS 长这样:

1@tailwind base;
2@tailwind components;
3@tailwind utilities;

v4 直接一行搞定:

1@import "tailwindcss";

改完这一处,配合上面的插件配置,Tailwind 就能跑了。base/components/utilities 这三层现在内部用 CSS @layer 组织,你不需要手动声明它们。

配置从 JS 搬进 CSS:@theme 和 CSS 变量

这是 v4 改动最大、也最影响日常写法的地方。v3 的设计哲学是"配置在 JS 文件里",v4 反过来——配置在 CSS 里,用 @theme 块定义。

我们那三百行 tailwind.config.js,绝大部分内容搬进了 CSS。举个例子,v3 里这样扩展颜色和字号:

1// tailwind.config.js (v3)
2module.exports = {
3  theme: {
4    extend: {
5      colors: {
6        brand: { 500: '#3b82f6', 600: '#2563eb' },
7      },
8      spacing: { 18: '4.5rem' },
9    },
10  },
11}

v4 里写进入口 CSS 的 @theme

1@import "tailwindcss";
2
3@theme {
4  --color-brand-500: oklch(0.62 0.19 259);
5  --color-brand-600: oklch(0.55 0.22 263);
6  --spacing-18: 4.5rem;
7}

命名是有约定的:--color-* 对应颜色工具类,--spacing-* 对应间距,--font-*--text-*--radius-* 各管一类。定义 --color-brand-500 之后,bg-brand-500text-brand-500border-brand-500 这些就自动有了,不用单独声明。

而且 @theme 里定义的每个 token 都会被暴露成真正的 CSS 变量输出到 :root。这意味着我可以在任何地方直接 var(--color-brand-500),包括在不走 Tailwind 的内联样式、第三方组件、甚至 JS 里读取:

1.custom-thing {
2  /* 直接用 token,不必走工具类 */
3  box-shadow: 0 1px 3px var(--color-brand-500);
4}

这点比 v3 强太多。v3 的主题值藏在 JS 配置里,运行时拿不到,要复用得自己再定义一份 CSS 变量。v4 一份定义,工具类和 CSS 变量两头都能用,主题切换、动态换肤这种需求一下子顺了很多。

颜色我顺手都换成了 oklch()。v4 默认调色板就是用 oklch 定义的,它在感知亮度上更均匀,做色阶(比如从 100 到 900)过渡更自然,宽色域屏幕上也更鲜艳。新加品牌色我都按 oklch 写,和默认色系风格统一。

破坏性变化,几个必须手动改的点

升级不是无痛的,有几个默认值变了,不注意会让 UI 悄悄变样。

默认边框颜色变了。 v3 里 border 类默认用一个浅灰(gray-200)。v4 改成默认用 currentColor,也就是跟随文字颜色。结果就是你写了 border 但没指定颜色的地方,边框颜色全变了。我们项目好多卡片就是裸 border,升级后边框突然变深。修法是要么显式加颜色 border border-gray-200,要么在全局统一设置一个默认值:

1@layer base {
2  *, ::after, ::before {
3    border-color: var(--color-gray-200);
4  }
5}

默认 ring 宽度变了。 v3 的 ring 默认是 3px,v4 默认改成 1px。我们好多聚焦态用的就是裸 ring,升级后焦点环明显变细。要保持原样就显式写 ring-3。这个坑很隐蔽,因为它不报错,只是视觉上细了一圈,得对着设计稿才发现。

space- 的实现变了。* space-x-* / space-y-* 这种给相邻子元素加间距的工具类,v4 改了底层选择器实现(从基于 margin 的 > * + * 改成了用 :not() 的方式),性能更好,但在某些复杂布局或者子元素顺序动态变化时,行为和 v3 会有细微差别。官方也明确建议能用 flex/gridgap-* 就尽量用 gap。我们借这次升级把一批 space-x 改成了 gap,反而更干净。

默认 placeholder、ring 颜色等也有微调,这些一般影响小,但迁移时最好全页面过一遍视觉回归。

container 查询内置了

v3 想用容器查询得装 @tailwindcss/container-queries 插件,v4 直接内置,插件可以卸了。用法就是父元素加 @container,子元素用 @-前缀的断点:

1<div class="@container">
2  <div class="grid grid-cols-1 @md:grid-cols-2 @xl:grid-cols-3">
3    <!-- 根据容器宽度而非视口宽度切换列数 -->
4  </div>
5</div>

我们一个卡片组件在不同宽度的栏位里复用,以前靠传 props 控制布局,现在 @container 一上,组件自己根据所在容器宽度自适应,复用性好了不少。

自定义工具类用 @utility

v3 里自定义工具类是在 JS 配置里用 addUtilities 插件 API 写,或者在 CSS 里塞进 @layer utilities。v4 给了个更直接的 @utility 指令:

1@utility content-auto {
2  content-visibility: auto;
3}

这样定义之后,content-auto 就是个正经工具类,能被 hover:md: 这些变体修饰,也参与正确的层级排序。比 v3 往 @layer utilities 里手写一段要规范。带参数的也支持,比如做一个按间距刻度取值的工具类:

1@utility inset-p-* {
2  padding: --value(--spacing-*, integer, [length]);
3}

--value() 里那几个参数是分优先级尝试的:先按 --spacing-* 这类主题变量去匹配(比如 inset-p-4 会去找 --spacing-4),找不到再尝试把 * 部分解析成裸整数或者方括号包起来的任意长度值(比如 inset-p-[10px])。我一开始想当然写成 --value(integer) 想着"随便传个数字就行",结果发现这样定义出来的工具类完全绕开了主题变量,同一个间距刻度在不同工具类之间就对不上了——真要做参数化工具类,最好还是先接住主题 token,裸值只留做最后的退路。

旧插件兼容性

这块要单独验。v4 改了插件和配置的底层机制,第一方插件(@tailwindcss/typography@tailwindcss/forms)都更新了 v4 兼容版本,升上去就行。

第三方插件就得看维护情况了。我们用的一个组件库自带 Tailwind 预设是基于 v3 的 JS 配置写的,v4 虽然还支持通过 @config "./tailwind.config.js" 加载旧版 JS 配置做过渡,但这条路只是过渡期的台阶,不是长久之计。我的处理是:能换成 v4 原生写法的就换,暂时换不了的插件用 @config 桥接,并标记成技术债等上游更新。

1@import "tailwindcss";
2@config "./tailwind.config.js"; /* 过渡期加载旧 JS 配置 */

动态值不再需要 safelist

v3 有个老大难问题:如果 class 名是运行时拼出来的(比如 bg-${color}-500),静态扫描扫不到,得手动写进 safelist 强制生成,否则上线就丢样式。我们 v3 配置里那段 safelist 维护得很痛苦,加个动态色就得记着同步。

v4 鼓励的做法是别拼 class,改用 CSS 变量 + 任意值。颜色这种动态的,直接把值通过 style 传成变量,class 里引用:

1function Tag({ color }) {
2  return (
3    <span
4      style={{ '--tag': color }}
5      className="bg-[var(--tag)] text-white px-2 rounded"
6    >
7      {/* ... */}
8    </span>
9  )
10}

这样无论 color 是什么运行时值,都不需要 Tailwind 预先生成对应 class,safelist 整段删掉。迁移时我把所有靠拼接 class 的地方都改成了这种变量注入的写法,既解决了扫描问题,逻辑也更直白。

用官方迁移工具打底

别手动改,先跑官方迁移工具:

1npx @tailwindcss/upgrade

它会自动帮你做一批机械活:装新依赖、把 @tailwind 指令换成 @import、尽量把 tailwind.config.js 里的 theme 搬进 CSS 的 @theme、更新一些重命名了的工具类(v4 改了几个类名,比如阴影、模糊相关的 shadow-sm 之类的命名调整)。它要求 Node 20 以上,最好在干净的 git 分支上跑,跑完逐个 diff 检查。

工具能省掉八成体力活,但上面那几个语义级的破坏性变化(border 颜色、ring 宽度、space 行为)它处理不了,得人工对着视觉回归收尾。我的流程是:开新分支,跑 upgrade 工具,把所有页面在本地过一遍截图对比,把 border 和 ring 那几个全局默认值补上,再让 QA 走一轮,没问题才合。

真实踩到的几个坑

第一个是 border 颜色那个,全站卡片边框集体变深,前面讲过了,是最先被发现的,因为太显眼。

第二个是 ring 宽度,焦点环变细,这个隐蔽,是我自己过视觉回归时特意切到键盘聚焦模式,一页页点过去才发现焦点环变细了。提醒一句,焦点态这种细节升级后一定要专门测。

第三个是一个第三方日期组件直接样式崩了,因为它的样式依赖 v3 的某个工具类命名,v4 改名了。最后用 @config 桥接旧配置临时解决,等组件库出 v4 版本。

第四个是 CI 里有个旧的 PostCSS 配置还引用着 autoprefixer,v4 内置前缀后这个变成多余且冲突,构建告警。把 autoprefixerpostcss.config 和依赖里一起删干净才清净。

产物体积和 CSS 层级

升级后我对比了一下打包出来的 CSS 体积。v4 生成的 CSS 略小一点,但更重要的变化是它生成的样式全部组织进了原生 CSS @layer(base / components / utilities 几层)。

这个对处理优先级冲突帮助很大。v3 时代偶尔会遇到工具类被自定义 CSS 盖掉、或者反过来盖掉自定义 CSS 的优先级官司,得靠 !important 或者调顺序硬解。v4 用原生 @layer,层级关系是显式声明的:utilities 层永远比 base 层优先,和源码书写顺序无关。我们之前几个靠 !important 顶上去的地方,升级后梳理 @layer 关系就解决了,把 !important 拿掉了。

如果你有自定义样式想插进某一层,可以显式指定:

1@layer components {
2  .btn {
3    /* 这段会在 components 层,被 utilities 工具类正常覆盖 */
4    border-radius: var(--radius-md);
5    background: var(--color-brand-500);
6  }
7}

这样写 .btn 上再叠 rounded-none 这种工具类时,工具类能正常生效覆盖它,符合直觉,不用打优先级补丁。

动态主题切换变简单了

借这次升级,我把项目的暗色模式也重构了。v3 时代我们的暗色方案是给一堆元素手写 dark:bg-xxx,类名翻倍,改一个色得满项目搜。v4 因为 token 直接是 CSS 变量,我改成了变量切换的方式。

思路是用 @theme 定义语义化 token(不是具体色值,而是"背景""前景"这类角色),然后在不同模式下重新赋值这些变量:

1@import "tailwindcss";
2
3@theme {
4  --color-bg: oklch(1 0 0);
5  --color-fg: oklch(0.2 0 0);
6  --color-surface: oklch(0.97 0 0);
7}
8
9@layer base {
10  .dark {
11    --color-bg: oklch(0.18 0 0);
12    --color-fg: oklch(0.95 0 0);
13    --color-surface: oklch(0.24 0 0);
14  }
15}

业务代码里只写 bg-bgtext-fgbg-surface 这类语义类,不再写 dark: 变体。切换主题就是给根元素加/去 .dark 类,所有变量整体换一套,过渡甚至能加 transition。一处定义、全局生效,比 v3 满屏 dark: 干净太多。

这套也方便做多套主题(不只是明暗),加一个 .theme-blue 类、重赋一遍变量就是一套新皮肤,业务代码完全不动。

任意值和 calc 里直接用变量

v4 因为 token 都是真 CSS 变量,在任意值语法里组合起来很顺手。比如要一个"品牌色的一半间距"或者基于 token 算出来的尺寸:

1<div class="w-[calc(var(--spacing-18)*2)]">
2  <span class="text-[var(--color-brand-500)]">...</span>
3</div>

v3 里这种要么把值硬编码进任意值,要么绕一圈。v4 因为变量是现成的,calc、clamp 这些 CSS 函数里直接引用 token,既保持了和主题的联动,又能做精细计算。我们一个响应式标题就用了 text-[clamp(1.5rem,var(--spacing-18),3rem)] 这种写法,跟着主题 spacing 走。

升级前最好先盘一遍自定义 CSS

有个经验教训:迁移前花点时间盘一下项目里所有非 Tailwind 的自定义 CSS,特别是那些直接 hardcode 了颜色值的。

我们项目里散落着一些组件级 .module.css,里面写死了 #3b82f6 这种和主题色重复的值。v3 时代它们和 Tailwind 配置是两套各管各的,主题色一改这些就对不上。趁 v4 把 token 暴露成 CSS 变量,我把这些 hardcode 全替换成了 var(--color-brand-500),从此组件 CSS 和 Tailwind 主题共享一套真理来源,再也不会出现"改了 Tailwind 的品牌色但某个组件还是老蓝色"的割裂。

这步迁移工具不会帮你做,得手动 grep 颜色值排查。但做完之后整个项目的颜色管理终于统一了,这是 v4 这套 CSS-first + 变量暴露的设计带来的额外红利,值得顺手做掉。

整体下来,迁移花了大概一天半,工具帮我省了大头,真正费时间的是逐页核对那几个默认值变化。值不值?构建快了三四倍、配置从三百行 JS 缩成几十行 CSS、主题 token 还能直接当 CSS 变量复用——对一个要长期维护的项目,这笔升级账是正的。我现在新起项目基本默认 v4 了,但建议老项目升级一定要挑个有视觉回归测试或者愿意手动过一轮设计走查的迭代去做,那几个静默的默认值变化最容易在事后被产品发现。