likes
comments
collection
share

CSS 工程化技术方案

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

Those who fail to learn History are doomed to repeat it.(那些不学习历史的人注定会重蹈覆辙。) ———温斯顿·丘吉尔

伴随着现代 web 框架的发展,css 也出现了各种各样的解决方案。如何决定在项目中使用哪一种技术方案,是一个非常值得探讨的话题。这篇文章将主要对一些典型方案进行梳理总结(这些方案的呈现基本遵循时间线发展),了解他们各自的优缺点和相关知识背景。希望能够对你有所帮助。

需要指明的是,文章不会详细介绍每一种技术方案的细节,优秀的文章已经有很多。如果你是一个具备丰富经验的开发工程师,你可以略过文章中你已经熟知的部分以节省你的阅读时间。如果你对文章中提到的相关知识不是很了解,很建议你花一些时间去阅读它,相信它会对你有所帮助。

1. CSS in .css

这是最原始的方式,将 CSS 全部写在 .css 文件中,使用原生的 CSS 语言去写样式。

这是刚刚进入前端开发领域的同学,一定会接触的样式实现方案。在早期前端开发还没有前后端分离概念,没有借助构建工具等工程化手段时,这种方式无疑是最寻常,最朴素的方式。

在开发一些小型应用时,这一定最好的方式,因为它能够帮你快速实现想要的样式,也不会影响你的开发体验,也能够得到很好的性能方面的问题。

但如果你是一个具备一定经验的开发者,具有大型项目的开发经验,你可能已经意识到以下问题:

  • 兼容性问题:我们往往需要自行解决各浏览器引擎的 CSS 兼容性问题。但这些兼容性大多是我们在重复解决的。

  • 缺失逻辑性:CSS 没有变量、函数这些概念,也没有模块机制,导致书写效率以及代码的维护性都不高。

  • 复用性低:CSS 缺少抽象的机制,选择器很容易出现重复,不利于维护和复用。

  • 全局污染:CSS 是层叠样式,作用域是全局的,这就可能导致元素的样式命中规则来源于一个 .css 文件多处或是多个 .css 文件;并且不同种类的选择器,例如 ID 选择器、类选择器、元素选择器等的权重也不一样,这很容易引起样式相互覆盖或冲突。

  • 难以维护:遇到冲突时,改起来可能会非常复杂,当我们好不容易解决了问题,但确很可能助推了 css 体积无限膨胀。

2. CSS 预处理器

沿用 MDN 的解释,CSS 预处理器是一个能让你通过预处理器自己独有的语法来生成 CSS 的程序。市面上有很多 CSS 预处理器可供选择,且绝大多数 CSS 预处理器会增加一些原生 CSS 不具备的特性,例如代码混合,嵌套选择器,继承选择器等。这些特性让 CSS 的结构更加具有可读性且易于维护。比较流行的 CSS 预处理器有:SASSLESSStylusPostCSS

使用过基于 webpack 构建项目的同学一定熟悉 sass-loaderless-loaderpostcss-loader,我想你已经清晰的了解到它们的作用是什么,为什么在你的项目中会用到它们。关于他们具体的使用和各自的特性这里不做赘述,下面总结了它们的优点和缺点:

优点

  • 相比于 CSS,Sass / Less 更像是一门编程语言,可以提升写 CSS 的效率,代码更易于组织和维护。例如imports 使样式表更容易拆分和组合,嵌套选择器让结构层化,提高了可读性。
  • 兼容标准,可以快速使用 CSS 新特性,兼容浏览器 CSS 差异等

缺点

  • 只是对 CSS 本身进行了增强,但是在帮助开发者如何写更好的 CSS、更高效、可维护的 CSS 方面并没有提供任何建议。
  • 依然需要自己定义 CSS 类、id,需要思考如何去用这些类、id 进行组合去描述 HTML 的样式。
  • 依然可能会写很多冗余的代码,然后造成项目的负担,在可维护性方面仍然存在问题。

3. 命名规约 - BEM

BEM的意思就是块(block)、元素(element)、修饰符(modifier),是由 Yandex 团队提出的一种前端命名方法论。这种巧妙的命名方法让你的CSS类对其他开发者来说更加透明而且更有意义。BEM命名约定更加严格,而且包含更多的信息,它们用于一个团队开发一个耗时的大项目。

<div class="card__body">
  <p class="card__body__content">Content</p>
  <div class="card__body__links">
    <a href="#" class="card__body__links__link--active">Link</a>
  </div>
</div>

BEM 不是框架也不是技术,是方法论,因此我们也可以将 BEM 和预处理结合获得各自的优势,参考这篇文章的介绍。

优点:

  • 所产生的 CSS 类名都只使用一个类别选择器,可以避免传统做法中由于多个类别选择器嵌套带来的复杂的属性级联问题。
  • 每个 CSS 类名都很简单明了,而且类名的层次关系可以与 DOM 节点的树型结构相对应,便于理解和维护。

缺点:

  • 很难为选择器起名字。为了避免和页面上其他元素的样式发生冲突,我们在起选择器名的时候需要深思熟虑,起的名字一定不能太普通。并且组件在块中嵌套的越多,类名就变得越长越不可读。深度嵌套或孙子选择器经常面临这个问题。
  • 团队多人合作困难。当多个人一起开发同一个项目的时候,特别是多个分支同时开发的时候,大家各自取的选择器名字可能有会冲突,可是在本地独立开发的时候这个问题几乎发现不了。当大家的代码合并到同一个分支的时候,一些样式的问题就会随之出现。同样的,在集成第三方代码时可能会遇到类似的困难。

4. CSS Modules

CSS Modules 加入了局部作用域和模块依赖,可以保证某个组件的样式不会影响到其他组件。

需要指出的是,CSS Modules 不是原生支持的,需要结合构建工具。在基于 webpack 构建的项目中,使用过 css-loader 的同学应该比较熟悉。

CSS Modules 允许我们像 import 一个 JS Module 一样去 import 一个 CSS Module。每一个 CSS 文件都是一个独立的模块,每一个类名都是该模块所导出对象的一个属性。通过这种方式,便可在使用时明确指定所引用的 CSS 样式。并且,CSS Modules 在打包时会自动将 id 和 class 混淆成全局唯一的 hash 值,从而避免发生命名冲突问题。

优点

  • 防止命名冲突。
  • 模块化机制。
  • 依赖关系明确,利于维护。

缺点

  • Typescipt 支持困难。
  • 与组件库难以配合。
  • 会带来一些使用成本,本地样式覆盖困难,写到最后可能一直在用 :global。
  • 要描述全局样式,必须使用不属于 CSS 规范的语法。

5. CSS-in-JS

随着 component-based 现代 Web 框架流行,使得开发者也想将组件的CSS样式也一块封装到组件中去以解决原生CSS写法的一系列问题。

CSS-in-JS 就是这样一种技术方案,它将 CSS 模型抽象到组件级别。

简单来说 CSS-in-JS 使我们能够将应用的CSS样式写在 JavaScript 文件里面,而不是独立为一些 .css.scss 或者 less 之类的文件,这样你就可以在 CSS 中使用一些属于JS的诸如模块声明,变量定义,函数调用和条件判断等语言特性来提供灵活的可扩展的样式定义。

需要说明的是,CSS-in-JS 在 React 社区的热度是最高的,这是因为React本身不会管用户怎么去为组件定义样式的问题,而 Vue 和 Angular 都有属于框架自己的一套定义样式的方案。

也正是由于 React 的自由度,社区出现了很多 CSS-in-JS 的库,每个库都提供了其独特的功能。这里不会详细介绍每一个库(因为其多达几十种),将主要分析这些库的共同的功能特性和差异化特性。

最后会总结 CSS-in-JS 库带来的优缺点,以便于我们在权衡业务中应该使用哪一类库有所参考。

5.1 共同具备的功能特性

几乎所有的 CSS-in-JS 都存在以下功能特性。

5.1.1 作用域

所有的类库基本都会生成一个独立的 css 类名。作用于特定的组件样式,不会影响其他样式。有了这个特性,我们不必再担心 css 类名冲突,也不必在整个代码库中想出唯一类名解决冲突。这个特性对于基于组件开发模式是非常有价值的。

5.1.2 SSR

目前来看,大部分 CSS in JS 框架都需要使用者在服务端渲染流程中添加额外的样式收集和插入流程,才能成功用上 SSR。不过也正是因为添加了样式收集流程,CSS in JS 的方案大多都支持提取关键样式(Critical CSS),可以在 SSR 时减小首屏请求大小,这也是它的一个优势。

5.1.3 自定生成浏览器前缀

所有 CSS-in-JS 库也提供了开箱即用的此功能。

5.1.4 没有内联样式

内联有一些缺点:

  • 无法定义伪类,伪元素,keyframe/animations 或媒体查询
  • 阻止浏览器缓存 CSS 以便在后续页面加载时重用。
  • 组件代码的可读性降低
  • 内联样式的性能不如类名。将它们用作样式化组件的主要方法通常是不鼓励的做法

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

5.2 差异化功能特性

几乎每个库都提供了一组独特的功能,这些功能可以极大地影响我们在为特定项目选择合适的解决方案时的决定。

5.2.1 React 相关 和 框架无关

当然,框架无关是最好的选择。但如果我们使用 React 框架,我们就拥有更多选择,找到最适合我们 React 项目的 CSS-in-JS 库。

5.2.2 样式和逻辑在同一位置也可以在不同位置

  • 有益于开发和维护
  • 当代码很多时,我们也没有损失分离样式代码的能力

5.2.3 样式定义语法

模板标记语法(Tagged Templates)

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

const heading = css`
    font-size: 2em;
    color: ${myTheme.color
}; `;

优点:

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

缺点:

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

对象样式语法

对象样式语法允许我们将样式定义为常规 JavaScript 对象

const heading = style({
  fontSize: "2em",
  color: myTheme.color,
});

优点:

  • 语法高亮是开箱即用的,因为我们实际上是在编写 JavaScript 代码。
  • 由于样式已经用 JavaScript 编写,因此不需要额外的解析或转换。
  • JavaScript 值可以按预期引用;

缺点:

  • CSS 属性用驼峰命名法编写,字符串值必须用引号括起来;
  • 感觉不像写 CSS,因为我们使用稍微不同的语法定义样式,但使用 CSS 中可用的相同属性名称和值(不要对此感到害怕,你很快就会习惯它);
  • 迁移现有的 CSS 需要进行必要的改造。

5.2.4 CSS-in-JS 的样式输出

<style> 标签插入DOM样式

在运行时将样式注入到 DOM 中,使用一个或多个 < style > 标签,或者使用 CSSStyleSheetAPI 直接在 CSSOM 中管理样式。

优势

  • SSR 友好。在 SSR 期间,样式总是作为 < style > 标记附加在呈现的 HTML 页面的 < head > 中。
  • 动态样式。组件在客户端呈现时插入 <style>。

缺点

  • 需要一个额外的运行时来处理浏览器中的动态样式;
  • 丢失缓存能力;
  • SSR 的样式会在 rehydration 阶段以 js 的形式再次到达客户端(double payload)。

提取静态 .css 文件

从加载性能的角度来看,我们得到了与编写普通 CSS 文件相同的优点和缺点。

优点

  • 发布的代码总量要小得多,因为不需要额外的运行时代码或 rehydration 开销。
  • 受益于浏览器缓存,对同一页面的后续请求不会再次获取样式。
  • 这种方法在处理 SSR 页面或静态生成页面时似乎更有吸引力,因为它们受益于默认的缓存机制。

缺点

  • 第一次访问,没有缓存,会增加 FCP。
  • 可以在页面上使用的所有动态样式都将包含在预生成的 bundle 中,这可能会导致更大的页面。需要预加载 css 资源。

5.3 CSS-in-JS 的优点和缺点

优点

局部样式

spa 应用样式很有可能出现冲突,更加需要样式隔离

const css = styleBlock => {
  const className = someHash(styleBlock);
  const styleEl = document.createElement('style');
  styleEl.textContent = `
    .${className} {
      ${styleBlock}
    }
  `;
  document.head.appendChild(styleEl);
  return className;
};
const className = css(`
  color: red;
  padding: 20px;
`); // 'c23j4'

避免无用的CSS样式堆积

在开发新的功能或者进行代码重构的时候,由于HTML代码和CSS样式之间没有显式的一一对应关系,我们很难辨认出项目中哪些CSS样式代码是有用的哪些是无用的,这就导致了我们不敢轻易删除代码中可能是无用的样式。这样随着时间的推移,项目中的CSS样式只会增加而不会减少。无用的样式代码堆积会导致以下这些问题:

  • 项目变得越来越重量级,加载到浏览器的CSS样式会越来越多,会造成一定的性能影响。
  • 开发者发现他们很难理解项目中的样式代码,甚至可能被大量的样式代码吓到,这就导致了开发效率的降低以及一些奇奇怪怪的样式问题的出现。

提取关键 CSS (Critical CSS) 支持

extract-critical-css

  • 在CSS-in-JS中,由于CSS是和组件绑定在一起的,只有当组件挂载到页面上的时候,它们的CSS样式才会被插入到页面的style标签内,所以很容易就可以知道哪些CSS样式需要在首屏渲染的时候发送给客户端,再配合打包工具的Code Splitting功能,可以将加载到页面的代码最小化,从而达到Critical CSS的效果。换句话来说,CSS-in-JS通过增加一点加载的JS体积就可以避免另外发一次请求来获取其它的CSS文件。而且一些CSS-in-JS的实现(例如styled-components)对Critical CSS是自动支持的

逻辑清晰,复用更加简单

因为所有的样式代码用 js 编写,能够获得 js 的完整能力。编码更加灵活,逻辑更加明确严谨。同时可以进行更好的模块化组织。

缺点

运行时消耗

由于大多数的CSS-in-JS的库都是在动态生成CSS的。这会有两方面的影响。首先你发送到客户端的代码会包括使用到的CSS-in-JS运行时(runtime)代码,这些代码一般都不是很小,例如styled-components的runtime大小是12.42kB min + gzip,如果你希望你首屏加载的代码很小,你得考虑这个问题。其次大多数CSS-in-JS实现都是在客户端动态生成CSS的,这就意味着会有一定的性能代价。不同的CSS-in-JS实现由于具体的实现细节不一样,所以它们的性能也会有很大的区别,你可以通过这个工具来查看和衡量各个实现的性能差异。

代码可读性差

大多数CSS-in-JS实现会通过生成唯一的CSS选择器来达到CSS局部作用域的效果。这些自动生成的选择器会大大降低代码的可读性,给开发人员debug造成一定的影响。

没有统一的业界标准

由于CSS-in-JS只是一种技术思路而没有一个社区统一遵循的标准和规范,所以不同实现的语法和功能可能有很大的差异。这就意味着你不能从一个实现快速地切换到另外一个实现。

5.4 在使用 css in js 之前,如何抉择

  • 项目是否使用 React 框架? 前面有说到 CSS-in-JS 在 React 社区的热度是最高的,这是因为React本身不会管用户怎么去为组件定义样式的问题,而Vue和Angular都有属于框架自己的一套定义样式的方案。
  • 是客户端渲染(CSR) 吗? 如果是,那我们可能不太关心 rehydration 的开销,也不太关心是否提取静态 .CSS 文件。
  • 是服务端渲染(SSR)吗? 如果是,那提取静态 .css 文件能力可能是一个更好的选择,因为它将允许我们从缓存中受益。
  • 是否需要迁移现有的 CSS 代码? 使用支持标记模板的库将使迁移更加容易和快速。
  • 是否需要优化初次用户还是返回访问者? 静态 .Css 文件通过缓存资源为返回访问者提供了最佳体验,但是第一次访问需要额外的 HTTP 请求来阻止页面呈现。
  • 样式是否经常更新? 如果我们频繁地更新样式,从而使任何缓存失效,那么所有缓存的.css 文件都没有价值。
  • 是否重用了很多样式和组件? 如果我们在我们的代码库中重用大量的 CSS 属性,那么原子 CSS 就会大放异彩。

回答上面的问题将帮助我们决定在选择 CSS -in-JS 解决方案时要寻找什么特性,从而使我们能够做出更有根据的决定。

总结

No Silver Bullets! - 没有银弹!,上面的内容将帮助我们决定在选择 CSS 解决方案时要寻找什么特性,从而使我们能够做出更有根据的决定。

总之, 一个好的 css 方案需要包含以下关键要素:

  • 局部样式:即 css-scope,避免样式冲突。
  • 可维护性:避免 css 体积膨胀,修改困难,影响页面性能指标。
  • Critical CSS 提取:提升页面性能指标。
  • SSR:是否支持 SSR 场景。
  • 浏览器引擎前缀:即 style-prefix,解决浏览器兼容性问题。

参考资料