Webpack手写loader和plugin

  我们在Webpack基础篇介绍了多种loader和plugin以及每种的用途;那么他们两者在webpack内部是如何进行工作的呢?让我们手写一个loader和plugin来看看它内部的原理,以便加深对webpack的理解。

手写loader

  我们在在Webpack配置基础篇介绍过,loader是链式传递的,对文件资源从上一个loader传递到下一个,而loader的处理也遵循着从下到上的顺序,我们简单了解一下loader的开发原则:

  1. 单一原则: 每个Loader只做一件事,简单易用,便于维护;
  2. 链式调用: Webpack 会按顺序链式调用每个Loader;
  3. 统一原则: 遵循Webpack制定的设计规则和结构,输入与输出均为字符串,各个Loader完全独立,即插即用;
  4. 无状态原则:在转换不同模块时,不应该在loader中保留状态;

  因此我们就来尝试写一个less-loaderstyle-loader,将less文件处理后通过style标签的方式渲染到页面上去。

同步loader

  loader默认导出一个函数,接受匹配到的文件资源字符串和SourceMap,我们可以修改文件内容字符串后再返回给下一个loader进行处理,因此最简单的一个loader如下:

1
2
3
module.exports = function(source, map){
return source
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

导出的loader函数不能使用箭头函数,很多loader内部的属性和方法都需要通过this进行调用,比如this.cacheable()来进行缓存、this.sourceMap判断是否需要生成sourceMap等。

  我们在项目中创建一个loader文件夹,用来存放我们自己写的loader,然后新建我们自己的style-loader:

1
2
3
4
5
6
7
8
9
10
//loader/style-loader.js
function loader(source, map) {
let style = `
let style = document.createElement('style');
style.innerHTML = ${JSON.stringify(source)};
document.head.appendChild(style)
`;
return style;
}
module.exports = loader;

  这里的source就可以看做是处理后的css文件字符串,我们把它通过style标签的形式插入到head中;同时我们也发现最后返回的是一个JS代码的字符串,webpack最后会将返回的字符串打包进模块中。

异步loader

  上面的style-loader都是同步操作,我们在处理source时,有时候会进行异步操作,一种方法是通过async/await,阻塞操作执行;另一种方法可以通过loader本身提供的回调函数callback

1
2
3
4
5
6
7
8
9
10
//loader/less-loader
const less = require("less");
function loader(source) {
const callback = this.async();
less.render(source, function (err, res) {
let { css } = res;
callback(null, css);
});
}
module.exports = loader;

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  callback的详细传参方法如下:

1
2
3
4
5
6
7
8
9
10
callback({
//当无法转换原内容时,给 Webpack 返回一个 Error
error: Error | Null,
//转换后的内容
content: String | Buffer,
//转换后的内容得出原内容的Source Map(可选)
sourceMap?: SourceMap,
//原内容生成 AST语法树(可选)
abstractSyntaxTree?: AST
})

  有些时候,除了将原内容转换返回之外,还需要返回原内容对应的Source Map,比如我们转换less和scss代码,以及babel-loader转换ES6代码,为了方便调试,需要将Source Map也一起随着内容返回。

1
2
3
4
5
6
7
8
9
10
//loader/less-loader
const less = require("less");
function loader(source) {
const callback = this.async();
less.render(source,{sourceMap: {}}, function (err, res) {
let { css, map } = res;
callback(null, css, map);
});
}
module.exports = loader;

  这样我们在下一个loader就能接收到less-loader返回的sourceMap了,但是需要注意的是:

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

Source Map生成很耗时,通常在开发环境下才会生成Source Map,其它环境下不用生成。Webpack为loader提供了this.sourceMap这个属性来告诉loader当前构建环境用户是否需要生成Source Map。

加载本地loader

  loader文件准备好了之后,我们需要将它们加载到webpack配置中去;在基础篇中,我们加载第三方的loader只需要安装后在loader属性中写loader名称即可,现在加载本地loader需要把loader的路径配置上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module.exports = {
module: {
rules: [{
test: /\.less/,
use: [
{
loader: './loader/style-loader.js',
},
{
loader: path.resolve(__dirname, "loader", "less-loader"),
},
],
}]
}
}

  我们可以在loader中配置本地loader的相对路径或者绝对路径,但是这样写起来比较繁琐,我们可以利用webpack提供的resolveLoader属性,来告诉webpack应该去哪里解析本地loader。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
module.exports = {
module: {
rules: [{
test: /\.less/,
use: [
{
loader: 'style-loader',
},
{
loader: 'less-loader',
},
],
}]
},
resolveLoader:{
modules: [path.resolve(__dirname, 'loader'), 'node_modules']
}
}

  这样webpack会先去loader文件夹下找loader,没有找到才去node_modules;因此我们写的loader尽量不要和第三方loader重名,否则会导致第三方loader被覆盖加载。

处理参数

  我们在配置loader时,经常会给loader传递参数进行配置,一般是通过options属性来传递的,也有像url-loader通过字符串来传参:

1
2
3
4
{
test: /\.(jpg|png|gif|bmp|jpeg)$/,
use: 'url-loader?limt=1024&name=[hash:8].[ext]'
}

  webpack也提供了query属性来获取传参;但是query属性很不稳定,如果像上面的通过字符串来传参,query就返回字符串格式,通过options方式就会返回对象格式,这样不利于我们处理。因此我们借助一个官方的包loader-utils帮助处理,它还提供了很多有用的工具。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const { 
getOptions,
parseQuery,
stringifyRequest,
} = require("loader-utils");

module.exports = function (source, map) {
//获取options参数
const options = getOptions(this);
//解析字符串为对象
parseQuery("?param1=foo")
//将绝对路由转换成相对路径
//以便能在require或者import中使用以避免绝对路径
stringifyRequest(this, "test/lib/index.js")
}

  常用的就是getOptions将处理后的参数返回出来,它内部的实现逻辑也非常的简单,也是根据query属性进行处理,如果是字符串的话调用parseQuery方法进行解析,源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//loader-utils/lib/getOptions.js
'use strict';
const parseQuery = require('./parseQuery');
function getOptions(loaderContext) {
const query = loaderContext.query;
if (typeof query === 'string' && query !== '') {
return parseQuery(loaderContext.query);
}
if (!query || typeof query !== 'object') {
return {};
}
return query;
}
module.exports = getOptions;

  获取到参数后,我们还需要对获取到的options参数进行完整性校验,避免有些参数漏传,如果一个个判断校验比较繁琐,这就用到另一个官方包schema-utils

1
2
3
4
5
6
7
8
9
const { getOptions } = require("loader-utils");
const { validate } = require("schema-utils");
const schema = require("./schema.json");
module.exports = function (source, map) {
const options = getOptions(this);
const configuration = { name: "Loader Name"};
validate(schema, options, configuration);
//省略其他代码
}

  validate函数并没有返回值,打印返回值发现是`undefined,因为如果参数不通过的话直接会抛出ValidationError异常,直接进程中断;这里引入了一个schema.json,就是我们对options``中参数进行校验的一个json格式的对应表:

1
2
3
4
5
6
7
8
9
10
11
12
{
"type": "object",
"properties": {
"source": {
"type": "boolean"
},
"name": {
"type": "string"
},
},
"additionalProperties": false
}

  properties中的健名就是我们需要检验的options中的字段名称,additionalProperties代表了是否允许options中还有其他额外的属性。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

less-loader源码分析

  写完我们自己简单的less-loader,让我们来看一下官方的less-loader源码到底是怎么样的,这里贴上部分源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import less from 'less';
import { getOptions } from 'loader-utils';
import { validate } from 'schema-utils';
import schema from './options.json';
async function lessLoader(source) {
const options = getOptions(this);
//校验参数
validate(schema, options, {
name: 'Less Loader',
baseDataPath: 'options',
});
const callback = this.async();
//对options进一步处理,生成less渲染的参数
const lessOptions = getLessOptions(this, options);
//是否使用sourceMap,默认取options中的参数
const useSourceMap =
typeof options.sourceMap === 'boolean'
? options.sourceMap : this.sourceMap;
//如果使用sourceMap,就在渲染参数加入
if (useSourceMap) {
lessOptions.sourceMap = {
outputSourceFiles: true,
};
}
let data = source;
let result;
try {
result = await less.render(data, lessOptions);
} catch (error) {
}
const { css, imports } = result;
//有sourceMap就进行处理
let map =
typeof result.map === 'string'
? JSON.parse(result.map) : result.map;

callback(null, css, map);
}
export default lessLoader;

  可以看到官方的less-loader和我们写的简单的loader本质上都是调用less.render函数,对文件资源字符串进行处理,然后将处理好后的字符串和sourceMap通过callback返回。

loader依赖

  在loader中,我们有时候也会使用到外部的资源文件,我们需要在loader对这些资源文件进行声明;这些声明信息主要用于使得缓存loader失效,以及在观察模式(watch mode)下重新编译。

  我们尝试写一个banner-loader,在每个js文件资源后面加上我们自定义的注释内容;如果传了filename,就从文件中获取预设好的banner内容,首先我们预设两个banner的txt:

1
2
3
4
5
//loader/banner1.txt
/* build from banner1 */

//loader/banner2.txt
/* build from banner2 */

  然后在我们的banner-loader中根据参数来进行判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//loader/banner-loader
const fs = require("fs");
const path = require("path");
const { getOptions } = require("loader-utils");

module.exports = function (source) {
const options = getOptions(this);
if (options.filename) {
let txt = "";
if (options.filename == "banner1") {
this.addDependency(path.resolve(__dirname, "./banner1.txt"));
txt = fs.readFileSync(path.resolve(__dirname, "./banner1.txt"));
} else if (options.filename == "banner2") {
this.addDependency(path.resolve(__dirname, "./banner1.txt"));
txt = fs.readFileSync(path.resolve(__dirname, "./banner1.txt"));
}
return source + txt;
} else if (options.text) {
return source + `/* ${options.text} */`;
} else {
return source;
}
};

  这里使用了this.addDependency的API将当前处理的文件添加到文件依赖中(并不是项目的package.json)。如果在观察模式下,依赖的text文件发生了变化,那么打包生成的文件内容也随之变化。

如果不添加this.addDependency的话项目并不会报错,只是在观察模式下,如果依赖的文件发生了变化生成的bundle文件并不能及时更新。

缓存加速

  在有些情况下,loader处理需要大量的计算非常耗性能(比如babel-loader),如果每次构建都重新执行相同的转换操作每次构建都会非常慢。

  因此webpack默认会将loader的处理结果标记为可缓存,也就是说在需要被处理的文件或者其依赖的文件没有发生变化时,它的输出结果必然是相同的;如果不想让webpack缓存该loader,可以禁用缓存:

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

1
2
3
4
5
module.exports = function(source) {
// 强制不缓存
this.cacheable(false);
return source;
};

手写loader所有代码均在webpackdemo19

手写plugin

  在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过Webpack提供的API改变输出结果。和手写loader一样,我们先来写一个简单的plugin:

1
2
3
4
5
6
7
8
//plugins/MyPlugin.js
class MyPlugin {
constructor() {
console.log("Plugin被创建了");
}
apply (compiler) {}
}
module.exports = MyPlugin;

  plugin的本质是类;我们在定义plugin时,其实是在定义一个类;定义好plugin后就可以在webpack配置中使用这个插件:

1
2
3
4
5
6
7
//webpack.config.js
const MyPlugin = require('./plugins/MyPlugin')
module.exports = {
plugins: [
new MyPlugin()
],
}

plugin.png

  这样我们的插件就在webpack中生效了;这时有些童鞋可能会想起来,我们在使用HtmlWebpackPlugin或者CleanWebpackPlugin等一些官方插件时,可以通过实例化插件传入参数;那么这里我们是否也能通过这种方式给我们的插件传参呢?

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//plugins/MyPlugin.js
class MyPlugin {
constructor(options) {
console.log("Plugin被创建了");
console.log(options);
this.options = options;
}
apply (compiler) {}
}
//webpack.config.js
module.exports = {
plugins: [
new MyPlugin({ title: 'MyPlugin' })
],
}

plugin1.png

  我们在构建插件时就能通过options获取配置信息,对插件做一些初始化的工作。在构造函数中我们发现多了一个apply函数,它会在webpack运行时被调用,并且注入compiler对象;其工作流程如下:

  1. webpack启动,执行new myPlugin(options),初始化插件并获取实例
  2. 初始化complier对象,调用myPlugin.apply(complier)给插件传入complier对象
  3. 插件实例获取complier,通过complier监听webpack广播的事件,通过complier对象操作webpack

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  我们可以通过apply函数中注入的compiler对象进行注册事件:

1
2
3
4
5
6
7
8
9
10
11
12
class MyPlugin {
apply(compiler) {
//不推荐使用,plugin函数被废弃了
// compiler.plugin("compile", (compilation) => {
// console.log("compile");
// });
//注册完成的钩子
compiler.hooks.done.tap("MyPlugin", (compilation) => {
console.log("compilation done");
});
}
}

  compiler不仅有同步的钩子,通过tap函数来注册,还有异步的钩子,通过tapAsynctapPromise来注册:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class MyPlugin {
apply(compiler) {
compiler.hooks.run.tapAsync("MyPlugin", (compilation, callback) => {
setTimeout(()=>{
console.log("compilation run");
callback()
}, 1000)
});
compiler.hooks.emit.tapPromise("MyPlugin", (compilation) => {
return new Promise((resolve, reject) => {
setTimeout(()=>{
console.log("compilation emit");
resolve();
}, 1000)
});
});
}
}

  这里又有一个compilation对象,它和上面提到的compiler对象都是Plugin和webpack之间的桥梁:

  • compiler对象包含了 Webpack 环境所有的的配置信息。这个对象在启动 webpack 时被一次性建立,并配置好所有可操作的设置,包括 options,loader 和 plugin。当在 webpack 环境中应用一个插件时,插件将收到此 compiler 对象的引用。可以使用它来访问 webpack 的主环境。
  • compilation对象包含了当前的模块资源、编译生成资源、变化的文件等。当运行webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。compilation 对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  compiler和compilation的区别在于:

  • compiler代表了整个webpack从启动到关闭的生命周期,而compilation只是代表了一次新的编译过程
  • compiler和compilation暴露出许多钩子,我们可以根据实际需求的场景进行自定义处理

手写FileListPlugin

  了解了compiler和compilation的区别,我们就来尝试一个简单的示例插件,在打包目录生成一个filelist.md文件,文件的内容是将所有构建生成文件展示在一个列表中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class FileListPlugin {
apply(compiler){
compiler.hooks.emit.tapAsync('FileListPlugin', (compilation, callback)=>{
var filelist = 'In this build:\n\n';
// 遍历所有编译过的资源文件,
// 对于每个文件名称,都添加一行内容。
for (var filename in compilation.assets) {
filelist += '- ' + filename + '\n';
}
// 将这个列表作为一个新的文件资源,插入到 webpack 构建中:
compilation.assets['filelist.md'] = {
source: function() {
return filelist;
},
size: function() {
return filelist.length;
}
};
callback();
})
}
}
module.exports = FileListPlugin

  我们这里用到了assets对象,它是所有构建文件的一个输出对象,打印出来大概长这样:

1
2
3
4
{
'main.bundle.js': { source: [Function: source], size: [Function: size] },
'index.html': { source: [Function: source], size: [Function: size] }
}

  我们手动加入一个filelist.md文件的输出;打包后我们在dist文件夹中会发现多了这个文件:

1
2
3
4
In this build:

- main.bundle.js
- index.html

  这个插件就完成了我们的预期任务了。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

参考

webpack loader从入门到精通全解析


本网所有内容文字和图片,版权均属谢小飞所有,任何媒体、网站或个人未经本网协议授权不得转载、链接、转贴或以其他方式复制发布/发表。如需转载请关注公众号【前端壹读】后回复【转载】。