Skip to content

CSS-in-JS

样式方案演进

全局 CSS → BEM 命名规范 → CSS Modules → CSS-in-JS → Utility-First (Tailwind) → Zero-Runtime CSS-in-JS
  |            |                |              |               |                        |
  原始时代    人肉约定          构建时作用域    JS 动态控制     原子化思路              编译时提取

每一代方案都在解决前一代的核心痛点:

痛点传统 CSSBEMCSS ModulesCSS-in-JS
全局污染靠命名约定✅ 构建时哈希✅ 运行时/编译时隔离
组件共置❌ 样式和组件分离⚠️ 需 import✅ 样式写在组件内
动态样式❌ 需手动拼 class✅ props 驱动
类型安全⚠️ 可生成类型✅ TypeScript 原生支持
运行时开销✅ 无✅ 无✅ 无⚠️ Runtime 方案有开销

Runtime CSS-in-JS

"Runtime" 指的是在浏览器运行时动态生成和注入 <style> 标签

组件渲染

JS 执行样式函数(读取 props/theme)

生成唯一 className + CSS 字符串

通过 CSSStyleSheet API 或 <style> 标签注入到 DOM

组件使用生成的 className

styled-components

最流行的 Runtime CSS-in-JS 方案,使用 Tagged Template Literal 语法。

tsx
import styled from 'styled-components';

const Button = styled.button<{ $primary?: boolean }>`
  padding: 8px 16px;
  border-radius: 4px;
  border: 2px solid #4a90d9;
  background: ${props => props.$primary ? '#4a90d9' : 'transparent'};
  color: ${props => props.$primary ? '#fff' : '#4a90d9'};
  font-size: 14px;
  cursor: pointer;
  transition: all 0.2s;

  &:hover {
    background: ${props => props.$primary ? '#357abd' : '#e8f0fe'};
  }
`;

// 使用
<Button $primary>确认</Button>
<Button>取消</Button>

核心原理

Tagged Template Literal

StyleSheet 管理器(维护已注入的样式缓存)

序列化:将模板字符串 + 插值函数求值 → 最终 CSS 字符串

哈希:MurmurHash → 生成唯一 className(如 .sc-aXeDK)

注入:通过 CSSStyleSheet.insertRule() 或 <style> 注入

返回带有 className 的 React 组件

Theme 系统

tsx
import { ThemeProvider } from 'styled-components';

const theme = {
  colors: {
    primary: '#4a90d9',
    danger: '#e74c3c',
  },
  spacing: (n: number) => `${n * 4}px`,
};

const Card = styled.div`
  padding: ${({ theme }) => theme.spacing(4)};
  border: 1px solid ${({ theme }) => theme.colors.primary};
`;

// 根组件注入
<ThemeProvider theme={theme}>
  <App />
</ThemeProvider>

样式继承与组合

tsx
const BaseButton = styled.button`
  padding: 8px 16px;
  border-radius: 4px;
  font-size: 14px;
`;

const PrimaryButton = styled(BaseButton)`
  background: #4a90d9;
  color: #fff;
`;

const DangerButton = styled(BaseButton)`
  background: #e74c3c;
  color: #fff;
`;

// as 多态:渲染为其他 HTML 元素或组件
<PrimaryButton as="a" href="/home">Go Home</PrimaryButton>

Emotion

API 设计与 styled-components 相似,但提供两种使用方式:

tsx
// 方式一:styled API(与 styled-components 几乎相同)
import styled from '@emotion/styled';

const Box = styled.div`
  padding: 16px;
  background: ${props => props.color || '#f5f5f5'};
`;

// 方式二:css prop(需要配置 JSX pragma 或 babel 插件)
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';

const dynamicStyle = (isPrimary: boolean) => css`
  padding: 8px 16px;
  background: ${isPrimary ? '#4a90d9' : '#eee'};
  color: ${isPrimary ? '#fff' : '#333'};
`;

function Button({ primary }: { primary?: boolean }) {
  return <button css={dynamicStyle(!!primary)}>Click</button>;
}

css prop 的优势:不需要额外创建 styled 组件,样式直接写在 JSX 上,适合一次性样式。

Runtime 方案的性能问题

用户交互 → 状态变化 → 组件重新渲染

                   样式函数重新求值

                   序列化 CSS 字符串

                   哈希计算 + 缓存检查

                   可能注入新 <style> 规则

                   浏览器重新计算样式(Recalculate Style)

核心开销

  1. 序列化开销 —— 每次渲染时将模板字符串 + 插值函数求值为 CSS 字符串
  2. 运行时注入 —— 动态创建/更新 <style> 标签触发样式重新计算
  3. SSR 水合 —— 服务端序列化的样式需要在客户端重新水合(Hydration),可能导致样式闪烁(FOUC)
  4. React 18 并发模式 —— useInsertionEffect 之前的方案在并发渲染下可能导致样式丢失

量化感知:在中等复杂度页面中,Runtime CSS-in-JS 的样式计算可占总渲染时间的 10-20%


Zero-Runtime CSS-in-JS

"Zero-Runtime" 指的是在构建时(编译时)将样式提取为静态 CSS 文件,运行时零 JS 开销。

开发时                     构建时                     运行时
JS 中写样式代码      →   编译器提取为 .css 文件    →   纯静态 CSS,无 JS 运行
  (开发体验好)           (Babel/SWC 插件)            (性能 = 传统 CSS)

Vanilla Extract

最成熟的 Zero-Runtime 方案。在 .css.ts 文件中用 TypeScript 写样式:

ts
// button.css.ts
import { style, styleVariants } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';

export const base = style({
  padding: '8px 16px',
  borderRadius: '4px',
  fontSize: '14px',
  cursor: 'pointer',
  transition: 'all 0.2s',
});

export const variants = styleVariants({
  primary: {
    background: '#4a90d9',
    color: '#fff',
    ':hover': { background: '#357abd' },
  },
  secondary: {
    background: 'transparent',
    border: '2px solid #4a90d9',
    color: '#4a90d9',
    ':hover': { background: '#e8f0fe' },
  },
});

// recipe:类似 CVA 的变体 API
export const button = recipe({
  base: {
    padding: '8px 16px',
    borderRadius: '4px',
  },
  variants: {
    size: {
      small: { fontSize: '12px' },
      medium: { fontSize: '14px' },
      large: { fontSize: '16px' },
    },
    intent: {
      primary: { background: '#4a90d9', color: '#fff' },
      danger: { background: '#e74c3c', color: '#fff' },
    },
  },
  defaultVariants: {
    size: 'medium',
    intent: 'primary',
  },
});
tsx
// Button.tsx
import { button, base, variants } from './button.css';

// recipe 用法
<button className={button({ size: 'large', intent: 'primary' })}>
  Submit
</button>

// style + styleVariants 用法
<button className={`${base} ${variants.primary}`}>
  Submit
</button>

构建产物

css
/* 生成的静态 CSS(className 被哈希化) */
.button_base__1a2b3c {
  padding: 8px 16px;
  border-radius: 4px;
  font-size: 14px;
  cursor: pointer;
  transition: all 0.2s;
}
.button_variants_primary__4d5e6f {
  background: #4a90d9;
  color: #fff;
}
.button_variants_primary__4d5e6f:hover {
  background: #357abd;
}

Panda CSS

结合了 Zero-Runtime 和 Utility-First 的思路:

tsx
import { css } from '../styled-system/css';

function Card() {
  return (
    <div className={css({
      padding: '16px',
      borderRadius: '8px',
      bg: 'gray.100',
      _hover: { bg: 'gray.200' },
      md: { padding: '24px' },
    })}>
      Content
    </div>
  );
}

构建时静态提取为原子化 CSS,运行时零开销。

StyleX (Meta)

Meta(Facebook)内部使用的 Zero-Runtime 方案,已开源:

tsx
import * as stylex from '@stylexjs/stylex';

const styles = stylex.create({
  base: {
    padding: 16,
    borderRadius: 8,
  },
  primary: {
    backgroundColor: '#4a90d9',
    color: '#fff',
  },
  large: {
    fontSize: 18,
    padding: 24,
  },
});

function Button({ primary, large }: { primary?: boolean; large?: boolean }) {
  return (
    <button {...stylex.props(styles.base, primary && styles.primary, large && styles.large)}>
      Click
    </button>
  );
}

StyleX 的独特设计

  • 原子化输出 —— 每个 CSS 属性生成一个独立的原子类,最大化跨组件复用
  • 确定性合并 —— 后传入的样式总是覆盖前面的("last applied wins"),解决 CSS 特异性问题
  • 编译时类型安全 —— Flow/TypeScript 全类型覆盖

Utility-First CSS(Tailwind CSS)

不属于传统 CSS-in-JS,但在组件化开发中解决了相同的问题。

传统 CSS-in-JS               Utility-First
--------------------          --------------------
写 CSS 属性 + 值               直接使用预定义的原子 class
需要 runtime / 编译器          构建时 tree-shake 未使用的 class
命名焦虑                       无需命名

核心概念

html
<!-- 传统方式:先想名字,再写样式 -->
<div class="card">
  <h2 class="card-title">Title</h2>
</div>

<!-- Tailwind:直接在标签上描述样式 -->
<div class="p-4 rounded-lg bg-white shadow-md hover:shadow-lg transition-shadow">
  <h2 class="text-xl font-bold text-gray-900">Title</h2>
</div>

设计系统约束

Tailwind 通过 Design Token 约束样式值,避免任意魔法数字:

js
// tailwind.config.js
module.exports = {
  theme: {
    spacing: {
      0: '0',
      1: '4px',
      2: '8px',
      3: '12px',
      4: '16px',
      // ...只能使用这些间距值
    },
    colors: {
      primary: {
        50: '#eff6ff',
        500: '#3b82f6',
        900: '#1e3a8a',
      },
    },
    extend: {
      borderRadius: {
        'xl': '12px',
      },
    },
  },
};

动态样式处理

Tailwind 是静态分析的,不支持真正的运行时动态样式:

tsx
// ❌ 不生效 —— Tailwind 无法静态分析模板字符串拼接的 class
function Box({ color }: { color: string }) {
  return <div className={`bg-${color}-500`}>Content</div>;
}

// ✅ 正确做法 —— 使用完整的 class 名
const colorMap: Record<string, string> = {
  red: 'bg-red-500',
  blue: 'bg-blue-500',
  green: 'bg-green-500',
};

function Box({ color }: { color: string }) {
  return <div className={colorMap[color]}>Content</div>;
}

// ✅ 更复杂的组合 —— 使用 clsx / tailwind-merge
import { twMerge } from 'tailwind-merge';
import clsx from 'clsx';

function Button({ primary, disabled, className }: ButtonProps) {
  return (
    <button className={twMerge(clsx(
      'px-4 py-2 rounded font-medium transition-colors',
      primary ? 'bg-blue-500 text-white hover:bg-blue-600' : 'bg-gray-200 text-gray-800',
      disabled && 'opacity-50 cursor-not-allowed',
      className,
    ))}>
      Click
    </button>
  );
}

Tailwind v4 重大变化

Tailwind v4(2025)从 PostCSS 插件转向基于 Rust 的独立引擎(Oxide):

Tailwind v3                     Tailwind v4
---                             ---
PostCSS 插件                    独立 Rust 引擎
tailwind.config.js 配置         CSS-first 配置(@theme 指令)
content 路径手动配置             自动内容检测
@tailwind base/components       @import "tailwindcss"
css
/* v4: CSS-first 配置 */
@import "tailwindcss";

@theme {
  --color-primary: #4a90d9;
  --spacing-18: 4.5rem;
  --font-display: "Inter", sans-serif;
}

方案横向对比

Runtime vs Zero-Runtime vs Utility-First

维度Runtime (styled-components)Zero-Runtime (Vanilla Extract)Utility-First (Tailwind)
样式写法Tagged Template / ObjectTypeScript Object(.css.tsHTML class
动态样式✅ 完全运行时动态⚠️ 需要 CSS 变量过渡⚠️ class 映射表
运行时开销❌ JS 解析 + 注入✅ 零开销✅ 零开销
Bundle 影响❌ 库体积 ~12KB gzipped✅ 无额外 JS✅ 无额外 JS
TypeScript✅ 泛型 props✅ 原生 TS⚠️ 通过 IDE 插件
SSR 兼容⚠️ 需要额外配置✅ 天然支持✅ 天然支持
学习曲线低(写 CSS)中(新语法)中(记忆 class 名)
调试体验⚠️ 生成的 class 不直观⚠️ 哈希 class⚠️ 长 class 列表
RSC 兼容❌ 需要 Client Component✅ 可用于 Server Component✅ 可用于 Server Component

选型决策树

你的项目需要大量运行时动态样式?
├─ 是 → 有性能敏感场景吗?
│       ├─ 是 → Vanilla Extract + CSS Variables
│       └─ 否 → styled-components / Emotion
└─ 否 → 使用 React Server Components?
         ├─ 是 → Tailwind CSS / Vanilla Extract / CSS Modules
         └─ 否 → 团队偏好?
                  ├─ 喜欢写 CSS → CSS Modules / styled-components
                  ├─ 喜欢 TS → Vanilla Extract
                  └─ 追求开发速度 → Tailwind CSS

各方案的真实使用者

方案典型使用者
styled-componentsAtlassian、Revolut、Framer
EmotionMUI (Material UI)、Storybook
Vanilla ExtractShopify Polaris、Seek
StyleXMeta (Facebook, Instagram)
Tailwind CSSVercel、Shopify Hydrogen、GitHub Primer
CSS ModulesNext.js 默认方案、Vite 内建支持

CSS Modules:被低估的方案

虽然不属于 CSS-in-JS,但 CSS Modules 是许多场景下最务实的选择。

css
/* Button.module.css */
.root {
  padding: 8px 16px;
  border-radius: 4px;
}

.primary {
  background: #4a90d9;
  color: #fff;
}

.primary:hover {
  background: #357abd;
}
tsx
// Button.tsx
import styles from './Button.module.css';
import clsx from 'clsx';

function Button({ primary, children }: ButtonProps) {
  return (
    <button className={clsx(styles.root, primary && styles.primary)}>
      {children}
    </button>
  );
}

优势

  • 零运行时开销,零 JS 体积
  • 构建时作用域隔离(Webpack/Vite 内建支持)
  • 学习成本为零(就是写 CSS)
  • 完美支持 SSR / RSC
  • 可配合 typed-css-modules 生成 .d.ts 获得类型提示

局限

  • 动态样式需要配合 CSS 变量或 class 切换
  • 样式和组件不在同一文件(非共置)
  • 没有 Theme 系统(需自行封装 CSS 变量)

动态样式的 Zero-Runtime 替代方案

Runtime CSS-in-JS 最大的优势是 props 驱动的动态样式。Zero-Runtime 方案如何实现?

策略一:CSS 变量桥接

tsx
// Vanilla Extract
import { style, createVar } from '@vanilla-extract/css';

const accentColor = createVar();
const padding = createVar();

export const box = style({
  vars: {
    [accentColor]: '#4a90d9',
    [padding]: '16px',
  },
  backgroundColor: accentColor,
  padding: padding,
});
tsx
// 组件中通过 style prop 动态修改 CSS 变量
import { box } from './box.css';
import { assignInlineVars } from '@vanilla-extract/dynamic';

function Box({ color, pad }: { color: string; pad: number }) {
  return (
    <div
      className={box}
      style={assignInlineVars({
        [accentColor]: color,
        [padding]: `${pad}px`,
      })}
    >
      Content
    </div>
  );
}

策略二:预定义变体 + data 属性

tsx
// 预定义有限的变体
const colorVariants = styleVariants({
  blue: { background: '#4a90d9' },
  red: { background: '#e74c3c' },
  green: { background: '#27ae60' },
});

// 或使用 data 属性
const box = style({
  selectors: {
    '&[data-variant="primary"]': { background: '#4a90d9' },
    '&[data-variant="danger"]': { background: '#e74c3c' },
  },
});

<div className={box} data-variant="primary">Content</div>

对比

                        Runtime CSS-in-JS        CSS 变量桥接
动态能力               任意 JS 表达式            CSS 变量值 + 预定义变体
运行时开销             序列化 + 注入              仅修改 style 属性
类型安全               泛型 props                 枚举变体
适合场景               复杂交互动画/主题          大部分 UI 组件

面试高频题

1. styled-components 的样式是怎么注入到页面中的?

通过 CSSStyleSheet API(insertRule)或动态创建 <style> 标签。组件渲染时,样式函数求值生成 CSS 字符串,经过哈希处理得到唯一 className,然后检查缓存——如果该哈希未被注入过,则调用 insertRule 将 CSS 规则插入到 <style> 标签中。在 SSR 场景下,通过 ServerStyleSheet 收集渲染过程中产生的所有样式,输出为 <style> 标签的 HTML 字符串,随页面一起返回。

2. 为什么 styled-components 在 SSR 中需要特殊处理?

因为 styled-components 的样式是运行时生成的——服务端渲染 HTML 时需要同步收集所有被使用到的样式。如果不做处理:①服务端返回的 HTML 没有对应样式 → 浏览器呈现无样式内容(FOUC);②客户端 Hydration 时重新注入样式 → 页面闪烁。解决方案是使用 ServerStyleSheet 包裹渲染过程,拦截所有 insertRule 调用,将样式收集后注入到 HTML 的 <head> 中。

3. Tailwind CSS 的 class 名是如何生效的?为什么动态拼接不生效?

Tailwind 在构建时通过静态分析源码文件中的 class 名,只生成被使用到的 CSS 规则(Tree-shaking)。它扫描的是完整的 class 字符串字面量(如 bg-red-500),而不是运行 JS 代码。因此 bg-${color}-500 这种动态拼接在构建时无法被识别,对应的 CSS 规则就不会被生成。正确做法是使用完整的 class 名映射表,确保所有可能的值都以字面量形式出现在源码中。

4. 什么是 Zero-Runtime CSS-in-JS?与 Runtime 方案的核心区别?

Zero-Runtime CSS-in-JS 在构建时(通过 Babel/SWC 插件)将样式代码提取为静态 CSS 文件,运行时不执行任何样式相关的 JS 代码。核心区别:①Runtime 方案在浏览器中执行序列化 + 注入,有 JS 开销;Zero-Runtime 在编译时完成,运行时等价于传统 CSS。②Runtime 支持任意 JS 表达式的动态样式;Zero-Runtime 需要通过 CSS 变量或预定义变体实现动态性。③Zero-Runtime 天然支持 RSC 和流式 SSR,而 Runtime 方案需要额外配置。代表方案:Vanilla Extract、StyleX、Panda CSS。

5. 如何在项目中选择合适的样式方案?

关键决策因素:

  • React Server Components(RSC) —— 如果使用 RSC,排除 Runtime CSS-in-JS(需要 "use client"
  • 动态样式需求 —— 大量运行时动态样式 → Runtime 方案;有限变体 → Zero-Runtime 或 Tailwind
  • 性能敏感度 —— 高 → 避免 Runtime;一般 → 均可
  • 团队偏好 —— 喜欢写 CSS → CSS Modules / styled-components;喜欢 TS → Vanilla Extract;追求速度 → Tailwind
  • 设计系统 —— 有严格设计规范 → Tailwind(Design Token 约束)或 Vanilla Extract(类型约束)
  • 已有技术栈 —— 使用 MUI → Emotion;使用 Next.js → CSS Modules / Tailwind

不存在"最好"的方案,只有最适合团队和项目约束的方案。


追问思考

  1. styled-components v6 引入了 shouldForwardProp 作为默认行为和 $ 前缀约定(Transient Props),这解决了什么问题?
  2. Tailwind CSS 的"原子化"思路和传统 OOCSS(Object-Oriented CSS)有什么联系和区别?
  3. 在微前端架构下,不同子应用使用不同的样式方案(如 A 用 styled-components,B 用 Tailwind),可能遇到哪些问题?如何解决?
  4. CSS 变量(--custom-property)在 Zero-Runtime 方案中扮演了什么角色?它的性能特性如何?
  5. 如果让你设计一个组件库,你会选择哪种样式方案?为什么?需要考虑哪些因素?

用心学习,用代码说话 💻