这篇文章将深入探讨最初吸引我使用CSS-in-JS以及后来又决定放弃的原因。如果你对CSS-in-JS背后的逻辑感兴趣,建议耐心看完本文。
CSS-in-JS是什么?
顾名思义,CSS-in-JS允许你通过在JavaScript或TypeScript代码中直接编写CSS来设置React组件的样式:
styled-components[1]和Emotion[2]是React社区中最受欢迎的CSS-in-JS库。
本文重点介绍运行时CSS-in-JS,包括styled-components和Emotion。运行时CSS-in-JS仅表示库在应用程序运行时解析并应用样式。并将在本文末尾简要讨论编译时CSS-in-JS。
CSS-in-JS的优缺点
优点
1.可以限定样式应用的范围。 在编写纯CSS时,很容易不小心扩大样式的应用范围。例如,假设你正在创建一个列表视图,每行都需要有一些填充和边框。你可能会这样写CSS:
几个月后,你完全忘记了这个列表视图,又创建了另一个具有列表行的组件。当然,你可以在这些元素上设置className="row"。现在,新组件的列表行有一个难看的边框,你不知道为什么!虽然这种类型的问题可以通过使用更长的类名或更具体的选择器来解决,但作为开发人员,你需要确保没有类名冲突。
CSS-in-JS通过控制样式的默认应用范围来完全解决此问题。如果将列表视图行编写为:
那么,填充和边框就不可能意外地应用于不相关的元素。
注意:CSS模块也提供本地范围的样式。
2.集中放置。 如果使用普通CSS,那么你可以把所有.css文件放在src/styles目录,同时所有React组件放在src/components中。但是随着应用程序规模的增长,很快就会难以判断每个组件使用哪种样式。于是这样的CSS很多时候就会变成死代码,因为没有简单的方法可以判断这些样式有没有被使用过。
组织代码的更好方法是,将与单个组件相关的所有内容都放在同一位置。这种做法称为集中放置。
问题在于,这在使用纯CSS时很难实现,因为CSS和JavaScript必须放在单独的文件中,并且无论.css文件位于何处,都将全局应用样式。另一方面,如果使用的是CSS-in-JS,则可以直接在使用它们的React组件中编写样式!如果操作正确,这将大大提高应用程序的可维护性。
注意:CSS模块还允许将样式与组件一起放置,即使不在同一文件中。
3.可以在样式中使用脚本变量。 CSS-in-JS使你能够在样式规则中引用JavaScript变量,例如:
如本例所示,你可以在CSS-in-JS样式中使用JavaScript常量(例如colors)和React prop/state(例如fontSize)。在某些情况下,在样式中使用JavaScript常量的功能减少了重复代码,因为不必将同一常量既定义为CSS变量又定义为JavaScript常量。使用props和state的功能允许你创建具有高度可自定义样式的组件,而无需使用内联样式。(当相同的样式应用于许多元素时,内联样式对于性能并不友好。)
缺点
1.CSS-in-JS会增加运行时开销。当呈现组件时,CSS-in-JS库必须将样式“序列化”为可插入到文档中的纯CSS。很明显,这会占用额外的CPU开销。
2.CSS-in-JS会增加Bundle的大小。这是一个显而易见的问题——每个访问网站的用户都必须下载CSS-in-JS库。Emotion是7.9kB压缩包,而styled-components是12.7kB。虽然这两个库都不是很大,但加在一起就不是了(react + react dom是44.5kB)。
3.CSS-in-JS会扰乱React开发工具。对于使用css prop的每个元素,Emotion将呈现<EmotionCssPropInternal>和<Insertion>组件。如果你在许多元素上使用css prop,那么Emotion的内部组件确实会使React DevTools变得混乱,如下所示:
最可怕的几个地方
1.频繁插入CSS会迫使浏览器做很多额外的工作。在并发渲染中,React将会在渲染之间让步于浏览器。如果你在组件中插入一个新CSS规则,那么浏览器必须先查看这些CSS规则是否适用于现有的DOM树,因此会重新计算样式规则。之后React渲染下一个组件,该组件发现一个新规则,然后同样的情况再次发生。
这就导致了在React渲染时每帧针对所有DOM节点会重新计算所有CSS规则。
关于这个问题最糟糕的是,这不是一个可修复的问题(在运行时CSS-in-JS的上下文中)。运行时CSS-in-JS库通过在组件呈现时插入新的样式规则来工作,这对基本的性能是不利的。
2.使用CSS-in-JS可能会出现更多错误,尤其是在使用SSR或组件库时。在Emotion GitHub存储库中,存在的问题有:
- 一次加载多个Emotion实例。
- 组件库通常不能让你完全控制样式的插入顺序。
- Emotion的SSR支持在React 17和React 18之间的工作方式不同。这对于与React 18的流式服务端渲染兼容是必要的。
这些缺点还只是冰山一角。
深入探讨性能
很明显,运行时CSS-in-JS既有重要的优点,也有显著的缺点。为了说明为什么我选择远离这项技术,我们需要探索CSS-in-JS对实际性能的影响。
渲染内部 vs. 渲染外部的序列化
样式序列化是指Emotion获取CSS字符串或对象样式并将其转换为可插入到文档中的纯CSS字符串的过程。Emotion还会在序列化期间计算普通CSS的哈希值——此哈希值是你在生成的类名中所看到的内容,例如.css-15nl2r3。
Emotion文档在渲染中执行序列化的示例,如下所示:
每次渲染MyComponent时,都会再次序列化对象样式。如果MyComponent频繁渲染(例如,在每次击键时),那么重复的序列化可能会产生很高的性能开销。
提高性能的方法是将样式移到组件外部,以便在加载模块时进行一次序列化,而不是在每次渲染时都进行序列化。@emotion/react的css函数可以做到这一点:
当然,这会阻止你访问样式中的prop,因此会错过CSS-in-JS的主要优点之一。
在使用Emotion时,对Member浏览器进行基准测试
下面是一个关于Member浏览器的简单列表视图。几乎所有Member浏览器的样式都使用Emotion,特别是css prop。
在此次测试中:
Member浏览器将显示20个用户,
将删除列表项周围的React.memo,
强制最顶层<BrowseMembers>组件每秒渲染一次,并记录前10次渲染的时间。
关闭React严格模式。
使用React DevTools分析该页面,前10次渲染时间的平均值为54.3毫秒。
我个人的经验法则是,React组件的渲染时间应该为16毫秒或更短的时间,因为以每秒60帧的速度渲染,渲染1帧是16.67毫秒。Member浏览器目前是这个数字的3倍以上,所以是一个非常重量级的组件。
该测试是在M1 Max CPU上执行的,此CPU比普通用户拥有的CPU快得多。在功能较弱的计算机上,54.3毫秒的渲染时间甚至很容易达到200毫秒。
分析火焰图
以下是上述测试中单个列表项的火焰图:
正如你所看到的,有大量的<Box>和<Flex>组件正在渲染——这些都使用css prop。虽然每个<Box>组件只需要0.1–0.2毫秒的渲染时间,但因为<Box>组件的总量大,所以总的耗时将是巨大的。
在没有用Emotion时,对Member浏览器进行基准测试
为了了解这种昂贵的渲染代价有多少是由于Emotion造成的,我重写了Member浏览器样式,使用的是Sass模块而不是Emotion。(Sass模块在构建时编译为普通CSS,因此使用时几乎没有性能损失。)
重复与上述相同的测试,前10次渲染的平均值是27.7毫秒。比原来减少了48%!
所以,这就是我们与CSS-in-JS说再见的原因:运行时性能成本太高了。
免责声明:如果你的代码库以更高性能的方式使用Emotion(例如,在渲染之外进行样式序列化),那么移除CSS-in-JS后,看到的性能提升可能并不显著。
如果你对此测试感兴趣,以下是原始数据:
新的样式系统
在下定决心要摆脱CSS-in-JS之后,有一个问题马上摆在了我们面前:那么用什么来代替呢?理想情况下,我们想要一个性能类似于普通CSS的样式系统,同时尽可能多地保留CSS-in-JS的好处。也就是说最好具有:
可以控制样式的应用范围。
样式与应用样式的组件放在一起。
可以在样式中使用脚本变量。
前面我说过CSS模块也提供样式应用范围控制和集中放置的能力。CSS模块编译为普通的CSS文件,因此使用它们没有运行时性能成本。
但是,CSS模块的主要缺点是,归根结底,它们仍然是普通的CSS——而普通的CSS缺乏改善DX和减少代码重复的功能。
幸运的是,这个问题有一个简单的解决方案——Sass模块,用Sass编写的CSS模块。获得CSS模块的本地范围样式和Sass强大构建功能的同时,基本上没有运行时成本。这就是为什么Sass模块将成为我们未来的通用样式解决方案的原因。
实用程序类
我们团队对从Emotion切换到Sass模块的一个担忧是,应用常见样式,如display: flex会不太方便。以前:
如果仅使用Sass模块执行此操作,则必须打开.module.scss文件并创建一个应用样式display: flex和align-items: center的类。
为了改进这方面的DX,我们决定引入一个实用程序类系统。实用工具类是在元素上设置单个CSS属性的CSS类。通常组合多个实用程序类以获得所需的样式。上面的示例可以编写为:
Bootstrap和Tailwind是提供实用程序类的最流行的CSS框架。我已使用Bootstrap多年,所以选择Bootstrap。虽然可以将Bootstrap实用程序类作为预构建的CSS文件引入,但我们需要自定义类以适应现有的样式系统,因此我将Bootstrap源代码的相关部分复制到了项目中。
将Sass模块和实用程序类用于新组件,我已经使用几个星期了,感到非常满意。DX与Emotion相似,但运行时性能要优越得多。
附注:还可以使用typed-scss-modules[3]来为Sass模块生成类型脚本定义。这样做的最大好处是,允许我们定义一个像classnames[4]一样工作的utils()帮助函数。但是不方便的一点是,它只接受有效的实用程序类名作为参数。
关于编译时CSS-in-JS的说明
本文重点介绍运行时CSS-in-JS库,如Emotion和样式化组件。最近,我发现有越来越多的CSS-in-JS库在编译时将样式转换为纯CSS。包括:
- Compiled[5]
- Vanilla Extract[6]
- Linaria[7]
这些库旨在提供与运行时CSS-in-JS类似的优势,而不会降低性能成本。
虽然我自己没有使用过编译时CSS-in-JS库,但我仍然认为与Sass模块相比,它们有缺点。以下是我在查看编译时看到的缺点:
- 当组件首次挂载时,仍会插入样式,这会强制浏览器在每个DOM节点上重新计算样式。
- 动态样式(如示例中的color prop)无法在构建时提取,因此编译时使用style prop(也称为内联样式)将值作为CSS变量添加。已知内联样式在应用许多元素时会导致性能欠佳。
- 库仍会将样式组件插入到React树中。这将使React开发工具变得混乱,就像运行时CSS-in-JS一样。
总结
本文深入探讨了运行时CSS-in-JS的优点和缺点。作为开发人员,我们需要评估这些优缺点,然后就该技术是否适合使用案例做出明智的决定。对于我来说,Emotion的运行时性能成本远远超过了DX方面的好处,尤其是考虑到Sass模块+实用程序类的替代方案仍然具有良好的DX,同时也提供了非常卓越的性能。
参考资料
[1]styled-components: https://styled-components.com/
[2]Emotion: https://emotion.sh/
[3]typed-scss-modules: https://www.npmjs.com/package/typed-scss-modules
[4]classnames: https://www.npmjs.com/package/classnames
[5]Compiled: https://compiledcssinjs.com/
[6]Vanilla Extract: https://vanilla-extract.style/
[7]Linaria: https://linaria.dev/