引言
CSS 的工程化是前端领域最"百花齐放"也最"众说纷纭"的部分:BEM 方法论、CSS Modules、CSS-in-JS、Tailwind 的 utility-first,以及容器查询等原生新特性,各有拥趸也各有取舍。本文梳理 CSS 的演进脉络,逐层对比主流样式方案的适用场景,并给出可落地的选型决策与团队规范建议。文中涉及 Tailwind CSS 的 tailwind.config.js、vanilla-extract 的类型安全、容器查询 @container、子网格 subgrid 等均为真实 API 与真实 CSS 特性。
一、CSS 发展史与 Reset 的演进
1.1 从"写页面"到"工程资产"
CSS 最初被当作"给 HTML 上色"的简单工具,但随着组件化与设计系统的兴起,样式从"页面装饰"变成了需要版本管理、复用、可维护的工程资产。其演进主线是:
| 阶段 | 代表 | 解决的核心问题 |
|---|---|---|
| 手写全局面 | 裸 CSS | 无(当时没有问题) |
| 方法论时代 | BEM、OOCSS、SMACSS | 类名冲突、可维护性 |
| 模块化时代 | CSS Modules、预处理器 | 作用域隔离、变量 |
| 组件化时代 | CSS-in-JS | 与组件逻辑绑定、主题化 |
| 原子化时代 | Tailwind、UnoCSS | 复用、一致性、体积 |
1.2 Reset 与 Normalize
不同浏览器默认样式差异(margin、字体大小、列表样式)是历史痛点。Reset(Eric Meyer)把所有元素默认样式清零;Normalize 则保留有用默认值、只统一差异。现代实践中更流行的是轻量做法——只 reset 关键项,配合 box-sizing: border-box 全局设置:
/* 现代轻量 Reset 的核心 */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
line-height: 1.5;
font-family: system-ui, -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
img,
picture,
video {
max-width: 100%;
display: block;
}
button,
input,
select,
textarea {
font: inherit;
}
1.3 层叠与优先级是根因
CSS 的混乱根源是全局命名空间 + 层叠:任何一条规则都可能影响任意元素。后续所有方法论与工具,本质上都是对这两个特性的约束与对抗。
CSS 的每一次方法论演进,都是对「全局命名空间 + 层叠」这两个原罪的重新约束——理解这一点,就理解了所有样式方案存在的理由。
二、BEM 与 OOCSS:方法论时代的遗产与价值
2.1 BEM 的三段式命名
BEM(Block Element Modifier)用命名约定制造"伪作用域":
- Block:独立组件,如
.product-card - Element:组件内部元素,用
__连接:.product-card__title - Modifier:状态变体,用
--连接:.product-card--featured
/* BEM 命名示例 */
.product-card__title {
font-size: 1.125rem;
font-weight: 600;
}
.product-card--featured {
border-color: var(--color-accent);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
BEM 的优点是命名即文档、无冲突、可预测;缺点是类名冗长、嵌套层级多时拼接困难。它是"无构建工具时代的 CSS 工程化",在纯手写 CSS 或传统服务端模板项目中仍有价值。
2.2 OOCSS 与 SMACSS
OOCSS(Object Oriented CSS)强调结构与皮肤分离:把布局结构与视觉主题拆成独立类,实现复用。SMACSS 则按角色分类规则(Base/Layout/Module/State/Theme),建立文件组织约定。这些方法论为后来的组件化 CSS 提供了思想原型——它们的问题在于依赖团队自律,无法从机制上强制。
三、CSS Modules:编译期作用域隔离
3.1 原理与使用
CSS Modules 把每个 CSS 文件当作局部作用域:编译时给类名加 hash 后缀,从"命名冲突靠约定"变为"作用域隔离靠机制"。同时保留 CSS 的全部原生能力:
/* Button.module.css */
.root {
padding: 0.5rem 1rem;
border-radius: 6px;
background: var(--color-primary);
}
.primary {
background: var(--color-primary);
}
.secondary {
background: var(--color-gray-200);
}
// Button.tsx
import styles from './Button.module.css';
export function Button({ variant = 'primary', children }) {
return (
<button className={`${styles.root} ${styles[variant]}`}>
{children}
</button>
);
}
3.2 组合与取舍
CSS Modules 支持 composes 做类组合,但过度使用会降低可读性。它不解决"设计 token 系统"与"主题切换"问题,这些仍需配合 CSS 自定义属性(Custom Properties)使用。Vite/webpack 都原生支持 .module.css,零配置即可用。
| 维度 | CSS Modules | BEM |
|---|---|---|
| 作用域 | 编译期强制隔离 | 命名约定 |
| 学习成本 | 低 | 低 |
| 动态样式 | className 拼接 | 类名拼接 |
| 调试 | 类名带 hash,需 sourcemap | 类名语义清晰 |
四、CSS-in-JS:styled-components 与 vanilla-extract
4.1 styled-components 的运行时方案
styled-components 把样式写进组件文件,用模板字符串生成组件,样式与逻辑同处一地:
import styled from 'styled-components';
const Button = styled.button<{ $variant?: 'primary' | 'secondary' }>`
padding: 0.5rem 1rem;
border-radius: 6px;
border: none;
cursor: pointer;
${(props) =>
props.$variant === 'primary'
? css`background: ${props.theme.colors.primary}; color: #fff;`
: css`background: transparent; color: ${props.theme.colors.text};`}
`;
export function App() {
return <Button $variant="primary">提交</Button>;
}
运行时 CSS-in-JS 的优势是主题化与动态样式能力(按 props 生成样式)、样式自动作用域隔离。缺点是运行时开销(生成 <style> 标签、序列化 props)与 SSR 时的 FOUC(样式注入时序)问题。
4.2 vanilla-extract:编译期 CSS-in-JS
vanilla-extract 把 CSS-in-JS 的优势保留到编译期:TypeScript 类型安全、零运行时、产物是纯 CSS 文件。用 .css.ts 文件声明样式:
// Button.css.ts
import { style, styleVariants, createVar } from '@vanilla-extract/css';
export const accent = createVar();
export const root = style({
padding: '0.5rem 1rem',
borderRadius: 6,
background: accent,
});
export const variant = styleVariants({
primary: { background: 'var(--color-primary)' },
secondary: { background: 'var(--color-gray-200)' },
});
vanilla-extract 的类型安全体验显著:styleVariants、createVar、createTheme 等 API 让样式声明可被 TypeScript 检查,适合对工程质量要求高的团队。
4.3 运行时 vs 编译期对比
| 维度 | styled-components | vanilla-extract |
|---|---|---|
| 运行时代价 | 有(样式注入) | 无(编译成 CSS) |
| 类型安全 | 弱 | 强 |
| 动态 props 样式 | 原生支持 | 需 CSS 变量配合 |
| SSR 处理 | 需要专门处理 | 天然兼容 |
| 调试 | 类名 hash | 类名语义化 |
五、Tailwind CSS:utility-first 与性能优化
5.1 核心思想与配置
Tailwind 把样式拆成原子级 utility 类,HTML 中直接组合。它通过 tailwind.config.js 生成设计 token 一致的类名,配合 @apply 提取重复组合:
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
brand: {
50: '#eef2ff',
500: '#6366f1',
700: '#4338ca',
},
},
spacing: {
18: '4.5rem',
},
},
},
darkMode: 'class', // 通过 class 切换暗色模式
plugins: [],
};
<!-- utility 组合 -->
<button
class="rounded-lg bg-brand-500 px-4 py-2 text-white hover:bg-brand-700 focus-visible:outline-2 focus-visible:outline-brand-700"
>
提交
</button>
5.2 Purge 与产物体积
Tailwind 的经典争议是"类名在 HTML 里,样式在哪"。答案:content 配置扫描源文件,只生成实际用到的类,产物通常远小于手写完整 CSS。这是 utility-first 在性能上的关键设计:
# 产物分析
npm run build
# → styles.css 可能只有 10-30KB(gzip 前)
5.3 utility-first 的取舍
| 优点 | 代价 |
|---|---|
| 无命名纠结、一致性高 | 模板中的长类名 |
| 天然响应式(sm:/md:/lg: 前缀) | 动态类名需显式列举 |
| 设计 token 驱动 | 团队需约束"不乱造新类" |
| 暗色模式、状态样式内置 | 学习曲线与记忆成本 |
关键限制是动态拼接类名不生效(class={bg-${color}}),因为扫描器无法静态发现。必须在配置或源码中完整写出候选类名。
utility-first 的威力来自「被约束的词汇表」:类名越少、越可预测,团队写出来的界面越一致。自由是效率之敌。
六、设计系统与 Token:CSS 自定义属性
6.1 Design Token 的分层
设计系统的根基是 token:颜色、间距、字体、阴影、圆角等被具名管理。CSS 自定义属性(CSS Variables)是最通用的 token 载体:
:root {
/* 色彩层级 */
--color-brand-500: #6366f1;
--color-text-primary: #1f2937;
--color-text-secondary: #6b7280;
--color-bg-canvas: #ffffff;
/* 间距 */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-4: 1rem;
/* 字体 */
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--radius-md: 0.5rem;
/* 阴影 */
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.1);
}
6.2 语义化 vs 原始 token
Token 建议分两层:原始 token(--color-500)与语义 token(--color-bg-primary)。组件只引用语义 token,主题切换时只需改语义层映射:
/* 亮色主题 */
:root {
--color-bg-primary: var(--color-white);
--color-text-primary: var(--color-gray-900);
}
/* 暗色主题:只覆盖语义映射 */
.dark {
--color-bg-primary: var(--color-gray-900);
--color-text-primary: var(--color-gray-100);
}
这样组件样式完全不感知主题,主题切换成为纯 token 层的替换。
七、暗色模式与主题系统
7.1 三种暗色模式实现
| 方案 | 原理 | 适用 |
|---|---|---|
| prefers-color-scheme | 跟随系统 | 无手动切换需求 |
| class 切换 | .dark 类覆盖语义 token | 需要用户手动切换 |
| 两者结合 | 默认跟随系统 + 手动覆盖 | 完整主题系统 |
Tailwind 的 darkMode: 'class' 让 .dark 类下的 utility 生效;React 中需要把"系统偏好 + 用户选择"组合成最终主题状态:
import { useEffect, useState } from 'react';
function useTheme() {
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
const saved = localStorage.getItem('theme');
if (saved === 'light' || saved === 'dark') return saved;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
useEffect(() => {
document.documentElement.classList.toggle('dark', theme === 'dark');
localStorage.setItem('theme', theme);
}, [theme]);
return [theme, setTheme] as const;
}
7.2 防闪烁(FOUC)处理
手动切换主题的常见 bug 是首帧闪烁:HTML 加载时 class 尚未应用,先显示亮色再变暗色。解决方法是把初始主题内联到 <head> 中,先于渲染执行:
<script>
(function () {
var saved = localStorage.getItem('theme');
var dark = saved === 'dark' ||
(!saved && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', dark);
})();
</script>
八、CSS 新特性:容器查询与子网格
8.1 容器查询(Container Queries)
传统媒体查询基于视口,而组件需要在不同容器宽度下自适应——这是长期以来用 JS 检测宽度 hack 的原因。CSS 容器查询让组件基于父容器响应:
/* 声明容器 */
.card-grid {
container-type: inline-size;
container-name: card-container;
}
/* 基于容器宽度的样式 */
@container card-container (min-width: 400px) {
.card {
display: grid;
grid-template-columns: 1fr 2fr;
}
}
<div class="card-grid">
<article class="card">
<img class="card__media" src="..." alt="..." />
<div class="card__body">…</div>
</article>
</div>
8.2 子网格(Subgrid)
subgrid 让嵌套网格继承父网格的行列轨道,解决网格嵌套时对齐困难的问题:
.page {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 1rem;
}
.card-row {
grid-column: 1 / -1;
display: grid;
grid-template-columns: subgrid; /* 继承 12 列 */
}
.card {
grid-column: span 4; /* 在子网格中按 12 列布局 */
}
8.3 现代 CSS 特性速查表
| 特性 | 作用 | 兼容性备注 |
|---|---|---|
@container | 容器查询 | 现代浏览器已支持 |
subgrid | 子网格继承 | 现代浏览器已支持 |
:has() | 父选择器 | 2023 年起全浏览器 |
color-mix() | 颜色混合 | 现代浏览器已支持 |
view-transition-api | 视图过渡 | Chrome/Edge 优先 |
scroll-driven 动画 | 滚动驱动 | 实验性阶段 |
这些原生特性正在消化"过去必须靠 JS 或框架 hack 才能实现"的能力。团队的增量策略:让新代码优先用原生特性,老 hack 逐步替换。
九、方案选型对比与决策
9.1 主流方案全景对比
| 维度 | BEM | CSS Modules | styled-components | vanilla-extract | Tailwind |
|---|---|---|---|---|---|
| 作用域 | 约定 | 机制 | 机制 | 机制 | 约定+配置 |
| 运行时 | 无 | 无 | 有 | 无 | 无 |
| 类型安全 | 无 | 无 | 弱 | 强 | 弱 |
| 动态主题 | 手动 | 手动 | 原生支持 | CSS 变量 | class 策略 |
| 学习成本 | 低 | 低 | 中 | 中 | 中高 |
| 生态成熟 | 老 | 成熟 | 成熟 | 快速增长 | 极活跃 |
| 适合团队 | 传统模板 | 通用组件库 | 快速迭代 | 高工程标准 | 产品迭代型 |
9.2 决策树:如何选
按团队特征与项目属性做选择:
| 项目特征 | 推荐方案 | 理由 |
|---|---|---|
| 需要严格主题化与动态样式 | styled-components / CSS-in-JS | 运行时主题能力最强 |
| 高工程标准、追求类型安全 | vanilla-extract | 零运行时 + TS 检查 |
| 快速产品迭代、强设计 token | Tailwind CSS | utility + token 一致性 |
| 通用组件库(对外发布) | CSS Modules | 作用域隔离 + 无运行时依赖 |
| 老项目、服务端模板 | BEM + 原生 CSS | 零构建成本接入 |
9.3 跨方案的团队规范
无论选哪个方案,跨团队统一的有三条:设计 token 必须用 CSS 变量沉淀;组件样式必须作用域隔离(机制或约定);禁止在业务代码中手写全局选择器。这三条独立于具体方案,是样式工程化的底线。同时建议把 CSS 的格式与排序交给 Prettier 等工具,避免格式争议消耗协作精力。
{
"prettier": {
"plugins": ["prettier-plugin-tailwindcss"]
}
}
结语
CSS 架构的演进脉络清晰可见:从"命名约定对抗冲突"到"编译机制隔离作用域",再到"utility-first 消解命名",最后回归"原生 CSS 新特性提供能力"——每一轮都在解决上一轮引入的复杂度。BEM 教会我们命名即约束,CSS Modules 教会我们机制优于约定,CSS-in-JS 教会我们样式与逻辑同构,Tailwind 教会我们一致性来自约束而非自由,而容器查询与子网格提醒我们:原生 CSS 始终在进化,别把框架 hack 当终局。
选型没有标准答案,但有稳定的决策框架:先看运行时约束(能否接受 CSS-in-JS 开销),再看类型安全需求(vanilla-extract),再看团队迭代模式(Tailwind 适配快速迭代)。把 token、作用域、规范这三件底层事做对,具体方案的选择就不再是关键风险。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。