再见,CSS-in-JS

15,786 阅读14分钟

首发于公众号 前端从进阶到入院,欢迎关注。

大家好,我 ssh。在过去的开发中,我一直在用 styled-component 库作为 CSS 的解决方案。它有很多优点,灵活、可复用性强、功能强大、可以接受动态 JS 变量传入组件等等…… 但今天我看到一篇文章,说都是 Spot 团队的人已经决定抛弃 CSS-in-JS 的方案了,因为对他们来说,性能损耗已经远远超过其灵活性的优势了。接下来,我来和大家分享一下这篇Why We're Breaking Up with CSS-in-JS

嗨,我是 Sam,来自Spot的软件工程师,也是广泛使用的 React CSS-in-JS 库 Emotion 的第二活跃的维护者。本文将深入探讨最初吸引我使用 CSS-in-JS 的原因,以及我(和 Spot 团队其他成员)决定抛弃它的原因。

我们将简要概述 CSS-in-JS 以及它的优缺点。然后,我们将深入探讨 CSS-in-JS 在 Spot 带来的性能问题,以及如何避免这些问题。

什么是 CSS-in-JS

顾名思义,CSS-in-JS 让你可以在 JavaScript 或 TypeScript 代码中编写 CSS 来设置 React 组件的样式:

// @emotion/react (css prop), 使用对象样式
function ErrorMessage({ children }) {
  return (
    <div
      css={{
        color: "red",
        fontWeight: "bold",
      }}
    >
      {children}
    </div>
  );
}

// styled-components 或 @emotion/styled, 使用字符串样式
const ErrorMessage = styled.div`
  color: red;
  font-weight: bold;
`;

在 React 社区中,最流行的 CSS-in-JS 库是styled-componentsEmotion。虽然我只使用过 Emotion,但我认为本文中的观点几乎全部适用于 styled-components。

本文重点讨论运行时 CSS-in-JS,这一类别包括 styled-components 和 Emotion。运行时 CSS-in-JS 意味着库会在应用运行时解释和应用样式。文章最后我们简要讨论编译时 CSS-in-JS。

CSS-in-JS 的优劣势

在深入探讨特定的 CSS-in-JS 编码模式及其对性能的影响之前,让我们先抽象描述概述一下用这个技术的原因,以及可能有什么不足。

优势

  1. 局部作用域样式。使用 Pure CSS 时,容易让样式运用过于宽泛。例如,想为列表视图的每行设置 padding 和边框,可能会这样写 CSS:
.row {
  padding: 0.5rem;
  border: 1px solid #ddd;
}

几个月后,你完全忘记了这个列表视图,然后在另一个组件中创建了行元素,并设置className="row"。现在新的组件的行会出现意外的边框,但你不知道为什么!虽然可以通过更长的类名或更具体的选择器解决此类问题,但作为开发者你仍需确保没有类名冲突。

CSS-in-JS 完全解决了这个问题,默认情况下样式是局部作用域的。如果这样编写列表视图:

<div css={{ padding: "0.5rem", border: "1px solid #ddd" }}>...</div>

这样边框和 padding 就不可能意外应用到无关的元素上。

注意:CSS Modules 也提供局部作用域样式。

  1. 同位(Colocation):如果使用 Pure CSS,可能会把所有.css文件放在src/styles目录,而 React 组件放在src/components目录。随着应用规模增长,很难知道每个组件使用了哪些样式。由于没有简单的方式判断样式是否在使用,CSS 中常会残留未使用的死代码。

组织代码的更好方式是相关的组件代码放在一起。这种做法称为同位(Colocation),Kent C. Dodds 的这篇博文有过讨论。

问题是 Pure CSS 很难实现同位,因为 CSS 和 JavaScript 必须在不同文件,样式无论.css文件位于何处都会全局应用。不同是的,使用 CSS-in-JS 可以直接在使用样式的 React 组件中编写样式代码!如果用得好,会极大提升应用的可维护性。

注意:CSS Modules 也允许样式与组件同位,但不在同一文件中。

  1. 可以在样式中使用 JavaScript 变量。CSS-in-JS 使你可以在样式规则中引用 JavaScript 变量,例如:
// colors.ts
export const colors = {
  primary: "#0d6efd",
  border: "#ddd",
  /* ... */
};

// MyComponent.tsx
function MyComponent({ fontSize }) {
  return (
    <p
      css={{
        color: colors.primary,
        fontSize,
        border: `1px solid ${colors.border}`,
      }}
    >
      ...
    </p>
  );
}

如例所示,在 CSS-in-JS 样式中可以使用 JavaScript 常量(如colors)和 React 的 props/state(如fontSize)。能在样式中使用 JavaScript 常量在某些情况下可以减少重复代码,因为同一个常量不必在 CSS 变量和 JavaScript 常量中各定义一次。能使用 props 和 state 使你可以创建具有高度可定制样式的组件,而不必使用内联样式。(当相同样式应用在许多元素时,内联样式对性能不利。)

中立的方面

  1. 这是热门的新技术。包括我在内,许多 Web 开发者都急于采用 JavaScript 社区中的最新趋势。这在一定程度上合理,因为在许多情况下,新库和框架已经证明它们是前身(如 jQuery 等早期库)的重大改进。另一方面,我们迷恋闪亮新工具的另一个原因就是这个迷恋本身。我们担心错过下一个大潮流,在决定采用新库或框架时可能会忽略真正的缺点。我认为这确实是 CSS-in-JS 获得广泛采用的一个因素——至少对我来说是这样。

劣势

  1. CSS-in-JS 增加了运行时开销。当组件渲染时,CSS-in-JS 库必须将样式“序列化”为可以插入文档的 Pure CSS。显然这需要额外的 CPU 消耗,但这会对应用性能产生明显影响吗?我们将在下一节深入研究这个问题。

  2. CSS-in-JS 增加了包体积。这很明显——每个访问你网站的用户现在都需要下载 CSS-in-JS 库的 JavaScript。Emotion 是7.9 kB压缩后,styled-components 是12.7 kB。所以这两个库都不大,但加起来还是有影响。(相比之下,react + react-dom 是 44.5 kB)。

  3. CSS-in-JS 弄乱了 React 开发者工具。对于每个使用css prop 的元素,Emotion 会渲染<EmotionCssPropInternal><Insertion>组件。如果在许多元素上使用css prop,Emotion 的内部组件会让 React 开发者工具很乱,如图所示:

乱七八糟

劣势

  1. 频繁插入 CSS Rules 会使浏览器做很多额外工作。React 核心团队成员、Hook 设计者 Sebastian Markbåge 在 React 18 工作组的这篇非常有价值的讨论中说道:

在并发渲染中,React 可以在渲染之间让出线程给浏览器。如果你在一个组件中插入新的 CSS,然后 React 让出线程,浏览器必须检查这些 CSS 是否适用于现有的树。所以它重新计算样式规则。然后 React 渲染下一个组件,然后那个组件发现新 CSS,过程再次发生。

这在 React 渲染时的每帧中都引发 DOM 节点对于 CSS 规则的重新计算,非常昂贵

这段引用具体是指 React 并发模式下的性能,没有 useInsertionEffect的情况下。如果想深入理解,我推荐去看看完整的讨论。感谢 Dan Abramov 在 Twitter 上指出这一不准确之处。

这个问题是无解的,在运行时 CSS-in-JS 环境下它是无法修复的。运行时 CSS-in-JS 库的工作方式是组件渲染时插入新样式规则,这在根本上和性能是对立的。

  1. 用 CSS-in-JS,更容易出错,特别是在使用 SSR 和组件库时。在 Emotion 的 GitHub 仓库中,我们收到了大量这样的 issue:

我在使用 Emotion 时启用了服务器端渲染和 MUI/Mantine/(另一个基于 Emotion 的组件库),由于...,出现了问题。

具体缘由因 issue 而异,但有一些共同点:

  • 同时加载了多个 Emotion 实例。即使多个实例都是同一版本,也可能导致问题。(Example issue)
  • 组件库通常不让你完全控制样式的插入顺序。(Example issue)
  • Emotion 在 React 17 和 React 18 中的 SSR 支持不同。这对兼容 React 18 的流式 SSR 是必需的。 (Example issue)

相信我,这些复杂性只是冰山一角。(如果你是个勇者的话,来看看@emotion/styled的 TypeScript 定义。)

性能深度剖析

至此,明显 CSS-in-JS 既有重要优点,也有重要缺点。为了理解我们为何决定放弃这个技术,我们需要探究 CSS-in-JS 的实际性能影响。

这一部分着重 Emotion 在Spot 代码库中的性能影响。因此,下面给出的性能数字不一定也适用于你的代码库 —— 有许多用 Emotion 的方式,每种方式都有其自身的性能特点。

渲染内与渲染外的序列化

样式序列化是指 Emotion 将你的 CSS 字符串或对象样式转换为可以插入文档的 Pure CSS 字符串的过程。在序列化过程中 Emotion 也会计算 CSS 的哈希——这个哈希就是你在生成的类名中看到的部分,例如 css-15nl2r3

虽然我没有测算过,但我认为影响 Emotion 性能的一个最重要因素是样式序列化是在 React 渲染周期内部还是外部进行的。

Emotion 文档中的例子是在渲染内进行序列化,如:

function MyComponent() {
  return (
    <div
      css={{
        backgroundColor: "blue",
        width: 100,
        height: 100,
      }}
    />
  );
}

每次MyComponent渲染时,对象样式都会重新序列化。如果MyComponent渲染频繁(如每次键盘输入都渲染),重复序列化可能具有很高的性能成本。

一种更高效的方法是将样式移到组件外部,这样序列化只在模块加载时执行一次,而不是每次渲染时都执行。可以使用 @emotion/react 中的 css 函数实现:

const myCss = css({
  backgroundColor: "blue",
  width: 100,
  height: 100,
});

function MyComponent() {
  return <div css={myCss} />;
}

当然,这阻止了你在样式中访问 props,所以你失去了 CSS-in-JS 的一个主要卖点。

在 Spot,我们是在渲染中进行序列化的,所以下面的性能分析将着重这种情况。

评测成员列表组件

是时候通过分析 Spot 的一个真实组件,让问题具体化了。我们用成员列表这个组件来举例,这是一个相当简单的列表视图,显示你团队中的所有用户。成员列表的几乎所有样式都使用 Emotion,特别是css prop。

Spot中的成员列表组件

测试中:

  • 成员列表组件将显示 20 个用户
  • 去除列表项周围的React.memo
  • 每秒强制重新渲染最外层的<BrowseMembers>组件,并记录前 10 次渲染的时间
  • 关闭严格模式。(它会使 profiler 中的渲染时间翻倍。)

我使用 React 开发者工具进行了分析,前 10 次渲染的平均时间是54.3 毫秒

我的经验是 React 组件渲染时间应该在 16 毫秒或更短,因为在 60 帧每秒下,每帧是 16.67 毫秒。当前成员列表组件渲染时间是这个数字的 3 倍多,所以是一个非常“昂贵”的组件。

而这个测试是在M1 Max CPU 上进行的,远快于普通用户的设备。在较弱的机器上,54.3 毫秒的渲染时间可能轻松达到200 毫秒

分析火焰图

下面是上述测试中单个列表项的火焰图:

成员列表组件项目组件的性能火焰图

如你所见,有大量渲染的<Box><Flex>组件——这些是我们使用css prop 的“样式原语”。每个<Box>只需要 0.1-0.2 毫秒渲染时间,但因为<Box>组件总数巨大,加起来就是很大的开销。

不使用 Emotion 评测成员列表组件

为了不错怪 Emotion,我用 Sass 模块重写了成员列表组件的样式。(Sass 模块编译为 Pure CSS,几乎没有性能损失。)

我重复了相同的测试,前 10 次渲染的平均时间是27.7 毫秒,较原来下降了 48%!

所以,这就是我们决定与 CSS-in-JS“分手”的原因:运行时性能成本太高。

再重复一遍我之前的免责声明:这个结果仅直接适用于 Spot 代码库及我们使用 Emotion 的方式。如果你的代码库以更高效的方式使用 Emotion(例如渲染外序列化样式),切换掉 CSS-in-JS 带来的好处可能会小得多。

如果你感兴趣的话,这是原始数据:

表格显示Emotion和非Emotion成员列表组件的渲染时间

我们的新样式系统

决定抛弃 CSS-in-JS 后,显而易见的问题是:我们应该使用什么来替代呢?理想情况下,我们希望一个样式系统具有接近 Pure CSS 的性能,同时保留尽可能多的 CSS-in-JS 优点。我在“优点”部分提到的 CSS-in-JS 的主要好处是:

  1. 样式是局部作用域的
  2. 样式与组件同位
  3. 可以在样式中使用 JavaScript 变量

如果你细心的话,就会记得我说过 CSS Modules 也提供了局部作用域样式和同位。而且 CSS Modules 编译为 Pure CSS 文件,使用它们不会有运行时性能损失。

在我看来,CSS Modules 的主要缺点是,它毕竟是 Pure CSS —— 而 Pure CSS 缺少一些提升开发体验和减少代码重复的特性。尽管嵌套选择器即将推出,但是还没落地。这个特性对我们来说是质的提升。

幸运的是,Sass Modules 这个简单的方案可以解决这个问题 —— 它就是用Sass编写的 CSS Modules。你同时获得了 CSS Modules 的局部作用域样式和 Sass 强大的构建时特性,几乎没有运行时成本。Sass Modules 将是我们的通用样式解决方案。

注意:使用 Sass Modules,会失去 CSS-in-JS 的第 3 个好处(在样式中使用 JavaScript 变量)。不过,你可以在 Sass 文件中使用:export来把 Sass 代码中的常量暴露给 JavaScript。这不是那么方便,但可以保持 DRY 原则。

实用工具类

团队担心从 Emotion 切换到 Sass Modules 会使应用极其常见的样式(如display: flex)变得不方便。以前,我们会这样写:

<FlexH alignItems="center">...</FlexH>

用 Sass Modules,你需要打开.module.scss文件并创建一个应用display: flexalign-items: center样式的类。这不可避免,但肯定麻烦了一些。

为了改善这一点,我们决定引入实用工具类系统。实用工具类就是设置单个 CSS 属性的类。通常你会组合多个实用工具类来获得所需的样式。对于上面的例子,就可以这样写:

<div className="d-flex align-items-center">...</div>

BootstrapTailwind是最流行的提供实用工具类的 CSS 框架。这些库在实用工具系统设计上下了很大功夫,所以直接使用它们比自己实现要合理得多。我已经使用 Bootstrap 多年了,所以我们选择了 Bootstrap。我们需要定制这些类以适应现有的样式系统,所以我把 Bootstrap 源代码相关部分拷到了项目中。

我们已经在新组件中使用 Sass Modules 和实用工具类几周了,目前感觉非常满意。开发体验接近 Emotion,但运行时性能大大优于它。

注意:我们还使用了typed-scss-modules包为 Sass Modules 生成 TypeScript 定义。其中一个最大的好处是,它允许我们定义一个类似classnamesutils()辅助函数,不同之处在于它只接受有效的实用工具类名作为参数。

关于编译时 CSS-in-JS 的说明

本文重点讨论了运行时 CSS-in-JS 库如 Emotion 和 styled-components。近来,我们看到越来越多在编译时将样式转换为 Pure CSS 的 CSS-in-JS 库,包括:

这些库声称提供类似运行时 CSS-in-JS 的好处,而没有性能损失。

尽管我自己没有使用过任何编译时 CSS-in-JS 库,但我认为与 Sass Modules 相比它们仍有劣势。在看 Compiled 时,我注意到的劣势包括:

  • 组件首次挂载时样式仍被插入,这会强制浏览器对每个 DOM 节点重新计算样式。(这一劣势之前我们有讨论。)

  • 这个例子中的color prop 那样的动态样式无法在构建时提取,所以 Compiled 使用style prop(即内联样式)将值作为 CSS 变量添加。众所周知,内联样式在大量应用时性能不佳。

  • 这里所示,这个库仍在你的 React 树中插入一些样板组件。这和运行时 CSS-in-JS 一样,会搞乱 React 开发者工具。

总结

感谢阅读本文对运行时 CSS-in-JS 的深度剖析。任何技术都有其优缺点。最终,作为开发者你需要评估这些优缺点,判断该技术是否适合你的使用场景,然后做出决定。对我们在 Spot 中的开发来说,Emotion 的运行时性能成本远远超过了开发体验的提升,特别是考虑到 Sass Modules + 实用工具类的替代方案仍具有良好的开发体验,而提供了远超 Emotion 的性能。

image.png