webpack工作原理
首先,我们要理解一个点,Webpack 最核心的功能:
At its core, webpack is a static module bundler for modern JavaScript applications.
也就是将各种类型的资源,包括图片、css、js等,转译、组合、拼接、生成 JS 格式的 bundler 文件。官网首页的动画很形象地表达了这一点:
基本概念
在了解 Webpack 原理前,需要掌握以下几个核心概念,以方便后面的理解:
- Entry:入口,Webpack 执行构建的第一步将从 Entry 开始,可抽象成输入。
- Module:模块,在 Webpack 里一切皆模块,一个模块对应着一个文件。Webpack 会从配置的 Entry 开始递归找出所有依赖的模块。
- Chunk:代码块,一个 Chunk 由多个模块组合而成,用于代码合并与分割。
- Loader:模块转换器,用于把模块原内容按照需求转换成新内容。
- Plugin:扩展插件,在 Webpack 构建流程中的特定时机会广播出对应的事件,插件可以监听这些事件的发生,在特定时机做对应的事情。
流程概括
Webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:
- 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数;
- 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译;
- 确定入口:根据配置中的 entry 找出所有的入口文件;
- 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理;
- 完成模块编译:在经过第4步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系;
- 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会;
- 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。
在以上过程中,Webpack 会在特定的时间点广播出特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果。
流程细节
这个过程核心完成了 内容转换 + 资源合并 两种功能,实现上包含三个阶段:
- 初始化阶段
- 初始化参数: 从配置文件、配置对象、Shell参数中读取,与默认配置结合的到最终参数
- 创建初始化编译器对象: 用上一步得到的参数创建
Complier
对象 - 初始化编译环境: 包括注入的插件、注册各种模块工厂、初始化
RuleSet
集合 、加载配置插件等 - 开始编译: 执行
complier
的run
方法 - 确定入口: 根据配置文件中的
entry
抓出所有的入口文件,调用complication.addEntry
将 入口文件转化成dependence
对象
- 构建阶段
- 编译模块(make): 根据
entry
对应的dependence
创建module
对象,调用loader
将模块转译为标准 JS 内容,调用 JS 解释器将内容转换为 AST 对象,从中找出该模块依赖的模块,再 递归 本步骤直到所有入口依赖的文件都经过了本步骤的处理 - 完成模块编译:上一步递归处理所有能触达到的模块后,得到了每个模块被翻译后的内容以及它们之间的 依赖关系图
- 编译模块(make): 根据
- 生成阶段:
- 输出资源(seal):根据入口和模块之间的依赖关系,组装成一个个包含多个模块的
Chunk
,再把每个Chunk
转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会 - 写入文件系统(emitAssets):在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
- 输出资源(seal):根据入口和模块之间的依赖关系,组装成一个个包含多个模块的
在每个大阶段中又会发生很多事件,Webpack 会把这些事件广播出来供给 Plugin 使用。
初始化阶段
/**
* @param {WebpackOptions} rawOptions options object
* @returns {Compiler} a compiler
*/
const createCompiler = rawOptions => {
const options = getNormalizedWebpackOptions(rawOptions);
applyWebpackOptionsBaseDefaults(options);
// 实例化 Compiler
const compiler = new Compiler(options.context, options);
new NodeEnvironmentPlugin({
infrastructureLogging: options.infrastructureLogging
}).apply(compiler);
if (Array.isArray(options.plugins)) {
for (const plugin of options.plugins) {
if (typeof plugin === "function") {
plugin.call(compiler, compiler);
} else {
plugin.apply(compiler);
}
}
}
applyWebpackOptionsDefaults(options);
compiler.hooks.environment.call();
compiler.hooks.afterEnvironment.call();
new WebpackOptionsApply().process(options, compiler);
compiler.hooks.initialize.call();
return compiler;
};
解释一下:
- 将
process.args + webpack.config.js
合并成用户配置 - 调用
validateSchema
校验配置 - 调用
getNormalizedWebpackOptions + applyWebpackOptionsBaseDefaults
合并出最终配置 - 创建
compiler
对象 - 遍历用户定义的
plugins
集合,执行插件的apply
方法 - 调用
new WebpackOptionsApply().process
方法,加载各种内置插件
主要逻辑集中在 WebpackOptionsApply
类,webpack 内置了数百个插件,这些插件并不需要我们手动配置,WebpackOptionsApply
会在初始化阶段根据配置内容动态注入对应的插件,包括:
- 注入
EntryOptionPlugin
插件,处理entry
配置 - 根据
devtool
值判断后续用那个插件处理sourcemap
,可选值:EvalSourceMapDevToolPlugin
、SourceMapDevToolPlugin
、EvalDevToolModulePlugin
- 注入
RuntimePlugin
,用于根据代码内容动态注入 webpack 运行时
到这里,compiler
实例就被创建出来了,相应的环境参数也预设好了,紧接着开始调用 compiler.compile
函数:
// 取自 webpack/lib/compiler.js
/**
* @param {Callback<Compilation>} callback signals when the compilation finishes
* @returns {void}
*/
compile(callback) {
const params = this.newCompilationParams();
this.hooks.beforeCompile.callAsync(params, err => {
if (err) return callback(err);
this.hooks.compile.call(params);
const compilation = this.newCompilation(params);
const logger = compilation.getLogger("webpack.Compiler");
logger.time("make hook");
this.hooks.make.callAsync(compilation, err => {
logger.timeEnd("make hook");
if (err) return callback(err);
logger.time("finish make hook");
this.hooks.finishMake.callAsync(compilation, err => {
logger.timeEnd("finish make hook");
if (err) return callback(err);
process.nextTick(() => {
logger.time("finish compilation");
compilation.finish(err => {
logger.timeEnd("finish compilation");
if (err) return callback(err);
logger.time("seal compilation");
compilation.seal(err => {
logger.timeEnd("seal compilation");
if (err) return callback(err);
logger.time("afterCompile hook");
this.hooks.afterCompile.callAsync(compilation, err => {
logger.timeEnd("afterCompile hook");
if (err) return callback(err);
return callback(null, compilation);
});
});
});
});
});
});
});
}
Webpack 架构很灵活,但代价是牺牲了源码的直观性,比如说上面说的初始化流程,从创建 compiler
实例到调用 make
钩子,逻辑链路很长:
- 启动 webpack ,触发
lib/webpack.js
文件中createCompiler
方法 createCompiler
方法内部调用WebpackOptionsApply
插件WebpackOptionsApply
定义在lib/WebpackOptionsApply.js
文件,内部根据entry
配置决定注入entry
相关的插件,包括:DllEntryPlugin
、DynamicEntryPlugin
、EntryPlugin
、PrefetchPlugin
、ProgressPlugin
、ContainerPlugin
Entry
相关插件,如lib/EntryPlugin.js
的EntryPlugin
监听compiler.make
钩子lib/compiler.js
的compile
函数内调用this.hooks.make.callAsync
- 触发
EntryPlugin
的make
回调,在回调中执行compilation.addEntry
函数 compilation.addEntry
函数内部经过一坨与主流程无关的hook
之后,再调用handleModuleCreate
函数,正式开始构建内容
这个过程需要在 webpack 初始化的时候预埋下各种插件,经历 4 个文件,7次跳转才开始进入主题。
构建阶段
基本流程
lib/Compilation.js
你有没有思考过这样的问题:
- Webpack 编译过程会将源码解析为 AST 吗?webpack 与 babel 分别实现了什么?
- Webpack 编译过程中,如何识别资源对其他资源的依赖?
- 相对于 grunt、gulp 等流式构建工具,为什么 webpack 会被认为是新一代的构建工具?
这些问题,基本上在构建阶段都能看出一些端倪。构建阶段从 entry
开始递归解析资源与资源的依赖,在 compilation
对象内逐步构建出 module
集合以及 module
之间的依赖关系,核心流程:
暂时无法在{app_display_name}文档外展示此内容
解释一下,构建阶段从入口文件开始:
调用
handleModuleCreate
,根据文件类型构建module
子类调用 loader-runner 仓库的
runLoaders
转译module
内容,通常是从各类资源类型转译为 JavaScript 文本调用 acorn 将 JS 文本解析为AST
遍历 AST,触发各种钩子
- 在
HarmonyExportDependencyParserPlugin
插件监听exportImportSpecifier
钩子,解读 JS 文本对应的资源依赖 - 调用
module
对象的addDependency
将依赖对象加入到module
依赖列表中
- 在
AST 遍历完毕后,调用
module.handleParseResult
处理模块依赖对于
module
新增的依赖,调用handleModuleCreate
,控制流回到第一步所有依赖都解析完毕后,构建阶段结束
这个过程中数据流 module => ast => dependences => module
,先转 AST 再从 AST 找依赖。这就要求 loaders
处理完的最后结果必须是可以被 acorn 处理的标准 JavaScript 语法,比如说对于图片,需要从图像二进制转换成类似于 export default "data:image/png;base64,xxx"
这类 base64 格式或者 export default "http://xxx"
这类 url 格式。
compilation
按这个流程递归处理,逐步解析出每个模块的内容以及 module
依赖关系,后续就可以根据这些内容打包输出。
总结
- Webpack 编译过程会将源码解析为 AST 吗?webpack 与 babel 分别实现了什么?
- 构建阶段会读取源码,解析为 AST 集合。
- Webpack 读出 AST 之后仅遍历 AST 集合;babel 则对源码做等价转换
- Webpack 编译过程中,如何识别资源对其他资源的依赖?
- Webpack 遍历 AST 集合过程中,识别
require/ import
之类的导入语句,确定模块对其他资源的依赖关系
- Webpack 遍历 AST 集合过程中,识别
- 相对于 grant、gulp 等流式构建工具,为什么 webpack 会被认为是新一代的构建工具?
- Grant、Gulp 仅执行开发者预定义的任务流;而 webpack 则深入处理资源的内容,功能上更强大
生成阶段
基本流程
构建阶段围绕 module
展开,生成阶段则围绕 chunks
展开。经过构建阶段之后,webpack 得到足够的模块内容与模块关系信息,接下来开始生成最终资源了。代码层面,就是开始执行 compilation.seal
函数:
// 取自 webpack/lib/compiler.js
compile(callback) {
const params = this.newCompilationParams();
this.hooks.beforeCompile.callAsync(params, err => {
// ...
const compilation = this.newCompilation(params);
this.hooks.make.callAsync(compilation, err => {
// ...
this.hooks.finishMake.callAsync(compilation, err => {
// ...
process.nextTick(() => {
compilation.finish(err => {
**compilation.seal**(err => {...});
});
});
});
});
});
}
seal
原意密封、上锁,我个人理解在 webpack 语境下接近于 “将模块装进蜜罐” 。seal
函数主要完成从 module
到 chunks
的转化,核心流程:
简单梳理一下:
- 构建本次编译的
ChunkGraph
对象; - 遍历
compilation.modules
集合,将module
按entry/动态引入
的规则分配给不同的Chunk
对象; compilation.modules
集合遍历完毕后,得到完整的chunks
集合对象,调用createXxxAssets
方法createXxxAssets
遍历module/chunk
,调用compilation.emitAssets
方法将资assets
信息记录到compilation.assets
对象中- 触发
seal
回调,控制流回到compiler
对象
这一步的关键逻辑是将 module
按规则组织成 chunks
,webpack 内置的 chunk
封装规则比较简单:
entry
及 entry 触达到的模块,组合成一个chunk
- 使用动态引入语句引入的模块,各自组合成一个
chunk
chunk
是输出的基本单位,默认情况下这些 chunks
与最终输出的资源一一对应,那按上面的规则大致上可以推导出一个 entry
会对应打包出一个资源,而通过动态引入语句引入的模块,也对应会打包出相应的资源,我们来看个示例。
写入文件系统
经过构建阶段后,compilation
会获知资源模块的内容与依赖关系,也就知道“输入”是什么;而经过 seal
阶段处理后, compilation
则获知资源输出的图谱,也就是知道怎么“输出”:哪些模块跟那些模块“绑定”在一起输出到哪里。seal
后大致的数据结构:
compilation = {
// ...
modules: [
/* ... */
],
chunks: [
{
id: "entry name",
files: ["output file name"],
hash: "xxx",
runtime: "xxx",
entryPoint: {xxx}
// ...
},
// ...
],
};
seal
结束之后,紧接着调用 compiler.emitAssets
函数,函数内部调用 compiler.outputFileSystem.writeFile
方法将 assets
集合写入文件系统,实现逻辑比较曲折,但是与主流程没有太多关系,所以这里就不展开讲了。
资源形态流转
OK,上面已经把逻辑层面的构造主流程梳理完了,这里结合资源形态流转的角度重新考察整个过程,加深理解:
compiler.make
阶段:entry
文件以dependence
对象形式加入compilation
的依赖列表,dependence
对象记录有entry
的类型、路径等信息- 根据
dependence
调用对应的工厂函数创建module
对象,之后读入module
对应的文件内容,调用loader-runner
对内容做转化,转化结果若有其它依赖则继续读入依赖资源,重复此过程直到所有依赖均被转化为module
compilation.seal
阶段:- 遍历
module
集合,根据entry
配置及引入资源的方式,将module
分配到不同的chunk
- 遍历
chunk
集合,调用compilation.emitAsset
方法标记chunk
的输出规则,即转化为assets
集合
- 遍历
compiler.emitAssets
阶段:- 将
assets
写入文件系统
- 将