我是Sam,Spot的一位软件工程师,也是Emotion库第二活跃的维护者。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;
`;

styled-componentsEmotion是React社区中最受欢迎的CSS-in-JS库。虽然我只使用了 Emotion,但我相信本文中的几乎所有要点也适用于styled-components。

本文将主要聚焦于运行时CSS-in-JS,这同时包含了styled-components和Emotion。运行时CSS-in-JS简单来说就是这个库会在应用运行的时候来解释和应用你的样式。我们将会在文章末尾简要讨论编译时CSS-in-JS。

好的、坏的及CSS-in-JS的丑陋

好的部分

1. 局部作用域样式。在通常写普通CSS时,很容易不小心意外地扩大样式的作用范围。比如,当你在做一个列表时,希望每一行都有一个边距和一个边框。你可能会这样写CSS:

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

几个月之后,你已经完全忘记了这个列表,你又另外创建了一个组件,这个组件也有一行一行的视图。自然而然地,你给这些元素设置了className="row"。现在这个新组建里面的每一行都有了不好看的边距,而且你还不知道为什么!虽然这种问题可以用更长的类名或者更精确的选择器来解决,但是这仍然要由开发人员来保证没有类名冲突。

CSS-in-JS通过默认样式为局部作用域完全解决了这个问题。如果你这样来写你的列表的话:

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

边距和边框不可能意外地用于不相关的元素。

备注:CSS Modules 同样也可以实现局部作用域样式。

2. 集中管理。如果你使用普通CSS,你可能会把所有的.css文件都放在src/styles目录下,而你所有的React组件都在src/components下面。当应用的规模扩大时,就很难区分哪个样式表是哪个组件在使用的了。通常这个时候,你的CSS代码里面就会有很多无用的代码,因为你也没办法区分哪些代码是没有被使用的。

一种更好的组织代码的方式是,把一个组件相关的所有内容都放在同一个地方。这种做法被称为集中管理,在Kent C. Dodds的一篇优秀的博文中有介绍。

问题在于使用普通CSS时很难实现集中管理,因为CSS和JavaScript必须放在单独的文件当中,而不管你的.css文件放在哪里,你的样式又会被全局使用。另一方面,如果你使用了CSS-in-JS,你可以直接在React组件中写相关的样式。如果操作正确,这将大大地提高你的应用的可维护性。

备注:CSS Modules 也允许你将样式与组件集中管理,即使他们不在同一文件中。

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>
  );
}

正如这个例子所示,你可以在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库。Emotion在压缩后有7.9KB,styled-components有12.7KB。虽然这两个库都不是很大,但是当库多以后加起来就大了。(作为对比reactreact-dom加起来是44.5KB。)

3. CSS-in-JS使React DevTools更加混乱。对于每一个使用了css属性的元素来说,Emotion都会渲染<EmotionCssPropInternal><Insertion>两个组件。如果你在很多元素上都使用了css,Emotion的内部组件会让React DevTools变得非常混乱,就像下面的这个:

丑陋的部分

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更新:这一段引用特指在React并发模式下的性能,没有使用useInsertionEffect。如果你想深入了解这些内容,建议你完整地阅读完这篇讨论。感谢Dan Abramov在Twitter上指出这一不准确之处。

最坏的是这个问题不是一个可以修复的问题(如果使用运行时CSS-in-JS)。运行时CSS-in-JS库在组件渲染时插入新的样式规则,这从底层来说对性能是不好的。

2. 使用CSS-in-JS会导致非常多的错误,尤其是使用SSR或者组件库。在Emotion的GitHub仓库中,我们收到了大量的这类问题:

我们在SSR和MUI/Mantine/(另一个Emotion驱动的组件库)中使用Emotion,但是有一些问题,因为……

虽然他们的根本原因对于每个问题来说都不相同,但是他们都有一些共同点:

  • 一次加载多个Emotion实例。即使这多个Emotion实例都是同一个版本,但仍然能导致问题。(示例问题
  • 组件库通常没法让你控制插入样式的顺序。(示例问题
  • Emotion的SSR支持在React 17和React 18中表现不一致。必须与React 18的流式SSR兼容。(示例问题

相信我,这些复杂性的来源都只是冰山一角。(如果你觉得自己很勇敢,可以看一下@emotion/styled的TypeScript定义。)

性能深入探讨

在这一点上很明确,运行时CSS-in-JS基友明显的优点,也有明显的缺点。要理解为什么我们团队放弃了这个技术,我们需要探索一下CSS-in-JS对性能的真实影响。

本节将重点介绍Emotion的性能影响,因为它在Spot代码库中使用。因此,对于你的代码库来说,下面的性能数据也许是不一样的——因为有很多种使用Emotion的方法,而没一种都有自己的性能特性。

在渲染内部序列化与在渲染外部序列化

样式序列化是指Emotion获取CSS字符串或对象然后把它们转化为可以插入文档的普通CSS的过程。Emotion在序列化过程中也会同时计算普通CSS的hash——这个hash就是在生成的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,我们在渲染中执行样式序列化,因此下面的性能分析都将聚焦这一情况。

对Member Browser组件进行基准测试

终于到了分析来自Spot的真实组件的时候了。我们将使用Member Browser这个组件,这是一个非常简单的列表,列出了团队中的所有成员。几乎Member Browser所有的样式都使用了Emotion,特别是css属性。

在测试中:

  • Member Browser将展示20个用户,
  • 项目列表相关的React.memo会被删除,
  • 我们会强制最顶层的<BrowseMembers>组件每秒渲染一次,并记录前10次渲染的时间。
  • 关闭React Strict Mode。(它有效地使你在性能分析器中看到的渲染时间翻倍。)

我使用React DevTools分析了页面,前10次渲染时间的平均时间为54.3毫秒

我个人认为一个React组件合理的渲染时间应该在16毫秒或者更短,因为每秒60帧时1帧的时间是16.67毫秒。Member Browser组件目前是这个数字的3倍,因此它是一个相当重量级的组件。

这个测试是在M1 MaxCPU上执行的,这个CPU速度远远超过平均用户的水准。这个54.3毫秒的渲染时间在其他弱一点的机器上很容易就达到200毫秒。

对火焰图进行分析

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

正如你所见,有大量的<Box><Flex>组件被渲染了——这些就是我们的css属性的基本体。虽然每个<Box>只花费了0.1-0.2毫秒渲染,由于<Box>组件数量庞大,导致总时间非常长。

不使用Emotion,对Member Browser组件进行基准测试

为了了解有多少渲染开销是Emotion造成的,我用Sass Modules重写了Member Browser组件的样式。(Sass Modules会在构建的时候编译为普通CSS,因此使用它们不会造成性能影响。)

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

所以,这就是我们放弃CSS-in-JS的原因:运行时性能开销太大了。

重申一遍上面的免责声明:这个结果只直接适用于Spot的代码库以及我们使用Emotion的方式。如果你的代码库以更高效的方式使用Emotion(比如在渲染之外序列化样式),你可能在移除了CSS-in-JS之后也只能获得较小的性能提升。

如果有人对数据感兴趣的话,这是原始数据:

我们新的样式系统

在我们下定决心放弃CSS-in-JS之后,显而易见的问题是:我们应该用什么来替代它?理想情况下,我们想要一个样式系统,其性能与普通CSS接近,同时尽可能地能保留CSS-in-JS的优点。下面是我在“好的部分”中描述的CSS-in-JS的主要好处:

  1. 样式是局部作用的。
  2. 样式与使用他们的组件集中管理。
  3. 你可以在样式中使用JavaScript变量。

如果你对那一节仔细关注过的话,你应该还记得我说过CSS Modules也提供了局部作用域和集中管理。CSS Modules编译成普通CSS文件,因此使用它们也不会有运行时性能开销。

在我看来,使用CSS Module的主要缺点,归根结底,它仍然是普通CSS——普通CSS缺乏改进开发体验及减少代码重复的功能。虽然嵌套选择器即将进入CSS,但它们还没有到来,这个特性对我们的生活质量来说是一个巨大的提升。

幸运的是,这个问题有一个简单的解决办法——Sass Modules,就是用Sass写的CSS Modules。你可以在得到局部作用域的CSS Modules以及Sass的强大构建时特性,而不会有任何运行时成本。这就是为什么Sass Modules会成为我们未来通用的样式解决方案。

旁注:使用Sass Modules,你将时区CSS-in-JS的好处3(在样式中使用JavaScript变量的能力)。不过,你可以通过在Sass文件中使用:export来导出常量给JavaScript。这不是为了方便,而是为了保持DRY。

工具类

从Emotion切换到Sass Modules,团队担心的一个问题是在使用公共样式时会没有那么方便,比如display: flex。在以前,我们可能这样写:

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

要仅仅使用Sass Modules,我们需要打开.module.scss文件,然后创建一个类包含样式display: flexalign-items: center。这不是世界末日,但确实不那么方便。

为了改进这个开发体验,我们决定引入一个工具类系统。如果你对这个工具类不是很熟悉,它们就是设置在元素上的CSS类,只不过这个类只包含了单个CSS属性。通常,你可能需要联合使用多个工具类来获得想要的样式。对于上面的例子,你可能需要这样写:

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

BootstrapTailwind是最流行的提供工具类的CSS框架。这些库在他们的工具系统中都投入了大量的设计工作,因此采用其中一个而不是我们自己重新设计个是最合理的。我已经使用过Bootstrap很多年了,所以我们选择了Bootstrap。虽然你可以讲Bootstrap工具类作为与构建的CSS文件,但我们需要定制这些类去适应我们的样式系统,因此我复制了Bootstrap中相关的部分代码到我们的项目中。

我们已经在新组件中使用Sass Modules和工具类几个星期了,目前还是很满意。开发体验接近于Emotion,运行时性能也非常出色。

旁注:我们还使用了typed-scss-modules包来为我们的Sass Modules生成TypeScript定义。可能这么做的最大好处是他可以允许我们定义一个类似于classnamesutils()帮助函数,除了它只接受有效的工具类名作为参数。

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

这篇文章主要关注运行时CSS-in-JS库,如Emotion和styled-components。最近,我们看到有越来越多的CSS-in-JS库在编译时将样式转换为普通CSS。其中包括:

这些库旨在提供与运行时CSS-in-JS类似的优点,但是不会降低性能。

虽然我自己没有使用过任何编译时CSS-in-JS库,我仍然认为与Sass Modules相比,他们有一些缺点。这些是我在看Compiled这个库时看到的:

  • 当首次加载组件时,仍然会插入样式,这会强制浏览器去重新计算所有DOM节点的样式。(这个缺点在“丑陋的部分”中有讨论。)
  • 这个示例中,动态样式比如color属性不能在构建时提取,因此Compiled通过使用style属性添加了一个CSS变量(就是内联样式)。内联样式在应用到许多元素时会导致性能欠佳。
  • 这里显示这个库仍然在React树中插入了样板组件。这会和CSS-in-JS一样让React DevTools变得混乱。

结论

感谢您阅读本文,深入了解运行时CSS-in-JS。像其他技术一样,它有其自身的优点和缺点。最终,作为开发人员,你需要评估这些优缺点,然后就该技术是否适合你的案例做出明智的决定。对于我们Spot来说,Emotion带来的运行时开销远超过它带来的开发体验,尤其是考虑到Sass Module这个替代方案仍然有很好的开发体验同时提供非常出色的性能时。

原文链接:Why We’re Breaking Up with CSS-in-JS – DEV Community 👩‍💻👨‍💻

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.