本文样式方案学习了 cssModules 解决样式冲突的基本原理,并在此基础上改进以达到缩减样式文件 Size 的目的。

作者简介


(资料图)

Can,携程前端开发,目前从事小程序开发工作,对编译打包技术、小程序跨平台解决方案有浓厚兴趣。

一、概述

目前我们团队小程序是使用 Taro 跨端方案 React 框架进行开发,基于现有样式方案,在编译打包后会产生大量的样式代码冗余,在项目编译后的产物中占有较大比例。

分析了编译后的样式代码后,我们发现冗余代码主要体现在两个方面:

项目样式文件中大量使用了父子选择器作为作用域进行样式隔离,编译后出现类名大量重复冗余。如以下 SCSS 文件样式代码中,编译后.box .item重复冗余了三次。
// 编译前代码.box {  .item {    .item1 {}    .item2 {}    .item3 {}    .item4 {}  }}// 编译后代码.box .item .item1 {}.box .item .item2 {}.box .item .item3 {}.box .item .item4 {}
样式代码中大量属性值重复冗余。如最常用的display: flex属性值,在项目中可能存在几百上千份重复冗余,而且为了兼容性开启了 Autoprefixer 插件后,display:flex将会变成display:-webkit-flex;display:-ms-flexbox;display:flex;,使得样式文件属性值的冗余情况更为严重。

针对 Taro项目 React 框架小程序遇到的以上问题,本文将介绍一种新的样式解决方案。本方案在较少改变现有开发体验的条件下,采用 cssModules 样式方案语法要求,利用 Taro 插件的便利性给出对应的解决方案,以此对产物进行“瘦身”。最终样式文件的瘦身效果可以达到 50% – 70%,进一步缓解官方包 Size 的限制,便于业务的高速发展。

二、cssModules 简单介绍

本文样式方案学习了 cssModules 解决样式冲突的基本原理,并在此基础上改进以达到缩减样式文件 Size 的目的。因此在正式了解本方案之前,本文先用 Taro 官网中使用 cssModules 方案的例子代码作为示例,简单了解下其语法要求与原理。

2.1 语法要求

在配置开启了 cssModules 后,按照语法要求,Taro 项目中有 index.module.scss 和 index.js 两个文件,文件代码如下。cssModules 默认是开启部分自定义模式转换,只有文件名中包含.module.的样式文件才会经过 cssModules 转换处理。在如下 index.module.scss 样式文件中,我们正常使用了父子选择器、类选择器。但是在index.js 文件中,className 赋值不再是字符串,而是 SCSS 文件导出的 Object 的某个 Key,该 Key 为 SCSS 文件中的类选择器的命名。

import React, { Component } from "react"import { View, Text } from "@tarojs/components"import styles from "./index.module.scss"export default class Index extends Component {  render() {    return (              Hello world!          )  }}
.test {  color: red;  .txt {    font-size: 36px;  }}

2.2 原理

Taro 项目开启 cssModules 配置后,在编译打包时,会使用实现了 cssModules 规范的 css-loader 对 SCSS 等样式文件进行处理。它首先会处理原 SCSS 文件中的类选择器,将类名进行哈希处理得到新类名如index-module__test___Bm2J6,生成新的样式代码输出到最终的 index.wxss,同时保存了原类名与哈希处理后的新类名的映射关系。此后它会将原 SCSS 文件 index.module.scss 编译为导出了原类名与哈希后的新类名的映射对象。JS 文件在运行时能通过该映射对象获取到哈希后的新类名,保证该文件类名不会与其他样式文件的同类名冲突,从而解决样式冲突问题。以下为编译后的代码示例,styles.test在运行时会会变成index-module__test___Bm2J6。

// index.module.scssexport default ({"test":"index-module__test___Bm2J6","txt":"index-module__txt___nIysk"});
// index.wxss.index-module__test___Bm2J6 {  color: red;}.index-module__test___Bm2J6 .index-module__txt___nIysk {  font-size: 36rpx;}

三、方案原理介绍

3.1 基本原理

3.1.1 当前样式文件 size 分析

在正式介绍本文方案是如何缩减样式文件 Size 之前,本文通过以下两个正则去分别匹配打包产物中所有样式文件的两个核心组成部分 ClassName 与 PropertyValue,并进行 Size 统计分析。

注:在本文中,有如该.txt .tit {color: #red;}CssRule代码,ClassName指的是其中的txt和tit,PropertyValue指的是color:#red;。

const classNamePattern = /(?<=\.)[A-Za-z0-9\-_]+(?=\s|{|:)/g // 匹配 ClassName 如 .txt {color: #red;}中的txtconst cssPropertyPattern = /(?<=\{)[^}]+(?=})/g // 匹配PropertyValue, 如 .txt {color: #red;}中  中括号之间的所有内容 color: #red;

下图是对整个编译打包后的小程序项目的样式文件进行组成 Size 分析。通过该图我们可以发现,我们项目打包编译后的所有的样式文件中,ClassName 占用大约有五分之一的空间,而 PropertyValue 则占用了有十分之七的空间,其余空间占比可能是如空格、伪类这种形态存在,本文暂不考虑。

3.1.2 处理方案

通过上一小节,我们可以知道一个样式文件中核心主要有两部分内容,一是 ClassName,二是 PropertyValue。本文样式方案对这两部分分别进行了处理来达到节省 Size 的目标。

1)缩减 ClassName 长度

核心就是将原 ClassName 替换成更短且唯一的 ClassName,在解决样式冲突的同时,也通过缩减了 ClassName 长度节省了 Size。当我们使用 cssModules 时,通常如第二章介绍 cssModules 时的示例代码一样,都是将 ClassName 进行 hash 化处理来保证唯一性,但是经过 hash 处理后的 ClassName 长度反而变得更长了,不符合我们缩减样式代码 Size 的目标。

本方案是从最短字符开始,逐渐递增的方式生成全项目唯一的 ClassName,从而保证唯一性的同时能够保证 ClassName 长度尽可能的短。如第一个解析到的 ClassName 替换成-a,第二个解析到的ClassName替换成-b,第五十二个解析到的 ClassName 替换成-Z,第五十三个解析到的 ClassName 替换成-aa。其中 ClassName 前面的-,用于防止新生成的类名与未转换的类名冲突。此外,新生成的 ClassName 注意需要符合规则,本插件算法先取prevString中一个字符,后续所有字符可以取任意charString中字符。

const prevString = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" // 52个字符数const charString = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" // 64个字符数

可能有人担心,随着整个项目中 ClassName 申明的越来越多,逐渐递增生成的 ClassName 也会越来越长,反而导致总 ClassName 过长。通过上述算法,算上最前面加上的 -, 当使用完三个字符长度的类名可 以替换 52 * 64 = 3328 个 ClassName 了,达到使用完四个字符长度需要 52 * 64 * 64 = 212992 个 className。新生成的 ClassName 不超过四个字符,就可以满足大部分项目的使用,使用本样式方案前可以检索下自己项目中 ClassName 的量级。

2)缩减 PropertyValue

通过上面的分析可以发现,其实占据样式文件 Size 最多的部分是 PropertyValue,因此缩减 PropertyValue 是本样式方案能够节省大量 Size 的核心手段。其实我们在开发时用到的样式属性值很多都是重复的,比如开发过程中用到的最多布局属性display:flex。每次用到该属性都需要新写一份,而且为了兼容性开启了Autoprefixer插件,display:flex将会变成display:-webkit-flex;display:-ms-flexbox;display:flex;,这使得样式文件的 Size 变得更大。本插件是通过尽可能复用 PropertyValue 的方式来缩减 PropertyValue。

本插件会将样式文件中的仅使用了类选择器的 CssRule 进行 PropertyValue 拆分,每一次拆分都会生成新的 PropertyValue ClassName。如以下示例代码,仅类选择器CssRuletxt被拆分了_a和_b两个 PropertyValue ClassName。后续若其他使用仅类选择器 CssRule 进行拆分时,若有相同的 PropertyValue 就会直接复用_a或者_b。

// 原代码.txt { display: flex;flex: 1; }// 处理后的代码._a {display: -webkit-flex; display: -ms-flexbox;display: flex;}._b {-webkit-flex: 1;-ms-flex: 1;flex: 1;}

而在使用 cssModules 样式写法的 js 文件中也需要进行相应的映射处理,通过 babel 插件在编译时进行转换处理,判断 css 文件的引用关系并进行替换,示例代码如下。

// 原代码import styles from "./index.module.scss"Index = () => {  return }// 处理后的代码import "./index.module.scss"Index = () => {  return }

本样式方案通过对仅使用了类选择器的 CssRule 的 PropertyValue 拆分成新的 PropertyValue ClassName,后续任何进行拆分的地方就可以直接复用该 PropertyValue ClassName,从而可以大量缩减 PropertyValue 重复冗余占用的 Size。

3)插件处理流程

以上两小节已经介绍了两个核心缩减 Size 的方案,本小节举一个更加全面的例子来介绍本插件是如何在编译时运用以上两个方案,对样式文件和 JS 文件进行处理转化的。主要有以下两步。

第一步,针对仅使用类选择器的 CssRule,进行 PropertyValue 拆分。如下示例代码中,.box{display:flex}拆分出了._a {display: -webkit-flex;display: -ms-flexbox;display: flex;},后续.item1` `.item2拆分时,直接复用了._a,缩减了 PropertyValue 重复冗余。

第二步,针对非仅使用类选择器的 CssRule,直接替换成全局唯一且更短的 ClassName。如下示例代码中,.box .item2{color: red;},原选择器中的 ClassName 直接替换成了更短的.-a .-b{ color: red;},并且添加了该映射关系styles = {box: “_a -a”, item1: “_a _b _c”, item2: “_a _b _d -b”},并在编译时进行替换。

// 原代码import React from "react"import styles from "./index.module.scss"export default Index = () => {  return     item1    item2  }// 处理后的代码import React from "react"import "./index.module.scss"// styles = {box: "_a -a", item1: "_a _b _c", item2: "_a _b _d -b"}export default Index = () => {  return     item1    item2  }
// 原index.module.scss代码.box {    display: flex;}.item1{    display: flex;    font-size: 32px;    color: red;}.item2{    display: flex;    font-size: 32px;    color: grey;}.box .item2{    color: red;}// 处理后index.module.scss代码._a {display: -webkit-flex;display: -ms-flexbox;display: flex;}._b {font-size: 32px;}._c {color: red;}._d {color: grey;}.-a .-b{    color: red;}

3.2 需要注意的问题

3.2.1 styles 对象的属性不支持运行时

cssModules 方案中,JS 文件中引入的样式文件对象支持运行时计算属性的,如以下示例写法。这是因为在打包后的 JS 文件中,保存有一份原 ClassName 与 hash 后新 ClassName 映射关系的对象数据,因此运行时 styles 还能映射属性,但是这种处理方式会导致 js 文件 size 增大。

import styles from "./index.module.scss"const Index = () => {  return }

本方案为了尽可能保证项目 Size 足够小,并没有采用 cssModules 这种处理方式。本方案在编译时会直接对原 CLassName 与拆分 PropertyValue 后的新 ClassName 直接进行了替换,如直接把className={styles.txt}替换成className=”_a _b”。

因此本方案styles对象不支持如上示例代码中,运行时计算得到txt属性,如需动态调整样式有两种方案,一是直接使用内联样式。二是新写 ClassName 而不是拼接,如className={value ? styles.txt1 : styles.txt2}}。

3.2.2 仅类选择器不依赖先后顺序定优先级

在上文中,提到过会拆分仅使用类选择器 CssRule,来尽可能复用已有的 PropertyValue ClassName。但是这种复用是有缺陷的,它会导致 ClassName 的先后顺序可能不符合预期,如下代码所示,通常来说我们认为标题颜色应当是grey。

// 原代码import styles from "./index.module.scss"const Index = () => {  return 标题}// 处理后的代码import styles from "./index.module.scss"const Index = () => {  return 标题}
// 原代码.other { color: green; color:red; }.tit1 { color: red; }.tit2 { color: green; }// 处理后的代码._a {color:green;}._b {color:red;}

但是经过本插件复用了 PropertyValue 后,导致._b{color:red;}出现在了._a{color:green;}后面了,此时标题的颜色也就变成了red,从而可能不符合开发者预期。

因此需要注意在编写仅类选择器 CssRule 的 ClassName 时,不能依赖类选择器先后顺序来定优先级,可通过兄弟选择器来将优先级提的更高,从而不受先后顺序影响,如下代码示例。这样就能确定标题颜色一定是green。

// 兄弟选择器来提高优先级.other { color: green; color:red; }.tit1 { color: red; }.tit1.tit2 { color: green; }

四、使用指南

4.1 使用

4.1.1 安装插件

本样式方案被集成在该 Taro 插件taro-plugin-split-class中,安装本插件。源码见仓库taro-plugin-split-class。

npm install -D taro-plugin-split-class

4.1.2 关闭cssModules功能

在 Taro 配置文件中,使得mini.posetcss.cssModules.enable = false,确保 cssModules 功能关闭,如下代码所示。

// config/index.js{    mini: {        postcss: {            cssModules: {                enable: false           }        }    }}

4.1.3 配置本插件

在 Taro 配置文件中,plugins配置中加入本插件taro-plugin-split-class。本插件支持配置类名转换白名单(实现功能类似 : global,见 2.4)classNameWhite,比如常用的 iconfont 是不需要转换的。

plugins: [    ["taro-plugin-split-class", {      classNameWhite: ["iconfont", /^ifont-/]    }]]

4.2 语法要求

a.样式文件命名需以 .module.xxx 结尾,如 index.module.scss,该样式文件方可被本插件转化处理。

b. 在 JS 文件中,将样式文件作为一个对象引入,并将类名作为对象的键进行使用。如下代码所示,使用className={styles.box}而不是className=”box”,其中box为定义在样式文件的中类名。

// 如下import styles from "./index.module.scss"// 而不是import "./index.module.scss"

c. 本方案支持所有选择器包括父子选择器、伪类选择器、兄弟选择器等等。但请尽可能的使用仅类选择器来定位元素,这样做可以便于插件尽可能复用 PropertyValue 从而更好的缩减 Size。本方案解决了类名冲突问题,因此开发者不需要担心因类名命名简单而导致的类名冲突。

// 如下仅类选择器的CssRule.box {    display: flex;    flex-direction: column;    align-items: center;}.tit {    display: flex;    font-size: 40px;    color: red;}// 而不是父子选择器.box {    display: flex;    flex-direction: column;    align-items: center;    .tit {        display: flex;        font-size: 40px;        color: red;    }}

d. 特殊类名不变

有时候我们希望一些特殊的 ClassName 不变,在 JS 文件中,不从 styles 取类名即可,如下代码中的extra。

import styles from "./index.module.scss"标题

但是在样式文件中默认所有 ClassName 都会被拆分或者压缩。如下代码示例,extra被处理成-a。

// 原类名.extra.tit {color: blue;}// 新类名.-a.-b {    color: blue;}

因此需要特殊标识符让插件感知到不需要处理该 ClasName。本方案提供了类似 cssModules 的:global的解决方案,有两种使用方式,一是:global(.extra),被包裹的类名不会被替换。

// 编译前:global(.extra).tit {  color: blue;}// 编译后.extra.-a {    color: blue;}

二是以:global开头,后续所有的类名都不会被替换。

// 编译前:global .extra1 .extra2 { color: red;}// 编译后.extra1 .extra2 { color: red;}

4.3 打包效果展示

4.3.1 开发环境

使用本插件后,原类名会被替换或拆分成更短且更多的新类名。这样处理后的新类名可读性很差,开发者不能很好的定位到原类名代码。因此在开发环境下,会在更短且更多的新类名前会加上[文件夹_文件名_原类名]。保留了原类名相关信息,便于开发者查找原类名。如下图代码所示,原类名为box,经过插件拆分和缩短后的新类名为_a _g _h -c,在新类名前加上了index_indes-module_box,最终展示的完整类名为index_index-module_box _a _g _h -c。

4.3.2 生产环境

在生产环境了,不需要考虑新类名可读性,因此直接会直接将类名完全替换为新类名。如下图代码所示,box直接被替换成_a _g _h -c。

五、方案分析

5.1 实践效果

5.1.1 页面改造前后对比

在使用本样式方案对某个页面进行改造后,改造前后 Size 对比如下。可以发现样式文件缩减了 44KB,缩减了将近 70% 的 Size,JS 文件有这 2kb 的增长。

JS文件

样式文件

总和

使用前

54kb

63kb

117kb

使用后

56kb

19kb

75kb

使用前编译后文件 Size 如下图:

使用后编译后文件 Size 如下图:

5.1.2 重构页面横向对比

最近我们项目重构了两个大型订单详情页面,本小节以这两个页面重构后的代码为例,分析编译打包前后的 Size 并进行横向对比。

整理出如下表格:

样式编码字符数

打包后实际Size

未使用本样式方案的订单详情页1

3620

86kb

使用本样式方案的订单详情页2

6615

73kb

两订单详情页代码组织结构类似,因此将它们进行横向对比。未采用本样式方案的订单详情页 1 的样式编码字符数为 3620,打包后实际 Size 为 86kb。若订单详情页 2 未使用本样式方案,打包前样式编码字符数为 6615,则预期打包后实际 Size 为 6615 / 3620 \* 86kb = 157kb,但订单详情页使用了本样式方案实际打包后为 73kb,相对于 157kb,缩减了 50% 左右的 Size。

以下为未使用本样式方案的订单详情页 1,该目录下样式文件包括了 50 个样式文件,共计 3620 个字符,最终打包出来的样式文件的 Size 为 86kb。

以下为使用了本样式方案的订单详情页 2,该目录下样式文件包括了 96 个样式文件,共计 6615 个字符,最终打包出来的样式文件 Size 为 73kb。

5.2 Size 缩减效果分析

以上两个实践效果,相较于项目中原样式写法方案,使用本方案后,主要从以下三个方面节省了 Size。

a. 本方案解决了样式冲突问题,编写样式代码时可以不再用父子选择器的方式来进行样式作用域隔离,减少了祖先选择器的冗余。如下使用了 sass 预处理器的样式代码所示,我们可以发现在最终编译生成的代码中,.box .item冗余了三次,而且若继续在.box .item下每新增一个叶子节点.item*,.box .item都会冗余一次。因此项目中使用父子选择器这种方式来隔离作用域,会导致大量的祖先选择器冗余。

// 编译前代码.box {  .item {  .item1 {}  .item2 {}  .item3 {}  .item4 {}  }}// 编译后代码.box .item .item1 {}.box .item .item2 {}.box .item .item3 {}.box .item .item4 {}

b. 将原 ClassName 直接缩短成更短的 ClassName,直接减少了字符数。这种方式较为直接,但优化效果有限。

c. 本方案尽可能拆分样式文件中仅类选择器的 CssRule,生成并复用 PropertyValue ClassName,尽可能减少了 PropertyValue 的重复冗余。虽然在 JS 文件中 ClassName 被替换成更短但更多的 PropertyValue ClassName,有一定的 Size 增加,如在实践效果 1 中,实践后 JS 文件有 2KB 的增长。但是相比于样式文件 Size 上的缩减效果可以忽略不计。

5.3 Size 增长分析

随着样式文件越多,采用本样式方案的项目,样式文件 Size 增长幅度将增长会越缓慢。本方案要求以仅类选择器的方式为主,少量场景使用其他选择器为辅的方式进行编写样式代码。随着项目中样式代码越来越多,仅类选择器 CssRule 经过本插件处理拆分生成的可复用的 PropertyValue CssRule 会越来越多。此时,在按要求新写仅类选择器 CssRule 使用到某个 PropertyValue 时,可复用的概率会更高。高概率的每一次复用都会节省一部分 Size,使得最终编译打包后生成的样式文件 Size 增长曲率逐渐放缓。

六、总结

针对 Taro 项目 React 框架小程序,本文介绍了一种新的样式解决方案,该方案被集成为一个 Taro 插件的形式,可以在在较少改变现有开发体验的条件下,缓解样式代码的冗余问题。

本样式方案学习借鉴了 cssModules 样式方案的语法规则以及原理,解决了样式冲突的问题,并且在此基础上从缩减 ClassName 长度和缩减 PropertyValue 两个方面实现了 Size 上的缩减,最终样式文件的瘦身效果可以达到 50%-70%。这有利缓解官方包 Size 的限制,便于业务的高速发展。

七、vscode 插件推荐

本方案基本语法跟 cssModules 一致,因此可以直接借助现有的 cssModules 插件,提升开发体验。

7.1 CSS-Modules-transform 插件

该插件支持让项目现有 JS 代码快速转成 cssModules 语法,将原类名使用方式,一键替换成本方案要求的类名使用语法,如classname=”a1″ => className={styles.a1}。需要注意的是,一键替换只支持非运行时的语法,运行时的语法还是需要手动替换。可以高效提高现有样式方案转化效率。

7.2 CSS Modules 插件

CSS Modules插件支持自动补全和类型定义,提高开发体验。

八、文章参考

GitHub – css-modules/css-modules: Documentation about css-modulescssModules插件

推荐内容