深入分析 CSS-in-JS

Avatar of Andrei Pfeiffer
Andrei Pfeiffer

DigitalOcean 为您的旅程每个阶段提供云产品。 立即开始使用 200 美元的免费积分!

想知道比选择 JavaScript 框架更具挑战性的是什么吗? 您猜对了:选择 CSS-in-JS 解决方案。 为什么? 因为有超过 50 个库 出现在那里,每个库都提供了一套独特的特性。

我们测试了 10 个不同的库 ,它们按以下顺序列出: Styled JSX styled-components Emotion Treat TypeStyle Fela Stitches JSS GooberCompiled。 我们发现,虽然每个库都提供了一套不同的特性,但这些特性中的许多实际上是大多数其他库之间共享的。

因此,我们不会单独回顾每个库,而是会分析最突出的特性。 这将帮助我们更好地了解哪一个最适合特定的用例。

注意:我们假设如果您在这里,您已经熟悉 CSS-in-JS。 如果您正在寻找更基础的文章,可以查看 “CSS-in-JS 简介”。

常见的 CSS-in-JS 特性

大多数积极维护的处理 CSS-in-JS 的库都支持以下所有功能,因此我们可以将其视为事实上的标准。

作用域 CSS

所有 CSS-in-JS 库都生成唯一的 CSS 类名,这是一种由 CSS 模块 首创的技术。 所有样式都作用域到它们各自的组件,提供封装而不影响组件外部定义的任何样式。

使用此内置功能,我们不必担心 CSS 类名冲突、特异性战争或浪费时间在整个代码库中创建唯一类名。

此功能对于基于组件的开发非常宝贵。

SSR(服务器端渲染)

当考虑单页应用程序(SPA)时,HTTP 服务器只提供一个初始的空 HTML 页面,所有渲染都在浏览器中执行,服务器端渲染(SSR)可能不是很有用。 但是,任何需要被搜索引擎解析和索引的网站或应用程序都必须具有 SSR 页面,并且样式也需要在服务器上生成。

相同的原则也适用于静态网站生成器(SSG),其中页面以及任何 CSS 代码在构建时预先生成为静态 HTML 文件。

好消息是我们测试的所有库都支持 SSR,使它们基本适用于任何类型的项目。

自动供应商前缀

由于复杂的 CSS 标准化过程,任何新的 CSS 功能可能需要几年时间才能在所有流行的浏览器中使用。 一种旨在提供对实验性功能的早期访问的方法是在供应商前缀下发布非标准 CSS 语法。 供应商前缀

/* WebKit browsers: Chrome, Safari, most iOS browsers, etc */
-webkit-transition: all 1s ease;

/* Firefox */
-moz-transition: all 1s ease;

/* Internet Explorer and Microsoft Edge */
-ms-transition: all 1s ease;

/* old pre-WebKit versions of Opera */
-o-transition: all 1s ease;

/* standard */
transition: all 1s ease; 

但是,事实证明,供应商前缀是有问题的,CSS 工作组打算将来停止使用它们。 如果我们希望完全支持不实现标准规范的旧浏览器,我们需要了解 哪些功能需要供应商前缀

幸运的是,有一些工具允许我们在源代码中使用标准语法,自动生成所需的供应商前缀 CSS 属性。 所有 CSS-in-JS 库也开箱即用地提供此功能。

无内联样式

有一些 CSS-in-JS 库,比如 Radium 或 Glamor,将所有样式定义输出为内联样式。 这种技术有一个很大的局限性,因为无法使用内联样式定义伪类、伪元素或媒体查询。 因此,这些库不得不通过添加 DOM 事件监听器并从 JavaScript 触发样式更新来破解这些功能,本质上是重新发明了原生 CSS 功能,比如 :hover:focus 等等。

通常也认为内联样式比类名 性能较低。 它通常是 不建议的做法,将它们用作样式化组件的主要方法。

所有当前的 CSS-in-JS 库都已不再使用内联样式,而是采用 CSS 类名来应用样式定义。

完整 CSS 支持

使用 CSS 类而不是内联样式的结果是,我们对可以使用和不能使用的 CSS 属性 没有限制。 在我们的分析过程中,我们特别感兴趣的是

  • 伪类和元素;
  • 媒体查询;
  • 关键帧动画。

我们分析的所有库都提供对所有 CSS 属性的完全支持。

差异化功能

这里更有趣的是。 几乎每个库都提供了一套独特的特性,这些特性会极大地影响我们选择特定项目所需解决方案时的决定。 一些库开创了特定的功能,而另一些则选择借用甚至改进某些功能。

React 特定或框架无关?

CSS-in-JS 在 React 生态系统中更为普遍,这已经不是秘密了。 这就是为什么一些库是专门为 React 构建的Styled JSXstyled-componentsStitches

但是,还有很多库是框架无关的,使其适用于任何项目:EmotionTreatTypeStyleFelaJSSGoober

如果我们需要支持除 React 之外的原生 JavaScript 代码或框架,那么决策很简单:我们应该选择一个框架无关的库。 但是,在处理 React 应用程序时,我们有更多选择,这最终会使决策更加困难。 因此,让我们探索其他标准。

样式/组件共存

在组件旁边定义样式的能力是一个非常方便的功能,它消除了在两个不同文件之间来回切换的需要:包含样式的 .css.less/.scss 文件以及包含标记和行为的组件文件。

React Native 样式表Vue.js SFC 或者 Angular 组件 默认支持样式的同处,这在开发和维护阶段都带来了实实在在的益处。如果觉得样式过于遮掩其他代码,我们总是可以选择将样式提取到单独的文件中。

几乎所有 CSS-in-JS 库都支持样式的同处。我们遇到的唯一例外是 Treat,它要求我们在单独的 .treat.ts 文件中定义样式,与 CSS 模块的工作方式类似。

样式定义语法

我们可以使用两种不同的方法来定义样式。一些库只支持一种方法,而另一些库则比较灵活,支持两种方法。

标记模板语法

标记模板语法允许我们将样式定义为标准 ES 模板字面量 中的纯 CSS 代码字符串。

// consider "css" being the API of a generic CSS-in-JS library
const heading = css`
  font-size: 2em;
  color: ${myTheme.color};
`;

我们可以看到

  • CSS 属性使用 kebab-case 编写,就像普通的 CSS 一样;
  • JavaScript 值可以被插值;
  • 我们可以轻松地迁移现有的 CSS 代码,无需重写。

一些需要注意的事情

  • 为了获得语法高亮代码建议,需要一个额外的编辑器插件;但这个插件通常适用于 VSCode、WebStorm 等流行的编辑器。
  • 由于最终代码必须最终在 JavaScript 中执行,因此样式定义需要解析并转换为 JavaScript 代码。这可以在运行时完成,也可以在构建时完成,会带来少量的捆绑包大小或计算方面的开销。
对象样式语法

对象样式语法允许我们将样式定义为普通的 JavaScript 对象。

// consider "css" being the API of a generic CSS-in-JS library
const heading = css({
  fontSize: "2em",
  color: myTheme.color,
});

我们可以看到

  • CSS 属性使用 camelCase 编写,字符串值必须用引号括起来;
  • JavaScript 值可以像预期的那样被引用;
  • 它不像编写 CSS,而是使用稍微不同的语法来定义样式,但具有与 CSS 中相同的属性名称和值(不要被吓倒,你很快就会习惯的);
  • 迁移现有的 CSS 需要使用这种新的语法进行重写。

一些需要注意的事情

  • 语法高亮开箱即用,因为我们实际上是在编写 JavaScript 代码。
  • 为了获得代码完成,库必须提供 CSS 类型定义,其中大部分扩展了流行的 CSSType 包。
  • 由于样式已经用 JavaScript 编写,因此不需要额外的解析或转换。
标记模板对象样式
styled-components
Emotion
Goober
Compiled
Fela🟠
JSS🟠
Treat
TypeStyle
Stitches
Styled JSX

✅  完全支持         🟠  需要插件          ❌  不支持

样式应用方法

既然我们已经知道定义样式有哪些选项,那么让我们看一下如何将它们应用到我们的组件和元素上。

使用类属性/className 属性

应用样式最简单、最直观的方式是将它们作为类名附加。支持这种方法的库提供了一个 API,该 API 返回一个字符串,它将输出生成的唯一类名。

// consider "css" being the API of a generic CSS-in-JS library
const heading_style = css({
  color: "blue"
});

接下来,我们可以获取包含生成的 CSS 类名字符串的 heading_style,并将其应用到我们的 HTML 元素上。

// Vanilla DOM usage
const heading = `<h1 class="${heading_style}">Title</h1>`;

// React-specific JSX usage
function Heading() {
  return <h1 className={heading_style}>Title</h1>;
}

正如我们所看到的,这种方法非常类似于传统的样式:首先我们定义样式,然后将样式附加到我们需要的地方。对于之前编写过 CSS 的任何人来说,学习曲线都很低。

使用 <Styled /> 组件

另一种流行的方法,最早是由 styled-components 库引入的(并以它命名),采用了不同的方法。

// consider "styled" being the API for a generic CSS-in-JS library
const Heading = styled("h1")({
  color: "blue"
});

我们不是将样式单独定义并附加到现有的组件或 HTML 元素上,而是使用一个特殊的 API,通过指定我们想要创建的元素类型以及想要附加到它的样式来实现。

该 API 将返回一个新的组件,其中已经应用了类名,我们可以像在应用程序中的任何其他组件一样渲染它。这基本上消除了组件与其样式之间的映射关系。

使用 css 属性

Emotion 推广的一种较新的方法,允许我们将样式传递给一个特殊的属性,通常名为 css。此 API 仅适用于基于 JSX 的语法。

// React-specific JSX syntax
function Heading() {
  return <h1 css={{ color: "blue" }}>Title</h1>;
}

这种方法具有一定的符合人体工程学的好处,因为我们不必从库本身导入和使用任何特殊的 API。我们只需要将样式传递给此 css 属性,就像使用内联样式一样。

请注意,此自定义 css 属性不是标准 HTML 属性,需要通过库提供的单独 Babel 插件来启用和支持。

className<Styled />css 属性
styled-components
Emotion
Goober🟠 2
Compiled🟠 1
Fela
JSS🟠 2
Treat
TypeStyle
Stitches🟠 1
Styled JSX

✅  完全支持         🟠 1  有限支持          🟠 2  需要插件          ❌  不支持

样式输出

有两种相互排斥的方法来生成和将样式发送到浏览器。这两种方法都有优缺点,让我们详细分析一下。

<style>-注入的 DOM 样式

大多数 CSS-in-JS 库在运行时使用一个或多个 <style> 标签 或使用 CSSStyleSheet API 直接在 CSSOM 中管理样式,将样式注入到 DOM 中。在 SSR 期间,样式始终作为 <head> 中的 <style> 标签附加到渲染的 HTML 页面。

这种方法有一些关键优势首选用例

  1. 在 SSR 期间内联样式可以 提高页面加载性能指标,例如FCP(首次内容绘制),因为渲染不会被从服务器获取单独的 .css 文件所阻塞。
  2. 它在 SSR 期间开箱即用地提供 关键 CSS 提取,方法是仅内联渲染初始 HTML 页面所需的样式。它还删除了任何动态样式,从而通过下载更少的代码进一步提高加载时间。
  3. 动态样式通常更容易实现,因为这种方法似乎更适合高度交互式用户界面和单页面应用程序 (SPA),其中大多数组件都是客户端渲染的

缺点通常与总捆绑包大小有关

  • 需要一个额外的运行时库来处理浏览器中的动态样式;
  • 内联的 SSR 样式不会开箱即用地缓存,因为它们是服务器渲染的 .html 文件的一部分,因此每次请求时都需要发送到浏览器;
  • 重绘过程中,内联在.html页面中的SSR样式将作为JavaScript资源再次发送到浏览器。
静态.css文件提取

有一些库采用完全不同的方法。它们不将样式注入DOM,而是生成静态.css文件。从加载性能的角度来看,我们获得了与编写普通CSS文件相同的优势和缺点。

  1. 由于不需要额外的运行时代码或重绘开销,因此发送的代码总量要小得多
  2. 静态.css文件受益于浏览器中的开箱即用缓存,因此对同一页面的后续请求不会再次获取样式。
  3. 当处理SSR页面静态生成的页面时,这种方法似乎更具吸引力,因为它们受益于默认的缓存机制。

但是,我们需要注意一些重要的缺点。

  • 当使用这种方法时,第一次访问页面(空缓存)将通常具有更长的FCP,与前面提到的方法相比;因此,决定是优化首次访问用户还是回访用户,在选择这种方法时可能起到至关重要的作用。
  • 页面上可以使用的所有动态样式都将包含在预生成的包中,这可能导致需要预先加载的更大的.css资源。

我们测试过的几乎所有库都实现了第一种方法,将样式注入DOM。唯一支持静态.css文件提取的测试库是Treat。还有其他库支持此功能,例如AstroturfLinariastyle9,但没有包括在我们的最终分析中。

原子CSS

一些库将优化更进一步,实施了一种称为原子CSS-in-JS的技术,该技术受到TachyonsTailwind等框架的启发。

它们不生成包含为特定元素定义的所有属性的CSS类,而是为每个唯一的CSS属性/值组合生成唯一的CSS类。

/* classic, non-atomic CSS class */
._wqdGktJ {
  color: blue;
  display: block;
  padding: 1em 2em;
}

/* atomic CSS classes */
._ktJqdG { color: blue; }
._garIHZ { display: block; }
/* short-hand properties are usually expanded */
._kZbibd { padding-right: 2em; }
._jcgYzk { padding-left: 2em; }
._ekAjen { padding-bottom: 1em; }
._ibmHGN { padding-top: 1em; }

这使得可重用性很高,因为这些单独的CSS类可以在代码库中的任何地方重复使用。

理论上,这在大型应用程序的情况下非常有效。为什么?因为整个应用程序所需的唯一CSS属性数量是有限的。因此,缩放因子不是线性的,而是对数的,与非原子CSS相比,产生的CSS输出更少。

但有一个问题:必须将单个类名应用于需要它们的每个元素,这会导致HTML文件略大。

<!-- with classic, non-atomic CSS classes -->
<h1 class="_wqdGktJ">...</h1>

<!-- with atomic CSS classes -->
<h1 class="_ktJqdG _garIHZ _kZbibd _jcgYzk _ekAjen _ibmHGN">...</h1>

因此,基本上我们将代码从CSS移到了HTML。大小的最终差异取决于太多方面,我们无法得出明确的结论,但总的来说,它应该减少发送到浏览器的字节总数。

结论

CSS-in-JS将彻底改变我们编写CSS的方式,提供许多好处并改善我们的整体开发体验。

但是,选择采用哪个库并不简单,所有选择都会带来许多技术上的折衷。为了确定最适合我们需求的库,我们必须了解项目的​​要求及其用例。

  • 我们是否使用React?React应用程序有更多选择,而非React解决方案必须使用与框架无关的库。
  • 我们是否正在处理一个高度交互的应用程序,使用客户端渲染?在这种情况下,我们可能不太关心重绘的开销,或者不太关心提取静态.css文件。
  • 我们是否正在构建一个包含SSR页面的动态网站?然后,提取静态.css文件可能是更好的选择,因为它可以让我们受益于缓存。
  • 我们需要迁移现有的CSS代码吗?使用支持标记模板的库将使迁移更容易、更快。
  • 我们是想优化首次访问用户还是回访用户?静态.css文件通过缓存资源为回访用户提供最佳体验,但首次访问需要额外的HTTP请求,这会阻塞页面渲染。
  • 我们是否经常更新样式?如果我们经常更新样式,所有缓存的.css文件都将毫无价值,从而使任何缓存失效。
  • 我们是否重复使用很多样式和组件?如果我们在代码库中重复使用了很多CSS属性,原子CSS将大放异彩。

回答上述问题将有助于我们决定在选择CSS-in-JS解决方案时寻找哪些功能,使我们能够做出更明智的决定。