92.Webpack如何编译打包
Opened this issue · 0 comments
本质上,webpack 是一个现代 Javascript 应用程序的静态模块打包工具。当 webpack 处理应用程序时,它会在内部构建一个 依赖图 (dependency graph),此依赖图会映射项目所需的每个模块,并生成一个多或多个 bundle。
从 v4.0.0开始,webpack 开箱即用,可以无需使用任何配置文件。然而,webpack 会假定项目的入口起点为 src/index,然后会在 dist/main.js 输出结果,并且在生产环境开启压缩和优化。
通常,你的项目还需要继续扩展此能力,为此你可以在项目根目录下创建一个 webpack.config.js 文件,webpack 会自动使用它。
核心概念
Compiler 和 Compilation
Compiler 和 Compilation 类是 webpack的核心模块,都继承于 Tapable。
Compiler 从宏观层面上,负责控制 webpack 打包的整个生命周期,而 Compilation 则从微观层面上,负责具体的读取文件内容、借助loader转译、借助 AST语法树 分析依赖、递归编译依赖文件等细颗粒度的工作。
整体打包流程
读取 webpack 配置和命令行参数,生成最终的配置;
启动 webpack,根据配置创建 Compiler 或 MultiCompiler 实例,将所有内部插件和自己配置的插件全部实例化,并通过实例方法 pluginInstance.apply(compiler),挂载到 compiler 上不同的hooks上,并打包项目;
打包阶段(compiler.run()),一开始会有一些准备工作,然后调用 compiler 的 compile 方法进入编译准备环节。
编译准备环节,会先触发 compiler.hooks.beforeCompile 钩子, 然后触发 compiler.hooks.compile 钩子, 然后新建 Compilation 实例。
在 compilation 的创建过程中,会触发 compiler.hooks.compilation 钩子,把之前创建的不同类型 module 的工厂实例注册到 compilation 的 dependencyFactories 上,用于 compiler.hooks.make 阶段使用。
compiler.hooks.make 阶段,就是编译阶段,最耗时的环节,会触发 compilation 上的不同钩子。首先会先从入口文件(entry)开始解析,并 acorn 生成AST语法树,将import、require等语法替换成webpack自定义的模块加载方法,如__webpack_require__,并分析其中的依赖文件,生成依赖列表,重复前面的操作,递归编译。等所有模块都加载完毕后,make 阶段才结束。
make 阶段结束后,compilation 调用 seal 方法进入 compilation 的seal 阶段, 会触发compilation.hooks.seal 、compilation.hooks.optimize、compilation.hooks.optimizeTree 等钩子,从钩子名称可以看出来,Tree Shaking , Code Spliting、代码压缩等都是在此阶段完成的。然后进入 emit 阶段。
compiler.hooks.emit阶段,使用 neo-async 库,并行写入文件。
读取配置
当开始执行 npx webpack时,先检查是否安装了 webpack-cli 或者 webpack-command(webpack-command 是一个简化版的 webpack-cli,目前已废弃)。然后实例化 WebpackCli,调用 run 方法。
webpack-cli 使用 commander 来封装命令,默认执行 build 命令。
// webpack-cli/lib/webpack-cli.js 1454行
const loadedConfig = await loadConfig(foundDefaultConfigFile.path);
const evaluatedConfig = await evaluateConfig(loadedConfig, options.argv || {});
默认从 webpack.config、.webpack/webpack.config、.webpack/webpackfile 读取配置信息,调用 evaluateConfig 合并配置文件信息和命令行参数。
实例化 Compiler
// webpack-cli/lib/webpack-cli.js 1847行
compiler = this.webpack(
config.options,
callback
? (error, stats) => {
if (error && this.isValidationError(error)) {
this.logger.error(error.message);
process.exit(2);
}
callback(error, stats);
}
: callback,
);
如果 config.options 是数组,则实例化 MultiCompiler,否则则实例化 Compiler。
options = new WebpackOptionsDefaulter().process(options);
WebpackOptionsDefaulter 会把其余未指定的默认配置添加到 options 中。
new NodeEnvironmentPlugin({
infrastructureLogging: options.infrastructureLogging
}).apply(compiler);
NodeEnvironmentPlugin 借助 graceful-fs 模块,为 compiler 提供读写文件的能力。
if (options.plugins && Array.isArray(options.plugins)) {
for (const plugin of options.plugins) {
if (typeof plugin === "function") {
plugin.call(compiler, compiler);
} else {
plugin.apply(compiler);
}
}
}
接下来,遍历所有自己配置的 plugin,调用 apply 方法,订阅不同的compiler 生命周期。例如, 我们常用的webpack.DefinePlugin 会订阅 compiler.hooks.compilation 钩子,插件会在编译的时候,替换符合条件的代码。
// webpack/lib/webpack.js 57行
compiler.options = new WebpackOptionsApply().process(options, compiler);
WebpackOptionsApply 会根据 webpack 配置中的 devtool、 target 等字段,再次往配置中添加内置的插件,并订阅相应的生命周期。其中最重要的是 EntryOptionPlugin 插件,没有它 webpack 就无法找到入口文件:
// webpack/lib/WebpackOptionsApply.js 290行
new EntryOptionPlugin().apply(compiler);
compiler.hooks.entryOption.call(options.context, options.entry);
EntryOptionPlugin 会订阅 compiler.hooks.entryOption 钩子,紧接着在下一行触发钩子,这时会根据是单入口还是多入口,选择实例化 SingleEntryPlugin 或 MultiEntryPlugin (当entry是函数时,会实例化 DynamicEntryPlugin) , 插件会订阅 compiler.hooks.comilation 和 compiler.hooks.make。在后续当 compiler 触发 make 钩子时,会执行 compilation.addEntry...,从入口文件开始执行依赖分析、编译等操作。
启动 (compiler.run())
上述操作完成之后,compiler 调用 run 方法开始打包。
// webpack/lib/Compiler.js 312行
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);
this.compile(onCompiled);
});
});
});
依次触发 beforeRun、run 钩子,然后调用 compile, 进入编译阶段。
编译准备阶段(compiler.compile(onCompiled))
// webpack/lib/Compiler.js 661行
const params = this.newCompilationParams();
// webpack/lib/Compiler.js 651行
newCompilationParams() {
const params = {
normalModuleFactory: this.createNormalModuleFactory(),
contextModuleFactory: this.createContextModuleFactory(),
compilationDependencies: new Set()
};
return params;
}
当调用 compiler 的 compile 方法后,会先新建一个用于实例化 Compilation 的参数对象,该对象包含 normalModuleFactory、contextModuleFactory 两种模块工厂。
normalModule 很好理解,就是编译时确定的普通模块,但大家看到 contextModule 可能会有些疑惑,这是什么模块?
require('template/' + name + '.js');
当出现形如上面的代码时,webpack 会对 require() 的调用进行解析,从中提取有用的信息。
Directory: ./template
Regular expression: /^.*\.js$/
于是就会产生一个contextModule。
如果下面是一个 id为 2 的 contextMoudle, 它包含了一个 map,里面保存了 template 目录下所有模块的引用:
{
'a.js':10,
'b.js':11
}
则
require('template/' + name + '.js'); // 运行时 name 为 a
// 编译过程中会转换为
__webpack_require__(21)(name + '.js')
! 注意: 为了满足动态require的需求,所以所有符合条件的 module 都会被打包到 bundle中。
接下来,会依次触发 beforeCompile、compile 钩子,然后新建一个 Compilation 实例。新建过程中,会触发 compiler.hooks.compilation 钩子,把之前创建的不同类型 module 的工厂实例注册到 compilation 的 dependencyFactories 上,用于 compiler.hooks.make 阶段使用。
编译阶段 compiler.hooks.make
实例化 Compilation,进入 compiler.hooks.make 阶段, 触发 SingleEntryPlugin 订阅的回调函数,执行 compilation.addEntry(), 从入口文件开始,使用 loader-runner 执行符合条件的 loader,然后使用 acorn 生成 AST语法树, 获取依赖,再递归执行前面的操作,直到所有依赖都被加载过。
// SingleEntryPlugin.js 40行
compiler.hooks.make.tapAsync(
"SingleEntryPlugin",
(compilation, callback) => {
const { entry, name, context } = this;
const dep = SingleEntryPlugin.createDependency(entry, name); // dep 就是入口模块
compilation.addEntry(context, dep, name, callback); // context 是 process.cwd()的结果
}
);
addEntry() 后面继续调用 this._addModuleChain(....), 然后一系列操作后,会执行 module.build(...),这就是 入口模块执行编译了。
// NormalModule.js 287行
build(options, compilation, resolver, fs, callback) {
// ...
return this.doBuild(options, compilation, resolver, fs,err => {
// ...
});
}
// ...
doBuild(options, compilation, resolver, fs, callback) {
// 创建当前 module 所有 loader 共用的 上下文
const loaderContext = this.createLoaderContext(
resolver,
options,
compilation,
fs
);
//
runLoaders({
resource: this.resource, // 模块路径
loaders: this.loaders, // loaders
context: loaderContext, // 上下文
readResource: fs.readFile.bind(fs) // 读取文件内容的能力
})
}
runLoaders 会使用 readResouce 读取文件内容,按照从右到左的顺序执行 loader。然后调用 this.parser.parse(code) (acorn) 生成 AST语法树, 读取依赖存储到 module 的 dependencies 上, 然后调用 compilation 的 addModuleDependencies 方法, 使用 neo-async 库异步处理每个 dependency 。
代码优化(compilation.seal 阶段)
我截取部分源码,看看 seal 阶段做了什么?
// Compilation.js 1186行
seal(callback) {
this.hooks.seal.call();
while (
this.hooks.optimizeDependenciesBasic.call(this.modules) ||
this.hooks.optimizeDependencies.call(this.modules) ||
this.hooks.optimizeDependenciesAdvanced.call(this.modules)
) {
/* empty */
}
this.hooks.afterOptimizeDependencies.call(this.modules);
this.hooks.beforeChunks.call();
// ...
}
可以看出,这个阶段是 webpack.config.js 的 optimization 的配置在起作用,Tree Shaking、Code Spliting 等代码优化工作都是在这个阶段完成的。
输出文件到指定的输出目录(compiler.hooks.emit 阶段)
代码优化完毕,差不多就会执行 compiler.compile(onCompiled) 中的 onCompiled 回调函数,我们再看下里面是什么?
const onCompiled = (err, compilation) => {
// ...
this.emitAssets(compilation, err => {
if (err) return finalCallback(err);
// ...
});
// ...
}
我们看到里面又调用了 this.emitAssets() 方法,输出文件的意思。具体源码就不展示了,里面是使用 aeo-async 异步输出文件,文件的 io 操作是通过 compiler.outputFileSystem 实现的。