webpack源码分析三:源码分析
# 0.前言
在前两篇中基本已经将 mini-webpack 基础原理实现,从本篇文章开始 webpack 源码之旅。
# 1.调试 webpack:如何搭建一个调试 DEMO
# 方案1:使用 link + .launch 调试方法
项目整体结构如下:
.
├── .vscode
│ ├── launch.json
├── demo
│ ├── dist
│ ├── index.js
│ ├── node_modules
│ └── package.json
└── webpack
└── 此处可再安装 `webpack-cli`(非必要)
2
3
4
5
6
7
8
9
10
由于无法在node_modules 中进行调试,因此第一个方法就是使用软链接的方式替换掉原有 module,步骤如下:
准备源码工程文件,如
webpack(opens new window)、webpack-cli(opens new window)。将源码工程下载后,需要使用
git工具回溯到指定版本,如webpack v5.10.1git reset --hard v5.10.11webpack库所有的版本号中,需要在Tags中查找。在
webpack源码工程下,建立全局软链接yarn link1在测试工程下,
link注册的源码文件yarn link webpack1对于软链接的说明和使用方式,见之前我写的博客:《npm link的用法》 (opens new window)、《软链接与硬链接》 (opens new window)。
在当前项目新建
.vscode文件夹,并创建launch.json文件。{ "version": "0.2.0", "configurations": [ { "type": "pwa-node", "request": "launch", "name": "Launch Program", "skipFiles": [ "<node_internals>/**" ], "program": "${workspaceFolder}/demo/node_modules/.bin/webpack", "args": ["./demo/src/index.js"], "runtimeArgs": [ "--preserve-symlinks", "--preserve-symlinks-main" ], "trace": true } ] }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20参数说明:
type:指定脚本执行环境,对于javascript而言,一般可选chrome(需要搭配devtools for Chrome插件) 和pwa-node。request:launch或attach。name:随便写,主要也是自己看。program:需要使用node执行的脚本,上面是触发npx webpackagrs:此处填写的是webpack解析的指定文件,默认为src/index.jsruntimeArgs:这里的参数最难,google好久才找到--preserve-sumlinks-main这个参数,当需要调试node_modules中的modules时,并且当前module是以软链接的形式提供的。经测试,使用该方案调试模块时,无法通过在测试文件左侧打红点断点,而只有手动在测试代码中添加
debugger时,才会进入调试模式。--preserve-symlinks-main字段介绍:node官网 (opens new window)
# 方案2:使用 .launch + node 脚本编写
此部分学习自来源于 掘金大佬:依柳诚 (opens new window) 的文章,对于调试
webpack是非常好的教程,但是必须提前学习当前代码的使用方式,对初学者想看懂源码的还不是很友好。
项目的整体结构如下:
webpack 源码工程:
debug-|
|--dist // 打包后输出文件
|--src
|--index.js // 源代码入口文件
|--package.json // debug时需要安装一些loader和plugin
|--start.js // debug启动文件
|--webpack.config.js // webpack配置文件
2
3
4
5
6
7
8
debug/start.js 的代码:相当于把 webpack 中创建 compiler 对象并运行的过程抽离出来了,无需经过 webpack-cli 。
//***** debug/start.js *****
const webpack = require('../lib/index.js'); // 直接使用源码中的webpack函数
const config = require('./webpack.config');
const compiler = webpack(config);
compiler.run((err, stats)=>{
if(err){
console.error(err)
}else{
console.log(stats)
}
});
2
3
4
5
6
7
8
9
10
11
对应的 launch.json 脚本为:
{
"configurations": [
{
"type": "node",
"request": "launch",
"name": "启动webpack调试程序",
"program": "${workspaceFolder}/debug/start.js"
}
]
}
2
3
4
5
6
7
8
9
10
# 方案3:使用 chrome 调试
使用 chrome 的 debug 模块,具体可见博客 《如何调试 node 代码》 (opens new window)
node --inspect-brk ./node_modules/webpack/bin/webpack.js
# 2.源码解读-梳理Hooks函数
幕布地址:https://www.mubucm.com/doc/GomYIGiRxQ
# 3.带着问题看源码
# 3.1 webpack 启动方式
这个问题也等价于:
webpack-cli与wabpack的区别。
调用webpack的方式一般有以下两种方式:
# 1. 终端启动:使用 webpack-cli 脚手架
在终端中通过 webpack-cli 脚手架启动,以下几种写法均可:
./node_modules/.bin/webpack-cli # 原始版
./node_modules/webpack-cli/bin/cli.js # 执行 webpack-cli
npx webpack-cli # 简化写法
2
3
以上文件默认找的 src/index.js 文件,完整写法:
npx webpack-cli ./src/index.js --config ./webpack.config.js
注:在测试的时候发现,上述的
webpack-cli也可以简写为webpack,但实际调用的仍是webpcak-cli的脚本,这点可以在webpack-cli/bin/cli.js脚本中打断点验证,后续会从源码角度进行验证。
# 2. 脚本启动:直接调 webpack
在 Node 脚本中,可以直接 require 的方式:
const webpack = require('../lib/index.js') // 直接使用源码中的webpack函数
const config = require('./webpack.config')
const compiler = webpack(config);
compiler.run((err, stats)=>{
if(err){
console.error(err)
}else{
console.log(stats)
}
});
2
3
4
5
6
7
8
9
10
# 3. 源码:webpack-cli 与 webpack 的关系
从名称即可看出两者的职责是不同的,cli全称为command Line Interface ,即命令行界面。webpack-cli 赋予终端以更灵活的方式调用webpack ,如下:
# 以 生产模式 打包应用
npx webpack --mode="production"
2
当然,复杂的配置更推荐使用 webpack.config.js 文件配置 WebPack的参数。发送到 CLI 的任何参数都将映射到配置文件中的相应参数。
# 源码解析1:为啥 npx webpack 与 npx webpack-cli 等价
在 webpack/bin/ 中本质调用还是 webpack-cli,第一步就会去检测 webpack-cli 的安装情况,如果没有安装还会自动会当前使用的包管理器(npm、pnpm、npm)提示你去下载:
const cli = {
name: "webpack-cli",
installed: isInstalled("webpack-cli"),
url: "https://github.com/webpack/webpack-cli" // 地址提前准备好
};
if (!cli.installed){
console.err("你需要去装 cli 脚本了!")
console.log("需不需要我帮你去装?(yes/no)") => 一堆安装逻辑
}else{
require("webpack-cli")
}
2
3
4
5
6
7
8
9
10
11
# 源码解析2:webpack-cli 本质调用的还是webpack
技巧:由于
webpack文件过于庞大,可以查阅package.json文件中的main字段寻找当前模块的入口文件。
在 webpack-cli 的bin/cli.js 的代码:
if (packageExists('webpack')) {
runCLI(rawArgs);
} else {...}
2
3
而 runCLI 是bootstrap.js 中的代码:
const runCLI = ()=>{
try{
const cli = new WebpackCLI();
....
await cli.run(parsedArgsOpts, core);
}catch(err){...}
}
2
3
4
5
6
7
上面的代码,通过WebpackCLI 创建了一个cli,并执行这个类的run 方法,如下:
const webpack = packageExists('webpack') ? require('webpack') : undefined;
...
class WebpackCLI{
async run(){
let compiler; // 构建一个编译器
compiler = this.createCompiler(options, callback);
}
....
createCompiler(){
let compiler;
try {
compiler = webpack(options, callback);
} catch (error) {...}
return compiler;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
由上可知,run 方法即调用WebpackCLI此类的 createCompiler 方法构造了一个编译器,而编译器的构造底层是借助 require("webpack") ,即 webpack 模块实现的。
总结一下:webpack-cli 通过调用 webpack 模块构建了一个编译器。
# 3.2 寻找 webpack 编译起点:Entry
根据第2章的内容可知,在 compile 函数中出现的钩子有:
beforeCompile --> compile --> make --> finishMake--> afterCompile。
根据经验,猜测入口分析流程应位于 make ->finishMake 之间,而两者之间并无代码,于是需要反向查找make钩子是在哪里注册的。
通过搜索关键词 hooks.make.tapAsync 找到了 lib/EntryPlugin.js中。
由于搜索出的结果很多,需要一个一个比对寻找到与入口
Entry有关的文件。
按照以下函数调用链条一层一层找:
compiler.hooks.make.tapAsync("EntryPlugin") |this指的是 compilation this.addEntry() this._addEntryItem() this.addModuleChain() this.handleModuleCreation() this.factorizeModule() this.factorizeModule() this.addModule() this.buildModule()
涉及两个文件
lib/EntryPlugin、lib/Compilation
重点模块解读:addModuleChain
......
const moduleFactory = this.dependencyFactories.get(Dep);
this.handleModuleCreation(factory: moduleFactory,....){.....}
2
3
通过后续分析,我们逐渐意识到工厂模式是后续所有步骤的理论基础。
其中,this 是compilation,通过在 EntryPlugin 中搜索compilation.dependencyFactories.set 可以发现如下代码:
compiler.hooks.compilation.tap(
"EntryPlugin",
(compilation, { normalModuleFactory }) => {
compilation.dependencyFactories.set(
EntryDependency,
normalModuleFactory
);
}
);
2
3
4
5
6
7
8
9
由上可知,后续的 factory 即为 normalModuleFactory ,一般简称为 nmf 对象。
重点模块解读:handleModuleCreation:处理模块创建
// 函数定义:
handleModuelCreation{ factory, dependencies,...},callback){
const moduleGraph = this.moduleGraph; // 找到 depRelation
const currentProfile = ... // Profile 与性能有关,可以忽略
this.factorizeModule(...){ // 工厂化依赖
this.addModule(...){newModule, (err,module)=>{
...
// 将依赖添加到 depRelation 中
for (let i = 0; i < dependencies.length; i++) {
const dependency = dependencies[i];
moduleGraph.setResolvedModule(originModule, dependency, module);
}
this.buildModule(module,err=>{....})
}}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
上述我们得到了一条非常关键的函数调用链条:
factorizeModule() addModuel() buildModule()
再细化下各个函数的调用栈:
factorizeModule :factorizeQueue _factorizeModule factory.create()
函数内部本质就是一个工厂队列, 加入到此队列中的函数,对应只需要见处理函数为:processor
// factorizeModule 函数中
this.factorizeQueue = new AsyncQueue({
name: "factorize",
parallelism: options.parallelism || 100,
processor: this._factorizeModule.bind(this)
});
// _factorizeModule 函数中:
_factorizeModule(...) {
...
factory.create(...){...} // 由此引出一个关键函数 factory.create
...
}
2
3
4
5
6
7
8
9
10
11
12
13
前述中已说明 factory 即为 nmf 对象,于是我们找到NormalModuleFactory.js 文件:
重点模块解读:NormalModuleFactory.create() 创建一个新模块
涉及到的 Hooks 调用次序为:hooks.beforeResolve -> hooks.factorize -> hooks.afterResolve ->hooks.createModule
// 最后一次调用:
this.hooks.createModule.callAsync(
createData,
resolveData,
(err, createdModule) => {
if (!createdModule) {
......
createdModule = new NormalModule(createData); // createModule
}
....
return callback(null, createdModule); // 这里 null-> error,createModule->newModule
}
);
2
3
4
5
6
7
8
9
10
11
12
13
重点模块解读:addModuel 接受 factorizeModule(也即,nmf.create()) 传递而来的 createModule
this.factorizeModule(...,(err,newModule)){
this.addModule(newModule, (err, module) => {})
}
2
3
函数调用链:addModuleQueue -> new AsyncQueue -> processor: this._addModule
核心代码:
class Compilation {
constructor(){
this.modules = new Set();
this._modules = new Map(); // _modules 私有变量标识当前 module 是否已经被添加过
...
}
...
this._addModule(){
const identifier = module.identifier(); // 读取 module 唯一的id
const alreadyAddedModule = this._modules.get(identifier);
if (alreadyAddedModule) { // 如果添加过 module 则弹出
return callback(null, alreadyAddedModule);
}
this._modulesCache.get(identifier, null, (err, cacheModule) => {
this._modules.set(identifier, module); // 标识 module 已处理过
this.modules.add(module); // 将 module 存入 compilation.modules
}
}
...
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
重点模块解读:buildModule
函数调用链:buildQueue -> new AsyncQueue -> processor: this._buildModule.bind(this)->hooks.buildModule->module.build()-> this.doBuild ->hooks.succeedModule/hooks.failedModule
当前步骤是构建的重要步骤,难点在于module.build 这里的 module 是什么?NormalModule实例化
这里就不详细演示了,耐心点往上翻就会发现这里
createdModule = new NormalModule(createData);
于是我们看下:NormalModule.build 做了什么?
build(...){
this._source = null; // 源代码存放位置
this._ast = null; // 初始化 ast 树
....
return this.doBuild(...){
const handleParseError = err=>{};
const handleParseResult = ()=>{return handleBuildDone}; // 这种写法完全可以避免回调地狱
const handleBuildDone = ()=>{};
const noParseRule = options.module && options.module.noParse;//控制当前module是否会被解析
// 开始解析 ast 树
let result;
try {
result = this.parser.parse(this._ast || this._source.source(), { // parse阶段:_source=>_ast
......
});
} catch (e) {
handleParseError(e);
return;
}
handleParseResult(result);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
再来看下 doBuild 函数定义中,有什么值得关注的事件?
doBuild(){
// 获取 compilation 的 Hooks
const hooks = NormalModule.getCompilationHooks(compilation);
hooks.beforeLoaders.call(this.loaders, this, loaderContext);
runLoaders(
{
resource: this.resource, // 加载初始代码,如 案例1中的 `es6Code`
loaders: this.loaders, // 将各种 loaders 加载进来。
context: loaderContext,
readResource: (resource, callback) => {
const scheme = getScheme(resource); // scheme 一般指协议
if (scheme) {
.......
} else {
// 正常走此回调
fs.readFile(resource, callback); // 加载外部资源
}
}
},
(err, result) => {...}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
在 doBuild 大致做两件事:
hooks.beforeLoaders:触发定义在loaders之前的所有事件。runLoaders:触发loaders阶段,这步的含义在于,webpack只能读取js文件,通过loader处理非js文件。此阶段还有一个任务:读文件,即
resource。
最后,handleModuleCreation 会将上述所有的产生的队列Queue关闭:
this.handleModuleCreation(
{
factory: moduleFactory,
dependencies: [dependency],
originModule: null,
context
},
err => {
if (err && this.bail) {
callback(err);
this.buildQueue.stop();
this.rebuildQueue.stop();
this.processDependenciesQueue.stop();
this.factorizeQueue.stop();
} else {
callback();
}
}
);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 3.3 webpack 解析阶段
解析阶段:将经上述 loaders 转化后的代码,解析为AST 语法树。
由上可知,对 sourceCode 进行 parse 的阶段是在 doBuild 函数调用 parser.parse 方法 ,其中parser ,即解析器。具体的代码可以在 lib/javascript/JavaScriptParser.js 文件中找到:
const { Parser: AcornParser } = require("acorn");
在 Webpack 中并未自己实现一个 parser,而是借助 acorn 的 parser 分析 JS。
在 JavaScriptParser.js 文件中我们可以验证 parse 是如何进行模块收集的:
// 遍历声明 :Block pre walking iterates the scope for【block variable declarations】
blockPreWalkStatements(statements) {
for (let index = 0, len = statements.length; index < len; index++) {
const statement = statements[index];
this.blockPreWalkStatement(statement);
}
}
blockPreWalkStatement(statement) {
this.statementPath.push(statement);
if (this.hooks.blockPreStatement.call(statement)) {
this.prevStatement = this.statementPath.pop();
return;
}
switch (statement.type) {
// 对应着 动态导入: `import("....")`
case "ImportDeclaration":
this.blockPreWalkImportDeclaration(statement);
break;
case "ExportAllDeclaration":
this.blockPreWalkExportAllDeclaration(statement);
break;
// 对应这默认导入: `import a from "a.js"`
case "ExportDefaultDeclaration":
this.blockPreWalkExportDefaultDeclaration(statement);
break;
// 对应着声明导入: `import {a} from "a.js"`
case "ExportNamedDeclaration":
this.blockPreWalkExportNamedDeclaration(statement);
break;
case "VariableDeclaration":
this.blockPreWalkVariableDeclaration(statement);
break;
case "ClassDeclaration":
this.blockPreWalkClassDeclaration(statement);
break;
}
this.prevStatement = this.statementPath.pop();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
根据不同的import情况,会触发不同的hooks钩子函数,这些钩子的监听函数代码保存在 lib/dependencies/HarmonyExportDependencyParserPlugin.js 中,目的是收集各个模块的依赖,将其记录在 module.denpendencies 数组中。
# 3.4 如何把 modules 合并为一个文件?
生成
chunk阶段在
compilation.seal()阶段,函数会创建chunks、并为每个chunk进行codeGeneration,然后为每一个chunk创建assets。生成
assets阶段在第二章可知在
onCompiled阶段,存在一个重要的函数emitAssets(),emit意为发射,Assets意为资产,此处则指的是合并生成后的文件,主要逻辑如下:emitAssets(compilation,callback){ let outputPath; // 指定输出路径 const emitFiles = err =>{ ... const processExistingFile = stats => { const content = getContent(); // 获取内容 return this.outputFileSystem.readFile(...,()=>{ // 进入 read 阶段 return doWrite(content) // 进入 write 阶段 }) } const doWrite = content =>{ // 执行 写 操作 this.outputFileSystem.writeFile(targetPath, content, err => {}) } }; // 在 emit 阶段做了两件事: // 1. 获取输出路径 // 2. 创建输出文件(具体写的内容,看emitFiles回调) this.hooks.emit.callAsync(compilation, err => { if (err) return callback(err); outputPath = compilation.getPath(this.outputPath, {}); mkdirp(this.outputFileSystem, outputPath, emitFiles); }); }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 重点概念解析
Dependency Graph的概念:Dependency Graph (opens new window)
# 参考资料
- https://juejin.cn/post/6844903987129352206
- Compilation Hooks列表 (opens new window)