likes
comments
collection
share

【译】与 CSS-in-JS say goodBye

作者站长头像
站长
· 阅读数 87

原文地址:dev.to/srmagura/wh…

#javascript #react #css #typescript

嗨,我是Sam——Spot的软件工程师,也是Emotion的第二个最活跃的维护者。Emotion,是一个广受欢迎的React CSS-in-JS库。本文将深入探讨我最初被CSS-in-JS吸引的原因以及团队的其他成员为什么决定转向其他方向。

首先概述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-components and Emotion 是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;
   }

当你完全忘记这个列表视图的时候,几个月后你又创建了另一个有行的组件。自然地,在这些元素上设置className="row"。现在新组件的行有了不美观的边框,你不知道为什么!虽然这种问题可以通过使用更长的类名或更具体的选择器来解决,但作为开发人员,你仍然需要确保没有类名冲突。

CSS-in-JS完全解决了这个问题,因为默认情况下使样式局部作用域。如果你将列表视图行写成:

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

CSS-in-JS会默认使样式本地作用域化,从而完全解决了这个问题。如果你使用CSS-in-JS将你的列表视图行写成:

CSS模块也提供本地作用域的样式。

2. 邻近性。 如果使用纯CSS,你可能会将所有.css文件放在src/styles目录中,而所有React组件都在src/components中。随着应用程序的规模增长,很快就难以确定每个组件使用的样式。通常情况下,你的CSS中会存在死代码,因为没有简单的方法来确定这些样式是否正在使用。

更好的代码组织方式是将与单个组件相关的所有内容放在同一位置。这种实践被称为 colocation,并在 Kent C. Dodds 的一篇优秀博客文章excellent blog post 中进行了介绍。

问题在于,使用普通的CSS时很难实现colocation,因为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常量(例如color)和React props / state(例如fontSize)。在样式中使用JavaScript常量的能力在某些情况下减少了重复,因为相同的常量不必同时定义为CSS变量和JavaScript常量。使用props和state的能力允许您创建具有高度可定制样式的组件,而不使用内联样式。 (当相同的样式应用于许多元素时,内联样式的性能不理想。)

中立性

1. 它是最热门的新技术。 许多网页开发者,包括我在内,很快就会采用 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 minzipped,而styled-components是12.7 kB。因此,这两个库都不是很大,但它们会累积起来。(与此相比,react + react-dom为44.5 kB。)

3. CSS-in-JS会使React DevTools杂乱无章。 对于每个使用css prop的元素,Emotion都会渲染<EmotionCssPropInternal><Insertion>组件。如果您在许多元素上使用css prop,则Emotion的内部组件可能会使React DevTools变得非常混乱,如下所示:

【译】与 CSS-in-JS  say goodBye

缺点

1. 频繁插入 CSS 规则会迫使浏览器做大量额外的工作。 Sebastian Markbåge,是React核心团队成员和React Hooks的原始设计者,在React 18工作组中撰写了一篇非常有启发性的讨论,extremely informative discussion其中讨论了CSS-in-JS库需要如何改变才能与React 18兼容以及运行时CSS-in-JS的未来。特别是他说:

在并发渲染中,React 可以在渲染之间让出控制权给浏览器。如果在组件中插入了一个新规则,那么 React 会让出控制权,然后浏览器会检查这些规则是否适用于现有的树。因此,它会重新计算样式规则。然后 React 渲染下一个组件,接着那个组件发现一个新规则,再次发生上述操作。 这实际上会导致在 React 渲染期间每帧重新计算所有 CSS 规则以适应所有 DOM 节点, 这非常慢。

2022年10月25日更新:Sebastian的这句话具体是指React并发模式下,没有使用useInsertionEffect时的性能问题。如果您想深入了解此问题,请阅读完整的讨论。感谢Dan Abramov在Twitter上指出这个错误

关于这个问题最糟糕的事情是,它在运行时 CSS-in-JS 的情况下无法解决。运行时 CSS-in-JS 库通过在组件渲染时插入新的样式规则来工作,这在根本上对性能是不利的。

2. 在使用CSS-in-JS时,出错的情况会更多,特别是在使用服务器端渲染或组件库时。 在Emotion的GitHub存储库中,我们收到很多这样的问题:

我正在使用 Emotion 进行服务器端渲染并使用 MUI/Mantine/(另一个 Emotion-powered 组件库),但它无法正常工作,因为...

尽管具体问题的根本原因各不相同,但是有一些共同的主题:

  • 同时加载了多个 Emotion 实例,即使这些实例都是相同版本的,也可能会引起问题。(Example issue)
  • 组件库通常无法完全控制样式插入的顺序。(Example issue)
  • Emotion在React 17和React 18之间的SSR支持工作方式不同。这是为了与React 18的流式SSR兼容而必要的。(Example issue)

而且相信我,这些复杂性的来源只是冰山一角。(如果你足够勇敢,可以看看Emotion文档的TypeScript definitions for @emotion/styled)部分。

深究性能

很明显,运行时 CSS-in-JS 技术既有显著的优点,也有突出的缺点。为了理解为什么我们的团队正在摒弃这项技术,我们需要探讨 CSS-in-JS 的实际性能影响。

本节重点关注 Emotion 在 Spot 代码库中的性能影响。因此,不能假设下面呈现的性能数据也适用于您的代码库,因为使用 Emotion 有许多方法,而每种方法都具有自己的性能特点。

在渲染过程中的序列化与在渲染之外的序列化

样式序列化指的是 Emotion 将你的 CSS 字符串或对象样式转换为纯 CSS 字符串的过程,以便将其插入到文档中。在序列化过程中,Emotion 还会计算纯 CSS 的哈希值,这个哈希值在生成的类名中是可以看到的。

虽然我没有进行具体的测试实验,但我认为影响 Emotion 性能的一个最重要因素是样式序列化的时机,可以是在 React 渲染周期内也可以是渲染之外进行的。

以下是一个在渲染过程中执行样式序列化的示例:

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的一个真实组件进行性能分析来具体说明该问题了。我们将使用成员浏览器(Member Browser),这是一个相当简单的列表视图,用于显示团队中的所有用户。几乎所有成员浏览器的样式都使用了Emotion,具体来说就是css prop【译】与 CSS-in-JS  say goodBye

该测试:

  • 成员浏览器展示20位使用者;
  • 围绕列表项的React.memo将被移除;
  • 我们将强制最顶层的组件每秒渲染一次,并记录前10次渲染的时间;
  • 关闭React严格模式。(它会使您在性能分析器中看到的渲染时间翻倍);

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

根据我的经验,一个React组件的渲染时间应该在16毫秒以内,因为每秒60帧的情况下,每帧的时间是16.67毫秒。目前,Member Browser组件的渲染时间超过这个数值的3倍,因此可以说它是一个相对较笨重的组件。

这个测试是在一台比普通用户使用的计算机性能要高得多的M1 Max CPU上进行的,我得到的54.3毫秒的渲染时间,如果在性能较低的机器上很可能会达到200毫秒以上。

火焰图分析

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

【译】与 CSS-in-JS  say goodBye

正如你所见,有大量的 <Box><Flex> 组件被渲染,它们是我们的“样式原语”,使用了 css 属性。虽然每个 <Box> 组件只需要 0.1 - 0.2 毫秒的渲染时间,但由于 <Box> 组件的总数非常多,这个时间也会逐渐累积。

不使用 Emotion,对成员浏览器进行基准测试

为了查看这个耗时渲染中有多少是由于 Emotion,我使用 Sass Modules 重写了 Member Browser 的样式,而不是使用 Emotion。(Sass Modules 在构建时被编译为普通的 CSS,因此几乎没有性能损失。)

我重复了上面描述的相同测试,并得到了平均首次渲染时间为 27.7 毫秒。相比与原始时间减少了48%!

因此,这就是我们放弃 CSS-in-JS 的原因:运行时的性能代价实在太高。

再次重申以上免责声明:这个结果仅直接适用于 Spot 代码库以及我们如何使用 Emotion。如果你的代码库在使用 Emotion 方面更具性能优势(例如,在渲染外部进行样式序列化),在从方程中移除 CSS-in-JS 后,你可能会看到更小的收益。

以下的一组数据或许可以为您答疑: 【译】与 CSS-in-JS  say goodBye

我们的一个新样式系统

在我们决定放弃 CSS-in-JS 后,一个现实问题就是:我们应该使用什么替代方案?理想情况下,我们希望拥有与纯 CSS 类似的性能,同时尽可能保留 CSS-in-JS 的许多优势。以下是我在“优点”一节中描述的 CSS-in-JS 的主要优势:

  • 让样式具有局部作用域;
  • 组件与所应用的样式绑定在一起;
  • 在样式中使用JavaScript变量;

如果你仔细关注了那一部分,你会记得我说过CSS模块还提供了局部作用域的样式和与js代码捆绑在一起的功能,而且CSS模块会编译成普通的CSS文件,因此使用它们不会有运行时性能损耗。

在我看来,CSS模块的主要缺点是,它们最终仍然是普通的CSS,而普通的CSS缺乏提高开发体验和减少代码重复的功能。尽管嵌套选择器正在被添加到CSS中,但它们尚未完全实现,而这个功能对我们来说是非常重要的,可以极大地改善开发体验。

幸运的是,这个问题有一个简单的解决方案——Sass模块(Sass Modules),它们只是用Sass编写的CSS模块。你既可以获得CSS模块的局部作用域样式,又可以享受Sass强大的构建时功能,几乎没有运行时的性能损耗。因此,Sass模块将成为我们未来通用样式的解决方案。

使用Sass模块时,你会失去CSS-in-JS的第三个优点(在样式中使用JavaScript变量的能力)。不过,你可以在Sass文件中使用:export块,将Sass代码中的常量导出给JavaScript使用。虽然这样做不够方便,但可以保持代码的DRY(不重复原则)。

实用类

团队对从Emotion切换到Sass模块的担忧之一是,应用非常常见的样式(例如display: flex)会变得不太方便。以前,我们会这样写:

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

如果只使用Sass模块来实现这个效果,我们需要打开.module.scss文件,并创建一个应用display: flexalign-items: center样式的类。虽然这并不是什么大问题,但确实不太方便。

为了改善这方面的开发体验,我们决定引入一个实用类系统。如果你对实用类不熟悉,它们是一种在元素上设置单个CSS属性的CSS类。通常情况下,你会组合多个实用类来获得所需的样式。对于上面的示例,你可以这样写:

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

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

我们已经使用Sass模块和实用类来开发新的组件已经有几个星期了,我们对此非常满意。开发体验与Emotion类似,并且运行时性能显著优越。

我们还使用了typed-scss-modules包来为我们的Sass模块生成TypeScript定义。其中最大的好处是它允许我们定义一个utils()辅助函数,类似于classnames,但它只接受有效的实用类名作为参数。

CSS-in-JS编译时的说明文档

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

这些库声称能够提供与运行时CSS-in-JS类似的优点,而不会有性能成本。

虽然我自己没有使用过任何编译时CSS-in-JS库,但我仍然认为与Sass模块相比,它们具有一些缺点。以下是我在查看Compiled时看到的缺点:

  • 在组件首次挂载时仍然会插入样式,这会强制浏览器在每个DOM节点上重新计算样式。这个缺点在标题为"The Ugly"的部分进行了讨论。
  • 这个示例中的color属性这样的动态样式无法在构建时提取,因此Compiled使用style属性(即内联样式)将值作为CSS变量添加。已知在应用于多个元素时,内联样式会导致性能下降。
  • 该库仍然会像这里显示的那样,在你的React树中插入样板组件。这会像运行时CSS-in-JS一样使React DevTools变得混乱。

结论

感谢阅读这篇深入探讨关于运行时 CSS-in-JS 的文章。像任何技术一样,它都有其优点和缺点。最终,作为开发者,你需要评估这些优点和缺点,然后根据你的项目做出明智的决策,判断这项技术是否适合你。对于我们来说,在 Spot,Emotion 的运行时性能代价远远超过了开发体验的好处,特别是考虑到 Sass Modules + utility classes 的替代方案在保持良好开发体验的同时,并且提供了更好的性能。

转载自:https://juejin.cn/post/7238519871138906173
评论
请登录