前面几节里,我们已经用工具类搭过布局、按钮、表单和常见组件。做到这里,一个项目通常已经“能用”了:页面能还原,响应式能切换,交互状态也有反馈。接下来最容易出现的误区,是把 v4 的新能力当成一张必做清单——看到容器查询就给每个组件加 @container,看到 :has() 就把选择器写成逻辑谜题,看到 3D 变换就让所有卡片一起翻面。
先别急着炫技。成熟项目真正关心的不是“我用了多少新语法”,而是几件很朴素的事:新增一个状态时,样式能不能稳定生成;组件换到侧栏后,布局会不会失效;主题值有没有继续失控;构建变慢时,能不能定位;升级后,旧页面有没有悄悄变样。
这一节就沿着一个已经上线的管理后台往前走。它不是缺少视觉效果,而是从“能跑”进入了“要长期维护”的阶段。我们会把 v4 的高级能力逐个放进真实问题里,并且每次都问同一个问题:它消除了哪一种复杂度,又引入了哪一种复杂度? 如果前者不明显,就先不用。
高级能力的价值不在于代码看起来新,而在于删掉重复判断、缩小影响范围,或把隐含约定变成可检查的规则。如果某种写法只有作者自己能解释,它多半还没有成熟到适合进入团队代码。
我们先给“值得用”一个可操作的标准。以后碰到 has-*、3D、任意值或自定义变体,不必凭喜好争论,顺着下面四个问题走一遍就够了。
先写清楚当前故障或重复劳动。例如“同一个商品卡放进主内容区和侧栏时,要靠页面层级传入两套布局参数”,这就是明确问题;“容器查询很酷”不是问题。
再看浏览器是否已经拥有这份状态。勾选状态、ARIA 状态、容器宽度和特性支持都能由 CSS 直接判断,就不必再在 JavaScript 里复制一份只为改样式的布尔值。
检查代码的局部可读性。一个组件内部的 has-checked: 很容易理解;跨越多层祖先、叠上多个 group 和 peer 的选择器虽然能工作,却可能把维护成本推给下一个人。
最后写出降级结果。高级视觉效果失效时,内容是否仍可读、按钮是否仍可点、状态是否仍可辨认?说不清降级结果,就不该把它放到关键流程上。
这把尺子会得出一个看似保守、其实很实用的结论:源码检测和迁移边界属于基础设施,应该优先处理;容器查询和语义状态变体能减少组件耦合,通常值得采用;现代渐变与 3D 多半属于增强层,要看产品目标;极长的组合变体、巨型生成清单和到处散落的任意值,则需要主动克制。
你也可以把判断压缩成一句话:先保证生成正确,再保证结构可复用,最后才追求表现力。 顺序反过来,项目往往会得到一张漂亮但不稳定的截图。
Tailwind 的构建器不会理解 React、Vue 或模板语言的业务含义。它会把源码当作普通文本,寻找长得像工具类的完整标记,再为能识别的标记生成 CSS。这个机制非常快,也解释了一个最常见的线上事故:开发者能算出类名,扫描器却从来没见过完整类名。
先把这条流水线画出来:扫描器只接收源码里已经完整出现的候选类名,运行时才拼出来的碎片不会因为“最后看起来正确”就自动补进产物。

下面这种组件在人脑看来没有问题,构建时却存在缺口:
function StatusBadge({ tone, children }) {
return (
<span className={`rounded-full bg-${tone}-100 px-2 py-1 text-${tone}-800`}>
{children}
</span>
);
}运行时,tone="emerald" 的确能拼出 bg-emerald-100。但源码中只有 bg-${tone}-100 这段碎片,没有完整的 bg-emerald-100。结果通常很迷惑:元素有圆角和内边距,颜色却没出现;某个颜色在别的页面偶然使用过时,它又可能“碰巧正常”。这不是缓存,也不是类名优先级,而是产物中根本没有那条规则。
稳妥写法是把有限业务状态映射到完整类名:
const toneStyles = {
success: "bg-emerald-100 text-emerald-900 ring-emerald-600/20",
warning: "bg-amber-100 text-amber-950 ring-amber-600/20",
danger: "bg-rose-100 text-rose-900 ring-rose-600/20",
};
function StatusBadge({ tone = "success", children }) {
const styles = toneStyles[tone] ?? toneStyles.success;
return (
<span className={`inline-flex rounded-full px-2 py-1 text-sm ring-1 ${styles
这段代码的可见结果很确定:三种状态分别得到绿色、琥珀色和红色反馈;传入未知状态时回到成功样式。更重要的是,业务值与视觉组合建立了明确边界。设计师要求警告态更醒目时,我们改一个映射项,不用在字符串拼接规则里猜哪个色阶会被使用。
不要为了保住动态拼接,粗暴生成整个色板的所有组合。它会掩盖组件真正支持哪些状态,还可能让输出和构建工作无谓膨胀。有限状态优先用静态映射;只有类名来自外部内容、数据库或独立包,且无法改成映射时,才考虑显式生成。
下面的实验台把“候选类名写得不完整”和“文件根本没进入扫描范围”放在同一张工作台上。先分别选择字符串拼接与完整映射,再切换文件位置;观察任务是判断每次失败属于类名形态问题,还是源码边界问题,并查看页面给出的 @source 调整结果。
v4 会自动寻找项目中的源码,但“自动”不是“扫描硬盘上的一切”。被版本控制忽略的目录、node_modules、二进制文件、CSS 文件和常见锁文件不会作为普通模板来源。构建命令的工作目录也会影响默认检测起点。于是,monorepo、共享组件包、代码生成目录和多入口应用最容易出现边界问题。
假设后台应用的入口样式在 apps/admin/src/app.css,组件却来自工作区里的 packages/ui:
@import "tailwindcss" source("./");
@source "../../../packages/ui/src";这里有两个动作。source("./") 把自动检测的基准明确到入口样式所在的应用源码,避免启动命令从仓库根目录执行时含义漂移;@source 再把共享组件目录注册进来。可见结果是:共享按钮里完整出现的类名会进入后台应用的 CSS,而仓库里无关服务不会因为“都在同一个根目录”就被顺手扫描。
你可以把自动检测理解成工坊自带的收件范围:范围内的文件会自己进来,围栏外的共享组件则要通过明确入口登记。图里最值得看的不是箭头多少,而是哪一侧负责声明边界。

如果一个应用有完全独立的 CSS 产物,还可以关闭自动检测,再逐个登记来源:
@import "tailwindcss" source(none);
@source "../pages";
@source "../components";
@source "../../../packages/ui/src";
@source not "../components/legacy";这不是每个项目都需要的写法。它适合边界清楚、多产物并行的大仓库;小型单入口应用使用默认检测更省心。显式来源越多,路径调整时越需要维护。我们换来的是可预测范围,不是免费的性能按钮。
@source inline()确实有少数类名不在源码里,例如后台允许内容编辑器选择一组受控样式,选择结果存进数据库。此时可以明确生成允许集合:
@import "tailwindcss";
@source inline("{hover:,focus-visible:,}bg-{sky,emerald,amber}-{100,600}");它会生成列出的颜色、色阶以及相应交互变体。判断它是否合理,只看两点:集合是否有限,负责它的人是否能解释每一个维度。若你准备写一条覆盖几十种颜色、全部色阶、所有状态的表达式,停下来重新设计内容模型;“编辑器可以任意拼类名”本身就不是可靠接口。
v4 的入口可以从一行普通 CSS 导入开始:
@import "tailwindcss";它看起来只是少写几行,实际变化更大。主题、来源、自定义工具和自定义变体都可以在 CSS 中声明;构建器使用原生层叠层组织主题、基础规则、组件规则和工具类。我们不再把“设计系统”和“最终样式”拆成两个语言世界,而是让它们进入同一条 CSS 因果链。
下面是一份适合成熟项目的精简入口:
@import "tailwindcss";
@source "../../packages/ui/src";
@theme {
--color-brand-50: oklch(0.97 0.02 250);
--color-brand-600: oklch(0.55 0.19 255);
--color-brand-700: oklch(0.48 0.18 255);
--font-sans: "Inter", "PingFang SC", sans-serif;
--radius-panel: 0.875rem;
--ease-productive: cubic-bezier(0.2, 0, 0, 1);
}
@layer base {
body {
background: var
有了这些主题变量,bg-brand-600、hover:bg-brand-700、rounded-panel 和 font-sans 都成为受约束的接口;普通 CSS 或动画代码也能读取对应变量。页面上的提交按钮会使用同一套品牌色与圆角,而不是一个组件读 JavaScript 配置,另一个组件又手写十六进制颜色。
@theme 和 :root 很像,但用途不同。需要参与工具类生成的设计令牌放进 @theme;只是某段业务 CSS 内部使用、并不希望出现对应工具类的变量,放进 :root 或局部选择器。把所有变量都塞进 @theme 会把内部实现误当成公共 API。
Tailwind 的层次可以理解为 theme、base、components、utilities。工具类位于后面的层,所以一个局部工具类能覆盖较宽泛的基础或组件规则。这个顺序很适合“组件给出默认外观,调用处做少量调整”的模式。
@layer components {
.panel-shell {
border-radius: var(--radius-panel);
background: white;
padding: calc(var(--spacing) * 5);
box-shadow: var(--shadow-sm);
}
}<section class="panel-shell p-8 shadow-none">
当前页面需要更大的留白,并关闭默认阴影。
</section>可见结果是组件仍保留白底和统一圆角,但调用处的 p-8 shadow-none 接管了内边距与阴影。这是清晰的覆盖关系。
真正危险的是在层外随手写规则。未分层样式会参与另一套优先级关系,可能压过你原本期望生效的工具类。遇到“类名存在却覆盖不了”时,不要马上加 !important;先检查这条规则属于哪个层、入口是否重复导入、选择器是否来自第三方未分层样式。!important 能让一处变绿,却会让下一次覆盖更困难。
下面这张图把 theme、base、components 和 utilities 画成四层透明设计板。顺着层次看一遍,你会发现“工具类能做局部调整”依赖的是明确的层叠秩序,不是谁临时把选择器写得更重。

旧的 JavaScript 配置可以通过 @config 显式加载,适合渐进迁移,但它不会像过去那样被自动发现。CSS 与旧配置并存时,要把谁负责主题、谁负责插件写清楚,避免同一个颜色在两处各有一个版本。迁移阶段允许双轨,稳定状态不该长期双轨。
v4 的默认颜色以 OKLCH 表达,并能利用现代屏幕的更宽色域。渐变也不再只有线性方向:可以直接创建径向、圆锥渐变,控制色标位置,并选择插值方式。能力变多以后,最需要补上的反而是设计判断。
先看一个管理后台的容量环。它需要表达“已使用约四分之三”,圆锥渐变在这里有信息含义:
<div
class="grid size-32 place-items-center rounded-full
bg-conic from-brand-600 from-0% via-brand-600 via-74%
to-slate-200 to-74%"
aria-label="存储空间已使用 74%"
>
<div class="grid size-24 place-items-center rounded-full bg-white text-center">
<strong class="text-2xl text-slate-950">74%</strong>
</div>
</div>读者看到的是一圈从起点延伸到 74% 的品牌色,剩余部分为灰色,中间保留数值。颜色不是唯一信号,因为 74% 和 aria-label 同时传达了信息。若只是想让卡片“更有科技感”,用圆锥渐变铺满背景就没有这么充分的理由。
径向渐变更适合表现局部光源或注意中心:
<section
class="rounded-2xl bg-radial-[at_20%_0%]
from-sky-100 from-0% via-white via-45% to-white to-100%
p-8"
>
<h3 class="text-xl font-semibold text-slate-950">本周发布检查</h3>
<p class="mt-2 max-w-xl text-slate-600">亮部停在标题附近,正文区域保持干净。</p>
</section>可见结果不是一块均匀的彩色卡片,而是左上角有轻微亮区,视线自然落到标题,正文仍保持足够对比度。如果把 from-0% 改成 from-30%,亮色会覆盖更大面积;如果把中心从 20% 0% 移到 80% 100%,视觉重心也会跟着移动。工具类在这里描述的是可验证的画面,不是“渐变语法清单”。
插值方式会影响两种颜色之间经过什么路径。默认方式通常能得到较均匀的感知过渡;品牌对颜色路径有明确要求时,可以用类似 bg-linear-to-r/srgb 或 bg-linear-to-r/oklch 的修饰符对比,再用真实设备检查。不要因为 OKLCH 数值看起来规整,就假设对比度一定合格;宽色域屏与普通屏的呈现不同,文字、焦点环和状态色仍要逐一验收。
把三种渐变放在一起更容易形成直觉:线性渐变表达方向,径向渐变强调一个中心,圆锥渐变适合环形进度或角度关系。先看它们各自在传递什么,再决定颜色应该停在哪里。

现代颜色最适合进入“设计令牌层”,由少量主题变量控制。业务组件优先消费 brand、success、danger 这类稳定角色,不要把大量一次性的 OKLCH 任意值散落到标签上,否则换色时还是要满项目搜索。
3D 变换很容易让人兴奋,也最容易被滥用。一个后台页面里,十张卡片都随着鼠标倾斜,不会自动变得高级;它更可能让文字难读、命中区域不稳定,还给容易眩晕的用户增加负担。
3D 真正合适的场景,是“前后”“层级”或“空间位置”本来就是信息的一部分。比如学习卡片正面是问题,背面是答案。这个关系适合翻面:
<article class="group perspective-distant">
<div
class="relative h-52 transform-3d transition-transform duration-500
group-hover:rotate-y-180 group-focus-within:rotate-y-180
motion-reduce:transition-none"
>
<section
class="absolute inset-0 grid place-items-center rounded-2xl bg-slate-950
p-6 text-white backface-hidden"
>
<h3 class="text-xl font-semibold">容器查询观察谁的宽度?</h3>
</section>
<section
class
这里有四层职责,少一层都可能出现“能转但不对”的效果:祖先的 perspective-distant 提供观察距离;中间场景使用 transform-3d 保留子元素的三维位置;背面预先 rotate-y-180;两个面都用 backface-hidden 隐藏反面。悬停或内部获得焦点时,场景旋转 180 度,答案转到正面。
如果三条旋转轴还容易混在一起,可以用同一张卡片做参照:横向、纵向和深度方向改变的是不同空间关系,而透视距离决定这些变化看起来有多强。没有透视时,旋转后的卡片更像被压扁;有透视时,远近关系才会出现。

接下来把渐变与 3D 放进同一个可调界面。依次切换线性、径向和圆锥渐变,再调整插值方式、透视距离与旋转轴;观察任务是找出“只改变表面配色”和“真正改变空间深度”的控件,并确认移除透视后画面发生了什么。
注意,这个示例仍只是视觉演示。生产中的学习卡最好提供真实按钮,点击后更新 aria-expanded,让触屏、键盘与辅助技术获得同一个状态。hover 不能承担关键交互,3D 也不能代替内容结构。
什么时候不该用?导航、支付、表单错误和删除确认都不适合靠翻面揭示关键信息;密集数据表也不适合用透视制造层次。它们的目标是快速阅读和准确操作。此时二维的边框、间距、颜色和显隐关系更可靠。即使要用,也应配合 motion-reduce 移除过渡,并确认变换不会制造新的滚动或覆盖相邻控件。
复杂变体的共同价值,是让浏览器已经知道的状态直接参与样式。它们不负责创造状态,只负责读取状态。把这条边界想清楚,has-*、not-*、data-*、aria-* 和 supports-* 就不会变成一团前缀汤。
has-*:父元素根据后代状态变化表单选项是最直观的例子。选中状态本来就在单选框上,标签容器可以直接读取:
<label
class="flex cursor-pointer items-center gap-3 rounded-xl border border-slate-200 p-4
has-checked:border-brand-600 has-checked:bg-brand-50
has-focus-visible:ring-2 has-focus-visible:ring-brand-600"
>
<input class="size-4 accent-brand-600" type="radio" name="plan" />
<span>
<strong class="block text-slate-950">团队版</strong>
<span class="text-sm text-slate-600">最多 20 位成员</span>
用户选中单选框后,整行出现品牌色边框与浅色背景;键盘焦点进入时,容器显示焦点环。我们没有额外维护 isSelected,也没有在点击事件里手动切换父元素类名。
has-* 不适合无限向上寻找“某个深层子树里可能出现的某个类”。状态离目标越远,结构耦合越重。若选择器必须知道五层内部实现,应该重新考虑组件边界,或把稳定状态提升成容器自己的 data-* 属性。
data-* 与 aria-*:样式跟着组件状态走许多无样式组件库会维护 data-state="open" 一类属性。样式可以直接绑定它:
<div
data-state="open"
class="rounded-xl border border-slate-200 bg-white
data-[state=open]:shadow-lg data-[state=closed]:opacity-80"
>
<p class="p-4 text-slate-700">当前面板处于展开状态。</p>
</div>ARIA 属性也能驱动外观,但它首先是语义契约。下面的按钮在展开时换底色并旋转图标;JavaScript 或组件库必须同步更新 aria-expanded,样式不会替你实现展开逻辑:
<button
type="button"
aria-expanded="true"
class="group flex w-full items-center justify-between rounded-lg px-3 py-2
text-left aria-expanded:bg-slate-100"
>
<span>筛选条件</span>
<span class="transition-transform group-aria-expanded:rotate-180" aria-hidden="true">⌄</span>
</button>可见结果与语义状态使用同一来源,不会出现“面板已经关了,但 React 里另一个 isActive 还让箭头朝上”的双状态漂移。不要为了方便上色而伪造 ARIA;错误的无障碍状态比没有样式更糟。
not-*:表达真正的排除条件not-* 适合条件本身就是排除关系的地方。例如按钮获得键盘焦点后,焦点反馈优先于普通悬停:
<button
class="rounded-lg bg-brand-600 px-4 py-2 text-white
hover:not-focus-visible:bg-brand-700
focus-visible:ring-2 focus-visible:ring-brand-600 focus-visible:ring-offset-2"
>
保存修改
</button>鼠标悬停且按钮没有可见焦点时,背景变深;键盘焦点存在时,焦点环保持主要反馈。若一句自然语言无法顺畅读出叠加条件,就拆开组件状态,别继续堆前缀。
supports-*:增强可以有,基础能力不能丢特性查询适合做渐进增强:
<aside
class="bg-slate-950/95 text-white
supports-backdrop-filter:bg-slate-950/70
supports-backdrop-filter:backdrop-blur-xl"
>
即使浏览器不支持背景模糊,文字仍有稳定的深色底。
</aside>支持背景滤镜时,侧栏获得半透明模糊;不支持时,它仍是高不透明度深色背景。这里的关键不是检测到了新能力,而是基础样式先成立。也可以用 not-supports-[display:grid]:flex 明确写降级布局,但如果项目浏览器基线已保证 Grid,就没有必要为每个属性重复检测。
这些变体看起来各管一摊,实际都在回答同一个问题:状态现在存在哪里。图中的五条线路分别从后代、排除条件、数据属性、无障碍属性和浏览器能力进入组件,只有状态来源真实存在,组合出来的样式才可靠。

下面的状态实验台允许你直接改变这些来源。先选中表单项,再切换通知属性与折叠状态,最后查看能力检测的降级结果;观察任务是为每次外观变化指出唯一的状态来源,并检查语义状态与画面有没有出现不同步。
响应式断点观察视口,这对页面骨架很合适,却不一定适合可复用组件。同一张订单卡可能出现在宽主栏、窄侧栏、弹窗和仪表盘网格里。视口有 1440 像素,不代表侧栏里的卡片也宽。
容器查询把判断依据改成组件实际获得的空间:
<section class="@container/order rounded-2xl border border-slate-200 bg-white p-4">
<article class="grid gap-4 @md/order:grid-cols-[1fr_auto] @md/order:items-center">
<div>
<p class="text-sm text-slate-500">订单 A-2048</p>
<h3 class="mt-1 font-semibold text-slate-950">企业协作套餐</h3>
<p class="mt-1 text-sm text-slate-600 @max-sm/order:hidden">
12 个席位,下一次续费在 8 月 30 日。
</
当订单卡容器小于相应阈值时,内容纵向排列,并在特别窄的空间隐藏次要说明;容器变宽后,信息与操作区变成两列。把它从主栏拖进侧栏,不需要父页面传入 compact,也不需要因为视口仍然很宽而错误地保持横排。
命名容器 /order 在存在嵌套容器时尤其有用。没有名字,子元素会匹配最近的合适容器;布局一旦包上新的局部容器,判断对象可能悄悄改变。名字让“观察谁”写在类名里。
同一个组件放进窄侧栏与宽主区时,变化的只是它拿到的空间。下面这张图把上下排列、左右排列和连续变宽的过程放在一起,帮助你把“组件响应容器”与“页面响应视口”分开。

不要把容器查询当成响应式断点的替代品。页面导航何时折叠、整体栏数如何变化,仍由视口断点表达更清楚;卡片、工具栏和嵌入式小组件根据局部空间重排,才是容器查询的主场。
采用容器查询前还要检查一件事:组件的容器由谁建立。如果每个内部节点都随手加 @container,后代可能匹配到错误层级。通常由可复用组件的外壳声明容器,内部只消费查询变体;页面布局不要替组件猜内部阈值。
v4 重写了构建引擎,也提供了更直接的 Vite、PostCSS 和 CLI 集成。你会感到开发反馈更快,但不要把别人的基准数字当成自己项目的承诺。构建时间还包含框架编译、模块解析、文件系统、源码映射和其他插件;一个慢项目不一定慢在 Tailwind。
集成方式应与项目工具链对齐:Vite 项目优先使用专门的 @tailwindcss/vite 插件;必须走 PostCSS 的项目使用 @tailwindcss/postcss;脚本式构建使用独立的 @tailwindcss/cli。不要在同一个入口上同时挂两种集成,否则可能重复处理 CSS,还会让“到底谁生成了这份产物”变得难查。
性能排查时,把一次模糊的“很慢”拆成三个样本:冷启动完整构建、增加一个从未出现过的工具类、只修改已有类附近的文本。三者差异能帮助判断问题是在初始化、生成新规则,还是整个开发服务器的刷新链路。每次只改一个变量,并记录同一台机器、同一份代码下的结果。
先确认只有一个 Tailwind 入口和一种构建集成。重复导入、旧 PostCSS 配置未删除、Vite 与 PostCSS 同时处理,都会让耗时和产物难以解释。
再检查源码范围。工作目录是否落在仓库根部?是否意外包含大型生成目录?共享包是否漏扫?用明确的 source()、@source 和 @source not 修正边界,不要凭感觉扩大到整个仓库。
查看显式生成集合。巨大的 @source inline() 虽然能修复“样式没生成”,也会要求构建器生成大量不会用到的组合。把它缩成业务真正允许的枚举。
构建快也不等于页面快。生成 CSS 的速度、CSS 体积、浏览器样式计算和动画流畅度是四个问题。为了“少写 CSS”而堆出极深的 has() 关系,或者让几十个大面积元素持续做模糊和 3D 动画,都可能把成本转移到浏览器。基础设施优化和运行时体验要分别测。
遇到样式不生效时,最省时间的办法不是连续换类名,而是判断问题在哪一层。
先看类名是否在源码中以完整字符串出现。若来自动态拼接,改成静态映射。若完整类名位于共享包、生成目录或 monorepo 另一个工作区,检查 @source 和基准路径。若类名只来自外部数据,建立有限的 @source inline() 集合。
一个很实用的信号是“部分工具类生效,只有运行时组合的颜色或尺寸不生效”。这通常指向检测问题,而不是整个 Tailwind 没有安装。
打开浏览器样式面板,看规则是否被划掉。检查层叠层、选择器权重、类名顺序与状态条件。特别留意未分层的业务 CSS、第三方组件样式和迁移期遗留的 !important。不要用另一个更强的 !important 把因果链继续埋深。
状态变体还要确认状态真的存在:aria-expanded="false" 不会匹配 aria-expanded:;data-state 拼写与值必须一致;has-checked: 需要后代控件真实处于选中状态;容器查询需要祖先建立容器。
如果规则存在且没有被覆盖,却只在旧设备失效,就检查浏览器基线。v4 的核心实现依赖现代 CSS,目标至少是 Safari 16.4、Chrome 111 和 Firefox 128。项目若必须支持更早版本,不要幻想靠几条 supports-* 就把整个框架降级;继续使用 v3.4,直到产品的浏览器范围允许升级。
对非核心视觉增强,supports-* 可以提供局部降级;对框架本身的运行边界,它不是兼容层。这个区别很重要。
把问题切成“有没有生成”“有没有赢得层叠”“浏览器能不能执行”,大多数样式故障都会迅速缩小范围。三层一起猜,才会产生反复清缓存、重启开发服务器却没有结论的情况。
从 v3 到 v4 是主版本迁移,不能只改依赖版本然后看首页能不能打开。一个稳妥过程要保留对比基线,并优先处理会改变视觉结果的规则。
先核对产品的浏览器范围。只要必须支持低于 Safari 16.4、Chrome 111 或 Firefox 128 的环境,就暂时留在 v3.4。兼容要求是迁移的入口条件,不是收尾检查。
在独立分支中固定现有页面截图、关键交互和构建时间。迁移前没有基线,迁移后就只能靠记忆判断“好像差不多”。
确认 Node.js 20 或更高版本,再运行升级工具。它可以迁移大量配置与模板写法,但产出的差异必须逐项审阅,尤其是自定义插件、复杂变体和组件库边界。
调整构建集成与 CSS 入口:PostCSS 插件改用独立包,Vite 项目切到专门插件,CLI 脚本使用独立 CLI 包,旧的 指令改为 。
几个细节特别容易漏掉。v4 的裸 border 默认颜色跟随 currentColor,旧项目若依赖过去的默认灰色,要给边框显式写色;裸 ring 的宽度和颜色也变了,原来依赖较粗蓝色焦点环的组件,应明确使用宽度与颜色;阴影、圆角和模糊的部分名称调整后,相同类名可能呈现不同尺寸。hover 还会考虑主输入设备是否真正支持悬停,依赖触屏“点一下触发 hover”的交互本来就该改成明确状态。
迁移期可以用 @config 载入旧 JavaScript 配置,让改造分批发生。但要给它设退出条件,例如“本迭代迁移主题变量,下个迭代迁移插件”。长期同时维护 CSS 主题与 JavaScript 主题,只会让一次升级变成永久双重配置。
还有一种常见冲动:既然自动工具能改,就一次把所有页面连同视觉重构一起做掉。我不建议这么做。先让 v4 在视觉上等价,再引入容器查询、新渐变和 3D 增强。升级差异与设计差异混在同一批提交里,回归时很难判断是谁造成的。
把迁移过程画成路线之后,分工会更清楚:工具负责搬运可机械转换的部分,人负责判断兼容边界、审查视觉差异并复测性能。任何一步没有留下可比较的结果,后面的绿色对勾都只能算“看起来成功”。

最后用一张决策台把容器查询与迁移边界对上。先拖动组件插槽宽度,比较 @sm 与 sm 各自观察什么;再填写浏览器基线、构建链、主题与回归条件。观察任务是尝试制造一次“容器适合升级、项目却暂时不适合迁移”的结果,并说清楚这两个判断为什么可以同时成立。
现在回到我们的管理后台。它从“能用”走到“稳健”,并不是因为每个组件都加了新特性,而是因为几个原本隐含的关系变清楚了:
@source 明确进入产物。@theme,工具类、普通 CSS 与动画共享同一组令牌;基础规则留在合适层级,局部工具类仍能覆盖。has-*、aria-*、data-* 读取真实 DOM 状态;特性查询只做增强,不承担框架级兼容。这也是整门课最后应该落下来的产出:你不只是能把一张设计稿翻译成一串工具类,还能判断样式从哪里生成、为什么覆盖、由什么状态驱动,以及一个组件换到新环境后是否仍然成立。
下面这组练习不要求继续堆语法,而是检查你有没有形成判断。
最后做一次真实改造:从你的项目里找一个依赖动态类名、页面宽度参数或重复 JavaScript 样式状态的组件,只选择其中一个问题处理。改完后写三句话:旧写法为什么不稳定,新能力删掉了哪一份复杂度,它又增加了什么约束。能把这三句讲清楚,比再背十个高级类名更接近“真正会用 Tailwind”。
最后隔离其他插件与开发服务器环节。若修改纯文本也同样慢,问题很可能不在新增工具类生成;继续盯着 Tailwind 配置,只会浪费时间。
@tailwind@import "tailwindcss"最后按组件而不是按文件验收。按钮、输入框、卡片、弹窗和列表分别检查默认态、悬停、焦点、禁用、暗色与响应式布局,再处理视觉差异。