5

Webpack手写loader和plugin

 3 years ago
source link: https://xieyufei.com/2020/10/12/Webpack-Handwrite.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

  我们在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从入门到精通全解析

更多前端资料请关注作者公众号``【前端壹读】``。
PS:公众号接入了图灵机器人小壹,欢迎各位老铁来撩。
follow.png

本文地址: http://xieyufei.com/2020/10/12/Webpack-Handwrite.html

@谢小飞的网站

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

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK