第 101 期 - Webpack5 打包原理及简易实现
摘要
本文介绍了 Webpack5 的核心打包原理,包括初始化参数、编译准备、模块编译、完成编译和输出文件等阶段,还通过创建目录、编写插件等操作实现了一个简易版的 Webpack
1. 前置知识
Webpack 在前端构建工具中非常重要,但理解其内部实现机制对开发者来说可能存在困难。这部分介绍了理解 Webpack 打包原理需要的前置知识。
- Tapable:
Tapable
包用于创建和触发自定义事件,类似 Nodejs 中的EventEmitter
Api,Webpack 的插件机制基于Tapable
实现与打包流程解耦。 - Webpack Node Api:前端日常使用
npm run build
命令通过环境变量调用bin
脚本,再调用Node Api
执行编译打包。 - Babel:Webpack 内部的
AST
分析依赖Babel
处理。
2. 流程梳理
整体从 5 个方面分析 Webpack 打包流程,这部分先对整体流程进行梳理,后面会逐步详细介绍每个阶段。
- 初始化参数阶段:从
webpack.config.js
和shell
命令中读取配置参数并合并,得到最终打包配置参数。 - 开始编译准备阶段:调用
webpack()
方法返回compiler
方法创建compiler
对象,注册Webpack Plugin
,找到配置入口的entry
代码,调用compiler.run()
方法进行编译。 - 模块编译阶段:从入口模块分析,调用匹配文件的
loaders
处理文件,分析模块依赖并递归编译。 - 完成编译阶段:递归完成后,每个引用模块经
loaders
处理得到模块间的依赖关系。 - 输出文件阶段:整理模块依赖关系,将处理后的文件输出到
ouput
磁盘目录。
3. 创建目录
为实现Packing tool
创建良好的目录结构,如下:
webpack/core
:存放自己将要实现的webpack
核心代码。webpack/example
:存放用来打包的实例项目,包含webpack.config.js
配置文件、入口文件entry1
、entry2
和模块文件index.js
等。webpack/loaders
:存放自定义loader
。webpack/plugins
:存放自定义plugin
。
4. 初始化参数阶段
介绍了日常给webpack
传递打包参数的两种方式,并开始动手实现webpack
。
- Cli 命令行传递参数:如
webpack --mode=production
,调用webpack
命令执行打包同时传入mode
为production
。 - webpack.config.js 传递参数:在项目根目录下导出一个对象进行
webpack
配置,示例配置如下:
const path = require('path');
module.exports = {
mode: 'development',
entry: {
main: path.resolve(__dirname, './src/entry1.js'),
second: path.resolve(__dirname, './src/entry2.js')
},
devtool: false,
context: process.cwd(),
output: {
path: path.resolve(__dirname, './build'),
filename: '[name].js'
},
plugins: [new PluginA(), new PluginB()],
resolve: {
extensions: ['.js', '.ts']
},
module: {
rules: [
{
test: /.js/,
use: [
path.resolve(__dirname, '../loaders/loader-1.js'),
path.resolve(__dirname, '../loaders/loader-2.js')
]
}
]
}
};
- 实现合并参数阶段:
- 在
webpack/core
下新建index.js
作为核心入口文件,新建webpack.js
作为webpack()
方法的实现文件。 - 在
index.js
中,引入webpack
和配置文件,通过webpack
方法执行调用命令,先初始化参数,根据配置文件和shell
参数合成参数。 - 在
webpack.js
中实现合并参数的逻辑,将外部传入的对象和执行shell
时的传入参数进行最终合并。
- 在
5. 编译阶段
在得到最终配置参数后,webpack()
函数需要做几件事,这部分逐步完善webpack
的编译功能。
- 创建 compiler 对象:
- 在
index.js
中补全逻辑代码,通过调用webpack(config)
得到compiler
对象,然后调用compiler.run()
方法启动编译。 - 在
webpack.js
中完善webpack
函数,合并参数后创建compiler
对象并返回。 - 在
webpack/core
下新建compiler.js
文件,作为compiler
的核心实现文件,Compiler
类有constructor
和run
方法,目前run
方法只是一个基础骨架。
- 在
- 编写 Plugin:
- 在
compiler.js
的Compiler
类构造函数中创建hooks
属性,值为三个SyncHook
方法实例run
、emit
、done
,可通过this.hook.run.tap
等方法添加事件监听和执行事件。 - 在
webpack.js
中填充插件注册逻辑,创建compiler
对象后调用_loadPlugin
方法注册插件,插件是一个类且必须有apply
方法,_loadPlugin
方法会依次调用传入插件的apply
方法并传入compiler
对象。 - 编写示例插件
plugin-a.js
和plugin-b.js
,在插件的apply
方法中通过compiler.hooks.run.tap
或compiler.hooks.done.tap
注册事件,当编译执行到相应阶段时触发事件添加逻辑影响打包结果。
- 在
6. 寻找 entry 入口
任何一次打包都需要入口文件,这部分介绍如何根据入口配置文件路径寻找到对应入口文件。
- 在
compiler.js
中,run
方法触发开始编译的plugin
后,通过getEntry
方法获取入口配置对象,getEntry
方法处理entry
配置,将其转化为{ [模块名]:[模块绝对路径]... }
的形式,考虑了常见的两种entry
配置方式。 - 注意点:
this.hooks.run.call()
:在_loadePlugins
函数中对插件进行订阅后,调用run
方法时触发订阅的plugin
逻辑。this.rootPath
:保存context
变量,context
是项目启动的目录路径,entry
和loader
中的相对路径都是相对于此参数。toUnixPath
工具方法:统一文件分隔符,方便后续生成模块ID
。
7. 模块编译阶段
在模块编译阶段需要做一系列的事情,这部分详细介绍模块编译的过程。
- 前期准备:在
compiler.js
的构造函数中添加属性来保存编译阶段生成的资源/模块对象,如entries
、modules
、chunks
、assets
、files
等。 - 编译入口文件:
- 在
run
方法中获取入口对象后,通过buildEntryModule
方法编译入口文件,该方法循环入口对象,对每个入口调用buildModule
方法进行编译并将结果添加到entries
中。 buildModule
方法接受模块所属入口文件名称和模块路径两个参数,它要做的事情包括读取文件源代码、调用loader
处理、通过babel
分析代码编译(针对require
语句修改路径)、处理模块依赖(无依赖则返回编译后的模块对象,有依赖则递归编译)。
- 在
- 读取文件内容:在
buildModule
方法中通过fs
模块读取文件原始代码并保存。 - 调用 loader 处理匹配后缀文件:
- 先实现自定义
loader
,loader
本质上是一个函数,接受源代码返回处理后的结果,如loader-1.js
和loader-2.js
的示例实现。 - 在
buildModule
方法中调用handleLoader
函数匹配对应loader
处理文件,handleLoader
函数获取所有传入的loader
规则,匹配后缀后倒序执行loader
处理代码并同步更新。
- 先实现自定义
- Webpack 模块编译阶段:
- 在
buildModule
方法中经过loader
处理后,调用handleWebpackCompiler
方法进行webpack
内部编译,主要将源代码中的依赖模块路径变为相对根路径的路径并建立模块依赖关系。 - 在
handleWebpackCompiler
方法中,计算模块ID
,创建模块对象,通过babel
相关API
分析代码,针对require
语句进行编译(如修改为__webpack_require__
语句),生成新的代码并挂载到模块对象上,最后返回模块对象。 - 介绍了
tryExtensions
工具方法的实现,用于处理文件后缀不全的情况。
- 在
- 递归处理:
- 针对入口文件调用
buildModule
得到返回对象,入口文件有依赖时递归调用buildModule
编译依赖模块,将编译后的模块保存到this.modules
中。 - 处理模块重复编译问题,在
handleWebpackCompiler
方法中判断模块是否已存在,不存在则添加依赖编译,存在则更新所属chunk
的name
属性。
- 针对入口文件调用
8. 编译完成阶段
在将所有模块递归编译完成后,需要根据依赖关系组合最终输出的chunk
模块。
- 在
buildEntryModule
方法中,编译入口文件并添加到entries
后,通过buildUpChunk
方法根据入口文件和模块的依赖关系组装chunk
。 buildUpChunk
方法创建chunk
对象,包含name
(入口文件名称)、entryModule
(入口文件编译后的对象)、modules
(与当前入口有关的所有模块对象组成的数组),然后将chunk
添加到this.chunks
中。
9. 输出文件阶段
这部分介绍如何根据前面的编译结果输出文件。
- 分析原始打包输出结果:先将
webpack/core/index.js
中的webpack
引用改为原始的webpack
进行打包,分析main.js
等原始打包生成的文件内容,其中__webpack_require__
函数代替了NodeJs
内部的require
方法,文件包含入口文件和依赖模块的定义等。 - 通过 this.chunks 输出文件:
- 在
Compiler
的run
方法中,buildEntryModule
模块编译完成后,通过exportFile
方法实现导出文件逻辑。 exportFile
方法做了以下几件事:- 获取输出配置,迭代
this.chunks
,替换output.filename
中的[name]
为入口文件名称,根据chunks
内容为this.assets
添加文件名和文件内容。 - 调用
plugin
的emit
钩子函数,判断输出文件夹是否存在,不存在则创建。 - 将本次打包生成的所有文件名存放到
files
中,循环this.assets
将文件写入磁盘,所有打包流程结束触发webpack
插件的done
钩子,并为NodeJs Webpack APi
呼应调用callback
传入参数。
- 获取输出配置,迭代
getSourceCode
方法接受chunk
对象返回其源代码,通过组合chunk
的属性,采用字符串拼接的方式实现__webpack__modules
对象属性和入口文件代码的拼接,解释了将模块require
方法路径转化为相对根路径的原因以及require
到__webpack_require__
的转换过程。
- 在
10. 结尾
至此实现了自己的webpack
,希望大家通过理解其工作流彻底理解compiler
对象,在后续webpack
相关底层开发中能熟练运用。同时给出了代码地址和参考文章。
扩展阅读
Made by 捣鼓键盘的小麦 / © 2025 Front Talk 版权所有