为什么我们要放弃 CSS-in-JS

2025-05-27

为什么我们要放弃 CSS-in-JS

大家好,我是 Sam,Spot的软件工程师,也是Emotion (一个广受欢迎的 React CSS-in-JS 库)的第二活跃维护者。这篇文章将深入探讨最初吸引我使用 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), with object styles
function ErrorMessage({ children }) {
  return (
    <div
      css={{
        color: 'red',
        fontWeight: 'bold',
      }}
    >
      {children}
    </div>
  );
}

// styled-components or @emotion/styled, with string styles
const ErrorMessage = styled.div`
  color: red;
  font-weight: bold;
`;


Enter fullscreen mode Exit fullscreen mode

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

本文重点介绍运行时 CSS-in-JS,该类别包含 styled-components 和 Emotion。运行时 CSS-in-JS 简单来说,就是该库在应用程序运行时解释并应用你的样式。我们将在文章末尾简要讨论编译时 CSS-in-JS。

CSS-in-JS 的优点、缺点和不足

在我们深入探讨具体的 CSS-in-JS 编码模式及其对性能的影响之前,让我们先从高层次概述一下为什么您可能会选择采用该技术,以及为什么您可能不会采用该技术。

优点

1. 局部作用域样式。编写纯 CSS 时,很容易意外地将样式应用到超出预期的范围。例如,假设您正在创建一个列表视图,其中每行都应该有一些内边距和边框。您可能会编写如下 CSS:



   .row {
     padding: 0.5rem;
     border: 1px solid #ddd;
   }


Enter fullscreen mode Exit fullscreen mode

几个月后,当你完全忘记了列表视图的存在时,你创建了另一个包含行的组件。当然,你会className="row"在这些元素上设置。现在,新组件的行出现了难看的边框,而你却一无所知!虽然这类问题可以通过使用更长的类名或更具体的选择器来解决,但作为开发人员,你仍然有责任确保不存在类名冲突。

CSS-in-JS 通过将样式默认设置为本地作用域,彻底解决了这个问题。如果你将列表视图行写成



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


Enter fullscreen mode Exit fullscreen mode

填充和边框不可能意外地应用到不相关的元素上。

注意:CSS 模块还提供本地范围的样式。

2. 共置。如果使用纯 CSS,你可能会将所有.css文件放在一个src/styles目录中,而所有 React 组件则放在 中src/components。随着应用程序规模的增长,很快就会难以区分每个组件使用了哪些样式。很多时候,你的 CSS 中会有一些死代码,因为没有简单的方法可以判断这些样式是否被使用。

组织代码的更好方法是将与单个组件相关的所有内容放在同一个位置。这种做法称为“主机托管”, Kent C. Dodds 的一篇精彩博客文章对此进行了介绍。

问题在于,使用纯 CSS 时很难实现共置,因为 CSS 和 JavaScript 必须放在不同的文件中,而且无论.css文件位于何处,样式都会全局应用。另一方面,如果使用 CSS-in-JS,则可以直接在使用样式的 React 组件中编写样式!如果操作正确,这将大大提高应用程序的可维护性。

注意:CSS 模块还允许您将样式与组件放在一起,尽管不在同一个文件中。

3. 您可以在样式中使用 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>
  );
}


Enter fullscreen mode Exit fullscreen mode

如本例所示,您可以在 CSS-in-JS 样式中同时使用 JavaScript 常量(例如colors)和 React props / state(例如fontSize)。在某些情况下,在样式中使用 JavaScript 常量可以减少重复,因为同一个常量不必同时定义为 CSS 变量和 JavaScript 常量。使用 props 和 state 的功能允许您创建具有高度可自定义样式的组件,而无需使用内联样式。(当将相同的样式应用于多个元素时,内联样式对性能并不理想。)

中立者

1. 它是热门的新技术。包括我在内的许多 Web 开发者都会迅速拥抱 JavaScript 社区中最热门的新趋势。部分原因在于,很多情况下,新的库和框架已被证明比其前身有显著的改进(想想 React 相较于 jQuery 等早期库在生产力方面的提升有多大)。另一方面,我们对闪亮新工具的痴迷也仅仅出于一种痴迷。我们害怕错过下一个热门趋势,并且在决定采用新的库或框架时可能会忽略其真正的缺点。我认为这无疑是 CSS-in-JS 被广泛采用的一个因素——至少对我来说是这样。

1. CSS-in-JS 会增加运行时开销。当你的组件渲染时,CSS-in-JS 库必须将你的样式“序列化”为可插入文档的纯 CSS。显然,这会占用额外的 CPU 周期,但这是否足以对应用程序的性能产生明显的影响?我们将在下一节深入探讨这个问题。

2. CSS-in-JS 会增加你的打包体积。这一点显而易见——每个访问你网站的用户现在都必须下载 CSS-in-JS 库的 JavaScript 代码。Emotion 压缩后大小为7.9 kB,styled-components 为12.7 kB。所以这两个库都不算大,但加起来就有点大了。(相比之下, react+库的大小为 44.5 kB。)react-dom

3. CSS-in-JS 会让 React DevTools 变得混乱。对于每个使用该 prop 的元素css,Emotion 都会渲染<EmotionCssPropInternal>相应的<Insertion>组件。如果你在多个元素上都使用了该cssprop,Emotion 的内部组件会严重影响 React DevTools 的运行,如下所示:

React DevTools 显示了许多内部的 Emotion 组件

丑陋

1. 频繁插入 CSS 规则会迫使浏览器做很多额外的工作。React 核心团队成员、React Hooks 的原设计者Sebastian Markbåge在 React 18 工作组中撰写了一篇极具启发性的讨论,探讨了 CSS-in-JS 库需要如何修改才能与 React 18 兼容,以及运行时 CSS-in-JS 的未来发展方向。他特别指出:

在并发渲染中,React 可以在渲染之间向浏览器让步。如果你在组件中插入一条新规则,React 会让步,然后浏览器必须检查这些规则是否适用于现有树。因此,它会重新计算样式规则。然后,React 渲染下一个组件,该组件会发现一条新规则,如此反复。

这实际上会导致 React 渲染时每帧都会重新计算所有 DOM 节点的 CSS 规则。这非常慢。

更新于 2022-10-25: Sebastian 的这段引言特指 React 并发模式下的性能,无需 useInsertionEffect。如果您想深入了解这一点,我建议您阅读完整的讨论。感谢 Dan Abramov在 Twitter 上指出了这一错误。

这个问题最糟糕的地方在于它无法修复(在运行时 CSS-in-JS 的环境下)。运行时 CSS-in-JS 库的工作原理是在组件渲染时插入新的样式规则,这从根本上损害了性能。

2. 使用 CSS-in-JS 时,可能会出现很多问题,尤其是在使用 SSR 和/或组件库时。在 Emotion 的 GitHub 仓库中,我们收到了大量类似这样的问题:

我正在使用带有服务器端渲染和 MUI/Mantine/(另一个由 Emotion 驱动的组件库)的 Emotion,但它不起作用,因为......

虽然各个问题的根本原因各不相同,但也存在一些共同点:

  • 多个 Emotion 实例会同时加载。即使多个实例都是同一版本的 Emotion,这也可能造成问题。(示例问题)
  • 组件库通常无法完全控制样式的插入顺序。(示例问题)
  • Emotion 的 SSR 支持在 React 17 和 React 18 中有所不同。这是为了兼容 React 18 的流式 SSR。(示例问题)

相信我,这些复杂性的来源只是冰山一角。(如果你够大胆,可以看看TypeScript 的定义@emotion/styled。)

深入探究性能

至此,运行时 CSS-in-JS 的优点和缺点显而易见。为了理解我们团队放弃这项技术的原因,我们需要探究 CSS-in-JS 在实际应用中的性能影响。

本节重点介绍 Emotion在 Spot 代码库中的性能影响。因此,假设下面列出的性能数据也适用于您的代码库是错误的——Emotion 的使用方式多种多样,每种方式都有其自身的性能特征。

渲染内部序列化 vs. 渲染外部序列化

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

虽然我没有测量过这一点,但我相信 Emotion 表现如何的最重要因素之一是样式序列化是在 React 渲染周期内部还是外部执行。

Emotion 文档中的示例在渲染过程中执行序列化,如下所示:



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


Enter fullscreen mode Exit fullscreen mode

每次MyComponent渲染时,对象样式都会重新序列化。如果MyComponent渲染频率较高(例如每次按键),重复序列化可能会带来较高的性能成本。

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



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

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


Enter fullscreen mode Exit fullscreen mode

当然,这会阻止您访问样式中的道具,因此您错过了 CSS-in-JS 的主要卖点之一。

在 Spot 中,我们在渲染过程中进行了样式序列化,因此下面的性能分析将重点关注这种情况。

对会员浏览器进行基准测试

现在终于到了通过分析 Spot 中一个真实的组件来具体化事情的时候了。我们将使用成员浏览器,这是一个相当简单的列表视图,可以显示团队中的所有用户。几乎所有成员浏览器的样式都使用了 Emotion,尤其是cssprop。

Spot 中的会员浏览器

为了测试,

  • 会员浏览器将显示 20 位用户,
  • React.memo列表项周围的内容将被删除,并且
  • 我们将强制最顶层的<BrowseMembers>组件每秒进行渲染,并记录前 10 次渲染的时间。
  • React 严格模式已关闭。(它实际上会使性能分析器中显示的渲染时间加倍。)

我使用 React DevTools 对页面进行了分析,得到前 10 次渲染时间的平均值是54.3 毫秒。

我个人的经验法则是,React 组件的渲染时间应该在 16 毫秒或更短,因为 60 帧/秒的帧率下,渲染 1 帧需要 16.67 毫秒。会员浏览器目前的渲染时间是这个数字的 3 倍多,所以它是一个相当重量级的组件。

这项测试是在M1 Max CPU 上进行的,它的速度远超普通用户的 CPU 性能。我得到的渲染时间为 54.3 毫秒,在性能较弱的机器上,渲染时间很容易就达到200 毫秒。

分析火焰图

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

BrowseMembersItem 组件的性能火焰图

如你所见,有大量的<Box><Flex>组件正在被渲染——这些是使用cssprop 的“样式基元”。虽然每个组件的<Box>渲染时间仅为 0.1 到 0.2 毫秒,但由于组件总数<Box>巨大,因此渲染时间加起来相当可观。

不带任何情感地对会员浏览器进行基准测试

为了了解 Emotion 在渲染开销方面占比多少,我使用 Sass Modules 重写了会员浏览器的样式,而不是 Emotion。(Sass Modules 在构建时会被编译成纯 CSS,因此使用它们几乎不会对性能造成任何影响。)

我重复了上述测试,前 10 次渲染的平均时间为27.7 毫秒。比原始时间减少了 48% !

所以,这就是我们放弃 CSS-in-JS 的原因:运行时性能成本实在太高了。

重申一下我上面的免责声明:此结果仅直接适用于 Spot 代码库以及我们使用 Emotion 的方式。如果您的代码库以更高性能的方式使用了 Emotion(例如,在渲染之外进行样式序列化),那么在移除 CSS-in-JS 后,您可能会看到更小的收益。

对于那些好奇的人来说,这是原始数据:

电子表格显示了 Emotion 和非 Emotion 会员浏览器之间的渲染时间

我们的新造型系统

在我们决定放弃 CSS-in-JS 之后,显而易见的问题是:我们应该使用什么来代替?理想情况下,我们希望一个样式系统的性能与纯 CSS 相似,同时尽可能保留 CSS-in-JS 的优势。以下是我在“优点”部分中描述的 CSS-in-JS 的主要优点:

  1. 样式是局部范围的。
  2. 样式与它们所应用的组件位于同一位置。
  3. 您可以在样式中使用 JavaScript 变量。

如果你仔细阅读了那部分内容,你就会记得我说过 CSS 模块也提供本地作用域样式和共置功能。而且 CSS 模块会编译为纯 CSS 文件,因此使用它们不会造成运行时性能损失。

我认为 CSS 模块的主要缺点是,归根结底,它们仍然是纯 CSS——而纯 CSS 缺少改进 DX 和减少代码重复的功能。虽然嵌套选择器即将加入 CSS,但目前还没有,而这个功能对我们来说是一个巨大的生活质量提升。

幸运的是,这个问题有一个简单的解决方案——Sass 模块,它就是用Sass编写的 CSS 模块。您可以获得 CSS 模块的局部作用域样式以及 Sass 强大的构建时功能,而且几乎不产生任何运行时成本。这就是为什么 Sass 模块将成为我们未来通用的样式解决方案。

附注:使用 Sass 模块,您将失去 CSS-in-JS 的优势 3(在样式中使用 JavaScript 变量的能力)。不过,您可以:export在 Sass 文件中使用块,使 Sass 代码中的常量可供 JavaScript 使用。虽然不太方便,但可以保持 DRY 原则。

实用程序类

团队担心从 Emotion 切换到 Sass Modules 时,应用一些非常常见的样式会变得不太方便,比如display: flex。之前,我们会这样写:



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


Enter fullscreen mode Exit fullscreen mode

要仅使用 Sass 模块来实现这一点,我们必须打开.module.scss文件并创建一个应用样式display: flex和的类align-items: center。这不是世界末日,但肯定不太方便。

为了改进 DX,我们决定引入一个实用类系统。如果您不熟悉实用类,它们是在元素上设置单个 CSS 属性的 CSS 类。通常,您需要组合多个实用类来获得所需的样式。对于上面的例子,您可以这样编写:



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


Enter fullscreen mode Exit fullscreen mode

BootstrapTailwind是最流行的 CSS 框架,它们都提供了实用类。这些库在其实用类系统的设计上投入了大量精力,因此采用其中一个比自行开发更合理。我已经使用 Bootstrap 多年,所以我们选择了 Bootstrap。虽然 Bootstrap 实用类可以作为预构建的 CSS 文件引入,但我们需要自定义这些类以适应我们现有的样式系统,因此我将 Bootstrap 源代码的相关部分复制到了我们的项目中。

我们已经在新组件中使用 Sass 模块和实用类好几周了,效果非常满意。DX 与 Emotion 类似,但运行时性能要好得多。

附注:我们还使用了typed-scss-modules包来为我们的 Sass 模块生成 TypeScript 定义。这样做最大的好处或许在于,它允许我们定义一个utils()辅助函数,其工作方式与classnames类似,只不过它只接受有效的工具类名作为参数。

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

本文重点介绍了 Emotion 和 styled-components 等运行时 CSS-in-JS 库。最近,我们看到越来越多的 CSS-in-JS 库可以在编译时将样式转换为纯 CSS。这些库包括:

这些库旨在提供与运行时 CSS-in-JS 类似的好处,但不会增加性能成本。

虽然我自己没有使用过任何编译时 CSS-in-JS 库,但我仍然认为它们与 Sass Modules 相比存在一些缺点。以下是我在研究 Compiled 库时发现的一些缺点:

  • 组件首次挂载时仍会插入样式,这会强制浏览器在每个 DOM 节点上重新计算样式。(此缺点已在“缺点”一节中讨论过。)
  • 像本例color中的 prop这样的动态样式无法在构建时提取,因此 Compiled 会使用 prop(又称内联样式)将值添加为 CSS 变量。众所周知,内联样式在应用于许多元素时会导致性能不佳。style
  • 该库仍然会将样板组件插入到你的 React 树中,如下所示。这会像运行时 CSS-in-JS 一样,使 React DevTools 变得混乱。

结论

感谢您阅读这篇关于运行时 CSS-in-JS 的深入探讨。与任何技术一样,它也有其优缺点。最终,作为开发者,您需要评估这些优缺点,然后做出明智的决定,确定该技术是否适合您的用例。对于 Spot 团队来说,Emotion 的运行时性能成本远远超过了 DX 带来的好处,尤其是考虑到 Sass 模块 + 实用程序类这样的替代方案仍然拥有良好的 DX,同时提供了显著优越的性能。

关于 Spot

Spot,我们正在构建远程办公的未来。当公司选择远程办公时,他们往往会失去办公室里原有的联系感和文化氛围。Spot 是一个新一代沟通平台,它结合了传统的消息传递和视频会议功能,并支持您创建和定制专属的 3D 虚拟办公室,将您的团队凝聚在一起。如果您感兴趣,欢迎关注我们!

附言:我们正在寻找优秀的软件工程师加入团队!详情请见此处。

Spot 的图片

这篇文章也发表在 Spot 博客上

文章来源:https://dev.to/srmagura/why-were-writing-up-wiht-css-in-js-4g9b
PREV
如何使用 Redis 实现分布式锁
NEXT
完美的黑暗模式