我在尝试以一种更为直白的方式看源码,下面示例中的代码均为简化代码,这些代码这文字解说是对应的,化繁为简。先骨骼,再慢慢丰盈。
1. webpack-loader 是什么
webpack 只能理解 JavaScript 和 JSON 文件,loader 让 webpack 能够去处理其他类型的文件,并将它们转换为有效 模块,以供应用程序使用,以及被添加到依赖图中。
2. loader 的配置及默认配置
webpack loaders 是怎么配置的,在哪里配置的,有默认配置么?
- 2.1 webpack.config.js 中配置 module.rules
- 2.2 在 webpack.js 中创建 compiler 对象前通过 WebpackOptionsDefaulter.js 初始化默认配置的时候会在合并(merge cli 和 webpack.config.js)后的配置项中增加 module.defaultRules;
// ... webpack.js
if (Array.isArray(options)) {}
else if (typeof options === "object") {
options = new WebpackOptionsDefaulter().process(options); // process 方法来自 WebpackOptionsDefaulter 父类 OptionsDefaulter
compiler = new Compiler(options.context);
compiler.options = options;
}
// WebpackOptionsDefaulter 类 来自 webpack/lib/WebpackOptionsDefaulter.js 如下:
class WebpackOptionsDefaulter extends OptionsDefaulter {
constructor () {
// ...
// loader 默认配置包括 js,json
this.set("module.defaultRules", "make", options => [
{ type: "javascript/auto", resolve: {} },
{ test: /.mjs$/i, type: "javascript/esm", resolve: { mainFields: 'example' } },
{ test: /.json$/i, type: "json" },
{ test: /.wasm$/i, type: "webassembly/experimental" }
]);
// ... other default cfg
}
}
思考:这些配置又是如何配合 loader 一起工作的?
3. loader 的具体执行部分
3.1 webpack loader 的 resolve
webpack.config.js 只需要配置一个名字,webpack 又是如何通过名字找到 loader 模块
webpack 查找 loader 则是要进入到编译阶段,而编译的入口在 Compiler 的 run 方法; 该过程发生在创建 compiler 实例(new Compiler()) 后,调用 compiler.run 方法初始化 compilation 对象。
初始化 compilation 对需要创建两个特殊的对象: NormalModuleFactory 和 ContextModuleFactory 实例,其中 NormalModuleFactory 是解答本问题的关键点。NormalModuleFactory 会为 webpack 的源码模块生成 NormalModule 实例,在创建 NormalModule 实例之前会经历一些必要的过程,这一过程就包含通过 loader resolver 根据 loader 的名字及相关配置,来解析 loader 的路径;
3.2 webpack.config.js 中的 module.rules
webpack.config.js 中配置的 module.rules 又是经历了怎样的过程才生效的?
webpack 会整合 webpack.config.js 配置项(modules.rules) 和 默认的配置项(options.defaultRules)创建 RuleSet 实例,这一过程同样发生在创建 NormalModuleFactory 实例的过程中;示例代码:
class NormalModuleFactory extends Tapable {
constructor(context, resolverFactory, options) {
// ...
this.ruleSet = new RuleSet(options.defaultRules.concat(options.rules))
}
}
RuleSet 实例相当于过滤器,通过调用其 exec 方法并传入当前的 request 相关信息,将会返回该 request 所需的 loader 数据,即根据 webpack.config.js 中 module 的配置匹配出的 loader 结果集。
exec 的调用发生在解析 inline-loader 后的回调中;示例代码:
asyncLib.parallel([/*处理 inline-loader 即路径信息*/], (err, results) => {
const result = this.ruleSet.exec({
resource: resourcePath,
realResource:
matchResource !== undefined
? resource.replace(/?.*/, "")
: resourcePath,
resourceQuery,
issuer: contextInfo.issuer,
compiler: contextInfo.compiler
});
})
3.3 inline-loader 和 普通 loader
除了 webpack.config.js 配置的 loader 还可以使用 inline-loader,inline-loader 执行和普通 loader 谁的优先级更高?
下面是一个 inline-loader 的例子:
// 示例:
import Styles from 'style-loader!css-loader?modules!./styles.css';
// webpack 处理 inline-loader 的代码:webpack/lib/NormalFactory.js -> constructor -> this.hooks.resolver.tap( handler
let elements = requestWithoutMatchResource
.replace(/^-?!+/, "")
.replace(/!!+/g, "!")
.split("!");
经过处理后得到两个 loader:style-loader
和 css-loader
。得到这两个 loader 以后用 loader-resolver 和模块 resolver,把 2 个 loader 和 模块 ./style.css
的路径解析出来;这个过程发生在上面解析 inline-loader 之后,是通过 asyncLib.parallel 方法并行处理的,在 asyncLib.parallel 的回调可以拿到 resolve 解析后的结果:
- 第一项为解析所得的 inline-loader 的路径及模块等信息;
- 第二项为模块路径信息,后面在这个回调里将对配置的 loader 和 inline-loader 进行排序;示例:
// webpack/lib/NormalFactory.js -> constructor -> this.hooks.resolver.tap( hanlder
asyncLib.parallel(
[
callback => this.resolveRequestArray(contextInfo, context, elements, loaderResolver, callback), // 解析 inline-loader 路径
callback => normalResolver.resolve(contextInfo, context, resource, {}, (err, resource, resourceResolveData) => { // 解析模块路径
callback(null, {resourceResolveData,resource })
})
],
(err, results) => {
// resolve 后的 inline-loader
let loaders = results[0]
}
)
3.4 enforce 调整 loader 优先级
通过配置
enforce
为pre
或post
来定义 loader 类型为 pre-loader 或 post-loader(若不配置称为普通loader),进而调整该 loader 的执行顺序,这又是怎么实现的?
webpack 内部在拿到 webpack.config.js 中的 loader 结果集合之后会按照是否配置了 pre/post 将配置 loader 分为三组,useLoaderPost, useLoaders, useLoaderPre。 接着就去 resolve 这些三组 loader 模块的路径信息,待 resolve 完成后对所有的 loader 的终极排序;最终得出执行顺序为:前置(pre)、普通(normal)、行内(inline)、后置(post)
- 简化代码如下:
// webpack/lib/NormalModuleFactory.js -> constructor
this.hooks.resolver.tap("NormalModuleFactory", () => (data, callback) => {
// ....
asyncLib.parallel([/*处理 inline-loader 即路径信息*/], (err, results) => {
let loaders = results[0]; // inline-loaders
const result = this.ruleSet.exec({});// 这是结合了 webpack.config.js 和当前的 request 信息得出的配置 loader 结果集
// 开始分组
const useLoadersPost = [];
const useLoaders = [];
const useLoadersPre = [];
for (const r of result) {
if (r.type === "use") {
if (r.enforce === "post" && !noPrePostAutoLoaders) {
useLoadersPost.push(r.value);
} else if (
r.enforce === "pre" &&
!noPreAutoLoaders &&
!noPrePostAutoLoaders
) {
useLoadersPre.push(r.value);
} else if (
!r.enforce &&
!noAutoLoaders &&
!noPrePostAutoLoaders
) {
useLoaders.push(r.value);
}
}
}
// 分组后开始 resolve 然后进行终极分组:
asyncLib.parallel(
[
this.resolveRequestArray.bind(/* other args,*/ useLoadersPost,loaderResolver),
this.resolveRequestArray.bind(/* other args,*/ useLoaders,loaderResolver),
this.resolveRequestArray.bind(/* other args,*/ useLoadersPre, loaderResolver)
],
(err, results) => {
if (matchResource === undefined) {
// results [useLoaderPost, useLoaders, useLoadersPre]
// loaders inline-loaders
// 最终结果:post-loader、inline-loader、normal-loader、pre-loader
loaders = results[0].concat(loaders, results[1], results[2]);
}
process.nextTick(() => {
callback(null, {
context: context,
request: loaders.map(loaderToIdent).concat([resource]).join("!"), // 拼接过 loader 的 request
dependencies: data.dependencies,
userRequest,
rawRequest: request,
loaders, // 经排序后的所有 loaders
resource,
matchResource,
resourceResolveData,
settings,
type,
parser: this.getParser(type, settings.parser),
generator: this.getGenerator(type, settings.generator),
resolveOptions
});
});
}
)
})
})
3.5 run loaders
在上一节 3.4 的最后,在 process.nextTick 调用 callback,传递一个包含经整合的 loaders
在内的对象。那么 callback 又是哪里传入的?里面又发生了什么?
callback 是在 NormalModuleFactory.constructor 中 执行 this.hooks.factory.tap("NormalModuleFactory", handler) 时,其 handler 返回的 factory 函数中调用 resolver 时传入的。
- 简化代码如下:
// webpack/lib/NormalModuleFactory.js
class NormalModuleFactory extends Tapable {
constructor(context, resolverFactory, options) {
this.hooks.factory.tap("NormalModuleFactory", () => (result, callback) => {
let resolver = this.hooks.resolver.call(null);
resolver(result, (err, data) => {
/* 该函数为 resolver 的 callback,用于接收整合后的 loaders */
this.hooks.afterResolve.callAsync(data, (err, result) => {
createdModule = new NormalModule(result); // webpack/lib/NormalModule.js
return callback(null, createdModule);
})
})
this.hooks.resolver.tap("NormalModuleFactory", () => (data, callback) => {})
}
}
忽略支线剧情,在这个 callback 中,会创建 NormalModule 的实例,将通过这个实例创建模块,在此期间将会 NormalModule.prototype.build() 方法,该方法会完成 run-loaders、解析 ast 分析依赖等工作,我们的重点仍放在 run-loaders,build
方法将会调用 doBuild
方法。
doBuild
方法中首先创建 loaderContext
对象,这个对象为开发一个 loader 提供了许多能力,接着调用 runLoaders
方法开始执行 loader,runLoaders 方法来自独立的库: loader-runner
- 简化代码:
// webpack/lib/NormalModule.js
class NormalModule extends Module {
constructor({type, request, userRequest, rawRequest, loaders, resource, matchResource, parser, generator, ...}) {
// 看下这个 constructor 的参数,其中的 loaders,rawRequest,request 等是不是很眼熟,是的,就是来自前面处理 loaders 排序后返回的对象
}
build(options, compilation, resolver, fs, callback) {
return this.doBuild(options, compilation, resolver, fs, err => {})
}
doBuild(options, compilation, resolver, fs, callback) {
const loaderContext = this.createLoaderContext(resolver, options, compilation,fs);
runLoaders(
{
resource: this.resource,
loaders: this.loaders,
context: loaderContext,
readResource: fs.readFile.bind(fs)
},
(err, result) => {}
)
}
createLoaderContext(resolver, options, compilation, fs) {
const requestShortener = compilation.runtimeTemplate.requestShortener;
const getCurrentLoaderName = () => {};
const loaderContext = {
version: 2,
emitWarning: warning => {},
emitError: error => {},
getLogger: name => {},
exec: (code, filename) => {},
resolve(context, request, callback) {},
getResolve(options) {},
emitFile: (name, content, sourceMap, assetInfo) => {},
rootContext: options.context,
webpack: true,
sourceMap: !!this.useSourceMap,
mode: options.mode || "production",
_module: this,
_compilation: compilation,
_compiler: compilation.compiler,
fs: fs
};
return loaderContext;
}
}
3.6 loader-runner 的内幕
在 loader-runner 导出的 runLoaders 方法用于执行 loader,runLoaders 方法接收包含 loaderContext 和 loaders 集合在内的一个 options 对象和 callback 函数; runLoader 方法执行时主要完成以下工作:
- 对 loaderContext 等重要参数做默认参数处理;
- 扩展 loaderContext 的属性和能力,其中
loaderIndex
在此时被添加,扩展能力例如 addDependency/adContextDependency 等方法; - 为便于使用,通过 defineProperty 重新定义 loaderContext 的一些属性的取值(get)、赋值(set)行为;
- 进入 loader 的
pitch
阶段;
// node_modules/loader-runner/lib/LoaderRunner.js
exports.runLoaders = function runLoaders(options, callback) {
// 示例 1
var loaderContext = options.context || {};
loaders = loaders.map(createLoaderObject);
// 示例 2
loaderContext.loaderIndex = 0;
loaderContext.addContextDependency = function addContextDependency(context) {
contextDependencies.push(context);
};
// 示例:3
Object.defineProperty(loaderContext, "remainingRequest", {
enumerable: true,
get: function() {
if(loaderContext.loaderIndex >= loaderContext.loaders.length - 1 && !loaderContext.resource)
return "";
return loaderContext.loaders.slice(loaderContext.loaderIndex + 1).map(function(o) {
return o.request;
}).concat(loaderContext.resource || "").join("!");
}
});
// 进入 piching 阶段
iteratePitchingLoaders(processOptions, loaderContext, function(err, result) {});
};
3.7 loader.pitch & normal & loaderContext.loaderIndex
众所周知的是 loader 的执行顺序恰好是与我们定义 loader 的顺序相反,即定义顺序为 A -> B -> C,则执行顺序为 C -> B -> A。这其实是描述的 loader 的 normal 执行阶段的顺序;每个 loader 运行阶段有 2 个部分组成,即 pitching 和 normal ;其中 pitching 阶段是执行定义在 loader 上的 pitch 方法上,当然 pitch 是可选的。
在 loader 执行之前,有一个 loadLoader
的方法调用过程,该方法去加载这些 loader 模块,并把 loader 本身赋值为 loader.normal
,pitch 函数赋值为 loader.pitch
。待分离完成后,会执行该 loader.pitch,如果 pitch 的没有返回结果,则递归去加载下一个 loader,这个过程会维护 loaderContext.loaderIndex 累加,所以这个过程是个顺序的,一直到 loaderIndex >= loaderContext.loaders.length 时退出递归再去执行 loader 的 normal;而执行 normal 的时候是以 loaderIndex-- 为索引,从 loaders 集合中取出并执行,一直到 loaderIndex <= 0,所以这个过程是个倒序的;
当然,如果在某个 pitch 函数中 return
一些内容,将会 -越过- 剩余 loader 的解析,直接进入已经解析过的 loader 的 normal 执行阶段
- 一个有 pitch 的 loader 示例
// some-loader.js
module.exports = function (cnt) { return someSyncOps(cnt, this.data.value )}
// 定义一个 pitch
module.exports.pitch = function (remainRequest, precedingRequest, data) {
data.value = 42
}
- pitch/normal 简化代码示例:
// node_modules/loader-runner/lib/LoaderRunner.js
exports.runLoaders = function runLoaders(options, callback) {
// 进入 piching 阶段
iteratePitchingLoaders(processOptions, loaderContext, function(err, result) {});
};
function iteratePitchingLoaders(options, loaderContext, callback) {
// 加载完最后一个 loader 后终止该递归,进入normal 阶段
if(loaderContext.loaderIndex >= loaderContext.loaders.length) return processResource(options, loaderContext, callback);
var currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];
// 前一个 loader 的 pitch 执行过后 pitchExecuted 为 true,维护 loaderIndex++,递归下一个
if(currentLoaderObject.pitchExecuted) {
loaderContext.loaderIndex++;
return iteratePitchingLoaders(options, loaderContext, callback);
}
// 加载 loader 模块(分组)
loadLoader(currentLoaderObject, function(err) {
var fn = currentLoaderObject.pitch;
currentLoaderObject.pitchExecuted = true;
// 如果没有 pitch 直接递归下一个:
if(!fn) return iteratePitchingLoaders(options, loaderContext, callback);
// 聪明的小伙伴都已经发现没有调用过程。。调用过程呢?在 runSyncOrAsync
runSyncOrAsync(
fn,
loaderContext, [loaderContext.remainingRequest, loaderContext.previousRequest, currentLoaderObject.data = {}],
function(err) {
var args = Array.prototype.slice.call(arguments, 1);
if(args.length > 0) {
// args 是调用 pitch 时的返回结果,若不为空则终止处理后面的 loader 进入到 normal 阶段
loaderContext.loaderIndex--;
// 见后面 function iterateNormalLoaders
iterateNormalLoaders(options, loaderContext, args, callback);
} else {
// 顺利执行完一个 pitch 后递归下一个 pitch
iteratePitchingLoaders(options, loaderContext, callback);
}
}
);
});
}
function iterateNormalLoaders(options, loaderContext, args, callback) {
// loaderIndex < 0 则直接退出递归执行后续逻辑,
// 所以可以在 loader 中通过设置 loaderIndex 为一个负数达到终止 run loader 的目的
if(loaderContext.loaderIndex < 0) return callback(null, args);
var currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];
// 维护 loaderIndex--
if(currentLoaderObject.normalExecuted) {
loaderContext.loaderIndex--;
return iterateNormalLoaders(options, loaderContext, args, callback);
}
var fn = currentLoaderObject.normal;
currentLoaderObject.normalExecuted = true;
if(!fn) return iterateNormalLoaders(options, loaderContext, args, callback);
runSyncOrAsync(fn, loaderContext, args, function(err) {
// 一个 loader 顺利执行完会执行该回调递归执行下一个
iterateNormalLoaders(options, loaderContext, args, callback);
});
}
// node_modules/loader-runner/lib/loadLoader.js
module.exports = function loadLoader(loader, callback) {
var module = require(loader.path);
loader.normal = typeof module === "function" ? module : module.default;
loader.pitch = module.pitch;
}
3.8 异步 loader 顺序管控
同步 loader 的顺序靠执行顺序即可正确保证,那么在使用异步 loader 时,webpack 是如何保证 loader 仍然以预期的顺序正确执行?
这个小技巧在上文出现 runSyncOrAsync
方法,这个方法用于跑 loader.normal 或者 loader.pitch,其中处理了异步管控流程。核心实现是通过 扩展的loaderContext.async()
方法维护 isSync
标识符来实异步串行。调用 loader 时,会将 loaderContext
同 loader 函数中的 this
绑定,loaderContext.async
即异步 loader 中的 this.async
;
调用该 async 方法得到一个 callback,当异步 loader 中的异步逻辑完成时需要调用这个 this.async 方法返回的 callback,意在告知 webpack 这个异步 loader 跑完了,跑下一个吧~;
- 简化代码
// node_modules/loader-runner/lib/LoaderRunner.js
function runSyncOrAsync(fn, context, args, callback) {
var isSync = true;
context.async = function async() {
isSync = false;
return innerCallback;
};
var innerCallback = context.callback = function() {
isSync = false;
callback.apply(null, arguments);
};
var result = (function LOADER_EXECUTION() {
// fn 即为 loader 函数,用 apply 将 loaderContext 绑定 this
return fn.apply(context, args);
}());
if(isSync) {
isDone = true;
if(result && typeof result === "object" && typeof result.then === "function") {
return result.then(function(r) {callback(null, r);}, callback);
}
return callback(null, result);
}
}