第 101 期 - Webpack5 打包原理及简易实现
摘要
本文介绍了 Webpack5 的核心打包原理,包括初始化参数、编译准备、模块编译、完成编译和输出文件等阶段,还通过创建目录、编写插件等操作实现了一个简易版的 Webpack
1. 前置知识
Webpack 在前端构建工具中非常重要,但理解其内部实现机制对开发者来说可能存在困难。这部分介绍了理解 Webpack 打包原理需要的前置知识。
- Tapable:
Tapable包用于创建和触发自定义事件,类似 Nodejs 中的EventEmitterApi,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相关底层开发中能熟练运用。同时给出了代码地址和参考文章。
