webpack loader机制源码解析

对于webpack loader相信大家都知道它是用于将一个模块转为js代码的,但估计不是每个人都知道webpack对于loader的内部处理流程。从大体上来说是遵循流水线机制的,即挨个处理每个loader,前一个loader的结果会传递给下一个loader

loader有一些主要的特性:

  • 同步、异步
  • pitch、normal execution
  • context

本文会从源码角度解释webpack是如何处理这些特性的,并在最后举一些实际的例子帮助大家理解如何写一个loader,所用的webpack版本是4.28.2.

入口

webpack在处理Module时就会先用loader,每个module可以配置多个loader,然后再将js代码转为AST,这部分的逻辑在webpack源码的lib/NormalModule.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
doBuild(options, compilation, resolver, fs, callback) {
const loaderContext = this.createLoaderContext(
resolver,
options,
compilation,
fs
);

runLoaders(
{
resource: this.resource, // 模块路径
loaders: this.loaders, // options中配置的loader
context: loaderContext,
readResource: fs.readFile.bind(fs)
},
(err, result) => {
// result即处理完的js代码,剩余逻辑略...
}
}

runLoaders是专门抽取出去的库loader-runner,所有逻辑都在这个库中,接下来我们重点放在这里。

loader-runner

先看看入口函数:

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
exports.runLoaders = function runLoaders(options, callback) {
// prepare loader objects
var loaders = options.loaders || [];
loaders = loaders.map(createLoaderObject);

// loaderContext的各种初始化赋值...

var processOptions = {
resourceBuffer: null,
readResource: readResource,
};
iteratePitchingLoaders(processOptions, loaderContext, function(err, result) {
if (err) {
return callback(err, {
cacheable: requestCacheable,
fileDependencies: fileDependencies,
contextDependencies: contextDependencies,
});
}
callback(null, {
result: result,
resourceBuffer: processOptions.resourceBuffer,
cacheable: requestCacheable,
fileDependencies: fileDependencies,
contextDependencies: contextDependencies,
});
});
};

入口函数其实做的事情比较简单,除了初始化外就是调用iteratePitchingLoaders了,这个函数执行完就触发webpack传递的回调函数。接下来看看这个函数。

iteratePitchingLoaders

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
function iteratePitchingLoaders(options, loaderContext, callback) {
// abort after last loader, loaderIndex初始为0,当所有loader pitch都执行完后,if条件成立
if (loaderContext.loaderIndex >= loaderContext.loaders.length) return processResource(options, loaderContext, callback);

// 当前loader
var currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];

// iterate,如果当前loader的pitch已经执行过,继续递归下一个loader
if (currentLoaderObject.pitchExecuted) {
loaderContext.loaderIndex++;
return iteratePitchingLoaders(options, loaderContext, callback);
}

// load loader module,加载loader的实现,
// loader默认导出函数赋值给normal属性,pitch函数赋值给pitch属性
loadLoader(currentLoaderObject, function(err) {
if (err) return callback(err);
var fn = currentLoaderObject.pitch; // pitch函数
currentLoaderObject.pitchExecuted = true;
// 没有pitch函数则递归下一个
if (!fn) return iteratePitchingLoaders(options, loaderContext, callback);

// 执行pitch函数,同步或者异步的
runSyncOrAsync(fn, loaderContext, [loaderContext.remainingRequest, loaderContext.previousRequest, (currentLoaderObject.data = {})], function(
err,
) {
// 执行完fn后的回调
if (err) return callback(err);
// args表示pitch函数的返回值,如果存在则跳过后续的递归处理流程,直接掉头处理loader的normal函数
// 在官网文档中也有专门的描述: https://webpack.js.org/api/loaders/#pitching-loader
var args = Array.prototype.slice.call(arguments, 1);
if (args.length > 0) {
loaderContext.loaderIndex--;
iterateNormalLoaders(options, loaderContext, args, callback);
} else {
iteratePitchingLoaders(options, loaderContext, callback);
}
});
});
}

初看很容易懵逼,到处都有递归,但仔细配合注释看下来会发现其实就是递归执行每个loaderpitch函数,并在所有pitch执行完后调用processResource。那么问题来了,pitch是个什么鬼?

参照官网的api解释,每个loader除了默认的处理函数外(我们可以称之为normal函数),还可以配置一个pitch函数,这两个函数的关系类似于浏览器的dom事件处理流程:先从前往后执行pitch,接着处理module自身一些逻辑,再从后往前执行normal,类似于先触发dom事件的捕获阶段,接着执行事件回调,再触发冒泡阶段。

如果我们给一个module配置了 3 个loader,这三个loader都配置了pitch函数:

1
2
3
4
5
6
7
8
9
10
11
module.exports = {
//...
module: {
rules: [
{
//...
use: ['a-loader', 'b-loader', 'c-loader'],
},
],
},
};

那么处理这个module的流程如下:

1
2
3
4
5
6
7
|- a-loader `pitch`
|- b-loader `pitch`
|- c-loader `pitch`
|- requested module is picked up as a dependency
|- c-loader `normal`
|- b-loader `normal`
|- a-loader `normal`

顺序执行normal函数的代码位于iterateNormalLoaders,稍后会描述。

loadLoader函数用于加载一个loader的实现,会尝试使用System.importrequire来加载,我不怎么熟悉System.import就不细讲了。loader默认导出函数会赋值给currentLoaderObjectnormal属性,pitch函数会赋值给pitch属性。

runSyncOrAsync用于执行一个同步或异步的fn,执行完后触发传入的回调函数。这个函数比较有意思,仔细看看:

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
// fn可能是同步也可能是异步的
function runSyncOrAsync(fn, context, args, callback) {
var isSync = true;
var isDone = false;
var isError = false; // internal error
var reportedError = false;
// context.async就是loader函数内部可以执行的this.async
// 用于告知context,此fn是异步的
context.async = function async() {
if (isDone) {
if (reportedError) return; // ignore
throw new Error('async(): The callback was already called.');
}
isSync = false;
return innerCallback;
};
// context.callback就是loader函数内部可以执行的this.callback
// 用于告知context,异步的fn已经执行完成
var innerCallback = (context.callback = function() {
if (isDone) {
if (reportedError) return; // ignore
throw new Error('callback(): The callback was already called.');
}
isDone = true;
isSync = false;
try {
callback.apply(null, arguments);
} catch (e) {
isError = true;
throw e;
}
});
try {
var result = (function LOADER_EXECUTION() {
// 调用fn
return fn.apply(context, args);
})();
// 异步loader fn应该在开头执行this.async, 以保证修改isSync为false,从而不会执行此处逻辑
if (isSync) {
isDone = true;
if (result === undefined) return callback();
if (result && typeof result === 'object' && typeof result.then === 'function') {
return result.catch(callback).then(function(r) {
callback(null, r);
});
}
return callback(null, result);
}
} catch (e) {
if (isError) throw e;
if (isDone) {
// loader is already "done", so we cannot use the callback function
// for better debugging we print the error on the console
if (typeof e === 'object' && e.stack) console.error(e.stack);
else console.error(e);
return;
}
isDone = true;
reportedError = true;
callback(e);
}
}

context上添加了asynccallback函数,它俩是给异步loader使用的,前者告诉context自己是异步的,后者告诉context自己处理完成了。所以在loader内部可以调用this.async以及this.callback. 同步的loader不需要用到这俩,执行完直接return即可。后面我们会分别举一个例子。

细心点会发现,loader内部的thiscontext,也就是最外层的loaderContext,如果想知道context上有哪些成员,可以直接看runLoaders内部的初始化逻辑,或者直接去webpack 官网 api查阅即可。

注意:执行完一个pitch后,会判断pitch是否有返回值,如果没有则继续递归执行下一个pitch;如果有返回值,那么pitch的递归就此结束,开始从当前位置从后往前执行normal

1
2
3
4
5
6
7
var args = Array.prototype.slice.call(arguments, 1);
if (args.length > 0) {
loaderContext.loaderIndex--; // 从前一个loader的normal开始执行
iterateNormalLoaders(options, loaderContext, args, callback);
} else {
iteratePitchingLoaders(options, loaderContext, callback);
}

这个逻辑在官网也有描述,继续用我们上面的例子,如果b-loaderpitch有返回值,那么处理这个module的流程如下:

1
2
3
|- a-loader `pitch`
|- b-loader `pitch` returns a module
|- a-loader `normal`

以上就是pitch的递归过程,下面看看processResource函数,它用于将目标module当做loaderContext的一个依赖。这个函数的逻辑还是比较简单的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 处理模块自身的资源,主要是读取及添加为context的依赖
function processResource(options, loaderContext, callback) {
// set loader index to last loader
loaderContext.loaderIndex = loaderContext.loaders.length - 1;

var resourcePath = loaderContext.resourcePath;
if (resourcePath) {
// requested module is picked up as a dependency
loaderContext.addDependency(resourcePath);
// 读取module内容
options.readResource(resourcePath, function(err, buffer) {
if (err) return callback(err);
options.resourceBuffer = buffer;
// 迭代loader的normal函数
iterateNormalLoaders(options, loaderContext, [buffer], callback);
});
} else {
iterateNormalLoaders(options, loaderContext, [null], callback);
}
}
1
2
3
4
5
var fileDependencies = [];

loaderContext.addDependency = function addDependency(file) {
fileDependencies.push(file);
};

iterateNormalLoaders

递归迭代normal函数,和pitch的流程大同小异,需要注意的是顺序是反过来的,从后往前。

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
// 与iteratePitchingLoaders类似,只不过是从后往前执行每个loader的normal函数
function iterateNormalLoaders(options, loaderContext, args, callback) {
if (loaderContext.loaderIndex < 0) return callback(null, args);

var currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];

// iterate
if (currentLoaderObject.normalExecuted) {
loaderContext.loaderIndex--;
return iterateNormalLoaders(options, loaderContext, args, callback);
}

// 在loadLoader中加载loader的实现,
// loader默认导出函数赋值给normal属性,pitch函数赋值给pitch属性
var fn = currentLoaderObject.normal;
currentLoaderObject.normalExecuted = true;
if (!fn) {
return iterateNormalLoaders(options, loaderContext, args, callback);
}

// 根据raw来转换args, https://webpack.js.org/api/loaders/#-raw-loader
convertArgs(args, currentLoaderObject.raw);

// fn: function ( source, inputSourceMap ) { … }
runSyncOrAsync(fn, loaderContext, args, function(err) {
if (err) return callback(err);

// 将前一个loader的处理结果传递给下一个loader
var args = Array.prototype.slice.call(arguments, 1);
iterateNormalLoaders(options, loaderContext, args, callback);
});
}

convertArgs用于根据raw来转换argsraw属性在官网有专门描述

By default, the resource file is converted to a UTF-8 string and passed to the loader.By setting the raw flag, the loader will receive the raw Buffer.Every loader is allowed to deliver its result as String or as Buffer.

1
2
3
4
function convertArgs(args, raw) {
if (!raw && Buffer.isBuffer(args[0])) args[0] = utf8BufferToString(args[0]);
else if (raw && typeof args[0] === 'string') args[0] = new Buffer(args[0], 'utf-8');
}

例如file-loader就会将raw设置为true,具体原因参考这里

以上就是整个loader-runner库的核心逻辑了,接下来举几个例子。

同步的 style-loader

它的逻辑从整体上看比较简单,就是做了一些同步的处理并在最后return了一个js字符串。注意他只有pitch函数而没有normal函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
module.exports = function() {};

module.exports.pitch = function(request) {
// ...

return [
// ...
'var content = require(' + loaderUtils.stringifyRequest(this, '!!' + request) + ');',
// ...
'var update = require(' + loaderUtils.stringifyRequest(this, '!' + path.join(__dirname, 'lib', 'addStyles.js')) + ')(content, options);',
// ...
].join('\n');
};

为啥style-loader要有pitch呢? 参考这篇博客的说法,是为了避免受到css-loader的影响:

因为我们要把 CSS 文件的内容插入 DOM,所以我们要获取 CSS 文件的样式。如果按照默认的从右往左的顺序,我们使用 css-loader ,它返回的结果是一段 JS 字符串,这样我们就取不到 CSS 样式了。为了获取 CSS 样式,我们会在 style-loader 中直接通过 require 来获取,这样返回的 JS 就不是字符串而是一段代码了。也就是我们是先执行 style-loader,在它里面再执行 css-loader。

从代码层面来看,

  • Style-loader 需要的是一个 css 文件路径, 而 css-loader 返回的是一个数组,这样就无法处理。 先用 style-loaderpitch 处理,其中会再次 require css 文件,这时才会用到 css-loader,此时 css-loader 的返回值会传递给 addStyle.js,就可以处理了
  • 路径前面的感叹号作用, 参考 1参考 2
    1. 1 个用于禁用 preloaderpreloader 是一个过时的东西,参考这里
    2. 2 个用于禁用所有 loader

异步的 less-loader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 调用less第三方库来处理less代码,返回值为promise
var render = (0, _pify2.default)(_less2.default.render.bind(_less2.default));

function lessLoader(source) {
var loaderContext = this;
var options = (0, _getOptions2.default)(loaderContext);
// loaderContext.async()告知webpack当前loader是异步的
var done = loaderContext.async();
var isSync = typeof done !== 'function';

if (isSync) {
throw new Error('Synchronous compilation is not supported anymore. See https://github.com/webpack-contrib/less-loader/issues/84');
}

// 调用_processResult2
(0, _processResult2.default)(loaderContext, render(source, options));
}

exports.default = lessLoader;

less-loader的核心是利用less这个库来解析less代码,less会返回一个Promise,所以less-loader是异步的。

我们可以看到在开头就调用了this.async()方法,正好符合我们的预期,接下来如果猜的没错会在_processResult2里调用this.callback:

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
function processResult(loaderContext, resultPromise) {
var callback = loaderContext.callback;

resultPromise
.then(
function(_ref) {
var css = _ref.css,
map = _ref.map,
imports = _ref.imports;

imports.forEach(loaderContext.addDependency, loaderContext);
return {
// Removing the sourceMappingURL comment.
// See removeSourceMappingUrl.js for the reasoning behind this.
css: removeSourceMappingUrl(css),
map: typeof map === 'string' ? JSON.parse(map) : map,
};
},
function(lessError) {
throw formatLessError(lessError);
},
)
.then(function(_ref2) {
var css = _ref2.css,
map = _ref2.map;

// 调用loaderContext.callback表示当前loader的处理已经完成,转交给下一个loader处理
callback(null, css, map);
}, callback);
}

bingo!!

实际上官网也推荐将loader变成异步的:

since expensive synchronous computations are a bad idea in a single-threaded environment like Node.js, we advise to make your loader asynchronously if possible. Synchronous loaders are ok if the amount of computation is trivial.

bundle-loader

最后再看这个使用pitch的例子bundle-loader,也是官网推荐的loader。它用于分离代码和延迟加载生成的bundle

原理: 正常情况下假如我们在entryrequire了一个普通js文件,这个目标文件是和entry一起打包到主chunk了,那么在执行时就是同步加载。 使用bundle-loader我们的代码不用做任何修改,就可以让目标js文件分离到独立chunk中,执行时通过模拟jsonp的方式异步加载这个js

看看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
module.exports = function() {};
module.exports.pitch = function(remainingRequest) {
// ...
var result;
if (query.lazy) {
result = [
'module.exports = function(cb) {\n',
' require.ensure([], function(require) {\n',
' cb(require(',
loaderUtils.stringifyRequest(this, '!!' + remainingRequest),
'));\n',
' }' + chunkNameParam + ');\n',
'}',
];
} else {
result = [
'var cbs = [], \n',
' data;\n',
'module.exports = function(cb) {\n',
' if(cbs) cbs.push(cb);\n',
' else cb(data);\n',
'}\n',
'require.ensure([], function(require) {\n',
' data = require(',
loaderUtils.stringifyRequest(this, '!!' + remainingRequest), // 此处require真正的目标module
');\n',
' var callbacks = cbs;\n',
' cbs = null;\n',
' for(var i = 0, l = callbacks.length; i < l; i++) {\n',
' callbacks[i](data);\n',
' }\n',
'}' + chunkNameParam + ');',
];
}
return result.join('');
};

可以看到只有pitch函数,为保证目标module分离到独立chunk,使用了require.ensure这种动态导入。另外将整个module替换成了自己的实现,module真正的加载时机在require.ensure的回调中。

为了加深理解,我参照官网利用一个小demo测试:

1
2
3
4
// webpack entry: index.js

import bundle from './util.bundle.js';
bundle(file => console.log(file));
1
2
3
4
5
// util.bundle.js

export function bundle() {
console.log('bundle');
}

打包后会生成两个文件,一个主chunk文件main.xxx.js,另一个是分离出去的bundle chunk文件0.xxx.js。在分析打包代码前,如果对webpack打包产物不熟悉的,可以参考我之前的博客,这里我只分析关键的部分。

index.js这个module对于util.bundle的引入方式没有什么值得注意的,精简如下:

1
2
3
4
5
6
var _util_bundle_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__('./util.bundle.js');
var _util_bundle_js__WEBPACK_IMPORTED_MODULE_0___default = __webpack_require__.n(_util_bundle_js__WEBPACK_IMPORTED_MODULE_0__);

_util_bundle_js__WEBPACK_IMPORTED_MODULE_0___default()(function(file) {
return console.log(file);
});

变化的是util.bundle.js这个module,它被替换成了bundle-loader的返回值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var cbs = [],
data;
module.exports = function(cb) {
if (cbs) cbs.push(cb);
else cb(data);
};
__webpack_require__
.e(/*! require.ensure */ 0) // jsonp加载分离的chunk
.then(
function(require) {
data = __webpack_require__(
/*! !../node_modules/babel-loader/lib??ref--5!./util.bundle.js */ './node_modules/babel-loader/lib/index.js?!./util.bundle.js',
);
var callbacks = cbs;
cbs = null;
for (var i = 0, l = callbacks.length; i < l; i++) {
callbacks[i](data);
}
}.bind(null, __webpack_require__),
)
.catch(__webpack_require__.oe);

可以看到真正的util.bundle.js被替换为使用__webpack_require__.e加载,也就是模拟的jsonp。我们在index.js中传入的回调被塞到cbs数组,直到真正的bundle被加载完才能执行__webpack_require__,之后会将bundle的导出内容依次传给cbs每个元素,整个逻辑还是比较清晰的。

自定义 loader

看了上面这些,我们也可以自己尝试写一个最简单的loader试一试,作为一个探索我们就针对后缀名为ttt(随便想的 😄)的文件吧。

我们的自定义loader什么事也不做,就只在文件内容前加上一串标记,loader代码如下:

1
2
3
4
5
6
7
// my-loader.js

module.exports = function(source) {
return `
export default "Convert by my custom loader: ${source}"
`;
};

注意loader的返回值需要是合法的js代码。然后修改webpack config,使得ttt后缀的文件使用my-loader, 使用resolveLoader配置来修改loader的指向:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module.exports = {
// ...
module: {
rules: [
{
test: /\.ttt$/,
use: 'my-loader',
},
],
},
resolveLoader: {
alias: {
'my-loader': path.resolve(__dirname, './my-loader.js'),
},
},
};

接下来自定义一个strange.ttt的文件,然后在入口文件index.js中导入它:

1
2
3
// strange.ttt

this is strange ttt

细心的人会发现strange.ttt的内容并不是字符串~~

1
2
3
4
// index.js

import strange from './strange.ttt';
console.log(strange);

运行webpack可以看到打包产物的关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// main.js

(function(modules) {
// 模板代码略 ...
})({
'./index.js': function(module, __webpack_exports__, __webpack_require__) {
var _strange_ttt__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./strange.ttt */ '.strange.ttt');
console.log(_strange_ttt__WEBPACK_IMPORTED_MODULE_0__[/* default */ 'a']);
},

'./strange.ttt': function(module, __webpack_exports__, __webpack_require__) {
__webpack_exports__['a'] = 'Convert by my custom loader: this is strange ttt';
},
});

很明显strange.ttt的内容被转化为了js代码并包含了my-loader添加的前缀,如果我们将main.js跑起来,控制台就会打印出Convert by my custom loader: this is strange ttt