💅 CSS-in-JS(CSS Modules → styled-components → 零运行时)

一句话定性

当前端进入组件化时代,样式遇到一个根本性的存在主义问题:样式到底属于谁? 全局 CSS 说它属于整个页面,但组件说它属于”我”。CSS-in-JS 给出了一个激进的答案——让样式和组件同生共死,把样式写进 JS,锁进组件作用域。它一度是 React 生态的主流,却最终在性能和 Server Components 的双重夹击下,被迫向”零运行时”回摆。


一、它是什么 & 出现的时代

CSS-in-JS 指的是用 JavaScript 来定义和管理组件样式的一类方案,兴起于 2013-2018 SPA时代,与 React 组件化深度绑定。它不是单一技术,而是一条不断演化的路线:

阶段代表时间形态
局部作用域CSS Modules约 2015仍写 .css,编译期把类名 hash 成唯一值
运行时 CSS-in-JSstyled-components / Emotion2016 起用 JS 模板字符串写样式,运行时注入 <style>
零运行时vanilla-extract / Linaria2020 起用 JS/TS 写样式,编译期提取成静态 CSS

二、为什么会出现:组件化时代,样式必须和组件绑在一起

全局 CSS 与组件化的三重根本矛盾

方法论(BEM)靠人自觉、不可靠;开发者想要技术上强制的解决方案。组件化时代,样式必须满足三个全局 CSS 天生满足不了的诉求:

  1. 作用域隔离:一个 <Button> 组件的样式只能作用于它自己,绝不能泄漏出去污染别人,也绝不被外部污染。这正是全局作用域的反面。
  2. 动态样式 / 与 props 联动:<Button variant="danger" size="lg"> 的样式要根据 props 实时变化。这是运行时的需求——静态 CSS 和 预处理器(编译期死值)都做不到。
  3. 依赖关系内聚:删掉一个组件,它的样式应该一起消失,而不是变成永远没人敢删的”死 CSS”。

CSS-in-JS 的核心主张:既然 React 已经把 HTML(JSX)和逻辑(JS)放进了组件,那样式凭什么还要游离在外?把三者合一,组件才真正”自包含”。 这是 UI = f(state) 思想在样式领域的自然延伸。


三、核心机制 & 为什么流行:三个阶段

阶段一:CSS Modules —— 用编译期 hash 实现”真·局部作用域”

你照常写 Button.module.css,但构建工具(Webpack/Vite)会把里面的 .button 编译成全局唯一的 Button_button_x7f9。你在 JS 里 import styles 引用它。

它优雅地解决了"作用域"

这是 BEM 想做的事(局部作用域)被编译器自动实现的版本:你不用再手写长类名,机器替你保证不冲突。它没有运行时开销,至今仍是非常稳健的选择。但它解决不了”动态样式/props 联动”——它本质还是静态 CSS。

阶段二:运行时 CSS-in-JS(styled-components 2016 / Emotion)—— 样式即组件

styled-components(Max Stoiber 等,2016)走得更激进,用 JS 模板字符串直接生成带样式的组件:

const Button = styled.button`
  background: ${props => props.primary ? 'blue' : 'gray'};
`;

它为什么俘获了整个 React 生态

  • 动态样式信手拈来:样式能直接读 props、读 theme、读任何 JS 变量——这是它对 CSS Modules 的碾压性优势。
  • 真正的组件内聚:样式、结构、逻辑全在一个 .js 文件里,组件成了完整的”样式 + 行为”单元。
  • 主题化 + 死代码自动消除:ThemeProvider 让换肤优雅;组件没了样式自然没了。

在 2016–2020 年间,styled-components / Emotion 几乎是 React 项目的默认样式方案,代表着”组件化美学”的巅峰。

阶段三:零运行时(vanilla-extract / Linaria)—— 鱼与熊掌

后文会讲到运行时方案的代价。零运行时方案的机制是:让你依然用 JS/TS 的写法和类型安全写样式,但在编译期就把它们提取成静态 .css 文件,运行时不再有任何 JS 参与样式注入。Linaria、vanilla-extract 是代表。它试图保留 CSS-in-JS 的开发体验,去掉它的运行时成本


四、带来的新问题 / 副作用:运行时方案的”原罪”

运行时 CSS-in-JS 的代价,几年后集中爆发

styled-components/Emotion 把样式注入放到了浏览器运行时,这带来一系列结构性问题:

  1. 运行时性能开销:每次渲染,JS 都要解析模板字符串、生成类名、序列化 CSS、插入 <style> 标签。在大型应用里,这部分开销实实在在拖慢渲染(尤其频繁更新的组件)。
  2. SSR 复杂且脆弱:服务端渲染时要”收集本次渲染用到的所有样式”再随 HTML 一起吐出,否则会出现 FOUC(无样式闪烁) 或 hydration 不匹配。各库都要写专门的 SSR 适配代码,复杂且易错。详见 渲染模式演进史
  3. 额外的运行时库体积:样式引擎本身要打包进 bundle。
  4. 样式与逻辑过度耦合:有时只想调个颜色,却要动 JS 文件、触发组件重渲染。

压垮骆驼的最后一根稻草:React Server Components

RSC 与运行时 CSS-in-JS 根本不兼容

React Server Components(RSC)的核心是:组件在服务端运行,不向客户端发送 JS。但运行时 CSS-in-JS 的整个机制都依赖”在浏览器里执行 JS 来注入样式”——这两件事在原理上互斥。RSC 组件里根本无法运行 styled-components 那套运行时逻辑。

这是近年 CSS-in-JS 路线剧变的最关键推力:当 React 官方把 RSC 定为未来方向(渲染模式演进史),运行时 CSS-in-JS 等于被判了”与框架未来不兼容”。Emotion 团队、Next.js 团队都公开讨论过这个困境。


五、为什么会衰落 / 现状:向零运行时与原子化双向回摆

运行时方案退潮,样式方案分流

到 2023 年后,纯运行时 CSS-in-JS 明显退潮。社区分流向两个方向:

  • 回摆到零运行时:vanilla-extract / Linaria 保留”用 JS/TS 写、类型安全、组件内聚”的好处,但编译期提取静态 CSS,既兼容 RSC、又无运行时开销。这是对运行时方案的”修正主义”。
  • 彻底转向原子化:很多团队干脆放弃 CSS-in-JS,转向 Tailwind——既有作用域隔离的效果(utility 不会冲突),又是编译期生成、零运行时、天然兼容 RSC。

这条路线的深层启示

CSS-in-JS 的兴衰,是一个”把工作放在哪个时间点做”的经典权衡:运行时方案为了”动态灵活”,把代价放到了用户的浏览器里;当性能和 SSR/RSC 成为硬约束,行业意识到能在编译期做的,绝不要拖到运行时。这和 Svelte”编译时干掉虚拟 DOM”、Vite 的预编译思路,是同一个时代精神:把成本前移到构建期,把运行时还给用户。

组件化:样式应属于组件,不应是全局的
        │
        ▼
CSS Modules ──► 编译期 hash 实现局部作用域(解决"冲突")
        │       └──► 但仍是静态 CSS,做不到 props 动态样式
        ▼
styled-components / Emotion(2016)──► 运行时注入, 动态样式 + props 联动
        │       └──► 副作用:运行时开销 + SSR 复杂 + bundle 膨胀
        │
        ├──► React Server Components 出现 ──► 运行时方案与 RSC 根本不兼容(致命一击)
        │
        ▼
        分流(2020+):
        ├─ 零运行时:vanilla-extract / Linaria(编译期提取, 兼容 RSC)
        └─ 转向原子化:Tailwind(编译期生成, 零运行时)→ [[原子化CSS]]

🔗 同组:CSS演进史 | 布局演进史 | CSS方法论 | CSS预处理器 | 原子化CSS 🔗 相关:React | 渲染模式演进史 | Svelte | Vite | Webpack | 2013-2018 SPA时代 | 2018-2023 工程化时代 | UI组件库演进史