撸一个webpack-loader&plugin

在 webpack 中,loader 和 plugin 的区分是很清楚的,针对文件模块转换要做的使用 loader,而其他构建过程,干涉构建内容的可以使用 plugin。

24 个实例入门并掌握「Webpack4」(一)

24 个实例入门并掌握「Webpack4」(二)

24 个实例入门并掌握「Webpack4」(三)

编写一个 loader

webpack-plugin webpack-loader大全

新建文件夹,npm init -ynpm i webpack webpack-cli -D,新建 src/index.js,写入 console.log('hello world')

新建 loaders/replaceLoader.js 文件

1
2
3
module.exports = function(source) {
return source.replace('world', 'loader')
}

source 参数就是我们的源代码,这里是将源码中的 world 替换成 loader

新建 webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const path = require('path')

module.exports = {
mode: 'development',
entry: {
main: './src/index.js'
},
module: {
rules: [
{
test: /.js/,
use: [path.resolve(__dirname, './loaders/replaceLoader.js')] // 引入自定义 loader
}
]
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js'
}
}

目录结构:

打包后打开 dist/main.js 文件,在最底部可以看到 world 已经被改为了 loader,一个最简单的 loader 就写完了

添加 optiions 属性

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
const path = require('path')

module.exports = {
mode: 'development',
entry: {
main: './src/index.js'
},
module: {
rules: [
{
test: /.js/,
use: [
{
loader: path.resolve(__dirname, './loaders/replaceLoader.js'),
options: {
name: 'xh'
}
}
]
}
]
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js'
}
}

修改 replaceLoader.js 文件,保存后打包,输出看看效果

1
2
3
4
module.exports = function(source) {
console.log(this.query)
return source.replace('world', this.query.name)
}


打包后生成的文件也改为了 options 中定义的 name

更多的配置见官网 API,找到 Loader Interface,里面有个 this.query

如果你的 options 不是一个对象,而是按字符串形式写的话,可能会有一些问题,这里官方推荐使用 loader-utils 来获取 options 中的内容

充分利用 loader-utils 包。它提供了许多有用的工具,但最常用的一种工具是获取传递给 loader 的选项。schema-utils 包配合 loader-utils,用于保证 loader 选项,进行与 JSON Schema 结构一致的校验。

安装 npm i loader-utils -D,修改 replaceLoader.js

1
2
3
4
5
6
7
const loaderUtils = require('loader-utils')

module.exports = function(source) {
const options = loaderUtils.getOptions(this)
console.log(options)
return source.replace('world', options.name)
}

console.log(options)console.log(this.query) 输出内容一致

如果你想传递额外的信息出去,return 就不好用了,官网给我们提供了 this.callback API,用法如下

1
2
3
4
5
6
this.callback(
err: Error | null,
content: string | Buffer,
sourceMap?: SourceMap,
meta?: any
)

修改 replaceLoader.js

1
2
3
4
5
6
7
8
const loaderUtils = require('loader-utils')

module.exports = function(source) {
const options = loaderUtils.getOptions(this)
const result = source.replace('world', options.name)

this.callback(null, result)
}

目前没有用到 sourceMap(必须是此模块可解析的源映射)、meta(可以是任何内容(例如一些元数据)) 这两个可选参数,只将 result 返回回去,保存重新打包后,效果和 return 是一样的

如果在 loader 中写异步代码,会怎么样

1
2
3
4
5
6
7
8
9
10
const loaderUtils = require('loader-utils')

module.exports = function(source) {
const options = loaderUtils.getOptions(this)

setTimeout(() => {
const result = source.replace('world', options.name)
return result
}, 1000)
}

报错 loader 没有返回,这里使用 this.async 来写异步代码

1
2
3
4
5
6
7
8
9
10
11
12
const loaderUtils = require('loader-utils')

module.exports = function(source) {
const options = loaderUtils.getOptions(this)

const callback = this.async()

setTimeout(() => {
const result = source.replace('world', options.name)
callback(null, result)
}, 1000)
}

模拟一个同步 loader 和一个异步 loader

新建一个 replaceLoaderAsync.js 文件,将之前写的异步代码放入,修改 replaceLoader.js 为同步代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// replaceLoaderAsync.js

const loaderUtils = require('loader-utils')
module.exports = function(source) {
const options = loaderUtils.getOptions(this)
const callback = this.async()
setTimeout(() => {
const result = source.replace('world', options.name)
callback(null, result)
}, 1000)
}

// replaceLoader.js

module.exports = function(source) {
return source.replace('xh', 'world')
}

修改 webpack.config.js,loader 的执行顺序是从下到上,先执行异步代码,将 world 改为 xh,再执行同步代码,将 xh 改为 world

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
module: {
rules: [
{
test: /.js/,
use: [
{
loader: path.resolve(__dirname, './loaders/replaceLoader.js')
},
{
loader: path.resolve(__dirname, './loaders/replaceLoaderAsync.js'),
options: {
name: 'xh'
}
}
]
}
]
}

保存后打包,在 mian.js 中可以看到已经改为了 hello world,使用多个 loader 也完成了

如果有多个自定义 loader,每次都通过 path.resolve(__dirname, xxx) 这种方式去写,有没有更好的方法?

使用 resolveLoader,定义 modules,当你使用 loader 的时候,会先去 node_modules 中去找,如果没找到就会去 ./loaders 中找

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
const path = require('path')

module.exports = {
mode: 'development',
entry: {
main: './src/index.js'
},
resolveLoader: {
modules: ['node_modules', './loaders']
},
module: {
rules: [
{
test: /.js/,
use: [
{
loader: 'replaceLoader.js'
},
{
loader: 'replaceLoaderAsync.js',
options: {
name: 'xh'
}
}
]
}
]
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js'
}
}

编写一个 plugin

使用阶段式的构建回调,开发者可以引入它们自己的行为到 webpack 构建流程中。
首先新建一个文件夹,npm 起手式操作一番,具体的在前几节已经说了,不再赘述

在根目录下新建 plugins 文件夹,新建 copyright-webpack-plugin.js,一般我们用的都是 xxx-webpack-plugin,所以我们命名也按这样来,plugin 的定义是一个类

1
2
3
4
5
6
7
8
classCopyrightWebpackPlugin{
constructor() {
console.log('插件被使用了')
}
apply(compiler) {}
}

module.exports = CopyrightWebpackPlugin

在 webpack.config.js 中使用,所以每次使用 plugin 都要使用 new,因为本质上 plugin 是一个类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const path = require('path')
const CopyrightWebpackPlugin = require('./plugins/copyright-webpack-plugin')

module.exports = {
mode: 'development',
entry: {
main: './src/index.js'
},
plugins: [new CopyrightWebpackPlugin()],
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js'
}
}

保存后打包,插件被使用了,只不过我们什么都没干

如果我们要传递参数,可以这样

1
2
3
new CopyrightWebpackPlugin({
name: 'xh'
})

同时在 copyright-webpack-plugin.js 中接收

1
2
3
4
5
6
7
8
9
classCopyrightWebpackPlugin{
constructor(options) {
console.log('插件被使用了')
console.log('options = ', options)
}
apply(compiler) {}
}

module.exports = CopyrightWebpackPlugin


我们先把 constructor 注释掉,在即将要把打包的结果,放入 dist 目录之前的这个时刻,我们来做一些操作

apply(compiler) {} compiler 可以看作是 webpack 的实例,具体见官网 compiler-hooks

hooks 是钩子,像 vue、react 的生命周期一样,找到 emit 这个时刻,将打包结果放入 dist 目录前执行,这里是个 AsyncSeriesHook 异步方法

1
2
3
4
5
6
7
8
9
10
11
12
13
class CopyrightWebpackPlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync(
'CopyrightWebpackPlugin',
(compilation, cb) => {
console.log(11)
cb()
}
)
}
}

module.exports = CopyrightWebpackPlugin

因为 emit异步的,可以通过 tapAsync 来写,当要把代码放入到 dist 目录之前,就会触发这个钩子,走到我们定义的函数里,如果你用 tapAsync 函数,记得最后要用 cb() ,tapAsync 要传递两个参数,第一个参数传递我们定义的插件名称

保存后再次打包,我们写的内容也输出了

compilation 这个参数里存放了这次打包的所有内容,可以输出一下 compilation.assets 看一下

返回结果是一个对象,main.js 是 key,也就是打包后生成的文件名及文件后缀,我们可以来仿照一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
classCopyrightWebpackPlugin{
apply(compiler) {
compiler.hooks.emit.tapAsync(
'CopyrightWebpackPlugin',
(compilation, cb) => {
// 生成一个 copyright.txt 文件
compilation.assets['copyright.txt'] = {
source: function() {
return'copyright by xh'
},
size: function() {
return15// 上面 source 返回的字符长度
}
}
console.log('compilation.assets = ', compilation.assets)
cb()
}
)
}
}

module.exports = CopyrightWebpackPlugin


在 dist 目录下生成了 copyright.txt 文件

之前介绍的是异步钩子,现在使用同步钩子

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
classCopyrightWebpackPlugin{
apply(compiler) {
// 同步钩子
compiler.hooks.compile.tap('CopyrightWebpackPlugin', compilation => {
console.log('compile')
})

// 异步钩子
compiler.hooks.emit.tapAsync(
'CopyrightWebpackPlugin',
(compilation, cb) => {
compilation.assets['copyright.txt'] = {
source: function() {
return'copyright by xh'
},
size: function() {
return15// 字符长度
}
}
console.log('compilation.assets = ', compilation.assets)
cb()
}
)
}
}

module.exports = CopyrightWebpackPlugin

参考博客


写一个插件,去除webpack打包生成js中多余的注释

怎么写一个插件?

参照webpack官方教程Writing a Plugin

一个webpack plugin由一下几个步骤组成:

  1. 一个JavaScript类函数。
  2. 在函数原型 (prototype)中定义一个注入compiler对象的apply方法。
  3. apply函数中通过compiler插入指定的事件钩子,在钩子回调中拿到compilation对象
  4. 使用compilation操纵修改webapack内部实例数据。
  5. 异步插件,数据处理完后使用callback回调

完成插件初始架构

在之前说Tapable的时候,写了一个MyPlugin类函数,它已经满足了webpack plugin结构的前两点(一个JavaScript类函数,在函数原型 (prototype)中定义一个注入compiler

现在我们要让Myplugin满足后三点。首先,使用compiler指定的事件钩子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MyPlugin{
constructor() {

}
apply(conpiler){
conpiler.hooks.break.tap("WarningLampPlugin", () => console.log('WarningLampPlugin'));
conpiler.hooks.accelerate.tap("LoggerPlugin", newSpeed => console.log(`Accelerating to ${newSpeed}`));
conpiler.hooks.calculateRoutes.tapAsync("calculateRoutes tapAsync", (source, target, routesList, callback) => {
setTimeout(() => {
console.log(`tapAsync to ${source}${target}${routesList}`)
callback();
}, 2000)
});
}
}

编写插件

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
class MyPlugin {
constructor(options) {
this.options = options
this.externalModules = {}
}

apply(compiler) {
var reg = /("([^\\\"]*(\\.)?)*")|('([^\\\']*(\\.)?)*')|(\/{2,}.*?(\r|\n))|(\/\*(\n|.)*?\*\/)|(\/\*\*\*\*\*\*\/)/g
compiler.hooks.emit.tap('CodeBeautify', (compilation)=> {
Object.keys(compilation.assets).forEach((data)=> {
let content = compilation.assets[data].source() // 欲处理的文本
content = content.replace(reg, function (word) { // 去除注释后的文本
return /^\/{2,}/.test(word) || /^\/\*!/.test(word) || /^\/\*{3,}\//.test(word) ? "" : word;
});
compilation.assets[data] = {
source(){
return content
},
size(){
return content.length
}
}
})
})
}
}
module.exports = MyPlugin

第一步,使用compiler的emit钩子

emit事件是将编译好的代码发射到指定的stream中触发,在这个钩子执行的时候,我们能从回调函数返回的compilation对象上拿到编译好的stream。

1
compiler.hooks.emit.tap('xxx',(compilation)=>{})

第二步,访问compilation对象,我们用绑定提供了编译 compilation 引用的emit钩子函数,每一次编译都会拿到新的 compilation 对象。这些 compilation 对象提供了一些钩子函数,来钩入到构建流程的很多步骤中。

其中,我们需要的是compilation.assets

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
assetsCompilation {
assets:
{ 'js/index/main.js':
CachedSource {
_source: [Object],
_cachedSource: undefined,
_cachedSize: undefined,
_cachedMaps: {} } },
errors: [],
warnings: [],
children: [],
dependencyFactories:
ArrayMap {
keys:
[ [Object],
[Function: MultiEntryDependency],
[Function: SingleEntryDependency],
[Function: LoaderDependency],
[Object],
[Function: ContextElementDependency],
values:
[ NullFactory {},
[Object],
NullFactory {} ] },
dependencyTemplates:
ArrayMap {
keys:
[ [Object],
[Object],
[Object] ],
values:
[ ConstDependencyTemplate {},
RequireIncludeDependencyTemplate {},
NullDependencyTemplate {},
RequireEnsureDependencyTemplate {},
ModuleDependencyTemplateAsRequireId {},
AMDRequireDependencyTemplate {},
ModuleDependencyTemplateAsRequireId {},
AMDRequireArrayDependencyTemplate {},
ContextDependencyTemplateAsRequireCall {},
AMDRequireDependencyTemplate {},
LocalModuleDependencyTemplate {},
ModuleDependencyTemplateAsId {},
ContextDependencyTemplateAsRequireCall {},
ModuleDependencyTemplateAsId {},
ContextDependencyTemplateAsId {},
RequireResolveHeaderDependencyTemplate {},
RequireHeaderDependencyTemplate {} ] },
fileTimestamps: {},
contextTimestamps: {},
name: undefined,
_currentPluginApply: undefined,
fullHash: 'f4030c2aeb811dd6c345ea11a92f4f57',
hash: 'f4030c2aeb811dd6c345',
fileDependencies: [ '/Users/mac/web/src/js/index/main.js' ],
contextDependencies: [],
missingDependencies: [] }

优化所有 chunk 资源(asset)。资源(asset)会以key-value的形式被存储在 compilation.assets

第三步,遍历assets。

1)assets数组对象中的key是资源名,在Myplugin插件中,遍历Object.key()我们拿到了

1
2
3
main.css
bundle.js
index.html

2)调用Object.source() 方法,得到资源的内容

1
compilation.assets[data].source()

3)用正则,去除注释

1
2
3
4
5
6
 Object.keys(compilation.assets).forEach((data)=> {
let content = compilation.assets[data].source()
content = content.replace(reg, function (word) {
return /^\/{2,}/.test(word) || /^\/\*!/.test(word) || /^\/\*{3,}\//.test(word) ? "" : word;
})
});

第四步,更新compilation.assets[data]对象

1
2
3
4
5
6
7
8
compilation.assets[data] = {
source(){
return content
},
size(){
return content.length
}
}

第五步 在webpack中引用插件

webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const path  = require('path')
const MyPlugin = require('./plugins/MyPlugin')

module.exports = {
entry:'./src/index.js',
output:{
path:path.resolve('dist'),
filename:'bundle.js'
},
plugins:[
...
new MyPlugin()
]
}