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.1
git reset --hard v5.10.1
1webpack
库所有的版本号中,需要在Tags
中查找。在
webpack
源码工程下,建立全局软链接yarn link
1在测试工程下,
link
注册的源码文件yarn link webpack
1对于软链接的说明和使用方式,见之前我写的博客:《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 webpack
agrs
:此处填写的是webpack
解析的指定文件,默认为src/index.js
runtimeArgs
:这里的参数最难,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)