十分钟写一个webpack精灵图plugin
前言
平时在开发时,UI
会提供很多icon
图片,有时候一个页面可能有十几张图片,这样加载此页面时就需要发送十几次http
请求,不光浪费流量,而且会造成页面卡顿,影响用户体验。这时候我们就需要“精灵图”来帮助我们减少请求次数了。
精灵图(英语:Sprite),又被称为拼合图。在电脑图形学中,当一张二维图像集成进场景中,成为整个显示图像的一部分时,这张图就称为精灵图。
因为常见碳酸饮料雪碧的英文名称也是“Sprite”,也有人会使用雪碧图的非正式译名
——维基百科
在个人开发中,自己制作精灵图和使用精灵图非常非常麻烦(需要计算每张小图在大图中的位置),不过有两个npm
库可以帮我们解决,spritesmith可以帮我们将一堆小图合并为精灵图,spritesheet-templates可以根据精灵图转为css
文件,这样在html
中就直接可以使用图片了。
我们何不将这两个库结合起来,开发一个自动化工具,在项目每次编译时都将小图转为精灵图。基于这种思想,我就我们平时最常用到的打包工具webpack
开发一个webpack-plugin
前置知识
在webpack
的初始化阶段,会遍历用户的plugins
,调用里面的apply
方法,传入complier
,compiler
可以调用webpack
构建过程中触发的很多钩子函数。
Compiler 模块是 webpack 的主要引擎,它通过 CLI 或者 Node API 传递的所有选项创建出一个 compilation 实例。 它扩展(extends)自 Tapable 类,用来注册和调用插件。 大多数面向用户的插件会首先在 Compiler 上注册。
在webpack
构建阶段,会创建compilation
,compilation
是单次编译过程的管理器,一次运行过程(从npm run server
开始到ctrl^c
)只有一个compiler
,但处于watch
(一旦项目文件变化会自动重新编译)状态时每次文件重新编译会再生成一个compilation
。构建过程会一边收集依赖模块一边遍历项目中所有的文件,这其中就包括loader
过程和babel
过程。再后面是生成阶段,这里我们用不到,就不赘述。
开发webpack-plugin
首先要了解清楚webpack
构建过程中各个时刻触发的钩子函数,然后选择适合的一个或多个钩子函数进行操作。
这两个钩子函数使我们会用到的
- run
在开始读取 records 之前调用。
- watchRun
监听模式下,一个新的 compilation 触发之后,但在 compilation 实际开始之前执行。
手写plugin
下面称合成精灵图的图片为“小图”
搭建环境
创建一个空文件,执行命令npm init -y
,如下图创建文件
安装相关依赖
yarn add webpack webpack-cli -D
配置一下/test/webpack.config.js
,模拟一下真实开发的场景
const path = require("path");
module.exports = {
mode: "development",
entry: {
main: path.resolve(__dirname, "./index.js")
},
output: {
filename: "main.js",
path: path.resolve(__dirname, "./dist")
},
};
在/package.json
里配置打包命令
// package.json
"scripts": {
"dev": "webpack --config test/webpack.config.js", // 指向test目录下的配置文件
"dev-watch": "webpack --config test/webpack.config.js --watch"
},
在命令后面加上加上--watch
,即进入watch
模式,若文件变化会重新构建,按ctrl^c
结束
运行yarn dev
即打包,出现main.js
即打包成功
开始
准备工作已经做好,我们就以src
下面的index.js
作为plugin
的入口。webpack-plugin
实质上是一个类,也可以说是一个函数,在webpack
构建过程中,会将plugin
类实例化,调用其中的apply
方法,并传入compiler
,在apply
方法里面就可以对compiler
提供的hook进行操作,代码如下
class plutoSprityPlugin {
apply(compiler) {
// 调用hook
}
}
module.exports = plutoSprityPlugin;
我们需要的hook
在compiler.hook
中,我们将compiler.hook
打印出来
{
...
run: Hook {
_args: [ 'compiler' ],
name: undefined,
taps: [ [Object] ],
interceptors: [],
_call: undefined,
call: undefined,
_callAsync: [Function: CALL_ASYNC_DELEGATE],
callAsync: [Function: CALL_ASYNC_DELEGATE],
_promise: [Function: PROMISE_DELEGATE],
promise: [Function: PROMISE_DELEGATE],
_x: undefined,
compile: [Function: COMPILE],
tap: [Function: tap],
tapAsync: [Function: tapAsync],
tapPromise: [Function: tapPromise],
constructor: [Function: AsyncSeriesHook]
},
watchRun: Hook {
_args: [ 'compiler' ],
name: undefined,
taps: [ [Object] ],
interceptors: [],
_call: undefined,
call: undefined,
_callAsync: [Function: CALL_ASYNC_DELEGATE],
callAsync: [Function: CALL_ASYNC_DELEGATE],
_promise: [Function: PROMISE_DELEGATE],
promise: [Function: PROMISE_DELEGATE],
_x: undefined,
compile: [Function: COMPILE],
tap: [Function: tap],
tapAsync: [Function: tapAsync],
tapPromise: [Function: tapPromise],
constructor: [Function: AsyncSeriesHook]
},
}
可以看到compiler.hook
下面就是对应的webpack
生命周期对应的钩子,想要在某个钩子里注册事件,还需访问里面的tap
、tapAsync
、tapPromise
,这三个方法继承自tapable
核心库。tap
注册的事件会同步执行;tapAsync
注册的事件是异步执行的;而使用tapPromise
注册的事件,事件处理函数必须返回一个Promise
实例。他们在不同的tapable
钩子里调用还不太一样,这里就不细说,我们这里是同步任务,只需使用tap
注册事件即可。
关于tap
,第一个参数是插件的名称(一个字符串),我试了一下,不管用什么字符串都可以,不过还是规范一点,使用当前插件的名称。第二个参数传入一个回调函数,当触发此hook
时就会调用该函数,并传入compiler
。在compiler
还可以调用其他更详细的hook
,这里用不到,大家有兴趣的话可以去webpack
官网学习。
编写如下代码测试一下能不能调用到webpack
钩子
apply(compiler) {
compiler.hooks.run.tap("pluto-sprity-webpack-plugin", compiler => {
console.log("触发run");
});
compiler.hooks.watchRun.tap("pluto-sprity-webpack-plugin", compiler => {
console.log("触发watchRun");
});
}
分别运行yarn dev
和yarn dev-watch
yarn dev
$ webpack --config test/webpack.config.js
触发run
yarn dev-watch
$ webpack --config test/webpack.config.js --watch
触发watchRun
可以看到成功调用
接下来我们在plutoSprityPlugin
类的constructor
中初始化一些参数,我们的参数在实例化插件的时候传进来,后面都会用得到。
// webpack.config.js
new plutoSprityPlugin(
{
glob: "assets/img/sprite/*.png",
cwd: path.resolve(__dirname, "src")
})
// /src/index.js
constructor(options) {
this._options = options;
if (!this._options.target) {
this._options.target = {};
}
this._options.target.css = this._options.target.css || "assets/css/sprite.css";
this._options.target.img = this._options.target.img || "assets/img/sprite.png";
}
将options
放到类的属性_options
中,并初始化css
路径和img
(大图)的路径。
监听文件
接下来我们就可以直接在run.tap
的回调函数里处理精灵图了,但是当webpack
处于watch
状态时,webpack
只会监听依赖的文件(从入口文件开始的全部依赖文件),我们的只使用了合并后的精灵图,小图并没有被其他文件依赖,所以当小图变化的时候webpack
并不会监听到,所以我们要手动监听。
在node的fs库里有fs.watch()和fs.watchFile
方法,但是它有很多限制
node.js
的fs.watch
:
- 不报告
MacOS
上的文件名。 - 在
MacOS
上使用Sublime
等编辑器时根本不报告事件。 - 经常报告事件两次。
- 发出大多数更改为
rename
. - 不提供递归查看文件树的简单方法。
- 不支持
Linux
上的递归监视。
node.js
的fs.watchFile
:
- 在事件处理方面几乎一样糟糕。
- 也不提供任何递归监测
- 导致
CPU
占用高。
我们这里使用一个完善了监听文件功能的npm
库,chokidar,具体的使用方法大家可以看文档,这里只演示怎么用。
我们在plutoSprityPlugin
类里面新增一个getWatcher
方法,这个方法负责监听options
里的glob
匹配的文件
const chokidar = require("chokidar");
class plutoSprityPlugin {
...
getWatcher() {
this._watcher = chokidar.watch(this._options.glob, {
cwd: this._options.cwd,
...this._options.options
});
this._watcher.on("all", (event, path) => {
console.log("event, path: ", event, path);
});
}
}
我们使用chokidar
的watch
方法,创建一个chokidar
实例,第一个参数传入glob
,第二个参数是一个对象,里面有很多配置可以选择,详情大家可以参考文档,这里我们配置cwd
选项,即根路径,这样的话chokidar
就只会监视cwd
路径下的glob
匹配的路径。创建完实例后使用on
方法监听,第一个参数为文件的行为,这里我们监测所有的行为all
,第二个参数是文件出现上述行为后的回调函数,会传入两个参数,event
为文件的行为,path
为文件的路径。
我们在/src/assets/img/sprite
下放几张图片
在run
的回调函数中调用getWatcher
compiler.hooks.run.tap("pluto-sprity-webpack-plugin", compiler => {
this.getWatcher()
});
运行yarn dev
$ webpack --config test/webpack.config.js
event, path: add assets\img\sprite\close.png
event, path: add assets\img\sprite\dianzan.png
event, path: add assets\img\sprite\down.png
event, path: add assets\img\sprite\share.png
可以看到文件的行为是add
,但是发现一个问题,在非watch
模式下调用getWatcher
后台进程会一直挂载
但是!在非watch
模式下我们不需要监听文件变化,只需要直接转换精灵图就可以,所以getWatcher
只在watchRun.tap
中调用。
我们是要文件变化以后才开始生成精灵图的,所以得传入一个回调函数,在文件变化后调用。
getWatcher(cb) {
...
this._watcher.on("all", () => {
typeof cb === "function" && cb();
});
}
我们定义一个generateSprite
方法来执行生成精灵图操作,写入console.log("生成精灵图")
来模拟一下
...
apply(){
compiler.hooks.watchRun.tap("pluto-sprity-webpack-plugin", compiler => {
this.getWatcher(() => {
this.generateSprite()
})
});
}
generateSprite(){
console.log("生成精灵图");
}
运行yarn dev-watch
,控制台打印如下
$ webpack --config test/webpack.config.js --watch
触发watchRun
生成精灵图
生成精灵图
生成精灵图
生成精灵图
什么?有几张图片就给我生成几次?而我们在第一次构建时只需要执行一次精灵图转换,要解决这个问题,我们可以使用chokidar
的一个配置
- ignoreInitial (default: false). If set to false then add/addDir events are also emitted for matching paths while instantiating the watching as chokidar discovers these file paths (before the ready event).
意思就是忽略第一次的文件变化,加上这么一句即可。
this._watcher = chokidar.watch(this._options.glob, {
...
ignoreInitial: true, // 忽略首次文件变更
});
为了第一次构建调用generateSprite
,在watchRun.tap
里再加上this.generateSprite()
,同时在run.tap
中也加上。
this.getWatcher(() => {
this.generateSprite()
})
this.generateSprite()
到这里我们就基本完成文件的监听和生成精灵图的联动,不管你对文件做什么操作,都会触发generateSprite
,但是这里有一个问题,当你试图更改文件的文件名时,比如说将close.png
改为close1.png
,chokidar
会先触发close1.png
的add
,然后触发close.png的unlink
,这样会导致generateSprite
运行两次。
快速触发两次还好,我们有时候会批量新增或者删除很多图片,这样会快速触发IO操作,而IO操作是非常消耗计算机性能的,这肯定不能忍。其实监听快速的IO操作类似在网页上监听onInput操作,在监听onInput时我们通常会使用防抖来防止过度发送http请求,这里也一样,它们都是只要一顿操作后的结果,中间过程不是很重要。所以我们引入防抖函数。
compiler.hooks.watchRun.tap(pluginName, compiler => {
this.getWatcher(Debounce(() => {
this.generateSprite();
}, 500));
...
});
这时我们再来批量操作文件,就不会在短时间内进行大量IO操作了
生成精灵图
接下来完善generateSprite
首先我们使用spritesmith
库的run
方法将小图合并为大图,详细的使用文档可以参考spritesmith,我这里新建一个util.js
,封装这个工具函数。由于spritesmith
的run
方法为异步操作,这里用promise
包裹一下。(记得yarn add
安装一下)
function spritesmithRun(src) {
return new Promise((resolve, reject) => {
Spritesmith.run({ src }, function handleResult(err, result) {
if (err) {
reject(err);
} else {
resolve(result);
}
});
});
}
传入的src
为所有小图的路径组成的数组,那么我们需要解析glob
来获取所有的图片路径。其实这里还有一个办法,就是使用this._watche
r的getWatched()
方法,下面来说说为什么不用它
generateSprite(){
const res = this._watcher.getWatched()
console.log('res: ', res);
}
// 打印出来
$ webpack --config test/webpack.config.js --watch
generateSprite:
res: {}
没错,第一次打印出来是一个空对象,等到监听的文件变化时第二次构建后,才能获取到监听的路径——这也太鸡肋了。
还有一个原因是非watch
模式下,并没有生成watch
实例。综上,我们还是使用glob库来解析,这个解析过程也是异步,写在util
中。
// yarn add glob
const glob = require("glob");
const getPaths = (globPath, cwd) => {
return new Promise((resolve, reject) => {
glob(globPath, {
cwd
}, function(err, files) {
if (err) {
reject(err);
} else {
resolve(files);
}
});
});
};
结合一下可得到下面代码
async generateSprite(){
const paths = await getPaths(this._options.glob, this._options.cwd);
const sourcePaths = paths.map(v => path.resolve(this._options.cwd, v));
const spritesRes = await spritesmithRun(sourcePaths);
console.log('spritesRes: ', spritesRes);
}
/*
spritesRes: {
coordinates: {
'D:\1-front-end\netease\pluto-sprity-plugin-test\test\src\assets\img\sprite\close.png': { x: 0, y: 0, width: 200, height: 200 },
'D:\1-front-end\netease\pluto-sprity-plugin-test\test\src\assets\img\sprite\dianzan.png': { x: 200, y: 0, width: 200, height: 200 },
'D:\1-front-end\netease\pluto-sprity-plugin-test\test\src\assets\img\sprite\down.png': { x: 0, y: 200, width: 200, height: 200 },
'D:\1-front-end\netease\pluto-sprity-plugin-test\test\src\assets\img\sprite\share.png': { x: 200, y: 200, width: 200, height: 200 }
},
properties: { width: 400, height: 400 },
image: <Buffer 89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52 00 00 01 90 00 00 01 90 08 06 00 00 00 80 bf 36 cc 00 00 00 02 49 44 41 54 78 01 ec 1a 7e d2 00 00 25 ... 9685 more bytes>
}
*/
可以看到输出的大图信息对象包含三个属性,coordinates
是小图的路径和宽高信息,properties
为大图的宽高,image
为大图的二进制信息。既然有了二进制信息,我们直接根据img
的路径将二进制信息写入文件。
如果img
路径对应的目录不存在,不能直接使用node.js
的fs.writeFile
入,需要先创建目录,这里使用mkdirp库,先生成对应目录,再写入文件,同样封装成一个函数写在util
中。在index.js
中直接传入大图路径和二进制信息即可。
// yarn add mkdirp
const mkdirp = require("mkdirp");
async function writrFile(dir, image) {
return new Promise((resolve, reject) => {
mkdirp.sync(path.dirname(dir));
fs.writeFile(dir, image, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
// index.js
const imgPath = path.resolve(this._options.cwd, this._options.target.img);
if (spritesRes.image) {
await writrFile(imgPath, spritesRes.image);
}
生成css文件
生成完大图,接下来生成css文件,使用spritesheet-templates。使用前先查看官网中需要传入的数据结构
这些数据在上面已经有了,整理一下直接使用就可以。css
中的background
路径我没有使用绝对路径,一般我们都是使用相对路径,所以我使用path.relative
处理了一下,转为相对路径,完整代码如下:
async generateSprite() {
const paths = await getPaths(this._options.glob, this._options.cwd);
const sourcePaths = paths.map(v => path.resolve(this._options.cwd, v));
const spritesRes = await spritesmithRun(sourcePaths);
const imgPath = path.resolve(this._options.cwd, this._options.target.img);
const cssPath = path.resolve(this._options.cwd, this._options.target.css);
// 相对路径
const cssToImg = path.normalize(path.relative(path.dirname(cssPath), imgPath));
if (spritesRes.image) {
await writrFile(imgPath, spritesRes.image);
}
const spritesheetObj = Object.entries(spritesRes.coordinates).reduce((v, t) => {
v.push({
name: path.parse(t[0]).name,
...t[1]
});
return v;
}, []);
const templaterRes = templater({
sprites: spritesheetObj,
spritesheet: {
...spritesRes.properties,
image: cssToImg // css文件中读取精灵图的路径
}
});
await writrFile(cssPath, templaterRes);
}
压缩css
现在生成的css文件是业务代码形式,这个css文件是不修改的,所以使用clean-css压缩一下,当然,是否压缩取决于用户,怎么配置也取决于用户。
const CleanCSS = require('clean-css');
// 根于传入的compressCss来决定是否压缩,若压缩,则使用cssOptions作为clean-css的options
await writrFile(cssPath, this._options.compressCss ? new CleanCSS(this._options.cssOptions).minify(templaterRes).styles : templaterRes);
测试
测试图如下
合成后的精灵图
生成的css(无压缩)
.icon-close {
background-image: url(..\img\sprite.png);
background-position: 0px 0px;
width: 200px;
height: 200px;
}
.icon-dianzan {
background-image: url(..\img\sprite.png);
background-position: -200px 0px;
width: 200px;
height: 200px;
}
.icon-down {
background-image: url(..\img\sprite.png);
background-position: 0px -200px;
width: 200px;
height: 200px;
}
.icon-share {
background-image: url(..\img\sprite.png);
background-position: -200px -200px;
width: 200px;
height: 200px;
}
压缩后
.icon-close{background-image:url(..\img\sprite.png);background-position:0 0;width:200px;height:200px}.icon-dianzan{background-image:url(..\img\sprite.png);background-position:-200px 0;width:200px;height:200px}.icon-down{background-image:url(..\img\sprite.png);background-position:0 -200px;width:200px;height:200px}.icon-share{background-image:url(..\img\sprite.png);background-position:-200px -200px;width:200px;height:200px}
尾声
webpack
自动生成精灵图的插件到这里就结束啦,创作不易,欢迎点赞、收藏、转发。
做完webpackPlugin,可以做一个babel-plugin放松一下,你还在手动部署埋点吗?从0到1开发Babel埋点自动植入插件!
npm
地址:www.npmjs.com/package/plu…
github
地址:github.com/plutoLam/pl…
转载自:https://juejin.cn/post/7106283722697080839