侧边栏

实现一个简易的CSS-in-JS工具

发布于 | 分类于 技术原理|本文包含AIGC内容

最新在处理项目antd4升级到antd5,里面有一个性能问题需要排查antd5里面使用的css in js

之前虽然对于css in js有一些耳闻,但并没有在项目中使用过,对其原理也没有特别了解,因此需要先学习一下。本文将参考@ant-design/cssinjs的源码,实现一个非常简单的css in js工具,了解其核心原理和实现。

什么是 CSS-in-JS

CSS-in-JS 是一种使用 JavaScript 编写 CSS 的技术,它允许你在 JavaScript 中定义样式,然后动态注入到页面中。

传统方式 vs CSS-in-JS

jsx
// 传统 CSS
// styles.css
.button { color: red; }

// component.jsx
import './styles.css';
<button className="button">Click</button>

// CSS-in-JS
const styles = { color: 'red' };
<button className={css(styles)}>Click</button>

核心优势

  • 样式隔离 - 自动生成唯一类名,避免冲突

还记得那些年被 .button 类名支配的恐惧吗?你写了个 .button,同事也写了个 .button,结果样式互相覆盖,页面乱成一团。CSS-in-JS 会自动生成 .css-abc123 这样的唯一类名,再也不用担心类名冲突了。

  • 动态样式 - 基于 props/state 动态计算

    想根据用户等级显示不同颜色?想根据进度条数值改变宽度?直接在 JS 里写逻辑就行,不用再搞什么 className={isPremium ? 'gold' : 'silver'} 这种繁琐的类名切换。

  • 按需加载 - 只加载使用的样式

    传统 CSS 文件一股脑全加载,用户可能只看了首页,结果把整个网站的样式都下载了。CSS-in-JS 只在组件渲染时才注入样式,用不到的组件,样式也不会加载。

  • 类型安全 - TypeScript 支持

    写错了属性名?拼错了颜色值?TypeScript 直接给你报错,不用等到浏览器里才发现问题。而且还有智能提示,开发体验不错(不过现在大部分IDE对于CSS、Less等提示也是比较到位的,两者差别不大)。

  • 主题切换 - 轻松实现多主题

    想做个暗黑模式?想让用户自定义主题色?CSS-in-JS 可以直接通过 Context 传递主题变量,所有组件自动响应,不用写一堆 CSS 变量或者 class 切换逻辑。

核心劣势

当然,CSS-in-JS 也不是银弹,它也有不少问题:

  • 运行时开销 - 性能是最大的痛点

    传统 CSS 是静态的,浏览器解析一次就完事了。CSS-in-JS 需要在运行时解析样式、生成哈希、注入 DOM,这些都要消耗性能。尤其是首屏渲染,可能会明显变慢。这也是为什么 antd5 升级后有些项目会遇到性能问题。

  • 包体积增加 - 要引入额外的库

    styled-components 压缩后也有 15KB+,@emotion/react 也要 10KB+。对于追求极致性能的项目来说,这个体积不算小。而且这些库的代码也要执行,也会占用 JS 主线程时间。

  • 调试困难 - 生成的类名不直观

    打开 DevTools 看到的是 .css-1a2b3c4,完全不知道这是哪个组件的样式。虽然可以配置生成可读的类名,但生产环境一般都会关闭(为了减小体积)。相比之下,传统 CSS 的 .header-nav-button 一眼就知道是哪里的样式。

  • SSR 复杂度 - 服务端渲染要额外处理

    传统 CSS 直接引入就行,CSS-in-JS 需要在服务端收集样式、序列化、注入到 HTML,客户端还要 hydrate。稍有不慎就会出现样式闪烁、重复注入等问题。配置起来也比较繁琐。

  • 学习成本 - 团队需要适应新的写法

    习惯了写 CSS 的同学,突然要在 JS 里写样式,还要理解哈希、缓存、注入这些概念,学习曲线还是有的。而且不同的 CSS-in-JS 库 API 差异很大,换个库可能又要重新学。

  • 工具链支持 - 不如传统 CSS 成熟

    CSS 有 PostCSS、Sass、Less 这些成熟的工具链,还有各种 lint、format、压缩工具。CSS-in-JS 的工具链相对不够完善,比如样式提取、Critical CSS 生成等功能,实现起来都比较麻烦。

  • 缓存策略 - 不能利用浏览器缓存

    传统 CSS 文件可以设置长期缓存,用户第二次访问直接从缓存读取。CSS-in-JS 的样式是动态生成的,每次都要重新解析注入,无法享受浏览器缓存带来的性能提升。

核心概念

CSS-in-JS 的实现围绕以下 5 个核心概念:(实际上是@ant-design/cssinjs的几个核心概念,其他的css in js库侧重点各有差异,我还没有细研究)

样式对象(Style Object)

使用 JavaScript 对象表示 CSS,其中的key或者value,就可以通过纯粹的js props进行传递。

typescript
const styleObject = {
  color: 'red',              // CSS 属性
  fontSize: 14,              // 数字自动添加 px
  backgroundColor: '#fff',   // 驼峰命名
  padding: '8px 16px',       // 字符串值
  
  // 嵌套选择器
  '&:hover': {
    color: 'blue'
  },
  
  // 子选择器
  '& > span': {
    fontWeight: 'bold'
  }
};

关键点:

  • 使用驼峰命名(backgroundColor)而非连字符(background-color)
  • 数字值自动添加单位
  • 支持嵌套语法

样式解析(Style Parsing)

将 上面 的样式对象(也就是JavaScript 对象)转换为 CSS 字符串,之后可以通过style标签直接注入到页面上,之后对应选择器的样式就会生效。

typescript
// 输入
{ color: 'red', fontSize: 14 }

// 输出
".css-abc123{color:red;font-size:14px;}"

核心步骤:

  1. 遍历对象属性
  2. 驼峰转连字符(fontSize → font-size)
  3. 数字添加单位(14 → 14px)
  4. 处理嵌套选择器
  5. 拼接成 CSS 字符串

动态注入(Dynamic Injection)

运行时将 CSS 字符串注入到 DOM:

typescript
// 创建 style 标签
const style = document.createElement('style');
style.innerHTML = '.css-abc123{color:red;}';
document.head.appendChild(style);

关键点:

  • <head> 中创建 <style> 标签
  • 设置唯一标识避免重复
  • 支持更新和删除

哈希生成(Hash Generation)

为样式生成唯一标识:

typescript
// 相同样式生成相同哈希
hash({ color: 'red' })  // 'abc123'
hash({ color: 'red' })  // 'abc123'

// 不同样式生成不同哈希
hash({ color: 'blue' }) // 'def456'

作用:

  • 生成唯一类名(css-abc123)
  • 实现样式缓存
  • 避免样式冲突

样式缓存(Style Cache)

避免重复解析和注入:

typescript
const cache = new Map();

function css(styleObj) {
  const hash = generateHash(styleObj);
  
  if (cache.has(hash)) {
    return cache.get(hash);  // 缓存命中
  }
  
  const className = createStyle(styleObj);
  cache.set(hash, className);
  return className;
}

优势:

  • 相同样式只解析一次
  • 相同样式只注入一次
  • 显著提升性能

实现原理

整体流程

JavaScript 对象

生成哈希(检查缓存)

解析为 CSS 字符串

注入到 DOM

返回类名

样式解析器

核心功能: 将对象转换为 CSS 字符串

typescript
// 驼峰转连字符
function camelToKebab(str: string): string {
  return str.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`);
}

// 格式化值(添加单位)
function formatValue(property: string, value: any): string {
  // 不需要单位的属性
  const unitless = ['opacity', 'zIndex', 'fontWeight', 'lineHeight'];
  
  if (typeof value === 'number' && !unitless.includes(property)) {
    return `${value}px`;
  }
  return String(value);
}

// 处理选择器
function processSelector(selector: string, parent: string): string {
  if (selector.includes('&')) {
    return selector.replace(/&/g, parent);  // & → .parent
  }
  if (selector.startsWith(':')) {
    return `${parent}${selector}`;  // :hover → .parent:hover
  }
  return `${parent} ${selector}`;  // .child → .parent .child
}

// 解析样式
function parseStyle(styles: any, selector: string): string {
  let css = '';
  const basic: string[] = [];
  const nested: Array<[string, any]> = [];
  
  // 分离基础样式和嵌套样式
  for (const [key, value] of Object.entries(styles)) {
    if (typeof value === 'object') {
      nested.push([key, value]);
    } else {
      const prop = camelToKebab(key);
      const val = formatValue(key, value);
      basic.push(`${prop}:${val};`);
    }
  }
  
  // 生成基础样式
  if (basic.length > 0) {
    css += `${selector}{${basic.join('')}}`;
  }
  
  // 递归处理嵌套
  for (const [nestedSel, nestedStyles] of nested) {
    const fullSelector = processSelector(nestedSel, selector);
    css += parseStyle(nestedStyles, fullSelector);
  }
  
  return css;
}

示例:

typescript
const styles = {
  color: 'red',
  fontSize: 14,
  '&:hover': {
    color: 'blue'
  }
};

parseStyle(styles, '.button');
// 输出:
// .button{color:red;font-size:14px;}
// .button:hover{color:blue;}

哈希生成器

核心功能: 为样式对象生成唯一标识,本质上可以理解为某种摘要算法,将相同内容的样式对象计算出一个相同的hash。

typescript
// 简单哈希算法(djb2)
function simpleHash(str: string): string {
  let hash = 5381;
  for (let i = 0; i < str.length; i++) {
    hash = (hash * 33) ^ str.charCodeAt(i);
  }
  return (hash >>> 0).toString(36);
}

// 对象序列化(保证顺序一致)
function serialize(obj: any): string {
  if (typeof obj !== 'object' || obj === null) {
    return String(obj);
  }
  
  if (Array.isArray(obj)) {
    return `[${obj.map(serialize).join(',')}]`;
  }
  
  // 对象按键排序
  const keys = Object.keys(obj).sort();
  const pairs = keys.map(k => `${k}:${serialize(obj[k])}`);
  return `{${pairs.join(',')}}`;
}

// 生成哈希
function generateHash(styles: any): string {
  return simpleHash(serialize(styles));
}

示例:

typescript
generateHash({ color: 'red', fontSize: 14 });
// 输出: 'abc123'

generateHash({ fontSize: 14, color: 'red' });
// 输出: 'abc123'  // 顺序不同,哈希相同

DOM 注入器

核心功能: 动态创建和管理 style 标签

typescript
// 查找已存在的 style 标签
function findStyle(id: string): HTMLStyleElement | null {
  return document.querySelector(`style[data-css-id="${id}"]`);
}

// 注入样式
function injectStyle(css: string, id: string): HTMLStyleElement {
  let style = findStyle(id);
  
  if (style) {
    // 已存在,检查内容是否相同
    if (style.innerHTML !== css) {
      style.innerHTML = css;  // 更新
    }
    return style;
  }
  
  // 不存在,创建新的
  style = document.createElement('style');
  style.setAttribute('data-css-id', id);
  style.innerHTML = css;
  document.head.appendChild(style);
  
  return style;
}

// 删除样式
function removeStyle(id: string): void {
  const style = findStyle(id);
  if (style) {
    style.remove();
  }
}

特性:

  • 避免重复创建
  • 支持样式更新
  • 支持样式删除
  • 使用 data 属性标识

缓存系统

核心功能: 管理样式缓存和引用计数

typescript
interface CacheEntry {
  hash: string;
  className: string;
  css: string;
  refCount: number;  // 引用计数
}

class StyleCache {
  private cache = new Map<string, CacheEntry>();
  
  get(hash: string): CacheEntry | undefined {
    return this.cache.get(hash);
  }
  
  set(hash: string, entry: CacheEntry): void {
    this.cache.set(hash, entry);
  }
  
  has(hash: string): boolean {
    return this.cache.has(hash);
  }
  
  // 增加引用
  addRef(hash: string): void {
    const entry = this.cache.get(hash);
    if (entry) entry.refCount++;
  }
  
  // 减少引用
  removeRef(hash: string): void {
    const entry = this.cache.get(hash);
    if (entry) {
      entry.refCount--;
      if (entry.refCount <= 0) {
        this.cache.delete(hash);
        removeStyle(hash);
      }
    }
  }
}

const globalCache = new StyleCache();

优化策略:

  • 引用计数:组件卸载时自动清理
  • WeakMap:避免内存泄漏
  • LRU 策略:限制缓存大小

核心 API

核心功能: 整合所有模块

typescript
// 主函数:创建样式并返回类名
function css(styles: any): string {
  // 1. 生成哈希
  const hash = generateHash(styles);
  
  // 2. 检查缓存
  if (globalCache.has(hash)) {
    const entry = globalCache.get(hash)!;
    globalCache.addRef(hash);
    return entry.className;
  }
  
  // 3. 生成类名
  const className = `css-${hash}`;
  
  // 4. 解析样式
  const cssString = parseStyle(styles, `.${className}`);
  
  // 5. 注入 DOM
  injectStyle(cssString, hash);
  
  // 6. 存入缓存
  globalCache.set(hash, {
    hash,
    className,
    css: cssString,
    refCount: 1
  });
  
  return className;
}

React 集成

核心功能: 提供 React Hook

typescript
import { useEffect, useMemo, useRef } from 'react';

function useStyles<T extends Record<string, any>>(
  styles: T
): { [K in keyof T]: string } {
  const prevHashesRef = useRef<string[]>([]);
  
  // 生成类名
  const classNames = useMemo(() => {
    const result: any = {};
    const hashes: string[] = [];
    
    for (const key in styles) {
      result[key] = css(styles[key]);
      hashes.push(generateHash(styles[key]));
    }
    
    prevHashesRef.current = hashes;
    return result;
  }, [styles]);
  
  // 清理
  useEffect(() => {
    return () => {
      prevHashesRef.current.forEach(hash => {
        globalCache.removeRef(hash);
      });
    };
  }, []);
  
  return classNames;
}

// 辅助函数:合并类名
function cx(...names: (string | undefined | false | null)[]): string {
  return names.filter(Boolean).join(' ');
}

完整示例

基础使用

typescript
import { useStyles } from './mini-cssinjs';

function Button() {
  const styles = useStyles({
    button: {
      padding: '8px 16px',
      fontSize: 14,
      backgroundColor: '#1890ff',
      color: '#fff',
      border: 'none',
      borderRadius: 4,
      cursor: 'pointer',
      transition: 'all 0.3s',
      
      '&:hover': {
        backgroundColor: '#40a9ff'
      },
      
      '&:active': {
        backgroundColor: '#096dd9'
      }
    }
  });
  
  return <button className={styles.button}>Click Me</button>;
}

动态样式

typescript
function Button({ type = 'default' }: { type?: 'primary' | 'default' }) {
  const colors = {
    primary: { bg: '#1890ff', hover: '#40a9ff' },
    default: { bg: '#fff', hover: '#f0f0f0' }
  };
  
  const styles = useStyles({
    button: {
      padding: '8px 16px',
      backgroundColor: colors[type].bg,
      color: type === 'primary' ? '#fff' : '#000',
      border: type === 'default' ? '1px solid #d9d9d9' : 'none',
      
      '&:hover': {
        backgroundColor: colors[type].hover
      }
    }
  });
  
  return <button className={styles.button}>Click Me</button>;
}

复杂组件

typescript
function Card() {
  const styles = useStyles({
    card: {
      padding: 24,
      backgroundColor: '#fff',
      borderRadius: 8,
      boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
      
      '&:hover': {
        boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
        transform: 'translateY(-2px)'
      }
    },
    
    header: {
      display: 'flex',
      justifyContent: 'space-between',
      marginBottom: 16,
      paddingBottom: 16,
      borderBottom: '1px solid #f0f0f0'
    },
    
    title: {
      fontSize: 18,
      fontWeight: 'bold',
      margin: 0
    },
    
    content: {
      fontSize: 14,
      lineHeight: 1.6,
      color: '#666'
    }
  });
  
  return (
    <div className={styles.card}>
      <div className={styles.header}>
        <h3 className={styles.title}>Card Title</h3>
      </div>
      <div className={styles.content}>
        Card content...
      </div>
    </div>
  );
}

性能优化

缓存策略,避免生成相同的样式

问题: 相同样式重复解析和注入

解决: 使用哈希缓存

typescript
// ❌ 没有缓存 - 每次都解析
function Button() {
  const className = css({ color: 'red' });  // 解析 + 注入
  return <button className={className}>Click</button>;
}

// 渲染 100 个按钮 = 100 次解析 + 100 次注入

// ✅ 有缓存 - 只解析一次
function Button() {
  const className = css({ color: 'red' });  // 第一次:解析 + 注入
  return <button className={className}>Click</button>;  // 后续:缓存命中
}

// 渲染 100 个按钮 = 1 次解析 + 1 次注入

对象引用优化,避免缓存失效

引入了缓存策略,随之而来的问题是哈希缓存算法需要尽量保证键值一致的对象,可以生成相同的hash,如果是基于对象引用来生成的hash,就很有可能导致缓存失效。

这是最常见的问题,也是最容易被忽略的。

typescript
// ❌ 性能杀手 - 每次渲染都是新对象
function UserCard({ user }) {
  const styles = useStyles({
    card: {
      backgroundColor: user.isPremium ? '#gold' : '#silver'  // 每次都是新对象!
    }
  });
  
  return <div className={styles.card}>{user.name}</div>;
}

// 渲染 1000 个用户 = 1000 次样式解析 + 1000 个 style 标签

为什么会慢?

  • 每次渲染都生成新的样式对象
  • 缓存完全失效
  • 大量重复的 CSS 被注入到 DOM

正确做法:

typescript
// ✅ 方案 1:提前定义样式
const premiumStyle = { card: { backgroundColor: '#gold' } };
const normalStyle = { card: { backgroundColor: '#silver' } };

function UserCard({ user }) {
  const styles = useStyles(user.isPremium ? premiumStyle : normalStyle);
  return <div className={styles.card}>{user.name}</div>;
}

// ✅ 方案 2:使用 CSS 变量
function UserCard({ user }) {
  const styles = useStyles({
    card: {
      backgroundColor: 'var(--card-bg)'
    }
  });
  
  return (
    <div 
      className={styles.card}
      style={{ '--card-bg': user.isPremium ? '#gold' : '#silver' }}
    >
      {user.name}
    </div>
  );
}

引用计数在组件卸载后移除样式标签

这在传统的css文件方案中并不太容易实现。

问题: 组件卸载后样式残留

这个问题不会立即显现,但时间长了会导致内存泄漏。

typescript
// ❌ 样式一直堆积
function Modal({ visible }) {
  const styles = useStyles({
    modal: { /* ... */ }
  });
  
  if (!visible) return null;
  
  return <div className={styles.modal}>Modal Content</div>;
}

// 打开关闭 100 次 = 100 个 style 标签残留在 DOM 里

为什么会有问题?

  • 组件卸载了,但 style 标签还在
  • 页面上的 style 标签越来越多
  • 最终导致页面变慢、内存占用高

正确做法:

前面实现的引用计数机制就是为了解决这个问题。确保你的 CSS-in-JS 库支持自动清理,或者手动管理样式的生命周期。

解决: 引用计数自动清理

typescript
function useStyles(styles) {
  const hashes = useRef([]);
  
  // 组件挂载:增加引用
  const classNames = useMemo(() => {
    const result = {};
    hashes.current = [];
    
    for (const key in styles) {
      const hash = generateHash(styles[key]);
      result[key] = css(styles[key]);
      hashes.current.push(hash);
      globalCache.addRef(hash);  // +1
    }
    
    return result;
  }, [styles]);
  
  // 组件卸载:减少引用
  useEffect(() => {
    return () => {
      hashes.current.forEach(hash => {
        globalCache.removeRef(hash);  // -1
      });
    };
  }, []);
  
  return classNames;
}

批量操作,优化DOM操作

问题: 频繁的 DOM 操作导致性能下降

解决: 使用 DocumentFragment 批量插入

typescript
function batchInjectStyles(styles: Array<{ css: string; id: string }>) {
  const fragment = document.createDocumentFragment();
  
  styles.forEach(({ css, id }) => {
    if (!findStyle(id)) {
      const style = document.createElement('style');
      style.setAttribute('data-css-id', id);
      style.innerHTML = css;
      fragment.appendChild(style);
    }
  });
  
  document.head.appendChild(fragment);  // 一次性插入
}

小心在循环中使用动态样式

这个问题在列表渲染时特别常见。

typescript
// ❌ 灾难级性能 - 每个列表项都生成独立样式
function TodoList({ todos }) {
  return todos.map(todo => {
    const styles = useStyles({
      item: {
        color: todo.completed ? '#999' : '#000',  // 每个 todo 都不一样!
        textDecoration: todo.completed ? 'line-through' : 'none'
      }
    });
    
    return <div className={styles.item}>{todo.text}</div>;
  });
}

// 100 个 todo = 100 个不同的样式 = 页面卡成 PPT

为什么会慢?

  • 每个列表项的样式都略有不同
  • 无法复用缓存
  • DOM 中充斥着大量相似的 style 标签

正确做法:

typescript
// ✅ 使用类名切换
const todoStyles = {
  item: { color: '#000' },
  completed: { 
    color: '#999',
    textDecoration: 'line-through'
  }
};

function TodoList({ todos }) {
  const styles = useStyles(todoStyles);
  
  return todos.map(todo => (
    <div className={cx(styles.item, todo.completed && styles.completed)}>
      {todo.text}
    </div>
  ));
}

// 100 个 todo = 只有 2 个样式类 = 丝滑流畅

避免在 render 函数里做复杂计算

有时候为了"灵活",会在样式里做一些计算,结果把性能拖垮了。

typescript
// ❌ 每次渲染都要算一遍
function ProgressBar({ progress }) {
  const styles = useStyles({
    bar: {
      width: `${progress}%`,
      // 根据进度计算颜色
      backgroundColor: progress < 30 ? 'red' 
        : progress < 70 ? 'orange' 
        : 'green',
      // 还要算阴影
      boxShadow: `0 0 ${progress / 10}px rgba(0,0,0,0.3)`
    }
  });
  
  return <div className={styles.bar} />;
}

为什么会慢?

  • 每个不同的 progress 值都生成新样式
  • progress 从 0 到 100,可能生成 100 个样式
  • 计算本身也消耗性能

正确做法:

typescript
// ✅ 用 CSS 变量 + 预定义样式
const progressStyles = {
  bar: {
    width: 'var(--progress)',
    backgroundColor: 'var(--color)',
    boxShadow: 'var(--shadow)'
  }
};

function ProgressBar({ progress }) {
  const styles = useStyles(progressStyles);
  
  const color = progress < 30 ? 'red' : progress < 70 ? 'orange' : 'green';
  
  return (
    <div 
      className={styles.bar}
      style={{
        '--progress': `${progress}%`,
        '--color': color,
        '--shadow': `0 0 ${progress / 10}px rgba(0,0,0,0.3)`
      }}
    />
  );
}

// 只生成 1 个样式类,无论 progress 是多少

SSR 时样式重复注入

服务端渲染时,如果处理不当,会导致样式在客户端重复注入。

typescript
// ❌ 服务端生成了样式,客户端又生成一遍
// 服务端 HTML:
<style>.css-abc { color: red; }</style>

// 客户端 hydrate 后:
<style>.css-abc { color: red; }</style>  <!-- 服务端的 -->
<style>.css-abc { color: red; }</style>  <!-- 客户端又生成了一个! -->

正确做法:

typescript
// 服务端:收集样式
const cache = createCache();
const html = renderToString(
  <CacheProvider value={cache}>
    <App />
  </CacheProvider>
);
const styles = extractStyles(cache);

// 客户端:复用服务端样式
const cache = createCache();
hydrateCache(cache, window.__STYLES__);  // 标记已存在的样式

hydrate(
  <CacheProvider value={cache}>
    <App />
  </CacheProvider>,
  document.getElementById('root')
);

WeakMap 优化

问题: 对象缓存导致内存泄漏

解决: 使用 WeakMap 自动回收

typescript
// 对象引用缓存
const objectCache = new WeakMap<object, string>();

function generateHash(styles: any): string {
  // 如果是对象且已缓存,直接返回
  if (typeof styles === 'object' && objectCache.has(styles)) {
    return objectCache.get(styles)!;
  }
  
  const hash = simpleHash(serialize(styles));
  
  // 缓存对象引用
  if (typeof styles === 'object') {
    objectCache.set(styles, hash);
  }
  
  return hash;
}

再看运行时css in js的优劣

前面我们实现了一个简易的 CSS-in-JS 工具,也列举了它的优劣势。但在实际项目中,尤其是 React 18 这种对性能有更高要求的环境下,运行时 CSS-in-JS 的问题会被进一步放大。这里我们深入聊聊它的灵活性和性能之间的权衡。

运行时的灵活性:双刃剑

运行时 CSS-in-JS 最大的卖点就是"灵活"——你可以在运行时根据任何 JavaScript 变量动态生成样式。这听起来很美好,但也是性能问题的根源。

灵活性的诱惑

typescript
// 看起来很爽的写法
function DynamicButton({ theme, size, status, priority }) {
  const styles = useStyles({
    button: {
      // 根据主题动态计算
      backgroundColor: theme.colors[priority],
      color: theme.textColors[priority],
      
      // 根据尺寸动态计算
      padding: size === 'large' ? '12px 24px' : '8px 16px',
      fontSize: size === 'large' ? 16 : 14,
      
      // 根据状态动态计算
      opacity: status === 'disabled' ? 0.5 : 1,
      cursor: status === 'disabled' ? 'not-allowed' : 'pointer',
      
      // 还可以做复杂计算
      boxShadow: `0 ${priority * 2}px ${priority * 4}px rgba(0,0,0,0.${priority})`,
    }
  });
  
  return <button className={styles.button}>Click</button>;
}

这种写法确实很灵活,但问题在于:

  1. 每个不同的 props 组合都会生成新样式

    • 3 个 priority × 2 个 size × 2 个 status = 12 种样式
    • 如果 theme 也是动态的,组合数会爆炸式增长
  2. 缓存基本失效

    • 即使两个按钮的样式看起来一样,但因为对象引用不同,还是会生成新的样式
    • 缓存命中率极低
  3. DOM 中充斥着大量 style 标签

    • 页面上可能有几十个按钮,每个都有自己的 style 标签
    • 浏览器要解析和应用这些样式,性能直线下降

在 React 18 中的问题

React 18 引入了并发渲染(Concurrent Rendering),这让运行时 CSS-in-JS 的问题更加严重。

问题 1:样式注入阻塞渲染

typescript
function App() {
  return (
    <Suspense fallback={<Loading />}>
      <HeavyComponent />  {/* 包含大量 CSS-in-JS 组件 */}
    </Suspense>
  );
}

在 React 18 的并发模式下:

  • React 可以中断渲染,优先处理更重要的更新
  • 但 CSS-in-JS 的样式注入是同步的,无法被中断
  • 大量样式注入会阻塞主线程,导致页面卡顿

问题 2:与 Transition API 的冲突

typescript
function SearchPage() {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();
  
  const handleSearch = (value) => {
    startTransition(() => {
      setQuery(value);  // 低优先级更新
    });
  };
  
  return (
    <>
      <SearchInput onChange={handleSearch} />
      <SearchResults query={query} />  {/* 包含大量动态样式 */}
    </>
  );
}

理想情况下,startTransition 应该让搜索结果的渲染不阻塞输入。但如果 SearchResults 里有大量运行时 CSS-in-JS:

  • 样式解析和注入是同步的,无法被标记为低优先级
  • 用户输入还是会被阻塞
  • Transition API 的优势完全发挥不出来

问题 3:Streaming SSR 的性能瓶颈

React 18 的 Streaming SSR 可以让页面分块渲染,但运行时 CSS-in-JS 会破坏这个优势:

typescript
// 服务端
<Suspense fallback={<Skeleton />}>
  <SlowComponent />  {/* 包含大量 CSS-in-JS */}
</Suspense>
  • 传统 CSS:HTML 可以立即流式传输,CSS 文件并行加载
  • 运行时 CSS-in-JS:必须等组件渲染完,收集所有样式,才能发送 HTML
  • 首字节时间(TTFB)显著增加

useInsertionEffect

useInsertionEffect 是 React 18 为 CSS-in-JS 提供的性能优化方案:

  • 执行时机更早 - 在 DOM 变更前注入样式
  • 避免双重布局 - 浏览器只需要计算一次样式
  • 消除样式闪烁 - 样式在 DOM 渲染前就已经准备好
  • 提升 40% 性能 - 相比 useLayoutEffect

但它也有局限性:

  • 只能用于 CSS-in-JS - 不要用它做其他事情
  • 不能访问 DOM - 执行时 DOM 还没创建
  • 不能使用 setState - 会导致额外的渲染

为什么需要 useInsertionEffect?

在 React 18 之前,CSS-in-JS 库通常使用 useLayoutEffect 来注入样式:

typescript
function useStyles(styles) {
  const [className, setClassName] = useState('');
  
  useLayoutEffect(() => {
    // 在这里注入样式
    const hash = generateHash(styles);
    const css = parseStyle(styles, `.css-${hash}`);
    injectStyle(css, hash);
    setClassName(`css-${hash}`);
  }, [styles]);
  
  return className;
}

这种方式有个严重的问题:

1. React 渲染组件(读取 DOM 布局)
2. useLayoutEffect 执行(注入样式,修改 DOM)
3. 浏览器重新计算样式
4. React 再次读取 DOM 布局(因为样式变了)
5. 浏览器重新绘制

这导致了双重布局计算,性能很差。而且在并发模式下,可能会出现样式闪烁:

typescript
function Button() {
  const styles = useStyles({ button: { color: 'red' } });
  
  // 第一次渲染:className 还是空的,按钮没有样式
  // useLayoutEffect 执行后:样式注入,按钮突然有了样式
  // 用户可能会看到一瞬间的无样式内容(FOUC)
  return <button className={styles.button}>Click</button>;
}

useInsertionEffect 的执行时机

useInsertionEffect 的执行时机比 useLayoutEffect 更早:

1. React 渲染组件(生成虚拟 DOM)
2. useInsertionEffect 执行(注入样式)← 在这里!
3. React 提交到真实 DOM
4. 浏览器计算样式和布局(只需要一次)
5. useLayoutEffect 执行
6. 浏览器绘制

关键点:

  1. 不能使用 setState - useInsertionEffect 执行时,DOM 还没有更新,调用 setState 会导致额外的渲染
  2. 使用 ref 存储结果 - 因为不能用 state,所以用 ref 来存储类名
  3. 只做 DOM 插入 - 这个 Hook 的设计目的就是插入 <style> 标签,不要做其他事情

注意意事项

  1. 只在 CSS-in-JS 库中使用

    useInsertionEffect 是专门为 CSS-in-JS 设计的,不要用它做其他事情。React 官方文档明确说明:

    useInsertionEffect is for CSS-in-JS library authors. Unless you are working on a CSS-in-JS library and need a place to inject the styles, you probably want useEffect or useLayoutEffect instead.

  2. 不能访问 refs

    typescript
    function MyComponent() {
      const ref = useRef(null);
      
      useInsertionEffect(() => {
        console.log(ref.current);  // ❌ null!DOM 还没创建
      });
      
      return <div ref={ref}>Hello</div>;
    }
  3. 不能读取 DOM 布局

    typescript
    useInsertionEffect(() => {
      const width = document.body.offsetWidth;  // ❌ 可能触发强制同步布局
    });
  4. 服务端渲染时不执行

    useEffectuseLayoutEffect 一样,useInsertionEffect 只在客户端执行。

性能问题的本质

运行时 CSS-in-JS 的性能问题,本质上是把构建时的工作推迟到了运行时

传统 CSS 的工作流

开发时写 CSS → 构建时处理(压缩、autoprefixer)→ 浏览器加载静态文件 → 解析应用
  • 大部分工作在构建时完成
  • 浏览器只需要解析和应用
  • 可以利用 HTTP 缓存

运行时 CSS-in-JS 的工作流

开发时写 JS 对象 → 浏览器加载 JS → 运行时解析对象 → 生成哈希 → 转换为 CSS → 注入 DOM → 浏览器解析应用
  • 大部分工作在运行时完成
  • 占用 JS 主线程时间
  • 每次访问都要重新执行
  • 无法利用 HTTP 缓存

具体的性能开销

以一个中等规模的页面为例(100 个组件,每个组件 3-5 个样式规则):

typescript
// 运行时开销分解
1. 对象序列化:100 个组件 × 4 个样式 × 0.1ms = 40ms
2. 哈希计算:400 个样式 × 0.05ms = 20ms
3. CSS 字符串生成:400 个样式 × 0.2ms = 80ms
4. DOM 注入:400 个 style 标签 × 0.1ms = 40ms
5. 浏览器重新计算样式:~50ms

总计:~230ms

这还是理想情况(缓存命中率高)。如果缓存命中率低,开销会翻倍。

对比传统 CSS:

1. 加载 CSS 文件:~20ms(gzip 压缩后通常很小)
2. 浏览器解析:~30ms

总计:~50ms

差距接近 5 倍。

替代方案

如果你遇到了运行时 CSS-in-JS 的性能问题,可以考虑这些方案:

CSS Modules + CSS 变量

typescript
// Button.module.css
.button {
  padding: 8px 16px;
  background-color: var(--button-bg);
  color: var(--button-color);
}

// Button.tsx
import styles from './Button.module.css';

function Button({ type }) {
  const vars = {
    '--button-bg': type === 'primary' ? 'blue' : 'white',
    '--button-color': type === 'primary' ? 'white' : 'black',
  };
  
  return <button className={styles.button} style={vars}>Click</button>;
}
  • 样式隔离
  • 动态主题
  • 性能接近传统 CSS

零运行时 CSS-in-JS

使用编译时 CSS-in-JS,如 Vanilla Extract、Linaria:

typescript
// styles.css.ts(编译时处理)
import { style } from '@vanilla-extract/css';

export const button = style({
  padding: '8px 16px',
  backgroundColor: 'blue',
});

// Button.tsx
import * as styles from './styles.css';

function Button() {
  return <button className={styles.button}>Click</button>;
}
  • 编译时生成静态 CSS 文件
  • 运行时零开销
  • 保留类型安全

这些方案本质上跟写css module或者css文件的差别不大,最后都是导出了独立的css静态文件。

Tailwind CSS

typescript
function Button({ type }) {
  return (
    <button className={cn(
      'px-4 py-2 rounded',
      type === 'primary' ? 'bg-blue-500 text-white' : 'bg-white text-black'
    )}>
      Click
    </button>
  );
}
  • 原子化 CSS
  • 按需生成
  • 性能优秀

总结

CSS-in-JS 的实现围绕 5 个核心概念:

  1. 样式对象 - 使用 JavaScript 对象表示 CSS
  2. 样式解析 - 将对象转换为 CSS 字符串
  3. 哈希生成 - 为样式生成唯一标识
  4. 动态注入 - 运行时创建 style 标签
  5. 样式缓存 - 避免重复计算和注入

需要实现的要点包括

  • 解析器: 驼峰转连字符 + 自动添加单位 + 处理嵌套
  • 哈希: 对象序列化 + 简单哈希算法
  • 注入: 创建 style 标签 + 避免重复
  • 缓存: Map 存储 + 引用计数 + 自动清理
  • React: Hook 封装 + 生命周期管理

运行时 CSS-in-JS 的灵活性是把双刃剑:

优势:

  • 极致的动态能力
  • 完美的样式隔离
  • 优秀的开发体验

代价:

  • 显著的运行时开销
  • 与 React 18 新特性的冲突
  • 难以优化的性能瓶颈

在 React 18 及未来的 React 版本中,运行时 CSS-in-JS 的性能问题会越来越明显。如果你的项目对性能有要求

@ant-design/cssinjs里面对于cssinjs的性能优化关键

  1. 缓存命中率 - 使用稳定的对象引用
  2. 引用计数 - 自动清理未使用的样式
  3. 批量操作 - 减少 DOM 操作次数
  4. WeakMap - 避免内存泄漏

但实际上,由于css in js 本身运行时存在的性能问题是无法被根治的,在antd6中,整个antd的样式切换为了0运行时的方案。

接下来可以更有针对性的学习@ant-design/cssinjs的源码

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。