webpack加载器编程

原理

loader 是导出为函数的 js 模块,它支持同步模式、异步模式。同步模式有两种编程方式,其一使用 return 返回结果作为下一个加载器的资源,其二使用 this.callback 传入结果。异步模式使用 this.async 开启,在 this.async 执行过程中再调用 this.callback 语句,以启用下一个加载器的执行逻辑。官方推荐使用异步模式,这样可以提升 webpack 在 node 单线程环境中的执行性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 同步模式一
module.exports = function(content, map, meta) {
return someSyncOperation(content);
};

// 同步模式二
module.exports = function(content, map, meta) {
this.callback(null, someSyncOperation(content), map, meta);
return; // 当调用 callback() 时总是返回 undefined
};

// 异步模式
module.exports = function(content, map, meta) {
var callback = this.async();
someAsyncOperation(content, function(err, result, sourceMaps, meta) {
if (err) return callback(err);
callback(null, result, sourceMaps, meta);
});
};

加载器在 loader runner 环境中被调用,参数包含错误 err,资源 content,source-map,以及任何数据(可以是元数据 meta)。当 module.exports.raw 被赋值为 true 时,参数 content 可以是 buffer。loader runner 提供了如下上下文:

  • this.version:loader API 的版本号。
  • this.context:资源文件所在的目录。
  • this.request:解析后的查询字符串。
  • this.query:options 选项或查询字符串。
  • this.callback(err, content, sourceMap?, meta?: any):调用下一个加载器。
  • this.async:启用异步模式。
  • this.data:在 pitch 阶段和正常阶段之间共享的 data 对象。
  • this.cacheable(flag):是否可缓存。一个可缓存的 loader 在输入和相关依赖没有变化时,必须返回相同的结果。这意味着 loader 除了 this.addDependency 里指定的以外,不应该有其它任何外部依赖。
  • this.loaders:所有 loader 组成的数组。它在 pitch 阶段的时候是可以写入的。
  • this.loaderIndex:loader 的索引。
  • this.resource:包括查询字符串的资源路径。
  • this.resourcePath:不包括查询字符串的资源路径。
  • this.resourceQuery:查询字符串。
  • this.target:编译目标,从配置选项中传递过来的。
  • this.webpack:是否由 webpack 编译。loader 最初被设计为可以同时当 Babel transform 用。
  • this.sourceMap:是否生成 source-map。
  • this.emitWarning:发出警告。
  • this.emitError:发出错误。
  • this.loadModule:解析给定的 request 到一个模块,应用所有配置的 loader ,并且在回调函数中传入生成的 source 、sourceMap 和 模块实例(通常是 NormalModule 的一个实例)。
  • this.resolve(context, request, callback):像 require 表达式一样解析一个 request。
  • this.addDependency(directory):加入一个文件作为产生 loader 结果的依赖,使它们的任何变化都可以被监听到。
  • this.addContextDependency:把文件夹作为 loader 结果的依赖加入。
  • this.clearDependencies:移除 loader 结果的所有依赖。甚至自己和其它 loader 的初始依赖。考虑使用 pitch。
  • this.emitFile(name, content):产生一个文件。
  • this.fs:用于访问 compilation 的 inputFileSystem 属性。

pitch 阶段指 webpack 预先自左向右调用加载器的 pitch 方法,然后再自右向左调用加载器本身。pitch 阶段可用于跳过部分 loader,其如果有返回值,就将跳过余下的 loader。其次 pitch 方法的参数 data 可以在 loader 本体中通过 this.data 共享访问。

loader-utils, schema-utils

loader-utils, schema-utils 用于辅助加载器编程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { getOptions } from 'loader-utils';
import validateOptions from 'schema-utils';

const schema = {
type: 'object',
properties: {
test: {
type: 'string'
}
}
};

export default function(source) {
const options = getOptions(this);

validateOptions(schema, options, 'Example Loader');

// 对资源应用一些转换……
return `export default ${ JSON.stringify(source) }`;
}

测试

官方文档中使用 jest 测试案例如下:

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
// compiler.js
import path from 'path';
import webpack from 'webpack';
import memoryfs from 'memory-fs';

export default (fixture, options = {}) => {
const compiler = webpack({
context: __dirname,
entry: `./${fixture}`,
output: {
path: path.resolve(__dirname),
filename: 'bundle.js',
},
module: {
rules: [{
test: /\.txt$/,
use: {
loader: path.resolve(__dirname, '../src/loader.js'),
options: {
name: 'Alice'
}
}
}]
}
});

// 使用 https://github.com/webpack/memory-fs 内存文件
compiler.outputFileSystem = new memoryfs();

return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
if (err || stats.hasErrors()) reject(err);

resolve(stats);
});
});
};

// loader.test.js
import compiler from './compiler.js';

test('Inserts name and outputs JavaScript', async () => {
const stats = await compiler('example.txt');
const output = stats.toJson().modules[0].source;

expect(output).toBe('export default "Hey Alice!\\n"');
});

典型 loader

svgr

svgr 是 umi/af-webpack 中的一个模块,用于解析 svg 模块。

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
58
59
60
61
62
63
64
import { getOptions } from 'loader-utils';
import { transform as babelTransform } from '@babel/core';
import convert from '@svgr/core';

function svgrLoader(source) {
const callback = this.async();
const { babel = true, ...options } = getOptions(this) || {};// 获取选项

const readSvg = () => {
return new Promise((resolve, reject) => {
this.fs.readFile(this.resourcePath, (err, result) => {
if (err) reject(err);
resolve(result);
});
});
};

const exportMatches = source
.toString('utf-8')
.match(/^module.exports\s*=\s*(.*)/);
const previousExport = exportMatches ? exportMatches[1] : null;

// es6 降级
const pBabelTransform = async jsCode => {
return new Promise((resolve, reject) => {
babelTransform(
jsCode,
{
babelrc: false,
// Unless having this, babel will merge the config with global 'babel.config.js' which may causes some problems such as using react-hot-loader/babel in babel.config.js
configFile: false,
presets: [
require.resolve('@babel/preset-react'),
[require.resolve('@babel/preset-env'), { modules: false }],
],
plugins: [
require.resolve('@babel/plugin-transform-react-constant-elements'),
],
},
(err, result) => {
if (err) reject(err);
else resolve(result.code);
},
);
});
};

const tranformSvg = svg => {
// svg 转转成 js
return convert(svg, options, {
webpack: { previousExport },
filePath: this.resourcePath,
})
.then(jsCode => (babel ? pBabelTransform(jsCode) : jsCode))
.then(result => callback(null, result))
.catch(err => callback(err));
};

if (exportMatches) {
readSvg().then(tranformSvg);
} else {
tranformSvg(source);
}
}

参考

loader API
编写一个 loader