CSS-in-JS 我最早只觉得写起来顺手,后来看 SSR 和运行时开销,才明白取舍在哪。
Q1: CSS-in-JS 的历史?为什么要有它?
1
传统 CSS 的作用域失控 (2010 年代初)
在组件化开发兴起之后,传统的全局 CSS 样式表成了一个巨大的工程隐患。当数十个开发者在同一个项目中各自编写
.button、.container、.title 等类名时,后写的样式会覆盖先写的——这就是臭名昭著的”CSS 全局作用域污染”问题。BEM 命名规范(.block__element--modifier)试图通过命名约定来缓解,但约定不是约束,总有开发者不遵守。大型团队的 CSS 代码库最终会退化成一团互相覆盖、不敢删除的意大利面条。2
CSS-in-JS 的诞生 (2014-2016)
2014 年,Christopher Chedeau 在 Nation.js 大会上发表了著名的演讲《CSS-in-JS》,指出传统 CSS 的 7 大工程问题:全局命名空间、依赖关系、死代码消除、压缩、常量共享、非确定性解析、隔离。随后,styled-components(2016)和 Emotion(2016)相继诞生。它们的核心理念是:既然 JavaScript 模块系统已经解决了作用域隔离问题(
import/export),那把样式也变成 JavaScript 模块,就能借用模块系统天然解决 CSS 的所有工程问题。3
运行时方案的性能反思 (2019-2021)
随着 CSS-in-JS 的大规模采用,性能问题逐渐暴露。运行时方案(styled-components、Emotion)需要在用户浏览器中执行 JavaScript 来生成 CSS 字符串、注入到
<style> 标签中。这意味着:首屏渲染必须等待 JS 执行完成、每次组件重新渲染都可能触发样式重新计算、SSR 时需要额外的序列化和注水步骤。在性能敏感的大型应用中,这些开销变得不可接受。4
零运行时方案的兴起 (2021 至今)
社区开始转向”零运行时”或”编译时”的 CSS-in-JS 方案。vanilla-extract、Panda CSS、StyleX 等方案在编译期将 CSS-in-JS 代码转化为静态 CSS 文件,运行时完全没有 JavaScript 开销。社区实践也从运行时方案大规模转向零运行时方案,新项目更倾向 CSS Modules 或编译时方案。CSS-in-JS 的演进方向从”运行时灵活”转向”编译时安全”。
CSS-in-JS 把样式变成了 JavaScript 模块,借用模块系统解决了全局作用域污染,但运行时方案的性能代价催生了零运行时方案的崛起。
🚫 传统 CSS 全局作用域
数十个开发者各自编写
.button、.container 等全局类名,后写的覆盖先写的。BEM 命名规范试图缓解,但约定不是约束。修改一行样式需要”全局影响评估”——你永远不知道改了 .title 会破坏哪个页面。✅ CSS-in-JS 组件级隔离
const Title = styled.h1\color: red;`自动生成唯一类名(如.sc-bdVTJa`),样式与组件一一绑定。删除组件时样式自动消除,无死代码。TypeScript 类型检查覆盖样式属性,重构有保障。Q2: styled-components / Emotion / CSS Modules 对比
三者的本质差异在于样式注入时机和运行时开销:基础梳理
专家视角深度剖析
很多人只能背下上面的表格,但实际更在意的是:为什么 Meta(Facebook)自己不用 styled-components,而是造了 StyleX? 答案在于原子化 CSS 的性能模型。styled-components 为每个组件生成一个完整的样式块(如.sc-bdVTJa { color: red; font-size: 16px; padding: 8px; }),当页面有 1000 个组件时,就有 1000 个样式块被注入到 DOM 中。而 StyleX 的思路完全不同——它在编译期将样式拆分为原子化 CSS 类(如 .color_red、.font-size_16、.padding_8),然后通过组合这些原子类来实现样式。由于原子类是可以复用的,1000 个组件可能只需要 200 个原子类,样式表体积大幅缩小。这就是 StyleX 在 Meta 的大规模应用中碾压 styled-components 的核心原因。
Q3: CSS-in-JS 的原理(运行时 vs 编译时)
运行时方案(styled-components / Emotion)
编译时方案(vanilla-extract / StyleX / Panda CSS)
Q4: CSS-in-JS 的性能问题(运行时开销、SSR)
运行时开销的根本原因: styled-components 和 Emotion 在每次组件渲染时都需要执行 JS 来计算 CSS 字符串、哈希、注入。当页面有大量组件且频繁重渲染时,这些开销会显著影响性能。 SSR 的额外代价: 服务端渲染时,CSS-in-JS 需要在 Node.js 中执行样式计算、收集生成的 CSS、序列化为字符串嵌入 HTML。客户端注水时,还需要将服务端生成的 CSS 与客户端的样式计算结果进行比对,确保一致性。 关键区别:
❌/✅ 代码对比(展示性能差异):
🔍 底层源码:styled-components 是如何工作的?
styled-components 的样式注入机制在浏览器中是怎么实现的?
styled-components 内部使用 StyleSheet 管理器来集中管理所有的 <style> 标签注入。它维护一个全局的样式缓存表(cache),以哈希后的类名为 key,CSS 字符串为 value。当一个新样式首次出现时,它会创建一个 <style> 标签(或使用 insertRule API 插入到已有的 <style> 标签中),并将 CSS 规则写入(详见 styled-components 源码 packages/styled-components/src/sheet):
<style> 标签)、高效注入(使用 insertRule API 而非 innerHTML,避免触发 DOM 解析和重排)。
Emotion 的注入机制与 styled-components 有何不同?
Emotion 使用类似的缓存和注入机制,但它额外支持了 @keyframes 和 @font-face 的序列化,并且在 SSR 场景中提供了更灵活的流式注入 API(createCache + renderToStream)。详见 Emotion 源码 packages/cache/src。
十、及格回答 vs 满分回答
问题:“你在项目中是怎么做样式方案选型的?styled-components 和 CSS Modules 怎么选?”
及格回答(60-70分):“我们用的 styled-components,因为它支持动态样式,写起来方便。CSS Modules 需要手动拼接类名,不太方便。”问题出在:只考虑了开发体验,忽略了性能和架构影响。 满分回答(90-100分):
样式方案选型的核心矛盾是开发灵活性 vs 运行时性能。styled-components 的优势在于动态样式——可以根据 props 实时计算 CSS 值,开发体验极佳。但它的代价是在运行时执行 JS 来生成 CSS 字符串、哈希、注入 <style> 标签。当页面有大量组件且频繁重渲染时,这些开销会显著拖慢渲染性能。[1]
CSS Modules 则完全相反——样式在构建时就被处理为静态 CSS 文件,运行时零开销。它的局限在于不支持基于 props 的动态样式,需要配合 CSS 变量或 clsx 条件类名来实现。但对于大多数场景,这种组合已经足够。[2]
在我负责的 C 端项目中,我们最终选择了 CSS Modules + CSS 变量 的混合方案。核心逻辑是:静态样式用 CSS Modules(零运行时开销),动态主题用原生 CSS 变量(运行时改一个变量值即可),极少量需要基于 props 的动态样式用 clsx 条件类名。这种方案的运行时开销几乎为零,SSR 友好,同时通过 TypeScript + typed-css-modules 保证了类型安全。如果团队偏好 CSS-in-JS 的开发体验,我会推荐 vanilla-extract 或 StyleX 这类零运行时方案。
常见追问
styled-components 的 className 是怎么生成的?为什么不会冲突?
styled-components 的 className 是怎么生成的?为什么不会冲突?
答题要点:
解释哈希算法和唯一性保证机制。高分回答:
“styled-components 使用一种确定性哈希算法(基于 murmurhash2),对 CSS 字符串内容进行哈希计算,生成一个 6-8 位的字母数字字符串(如
.sc-bdVTJa)。这个哈希值有两个关键特性:(1) 确定性——相同的 CSS 字符串总是产生相同的哈希值,所以服务端和客户端渲染出的类名完全一致(这对 SSR 至关重要);(2) 唯一性——不同的 CSS 字符串几乎不可能产生相同的哈希值(哈希碰撞概率极低)。此外,styled-components 还会加上一个组件名前缀(如 StyledButton),让开发者工具中的类名可读性更好。”CSS-in-JS 方案如何实现主题系统?ThemeProvider 的原理是什么?
CSS-in-JS 方案如何实现主题系统?ThemeProvider 的原理是什么?
答题要点:
解释 React Context 注入机制。高分回答:
“styled-components 的 主题切换时,
ThemeProvider 基于 React Context 实现。ThemeProvider 接收一个 theme 对象,将其放入 React Context 中。当组件树中的 styled 组件被渲染时,它通过 useContext 读取当前的 theme 对象,然后在模板字符串的插值函数中使用:ThemeProvider 更新 Context 值 → 所有消费了 theme 的组件重新渲染 → styled-components 重新计算 CSS 字符串 → 注入新的样式。这个过程的开销与使用了 theme 的组件数量成正比。优化技巧:将不依赖 theme 的静态样式提取到组件外部,避免主题切换时不必要的重新计算。”CSS-in-JS 在 SSR 中是怎么工作的?有什么坑?
CSS-in-JS 在 SSR 中是怎么工作的?有什么坑?
答题要点:
解释服务端样式收集和客户端注水的流程。高分回答:
“SSR 的流程是:(1) 服务端渲染组件树时,styled-components 同时在内存中计算并收集所有生成的 CSS 规则;(2) 将收集到的 CSS 序列化为字符串,注入到 HTML 的
<style> 标签中;(3) 客户端收到 HTML 后,React 注水(Hydration)时,styled-components 会比对服务端注入的样式与客户端计算的样式是否一致。
常见坑:(1) 服务端和客户端的随机数不一致导致类名不匹配(styled-components 通过确定性哈希避免了这个问题);(2) useLayoutEffect 在服务端不执行,可能导致闪烁(styled-components 通过 <style> 标签预注入避免);(3) 流式渲染(React 18 Streaming SSR)中,样式收集的时机变得复杂——组件可能在不同的时间点渲染,需要分批收集和注入样式。”styled-components 和 Emotion 的核心区别是什么?
styled-components 和 Emotion 的核心区别是什么?
答题要点:
从 API 设计、性能特征、SSR 能力三个维度对比。高分回答:
“两者的核心区别在于 API 设计哲学和底层实现细节:
1. API 设计:styled-components 以 tagged template literals 为主(
styled.div 加模板字符串),Emotion 额外支持 css prop,可以直接在 JSX 元素上传入样式,灵活性更高。
2. 序列化方式:Emotion 使用自己的序列化引擎,样式注入路径更短;两者同属运行时方案,开销在同一量级,差异不足以成为选型依据。
3. 包体积:两者都会向产物中引入运行时库,具体大小随版本变化,不作为选型的主要依据。
4. SSR:两者都支持 SSR,但 Emotion 的流式注入 API 更灵活(createCache+renderToStream)。
5. 生态:styled-components 的社区生态更大(更多的第三方库兼容),但 Emotion 在 React 社区的影响力也很大(MUI 默认使用 Emotion 作为样式引擎)。”CSS Modules 的实现原理是什么?构建工具是怎么处理的?
CSS Modules 的实现原理是什么?构建工具是怎么处理的?
答题要点:
解释类名哈希映射和构建时转换过程。高分回答:
“CSS Modules 的核心原理是构建时的类名哈希映射。当 Webpack 的 在 JS 代码中,你通过
css-loader 处理一个 .module.css 文件时,它会:(1) 解析 CSS 文件中的所有类名;(2) 对每个类名进行哈希处理,生成唯一标识(如 .button → .Button_button_x7ks2);(3) 输出一个 JS 对象,将原始类名映射到哈希后的类名。styles.button 访问哈希后的类名,而原始的 .button 类名在输出的 CSS 中被替换为 .Button_button_x7ks2。这种机制确保了每个组件的类名都是唯一的,不会与其他组件冲突。”什么是原子化 CSS?为什么 Meta 推出了 StyleX?
什么是原子化 CSS?为什么 Meta 推出了 StyleX?
答题要点:
解释原子化 CSS 的性能模型和 StyleX 的设计动机。高分回答:
“原子化 CSS 的核心理念是:每个 CSS 类只包含一个属性(如
.color_red { color: red }、.padding_8 { padding: 8px })。通过组合这些原子类来实现样式。优势在于:(1) CSS 文件体积极小——因为原子类是可复用的,1000 个组件可能只需要 200 个原子类;(2) 样式冲突为零——每个类只做一件事;(3) 缓存友好——原子类不会因组件修改而失效。
Meta 推出 StyleX 的原因:在 Facebook 的规模下(数十亿用户、数千个组件),styled-components 的运行时开销和 CSS 体积膨胀变得不可接受。StyleX 在编译期将样式拆分为原子类,运行时只做 className 拼接,性能接近零开销。同时,StyleX 的类型系统可以在编译期捕获无效的样式属性,提供比 styled-components 更强的类型安全保证。”CSS-in-JS 和 Tailwind CSS 怎么选?各自的优劣是什么?
CSS-in-JS 和 Tailwind CSS 怎么选?各自的优劣是什么?
答题要点:
从开发体验、性能、可维护性三个维度对比。高分回答:
“CSS-in-JS 和 Tailwind CSS 代表了两种完全不同的样式哲学:
CSS-in-JS:样式与组件逻辑共存,支持完整的 JavaScript 编程能力(条件、循环、函数),适合复杂组件库的开发。缺点是运行时开销(styled-components)或学习成本(vanilla-extract 的
.css.ts 写法)。
Tailwind CSS:通过原子化的 utility class 在 HTML 中直接写样式,零运行时开销,JIT 模式下 CSS 体积极小。缺点是 HTML 可读性差(class='flex items-center justify-between p-4 bg-white rounded-lg shadow-md'),不适合需要复杂动态样式的场景。
选型建议:C 端产品(营销页、管理后台)用 Tailwind CSS(开发速度快、样式一致性好);组件库用 CSS-in-JS(需要封装和类型安全);大型团队用 StyleX(兼具原子化 CSS 的性能和 CSS-in-JS 的类型安全)。”如何从 styled-components 迁移到零运行时方案?迁移策略是什么?
如何从 styled-components 迁移到零运行时方案?迁移策略是什么?
答题要点:
给出渐进式迁移的实际策略。高分回答:
“迁移策略应该是渐进式的,而不是一次性重写:
第一步:隔离动态样式。梳理所有使用了 props 动态计算的 styled 组件,将它们标记为’需要保留运行时’。将不依赖 props 的静态 styled 组件标记为’可迁移’。
第二步:并行运行。在项目中同时配置 styled-components 和目标零运行时方案(如 vanilla-extract)。新组件强制使用零运行时方案,旧组件保持不动。
第三步:逐步替换。按照页面或模块的优先级,将旧的 styled 组件逐步替换为零运行时方案。对于需要动态样式的组件,使用 CSS 变量 +
clsx 条件类名来替代。
第四步:清理。当所有 styled 组件都被替换后,移除 styled-components 的依赖。
注意事项:迁移过程中,确保主题系统的一致性——可以在零运行时方案中继续使用 CSS 变量做主题,而不是依赖 ThemeProvider。”📚 延伸阅读与引用参考
- 🔗 styled-components 官方文档:styled-components 的完整 API 参考和最佳实践。[1]
- 📖 Christopher Chedeau - CSS-in-JS 演讲:CSS-in-JS 理念的开创性演讲,提出了传统 CSS 的 7 大工程问题。[2]
- 🟢 StyleX 官方文档:Meta 推出的编译时 CSS-in-JS 方案,详解原子化 CSS 的设计哲学。[3]
