boi剖析 - 基于webpack的css sprites实现方案
本文是58到家前端工程化集成解决方案boi的博文系列之一。boi是基于webpack打造的一站式前端工程化解决方案,现已开源Github。
作为前端构建工具不可或缺的一个环节,自动生成css sprites图片不仅仅能够减少频繁的人工操作,还能够避免多人协作时对同一个sprites图片维护过程中因个人原因引起的图片不规范问题。58到家前端工程化解决方案boi的自动css sprites功能基于webpack实现,本文记录一下实现方案的各个细节以及需要注意的地方。
1. 功能需求
css sprites的功能需求简单说就是将style中引用的散列小图标合并成一张sprites图片。从功能角度来讲比较单一,从实现角度来讲需要具备以下几点:
- 对style文件进行资源依赖分析,能够得出style中引用的图片资源;
- style文件引用的图片并非都是图标,其他的比如背景图等资源不应该被sprites合并。所以必须有明确的标识可以区分图标与非图标资源。
对于第一点,webpack本身就具备依赖分析的功能,所以无需自行实现。那么如何设计明确的标识以便区分资源类型呢?
2. 用户至上的设计原则
上文提到的资源标识,我们首先看一下业内的同类产品是如何实现的。以fis为例,请看以下代码:
li.list-1::before {
background-image: url('./img/list-1.png?__sprite');
}
li.list-2::before {
background-image: url('./img/list-2.png?__sprite');
}
fis的css sprites功能要求开发者在style代码中添加__sprite
标识,fis通过识别这个标识来区分资源类型。这种模式的优点是可以精确地进行定位,而且对图标文件的路径没有强制要求,可以将图标文件与其他资源文件混合存放。但是,在代码中书写标识,首先需要具体的业务开发人员时刻注意不要遗漏;其次,这种模式实质上是对代码的一种“绑架”,代码中存在与业务无关的内容并且可移植性不高。
作为框架,所有方案都应该遵循用户至上的设计原则:
- 配置API语义化,一目了然;
- 减少代码绑架,减少代码中存在与业务无关的内容,以便代码的高可移植性;
- 提供高级配置API,方便用户进行自定义。
基于以上原则,boi在设计配置API时尽量做到了语义化,并且style代码中不存在任何与业务无关的内容。以下代码是boi配置css sprites功能的demo:
boi.spec('style',{
sprites: true,
spritesConfig: {
dir: 'assets/image/icons',
split: true,
retina: true,
postcssSpritesOpts: null
}
});
与sprites功能相关的配置项细节如下:
sprites
-Boolean
,是否开启自动sprites功能,默认false
。只有在sprites
为true
时,spritesConfig
才会生效;spritesConfig
-Object
,功能配置细节:dir
-String
,图标文件的目录路径,默认为undefined
。boi以路径作为区分图标与非图标资源的标识,也就是说参与自动sprites的图标文件必须存放于独立的目录下,比如'assets/image/icons'
;split
-Boolean
,是否识别子目录并且每个子目录分别编译为sprites图片,默认为true
。比如上述代码对应的项目中存在图标目录'assets/image/icons'
,在此目录下又存在两个子目录'assets/image/icons/index'
和'assets/image/icons/admin'
,分别存在index
页面和admin
页面的图标文件。如果配置split:true
,boi将会编译输出两个sprites图片sprite.index.png
和sprite.admin.png
;如果配置split:false
,boi只会编译输出一个sprites图片文件sprite.icons.png
。retina
-Boolean
,是否识别分辨率标识,默认为true
。分辨率标识指的是类似@2x
的文件名标识,比如存在两个图标文件logo.png
和logo@2x.png
并且style文件中对两张图标都有引用,如下:
@media screen and (max-width:780px){ .logo{ background-image: url(../assets/icons/logo.png) } } @media screen and (min-width:781px and max-width:900px){ .logo{ background-image: url(../assets/icons/logo@2x.png) } }
如果配置`retina:true`,boi将把两种分辨率的图片分别合并为一张sprites图片,否则会编译到同一张sprites图片里。详细内容可以参考[boi-example-css-sprites](https://github.com/boijs/boi-example-css-sprites)。
postcssSpritesOpts
-Object
,默认为null
。boi使用postcss-sprites作为实现css sprites的技术选型。postcssSpritesOpts
是提供给用户自定义postcss-sprites相关功能的,这个配置项一般情况下是不需要用户操作的。如果遇到上文提到的配置项不能满足的应用场景,用户可以通过此API直接对postcss-sprites进行配置。
3. 技术选型
boi实现css sprites功能的技术选型如下:
- 构建内核: webpack;
- 资源编译loader:postcss-loader
- sprites功能实现: postcss-sprites
4. 实现方案
上文第二节中提到了boi实现sprites功能的设计原则和工作模式。用户在配置API中指定图标文件的路径
,boi以此路径作为区分图标与非图标文件的标识;并且支持识别分辨率标识进行单独编译。
在配置postcss时,要注意以下几点:
- 使用less/sass等css预编译器时postcss的执行时机问题;
- 通过路径进行图标文件合法性过滤;
- 以子目录名称和分辨率标识为基础的sprites图片命名规则。
下文将分别介绍boi针对上述问题的具体解决方案。
4.1 与css预编译器综合使用
postcss并非只支持原始的css语法,同时也支持less和sass等预编译语法。webpack根据loader的先后顺序从右至左依次进行编译,比如:
{
test: /\.less$/,
loader: 'css!less'
}
webpack对less文件的编译顺序为:less->css->style。那么在使用postcss时应该在哪一步执行呢?
虽然postcss支持less和sass,笔者也并不推荐直接使用postcss去编译less和sass。一方面是因为postcss支持的预编译器类型有限;另一方面即使postcss支持所有预编译语言,考虑到用户配置预编译器的多样性,如果对不同编译器分派不同的postcss插件势必会造成boi框架体积的臃肿。
基于上述的考虑,postcss-loader的位置就已经确定了:在预编译loader之后,css-loader之前。如下:
{
test: /\.less$/,
loader: 'css!postcss!less'
}
之所以在css-loader之前还有另外一个原因, postcss-sprites将散列的图标合并成sprites之后首先要将生成的sprites图片存放于一个临时目录内,然后在通过css-loader进行资源依赖解析并编译到统一的dest目录中。所以中间有一个暂存的过程,必须通过css-loader进行依赖解析才能得到最终的结果。
4.2 合法性过滤
boi通过路径进行图标合法性标识,首先根据用户的配置创建验证正则:
const REG_SPRITES_NAME = new RegExp([
path.posix.normalize(spritesConfig.dir).replace(/^\.*/, '').replace(/\//, '\\/'),
'\\/\.+\\.',
_.isArray(config.image.extType) ? '(' + config.image.extType.join('|') +')' : config.image.extType,
'\$'
].join(''), 'i');
然后配置postcss-sprites的filterBy
钩子函数进行合法性验证:
filterBy: (image) => {
if (!REG_SPRITES_NAME.test(image.url)) {
return Promise.reject();
}
return Promise.resolve();
}
4.3 分组规则
分组的依据有两个:目录名称和分辨率标识。首先需要根据用户的配置创建目录名称验证和分辨率标识验证的正则:
// 合法的散列图path
const REG_SPRITES_PATH = new RegExp([
path.posix.normalize(spritesConfig.dir).replace(/^\.*/, '').replace(/\//, '\\/'),
'\\/(.*?)\\/.*'
].join(''), 'i');
// 合法的retina标识
const REG_SPRITES_RETINA = new RegExp([
'@(\\d+)x\\.',
_.isArray(config.image.extType) ? '(' + config.image.extType.join('|') +')' : config.image.extType,
].join(''), 'i');
然后通过postcss-sprites的groupBy
钩子函数进行分组规则制定:
groupBy: (image) => {
let groups = null;
let groupName = undefined;
if (spritesConfig && spritesConfig.split) {
groups = REG_SPRITES_PATH.exec(image.url);
groupName = groups ? groups[1] : 'icons';
} else {
groupName = 'icons';
}
if (spritesConfig && spritesConfig.retina) {
image.retina = true;
image.ratio = 1;
let ratio = REG_SPRITES_RETINA.exec(image.url);
if (ratio) {
ratio = ratio[1];
while (ratio > 10) {
ratio = ratio / 10;
}
image.ratio = ratio;
image.groups = image.groups.filter((group) => {
return ('@' + ratio + 'x') !== group;
});
groupName += '@' + ratio + 'x';
}
}
return Promise.resolve(groupName);
}
上述代码包括以下逻辑:
- 如果用户配置
split:true
,boi会对子目录进行正则验证,如果存在子目录将会单独分组;若不存子目录子默认分组名称为'icons'
; - 如果用户配置
retina:true
,boi会验证图标文件名是否包含分辨率标识,如果存在则将groupName
加上类似'@2x'
的后缀。
各位可能注意到上述代码中以下的部分比较怪异:
image.groups = image.groups.filter((group) => {
return ('@' + ratio + 'x') !== group;
});
postcss-sprites识别到图标存在分辨率标识会生成单独的分组名称,如果不进行上述过滤的话,最终生成的sprites图片名称类似sprites.@2x.icons.png
。以上过滤是为了将@2x
分组删除,以便编译后的文件名更具语义化,比如sprites.icons@2x.png
。
5. 开源代码
各位可以结合源码/lib/config/genConfig/mp/style.js理解本文的内容。