webpack的工作流程(附带部分源码分析)
@
webpack的工作流程
说明工作流程之前,先抛出两个结论:
webpack 的核心功能,是抽离成很多个内部插件来实现的。
webpack插件通过监听对象对应的钩子而实现特定功能。
按照核心流程分为三个阶段:
- webpack的准备阶段
- modules和chunks的生成阶段
- 文件生成阶段
P.S. 以下的分析都基于 webpack 4.42.1
核心流程图如下:
说明:
图中每一列顶部名称表示该列中任务点所属的对象
图中每一行表示一个阶段
图中每个节点表示任务点名称
图中每个节点括号表示任务点的参数,参数带有callback是异步任务点
图中的箭头表示任务点的执行顺序
图中虚线表示存在循环流程
webpack的准备阶段
这个阶段的主要工作,是创建 Compiler 和 Compilation 实例。
Compilation:这个对象是后续构建流程中最核心最重要的对象,它包含了一次构建过程中所有的数据。也就是说一次构建过程对应一个Compilation 实例。
本阶段流程和钩子
Compiler初始化,加载内部插件,该阶段在Compiler*对象*
本阶段流程和主要钩子 run -> compile->make
run:
任务点 run 只有在 webpack 以正常模式运行的情况下会触发,如果我们以监听(watch)的模式运行 webpack,那么任务点 run 是不会触发的,但是会触发任务点 watch-run。
// ../node_modules/webpack/lib/webpack.js
const webpack = (options, callback) => {
...
let compiler;
if (callback) {
...
// compiler run
compiler.run(callback);
}
return compiler;
};
compile
complie执行时,会生成createNormalModuleFactory和createContextModuleFactory,并传入compilation,后会面modules和chunks的生成阶段做准备
(工厂对象顾名思义就是用来创建实例的,它们后续用来创建 NormalModule 以及 ContextModule 实例)。
// ../node_modules/webpack/lib/Compiler.js
run(callback) {
...
this.hooks.beforeRun.callAsync(this, err => {
if (err) return finalCallback(err);
this.hooks.run.callAsync(this, err => {
if (err) return finalCallback(err);
this.readRecords(err => {
if (err) return finalCallback(err);
// compiler compile
this.compile(onCompiled);
});
});
});
}
make
make钩子触发,相应插件运行,开始进入modules和chunks的生成阶段
// ../node_modules/webpack/lib/Compiler.js
newCompilationParams() {
const params = {
normalModuleFactory: this.createNormalModuleFactory(),
contextModuleFactory: this.createContextModuleFactory(),
compilationDependencies: new Set()
};
return params;
}
compile(callback) {
// createNormalModuleFactory和createContextModuleFactory 对象
const params = this.newCompilationParams();
this.hooks.beforeCompile.callAsync(params, err => {
if (err) return callback(err);
this.hooks.compile.call(params);
// 生成compilation
// 传入createNormalModuleFactory和createContextModuleFactory 对象
const compilation = this.newCompilation(params);
// make钩子触发 向其他插件传入compilation对象
// 开始进入modules和chunks的生成阶段
this.hooks.make.callAsync(compilation, err => {
if (err) return callback(err);
// compilation对象 finishModules钩子触发 所有modules解析构建完成
compilation.finish(err => {
if (err) return callback(err);
// compilation对象 seal钩子触发
compilation.seal(err => {
if (err) return callback(err);
// compiler对象 afterCompile钩子触发
this.hooks.afterCompile.callAsync(compilation, err => {
if (err) return callback(err);
return callback(null, compilation);
});
});
});
});
});
}
modules和chunks的生成阶段
这个阶段的主要内容,是先解析项目依赖的所有 modules,再根据 modules 生成 chunks。
module 解析,包含了三个主要步骤:创建实例、loaders应用以及依赖收集。
chunks 生成, 主要步骤是找到 chunk 所需要包含的 modules。
module 解析
当上一个阶段make钩子被触发,此时内部入口插件(SingleEntryPlugin, MultiEntryPlugin, DynamicEntryPlugin)监听器会开始执行。监听器都会调用 Compilation 实例的 addEntry 方法,该方法将会触发第一批 module 的解析,这些 module 就是 entry 中配置的入口模块。
内部入口插件包括:SingleEntryPlugin, MultiEntryPlugin, DynamicEntryPlugin
根据不同entry配置调用不同入口插件
内部入口插件注册大概流程:
// ../node_modules/webpack/lib/webpack.js
// 通过WebpackOptionsApply 注册内部插件
compiler.options = new WebpackOptionsApply().process(options, compiler);
// ../node_modules/webpack/lib/WebpackOptionsApply .js
class WebpackOptionsApply extends OptionsApply {
...
process(options, compiler) {
// 注册entryOption钩子监听器
new EntryOptionPlugin().apply(compiler);
// entryOption钩子
compiler.hooks.entryOption.call(options.context, options.entry);
}
}
// ../node_modules/webpack/lib/EntryOptionPlugin .js
const itemToPlugin = (context, item, name) => {
if (Array.isArray(item)) {
return new MultiEntryPlugin(context, item, name);
}
return new SingleEntryPlugin(context, item, name);
};
module.exports = class EntryOptionPlugin {
apply(compiler) {
// 监听entryOption钩子
compiler.hooks.entryOption.tap("EntryOptionPlugin", (context, entry) => {
if (typeof entry === "string" || Array.isArray(entry)) {
itemToPlugin(context, entry, "main").apply(compiler);
} else if (typeof entry === "object") {
for (const name of Object.keys(entry)) {
itemToPlugin(context, entry[name], name).apply(compiler);
}
} else if (typeof entry === "function") {
new DynamicEntryPlugin(context, entry).apply(compiler);
}
return true;
});
}
};
不管哪个插件,内部都会监听 Compiler 实例对象的 make 钩子; 然后compilation.addEntry运行
以 SingleEntryPlugin 为例:
class SingleEntryPlugin {
...
apply(compiler) {
...
// 监听make钩子函数 compilation.addEntry运行
compiler.hooks.make.tapAsync(
"SingleEntryPlugin",
(compilation, callback) => {
const { entry, name, context } = this;
const dep = SingleEntryPlugin.createDependency(entry, name);
compilation.addEntry(context, dep, name, callback);
}
);
}
解析流程
流程图:
1. 创建 NormalModule 实例
这里需要用到上一个阶段讲到的 NormalModuleFactory 实例, NormalModuleFactory 的 create 方法是创建 NormalModule 实例的入口,内部的主要过程是解析 module 需要用到的一些属性,比如需要用到的 loaders, 资源路径 resource 等等,最终将解析完毕的参数传给 NormalModule 构建函数直接实例化。
这里在解析参数的过程中,有两个比较实用的任务点 before-resolve 和 after-resolve,分别对应了解析参数前和解析参数后的时间点。举个例子,在任务点 before-resolve 可以做到忽略某个 module 的解析,webpack 内部插件 IgnorePlugin 就是这么做的:
// https://github.com/webpack/webpack/blob/master/lib/IgnorePlugin.js class IgnorePlugin { checkIgnore(result, callback) { // check if result is ignored if(this.checkResult(result)) { return callback(); // callback第二个参数为 undefined 时会终止module解析 } return callback(null, result); } apply(compiler) { compiler.plugin("normal-module-factory", (nmf) => { nmf.plugin("before-resolve", this.checkIgnore); }); compiler.plugin("context-module-factory", (cmf) => { cmf.plugin("before-resolve", this.checkIgnore); }); } }
2. NormalModule实例调用 build()继续进行模块的构建
我们熟悉的 loaders 将会在这里开始应用,NormalModule 实例中的 loaders 属性已经记录了该模块需要应用的 loaders。应用 loaders 的过程相对简单,直接调用loader-runner 这个模块即可
webpack 中要求 NormalModule 最终都是 js 模块,所以 loader 的作用之一是将不同的资源文件转化成 js 模块。比如 html-loader 是将 html 转化成一个 js 模块。在应用完 loaders 之后,NormalModule 实例的源码必然就是 js 代码,这对下一个步骤很重要。
3. 需要得到这个 module 所依赖的其他模块,所以就有一个依赖收集的过程。
webpack 的依赖收集过程是将 js 源码传给 js parser(webpack 使用的 parser 是 acorn)
parser 将 js 源码解析后得到对应的AST(抽象语法树, Abstract Syntax Tree)。然后 webpack 会遍历 AST,按照一定规则触发任务点。
有了AST对应的任务点,依赖收集就相对简单了,比如遇到任务点 call require,说明在代码中是有调用了require函数,那么就应该给 module 添加新的依赖。webpack 关于这部分的处理是比较复杂的,因为 webpack 要兼容多种不同的依赖方式,比如 AMD 规范、CommonJS规范,然后还要区分动态引用的情况,比如使用了 require.ensure, require.context。但这些细节对于我们讨论构建流程并不是必须的,因为不展开细节讨论。
parser 解析完成之后,module 的解析过程就完成了。每个 module 解析完成之后,都会触发 Compilation 实例对象的任务点 succeed-module,我们可以在这个任务点获取到刚解析完的 module 对象。正如前面所说,module 接下来还要继续递归解析它的依赖模块,最终我们会得到项目所依赖的所有 modules。此时任务点 make 结束。进入下个任务点 seal**
module 解析完成之后的操作,它会递归调用它所依赖的 modules 进行解析,所以当解析停止时,我们就能够得到项目中所有依赖的 modules,它们将存储在 Compilation 实例的 modules 属性中,并触发任务点 finish-modules:
chunks 生成
Compialtion 实例的 seal 方法会被调用并马上触发任务点 seal。在这个任务点,我们可以拿到所有解析完成的 module,有了所有的 modules 之后,webpack 会开始生成 chunks。webpack 中的 chunk 概念,要不就是配置在 entry 中的模块,要不就是动态引入(比如 require.ensure)的模块。这些 chunk 对象是 webpack 生成最终文件的一个重要依据。
每个 chunk 的生成就是找到需要包含的 modules。这里大致描述一下 chunk 的生成算法:
- webpack 先将 entry 中对应的 module 都生成一个新的 chunk
- 遍历 module 的依赖列表,将依赖的 module 也加入到 chunk 中
- 如果一个依赖 module 是动态引入的模块,那么就会根据这个 module 创建一个新的 chunk,继续遍历依赖
- 重复上面的过程,直至得到所有的 chunks
在所有 chunks 生成之后,webpack 会对 chunks 和 modules 进行一些优化相关的操作,比如分配id、排序等,并且触发一系列相关的任务点,这些任务点一般是 webpack.optimize 属性下的插件会使用到,比如 CommonsChunkPlugin 会使用到任务点 optimize-chunks,但这里我们不深入讨论。
至此,modules 和 chunks 的生成阶段结束。接下来是文件生成阶段。
文件生成阶段
这个阶段的主要内容,是根据 chunks 生成最终文件。主要有三个步骤:
- 模板 hash 更新
- 模板渲染 chunk
- 生成文件
Compilation 在实例化的时候,就会同时实例化三个对象:MainTemplate, ChunkTemplate,ModuleTemplate。这三个对象是用来渲染 chunk 对象,得到最终代码的模板。第一个对应了在 entry 配置的入口 chunk 的渲染模板,第二个是动态引入的非入口 chunk 的渲染模板,最后是 chunk 中的 module 的渲染模板。
模板 hash 更新
在开始渲染之前,Compilation 实例会调用 createHash 方法来生成这次构建的 hash。在 webpack
的配置中,我们可以在 output.filename 中配置 [hash] 占位符,最终就会替换成这个 hash。同样,createHash 也会为每一个 chunk 也创建一个 hash,对应 output.filename 的 [chunkhash] 占位符。
每个 hash 的影响因素比较多,首先三个模板对象会调用 updateHash 方法来更新 hash,在内部还会触发任务点 hash,传递 hash 到其他插件。 chunkhash 也是类似的原理。
模板渲染 chunk
当 hash 都创建完成之后,下一步就会遍历 compilation.chunks 来渲染每一个 chunk。如果一个 chunk 是入口 chunk,那么就会调用 MainTemplate 实例的 render 方法,否则调用 ChunkTemplate 的 render 方法
这里注意到 ModuleTemplate 实例会被传递下去,在实际渲染时将会用 ModuleTemplate 来渲染每一个
module,其实更多是往 module 前后添加一些"包装"代码,因为 module 的源码实际上是已经渲染完毕的,前面的loader已经将源码转为JS源码
不同模版的说明:
// 入口 chunk
/******/ (function(modules) { // webpackBootstrap
/******/ // install a JSONP callback for chunk loading
/******/ var parentJsonpFunction = window["webpackJsonp"];
/******/ window["webpackJsonp"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {
/******/ // add "moreModules" to the modules object,
/******/ // then flag all "chunkIds" as loaded and fire callback
/******/ var moduleId, chunkId, i = 0, resolves = [], result;
/******/ for(;i < chunkIds.length; i++) {
/******/ chunkId = chunkIds[i];
/******/ if(installedChunks[chunkId]) {
/******/ resolves.push(installedChunks[chunkId][0]);
/******/ }
/******/ installedChunks[chunkId] = 0;
/******/ }
/******/ for(moduleId in moreModules) {
/******/ if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
/******/ modules[moduleId] = moreModules[moduleId];
/******/ }
/******/ }
/******/ if(parentJsonpFunction) parentJsonpFunction(chunkIds, moreModules, executeModules);
/******/ while(resolves.length) {
/******/ resolves.shift()();
/******/ }
/******/
/******/ };
/******/ // 其他代码..
/******/ })(/* modules代码 */);
// 动态引入的 chunk
webpackJsonp([0],[
/* modules代码.. */
]);
createChunkAssets 流程如下:
当每个 chunk 的源码生成之后,就会添加在 Compilation 实例的 assets 属性中。
assets 对象的 key 是最终要生成的文件名称,因此这里要用到前面创建的 hash。调用 Compilation 实例内部的 getPath 方法会根据配置中的 output.filename 来生成文件名称。
assets 对象的 value 是一个对象,对象需要包含两个方法,source 和 size分别返回文件内容和文件大小。
当所有的 chunk 都渲染完成之后,assets 就是最终更要生成的文件列表。此时 Compilation 实例还会触发几个任务点,例如 addtional-chunk-assets,addintial-assets等,在这些任务点可以修改 assets 属性来改变最终要生成的文件。
完成上面的操作之后,Compilation 实例的 seal 方法结束,进入到 Compiler 实例的 emitAssets 方法。Compilation 实例的所有工作到此也全部结束,意味着一次构建过程已经结束,接下来只有文件生成的步骤。
生成文件
在 Compiler 实例开始生成文件前,最后一个修改最终文件生成的任务点 emit 会被触发:
// 监听 emit 任务点,修改最终文件的最后机会 compiler.plugin("emit", (compilation, callback) => { let data = "abcd" compilation.assets["newFile.js"] = { source() { return data } size() { return data.length } } })
当任务点 emit 被触发之后,接下来 webpack 会直接遍历 compilation.assets生成所有文件,然后触发任务点 done,结束构建流程。
总结
经过对核心流程梳理,发现webpack核心流程还是相当繁琐,且参与对象较多,任务点也多;为了便于记忆,个人总结了一个简易版的
- 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数;
- 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译;
- 确定入口:根据配置中的 entry 找出所有的入口文件;
- 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理;
- 完成模块编译:在经过第4步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系;
- 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会;
- 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统