讨论 Tailwind 前,先把样式方案的演进拆开。Sass、Less、CSS Modules、CSS-in-JS 和 Tailwind 并不是同一把尺子上的前后版本,它们分别在处理表达能力、作用域、组件绑定、运行时状态和设计约束。

如果把这些问题混成一句“哪种写法更先进”,结论通常会失真。更准确的判断方式是:当前项目的主要样式成本来自哪里,方案把这个成本转移到了什么地方。

原生 CSS 的难点在组织,而不在选择器不够用

CSS 本身的能力并不弱。选择器、级联、继承、媒体查询、自定义属性、容器查询和现代颜色函数已经覆盖了大量 UI 场景。真正让工程项目变难的不是“写不出样式”,而是样式随着页面、组件、状态和业务分支增长后,来源变得不透明。

一个常见例子是按钮样式。刚开始只有 .button,后来出现 .button-primary.button-large.button-disabled.dialog .button-primary.sidebar .button-primary。这些类名看起来有语义,但语义会被上下文慢慢稀释。.button-primary 到底是品牌色按钮,还是某个页面里更醒目的按钮?.card-title 到底是卡片标题,还是一个 16px 粗体灰黑色文本组合?当类名既承担业务语义,又承担视觉复用,维护者很难判断改一处会影响哪些地方。

CSS 的全局特性会放大这个问题。一个选择器只要命中页面元素,就会生效;一个更高优先级的选择器会覆盖前面的规则;一个组件内部的 class 也可能被外部容器选择器影响。小项目里这很灵活,大项目里就会变成依赖网络。

.card .title {
  font-size: 16px;
  font-weight: 600;
}

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

.settings-panel .title {
  font-weight: 500;
}

这段 CSS 没有语法错误,但它已经把样式来源分散到多个上下文。某个标题最终长什么样,需要同时知道 DOM 位置、选择器优先级和加载顺序。样式方案的第一类问题就是如何降低这种全局推理成本。

Sass 和 Less 提高表达能力,但输出仍然是全局 CSS

Sass 和 Less 主要解决的是 CSS 编写体验。变量让颜色、间距和断点可以集中定义,嵌套让选择器关系更接近 DOM 层级,mixin 让重复声明可以复用,函数让一部分样式计算可以提前完成。

$brand: #2563eb;
$radius: 8px;

@mixin focus-ring($color) {
  outline: 2px solid rgba($color, 0.4);
  outline-offset: 2px;
}

.button {
  border-radius: $radius;
  background: $brand;
  color: white;

  &:focus-visible {
    @include focus-ring($brand);
  }
}

这类写法比重复手写 CSS 更舒服,也更容易统一变量。但它没有改变最终产物的性质。编译结果仍然是全局 CSS,仍然要面对命名、优先级、覆盖顺序和选择器耦合。预处理器让样式更好写,不等于让样式天然更好维护。

预处理器还会制造另一种隐蔽成本。过深嵌套会让选择器越来越长,mixin 过度复用会让声明来源不易追踪,变量命名也可能从设计 token 退化成零散常量。团队如果只把 Sass 当成“CSS 增强语法”,仍然需要额外约定来管理作用域和复用边界。

CSS Modules 把作用域收回组件

CSS Modules 处理的是另一个层面的痛点:类名污染。它让组件内的 .title 编译成带哈希的局部类名,避免不同组件之间因为同名 class 互相影响。

import styles from "./UserCard.module.css";

export function UserCard() {
  return (
    <article className={styles.card}>
      <h2 className={styles.title}>Ada Lovelace</h2>
      <p className={styles.description}>Compiler notes and analytical engines.</p>
    </article>
  );
}
.card {
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  padding: 16px;
}

.title {
  font-size: 18px;
  font-weight: 600;
}

这种模式让“这个样式属于哪个组件”变得清楚。组件移动、删除、重命名时,样式文件也能跟着走。对于需要长期维护的业务组件,这是很大的改进。

但 CSS Modules 不负责设计约束。你仍然可以在十个组件里写十种相近的灰色、八种略有差异的圆角、五种间距组合。局部作用域解决了污染问题,没有解决重复声明和视觉漂移问题。它把“会不会影响别人”处理好了,但“大家是否使用同一套视觉语言”仍然要靠设计 token、lint、review 或组件库约束。

CSS-in-JS 把样式绑定到组件和状态

CSS-in-JS 的出发点是让样式和组件状态靠得更近。组件可以根据 props、theme、运行时上下文生成样式,样式也能跟随组件拆分和懒加载。对于复杂主题、白标系统、设计系统组件库,这种能力很直接。

const Button = styled.button<{ intent: "primary" | "danger" }>`
  border-radius: 8px;
  padding: 8px 12px;
  color: white;
  background: ${({ intent }) => (intent === "danger" ? "#dc2626" : "#2563eb")};

  &:disabled {
    opacity: 0.5;
  }
`;

这段代码的优势是状态和样式在同一个组件里。intent 如何影响颜色,不需要跨文件查找。主题切换、动态样式和组件封装也很自然。

代价来自运行时和构建链路。不同 CSS-in-JS 方案的实现差异很大,有些会在运行时生成和注入样式,有些会做编译期提取,有些需要 SSR 时收集 critical CSS。项目越重视首屏、流式渲染、缓存命中和可预测 CSS 输出,就越需要评估这些代价。CSS-in-JS 并不是“慢”的同义词,但它确实把一部分样式工作放进了 JavaScript 工程系统。

如果一个项目的大部分样式都是静态布局、颜色、间距和状态类,那么运行时动态样式能力可能没有被充分利用。团队获得了组件绑定能力,也承担了额外抽象和工具链复杂度。

Tailwind 改变的是样式复用单位

Tailwind 的核心变化不是“把 CSS 写在 HTML 里”,而是把复用单位从业务类名转成工具类。px-4 表示水平内边距,text-sm 表示字号,bg-slate-900 表示背景色,rounded-lg 表示圆角。这些类名不试图描述业务语义,它们描述稳定的视觉能力。

<button
  class="inline-flex items-center rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-slate-700 focus:outline-2 focus:outline-offset-2 focus:outline-slate-900"
>
  Save changes
</button>

这段 HTML 比 .button-primary 长,但它把按钮的视觉构成直接暴露出来。维护者不需要跳到 CSS 文件里查 .button-primary 到底包含什么,也不用担心 .dialog .button-primary 在另一个文件里覆盖了它。状态样式通过 hover:focus: 这类变体表达,响应式通过 md:lg: 等前缀表达。

Tailwind 真正依赖的是设计 token。颜色、间距、字号、圆角和断点不是随便写的任意值,而是从一组受控命名空间里选择。这样做的结果是,开发者仍然在写样式,但可选空间被缩小了。团队不再需要 review 每一个 13px#2562ee9px 圆角是否合理,因为默认路径鼓励使用已有刻度。

“少写 CSS”不是 Tailwind 的主要价值

Tailwind 项目里并不是没有 CSS。你仍然需要入口 CSS、主题 token、自定义工具、第三方库覆盖、富文本排版、动画和极少数复杂选择器。它减少的是大量一次性语义类和重复声明。

一个更实际的变化是审阅方式。传统 CSS 里,组件 JSX 和 CSS 文件分离,代码审查时需要同时对照结构和样式。Tailwind 让大多数视觉意图出现在组件附近,reviewer 能直接看到布局、间距、状态和颜色组合。组件稳定后,可以再把重复组合沉淀成 Button、Card、Input 等基础组件。

type ButtonIntent = "primary" | "secondary" | "danger";

const intentClass: Record<ButtonIntent, string> = {
  primary: "bg-slate-900 text-white hover:bg-slate-700",
  secondary: "bg-white text-slate-900 ring-1 ring-slate-300 hover:bg-slate-50",
  danger: "bg-red-600 text-white hover:bg-red-500",
};

export function Button({ intent = "primary", children }: { intent?: ButtonIntent; children: React.ReactNode }) {
  return (
    <button className={`inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium ${intentClass[intent]}`}>
      {children}
    </button>
  );
}

这里的抽象边界在组件,而不是重新发明 .btn-primary。调用方表达业务意图,组件内部映射到完整 Tailwind 类名。这样既保留工具类的透明度,也避免页面里到处复制同一串按钮类。

方案选择应该从项目压力出发

活动页、后台系统、组件库、内容站和白标 SaaS 面对的样式压力不同。活动页可能需要大量定制视觉和一次性动画,传统 CSS 或 Sass 仍然顺手。大型后台系统更看重一致性和开发速度,Tailwind 配合组件库往往有效。组件库如果需要对外暴露高度可配置主题,CSS-in-JS 或编译期提取方案可能更合适。已有项目如果已经用 CSS Modules 管理得很好,引入 Tailwind 要先评估迁移收益。

Tailwind 的定位可以概括为:用构建期生成的工具类和设计 token,降低日常 UI 组合的重复声明成本。它不是 CSS 的替代品,也不是设计系统本身。它更像一套约束良好的样式汇编语言,让组件开发者用有限的、可审查的视觉原语快速搭出界面。

理解这一点后,再讨论 Tailwind 的性能、JIT、扫描和 Vite 插件才有意义。它的工程价值不来自某个神奇语法,而来自复用单位、构建时机和约束模型的组合。

Logo

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

更多推荐