CSS-in-JS

解释运行时与构建时 CSS-in-JS 的工作模型,以及如何处理变体、动态值、层叠和服务端渲染。

难度 进阶 时长 标准深度约 13分钟
版本 React 19
what

CSS-in-JS 让 JavaScript 或 TypeScript 描述样式,再在应用运行时或构建期间把这些描述转换成 CSS 规则或类名。

trap

生成的类名可以限制选择器作用范围,却不会让层叠失效,也不会消除浏览器渲染成本或自动保证服务端与客户端输出一致。

fix

明确选择执行边界,让有限变体保持静态,把频繁变化的值放进 CSS 自定义属性,并检查最终生成的 CSS。

是什么,为什么存在

CSS-in-JS 是一类用应用代码声明样式,再由库或编译器生成 CSS 的技术。编写形式可以是标签模板、JavaScript 对象、接收组件属性的函数或带类型的配方。浏览器最终接收的仍是 CSS 声明、选择器和值;JavaScript 负责样式的编写和交付,并没有取代 CSS。

这种方式让组件明确依赖自身样式。生成的类名减少了意外的选择器重名,普通模块导入则更容易在删除组件时一并清理只供它使用的样式。JavaScript 边界还能把组件状态、主题令牌和变体提供给样式层。

CSS-in-JS 最常见于组件库和 React 应用,但这个名称涵盖不同的执行模型。运行时库在组件渲染时创建或选择规则;构建时系统在部署前分析源文件并生成 CSS 资源。有些工具采用混合方式,为构建时无法确定的值保留一小段运行时代码。

这个区别比模板语法更重要。它决定哪些表达式可用、多少样式代码会进入客户端、规则何时可用,以及服务端渲染必须收集什么。应把 CSS-in-JS 看作一组设计选择,而不是只有一种 API 和一种性能特征的单一方案。

如果组件所有权、类型化令牌或可编程变体 API 能减少实际协作成本,就可以选用它。如果现有层叠已经能清楚表达设计,普通样式表、CSS Modules、Sass 和工具类也都合理。样式与组件放在一起很有用,但仅凭这一点不足以引入运行时依赖。

工作原理

每种实现都要把编写时的样式描述连接到浏览器可见的规则。主要区别是这一步发生在渲染期间还是编译期间。

运行时生成

运行时引擎会在组件创建或渲染时读取样式声明。它把声明序列化,生成标识符,查询缓存,并在需要时向样式表插入规则。组件则得到对应的类名。不同库执行每一步的时机并不相同,因此应该检查库的实际输出,不能假定每次渲染都会创建新的 <style> 元素。

样式注入(style injection) 是把生成规则加入文档或服务端样式收集器的过程。缓存很重要,因为相同的规范化样式应该复用同一条规则。根据属性插值时可能产生更多规则组合,输入值范围很大时尤其如此。

运行时生成可以计算任意应用状态。这种灵活性适合确实依赖实时 JavaScript 的值,但也会让样式能否及时出现取决于组件生命周期。库的服务端渲染集成必须收集客户端预期的同一组规则。

构建时提取

静态提取(static extraction) 会在构建期间计算工具支持的声明,并写出普通 CSS。应用代码导入生成的类名,或调用编译器能够理解全部可能结果的 API。浏览器下载生成的样式表,这些声明不再需要客户端规则生成器。

提取要求源码可分析。字面量对象、令牌和有限变体映射都很直接。来自请求、浏览器测量或任意运行时函数的值,无法在构建期间变成一条固定规则。工具通常用 CSS 自定义属性、行内样式、预声明变体或明确记录的运行时层来跨过这条边界。

「零运行时」描述的是样式系统生成规则的路径,并不是说整个应用没有运行时代码。提取出的 CSS 仍有传输、解析、层叠、样式计算、布局和绘制成本。更准确的说法是:静态声明不需要该库在浏览器中生成规则。

最终结果仍由层叠决定

两条路径最终都进入 CSS 层叠。唯一类名可以减少重名,但声明来源、层、重要性、优先级、作用域邻近性和源码顺序等普通规则仍会决定谁胜出。全局选择器可以影响带生成类名的元素,生成的选择器也可以覆盖其他样式。

当选择器优先级相同时,插入顺序就会成为行为的一部分。如果集成没有定义顺序,代码分割、流式传输、多个缓存或混用样式系统都可能改变它。使用层叠层和明确记录的样式插入点,比不断提高优先级直到测试通过更可靠。

离散状态与连续值

tone="danger"size="small"disabled 等有限状态适合使用预声明类或数据属性选择器。它们的集合有明确边界,因此编译器可以生成所有规则,运行时引擎也只需复用一小组缓存。这样还可以通过类型和测试发现不受支持的状态。

拖动坐标、颜色选择器结果或进度百分比等值拥有很大甚至无界的取值范围。每个值都生成类名会让规则集不断增长,也会让样式引擎重复工作。应让结构规则保持稳定,再通过经过验证的 CSS 自定义属性或行内样式传入变化值。

示例

下面用不依赖第三方包的小程序展示内部机制。它们是教学模型,不是应该复制到生产环境的 API;成熟的库还要处理嵌套选择器、条件规则、转义、前缀、并发和服务端集成。

对运行时规则做哈希和缓存

第一个模型会规范化对象,对得到的声明字符串计算哈希,再按类名缓存规则。属性排序让对象的插入顺序不影响结果。第二次调用得到相同类名,也不会新增缓存项。

runtime-styles.js
const insertedRules = new Map();

function toKebabCase(name) {
  return name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
}

function serialize(style) {
  return Object.entries(style)
    .sort(([left], [right]) => left.localeCompare(right))
    .map(([name, value]) => `${toKebabCase(name)}:${value}`)
    .join(";");
}

function hash(text) {
  let value = 2166136261;
  for (const character of text) {
    value ^= character.charCodeAt(0);
    value = Math.imul(value, 16777619);
  }
  return (value >>> 0).toString(36);
}

function classFor(style) {
  const declarations = serialize(style);
  const className = `cw-${hash(declarations)}`;
  const inserted = !insertedRules.has(className);
  insertedRules.set(className, `.${className}{${declarations}}`);
  return { className, inserted, cssText: insertedRules.get(className) };
}

const buttonStyle = {
  backgroundColor: "#2563eb",
  color: "white",
  padding: "0.5rem 0.75rem",
};

const first = classFor(buttonStyle);
const second = classFor(buttonStyle);
console.log(first.className);
console.log(first.cssText);
console.log(`inserted again: ${second.inserted}`);
cw-ix791b
.cw-ix791b{background-color:#2563eb;color:white;padding:0.5rem 0.75rem}
inserted again: false

生产级引擎需要处理哈希冲突,还要有完整的 CSS 序列化器;这个小程序两者都没有。这里需要观察的是数据流:等价声明需要稳定标识和共享缓存。如果服务端与客户端的规范化方式不同,即使预期 CSS 相同,类名也可能不一致。

选择有限变体

构建时配方可以生成一组有明确边界的类,运行时代码只负责选择。这个纯 JavaScript 模型会验证两个变体维度,而不是在生成代码虚构变体名称时悄悄回退。

variant-classes.js
const buttonRecipe = {
  base: "button",
  tone: {
    primary: "button_tone_primary",
    quiet: "button_tone_quiet",
  },
  size: {
    small: "button_size_small",
    medium: "button_size_medium",
  },
};

function buttonClass({ tone = "primary", size = "medium" } = {}) {
  const toneClass = buttonRecipe.tone[tone];
  const sizeClass = buttonRecipe.size[size];
  if (!toneClass || !sizeClass) throw new RangeError("unknown button variant");
  return [buttonRecipe.base, toneClass, sizeClass].join(" ");
}

console.log(buttonClass());
console.log(buttonClass({ tone: "quiet", size: "small" }));

try {
  buttonClass({ tone: "warning" });
} catch (error) {
  console.log(`${error.name}: ${error.message}`);
}
button button_tone_primary button_size_medium
button button_tone_quiet button_size_small
RangeError: unknown button variant

实际配方工具通常会一起生成这些类名和 TypeScript 类型。这里的边界仍然很清楚:CSS 覆盖有限状态空间,运行时代码只选择有效组合。设计系统组件应该记录默认值和无效组合,而不是接受任意字符串。

传入连续值

进度可能变化成许多值,因此每次更新不应该创建新类。一条稳定规则读取 --progress,应用代码负责限制输入范围,并且只设置这个自定义属性。同一个数值还用于无障碍状态。

dynamic-css-variables.js
function progressProps(input) {
  const value = Math.min(100, Math.max(0, Number(input)));
  if (!Number.isFinite(value)) throw new TypeError("progress must be numeric");

  return {
    className: "progress",
    style: { "--progress": `${value}%` },
    "aria-valuenow": value,
  };
}

const rule = ".progress { inline-size: var(--progress); }";
console.log(rule);

for (const input of [-15, 45, 120]) {
  console.log(JSON.stringify(progressProps(input)));
}
.progress { inline-size: var(--progress); }
{"className":"progress","style":{"--progress":"0%"},"aria-valuenow":0}
{"className":"progress","style":{"--progress":"45%"},"aria-valuenow":45}
{"className":"progress","style":{"--progress":"100%"},"aria-valuenow":100}

TypeScript 可以描述自定义属性键,却不会限制运行时数值,也不会自动补上单位。验证应放在不可信或弱类型数据进入组件的位置。如果属性可能缺失,CSS 还应该提供回退值。

收集确定性的服务端样式

服务端渲染需要同时得到 HTML 和本次渲染用到的规则。这个模型把有限变体规则放进本次请求专用的注册表,按类名去重,并在序列化前排序。实际集成可能规定另一种顺序,尤其是顺序会影响层叠时。

style-registry.js
function createStyleRegistry() {
  const rules = new Map();

  return {
    use(className, cssText) {
      rules.set(className, `.${className}{${cssText}}`);
      return className;
    },
    flush() {
      return [...rules.entries()]
        .sort(([left], [right]) => left.localeCompare(right))
        .map(([, rule]) => rule)
        .join("\n");
    },
  };
}

function renderCard(registry, tone, label) {
  const variants = {
    neutral: ["card-neutral", "background:#f3f4f6;color:#111827"],
    urgent: ["card-urgent", "background:#fee2e2;color:#991b1b"],
  };
  const selected = variants[tone];
  if (!selected) throw new RangeError("unknown card tone");
  const className = registry.use(...selected);
  return `<article class="${className}">${label}</article>`;
}

const registry = createStyleRegistry();
const html = [
  renderCard(registry, "urgent", "Retry payment"),
  renderCard(registry, "neutral", "Receipt ready"),
].join("");

console.log(html);
console.log(registry.flush());
<article class="card-urgent">Retry payment</article><article class="card-neutral">Receipt ready</article>
.card-neutral{background:#f3f4f6;color:#111827}
.card-urgent{background:#fee2e2;color:#991b1b}

注册表按每次渲染创建,因此并发请求不会相互泄漏规则。这个模型的输出顺序是确定的,同一类名重复使用时只会覆盖同一个映射项。框架集成还要处理流式传输、nonce、缓存接管和清理规则,这个示例有意省略了它们。

陷阱

在渲染期间声明样式组件

React 可能重新挂载子树,导致焦点、选择范围、非受控输入状态和组件局部状态丢失。样式库也可能重复执行注册工作。它首先是正确性问题,然后才是微小的性能优化问题。

**修复:**在模块作用域声明标识稳定的样式组件。如果样式依赖属性,就把属性传给模块级声明,或选择预声明变体。用焦点保留测试验证组件标识,不能依赖视觉快照。

为每个实时值生成类名

具体缓存行为取决于所用库,因此不存在通用的变慢阈值。问题在于语义不匹配:值是取值范围很大的实例数据,类名表示的却应该是可复用样式类别。

**修复:**有限状态和变体使用有限类名。高基数值应放进经过验证的 CSS 自定义属性或行内样式;如果更新路径很重要,再录制浏览器性能轨迹。

把样式专用属性传给 DOM

这可能产生无效标记、React 警告、误导性的 DOM 快照或意外的字符串属性。不同库会使用临时属性约定或属性过滤回调,具体做法不能互换。

**修复:**采用当前库文档规定的过滤机制,并检查渲染后的 DOM。只有当值确实具有 DOM 语义时才使用 data-* 或 ARIA 属性;不要为了压掉警告而把每个泄漏属性都改名成 data-*

误以为生成类名能绕过层叠

不断添加 !important 或嵌套选择器,直到生成规则胜出,会让样式所有权更难理解,也可能破坏使用方需要的受支持覆盖点。

**修复:**定义组件的覆盖契约,把各样式系统放进有意设计的层叠层或插入点,规则冲突时检查计算样式。应用外壳与第三方内容要一起测试,因为所谓隔离通常会在边界处失效。

把服务端集成当成可直接粘贴的细节

随机值、依赖区域设置的序列化、只在浏览器执行的分支、共享的全局注册表,以及顺序错误的流式刷新,都可能让输出不确定。不同库和版本的支持也不同,包括对 React 服务端组件的支持。

**修复:**针对所选框架和渲染模式使用当前版本库的集成方案。比较多次渲染的服务端 HTML 与样式输出,把水合警告当作失败处理,并在延迟 JavaScript 时确认首屏样式仍然存在。

让提取器看不到运行时表达式

生成的代码可能看似合理,因为样式调用可以通过类型检查,生产 CSS 却没有对应规则。如果开发插件或监视器扫描了更多文件,开发环境的表现还可能不同。

**修复:**把可提取声明写在工具文档支持的语法和文件范围内。执行生产构建,在生成的 CSS 中查找每个重要变体,并测试一个非默认值。真正的运行时数据应通过工具支持的变量或行内样式出口传入。

AI 时代

在改动整个样式系统之前,可以让智能体先对一个有状态的服务端渲染组件进行迁移实验。它按照锁文件中的版本迁移组件,让连续值通过 CSS 自定义属性传递,再把生产结果与现有渲染契约比较:首次响应包含 CSS、重复渲染产生稳定类标识、水合无异常、样式专用属性没有泄漏到 DOM,并且计算样式和受支持的覆盖方式保持一致。如果实验保留了这份契约,就可以把同一变换扩展到类似组件;若没有,生成的 HTML 和 CSS 会为修改或放弃迁移提供具体依据。

深入 选择执行边界

选择执行边界

先列出组件必须表达的值。如果每种状态都属于一小组已知变体,静态 CSS 加类名或数据属性选择通常已经足够。如果某个值只能在运行时得到,先检查 CSS 是否已有合适的输入渠道,例如自定义属性、媒体查询、容器查询、继承属性或状态选择器。

只有当一个持续维护的库能提供静态路径难以清楚表达的能力时,才有理由在运行时生成规则。决策范围还包括缓存、服务端收集器、内容安全策略支持、调试输出和升级路径。仅看模板语法无法判断这些运维成本。

应使用生产构建产物和浏览器轨迹比较候选方案。统计路由收到的样式相关 JavaScript,检查 CSS 何时可用,并记录真实交互期间的样式重新计算。不要照搬其他应用的包体积或耗时数字;库版本、编译器配置、组件数量和实际渲染的状态空间都会改变结果。

关注点运行时生成构建时提取
规则创建应用执行期间在构建期间处理工具支持的声明
动态输入可以计算运行时 JavaScript使用变体、变量、行内样式或运行时出口
客户端依赖通常包含样式运行时静态声明不需要客户端规则生成器
服务端工作收集并序列化本次渲染用到的规则链接或内联生成的 CSS 资源
主要失败方式规则无限增长,或服务端与客户端不匹配提取器无法分析代码,导致 CSS 缺失

这张表描述的是执行边界,不是质量排名。声明稳定的运行时系统可能比集成不良的提取器更适合项目。构建时系统也可能输出过多 CSS,或让覆盖方式变得含糊。

主题与令牌

主题通常把 surfacetextMuteddangerBorder 等语义名称映射到 CSS 值。组件声明应依赖语义令牌,这样切换主题时无需改写组件逻辑。CSS 自定义属性适合作为输出格式,因为它们可以继承并参与层叠,还能在属性或媒体查询下切换,无需重新生成组件类。

类型系统可以证明源模型中存在某个令牌名称,却不能证明前景色与背景色的对比度足够、长度使用了正确单位,或用户提供的字符串适合放进 CSS。这些仍然要靠验证和浏览器测试。

在水合前切换主题需要特别处理。如果服务端选择一种主题,而客户端启动时选择另一种,用户可能看到闪烁,React 也可能报告不匹配。应优先使用服务端可以读取的偏好,或小型且有明确说明的绘制前机制,并测试禁用脚本和慢脚本路径。

不靠猜测完成服务端渲染

运行时 CSS-in-JS 通常需要一个仅供本次服务端渲染使用的注册表或缓存。组件在渲染树的过程中注册规则;集成层把规则序列化进响应,再让客户端接管。进程级全局可变注册表可能造成跨请求泄漏,输出也会受请求顺序影响。

确定性不只涉及哈希函数。两端必须使用相同的源码转换、插件配置、输入属性、区域设置、主题和规则顺序。除非服务端能得到等价值,否则不应该让基于 Date.now()、随机数、浏览器测量或客户端存储的值决定初始类名。

流式传输还把时序纳入契约。某个区块使用的规则必须早于或同时于其标记到达,后续区块也不能以不可预测的方式重排同优先级规则。应采用框架与样式库支持的流式集成,不要根据拼接字符串的示例自行设计收集器。

服务端渲染要在网络边界测试。在不运行 JavaScript 的情况下获取 HTML,确认首屏元素已有对应规则,再执行水合并收集控制台错误。进入代码分割路由后返回,可以发现重复插入、残留注册表和顺序变化。

构建时提取把大部分工作移到资源生成阶段,但交付仍可能失败。路由可能漏掉 CSS 区块、预加载错误资源,或把后到的样式表放在覆盖层之后。应该检查生成清单和响应,不能只看源码导入。

所有权、覆盖与迁移

需要明确组件拥有哪些样式,又允许调用方覆盖哪些样式。结构不变量、内部状态和语义令牌通常属于组件。由父元素施加的布局,例如网格定位或外边距,通常放在组件外部更容易管理。只有当源码顺序和优先级让行为可以预测时,公开的 className 出口才真正有用。

不要把生成的类名字符串公开成契约。哈希和编译器名称可能随构建改变,内部选择器也可能在重构时消失。测试应该查询语义角色、标签、状态或有意保留的数据属性;视觉测试可以覆盖实际外观。

CSS-in-JS 系统之间的迁移不只是替换语法。应先清点全局规则、主题传递、变体行为、服务端收集、样式顺序和使用方覆盖。先让一个小型垂直切片经过生产构建与水合,再转换共享基础组件。

迁移期间,应把新旧系统放进明确的层叠层或插入区域。如果没有这条边界,迁移顺序可能改变同优先级规则的胜负,造成看似与当前组件无关的回归。只有路由级构建产物证明没有组件继续导入旧运行时后,才能移除它。

延伸阅读

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

前置内容 前端开发基础
下一篇 React performance 即将上线 React server components 即将上线 Tailwind CSS Nextjs 即将上线
复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?