【第1498期】webpack loader机制源码解析
前言
今日早读文章由今日头条@十年一刻投稿分享。
正文从这开始~~
对于webpack loader相信大家都知道它是用于将一个模块转为js代码的,但估计不是每个人都知道webpack对于loader的内部处理流程。从大体上来说是遵循流水线机制的,即挨个处理每个loader,前一个loader的结果会传递给下一个loader。
loader有一些主要的特性:
同步、异步
raw
pitch
context
本文会从源码角度解释webpack是如何处理这些特性的,并在最后举一些实际的例子帮助大家理解如何写一个loader。
入口
webpack在处理Module时就会先用loader,每个module可以配置多个loader,然后再将js代码转为AST,这部分的逻辑在webpack源码的lib/NormalModule.js:
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
先看看入口函数:
exports.runLoaders = function runLoaders(options, callback) {
// prepare loader objects
var loaders = options.loaders || [];
loaders = loaders.map(createLoaderObject);
// 各种初始化赋值...
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
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);
}
});
});
}
初看很容易懵逼,到处都有递归,但仔细配合注释看下来会发现其实就是递归执行每个loader的pitch函数,并在所有pitch执行完后调用processResource。那么问题来了,pitch是个什么鬼?
参照官网的api解释,每个loader除了默认的处理函数外(我们可以称之为normal函数),还可以配置一个pitch函数,这两个函数的关系类似于浏览器的dom事件处理流程:先从前往后执行pitch,接着处理module自身一些逻辑,再从后往前执行normal,类似于先触发dom事件的捕获阶段,接着执行事件回调,再触发冒泡阶段。
如果我们给一个module配置了 3 个loader,这三个loader都配置了pitch函数:
module.exports = {
//...
module: {
rules: [
{
//...
use: ['a-loader', 'b-loader', 'c-loader'],
},
],
},
};
那么处理这个module的流程如下:
|- 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.import或require来加载,我不怎么熟悉System.import就不细讲了。loader默认导出函数会赋值给currentLoaderObject的normal属性,pitch函数会赋值给pitch属性。
runSyncOrAsync用于执行一个同步或异步的fn,执行完后触发传入的回调函数。这个函数比较有意思,仔细看看:
// 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上添加了async和callback函数,它俩是给异步loader使用的,前者告诉context自己是异步的,后者告诉context自己处理完成了。所以在loader内部可以调用this.async以及this.callback. 同步的loader不需要用到这俩,执行完直接return即可。后面我们会分别举一个例子。
注意:执行完一个pitch后,会判断pitch是否有返回值,如果没有则继续递归执行下一个pitch;如果有返回值,那么pitch的递归就此结束,开始从当前位置从后往前执行normal:
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-loader的pitch有返回值,那么处理这个module的流程如下:
|- a-loader `pitch`
|- b-loader `pitch` returns a module
|- a-loader `normal`
以上就是pitch的递归过程,下面看看processResource函数,它用于将目标module当做loaderContext的一个依赖。这个函数的逻辑还是比较简单的:
// 处理模块自身的资源,主要是读取及添加为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);
}
}
var fileDependencies = [];
loaderContext.addDependency = function addDependency(file) {
fileDependencies.push(file);
};
iterateNormalLoaders
递归迭代normal函数,和pitch的流程大同小异,需要注意的是顺序是反过来的,从后往前。
// 与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来转换args,raw属性在官网有专门描述:
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.
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函数。
module.exports = function() {};
module.exports.pitch = function(request) {
// ...
return [
// 一些数组元素...
].join('\n');
};
为啥style-loader要有pitch呢? 参考这篇博客的说法,是为了避免受到css-loader的影响:
因为我们要把 CSS 文件的内容插入 DOM,所以我们要获取 CSS 文件的样式。如果按照默认的从右往左的顺序,我们使用 css-loader ,它返回的结果是一段 JS 字符串,这样我们就取不到 CSS 样式了。为了获取 CSS 样式,我们会在 style-loader 中直接通过 require 来获取,这样返回的 JS 就不是字符串而是一段代码了。也就是我们是先执行 style-loader,在它里面再执行 css-loader。
异步的 less-loader
// 调用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:
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。
原理: 正常情况下假如我们在entry中require了一个普通js文件,这个目标文件是和entry一起打包到主chunk了,那么在执行时就是同步加载。 使用bundle-loader我们的代码不用做任何修改,就可以让目标js文件分离到独立chunk中,执行时通过模拟jsonp的方式异步加载这个js。
看看loader的源码实现:
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测试:
// webpack entry: index.js
import bundle from './util.bundle.js';
bundle(file => console.log(file));
// util.bundle.js
export function bundle() {
console.log('bundle');
}
打包后会生成两个文件,一个主chunk文件main.xxx.js,另一个是分离出去的bundle chunk文件0.xxx.js。在分析打包代码前,如果对webpack打包产物不熟悉的,可以参考我之前的博客,这里我只分析关键的部分。
index.js这个module对于util.bundle的引入方式没有什么值得注意的,精简如下:
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的返回值:
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每个元素,整个逻辑还是比较清晰的。
关于本文
作者:@十年一刻
原文:https://hellogithub2014.github.io/2019/01/03/webpack-loader/
最后,为你推荐