第一篇已经把 Sass、CSS Modules、CSS-in-JS 和 Tailwind 的位置拆开了。现在可以继续追问一个更具体的问题:为什么把大量视觉声明放进 class 属性里,反而可能让项目更好维护?

答案不在“少写 CSS”。Tailwind 的维护模型是把样式复用单位从业务类名改成稳定声明能力,再把业务语义交还给组件名、props 和设计 token。

可维护性问题通常不是 class 太长

传统 CSS 项目里,最早的类名往往很清楚。.card 表示卡片,.card-title 表示标题,.button-primary 表示主要按钮。问题出现在这些类名被多次复用、覆盖和扩展之后。.button-primary 可能先是品牌色按钮,后来又承担弹窗主按钮、表单提交按钮、营销页 CTA 的含义。名称没有变,语义已经分裂。

一个长期项目里常见的样式并不难写,难的是判断某条规则能不能改。维护者看到 .dashboard .card-title 时,必须知道 DOM 层级、选择器优先级、CSS 文件加载顺序和历史覆盖关系。所谓“样式混乱”不是因为 CSS 语法弱,而是因为视觉规则来源变得不可追踪。

.card-title {
  font-size: 16px;
  font-weight: 600;
  color: #0f172a;
}

.billing-page .card-title {
  font-size: 18px;
}

.compact .card-title {
  font-size: 14px;
  color: #334155;
}

这段代码没有任何语法问题,但它把“一个标题长什么样”拆散到了多个上下文里。随着项目继续增长,.card-title 不再是一个稳定接口,而是一个可被任何页面重新解释的挂钩。

Tailwind 不是用长 class 解决所有问题。它解决的是另一个问题:让视觉声明更靠近使用位置,并让声明本身来自一套有限、可审查的 token。

原子化 CSS 复用的是声明能力

原子化 CSS 的复用单位不是 .pricing-card-title 这类业务类名,而是 text-smfont-mediumpx-4rounded-lgbg-slate-900 这类声明能力。它们的含义不依赖页面业务。px-4 在价格卡片、用户列表、弹窗按钮里都表示同一个水平内边距刻度。

这种稳定性改变了审查方式。传统 CSS 需要先跳到样式文件理解 .button-primary 的内容,再回到组件判断 DOM 结构。Tailwind 让布局、间距、颜色、状态大多出现在组件附近,reviewer 可以直接判断一个元素使用了哪些视觉原语。

export function MetricCard() {
  return (
    <section className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
      <p className="text-sm font-medium text-slate-500">激活率</p>
      <p className="mt-2 text-2xl font-semibold text-slate-950">42.8%</p>
      <p className="mt-1 text-sm text-emerald-700">+3.4%,高于上周</p>
    </section>
  );
}

这段组件没有创造新的 CSS 接口。卡片边框、背景、内边距、字号、字重、颜色都直接来自工具类。业务语义留在 MetricCardActivation rate 和数据来源里,视觉语义留在 Tailwind 的 token 命名空间里。

这也是 Tailwind 与 BEM、CSS Modules 的分界。BEM 和 CSS Modules 倾向于为组件创建局部类名,然后在 CSS 文件中声明视觉规则。Tailwind 倾向于在组件里组合已有视觉规则,只有当组合开始重复、需要封装行为或需要暴露业务意图时,才抽出组件。

Tailwind 不是内联样式的另一种写法

把 Tailwind 简化成“class 版 style 属性”会漏掉几个工程差异。内联样式可以随意写 style={{ color: "#2562ee", padding: 13 }},Tailwind 默认鼓励开发者从既定的颜色、间距、字号和圆角刻度里选择。项目使用 @theme 扩展 token 后,新的工具类仍然来自受控命名空间,而不是任意散值。

内联样式也很难表达伪类、媒体查询、容器条件和语义状态。Tailwind 的 hover:focus-visible:disabled:md:aria-expanded:data-[state=open]: 这些变体让状态表达进入同一套类名系统。

<button
  className="inline-flex items-center rounded-md bg-slate-950 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-950 disabled:pointer-events-none disabled:opacity-50"
  disabled={isSaving}
>
  保存更改
</button>

浏览器最终拿到的是构建阶段生成的 CSS,而不是每个元素一份不可复用的 style 对象。Tailwind 的扫描器会在源码中发现这些候选类名,再生成对应 CSS 规则。这个过程发生在构建期或开发服务器中,不是浏览器运行时样式引擎。

当然,Tailwind 仍然允许任意值,例如 w-[37rem]bg-[#123456]。这些能力应该服务于少量无法进入通用 token 的场景,而不是替代设计刻度。项目里任意值越多,Tailwind 越像散乱内联样式;任意值越少,工具类越接近受控设计语言。

用价格卡片看组合边界

先看一个完全展开的价格卡片。它适合出现在原型或新组件的第一版中,因为视觉意图直接可见。

export function PricingCard() {
  return (
    <article className="flex h-full flex-col rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
      <div className="flex items-start justify-between gap-4">
        <div>
          <h3 className="text-base font-semibold text-slate-950">团队</h3>
          <p className="mt-1 text-sm leading-6 text-slate-600">
            适合每周持续交付的产品小组。
          </p>
        </div>
        <span className="rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700">
          热门
        </span>
      </div>

      <p className="mt-6 flex items-baseline gap-1 text-slate-950">
        <span className="text-4xl font-semibold">¥29</span>
        <span className="text-sm text-slate-500">/席位</span>
      </p>

      <a
        href="/checkout"
        className="mt-6 inline-flex items-center justify-center rounded-md bg-slate-950 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-950"
      >
        开始试用
      </a>
    </article>
  );
}

如果项目里只有一个价格卡片,这段代码没有必要继续抽象。维护者能直接读到布局、层级、颜色和交互状态。问题出现在同一类组合反复出现时。按钮类名、徽标类名、卡片外壳类名被复制到多个文件后,改动就会重新变成全局搜索和人工同步。

这时应该抽组件,而不是回到 .btn-primary.pricing-card 的旧模式。组件把业务意图变成 API,Tailwind 类名仍然留在组件内部。

type ButtonTone = "primary" | "secondary";

const buttonToneClass: Record<ButtonTone, string> = {
  primary: "bg-slate-950 text-white hover:bg-slate-800 focus-visible:outline-slate-950",
  secondary: "bg-white text-slate-900 ring-1 ring-slate-300 hover:bg-slate-50 focus-visible:outline-slate-400",
};

export function Button({
  tone = "primary",
  className = "",
  ...props
}: React.ComponentProps<"button"> & { tone?: ButtonTone }) {
  return (
    <button
      className={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-50 ${buttonToneClass[tone]} ${className}`}
      {...props}
    />
  );
}

调用方不需要知道主要按钮到底使用 bg-slate-950 还是 bg-brand-600。它只表达 tone="primary"。组件内部仍然保持 Tailwind 的透明性,不需要另建一套 .btn 样式文件。

这个边界比“class 超过多少个就抽象”更可靠。重复的视觉组合、稳定的交互行为、需要对外暴露的业务意图,才是抽组件的理由。单个页面里的复杂布局可以先保留工具类,等形状稳定后再沉淀。

动态类名要映射成完整字符串

Tailwind 按纯文本扫描源码,不会理解 JavaScript 字符串拼接背后的含义。bg-${color}-600 看起来能生成正确 class,但扫描器看到的是不完整片段。开发服务器可能没有生成对应 CSS,线上构建也可能丢样式。

错误写法通常长这样:

function Badge({ color }: { color: "green" | "red" | "blue" }) {
  return <span className={`rounded-full bg-${color}-50 px-2 py-1 text-${color}-700`} />;
}

正确写法是把 props 映射到完整类名字符串。这样每个可能出现的类名都真实存在于源码中,扫描器可以发现它们,类型系统也能约束取值范围。

type BadgeTone = "success" | "danger" | "info";

const badgeToneClass: Record<BadgeTone, string> = {
  success: "bg-emerald-50 text-emerald-700 ring-emerald-600/20",
  danger: "bg-rose-50 text-rose-700 ring-rose-600/20",
  info: "bg-sky-50 text-sky-700 ring-sky-600/20",
};

export function Badge({ tone, children }: { tone: BadgeTone; children: React.ReactNode }) {
  return (
    <span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset ${badgeToneClass[tone]}`}>
      {children}
    </span>
  );
}

这种写法看似多了一张表,实际减少了隐式规则。设计师改语义色时,维护者只改 badgeToneClass。产品新增状态时,类型定义和映射表会一起提示缺口。

AI 生成代码适合 Tailwind,但仍需要工程约束

很多 AI 产品和原型工具偏爱 Tailwind,并不是因为 Tailwind 天然适合所有界面,而是因为它的输出空间更容易约束。类名可枚举,语义相对稳定,组件代码包含结构和视觉意图,生成结果也更容易在代码审查中局部修正。

生成式工具写 .analytics-header 时,后续还要补 CSS 文件;写 flex items-center justify-between gap-4 时,布局意图已经在模板里。对于快速原型,这能显著减少来回切换上下文的成本。

但 AI 也容易制造 Tailwind 项目的典型问题:同一按钮在十个文件里有十种轻微差异,颜色任意值越来越多,响应式状态没有覆盖,复杂 class 没有组件边界。生成速度越快,越需要把 token、组件和变体 map 固化到项目中。否则 Tailwind 只是把无序 CSS 换成无序 class。

一个可控的做法是先让 AI 使用现成组件和 props。如果必须生成新界面,要求它优先复用 ButtonBadgeCardInput 这类基础组件,只在页面布局层写工具类。这样生成代码仍然快,但视觉语言不会无限分叉。

失控边界要提前写清楚

Tailwind 项目最容易失控的地方通常不是一眼能看到的长 class,而是几个隐蔽习惯。

大量 arbitrary value 会绕开 token 系统。mt-[13px]text-[#1f2938]rounded-[7px] 可以解决局部问题,但如果频繁出现,就说明设计刻度没有建立好,或者开发者没有被引导使用已有 token。

过度 @apply 会把 Tailwind 重新变成传统 CSS。@apply 很适合覆盖第三方库、富文本内容、CMS 输出或少量无法控制 DOM 的结构,但如果项目里到处是 .btn-primary { @apply ... },维护者又要在组件和 CSS 文件之间来回跳转。

动态拼接会让样式生成不可预测。凡是来自 props、后端字段或 CMS 配置的视觉状态,都应该先映射到项目允许的完整类名。不能让任意字符串直接进入 class。

组件边界过晚会制造复制粘贴成本。一个长 class 第一次出现不是问题,第三次出现在不同文件里就应该停下来判断它是否已经是一个组件或 helper。

Tailwind 的维护性不是自动获得的。它依赖团队持续把视觉原语收敛到 token,把重复组合收进组件,把动态状态变成完整映射,把特殊 CSS 留在少数明确位置。这样做之后,长 class 反而不是主要矛盾;真正的复杂度被限制在可以命名、可以审查、可以测试的边界里。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐