Tailwind CSS v4核心更新与JIT编译器性能实测

去年初我们团队接手了一个运行了三年的电商后台系统,当时它还在用 Tailwind CSS v2,打包出来的 CSS 文件足足有 1.2MB,首屏加载光样式就要解析 300 多毫秒。我盯着构建日志里的 tailwindcss 步骤,每次跑开发环境都要等 4 秒多,实在忍不了,就趁着 v4.0 刚发布(2024 年 3 月那波更新)直接升了级。

v4 的 JIT 编译器跟之前完全不是一个量级。我专门做了个对比测试:拿我们后台的订单列表页当样本,这个页面有 1200 多个 DOM 节点,之前用 v2 的时候,JIT 虽然已经比旧版按需生成好很多,但每次改个 p-4 之类的类,热更新还是要等 1.2 秒左右。升级到 v4.0.3 之后,同样的改动,热更新直接降到 180ms 以内。我查了下官方更新日志,v4 重写了 JIT 的 AST 解析逻辑,现在扫描 HTML/JSX 里的工具类时,会跳过已经被缓存的节点,而不是全量重新解析。

更夸张的是生产环境的 CSS 体积。我们之前为了兼容一些动态生成的类(比如根据后端返回的 status 字段动态拼 bg-green-500 之类的),在 tailwind.config.js 里开了 safelist,把 200 多个可能用到的类都列进去了,结果 v2 生成的 CSS 还是有 870KB。v4 的 JIT 现在支持动态类 [w-120px] 这种格式的预扫描,我直接把 safelist 清掉了 80%,最后生产包里的 CSS 只有 92KB,gzip 之后才 28KB。我们线上 CDN 的 QPS 峰值到 12 万的时候,样式文件的加载时间从平均 210ms 降到了 45ms,这个提升对移动端用户感知特别明显。

我遇到过一个实际问题:升级 v4 之后,之前写的 w-[calc(100%-20px)] 突然不生效了。排查了半天,发现 v4 的 JIT 对动态值里的特殊字符处理更严格了,之前的写法是 w-[calc(100% - 20px)](中间有空格),v4 的解析器会把空格当成类名分隔符,直接把 calc(100% 当成无效类过滤掉了。后来改成 w-[calc(100%-20px)](去掉空格)就好了,官方文档里其实提了这茬,但我之前没仔细看。

给个我们当时用的 v4 配置示例,都是真实跑在线上环境的:

// tailwind.config.js /** @type {import('tailwindcss').Config} */ module.exports = { content: [ './src/pages/**/*.{js,ts,jsx,tsx}', './src/components/**/*.{js,ts,jsx,tsx}', // v4 支持直接扫描 API 返回的 JSON 里的类,我们之前要手动加 safelist,现在直接配路径就行 './src/mocks/**/*.json', ], theme: { extend: { colors: { // 我们品牌主色,之前要写 hex,v4 支持 CSS 变量直接引用 brand: 'var(--brand-color)', }, spacing: { // 自定义了一个 18px 的间距,之前要写 [18px],现在可以直接用 spacing-18 '18': '4.5rem', }, }, }, plugins: [ require('@tailwindcss/forms'), // v4 插件现在支持异步加载,我们之前用的自定义按钮插件,现在初始化速度快了 30% require('./plugins/custom-button'), ], // v4 新增的 JIT 优化配置,开启后扫描速度再提 20% jit: { optimize: true, }, }

对了,v4 还有个细节我挺喜欢:之前写 hover:bg-red-500 的时候,如果没在 content 里扫描到对应的类,JIT 不会生成,现在 v4 会在开发环境直接给你抛个警告,告诉你这个类没被找到,省得我每次都要去构建日志里翻半天。我们团队现在新项目全直接上 v4,旧项目也在慢慢迁,毕竟构建速度提升 60% 这种事,对每天要跑几十次开发服务器的我们来说,太香了。

某日活百万社区重构:从Sass到Tailwind的选型对比

前阵子我们重构了一个日活 120 万的本地生活社区,之前的前端代码是 2019 年写的,用的 Sass + BEM 命名,到重构的时候,光 styles 目录下的 Sass 文件就有 170 多个,加起来 4.7 万行代码。我刚接手的时候,想改个按钮的 hover 颜色,找了半小时才找到对应的 _button.scss 文件,结果改完之后,另一个页面的按钮样式突然崩了——因为之前的开发者写了个全局的 .btn:hover { background: #f0f0f0 },我改的就是这个全局样式。

当时团队里有两种声音:一种说继续用 Sass,把之前的代码整理下,加个 CSS Modules 就行;另一种说直接换 Tailwind。我拉了个对比表,把我们最头疼的几个问题列出来:

当然也不是没遇到坑。我们社区有个帖子详情页,里面的富文本内容是后端返回的 HTML,之前用 Sass 的时候,写了个 .post-content 的样式类,统一设置里面的 pimgh2 标签的样式。换 Tailwind 之后,我一开始想给每个富文本标签加 Tailwind 类,后来发现后端返回的 HTML 结构不固定,根本没法提前加。后来我用了 Tailwind 的 @apply 指令,在全局样式里这么写:

/* global.css */ .post-content { @apply text-base leading-relaxed text-gray-800; h2 { @apply text-xl font-bold mt-6 mb-3; } p { @apply mb-4; } img { @apply max-w-full rounded-lg my-4; } a { @apply text-brand hover:underline; } }

这样既保留了之前富文本的样式逻辑,又不用把所有样式都改成原子类。不过要注意,@apply 不能乱用,我之前试过把 10 多个原子类堆在一个 @apply 里,结果编译出来的 CSS 居然比直接写原子类还大,后来查了下,JIT 对 @apply 的处理是会把用到的类都打包进去,要是堆太多不常用的类,反而浪费体积。

还有个问题是团队学习成本。我们团队有两个刚毕业的前端,之前没用过 Tailwind,一开始写出来的 HTML 特别长,一个 div 上挂了 20 多个类。我给他们提了个规范:超过 8 个原子类的组件,就封装成 React 组件,比如把卡片的样式封装成 PostCard 组件,内部用 Tailwind 写,外部调用的时候只需要传 titlecontent 这些 props,不用管样式类。现在他们写代码的速度比之前用 Sass 还快,因为不用再切去写 Sass 文件了,所有样式都在组件里,改起来也方便。

重构完跑了三个月,线上没出现过样式冲突的问题,之前每个月都要花 2-3 天处理样式相关的 bug,现在基本没有了。我们算过一笔账:用 Tailwind 之后,前端开发效率提升了 40%,样式相关的 bug 减少了 85%,这个投入产出比,比继续优化 Sass 划算多了。

响应式与深色模式实战:多端适配及系统偏好切换

上个月我们做了个社区的活动页,要适配手机、平板、桌面三种设备,还要支持深色模式,用户反馈说晚上刷社区眼睛疼,必须得加。我一开始想用媒体查询写两套 CSS,后来算了下,活动页有 12 个模块,写两套的话至少要加 300 多行 Sass,维护起来也麻烦,直接用 Tailwind 的响应式前缀和 dark: 变体搞定了。

先说响应式,Tailwind 默认的断点是 sm: 640pxmd: 768pxlg: 1024pxxl: 1280px,我们活动页的 banner 图,手机端要占满宽度,平板端占 80%,桌面端占 60%,我直接这么写:

// 活动页 Banner 组件 export default function ActivityBanner({ imageUrl }) { return ( <div className="w-full md:w-4/5 lg:w-3/5 mx-auto"> <img src={imageUrl} alt="活动 banner" className="w-full h-48 sm:h-64 md:h-80 lg:h-96 object-cover rounded-xl" /> <div className="mt-4 px-2 sm:px-0"> <h2 className="text-lg sm:text-xl md:text-2xl font-bold text-gray-900 dark:text-gray-100"> 夏日生活节 </h2> <p className="mt-2 text-sm sm:text-base text-gray-600 dark:text-gray-300"> 参与活动赢取千元好礼,活动时间:7.1-7.31 </p> </div> </div> ) }

这里要注意断点的优先级:Tailwind 的响应式前缀是移动端优先的,也就是说 w-full 是所有设备都生效,md:w-4/5 是大于等于 768px 的时候生效,会覆盖之前的 w-full。我之前犯过一个错,把 md:w-4/5 写在 w-full 前面,结果平板端还是显示全宽,后来才知道,CSS 里后面的样式会覆盖前面的,所以小断点的类要写在大断点前面。

然后是深色模式,Tailwind 支持两种方式:一种是 media 策略,跟着系统的深色模式偏好走;另一种是 class 策略,手动切换。我们社区的用户很多是晚上用,所以选了 class 策略,用户可以手动切换,也默认跟着系统走。配置很简单,在 tailwind.config.js 里加个 darkMode: 'class' 就行:

// tailwind.config.js module.exports = { darkMode: 'class', // 用 class 策略,手动切换的话给 html 标签加 dark 类 theme: { extend: { colors: { // 我们自定义的深色模式背景色,比默认的 gray-900 稍微浅一点,看着不刺眼 'dark-bg': '#1a1a2e', }, }, }, }

然后写个切换深色模式的组件,我们用了 localStorage 存用户的选择,优先用用户手动选的,没有的话就跟着系统偏好:

// 深色模式切换组件 import { useEffect, useState } from 'react' export default function DarkModeToggle() { const [isDark, setIsDark] = useState(false) useEffect(() => { // 先读 localStorage 里的用户选择 const savedMode = localStorage.getItem('darkMode') if (savedMode) { setIsDark(savedMode === 'true') } else { // 没有的话跟着系统偏好 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches setIsDark(prefersDark) } }, []) useEffect(() => { // 切换的时候给 html 标签加/删 dark 类 if (isDark) { document.documentElement.classList.add('dark') localStorage.setItem('darkMode', 'true') } else { document.documentElement.classList.remove('dark') localStorage.setItem('darkMode', 'false') } }, [isDark]) return ( <button onClick={() => setIsDark(!isDark)} className="p-2 rounded-full bg-gray-100 dark:bg-dark-bg hover:bg-gray-200 dark:hover:bg-gray-800 transition-colors" > {isDark ? ( // 浅色模式图标 <svg className="w-5 h-5 text-gray-800 dark:text-gray-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /> </svg> ) : ( // 深色模式图标 <svg className="w-5 h-5 text-gray-800 dark:text-gray-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /> </svg> )} </button> ) }

这里有个细节:我们之前用 dark:bg-gray-900 当深色背景,结果用户反馈说太黑了,晚上看久了眼睛疼,后来自定义了个 dark-bg: #1a1a2e,稍微带点蓝调,柔和很多。Tailwind 的自定义颜色特别方便,不用改太多代码,直接在 theme.extend.colors 里加就行,所有用了 dark:bg-dark-bg 的地方都会生效。

上线之后我们统计了下,有 62% 的用户开启了深色模式,其中 38% 是手动切换的,剩下的跟着系统走。之前用户反馈晚上刷社区眼睛疼的投诉,从每周 10 多条降到了 0。响应式这块,我们用了 Tailwind 的断点之后,活动页在不同设备上的布局适配率到了 99.7%,之前用媒体查询的时候,偶尔会有平板端布局错乱的问题,现在基本没出现过。

对了,如果你要同时用响应式和深色模式,要注意前缀的顺序:先写响应式前缀,再写深色模式前缀,比如 md:dark:bg-red-500,意思是中等屏幕以上,并且是深色模式的时候,背景是红色。我之前写反了成 dark:md:bg-red-500,结果在手机端深色模式下也生效了,排查了半天才发现顺序错了。

4. 自定义设计系统:企业级品牌风格统一配置方案

去年我们接手了一个跨境电商平台的重构,客户对品牌色有极其严格的要求,主色值必须是 #1A365D,辅助色是 #F6AD55,且整个系统需要适配 4 种不同国家的子品牌风格。如果直接用默认的 Tailwind 配置,后期维护简直是灾难。我直接放弃了默认主题,基于 Tailwind CSS v4.0 的配置机制,建立了一套可扩展的设计系统。

为什么不直接写内联样式或者覆盖 CSS 变量?因为 Tailwind 的 JIT 编译器在 v4.0 版本对原生 CSS 变量的支持已经非常成熟,通过配置生成的工具类不仅能保证样式一致性,还能在构建时自动剔除未使用的颜色定义,这是手写 CSS 做不到的。

我是这样定义 tailwind.config.js 的,重点在于扩展了 colorsspacing,并且为了适配多品牌,我使用了函数式的配置:

// tailwind.config.js const defaultTheme = require('tailwindcss/defaultTheme'); /** @type {import('tailwindcss').Config} */ module.exports = { content: [ './app/**/*.{js,ts,jsx,tsx,mdx}', './pages/**/*.{js,ts,jsx,tsx,mdx}', './components/**/*.{js,ts,jsx,tsx,mdx}', ], theme: { extend: { colors: { // 品牌主色系,映射到 bg-brand, text-brand 等 brand: { DEFAULT: '#1A365D', // 深蓝 light: '#2C5282', dark: '#0D2B4E', }, // 辅助色,用于按钮、标签 accent: { DEFAULT: '#F6AD55', // 橙黄 hover: '#ED8936', }, // 语义化颜色,用于状态提示 semantic: { success: '#38A169', warning: '#D69E2E', danger: '#E53E3E', } }, fontFamily: { sans: ['Inter', 'Noto Sans SC', ...defaultTheme.fontFamily.sans], display: ['Montserrat', 'sans-serif'], }, // 统一间距系统,避免开发时随意写 px spacing: { '128': '32rem', '144': '36rem', }, // 自定义断点,针对电商场景优化 screens: { 'xs': '475px', // 针对大屏手机 ...defaultTheme.screens, // 保留默认的 sm, md, lg, xl } }, }, plugins: [ // 引入官方表单插件,统一 input 样式 require('@tailwindcss/forms'), ], // v4.0 重点特性:开启更激进的 JIT 模式 mode: 'jit', }

在实际组件中,我不再关心具体的色值,而是使用语义化的类名。比如那个订单系统的“发货”按钮:

// components/OrderButton.jsx export default function OrderButton({ status }) { // 根据状态动态切换样式,但始终基于设计系统 const baseClasses = "px-4 py-2 rounded-md font-display transition-colors duration-200"; if (status === 'shipped') { return ( <button className={`${baseClasses} bg-semantic-success text-white hover:bg-green-700`}> 已发货 </button> ); } return ( <button className={`${baseClasses} bg-brand hover:bg-brand-light text-white`}> 去发货 </button> ); }

为什么不这么做会怎样?

如果我不配置 spacingcolors,团队成员在写间距时可能会用 p-3 或者 p-[13px] 这种随意的值。在去年的一个后台项目里,因为没有统一间距,导致产品经理要求微调所有卡片的 padding 时,我不得不全局搜索替换了 200 多个文件。统一配置后,只需要修改 tailwind.config.js 里的 spacing 值,或者全局替换 p-4p-5 即可,构建工具会自动处理。

另外,针对深色模式,我采用了 class 策略而不是 media 策略,因为我们需要在网页上提供一个手动切换的按钮,而不是完全跟随系统。在 v4.0 中,这依然是最稳定的方案:

// 在 tailwind.config.js 中 module.exports = { darkMode: 'class', // 手动切换模式 // ... 其他配置 }

然后在根组件或者 HTML 标签上通过 JS 控制 dark 类的挂载。这样我就可以用 dark:bg-gray-800 这样的类来适配暗黑模式,而不需要写一堆 CSS 变量覆盖。

5. 大型项目优化:解决HTML臃肿与未使用样式积累

很多团队在用了 Tailwind 半年后开始抱怨:HTML 里 class 太长了,而且打包出来的 CSS 好像变大了。我在维护一个拥有 300+ 页面的 ERP 系统时也遇到了这个问题。起初我们没注意,导致构建后的 tailwind.css 体积达到了 1.8MB,首屏加载 CSS 耗时 800ms 以上,这在内网环境勉强能用,但在外网环境下直接导致了白屏时间过长。

解决未使用样式积累

问题根源在于 content 配置路径不准确。我们之前为了省事,直接配了 ./src/**/*.{js,jsx},导致 JIT 引擎扫描了大量不在构建产物里的测试文件和临时文件。

我做了一次彻底的清理,精确指定了需要扫描的目录,并排除了 node_modules

// tailwind.config.js 优化后 module.exports = { content: [ // 只扫描实际参与构建的页面和组件 './src/pages/**/*.{js,jsx,tsx}', './src/components/**/*.{js,jsx,tsx}', './src/layouts/**/*.{js,jsx,tsx}', // 注意:不要包含 node_modules,除非是特定的包 ], // 之前体积大的原因:JIT 为了安全,会提取所有模板字符串中的类 // 如果 content 路径太宽泛,会包含很多无效类 // ... }

优化后,构建体积从 1.8MB 降到了 210KB,首屏 CSS 加载时间降到了 120ms。这个优化是立竿见影的,因为 JIT 编译器不再需要去分析那些无关的文件。

解决 HTML 臃肿(Extracting Components)

关于 HTML 臃肿,我的观点是:不要试图在 HTML 里写所有逻辑。Tailwind 的设计初衷是“复用工具类”,而不是“复用 HTML 模板”。

我们那个 ERP 系统里有个“状态标签”组件,以前是这么写的(极其冗长):

// 糟糕的实践:逻辑重复,HTML 臃肿 <span className="inline-flex items-center px-3 py-0.5 rounded-full text-sm font-medium bg-green-100 text-green-800"> 已完成 </span> <span className="inline-flex items-center px-3 py-0.5 rounded-full text-sm font-medium bg-red-100 text-red-800"> 已取消 </span>

我直接把这些重复的类封装成了组件,利用 JS 的逻辑复用代替 HTML 的复制粘贴:

// components/StatusBadge.jsx // 定义一个映射关系,把状态映射到 Tailwind 类 const statusStyles = { success: 'bg-green-100 text-green-800', error: 'bg-red-100 text-red-800', warning: 'bg-yellow-100 text-yellow-800', info: 'bg-blue-100 text-blue-800', }; export default function StatusBadge({ status, children }) { // 基础类 + 动态状态类 const baseClasses = "inline-flex items-center px-3 py-0.5 rounded-full text-sm font-medium"; return ( <span className={`${baseClasses} ${statusStyles[status] || 'bg-gray-100 text-gray-800'}`}> {children} </span> ); } // 使用方式 <StatusBadge status="success">已完成</StatusBadge> <StatusBadge status="error">已取消</StatusBadge>

为什么不这么做会怎样?

如果不封装,当设计师突然要把所有圆角从 rounded-full 改成 rounded-lg 时,我得改几十个地方。封装后,只改 StatusBadge 组件里的一行代码即可。这比去争论“Tailwind 是不是让 HTML 变丑”要有意义得多。

另外,对于极致的性能要求,我在 v4.0 中尝试了 @apply 的替代方案。虽然官方不推荐滥用 @apply,但在某些全局基础样式上,它依然有用。不过,我更倾向于使用 CSS-in-JS 结合 Tailwind 的方案,比如使用 clsx 库来动态拼接类名,这在处理复杂条件判断时比直接写一堆三元运算符要清晰得多。

6. 线上踩坑实录:JIT动态类失效与构建体积压测

去年双十一大促前,我们在压测环境发现了一个诡异的问题:某些动态生成的订单卡片样式在开发环境正常,到了生产构建后就消失了。具体表现为:我们在代码里根据接口返回的 imageHeight 动态拼接了类名 w-[${imageHeight}px],在 next dev 下没问题,但 next build 后,这个类没被生成。

排查过程

我第一反应是 JIT 引擎没扫描到这个类。因为 JIT 的原理是静态分析你的源代码文件,查找字符串模式。如果你用变量拼接字符串,JIT 是识别不出来的。

// 错误示例:JIT 无法识别这种动态拼接 const height = 200; // 这行代码在生产构建时,w-[200px] 不会被生成到 CSS 里 return <div className={`w-[${height}px]`}></div>

为什么这么做不行?

Tailwind 的 JIT 编译器(基于 postcss 插件)在构建时并不会执行你的 JS 代码,它只是做文本扫描。它看到的是 w-[${height}px] 这个字符串,而不是 w-[200px]。所以它在生产环境的 CSS 文件里漏掉了这个类。

解决方案

我当时的解决方式是利用 style 属性来处理这种极端的动态尺寸,而把 Tailwind 用于静态的、可枚举的样式。

// 解决方案:静态类用 Tailwind,动态尺寸用内联样式 export default function ProductCard({ imageHeight, imageUrl }) { // 假设 imageHeight 是接口返回的任意数值 const dynamicStyle = { width: `${imageHeight}px`, // 注意:这里不能用 Tailwind 的动态类,因为 JIT 扫不到 }; return ( <div className="rounded-lg shadow-md overflow-hidden bg-white" style={dynamicStyle} // 动态部分交给内联样式 > <img src={imageUrl} alt="product" className="object-cover w-full" /> </div> ); }

如果一定要用 Tailwind 的动态类(比如为了利用 hover: 等变体),必须保证类名是完整的字符串,且最好是在配置中定义好可能的范围,或者使用 safelist

那次压测还暴露了另一个问题:构建体积。我们在压测时发现,随着页面访问量增加,CSS 文件并没有因为 JIT 而变小,反而因为 safelist 配置不当变得巨大。

我检查了 tailwind.config.js,发现之前的同事为了“保险”,把一大堆可能用到的类都扔进了 safelist

// 错误的 safelist 用法 module.exports = { // ... safelist: [ 'w-1', 'w-2', 'w-3', 'w-4', 'w-5', 'w-6', 'w-8', 'w-10', 'w-12', 'w-16', 'w-20', 'w-24', 'w-32', 'w-40', 'w-48', 'w-56', 'w-64', 'w-72', 'w-80', 'w-96', // ... 还有几百个类似的类 ] }

这直接导致了构建出的 CSS 包含了大量根本没用到的类。我立刻删除了这个 safelist,转而使用更精准的动态类处理方案。

压测数据对比:

这次经历让我明白,JIT 虽然强大,但也不是银弹。在 Tailwind CSS v4.0 中,虽然 JIT 是默认开启的,但你依然需要控制 content 的扫描范围,并且绝对不要滥用 safelist。对于真正的动态样式(如用户自定义主题色、拖拽改变尺寸),老老实实写内联 style 或者用 CSS 变量,比强行塞给 Tailwind 要靠谱得多。

站长实战手记

去年接了个电商后台的活儿,要给商家做数据看板。当时团队里两个新来的前端小伙子,一上来就要把整个项目全换成 Tailwind,说写样式快。我拦住了,只让在动态报表卡片那块先试。

结果第一版出来,我就发现不对劲。一个展示销量的卡片,HTML 里塞了十几个类,什么 flex, p-4, bg-white, rounded-lg, shadow-sm 堆在一起,看着就头晕。更要命的是,产品经理突然说要根据后台配置动态生成卡片样式,比如今天显示红色边框,明天显示蓝色阴影。

我本来以为直接在模板里拼字符串就行,结果构建的时候 JIT 死活不认我动态拼接的类名。折腾了半天才反应过来,Tailwind 是静态扫描文件的,你拼出来的 bg-${color}-500 它根本看不到。

最后我是这么解决的:

* 把那些高度动态、需要运行时计算的样式,老老实实写回了 CSS Modules 里,用 CSS 变量去控制颜色。

* 把固定的布局、间距这些不变的结构,继续用 Tailwind 的 gridp-* 来写。

后来我算了一笔账,这个后台项目如果全上 Tailwind,光是 HTML 体积就得涨 30%,而且维护起来全是 text-smmt-2,找逻辑特别费劲。

我现在觉得,Tailwind 特别适合那种组件化、结构固定的场景,比如公用的 Button、Card 或者官网落地页。但如果你做的是那种像搭积木一样、样式完全由后端数据驱动的复杂业务系统,千万别硬上,否则动态类失效能让你怀疑人生。

学这玩意儿别死记文档,我都是开着编辑器,用到啥查啥。别为了用工具而用工具,代码写得顺手、好改,比啥都强。