跳到主内容

3

样式方案选型与样式系统

Tailwind / CSS Modules / CSS-in-JS / 无头组件 / shadcn / 设计令牌——选什么、配什么、为什么

必学38 分钟

这一节讲一旦写到真实项目就绕不开的问题——怎么组织样式、怎么和团队协作、怎么和 AI 协作。前面四节讲了 CSS 能怎么写,§3.5 讲该怎么组织

§3.1-§3.4 把 CSS 语言层、布局、语义、响应式都讲清楚了。但写一个真实项目时,第一个真实问题是:这堆样式放哪?怎么命名?怎么复用?怎么和组件库 / 设计系统对接?——这就是"样式方案选型"。

选错样式方案的代价,往往要等上线半年后才显现——重构成本陡增、新人上手慢、AI 协作低效、设计系统对接困难。这一节就是给你判断地图。

怎么读这一节(建议):

你是直接读
新人 / 转行者§0 简史 → §1 选型决策图 → §3 CSS Modules vs Tailwind
老前端跟进 2024+ 现状§0.2 转折点 → §4 CSS-in-JS 现状 → §6 shadcn → §8 令牌
团队负责人 / 架构师选型§1 选型图 → §3 / §6 / §7 → §10 设计判断暗线
想深入设计令牌§8 设计令牌(整段)+ §10 信息隐藏
要带 AI 写样式§11 AI 时代写样式的最小集

通读约 60-80 分钟;按上述路径挑读 20-25 分钟。§3 / §6 / §8 是核心——它们对应 2026 年最常被讨论的三组选择题(写法、组件分发、设计系统)。


0. 一段必要的回顾:样式组织的演进史

跟前几节一样,先看演进——今天每一个被推荐的现代方案,都在替换某个 2010 年代的旧解法。这条线索串起来,选型判断就不再是"信谁的口味"。

0.1 一张时间表

年代主流方案解决了什么留下了什么
~2005全局 CSS + 命名约定(BEM给样式起规则全局污染、命名地狱、协作冲突
~2010Sass / Less 预处理器变量、嵌套、mixin仍是全局样式;增加构建依赖
~2014CSS Modules局部作用域——按文件隔离类名仍要写传统 CSS 文件
~2016CSS-in-JS(styled-components 等)组件 + 样式同文件运行时开销、SSR 复杂、与 RSC 不兼容(详见 §4)
~2017Tailwind CSS原子化类名 + 配置驱动类名长;初见"反 CSS"心智
~2020设计令牌(design tokens)成熟跨方案的"设计系统单一来源"命名分层是新挑战
~2023shadcn/ui 复制粘贴范式代码进项目而非进 node_modules维护责任落到项目方
2024+RSC + Tailwind 默认组合(Next.js / Remix)服务端组件场景的标准答案CSS-in-JS 在 RSC 下受限的争议

两条主线值得记住

  1. 从"全局共享"逐步走向"局部隔离"——BEM 用命名约定模拟隔离、CSS Modules 用编译做隔离、CSS-in-JS 用 JS 做隔离、Tailwind 用类名工具集做"无需隔离"。每一代都在回答同一个问题:怎么避免样式互相干扰

  2. 从"运行时计算"走回"编译时确定"——CSS-in-JS 顶峰时期把样式逻辑放到运行时(动态主题、props 驱动),但代价是 SSR 慢、bundle 大、与 RSC 冲突。2024 起主流方向是 Tailwind(编译时)+ CSS 变量(运行时切换) 的组合——把"什么样式"在编译时确定,"应用哪套"在运行时切换。

0.2 转折点:2022-2023 RSC 的影响

React 18 引入服务端组件()后,styled-components / Emotion 等运行时 CSS-in-JS 库无法在服务端组件里使用——它们依赖运行时 JS 注入样式,而 RSC 在服务器上完成渲染、不带运行时 JS。这是 2024 年 CSS-in-JS 份额下滑的直接原因。

权威参考:Next.js 官方文档:CSS-in-JS(明确说明运行时 CSS-in-JS 只在客户端组件可用——服务端组件不支持,且要写 StyledComponentsRegistry 等包装器);styled-components #4025(官方对 RSC 的回应)。

写给老前端:如果你最近一次系统选样式方案是 2018-2020 年的 styled-components 或 SCSS 时代,这一节你需要重读。不是因为旧方案不能用,是因为生态默认组合换了——新项目从 Tailwind + shadcn + Radix 开始走,能少一半的"自己造轮子"工作。

写给新人:你看到的"选型争论"很多其实是 2018-2020 年的遗留——那时 CSS-in-JS 是 React 圈的默认推荐。现在主流默认换了,直接学 Tailwind + 设计令牌就可以——CSS Modules / SCSS 做项目时遇到再补,CSS-in-JS 大概率不需要主动选择。


1. 一张选型决策图

来了一个项目,先按这棵树问:

项目类型?
 
├─ 内容站 / 文档 / 博客
│  └─ CSS Modules 或 Tailwind 都行
│     理由:样式复杂度低,两者都能干净完成

├─ 设计系统重的产品(Linear / Stripe 风格)
│  └─ Tailwind + shadcn/ui + Radix UI
│     理由:设计令牌配置驱动,组件按需复制粘贴,AI 协作最佳

├─ 中后台业务系统(表单 / 表格 / 数据密集)
│  └─ 完整组件库(Ant Design / Element Plus / MUI)
│     理由:开箱大量业务组件,快速搭建比定制重要

├─ 多品牌 / 高度定制的 SaaS
│  └─ 设计令牌 + Tailwind / 无头组件
│     理由:令牌层统一,上层可按品牌切换

└─ 高交互的 SPA(编辑器 / 绘图 / 游戏)
   └─ CSS Modules / 编译时 CSS-in-JS(vanilla-extract)
      理由:动态样式计算需要精细控制,Tailwind 约束过强

几条经验法则

  1. 无特殊原因选 Tailwind + shadcn——2024-2026 年新项目的默认组合。特殊原因包括:团队已有成熟的 CSS Modules 实践 / 设计师不接受原子化类名 / 现有代码库是 CSS-in-JS / 明确的动态样式计算需求。
  2. 不要混用两套写法层——Tailwind + CSS Modules 并存 = 命名冲突 + 优先级大战。选一套写法,团队统一。
  3. 完整组件库 vs 无头组件是互斥选择——Ant Design 自带样式 + Radix(无头)自己写样式 = 两套设计系统互砍。要么选 Ant 接受它的设计语言,要么选 Radix + shadcn 从头定制。
  4. 设计令牌是跨方案的——无论选哪套写法,令牌层都该立起来。Tailwind 把令牌写进 tailwind.config.ts,CSS Modules 把令牌写成 CSS 变量。

"我能不能用 SCSS":详见下面的 §1.5 "CSS 预处理器何时还有用"。短答:新项目不用,老项目继续维护

1.5 CSS 预处理器(Sass / Less / Stylus)何时还有用

预处理器(Sass / Less / Stylus)在 2010 年代的核心价值——变量、嵌套、mixin、运算——已经被 2020-2024 年的原生 CSS 全面赶上:

预处理器特性原生 CSS 现状
变量($primary✅ CSS 变量(--primary)—— 而且运行时可改,预处理器变量做不到
嵌套(.parent { .child {} }原生嵌套(2023 全支持)
运算(width: 100% - 20pxcalc() / min() / max() / clamp()
颜色操作(darken() / lighten()color-mix() / oklch() / 相对颜色
@import 模块化✅ CSS Modules / Tailwind / @layer
Mixin(@include⚠️ 没有原生对等——但 Tailwind 的"原子类组合"实际上更优雅

唯一仍有差距的是 mixin / 函数式抽象——但这点在现代写法里通过"组件 + variants"完成,不需要 CSS 层做。

现代项目立场(明确)

场景推荐
2026 年新项目不用 Sass / Less——原生 CSS + Tailwind 够了
维护 2015-2020 年代的 Sass 老项目沿用——不要花精力迁移到原生 CSS
公司内部组件库 / 设计系统打包⚠️ 看团队基线——新项目无理由不用原生
Vue 生态(Element Plus 等)⚠️ 一些库内部用 Sass 配置主题——按文档来

为什么"新项目不用预处理器"是明确立场

  1. 构建链多一层——多一个工具就多一个出错点(Sass loader / dart-sass / @import 路径解析)
  2. 运行时优势丧失——CSS 变量能在浏览器里实时改(暗色切换、用户偏好),Sass 变量编译就死了
  3. Tailwind 配置驱动——已经覆盖了 mixin 想做的"配置一次、全局复用"
  4. AI 协作——AI 写原生 CSS 比写 Sass 更稳(训练数据更新、语法歧义少)

特殊情况:你接手的项目是 Sass 重度使用(如 Element Plus 主题定制 / 老 Vue 项目)——沿用即可。本节不提倡迁移——迁移成本几乎肯定大于收益。


2. 主流方案分档:4 类不要混淆

样式相关的概念容易混淆——先把它们按层级理清:

2.1 四个层级

层级概念举例
写法层怎么写样式代码原生 CSS / CSS Modules / SCSS / CSS-in-JS
方法论怎么命名和组织BEM / OOCSS / Atomic CSS
框架提供工具链 + 预设类Tailwind CSS / UnoCSS / Windi CSS
组件分发怎么给项目引入可复用组件组件库 / 无头组件 / shadcn 复制粘贴

为什么容易混淆

  • "Tailwind" 是框架,但它基于 Atomic CSS 方法论
  • "shadcn" 不是组件库,是组件分发范式(CLI 工具)
  • "CSS Modules" 是写法层,但它可以配合 BEM 方法论使用

实战里的常见组合

写法层          + 方法论/框架              + 组件分发
────────────────────────────────────────────────────────
CSS Modules     + BEM 命名                 + 自建组件
Tailwind        + Atomic CSS(内置)       + shadcn/ui
CSS-in-JS       + 自由命名                 + 自建组件
原生 CSS        + 无(或 BEM)             + 完整组件库(Ant)

你需要做的选择(按优先级):

  1. 组件分发:用完整组件库还是无头组件还是 shadcn?
  2. 写法层:Tailwind 还是 CSS Modules 还是 CSS-in-JS?
  3. 方法论(可选):如果选 CSS Modules,要不要 BEM?

本节下面几个小节按这个顺序展开——§3 写法层、§5-§7 组件分发、§8 令牌(跨层)。


3. CSS Modules vs Tailwind:写法层核心

2024-2026 年,写法层的核心选择就是这两个——它们代表两种相反的心智。

3.1 心智对比:传统 vs 原子化

维度CSS ModulesTailwind CSS
核心心智传统 CSS:选择器 + declaration block原子化类名:一个类名 = 一条样式声明
文件组织样式与组件分文件(Button.tsx + Button.module.css样式直接在 className
学习曲线几乎为 0(就是 CSS)中(需记忆类名映射:flex / items-center 等)
复用方式提取 CSS 类 + @apply提取组件(React 函数组件)
设计系统对接需自建 CSS 变量tailwind.config.ts 配置驱动
重构成本高(改类名要跨文件)低(删 className 即删样式)
团队适配设计师友好(传统 CSS 心智)工程师友好(组件化思维)
AI 协作AI 易写,但难判断"这个样式该提取还是内联"AI 极擅长(类名组合是机械任务)

3.2 CSS Modules:传统心智 + 局部作用域

核心机制:编译时把类名转换成唯一标识(如 .button.Button_button__a3f2k),避免全局污染。

/* Button.module.css */
.button {
  padding: 0.5rem 1rem;
  background: var(--color-primary);
  border-radius: 0.25rem;
}
 
.button:hover {
  background: var(--color-primary-dark);
}
// Button.tsx
import styles from "./Button.module.css";
 
export function Button({ children }) {
  return <button className={styles.button}>{children}</button>;
}

优势

  • 零学习曲线——就是写 CSS
  • 设计师能直接看懂
  • 可以用任何 CSS 特性(@layer / :has() / 容器查询等)

劣势

  • 样式与组件分文件——改一处要跳两个文件
  • 复用靠"在 CSS 里提取类" + "在 JSX 里组合类名"——心智分裂
  • 全局心智弱——跨组件复用要靠"全局 CSS 变量" + "手动约定"

何时选

  • 团队习惯传统 CSS 心智
  • 设计师需要直接修改样式文件
  • 现有代码库是 CSS Modules,迁移成本高

权威参考:Next.js: CSS Modules

3.3 Tailwind CSS:原子化 + 配置驱动

核心机制:提供一组预设的原子类名——每个类名 = 一条 CSS 声明。组合多个类名表达样式。

// 直接在 className 里写样式,不需要 CSS 文件
export function Button({ children }) {
  return (
    <button className="px-4 py-2 bg-blue-500 hover:bg-blue-700 rounded text-white">
      {children}
    </button>
  );
}

类名映射示例

  • px-4 = padding-left: 1rem; padding-right: 1rem
  • py-2 = padding-top: 0.5rem; padding-bottom: 0.5rem
  • bg-blue-500 = background: #3b82f6(来自 Tailwind 默认色板)
  • hover:bg-blue-700 = &:hover { background: #1d4ed8 }

优势

  • 配置驱动设计系统——tailwind.config.ts 定义令牌,全局生效
  • AI 协作极好——类名组合是机械任务,AI 准确率极高
  • 重构成本低——删 className 就是删样式
  • bundle 极小——PurgeCSS 自动剔除未使用的类
  • 响应式 / 暗色 / 状态写在一起——md:flex hover:bg-red-500 dark:bg-gray-800

劣势

  • 类名长——复杂组件 className 可能 100+ 字符
  • 学习曲线——需记住"哪条 CSS 对应哪个类名"
  • 与传统 CSS 心智冲突——初见会觉得"反 CSS"

何时选

  • 新项目默认选这个(2024-2026 年事实标准)
  • 重视 AI 协作效率
  • 设计系统驱动开发(设计师 ↔ 工程师靠令牌沟通)

长 className 的应对

// ❌ 直接写一长串
<button className="px-4 py-2 bg-blue-500 hover:bg-blue-700 dark:bg-blue-400 rounded text-white font-medium transition-colors">
 
// ✅ 提取 React 组件(复用单元 = 组件)
<Button variant="primary">Click</Button>
 
// ✅ 用 clsx / cva 组织变体
const button = cva(
  'px-4 py-2 rounded font-medium transition-colors',
  {
    variants: {
      variant: {
        primary: 'bg-blue-500 hover:bg-blue-700 text-white',
        secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-900',
      },
    },
  }
);

cva(Class Variance Authority)是 Tailwind 生态的事实标准变体管理工具,shadcn/ui 内部就是用它。

权威参考:Tailwind CSS 官方安装文档Next.js 集成

3.4 三个常见误解

误解 1:原子化类名 = 复制粘贴 = 难维护

事实:原子化类名的复用单元从"CSS 类"上升到"React 组件"——你提取 <Button> 组件,组件内部的类名就被封装了。这比"提取 CSS 类被很多组件引用"更易追踪改动影响——改组件 = 改所有用它的地方。

误解 2:Tailwind 让 HTML "脏"

事实:HTML 里出现样式信息是因为样式逻辑本来就属于组件——<Button> 组件的样式应该和组件代码在一起。Tailwind 让样式和结构在同一个文件,减少了"改一处要跳多个文件"的摩擦。这是 React 时代"组件 = UI + 样式 + 行为"心智的延续。

误解 3:Tailwind 适合所有项目

事实:Tailwind 不擅长动态计算样式——如果你写的是图表 / 编辑器 / 高度运行时计算的组件(比如 transform: translate(${x}px, ${y}px)),Tailwind 帮不上忙,回到 CSS Modules + style 属性更好。

3.5 选型推荐立场

新项目无特殊原因选 Tailwind + shadcn + Radix——这是 2024-2026 年生态默认组合。"特殊原因"包括:

  • 团队成员中超过 50% 是设计师 / 视觉工程师,不接受原子化类名
  • 现有代码库是 CSS Modules / SCSS,迁移成本高于收益
  • 项目以高度动态样式计算为主(编辑器 / 图表 / 游戏)
  • 框架 / 平台限制(如某些低代码平台、邮件模板)

不在"特殊原因"里就选 Tailwind——它在 AI 协作 / 设计系统对接 / 重构成本三个维度都是当前最优解。

这是手册的显式立场——不是"各有适用场景"的和稀泥。手册定位是判断 + 速查,模糊立场对读者不利。如果你的项目落在"特殊原因"里,回到 §1 选型决策图找替代方案。

社区数据参考:State of CSS 2024——Tailwind 是过去三年使用率和满意度双第一的 CSS 框架,CSS Modules 也保持稳定份额(两者并存)。


4. CSS-in-JS:曾经流行,现在份额下滑

CSS-in-JS 是 2017-2021 年 React 生态的"最佳实践"推荐——代表库 styled-components / Emotion。但 2022 年起 React 18 引入 RSC(服务端组件)后,运行时 CSS-in-JS 无法在服务端组件里使用——这是 2024 年份额下滑的直接原因。

4.1 核心机制

CSS-in-JS 把样式写成 JavaScript 对象或模板字符串,运行时注入到 DOM

import styled from "styled-components";
 
const Button = styled.button`
  padding: 0.5rem 1rem;
  background: ${(props) => (props.primary ? "#3b82f6" : "#e5e5e5")};
  color: ${(props) => (props.primary ? "#fff" : "#1a1a1a")};
  border-radius: 0.25rem;
 
  &:hover {
    opacity: 0.8;
  }
`;
 
<Button primary>Click</Button>;

优势(2017-2021 年的卖点)

  • 样式 + 组件同文件,真正的"组件封装"
  • 动态主题 / props 驱动样式(如上面 props.primary
  • 自动生成唯一类名,避免冲突
  • TypeScript 类型安全(类型化的 props → 样式)

4.2 为什么在 RSC 下不工作

React Server Components(RSC)的核心机制:

  • 组件在服务端渲染完成
  • 输出 HTML + 少量 JSON(不含组件 JS)
  • 客户端只加载"必须在客户端运行"的部分

运行时 CSS-in-JS 的机制:

  • 组件渲染时,JS 代码读取 props → 生成样式 → 注入到 <style> 标签
  • 这一套依赖客户端运行时 JS

两者冲突:RSC 在服务端渲染完就输出 HTML,不带 styled-components 的运行时——客户端收到的 HTML 里没有样式,加载后"闪白"或"无样式"。

Next.js 给出了集成方案(用 StyledComponentsRegistry 等包装器),但只能用于客户端组件——服务端组件这一层完全用不上 CSS-in-JS 的"运行时计算"能力,详见 Next.js 官方文档:CSS-in-JS

4.3 编译时 CSS-in-JS:vanilla-extract

如果你需要"写 JS 对象、编译成 CSS 文件",可以用编译时 CSS-in-JS——代表是 vanilla-extract

// button.css.ts
import { style } from "@vanilla-extract/css";
 
export const button = style({
  padding: "0.5rem 1rem",
  background: "#3b82f6",
  borderRadius: "0.25rem",
  ":hover": {
    opacity: 0.8,
  },
});
// Button.tsx
import { button } from "./button.css";
 
export function Button({ children }) {
  return <button className={button}>{children}</button>;
}

编译后生成真实的 .css 文件(类似 CSS Modules),没有运行时开销,支持 RSC。

何时选 vanilla-extract

  • 团队习惯 CSS-in-JS 写法,但要上 RSC
  • 需要 TypeScript 类型安全的样式(vanilla-extract 全类型化)
  • 不需要运行时动态计算(运行时切换用 CSS 变量)

4.4 什么时候仍然选运行时 CSS-in-JS

极少数场景仍然合理

场景为什么
高度动态的主题切换(用户上传配色方案)CSS 变量 + Tailwind 办不到——需要运行时解析颜色、生成类
纯客户端 SPA(不用 SSR / RSC)没有服务端渲染,运行时 CSS-in-JS 没有冲突
现有代码库是 styled-components,迁移成本巨大维持现状比重写更经济
组件库(发布给外部用)需要零配置接入styled-components 能做到"用户不用配置,直接 import 有样式"

如果不在上面四种,不要选运行时 CSS-in-JS——2024-2026 年的默认是 Tailwind + CSS 变量 + shadcn。

一手参考:styled-components #4025(官方对 RSC 支持的讨论);Next.js: CSS-in-JS


5. 无头组件(Headless UI)

"无头组件"是 2020 年后成熟的一套理念:组件库只提供行为 + a11y + 状态管理,不带样式——样式交给项目方自己写。

5.1 核心理念

传统组件库(Ant Design / MUI)的打包方式:

UI = 行为 + 样式 + a11y + 状态
你 import Button,得到的是"完整的按钮"(样式固定)

无头组件的打包方式:

UI = 行为 + a11y + 状态
你 import Button,得到的是"行为正确的 button 元素"
样式 = 你自己写(Tailwind / CSS Modules / CSS-in-JS)

为什么叫"无头":headless = 没有视觉呈现(head),只有功能(body)——比喻来自"无头浏览器"(没有 UI 界面的浏览器)。

5.2 主流无头组件库

背景特点
Radix UIModulz 团队(shadcn 基础)最成熟;shadcn 基于它
Headless UITailwind 官方与 Tailwind 天然搭档
Ark UIChakra UI 团队新作跨框架(React / Vue / Solid)
React AriaAdobea11y 极强;学习曲线陡

5.3 实例:Radix 的 Dialog

import * as Dialog from "@radix-ui/react-dialog";
 
<Dialog.Root>
  <Dialog.Trigger asChild>
    <button className="px-4 py-2 bg-blue-500 text-white rounded">Open Dialog</button>
  </Dialog.Trigger>
 
  <Dialog.Portal>
    <Dialog.Overlay className="fixed inset-0 bg-black/50" />
    <Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6 rounded shadow-lg">
      <Dialog.Title className="text-xl font-bold">Title</Dialog.Title>
      <Dialog.Description className="mt-2 text-gray-600">Description</Dialog.Description>
      <Dialog.Close asChild>
        <button className="mt-4 px-4 py-2 bg-gray-200 rounded">Close</button>
      </Dialog.Close>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>;

Radix 提供的:

  • ✅ 焦点管理(打开时 focus trap、关闭后 focus 回到 trigger)
  • ✅ 键盘交互(Esc 关闭、Tab 循环)
  • ✅ ARIA 属性(role="dialog" / aria-labelledby 等)
  • ✅ Portal(渲染到 body,避免 z-index 问题)

你自己写的:

  • 所有 className(样式完全自定义)

5.4 无头组件 vs 完整组件库

维度无头组件(Radix)完整组件库(Ant Design)
样式自由度100%(完全自己写)低(需覆盖默认样式)
开箱速度慢(每个组件都要自己写样式)快(import 即用)
设计系统对接天然(样式从令牌层开始)需覆盖主题变量
学习曲线中(需理解组件行为 API)低(文档齐全)
适用场景定制产品 / 多品牌 SaaS / 设计系统中后台业务 / 快速搭建

何时选无头组件

  • 有设计系统,需要样式完全自定义
  • 与 Tailwind / shadcn 配合(天然搭档)
  • 团队有前端设计能力

何时选完整组件库

  • 中后台业务系统(表单 / 表格密集)
  • 快速搭建比定制重要
  • 接受"设计语言 = Ant Design / MUI 风格"

权威参考:Radix UI 文档


6. shadcn/ui:复制粘贴范式

shadcn/ui 是 2023 年起迅速崛起的新范式——它不是组件库,是 CLI 工具 + 代码模板

6.1 核心心智:代码进项目,不进 node_modules

传统组件库:

npm install antd
import { Button } from "antd";
// Button 组件在 node_modules/antd 里
// 你不能改它的实现,只能用 props 配置 + 主题覆盖

shadcn 范式:

npx shadcn-ui@latest add button
✓ 创建 src/components/ui/button.tsx
import { Button } from "@/components/ui/button";
// Button 组件代码就在你的项目里 —— 你可以随便改

这是范式差异——shadcn 把组件代码复制到你的项目,你拥有完整代码控制权。它内部基于 Radix UI(行为)+ Tailwind(样式)+ cva(变体管理)。

6.2 "灵活修改"具体能做什么

"代码进项目" 听起来抽象。具体到日常开发,shadcn 让你能做这五件传统组件库做不到(或要付出巨大代价)的事:

想做什么传统组件库(Ant Design)shadcn(代码在项目内)
改 Button 的圆角默认值全局主题 token / :where() 覆盖直接改 button.tsx 里的 rounded-md
给 Dialog 加一个项目专属的"半透明遮罩"动画写包装组件 + CSS 注入直接在 dialog.tsx 里改 <DialogOverlay>
删掉一个不用的 prop(比如 loading不能删(库的 API 固定)直接删——你的代码你做主
增加一个新 variant(如 variant="brand"包装组件或 className 覆盖cva() 里加一行
让 AI 帮你重写整个组件以适配新设计系统几乎不可能(AI 改 node_modules 很难)AI 直接改 src/components/ui/*.tsx

就是"变体"——同一个组件的不同视觉版本(如按钮的 primary / secondary / destructive)。cva(Class Variance Authority)让你用配置而非 if-else 组织变体的类名映射。

下面是 shadcn 复制到你项目里的 Button 组件长什么样(简化版)——这就是你能改的代码

// src/components/ui/button.tsx —— 复制到你项目里的代码
import { cva, type VariantProps } from "class-variance-authority";
 
const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive: "bg-destructive text-white hover:bg-destructive/90",
        outline: "border border-input bg-background hover:bg-accent",
        // 想加新 variant?直接在这里加一行
      },
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 px-3",
        lg: "h-11 px-8",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  },
);
 
// 这个文件是你的项目代码——想改什么改什么
export function Button({ variant, size, ...props }) {
  return <button className={buttonVariants({ variant, size })} {...props} />;
}

关键洞察:传统组件库把"组件实现"藏在 node_modules 里——你只能用提供的 props / theme API。shadcn 把"组件实现"暴露在你项目里——你拥有的不是组件库,是组件源码

6.3 为什么这个范式特别合适 AI 时代

AI 改 node_modules 难,改项目内代码容易

场景传统组件库(在 node_modules)shadcn(在项目内)
AI 阅读组件实现要让 AI 读 node_modules/antd/...(巨大)AI 直接读 src/components/ui/button.tsx
AI 修改组件行为几乎不可能(要 fork 整个库)AI 直接编辑文件
AI 添加新变体要写包装组件AI 直接加 variant
升级风险升级 antd 可能破坏整套样式不升级也行;升级 = 重新 add 单个组件
自定义自由度受限于 props API100%(代码全在自己手里)

AI 协作下 shadcn 范式的真正优势:你和 AI 在同一份代码上工作——AI 能读懂、改对、不破坏。这是传统组件库做不到的。

6.4 工作流示例

# 1. 初始化(一次)
npx shadcn-ui@latest init
 
# 2. 按需添加组件
npx shadcn-ui@latest add button
npx shadcn-ui@latest add dialog
npx shadcn-ui@latest add form

每个命令把对应组件代码复制到 src/components/ui/

src/components/ui/
├─ button.tsx      ← 你的代码,可以改
├─ dialog.tsx      ← 你的代码,可以改
└─ form.tsx        ← 你的代码,可以改

6.5 shadcn vs 传统组件库的边界

何时选 shadcn

  • 需要设计系统定制(每个组件都要按自己的设计语言改)
  • 用 Tailwind + Radix 组合
  • AI 协作密集的项目
  • 重视长期维护成本(不被组件库版本绑死)

何时选传统组件库

  • 中后台业务,开箱大量复杂组件(DatePicker / Table / Form)
  • 团队没有前端设计能力
  • 接受"长得像 Ant / MUI"的 UI

误区:shadcn 不是替代所有组件库——它在"基础 UI 元素 + 设计系统"层是最优解,但复杂业务组件(数据表格 / 高级表单)shadcn 没那么齐全。Ant Design Pro 这种"开箱即中后台"的产品仍有市场。

6.6 维护责任的转移

shadcn 范式的代价:组件代码在你项目里,升级和维护都是你的责任

情况传统组件库shadcn
安全漏洞(如 Radix 依赖)npm update antd你需要自己跟进 Radix 升级
Bug 修复等官方发版自己改(或重新 add
一致性自动一致需要自己维护(多个组件都要按设计令牌走)

应对建议

  • 把 shadcn 添加的组件当作自己的代码维护——纳入团队 review 流程
  • 定期看 shadcn 仓库更新(GitHub
  • 让 AI 帮你跟进 Radix / Tailwind 的破坏性变更

权威参考:shadcn/ui 官方文档GitHub


7. 完整组件库:中后台高频选择

完整组件库(Ant Design / Element Plus / Material UI 等)在 2026 年仍是中后台业务系统的高频选择——不是过时方案,而是不同场景的最优解。

7.1 主流组件库

生态设计语言主要场景
Ant DesignReactAnt Design中后台业务(中文社区主流)
Element PlusVue 3Element中后台业务(中文社区主流)
Material UI (MUI)ReactMaterial 3国际化产品 / 类 Google 风格
Naive UIVue 3自有Vue 3 主推
MantineReact自有现代设计 + 极全组件

7.2 何时选完整组件库

合理场景

  • 中后台业务系统——表单 / 表格 / 树 / 日期选择 / 工作流密集
  • 快速搭建 MVP——开箱即用比定制重要
  • 团队没有前端设计师——直接用组件库的设计语言
  • 企业内部系统——一致性 > 个性化

反模式场景

  • C 端产品强调品牌差异化——组件库的"通用美感"和你的品牌不匹配
  • 设计系统驱动开发——组件库的扩展点不够
  • 多品牌 / 主题切换重的 SaaS——主题覆盖维护成本高

7.3 与 Tailwind 配合的陷阱(中国开发者高频实战)

国内大多数中后台 / B 端项目都在 Ant Design + Tailwind 共存的现实里——抛弃 antd 不现实(业务组件密度太高),抛弃 Tailwind 也不愿意(业务侧自定义页面、营销页、详情页的样式效率它最高)。这一段给出可落地的混用模式。

冲突点 1:Tailwind Preflight 重置 antd 内置样式

Tailwind 默认注入的 (基于 modern-normalize)会重置 <button> / <h1> / <ul> 等元素的浏览器默认样式——但 antd 的组件依赖某些默认样式(比如 <button> 自带 padding、<h1> 字号)。两者叠加会让 antd 组件视觉错乱。

症状:用了 antd 的 <Button> 后字体变小、边距错位、标题字号丢失。

冲突点 2:CSS 优先级互砍

// ❌ Tailwind className 看似生效但被 antd 内部样式覆盖
<Button className="bg-red-500">Click</Button>

原因:antd v5+ 用 CSS-in-JS(@ant-design/cssinjs)在运行时注入样式——注入顺序不可控,特异性也较高。

三种应对方案(按推荐度排序)

方案 A:scoped Preflight(推荐)

tailwindcss-scoped-preflight 让 Preflight 只作用在 antd 子树之外的区域

// tailwind.config.ts
import { scopedPreflightStyles, isolateInsideOfContainer } from "tailwindcss-scoped-preflight";
 
export default {
  // ...
  plugins: [
    scopedPreflightStyles({
      isolationStrategy: isolateInsideOfContainer(".tw-scope", {
        except: ".ant-app, .ant-message",
      }),
    }),
  ],
};
// 你的页面用 .tw-scope 包裹——Preflight 只生效在这里
<div className="tw-scope">
  {/* Tailwind 在这里随便写 */}
  <h1 className="text-3xl font-bold">营销页标题</h1>
</div>;
 
{
  /* antd 区域不受 Preflight 影响 */
}
<App>
  <Button>antd 正常工作</Button>
</App>;
方案 B:完全关闭 Preflight
// tailwind.config.ts
export default {
  corePlugins: {
    preflight: false, // 全局关闭
  },
};

代价:失去 box-sizing / margin 重置等好处——你需要自己处理浏览器默认样式差异。

方案 C:用 PostCSS 插件修补

postcss-antd-fixes 在构建期把 antd 受影响的元素打补丁——零运行时代价。

// postcss.config.js
module.exports = {
  plugins: {
    "postcss-antd-fixes": {},
    tailwindcss: {},
    autoprefixer: {},
  },
};

改样式的边界:什么用 antd token,什么用 Tailwind

想改什么用什么手段
antd 组件的颜色 / 圆角 / 字号(全局基调)ConfigProvider + theme.token
antd 组件的局部覆盖(某个页面的特殊 Button)antd 的 classNames / styles prop(v6 新增)
业务自定义页面(无 antd 组件)直接 Tailwind
antd 表单嵌业务组件的"夹缝"Tailwind(用 tw-scope 包住)

经验法则

  • 不要用 Tailwind 类强行覆盖 antd 组件内部样式——那是 antd 的领地
  • 不要用 antd token 给业务自定义页面做样式系统——那是 Tailwind 的领地
  • 边界清晰 比"哪个赢"重要

7.4 antd v6(2025 年发布):CSS 变量 + Semantic Design

Ant Design v6 在 2025 年正式发布——是技术升级,API 与 v5 高度兼容(不需要 codemod)。三个值得关注的变化:

变化 1:默认 CSS 变量模式

v5 用的 @ant-design/cssinjs 会在运行时为每个组件实例哈希一个唯一类——大列表场景下哈希成本累积可观。v6 默认改成 CSS 变量模式——token 编译为 --ant-* 变量,组件类名稳定,哈希次数大幅减少。

对你的影响

  • 主题切换更便宜(改 CSS 变量即可,不用重新哈希)
  • bundle 略小(减少了重复的样式声明)
  • 依赖 IE 的项目无法升级 v6(不过 v5 也已经放弃 IE 了)

变化 2:Semantic Design(语义化 DOM API)

v6 给每个组件暴露稳定的内部 DOM 部位映射——可以通过 classNamesstyles prop 精确改某个部位:

// 给 Modal 的 header 加 Tailwind 类,不影响 body
<Modal
  classNames={{
    header: "bg-gradient-to-r from-blue-500 to-purple-500",
    body: "p-6",
  }}
  styles={{
    footer: { borderTop: "none" },
  }}
/>

这一改动让"局部样式覆盖"成为一等公民——以前要靠 :global 选择器或 !important 才能改的部位,现在有规范 API。

变化 3:React 18+ 必需

v6 不再兼容 React 17 及更早——升级 v6 前先升级 React

升级建议

  • 新项目:直接 v6 起步
  • v5 项目:等内部 token 自定义稳定后再升(API 兼容,但 CSS 变量模式可能影响主题魔改代码)
  • 极老项目(React < 18):先升 React,再升 antd

7.5 升级成本:组件库版本绑定

完整组件库的隐性成本:升级风险。

风险点例子
Major 版本破坏性变更Ant Design v4 → v5 改了 CSS-in-JS 引擎,整套样式重写
设计语言迭代Material UI v4 → v5 的 sx prop API 完全不一样
组件 API 微调某属性改名,跨项目所有用法都要改

判断要点:选组件库前问自己——"如果这个库三年后我们要升级 major 版本,我们承担得起吗?" 答不上 → 考虑 shadcn 范式(代码在自己手里,无版本绑定)。


8. 设计令牌(design tokens):跨方案的根

这一段是本节的核心——无论你选什么写法、什么组件分发方式,设计令牌都是基础。把它立起来,上层方案怎么换都不痛;不立起来,迟早要重构。

8.1 什么是设计令牌

设计令牌(design tokens)是视觉设计决策的命名键值对——把"颜色"、"字号"、"间距"、"阴影"等设计原语抽象成命名变量,跨设计稿、代码、文档、多平台共享。

最简形式

:root {
  /* 颜色 */
  --color-primary: #3b82f6;
  --color-text: #1a1a1a;
 
  /* 间距 */
  --space-2: 0.5rem;
  --space-4: 1rem;
 
  /* 字号 */
  --font-size-base: 1rem;
  --font-size-lg: 1.125rem;
 
  /* 圆角 */
  --radius-sm: 0.125rem;
  --radius-md: 0.25rem;
}
.button {
  background: var(--color-primary);
  padding: var(--space-2) var(--space-4);
  border-radius: var(--radius-md);
}

这不只是"CSS 变量的别名"——它是设计系统的单一来源(Single Source of Truth)

8.2 不只是变量:三层命名

成熟的令牌系统有三层命名——这是大部分团队没做对的部分:

原始层(raw / global)

语义层(semantic / alias)

组件层(component-specific)

展开示例

:root {
  /* —— 第 1 层:原始令牌(raw) —— */
  /* 给"颜色本身"起名,不绑定用途 */
  --blue-50: #eff6ff;
  --blue-500: #3b82f6;
  --blue-900: #1e3a8a;
  --gray-100: #f3f4f6;
  --gray-900: #111827;
 
  /* —— 第 2 层:语义令牌(semantic) —— */
  /* 给"用途"起名,引用原始令牌 */
  --color-primary: var(--blue-500);
  --color-primary-hover: var(--blue-900);
  --color-bg: var(--gray-100);
  --color-text: var(--gray-900);
 
  /* —— 第 3 层:组件令牌(component) —— */
  /* 给"组件具体属性"起名,引用语义令牌 */
  --button-bg: var(--color-primary);
  --button-bg-hover: var(--color-primary-hover);
  --button-text: var(--gray-100);
}

为什么要分三层

场景没分层的代价分层后的好处
改主色(蓝 → 紫)全局搜 #3b82f6,逐个改--blue-500 一处
暗色模式给每个组件写一份"暗色版" CSS--color-bg / --color-text 一处
多品牌切换整套 CSS 重写改语义层一处(语义指向不同原始令牌)
重构按钮样式找所有用 --color-primary 的地方--button-bg 一处

实战:暗色模式 + 三层令牌

:root {
  /* 原始令牌(明暗共用) */
  --blue-500: #3b82f6;
  --gray-100: #f3f4f6;
  --gray-900: #111827;
 
  /* 语义令牌(按主题切换) */
  --color-bg: var(--gray-100);
  --color-text: var(--gray-900);
}
 
[data-theme="dark"] {
  /* 暗色模式只改语义层指向 */
  --color-bg: var(--gray-900);
  --color-text: var(--gray-100);
}

组件代码不需要写"亮色版本 / 暗色版本"——只引用语义令牌,主题切换由 [data-theme] 控制。

8.3 跨方案落地:CSS 变量 / Tailwind / Style Dictionary

落地方式 1:纯 CSS 变量

适合任何写法(CSS Modules / Tailwind / 原生 CSS):

/* tokens.css */
:root {
  --blue-500: #3b82f6;
  --color-primary: var(--blue-500);
  /* ... */
}
// Tailwind 中引用 CSS 变量
<button className="bg-[var(--color-primary)]">Click</button>

落地方式 2:Tailwind 配置

把令牌写进 tailwind.config.ts,Tailwind 自动生成对应类名:

// tailwind.config.ts
import type { Config } from "tailwindcss";
 
export default {
  theme: {
    extend: {
      colors: {
        primary: {
          DEFAULT: "#3b82f6",
          hover: "#1e3a8a",
        },
        bg: "var(--color-bg)", // 引用 CSS 变量
      },
      spacing: {
        2: "0.5rem",
        4: "1rem",
      },
      borderRadius: {
        sm: "0.125rem",
        md: "0.25rem",
      },
    },
  },
} satisfies Config;
<button className="bg-primary hover:bg-primary-hover">Click</button>

落地方式 3:Style Dictionary(多平台输出)

如果你的设计系统需要输出到多个平台(Web + iOS + Android),用 Style Dictionary——一份令牌定义,编译到不同格式:

// tokens.json
{
  "color": {
    "primary": { "value": "#3b82f6" }
  }
}

编译输出:

  • Web:--color-primary: #3b82f6
  • iOS:UIColor(red: 0.23, green: 0.51, blue: 0.96, alpha: 1)
  • Android:<color name="color_primary">#3b82f6</color>

何时需要 Style Dictionary:跨 Web / iOS / Android 的设计系统。纯 Web 项目用 CSS 变量 / Tailwind 配置就够。

8.4 命名约定的细节

几条经验

  1. 数字阶梯优先——--space-2 / --space-4 / --space-8--space-small / --space-medium 更可扩展(要插入新档时不破坏)
  2. 语义层不要带具体值描述——用 --color-primary 而不是 --color-blue(颜色未来可能换)
  3. 保持单一维度——--color-bg 不要写成 --color-bg-light("light" 是主题维度,应该在 [data-theme] 切换层处理)
  4. 避免过度细分——--button-bg-hover-disabled-active 太长,复合状态用类名组合表达

轻量替代方案:如果你不想自己设计令牌系统,Open Props 提供了一组开箱即用的 CSS 令牌(颜色 / 间距 / 字体等),导入即用。适合个人项目 / 原型快速搭建。

权威参考:MDN: 使用 CSS 自定义属性Style DictionaryOpen Props

8.5 实战落地步骤:从零开始建立令牌系统

很多团队听说"设计令牌很重要",但不知道第一步该做什么。下面是一个最小但完整的落地路径——按这个顺序做,2-3 天就能让团队用起来。

Step 1:从设计稿提取核心 token(半天)

打开设计稿(Figma / Sketch),列出所有出现的

  • 颜色:合并相近色(设计稿里 #3B82F6 和 #3A82F7 八成是设计师手抖,统一)
  • 字号:通常 5-8 档(如 12 / 14 / 16 / 20 / 24 / 32)
  • 间距:通常基于 4 或 8 的倍数(4 / 8 / 12 / 16 / 24 / 32 / 48 / 64)
  • 圆角:通常 3-4 档(0 / 4 / 8 / 16)
  • 阴影:通常 3-5 档(无 / 小 / 中 / 大 / 超大)

输出:一份 tokens-raw.json(或与设计师共享的表格),列出"原始层"。

{
  "color": {
    "blue": { "50": "#eff6ff", "500": "#3b82f6", "900": "#1e3a8a" },
    "gray": { "50": "#f9fafb", "100": "#f3f4f6", "900": "#111827" }
  },
  "spacing": { "1": "4px", "2": "8px", "3": "12px", "4": "16px" },
  "radius": { "sm": "4px", "md": "8px", "lg": "16px" },
  "fontSize": { "xs": "12px", "sm": "14px", "base": "16px", "lg": "20px" }
}

Step 2:定义语义层 token(半天)

把"原始层"映射到"用途":

:root {
  /* 引用原始 */
  --color-primary: var(--blue-500);
  --color-primary-hover: var(--blue-700);
  --color-text: var(--gray-900);
  --color-bg: var(--gray-50);
  --color-border: var(--gray-200);
 
  /* 状态色 */
  --color-success: var(--green-500);
  --color-warning: var(--yellow-500);
  --color-error: var(--red-500);
}
 
[data-theme="dark"] {
  --color-primary: var(--blue-400);
  --color-text: var(--gray-50);
  --color-bg: var(--gray-900);
  --color-border: var(--gray-700);
}

关键:暗色模式只需要改语义层指向,原始层不变

Step 3:接入 Tailwind 配置(半天)

让 Tailwind 直接用 CSS 变量——不需要在 Tailwind 配置里复制颜色值:

// tailwind.config.ts
export default {
  theme: {
    extend: {
      colors: {
        primary: "var(--color-primary)",
        "primary-hover": "var(--color-primary-hover)",
        text: "var(--color-text)",
        bg: "var(--color-bg)",
        border: "var(--color-border)",
        success: "var(--color-success)",
        warning: "var(--color-warning)",
        error: "var(--color-error)",
      },
      spacing: {
        1: "var(--spacing-1)",
        2: "var(--spacing-2)",
        // ...
      },
      borderRadius: {
        sm: "var(--radius-sm)",
        md: "var(--radius-md)",
        lg: "var(--radius-lg)",
      },
    },
  },
};

好处bg-primary 类名自动跟随主题——亮色 / 暗色模式无需写两套。

Step 4:与 antd 集成(半天,仅当用 antd)

import { ConfigProvider, theme } from "antd";
 
const App = () => {
  const isDark = useThemeMode();
 
  return (
    <ConfigProvider
      theme={{
        algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
        token: {
          // 让 antd 用你的语义令牌
          colorPrimary: getCSSVar("--color-primary"),
          colorBgBase: getCSSVar("--color-bg"),
          colorTextBase: getCSSVar("--color-text"),
          borderRadius: parseInt(getCSSVar("--radius-md")),
          // ...
        },
      }}
    >
      {/* 你的应用 */}
    </ConfigProvider>
  );
};
 
// 工具函数:从 :root 读 CSS 变量
function getCSSVar(name: string) {
  if (typeof window === "undefined") return "";
  return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}

关键:让 antd 和 Tailwind 共享同一个语义令牌层——亮暗切换、品牌色变更都只改一处。

Step 5:为 token 写文档(半天)

最简方式是用 Tailwind 类名加文档注释——开发者就能直接看:

// tailwind.config.ts
export default {
  theme: {
    extend: {
      colors: {
        // 主色——用于按钮、链接、关键操作的高亮
        primary: "var(--color-primary)",
        // 主色 hover 状态
        "primary-hover": "var(--color-primary-hover)",
      },
    },
  },
};

更全面的方案是用 StorybookBacklight 搭建设计系统站点。前期不必上 Storybook——半页 Markdown 文档就够。

落地节奏建议

  • 第 1 周:Step 1-3,让 Tailwind 用上 token
  • 第 2 周:Step 4,让 antd 共享 token
  • 第 1-2 月:Step 5,逐步沉淀文档
  • 季度复盘:扩展 token(新颜色 / 新阴影),评估命名是否需要调整

经验不要一开始就追求"完美令牌系统"——有 80% 的 token 在用就比"100% 漂亮但写完没人用"强得多。

8.6 设计令牌的常见失败模式

反模式症状修复
令牌定义在 PR 模板里没人维护设计稿 token 和代码 token 不一致让设计稿和代码共享 SSOT(如 Figma Variables → JSON)
只有一层(没分原始 / 语义)改主色要改几十处先建语义层(详见 §8.2)
Tailwind 配置里硬编码十六进制色值改主题要重构Tailwind 配置引用 CSS 变量
暗色模式靠重复定义所有色值维护两套用语义层 + [data-theme='dark'] 覆盖
token 文档跟代码脱节新人不知道有什么 token用工具站点(Storybook / Backlight)

9. 三档归位

知识点归位理由
Tailwind 基础类名(px / py / flex 等)必学2024-2026 年事实标准
CSS Modules 写法必学Next.js 默认支持;团队可能选它
设计令牌三层命名(原始 / 语义 / 组件)必学跨方案的根;不立起来迟早重构
shadcn/ui 的"复制粘贴范式"必学AI 时代组件分发最优解
无头组件(Radix / Headless UI)核心理念必学与 Tailwind + shadcn 天然搭档
CSS 变量(--var-name)+ var()必学令牌系统落地手段
Tailwind 配置驱动设计系统必学tailwind.config.ts 定义令牌
为什么 CSS-in-JS 在 RSC 下受限必学理解 2024 年生态转折
选型决策判断(内容站 / 中后台 / SaaS 等)必学判断比技术细节重要
BEM 命名约定仍需理解CSS Modules 场景可能会用
完整组件库(Ant / MUI)的边界仍需理解中后台高频,知道何时选、何时不选
vanilla-extract(编译时 CSS-in-JS)仍需理解知道存在,有需求再学
cva(Class Variance Authority)仍需理解shadcn 内部用;自己写变体管理时会用
Style Dictionary(多平台令牌编译)仍需理解跨 Web / iOS / Android 时用
运行时 CSS-in-JS(styled-components)详细 API可委托AI 会写;你知道"不适合 RSC"即可
SCSS / Less 详尽语法可委托原生 CSS 已够;遗留项目让 AI 写
Atomic CSS 方法论完整推导可委托知道 Tailwind 基于它即可

10. 设计判断暗线

本节贯穿几条核心设计判断,会在以下位置点破:

信息隐藏(information hiding):每层抽象都在"藏什么、暴露什么"

样式方案选型的本质是选择抽象层次——每一层都在"藏"某些细节、"暴露"某些接口。

方案藏了什么暴露了什么代价
设计令牌具体数值(#3b82f6语义接口(--color-primary需要设计命名约定
TailwindCSS declaration block原子类名(px-4 / bg-blue-500类名长;需学习映射
CSS Modules类名作用域(编译时生成唯一标识)普通 CSS 类名样式与组件分文件
shadcn主动破除"藏"——代码暴露在项目内完整组件实现维护责任转移
完整组件库组件实现 + 样式细节(黑盒)Props API控制权低;升级风险

选错抽象层 = 后续技术债的根

  • 过度抽象:完整组件库把样式锁死,想定制就只能"覆盖样式 + 魔法 CSS"——维护地狱
  • 抽象不足:不分离设计令牌,颜色 / 间距到处硬编码——重构时牵一发动全身
  • 合适抽象:令牌层藏数值、暴露语义;Tailwind 藏 declaration、暴露原子类;shadcn 暴露全部实现——三层协同工作

抽象的代价 vs 复利

抽象有初始成本(学习 / 配置 / 维护),但带来长期复利(重构容易 / 一致性强 / AI 协作好)。

阶段完整组件库(高初始抽象)Tailwind + shadcn(低抽象)
第 1 周快(开箱即用)慢(要写样式)
第 1-3 个月中等(定制开始受限)快(按自己设计系统积累)
半年后慢(重度定制 = 覆盖大战)快(样式与组件完全自控)
升级时痛(major 版本破坏性大)不痛(代码在自己手里,无版本绑定)

判断要点

  • 项目生命周期 < 6 个月?选高抽象(完整组件库)
  • 项目会持续迭代 1-3 年?选低抽象(Tailwind + shadcn)
  • 不确定?选低抽象——可逆性更高(见下)

可逆性优先(reversibility first)

选方案时优先选"删得掉"的,不选"删不掉"的。

方案可逆性理由
CSS 变量极高删一个变量,编辑器报错告诉你哪里用了
Tailwind删类名 = 删样式;不影响其他组件
shadcn组件在项目内,想删就删
CSS Modules样式与组件分文件,改动要跨文件验证
CSS-in-JS中低运行时注入,删除时要检查"是否有其他组件共享这段样式"
完整组件库删 Ant Design?整套 UI 重写

为什么可逆性重要

  • 决策错了能改——选错方案不等于"项目死刑"
  • 技术栈迁移容易——设计令牌 + Tailwind 迁到 Vue / Svelte 成本远低于 Ant Design
  • AI 协作友好——AI 删代码比"魔改抽象"更准确

实战建议:无特殊原因选 Tailwind + shadcn + CSS 变量——三者可逆性都高,删得掉、改得动、AI 写得准。


11. AI 时代写样式的最小集

你想做AI 帮得上吗
写 Tailwind 类名组合✅ AI 极擅长(机械任务)
写 CSS Modules 样式✅ AI 易写,但难判断"该提取还是内联"
写 CSS-in-JS(styled-components)✅ AI 会写,但你要 review "是否适合 RSC"
选型判断(Tailwind vs CSS Modules)❌ 业务上下文 AI 拿不到——列方案、你拍板
设计令牌命名⚠️ AI 能给候选,但"原始 / 语义 / 组件"分层需要你 review
改 shadcn 组件实现✅ shadcn 范式让 AI 改成本极低(代码在项目内)
覆盖 Ant Design 组件样式⚠️ AI 会写,但常出"优先级打不过内部样式"的问题

重点强调

Tailwind 是 AI 协作事实标准——为什么

  1. 类名组合是机械任务——flex items-center justify-between 没有歧义,AI 准确率极高
  2. 配置驱动 = 明确约束——AI 知道"哪些类名存在"(从 tailwind.config.ts 读),不会编造
  3. 一对一映射——px-4 = padding-left: 1rem; padding-right: 1rem,AI 不需要推理"这段样式该提取还是内联"
  4. shadcn 范式加持——AI 直接编辑项目内组件代码,不需要"理解 node_modules 里的组件库 API"

你必须把握的 review 要点

Tailwind 场景

  • 类名是否按设计令牌走(bg-primary 而非 bg-blue-500
  • 响应式断点是否合理(md: / lg: 分得对不对)
  • 是否该提取组件(类名超过 100 字符 → 提取)

CSS Modules 场景

  • 是否有全局污染风险(:global() 用对了吗)
  • 类名是否语义化(.button-primary 而非 .btn1
  • 是否复用了设计令牌(var(--color-primary) 而非 #3b82f6

CSS-in-JS 场景

  • 是否在 RSC 里用了运行时 CSS-in-JS(styled-components / Emotion)
  • 动态样式是否必要(能用 CSS 变量 + Tailwind 解决的不要写 JS)

完整组件库场景

  • 覆盖样式是否用了 !important(应该用组件库的主题 API)
  • 是否与 Tailwind 混用导致优先级互砍

12. 翻车库

12.1 Tailwind 类名失效——PurgeCSS 误删

症状:开发环境正常,构建生产版本后某些 Tailwind 类名样式没生效。

原因:Tailwind 在生产构建时会扫描源代码里出现的类名,没扫到的会被删除(这是 bundle 小的原因)。如果你的类名是动态拼接的,扫描器找不到:

// ❌ 反例:动态拼接,扫描器找不到 `bg-red-500`
const color = "red";
const className = `bg-${color}-500`;
 
// ❌ 反例:模板字符串里嵌字段
<div className={`bg-${theme}-500`} />;

修复

// ✅ 把所有可能的类名完整写出来
const variants = {
  red: "bg-red-500",
  blue: "bg-blue-500",
  green: "bg-green-500",
};
<div className={variants[color]} />;
 
// ✅ 或者在 safelist 里声明
// tailwind.config.ts
export default {
  safelist: ["bg-red-500", "bg-blue-500", "bg-green-500"],
};

12.2 CSS Modules 全局类污染

症状:CSS Modules 文件里写的 .button 影响到了项目其他组件的同名类。

原因:用了 :global() 而忘了关闭作用域:

/* ❌ 反例 */
:global {
  .button {
    color: red; /* 全局污染所有 .button */
  }
}

修复

/* ✅ 正确:默认局部作用域,只在需要时显式 :global() */
.button {
  color: red; /* 编译为 .Button_button__abc123 */
}
 
/* 必须全局时用单选择器 :global() */
:global(.legacy-button) {
  color: red;
}

12.3 Next.js App Router 下用 styled-components 报错

症状:Next.js 13+ App Router 项目用 import styled from 'styled-components' 后页面"闪白"或样式丢失。

原因:App Router 默认是 RSC,运行时 CSS-in-JS 不支持。

修复(按优先级)

  1. 首选:迁移到 Tailwind + CSS 变量
  2. 次选:迁移到 vanilla-extract(编译时 CSS-in-JS)
  3. 兜底:用 Next.js 官方 CSS-in-JS 适配方案(要写 Server Component 包装器,复杂)

12.4 Ant Design + Tailwind 混用,优先级互砍

症状

<Button className="bg-red-500">Click</Button>

按钮颜色没变红——Ant Design 内部样式优先级更高。

原因:Ant Design v5 用 CSS-in-JS 注入样式,特异性比 Tailwind 类高(或加载顺序在后)。

修复

  • 用 Ant 的 ConfigProvider + token 改主题
  • 不要混用——选一个:要么全 Ant,要么 Tailwind + 无头组件
  • 极少数场景用 ! 前缀:<Button className="!bg-red-500">(不推荐,是 hack)

12.5 设计令牌没分层——后期重构灾难

症状:项目迭代两年后,要换主色调(蓝 → 紫),全局搜 --color-primary 居然有 80 处直接引用——其中一半用法只是"恰好和主色一样",但语义不该绑死。

原因:令牌系统只有一层(--color-primary),没有原始层 / 语义层 / 组件层划分——所有"蓝色"都直接用 --color-primary,包括"主按钮"和"链接"和"提示标签"——它们语义不同,但绑了同一变量。

修复:分层:

:root {
  /* 原始 */
  --blue-500: #3b82f6;
  --blue-700: #1d4ed8;
 
  /* 语义 */
  --color-primary: var(--blue-500);
  --color-link: var(--blue-700); /* 链接和主按钮可以是不同蓝 */
 
  /* 组件 */
  --button-bg: var(--color-primary);
  --tag-info-bg: var(--color-primary);
}

之后换主色 → 只改原始层;某个组件不想跟主色 → 改组件层指向;语义层稳定。

12.6 shadcn 复制后没有维护机制

症状:项目用 shadcn 一年后,Radix UI 出了安全更新,但项目里 shadcn 复制的 dialog.tsx 仍然引用旧版 Radix——有人记得要升级吗?

原因:shadcn 的"代码进项目" = 维护责任也进项目。没有 npm update 能自动跟上 Radix 升级。

修复

  • 把 shadcn 添加的组件纳入 Code Review 流程——和业务代码同样对待
  • 定期看 shadcn 仓库 的 changelog
  • Renovate / Dependabot 监控 Radix / Tailwind 版本——内核库升级时手动重新 npx shadcn-ui@latest add 对应组件

12.7 类名拼写错误,IDE 不报

症状:写了 <div className="flex itmes-center">items 拼成 itmes),IDE 不报错,运行时样式没生效。

原因className 是字符串类型——TypeScript / IDE 默认不验证内容。

修复

12.8 CSS-in-JS 在客户端组件里仍然用,但 hydration 不一致

症状:用 styled-components 在 Next.js 的客户端组件里,控制台报 "Hydration failed because the initial UI does not match what was rendered on the server."

原因:服务端渲染时类名 hash 和客户端不一致。

修复:按 Next.js 官方文档配置 StyledComponentsRegistry——但这只是缓兵之计,根治方案是迁出 CSS-in-JS


13. 读完本节你应该能

选型判断

  • 给一个项目(说明类型 + 团队规模 + 设计要求),能口头说出推荐的样式方案组合,理由不止"我习惯用"
  • 解释为什么"无特殊原因新项目选 Tailwind + shadcn + Radix"
  • 列出选完整组件库(Ant / MUI)合理的场景和反模式场景
  • 解释 CSS-in-JS 在 RSC 下为什么受限

写法层

  • 区分写法层 / 方法论 / 框架 / 组件分发四个层级,给具体例子
  • 写出 CSS Modules 和 Tailwind 实现同一个 Button 组件的代码
  • 解释 Tailwind 长 className 的应对方式(提取组件 / cva)

组件分发

  • 解释 shadcn/ui 为什么不算"传统组件库"——以及"代码进项目"的好处和代价
  • 区分无头组件(Radix / Headless UI)和完整组件库(Ant / MUI)的边界
  • 解释为什么 shadcn 在 AI 时代特别合适

设计令牌

  • 写出三层令牌结构(原始 → 语义 → 组件)的代码示例
  • 解释为什么暗色模式只需要改语义层,不需要改组件代码
  • 知道何时需要 Style Dictionary,何时 CSS 变量 + Tailwind 配置就够

设计判断

  • 用"信息隐藏"心智解释 Tailwind / 完整组件库 / shadcn 三种方案"藏什么暴露什么"
  • 用"可逆性"心智判断方案选型——为什么删得掉的方案优于删不掉的
  • 解释抽象的代价 vs 复利曲线——为什么短期项目选高抽象、长期项目选低抽象

延伸阅读

访问性说明:MDN 中文版 + State of CSS 中文版直接可访问;其他英文站国内访问稳定。


下一节:§3.6 动画与 View Transitions——transition / animation 三件套,View Transitions API 跨页面动画。