十分钟写一个webpack精灵图plugin

前言

平时在开发时,UI会提供很多icon图片,有时候一个页面可能有十几张图片,这样加载此页面时就需要发送十几次http请求,不光浪费流量,而且会造成页面卡顿,影响用户体验。这时候我们就需要“精灵图”来帮助我们减少请求次数了。

精灵图(英语:Sprite),又被称为拼合图。在电脑图形学中,当一张二维图像集成进场景中,成为整个显示图像的一部分时,这张图就称为精灵图

因为常见碳酸饮料雪碧的英文名称也是“Sprite”,也有人会使用雪碧图的非正式译名

——维基百科

在个人开发中,自己制作精灵图和使用精灵图非常非常麻烦(需要计算每张小图在大图中的位置),不过有两个npm库可以帮我们解决,spritesmith可以帮我们将一堆小图合并为精灵图,spritesheet-templates可以根据精灵图转为css文件,这样在html中就直接可以使用图片了。

我们何不将这两个库结合起来,开发一个自动化工具,在项目每次编译时都将小图转为精灵图。基于这种思想,我就我们平时最常用到的打包工具webpack开发一个webpack-plugin

前置知识

webpack的初始化阶段,会遍历用户的plugins,调用里面的apply方法,传入compliercompiler可以调用webpack构建过程中触发的很多钩子函数。

Compiler 模块是 webpack 的主要引擎,它通过 CLI 或者 Node API 传递的所有选项创建出一个 compilation 实例。 它扩展(extends)自 Tapable 类,用来注册和调用插件。 大多数面向用户的插件会首先在 Compiler 上注册。

webpack构建阶段,会创建compilationcompilation是单次编译过程的管理器,一次运行过程(从npm run server开始到ctrl^c)只有一个compiler,但处于watch(一旦项目文件变化会自动重新编译)状态时每次文件重新编译会再生成一个compilation。构建过程会一边收集依赖模块一边遍历项目中所有的文件,这其中就包括loader过程和babel过程。再后面是生成阶段,这里我们用不到,就不赘述。

开发webpack-plugin首先要了解清楚webpack构建过程中各个时刻触发的钩子函数,然后选择适合的一个或多个钩子函数进行操作。

这两个钩子函数使我们会用到的

  1. run

在开始读取 records 之前调用。

  1. 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;
复制代码

我们需要的hookcompiler.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生命周期对应的钩子,想要在某个钩子里注册事件,还需访问里面的taptapAsynctapPromise,这三个方法继承自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 devyarn 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.jsfs.watch

  • 不报告 MacOS 上的文件名。
  • MacOS 上使用 Sublime 等编辑器时根本不报告事件。
  • 经常报告事件两次。
  • 发出大多数更改为rename.
  • 不提供递归查看文件树的简单方法。
  • 不支持 Linux上的递归监视。

node.jsfs.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);
    });
  }
}
复制代码

我们使用chokidarwatch方法,创建一个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.pngchokidar会先触发close1.pngadd,然后触发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,封装这个工具函数。由于spritesmithrun方法为异步操作,这里用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._watcher的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.jsfs.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…

猜你喜欢

转载自juejin.im/post/7106283722697080839