为什么很多项目会选择 Tailwind CSS

要不要在一个新项目里用 Tailwind CSS,团队内部的判断标准通常很简单:先看它解决的是什么问题,再看这些问题是不是你项目里真实存在的。喜欢它的人觉得开发效率高,讨厌它的人觉得 HTML 里全是 class,看起来很乱。这两种反应其实都对,只是站的角度不一样,要判断它适不适合自己的项目,得先把传统 CSS 的痛点和 Tailwind 的思路都摆清楚。

这周 Vue 3 刚成为 npm create vue 和官方文档的默认版本,新项目起手就是 Vue 3。趁手上有个内部工具要重新搭界面,正好把 Tailwind 认真评估了一遍——不是要不要用它替换 Element Plus 这类组件库,而是布局、间距这类原子级样式要不要交给工具类来管。

传统 CSS 的常见痛点

类名难取、样式容易重复、文件越滚越长、死代码不好清理、全局样式互相影响、响应式写法分散在各处,普通项目写着写着都会遇到这几条。项目越大,这些问题越明显。

我印象最深的是类名难取和死代码不好清理这两条。早些年带一个中后台项目,光是给一堆相似的卡片想类名就能纠结半天:.card.card-wrap.card-inner.card-box,最后自己都分不清谁是谁。更糟的是删功能的时候——页面删了,对应的 CSS 谁也不敢动,因为不知道还有没有别的地方在引用。结果就是 app.css 越滚越大,一年下来轻松上万行,里面至少三成是没人用的死样式,但没人敢清。

全局污染也踩过坑。有次同事写了个 .title { margin: 0 },本意是改某个组件,结果整站凡是叫 .title 的地方间距全乱了,排查了大半天才定位到。这类问题的根源就是 CSS 默认全局,只能靠约定让各处样式互不牵连,而约定在多人协作下迟早会被打破。CSS Modules 能靠构建时生成哈希类名解决命名冲突,但它解决不了"死代码没人敢删"和"响应式写法分散"这两条,这也是这次评估里绕不开的对比对象。

Tailwind 的核心思路

Tailwind 提供大量工具类,不需要先写一个 .card-title,再到 CSS 文件里补样式,而是直接组合已有类名。

1<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
2  ...
3</div>

这段代码能直接表达:小屏一列,中屏两列,大屏三列。这种写法的关键不是"把 CSS 写到 HTML 里",而是把常见设计能力拆成可组合的原子。间距、颜色、字号、圆角、阴影、断点、状态,都可以通过同一套规则组合出来。

例如一个按钮:

1<button
2  class="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-400"
3>
4  保存
5</button>

这段 class 看起来长,但它把按钮的尺寸、颜色、文字、hover 和 focus 都写在了使用处。读代码时不用在 HTML 和 CSS 文件之间来回跳。

这个"不用来回跳"的体验,是我用了一段时间之后才真正感受到价值的。以前改一个按钮样式,得先在模板里找到它的 class 名,再去搜索整个 CSS 文件定位规则,改完还得担心这条规则有没有被别处复用。用 Tailwind 之后,改样式就是在元素上加减几个词的事,所见即所得,删元素的同时样式也跟着没了,不会留垃圾。这种"样式生命周期跟着 DOM 走"的特性,在迭代频繁的项目里让改样式这件事顺手了不少,不用再担心改了一处会牵连别处。

它和行内样式不一样

Tailwind 看起来像行内样式,但它不是简单的 style=""。区别在于:它来自统一设计系统,支持响应式前缀,支持 hover、focus、dark 等状态,能通过构建过程清理未使用样式,还能保持颜色、间距、字号等规则一致。所以它解决的不只是少写 CSS,而是把样式约束放进工具类体系里。

行内样式最大的问题是缺少约束:

1<div style="margin-top: 17px; color: #3265ff;"></div>

这种写法每个人都能随手写一个新值,时间久了项目里会出现大量不成体系的颜色和间距。Tailwind 的 mt-4text-blue-600 虽然也是直接写在元素上,但它们来自同一套配置,天然更接近设计令牌。

如果团队维护得好,Tailwind 配置本身就可以成为轻量设计系统:

1// tailwind.config.js
2module.exports = {
3  theme: {
4    extend: {
5      colors: {
6        brand: {
7          500: '#2563eb',
8          600: '#1d4ed8',
9        },
10      },
11    },
12  },
13};

这样业务里用的是 bg-brand-500,而不是每个页面自己复制一段十六进制颜色。哪天品牌色要从 #2563eb 调成另一个蓝,改一处配置全站生效,不用满项目搜十六进制值。我之前在一个非 Tailwind 的项目里干过"全局替换颜色值"这种活,光是确认每一处替换会不会误伤就花了一下午,体验很差。

间距也是同理。Tailwind 默认的间距是按 0.25rem(4px)为基准的等差刻度,p-2 是 8px,p-4 是 16px。一开始我嫌它不够自由,后来反而觉得这种"受限"是好事——它逼着你在一套有限的刻度里做选择,页面里就不会冒出 13px、17px、23px 这种随手敲出来的杂值,整体视觉的节奏感自然就齐了。真要用非常规值,p-[18px] 这种任意值语法也留了口子,但每次写方括号的时候我都会下意识停一下,想想是不是真有必要,这本身就是一道有用的提醒。

后来我更愿意把 Tailwind 配置看成设计 token 的落点,而不是单纯的工具类开关。颜色、间距、圆角、阴影如果都来自同一套配置,页面之间的一致性会自然很多。反过来,如果大家到处写任意值,比如 mt-[17px]text-[#3265ff],Tailwind 也会退化成另一种形式的行内样式。

任意值不是不能用,适合解决少量特殊场景。但如果某个任意值反复出现,就应该回到配置里,变成团队认可的 token。

响应式和状态写法更集中

Tailwind 很适合处理响应式,因为断点直接写在 class 前缀里:

1<section class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
2  ...
3</section>

这比在 CSS 文件里散落多个 media query 更容易定位。你看到这个元素,就能知道它在不同屏幕下怎么变化。

状态样式也是类似:

1<input
2  class="border border-slate-300 px-3 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 disabled:cursor-not-allowed disabled:bg-slate-100"
3/>

普通态、聚焦态、禁用态都集中在同一个元素上。对于组件化项目来说,这种局部可见性很实用。

这里有个我踩过的细节:响应式前缀是"移动优先"的。md:grid-cols-2 的意思是"从 md 断点往上才两列",而不是"只在 md 这一档两列"。刚上手时我按桌面思维写,先写一堆默认大屏样式,再用 sm: 去覆盖小屏,结果断点逻辑全反了,改半天没效果。后来想通了:不带前缀的是小屏基线,带前缀的是往上叠加,整个写法就顺了。

另一个好用的是组合修饰符。比如暗色模式下 hover 的样式,可以直接 dark:hover:bg-slate-700 串起来;groupgroup-hover: 能让父元素 hover 时改子元素样式,这在做卡片悬浮交互时特别省事:

1<a class="group block rounded-lg border p-4 hover:border-blue-500">
2  <h3 class="font-medium group-hover:text-blue-600">标题</h3>
3  <span class="text-slate-400 group-hover:text-slate-600">查看详情</span>
4</a>

放在以前,这种"鼠标移到卡片上、内部文字一起变色"的效果,得自己写 .card:hover .card-title 这类后代选择器,现在一行前缀搞定。

顺带提一句,听说 Safari 下个版本要支持 :has() 选择器了,理论上父元素根据子状态变化这种场景以后也能靠原生 CSS 解决,但现在连 Safari 自己都还没正式支持,Chrome 这边更是没有动静,眼下也就只能先记着这个方向,父元素根据子状态变化这种场景,主流项目还是得靠 group-hover: 这类工具类方案扛着。

什么时候适合用

Tailwind 适合需要快速搭界面的项目、设计系统比较统一的项目、组件化程度高的前端应用,以及不想维护大量全局 CSS 的团队。如果项目已经有成熟组件库和稳定 CSS 规范,也不一定非要迁移。

我会特别看团队是否愿意接受"样式跟组件走"的组织方式。如果团队长期习惯 BEM、CSS Modules 或设计系统组件,Tailwind 并不是必须替换它们。它更适合作为一种实用工具,而不是信仰。

在中后台、运营工具、个人项目、快速验证界面时,Tailwind 的收益通常比较明显。因为这些场景里页面多、组件多、状态多,但对极端视觉定制的要求不一定高。倒是要提醒一句:目前团队里主力的中后台项目用的还是 Element Plus,这套组件库经过去年一年追赶,样式体系已经比较完整,短期内没有替换的打算——Tailwind 更适合拿来搭配着写组件之外的布局层,而不是取代整套组件库的视觉语言。

class 很长怎么办

这是 Tailwind 最常见的争议。如果一个元素 class 很长,首先要判断它是不是一个可复用组件。比如按钮、输入框、卡片、标签,这些应该抽成组件,而不是每次复制一大串 class。

1function PrimaryButton({ children }) {
2  return (
3    <button className="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
4      {children}
5    </button>
6  );
7}

如果只是一次性的页面布局,class 长一点并不一定是问题。长本身不碍事,重复、无规则、难以维护才是需要盯住的地方。

Tailwind 也提供 @apply,但我不会把它当成默认方案:

1.primary-button {
2  @apply rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white;
3}

@apply 适合少量抽取稳定样式。如果用它把 Tailwind 又写回大量 CSS 类里,就会失去原子化的部分优势。

它也有成本

Tailwind 不是没有成本。首先,它要求团队熟悉工具类命名,刚开始写的时候,很多人会频繁查文档。其次,HTML 模板会变长,对代码审查和格式化提出更高要求。再次,如果设计规范本身混乱,Tailwind 也救不了项目。

还有一个容易忽略的问题是动态 class:

1const color = 'red';
2const className = `bg-${color}-500`;

这类动态拼接可能无法被构建工具正确识别,导致样式没有生成。更稳的方式是维护明确映射:

1const colorClassMap = {
2  danger: 'bg-red-500',
3  success: 'bg-green-500',
4};

工具类体系依赖构建扫描,写法越明确,结果越稳定。

如果状态比较多,我会把映射表写完整一点:

1const statusClassMap = {
2  pending: 'bg-amber-50 text-amber-700',
3  success: 'bg-emerald-50 text-emerald-700',
4  error: 'bg-red-50 text-red-700',
5};

这个写法虽然多几行,但可搜索、可扫描、可 review。比字符串拼接更稳,也更容易发现某个状态漏了样式。

这个坑我是在生产环境才发现的。本地开发时偶尔能蒙对,是因为那个类碰巧被别的地方静态写过、已经生成了;可一上线,扫描器在源码里搜不到完整的 bg-red-500 字符串,对应样式压根没进产物,按钮就成了透明的。排查时还特别容易误判成数据或逻辑问题,因为 class 属性里明明写着对的值。记住一条铁律:Tailwind 的扫描是纯文本匹配的,它不执行你的 JS,所以完整的类名必须以字面量形式出现在源码里。

这个坑排查起来最费时间的地方,其实是定位到底是"样式没生成"还是"样式生成了但被别的规则覆盖"。我后来养成了一个习惯:怀疑某个类没生效,先去构建产物里直接搜这个类名字符串,而不是先怀疑优先级或者浏览器缓存。产物里搜不到,问题一定出在扫描环节;搜得到还是没效果,才轮到去查权重和覆盖顺序。这个排查顺序看着简单,但顺序反了会把人绕进死胡同——我见过同事在权重上折腾了快一个小时,其实类压根没被打进最终的 CSS 文件。

除了上面的映射表,配置 content 路径也要留意,漏配某个目录会导致那一片的样式集体丢失:

1// tailwind.config.js
2module.exports = {
3  content: ['./src/**/*.{js,jsx,ts,tsx,vue,html}'],
4};

还有件值得提醒的事:早期版本要靠 PurgeCSS 单独配置才能裁剪未用样式,后来的 JIT 模式直接按需生成,开发时改 class 几乎是即时的,产物也只包含真正用到的类。如果还在用很老的版本,升级一下能省掉不少配置上的麻烦。

工具链能抵消大半成本

上面提到的"查文档""class 顺序乱"这些痛点,其实很大一部分能靠工具链解决,这点我希望早点有人告诉我。

编辑器装上官方的 Tailwind CSS IntelliSense 插件后,写 class 有自动补全和悬浮预览,能直接看到 text-sm 到底是多大、bg-slate-900 是什么颜色,基本不用再翻文档。配合 prettier-plugin-tailwindcss,保存时会自动按推荐顺序重排 class,团队里再也不会因为"class 该怎么排"吵架,diff 也更干净:

1// .prettierrc
2{
3  "plugins": ["prettier-plugin-tailwindcss"]
4}

至于条件 class 拼接,裸用模板字符串很容易拼出多余空格或冲突的类,我一般用 clsx 配合 tailwind-merge,后者能在出现 px-2 px-4 这种冲突时智能保留后写的那个:

1import { twMerge } from 'tailwind-merge';
2import clsx from 'clsx';
3
4function Button({ active, className }) {
5  return (
6    <button
7      className={twMerge(
8        clsx('rounded-md px-4 py-2', active && 'bg-blue-600 text-white'),
9        className, // 调用方传入的覆盖样式不会和默认值打架
10      )}
11    />
12  );
13}

把这套工具配齐之后,我对"HTML 里 class 太长"的抵触情绪就小多了——它依然长,但写起来顺、读起来有提示、合并起来不打架,成本就摊薄了。

顺带一提,项目的 TypeScript 版本这次也升到了 4.5,tailwind.config.js 换成 tailwind.config.ts 之后,配置项能拿到类型提示,写错主题字段的名字编译期就能发现,比以前手滑打错单词、构建完才发现样式没生效要省心不少。

迁移不要一口吃完整个项目

如果老项目已经有大量 CSS,我不建议为了用 Tailwind 全量重写。更现实的方式是从新页面、新组件或局部工具页面开始,把收益和问题都看清楚。

我比较认可的落地顺序是:先统一 Tailwind 配置里的颜色、间距、断点;新组件优先使用工具类;高频组件抽成组件而不是复制 class;老 CSS 只在改动相关功能时顺手收敛;code review 里约束任意值和重复 class。这样迁移不会变成一次大重构,也不会让团队突然被迫改变所有写法。

这次顺手把项目的包管理也从 yarn 切到了 pnpm 做了个小范围试点,纯粹是因为 node_modules 体积和安装速度的问题,跟 Tailwind 本身没有直接关系,但两件事赶在同一个窗口期评估,倒是能一起把项目脚手架理一遍——磁盘占用降下来之后,CI 里跑构建的时间也短了一截。pnpm 用的是硬链接加内容寻址的存储方式,同一个依赖在多个项目间不用重复落盘,这对我们这种一人手头挂着好几个中后台项目的情况尤其省磁盘。当然它还谈不上团队里的默认选择,目前也就这一个工具项目在用,其他项目还是 yarn,算是留着观察一阵子。

和 CSS Modules、组件库不是对立关系

Tailwind 不一定要替代所有 CSS。复杂动画、第三方组件覆盖、富文本内容、少量不可避免的全局样式,继续用普通 CSS 或 CSS Modules 也没问题。

我现在更倾向于组合使用:布局、间距、颜色、状态用 Tailwind;复杂结构样式或可读性很差的部分再抽 CSS。工具是为项目服务的,不需要把所有样式都塞进 class。

这次评估下来,手上这个内部工具的新界面,我打算直接用 Tailwind 起步:布局、间距、状态这些原子样式交给工具类,复杂动画和第三方组件覆盖的部分继续留给普通 CSS,中后台主项目那边组件库还是 Element Plus 不动,两边各写各的。用工具类表达局部样式、用配置承载设计约束、用组件收掉重复结构,这三件事只要职责划分清楚,Tailwind 在这个项目里就不会变成一堆乱七八糟的 class。