通过一个demo带你深入进入webpack@4.46.0
源码的世界,分析构建原理,专栏地址,共有十篇。
- 1. 从构建前后产物对比分析webpack做了些什么?
- 2. webpack构建的基石: tapable@1.1.3源码分析
- 3. webpack构建整体流程的组织:webpack -> Compiler -> Compilation
- 4. 创建模块实例,为模块解析准备
- 5. 路径解析:enhanced-resolve@4.5.0源码分析
- 6. 模块构建之loader执行:loader-runner@2.4.0源码分析
- 7. 模块构建之解析_source获取dependencies
- 8. 从dependency graph 到 chunk graph
- 9. 从chunk到最终的文件内容到最后的文件输出?
- 10. webpack中涉及了哪些设计模式呢?
第四节创建完模式实例后,进入模块的构建工作,本节重点说loaders的执行。
看下a.js
在执行runLoaders是控制台的日志:
看到先是执行所有的module.exports.pitch指向的函数函数,然后再执行module.exports指向的函数。实际上webpack将loader的执行分为两个阶段:pitching和normal。
再关注下在某个阶段不同类型(pre、post、normal、inline四种类型)的loader的执行顺序:
- pitching阶段:post -> inline -> normal -> pre
- normal阶段:pre -> normal -> inline -> post
下面分析下具体的执行过程:NormalModuleFactory.resolve 解析获取loader后
代码语言:javascript复制// Compilation.js
buildModule(module, optional, origin, dependencies, thisCallback){
module.build(..., error => {/*...*/})
}
// NormalModule.js
const { getContext, runLoaders } = require("loader-runner"); // 调用的loader-runner包
build(options, compilation, resolver, fs, callback) {
return this.doBuild(..., fs, err => {/*...*/})
}
doBuild(options, compilation, resolver, fs, callback) {
// 创建loader的执行上下文
const loaderContext = this.createLoaderContext(/*...*/);
runLoaders({
resource: this.resource,
loaders: this.loaders, // 传入loaders ,NomalModleFactory new NormalModule()传入的
context: loaderContext,
readResource: fs.readFile.bind(fs)
}, (err, result) => { /*...*/ })
}
并且经过xxxResolver.resolve拿到了loader和resouce的本地路径
LoadRunner
loader-runner@2.4.0目录结构如下:
主要是LoaderRunner.js和loadLoader.js,前者执行loader,后者通过指定本地路径加载js代码到内存。
runLoaders(loader-runner包)
LoaderRunner.js
这个文件主要包含三个部分内容
1. 给loaderContext对象添加部分属性和方法
代码语言:javascript复制// read options
var resource = options.resource || ""; // 资源信息,将上面截图
var loaders = options.loaders || [];
// 来自NormalModule创建的context,所以每个module的执行loader的loaderContext是独立的
// 但是该module的所有loaders共享这个上线文
var loaderContext = options.context || {};
var readResource = options.readResource || readFile; // 文件读取方法
// 拆分resource为path和query单独存储
var splittedResource = resource && splitQuery(resource);
var resourcePath = splittedResource ? splittedResource[0] : undefined;
var resourceQuery = splittedResource ? splittedResource[1] : undefined;
var contextDirectory = resourcePath ? dirname(resourcePath) : null; // 资源所在目录
// execution state
var requestCacheable = true; // 是否要缓存当前模块
var fileDependencies = []; // 依赖的文件
var contextDependencies = []; // 依赖的目录
loaders = loaders.map(createLoaderObject);
loaderContext.context = contextDirectory;
loaderContext.loaderIndex = 0; // 用于记录当前执行的loader的索引,查找当前执行的loader
loaderContext.loaders = loaders; // 保存这次所有的loaders
loaderContext.resourcePath = resourcePath;
loaderContext.resourceQuery = resourceQuery;
loaderContext.async = null; // 异步loader标识
loaderContext.callback = null; // 异步loader回调
loaderContext.cacheable = function cacheable(flag) { // 缓存
if(flag === false) {
requestCacheable = false;
}
};
当前demo中以解析a.js为例,原始的资源路径是:/Users/.../src/simple/a.js?c=d'
resourcePath: '/Users/.../src/simple/a.js'
resourceQuery: '?c=d'
async
和callback
使用为支持异步loader
,后面分析loader执行时会说到。fileDependencies
和contextDependencies
:表示依赖的文件和文件夹,可以通过下面的方法addDependency和addContextDependency进行添加,getDependencies和getContextDependencies获取。关于fileDependencies和contextDependencies的具体作用后面会单独说。
// -------- 文件依赖、目录依赖 添加、获取、清理----------
// NormalModule保存了fileDependencies、contextDependencies,猜测变更时使用?
// 当前文件的processResource方法有用到
loaderContext.dependency = loaderContext.addDependency = ...
loaderContext.addContextDependency = //...
loaderContext.getDependencies = //...;
loaderContext.getContextDependencies = //...
loaderContext.clearDependencies = //...
cacheable(boolean)
用来设置是否需要缓存当前模块的构建结果。后面会细说- 通过Object.defineProperty设置get/set,动态计算属性结果。
// 下面的xxx: request、remainingRequest、currentRequest、previousRequest、query、data
Object.defineProperty(loaderContext, `${xxx}`, ...);
// finish loader context
// 冻结,不让扩展loaderContext
if(Object.preventExtensions) {
Object.preventExtensions(loaderContext);
}
request
、remainingRequest
、currentRequest
、previousRequest
:loaders
和resource
通过!
拼接的结果,这几个属性的主要区别在于包含的loaders
不一样;request
属性包含所有的loaders,remainingRequest
包含剩余未执行的loaders,currentRequest
包含当前正在执行及后面未执行的loaders,previousRequest
包含已经执行过的loaders
loaderContext.request在当前demo中的结果
代码语言:javascript复制// loaderContext.request
/Users/.../src/simple/custom-loaders/custom-post-loader.js!/Users/.../src/simple/custom-loaders/custom-inline-loader.js??share-opts!/Users/.../src/simple/custom-loaders/custom-normal-loader.js??share-opts!/Users/.../src/simple/custom-loaders/custom-pre-loader.js!/Users/.../src/simple/a.js?c=d
loaderContext.query:当前正在执行的loader的options或者query;假设当前这在执行custom-inline-loader,loaderContext.query的结果是
代码语言:javascript复制{ a: 'b' } // options
2. 给每一个loader创建一个运行时对象,用来存储该loader的执行状态
代码语言:javascript复制loaders = loaders.map(createLoaderObject);
// createLoaderObject返回一个对象包含以下属性用于记录loader执行是的一些状态
{
path: null, // loader路径,通过Object.defineProperty定义request属性,然后 obj.request = loader;
query: null, // 如果loader设置了options则返回options,否则返回laoder的query即‘?’及其后面的字符串
options: null, // 定义loader可以是字符串,也可以是对象,对象的化可以提供该选项
ident: null, // 标识符,可以通过该标志查找loader
normal: null, // normal类型的loader - 函数
pitch: null, // pitch类型的loader - 函数
raw: null, // 是否返回二进制资源
data: null, // loader间共享的数据
pitchExecuted: false, // 标识pitch类型的loader是否执行过,一个loader可以提供normal,pitch
normalExecuted: false // 标识normal类型的loader是否执行过
};
上面注释很清楚了,不在赘述
3. pitching阶段执行所有loaders
代码语言:javascript复制var processOptions = {
resourceBuffer: null,
readResource: readResource // 静态资源读取的方法
};
// 开始遍历loader并执行,执行每个loader文件中的module.exports.pitch函数
iteratePitchingLoaders(processOptions, loaderContext, function(err, result) {
//...
callback(null, { //返回给 NormalModule.doBuild的回调
result: result, // result结构: [source, sourceMap, {webpackAST}]
resourceBuffer: processOptions.resourceBuffer,
cacheable: requestCacheable,
fileDependencies: fileDependencies,
contextDependencies: contextDependencies
});
});
在前面章节有说到loader的有两个执行阶段:pitching 和 normal;首先会进入pitching阶段,即这里的iteratePitchingLoaders
,用于遍历所有的loader上pitch
函数。
另外这里的callback参数是最终交给NormalModule.runLoaders的回调的。
piching阶段:iteratePitchingLoaders
iteratePitchingLoaders
代码语言:javascript复制// 这里面的执行逻辑都是 pitch函数 的
function iteratePitchingLoaders(options, loaderContext, callback) {
// 如果pichingLoader已经执行完,则走 processResource
// processResource 读取资源,然后进入normalLoader的遍历
if (loaderContext.loaderIndex >= loaderContext.loaders.length)
return processResource(options, loaderContext, callback);
// 获取当前loader的属性
var currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];
// iterate
// 当前loader的pitchingLoader是否执行过了
// 已经执行过,索引加一,进入下一个pitchingLoader的执行
if (currentLoaderObject.pitchExecuted) {
loaderContext.loaderIndex ;
return iteratePitchingLoaders(options, loaderContext, callback);
}
// load loader module
// 如果当前pitchingLoader没有执行过,则执行
// loaderLoader类似于 reuqire或者import,根据loader.path加载loader模块
// 然后设置currentLoaderObject的pitch、normal,raw等属性
loadLoader(currentLoaderObject, function (err) {
//...
var fn = currentLoaderObject.pitch; // 获取pitchingLoader
currentLoaderObject.pitchExecuted = true; // 设置当前pitchingLoader执行过的标志
// 如果不是函数,则进入下一个pichingLoader的执行
if (!fn) return iteratePitchingLoaders(options, loaderContext, callback);
// 开始执行loader(支持同步和异步方式调用)
runSyncOrAsync(fn,
// 参数同时传递了 remainingRequest、previousRequest,loader.data引用
loaderContext, [loaderContext.remainingRequest, loaderContext.previousRequest, currentLoaderObject.data = {}],
function (err) {
//...
// **注意**:这里的参数会被传递给normalLoader的入参
// 应该是字符串或者数组,字符串或者数组的第一个元素当做normalLoader的入参:source
// 即资源的内容
// 解释了没有走 processResource 的source 该如何获取的问题
// 如果所有的pitchLoader都没有返回参数则调用processResource读取,如果任意一个返回
// 则从返回值获取source,传递给normalLoader
var args = Array.prototype.slice.call(arguments, 1);
// 如果你的pitchingLoader返回了结果,则会忽略后面所有的loader(不论是normal还是pitch)
// 直接从从上一个loader.nomal开始执行
if (args.length > 0) {
loaderContext.loaderIndex--; // 从上一个loader开始,即当前loader.normal不会被执行了
// 注意:有传递args给normalLoader
iterateNormalLoaders(options, loaderContext, args, callback);
} else { // 否则继续执行下一个pitchingLoader
iteratePitchingLoaders(options, loaderContext, callback);
}
}
);
});
}
- loader的执行顺序是通过loaderIndex控制的,默认是0。pitching阶段,loaderIndex一直自增直到执行完所有的loader即
loaderIndex>=loaders.length
- 当满足该条件后则开始资源的读取即执行
processResource
,该方法进入normal loader的执行。 - 如果没有,判断当前loader是否执行过(
pitchExecuted
),- 如果已经执行过,则
loaderIndex
后递归调用iteratePitchingLoaders
,进入下一个loader的执行。 - 如果没有执行过,则调用
loadLoader
从本地路径
中加载loader,这个加载的过程可能是异步的,加载成功后在回调中开始执行该loader.pitch
,设置该loader.pitchExecuted=true标识该loader的pitch被执行过- 如果没有
loader.pitch
则递归调用iteratePitchingLoaders
执行下一个loader - 如果有
loader.pitch
则调用runSyncOrAsync
来执行loader.pitch
函数(runSyncOrAsync可以让为同步函数动态添加异步能力,同步和异步由当前函数的执行过程动态决定),当执行完pitch
函数后进入回调根据当前pitch
的返回结果判断进入normal
阶段还是继续pitching阶段的执行,如果返回了参数,则进入normal
阶段执行loader即执行iterateNormalLoaders
,注意这里会将返回值传递给iterateNormalLoaders
。
- 如果没有
- 如果已经执行过,则
- 当满足该条件后则开始资源的读取即执行
简单说下loadLoader
,加载并执行对应的本地js资源
,读取默认值
给currentLoaderObject.normal
属性,读取pitch
属性给currentLoaderObject.pitch
,并记录raw
的值
思考:只有执行完所有的loader.pitch才会进入资源的读取,那如果没有执行完怎么办❓
下面看下资源读取的方法processResource
processResource
代码语言:javascript复制function processResource(options, loaderContext, callback) {
// set loader index to last loader
// 从最后一个loader掉头反向执行所有的normalLoader
loaderContext.loaderIndex = loaderContext.loaders.length - 1;
var resourcePath = loaderContext.resourcePath;
if(resourcePath) {
// 添加文件依赖
loaderContext.addDependency(resourcePath);
// 根据资源地址读取文件内容
options.readResource(resourcePath, function(err, buffer) {
//...
options.resourceBuffer = buffer;
// 进入normalLoader的执行,将资源内容作为参数传递进去
iterateNormalLoaders(options, loaderContext, [buffer], callback);
});
} else {
// 如果没有文件路径,直接进入loader.normal的执行
iterateNormalLoaders(options, loaderContext, [null], callback);
}
}
逻辑很简单,获取resourcePath如这里的/Users/.../src/simple/a.js
,如果没有这个地址,直接进入normal阶段的执行。如果有会先调用options.readSource
(由NormalModuel调用runLoaders时传入)读取该文件,并且会将文件添加到文件依赖fileDependencies
中,然后会进入到normal阶段的执行,注意这里将读取的文件内容
传给了iterateNormalLoaders
。
normal阶段:iterateNormalLoaders
代码语言:javascript复制function iterateNormalLoaders(options, loaderContext, args, callback) {
if (loaderContext.loaderIndex < 0) // 显然
// callback到runLoader方法调用iteratePitchingLoaders的回调
return callback(null, args);
var currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];
// iterate
// 如果当前执行过,则执行下一个normalLoader - 递归
if (currentLoaderObject.normalExecuted) {
loaderContext.loaderIndex--;
return iterateNormalLoaders(options, loaderContext, args, callback);
}
var fn = currentLoaderObject.normal;
currentLoaderObject.normalExecuted = true;
if (!fn) { // 如果没有提供normalLoader,则进入下一个normalLoader的执行
return iterateNormalLoaders(options, loaderContext, args, callback);
}
// 将上一个loader返回的source,根据raw值来决定是:string | buffer
// 当前normalLoaderh会通过暴露一个raw属性来决定传入的source是什么类型
// 通过图片等静态资源要求是buffer
convertArgs(args, currentLoaderObject.raw);
// 开始执行normalLoader
runSyncOrAsync(fn, loaderContext, args, function (err) {
if (err) return callback(err);
// 将当前函数的参数的err移除,取后面所有参数传递给下一个normalLoader
var args = Array.prototype.slice.call(arguments, 1);
iterateNormalLoaders(options, loaderContext, args, callback);
});
}
显然,这里的流程和iteratePitchingLoaders
基本上是对应的,判断normal loaders是否执行完;判断当前normal loader是否执行过;区别如下:
- 多了一个
convertArgs
,会根据raw
的值转换原始资源的内容,raw=true转为二进制,否则转为字符串 - 这里不需要loadLoader,因此该loader已经加载过了,只是之前是获取
pitch
属性,现在是获取默normal
属性。,
runSyncOrAsync
runSyncOrAsync
可以让为同步函数动态添加异步能力,同步和异步由当前函数的执行过程动态决定
如果loader调用this.async()
则会动态的支持接收当前函数的异步结果;如果不调用,默认是同步的;
function runSyncOrAsync(fn, context, args, callback) {
var isSync = true; // 当前loader是同步的还是异步的
var isDone = false; // 当前loader是否执行完成
var isError = false; // internal error
var reportedError = false;
context.async = function async() {
if (isDone) //... 调用该函数时,如果loader已经执行完成则报错
isSync = false; // 置为异步loader
return innerCallback; // 返回异步loader需要的回调,可以通过该回调将异步结果返回给下一个loader
};
var innerCallback = context.callback = function () { // 异步回调
if (isDone) //... 如果已经完成,则报错
isDone = true; // 异步loader调用函数传递结果,说明该loader执行完成
isSync = false; // 置为异步的
try {
callback.apply(null, arguments); // 调用者的回调
} catch (e) //...
};
try {
// 注意:同步执行loader
var result = (function LOADER_EXECUTION() { return fn.apply(context, args); }());
// 在上面同步执行的过程中,如果没有发生调用 this.async()
// 则说明这是一个同步loader
if (isSync) {
isDone = true; // 同步loader执行完成
if (result === undefined) return callback();
if (result && typeof result === "object" && typeof result.then === "function") {
// 兼容loader返回Promise实现异步,可以提供一个案例
return result.then(function (r) { callback(null, r); }, callback);
}
return callback(null, result);
}
} catch (e) // ...
}
默认是同步的,即在没有调用this.async
情况下,loader执行完成后会直接调用runSyncOrAsync
入参中的callback
,结束当前loader的执行。
看到上面方法中给context添加了两个方法async
和callback
,如果执行的loader函数中同步调用
了这async()
,则会设置isSync = false
;那么在执行完LOADER_EXECUTION()
后,由于isSync
是false,所以不会立即调用runSyncOrAsync
入参中的callback
结束来结束当前loader的执行;当前loader的完成状态由async
返回的innerCallback
决定,需要调用者主动结束。
另外LOADER_EXECUTION()
同步执行过程中可以直接调用this.callback
即innerCallback
来结束当前loader的执行。
总结
loader的执行过程如下两种情况:
思考:看到loader执行完成后会返回cacheable、fileDependencies、contextDependencies三个参数的作用❓
代码语言:javascript复制// NormalModule.js
// doBuild()
runLoaders({ /*...*/ }, (err, result) => {
if (result) {
this.buildInfo.cacheable = result.cacheable;
this.buildInfo.fileDependencies = new Set(result.fileDependencies);
this.buildInfo.contextDependencies = new Set(
result.contextDependencies
);
}
//...
})