读《webpack原理》总结-2020.05.31
学习时间:2020.05.31
学习章节:webpack原理
一、前言
webpack 是一个常用的打包工具,它的流行得益于模块化和单页应用的流行。平时开发过程中,更多在意的是它的使用。从加载配置文件、到构建、打包、输出文件的过程,也是一件超级有趣的事情。
在了解它的构建过程之前,可以先稍微了解一些东西:
- Loader:模块转换器,用于把模块原内容按照需求转换成新内容。
- Plugin:扩展插件,在 Webpack 构建流程中的特定时机会广播出对应的事件,插件可以监听这些事件的发生,在特定时机做对应的事情。
- Compiler:
Compiler
负责文件监听和启动编译。Compiler
实例中包含了完整的Webpack
配置,全局只有一个Compiler
实例。 - Compilation:当
Webpack
以开发模式运行时,每当检测到文件变化,一次新的Compilation
将被创建。一个Compilation
对象包含了当前的模块资源、编译生成资源、变化的文件等。Compilation
对象也提供了很多事件回调供插件做扩展。
二、流程概括
- 初始化:解析命令行参数, 初始化配置参数(配置文件参数与默认配置参数合并)、创建 compiler 实例、遍历 plugins 数组(如果数组某一项类型不是 function 就调用它的 apply 方法)、根据配置参数添加插件之类。执行 compiler 实例的 run 方法进入编译阶段。
- 编译阶段:从构建的入口文件开始,解析得到 loader 路径和入口路径(同时将loader解析成了固定格式,因为配置的时候支持多种格式配置)。调用 doBuild,创建 normalModule (normalModule 是一个要构建的模块实例,它记录了入口文件是什么、要使用的 loader 有哪些等等) ,之后开始build模块。build 的时候首先是执行 runloaders,它会递归的加载loader,然后使用 loader 对文件处理,得到最后的输出结果。
- 寻找依赖:使用 acorn 解析上面得到的输出结果,寻找依赖关系。之后重复 2-3 来处理所依赖的文件。
- 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表。
- 写入内容:根据配置确定输出的路径和文件名,创建目录、创建文件、接着把文件内容写入。
三、流程图
四、流程分析
1. 配置文件
1 | const path = require('path') |
1 | // src/index.js 入口文件 |
1 | // 自定义 loader |
1 | // 自定义插件 |
2. 命令行执行
我们通常通过在 package.json 文件配置 scripts 来启动构建。webpack 作为可执行命令,如果是局部安装的话,它的位置在 node_modules/.bin/webpack
,可以通过执行它来进入调试。
1 | ... |
3. 解析参数
3.1从 Shell 语句中解析
首先先了解一下 process.argv
,这个属性返回一个数组,其中包含当启动 Node.js 进程时传入的命令行参数。 第一个元素是 process.execPath
。 其余元素将是任何其他命令行参数。
所以当我们执行 npm run build:debug
的时候,process.argv
数组是长这样,包含了 shell 语句中的参数。
4. 初始化
4.1 这里的例子,在解析完命令行参数之后,去加载了配置文件 webpack.config.js,得到配置参数。 node模块加载机制。
4.2 与默认配置合并
就算没有写配置,webpack 也是有默认配置的。它有一个合并用户配置与默认配置的过程。
1 | process(options) {// 传入的 options 是用户的配置 |
4.3 初始化 compiler
它继承于 Tapable,包含了完整的 webpack 配置,拥有很多的钩子,可以在感兴趣的钩子上添加回调,会在 webpack 构建流程中被触发,同时它也负责启动编译。
1 | class Compiler extends Tapable { |
4.4 遍历 plugins 数组
遍历 plugins 的过程,就是判断每一项是否类型是 function,不是的话就调用插件实例上的 apply 方法,在 apply 方法里,就可以在 compiler 暴露出的钩子上添加回调。 回调被调用的时机点,遍布 webpack 构建的生命周期。跟 vue 和 react 生命周期中在某个时机点,调用生命周期钩子函数的意思差不多。
1 | ... |
4.5 在进入编译阶段之前,这里还需根据参数做一些事情。
比如 mode 是 development 或者 production,会通过 DefinePlugin 插件在 process.env.NODE_ENV 上设置值。optimization.minimizer 可以添加插件来覆盖默认的。根据 options.externals 是否加载 ExternalsPlugin 插件等等。
5 编译阶段
compiler.run 方法调用之后进入编译阶段。
5.1 实例化 compilation ,它也是继承于 Tapable
compilation 实例上会用于保存了compiler 的引用、模块资源、以及编译生成资源等等
1 | class Compilation extends Tapable { |
我们的自定义 plugin 之前订阅了 compilation 创建事件,所以在 compilation 实例被创建的时候被调用。调用的同时又订阅了优化开始的事件。
1 | compiler.hooks.compilation.tap('myPlugin', compilation => { |
5.2 从入口文件开始
1 | (compilation, callback) => { |
在真正的使用 loader 对文件内容处理之前,会先解析入口文件的绝对路径和 loader 的绝对路径,并且将 loader 解析成固定格式。
这里的固定格式的意思就是无论你使用哪种姿势配置 loader,如这样
1 | module: { |
或这样
1 | module: { |
最后整理出来的 loaders 数组格式,差不多都长图片中的这样。转成统一格式,方面后续处理。
5.3 创建 normalModule 实例
normalModule 是一个要构建的模块实例,它记录了入口文件是什么、要使用的 loader 有哪些等等。后面的 chunk 生成文件模块也要使用到它上面记录的信息。它也会被保存在 compilation 实例中。
1 | let createdModule = this.hooks.createModule.call(result); |
之后便开始了真正的模块 build,也就是在 build 之前,会调用 compilation 的 buildModule 钩子事件。这个钩子能做的事情很有趣,可以把 module(也就是 normalModule 实例)的文件路径指向一个空文件(也就是之后构建的时候都是对这个空文件做处理),来实现插件可插拔的配置。
1 | this.hooks.buildModule.call(module); |
5.4 加载 loader,从 loaders 数组第一位开始加载
1 | function iteratePitchingLoaders(options, loaderContext, callback) { |
看一下 processResource 方法
1 | function processResource(options, loaderContext, callback) { |
然后进入 loader 对模块文件处理的步骤
5.5 使用 loader 对入口模块文件进行处理,从 loaders 最后一位开始对模块处理
上面设置 loader
的下标,和这边的 loaderContext.loaderIndex--
, 让 loader 的处理顺序从右往左。
1 | function iterateNormalLoaders(options, loaderContext, args, callback) { |
就在这个处理的过程中,就调用到了我们的自定义 loader,这里只是简单的把文件内容的 const 替换为了 var
1 | module.exports = function loader(source) { |
5.6 寻找依赖
当 loader 对模块处理完之后,剩下要做的事情就是找出模块所依赖的文件。通过 acorm 来解析模板文件。
1 | parse(source, initialState) { |
5.7 找到依赖之后,处理方式跟入口文件处理一样。
6. 输出资源
6.1 创建 hash 的过程
有需要再看吧。
1 | createHash() { |
6.2 输出
输出是把每个包含多个模块的 Chunk
转换成一个单独的文件加入到输出列表。这里的 Chunk
包含了两个模块,分别是 index.js
和 index1.js
的 normalModule
。
1 | createChunkAssets() { |
写到这里不得不提一嘴,我们在配置文件中,经常使用中括号包裹一些变量,比如 [hash]
之类的,也是在这里替换的。就是拿到各种生成后的变量,通过replace
大法替换。
1 | const replacePathVariables = (path, data, assetInfo) => { |
7. 写入文件
7.1 创建输出目录
输出路径创建dist 目录
1 | this.hooks.emit.callAsync(compilation, err => { |
7.2 输出文件
1 | const emitFiles = err => { |
五、使用的依赖
1.neo-async
1.1 parallel 方法
parallel函数是并行执行多个函数,每个函数都是立即执行,不需要等待其它函数先执行。 传给最终callback的数组中的数据按照 tasks 中声明的顺序,而不是执行完成的顺序。
1 | var order = []; |
1.2 map方法
并且调用,有返回结果。
1 | // array |
1.3 each方法
并行的应用迭代器去遍历 array,iterator将调用数组列表,回调函数在它结束时进行调用。
1 | // array |
1.4 eachLimit 方法
类似于each,但它限制了每次异步操作时的允许的并发执行的任务数量。
1 | // array with index |
2.enhanced-resolve
提供了异步的 resolve 方法,获取模块的绝对地址,顺便判断一下模块是否存在。
1 | const fs = require("fs"); |
3.crypto
这边用于生成 hash 值。
1 | crypto.createHash(algorithm) |
4.Tappable
webpack
本质上是一种事件流的机制,它的工作流程就是将各个插件串联起来,而实现这一切的核心就是Tapable
。Tapable
暴露出挂载plugin
的方法,使我们能将plugin
控制在webapack
事件流上运行, Compiler
、Compilation
等都是继承于Tabable
类。也就是说在整个构建过程,插件可以做很多很多的事情都是因为它会在构建的某个时机点广播出事件通知。