通过一个demo带你深入进入webpack@4.46.0
源码的世界,分析构建原理,专栏地址,共有十篇。
- 1. 从构建前后产物对比分析webpack做了些什么?
- 2. webpack构建的基石: tapable@1.1.3源码分析
- 3. webpack构建整体流程的组织:webpack -> Compiler -> Compilation
- 4. 创建模块实例,为模块解析准备
- 5. 路径解析:enhanced-resolve@4.5.0源码分析
- 6. 模块构建之loader执行:loader-runner@2.4.0源码分析
- 7. 模块构建之解析_source获取dependencies
- 8. 从dependency graph 到 chunk graph
- 9. 从chunk到最终的文件内容到最后的文件输出?
- 10. webpack中涉及了哪些设计模式呢?
示例代码
废话不多说,直接上demo,也是全文最重要的demo,通过该demo从源码中分析构建的核心流程(基于webpack@v4.46.0
)。
目录结构如下:
src/main.js、src/a.js、src/b.js、src/c.js 文件内容和引用关系
代码语言:javascript复制// 同步方式引入资源
import {logA} from './custom-loaders/custom-inline-loader.js??share-opts!./a?c=d'
function logAB() {
logA();
// 异步方式引入资源
import(/* webpackChunkName: "ChunkB", webpackPrefetch: true */ './b').then(asyncModule => asyncModule.logB())
}
logAB()
// src/a.js
import {logC} from './c'
export function logA() {
logC()
console.log('A')
}
export const A = 'A'
// src/b.js
export function logB() {
console.log('B')
}
// src/c.js
export function logC() {
console.log('C')
}
main.js
文件是构建的入口文件,这里使用ESM
(即ECMAScript Modules)规范进行模块导入导出。
该文件使用了同步引入和异步引入两种方式。异步引入(即import(),动态导入方式之一),目的是做代码分割。
注意引用logA的路径和通常引入模块的用法有些区别,通常是import {logA} from './a'
,但是这里的引用路径中有loader信息即query信息。因为在vue-loader
中利用了此种用法(内联loader),因此在这里提供该用法示例,后面会具体分析该用法,了解完此种用法后就可以看到vue-loader
的巧妙之处。
对于import()
用法,webpack提供几个选项参数,通过注释提供,具体的含义会在后面用到时说到。
config/webpack.config.simple.js 内容如下
代码语言:javascript复制// config/webpack.config.simple.js
const path = require('path')
const CopyWebpackPlugin = require("copy-webpack-plugin");
const {CleanWebpackPlugin} = require('clean-webpack-plugin');
module.exports = {
entry: {
chunkMain: './src/simple/main.js',
},
output: {
path: path.join(__dirname, '../dist/simple'), filename: '[name].js',
},
optimization: {
runtimeChunk: {
name: 'runtimeChunk'
},
},
module: {
rules: [
{
test: /.js$/,
enforce: 'post',
loader: './src/simple/custom-loaders/custom-post-loader',
},
{
test: /.js$/,
enforce: 'pre',
loader: './src/simple/custom-loaders/custom-pre-loader',
},
{
test: /.js$/,
use: {
ident: 'share-opts',
options: {
a: 'b'
},
loader: './src/simple/custom-loaders/custom-normal-loader',
}
},
],
},
plugins: [
new CleanWebpackPlugin(),
new CopyWebpackPlugin(['./src/simple/index.html'])
],
mode: 'none',
// watch: true
}
上面配置文件中涉及的配置项的解释如下:
上面涉及的配置项 | 解释 |
---|---|
entry | 入口起点(entry point) 指示 webpack 应该使用哪个模块,来作为构建其内部 依赖图(dependency graph) 的开始。进入入口起点后,webpack 会找出有哪些模块和库是入口起点(直接和间接)依赖的。 |
output | 可以通过配置 output 选项,告知 webpack 如何向硬盘写入编译文件。注意,即使可以存在多个 entry 起点,但只能指定一个 output 配置。 |
module | 其下面的选项决定了如何处理项目中的不同类型的模块。 .rules:创建模块时,匹配请求的规则数组。这些规则能够修改模块的创建方式。 这些规则能够对模块(module)应用 loader,或者修改解析器(parser)。 Loaders概念(给出了内联 loader用法), Rule.enfore:指定 loader 种类。没有值表示是普通loader(normal loader)。可能的值有:"pre" | "post",加上上面的内联loader(inline loader)。loader一共四种类型:normal、pre、post、inline。 |
resolve | 配置模块如何解析。例如,当在 ES2015 中调用 import 'lodash',resolve 选项能够对 webpack 查找 'lodash' 的方式去做修改 .alias: 创建 import 或 require 的别名,来确保模块引入变得更简单。 |
optimization | 从 webpack 4 开始,会根据你选择的 mode 来执行不同的优化, 不过所有的优化还是可以手动配置和重写。 将 optimization.runtimeChunk 设置为 true 或 'multiple',会为每个入口添加一个只含有 runtime 的额外 chunk。默认值是 false:每个入口 chunk 中直接嵌入 runtime。 runtime:在浏览器运行过程中,webpack 用来连接模块化应用程序所需的所有代码。它包含:在模块交互时,连接模块所需的加载和解析逻辑。包括:已经加载到浏览器中的连接模块逻辑,以及尚未加载模块的延迟加载逻辑。可以理解为webpack自己的模块化机制 |
plugins | 注册自定义插件,基于tabable提供的hooks能力参与webpack构建流程 CopyWebpackPlugin:将已存在的单个文件或整个目录复制到构建目录。 CleanWebpackPlugin:用于删除/清理构建产物 |
loader
上面配置项中说到loader的类型有inline
、normal
、pre
、post
四种类型(指向一个js文件),示例中会提供四种类型的loader示例
另外loader的执行还分为两个运行阶段,分别是Normal和Pitching两个阶段。下面示例中module.exports
指向的函数会运行在Normal阶段,module.exports.pitch
则会运行在Pitching阶段。
// src/simple/custom-loaders/custom-inline-loader.js
module.exports = function (source) {
console.log('inline-normal-phase', this.resourcePath)
return `console.log('inline-loader');n${source}`;
}
module.exports.pitch = function () {
console.log('inline-pitch-phase', this.resourcePath)
}
代码语言:javascript复制// src/simple/custom-loaders/custom-normal-loader.js
module.exports = function (source) {
console.log('normal-normal-phase', this.resourcePath)
return `${source};nconsole.log('normal')`
}
module.exports.pitch = function(){
console.log('normal-pitch-phase', this.resourcePath)
}
代码语言:javascript复制// src/simple/custom-loaders/custom-post-loader.js
module.exports = function (source) {
console.log('post-normal-phase', this.resourcePath)
return `${source};nconsole.log('post')`
}
module.exports.pitch = function(){
console.log('post-pitch-phase', this.resourcePath)
}
代码语言:javascript复制// src/simple/custom-loaders/custom-pre-loader.js
module.exports = function (source) {
console.log('pre-normal-phase', this.resourcePath)
return `console.log('pre');n${source}`
}
module.exports.pitch = function(){
console.log('pre-pitch-phase', this.resourcePath)
}
所以这里实际是有:(4个类型 * 2个阶段 = )8个函数被执行。
package.json: 注册命令行命令
代码语言:javascript复制"scripts": {
"build-simple": "webpack -c config/webpack.config.simple.js"
},
执行 npm run build-simple
开始构建我们的项目。
日志输出
这里有三个部分,下面分析每个部分的信息。
日志分析
这个日志的输出由webpack/lib/Stats.js生成。
第一部分:列出了输出文件的信息取自compilation.assets,包括大小(Size),文件名称(Asset),chunkId(Chunks)等信息,这里chunkName有可能为空。这里实际可以看出文件和chunk的关系即该文件对应的chunk
的名称,如这里的chunkMain.js属于chunkName为chunkMain的chunk
。这里共有三个Chunk
。其中index.html这里可以忽略,是copy-webpack-plugin
添加到compilation.assets中的,并没有一个Chunk
与之关联。
第二部分:显示了该entry(main.js)构建后生成的entryPoint
(是一个特殊的ChunkGroup
),等于号后面是该chunkGroup
包含的所有文件
的信息,一个ChunkGroup
会包含多个Chunk
,通常一个Chunk
会对应一个文件,看到这里EntryPoint
关联了两个文件:chunkMain.js和runtimeChunk.js。Stats.js中显示通过EntryPoint
获取其包含的所有Chunk
,然后再从各Chunk
中获取包含的文件
。
第三部分:构建过程中产生的模块的信息来自compilation.modules,而'[built]'
对应如NormalModule
(extends Module)中built属性,用来表示该文件是否经过build,当调用normalModule.build()则会设置该属性,表明是经过模块构建的。compilation.modules中的模块是Module
类型,控制台展示路径取自userRequest
属性的相对路径指向原始
资源路径,主要是用来说明原始资源构建后的情况。
注意区分:compilation.assets
和compilation.modules
概念介绍
上面三部分我们看到很多概念:Asset
、Chunk
、EntryPoint
、Module
、 ChunkGroup
,我们先简单介绍这些概念。
Asset
: webpack中并没有对应的类,核心流程中的compilation
对象有一个assets
属性,用来记录输出文件
的信息,而Compiler
最终是基于compilation.assets
来进行最终的文件写出。
Module
: 内部有一个Module
类型,其存在多个子类,主要是NormalModule
类型,该类型存在一个唯一标识符即request
,该属性对应一个资源路径,比如上面的./src/simple/main.js
,并且这个路径可以包含query信息,比如App.vue?vue&tyep=template
,compilation
对象中有modules
属性用来存储构建过程中产生的所有模块
。
Chunk
:内部对应Chunk
类,是Module
的容器即一个Chunk
会包含多个Module
。通常一个原始资源(如js文件)会对应一个Module
,Chunk
则可能会聚合多个资源文件(如多个js文件)然后进行产物输出。
ChunkGroup
:Chunk
的容器
EntryPoint
: 该类是ChunkGroup
的子类,主要的区别是其可以设置runtimeChunk
。比如本文中使用的ESM
规范,但是webpack在构建后实际上提供自己的模块化机制,也就是这里的runtimeChunk,该runtimeChunk是动态生成的,实际可以兼容commonjs、amd、esm等各种模块化机制。
内容输出
产物文件 | 关联的原始文件 |
---|---|
chunkB.js | b.js |
chunkMain.js | main.js、a.js、c.js |
runtimeChunk.js | webpack自己生成的运行时代码 |
chunkB.js
b.js 构建前后的内容对比
变更的部分分为两部分:背景色部分和虚线框部分
- 背景色部分:运行时相关的逻辑,保证被webpack的运行时正确加载和执行。
- 虚线框部分:原始内容的变更:loader修改的内容、export被转换为运行时相关的逻辑。
思考:在什么阶段以及如何发生内容的改变的❓
chunkMain.js
代码语言:javascript复制(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[1],[
/* 0 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
// main.js 转换后的内容
/***/ }),
/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
// a.js 转换后的内容
/***/ }),
/* 2 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
// c.js 转换后的内容
/***/ })
],[[0,0]],[2]]);
对比上面的chunkB.js
的主要差异是,该文件包含了多个模块的定义(main.js,a.js,c.js)
window"webpackJsonp".push方法的入参是一个数组,在chunkB.js
中该数组只有两个元素,而这里有四个元素。
思考:
- push参数中的每个元素的含义是什么呢❓
- 为什么b.js被单独输出一个文件,而a.js和c.js没有,却和main.js放在一起构建出一个文件❓
runtimeChunk.js
这个文件(runtimeChunk)的主要功能是webpack自己模块化机制,目的是为了兼容其他模块化规范,实际的做法是将其他的模块化都转为webpack自己的模块化形式,然后只需要提供自己的模块化机制就可以了。所以该文件是额外输出的,并没有原始文件与之对应。
Runtime
The runtime, along with the manifest data, is all the code webpack needs to connect your modularized application while it's running in the browser. It contains the loading and resolving logic needed to connect your modules as they interact. This includes connecting modules that have already been loaded into the browser as well as logic to lazy-load the ones that haven't.
runtime是如何发挥作用的?
由于runtime的主体内容基本是固定的,下面分析下webpack如何实现自己的模块化规范来保证构建后的产物正常运行。
始于 index.html
从浏览器加载index.html文件开始,内容如下
代码语言:javascript复制<!DOCTYPE html>
<html>
<head>
<script src="./runtimeChunk.js"></script>
<script src="./chunkMain.js"></script>
</head>
<body></body>
</html>
由于runtimeChunk.js
是底层依赖,需要先于所有的js文件先执行,并且需要引入我们页面的主入口chunkMain.js
.
runtimeChunk.js的加载和执行
<script/>
标签默认特性下,会保证runtimeChunk.js
和chunkMain.js
的执行顺序。
参考: load and execute order of scripts
runtimeChunk.js
的内容如下,列出上述产物中出现的较重要的方法,这里先简单介绍各方法的作用,具体的功能下面会详细介绍
// 首先是一个立即执行函数,参数modules用来保存当前运行时加载的所有模块
(function (modules) { // modules 缓存加载了文件包含的模块的定义,此时模块未执行也为注册
var installedModules = {}; // 保存已经执行并且注册了的模块,区别于modules
// 存储chunk的加载状态,枚举值有:undefined、null、Promise、0
// undefined:chunk尚未加载,
// null:chunk preloaded/prefetched
// Promise:当前chunk正在加载
// 0:chunk已经成功加载
var installedChunks = { 0: 0 }; // key是chunkId,value是chunk的加载状态
// 暂时只列出chunk加载和模块注册相关的逻辑
// 1. 缓存chunk的加载状态
// 2. 缓存模块的定义
// 3. 异步模块加载的resolve()
// 4. 校验 执行待执行模块(data[2])
function webpackJsonpCallback(data) {...};
// 通过moduleId执行指定的模块,并获取该模块对外暴露的变量
function __webpack_require__(moduleId) {...}
// 提供给具体的模块使用,模块通过该方法定义暴露的变量
__webpack_require__.d = function (exports, name, getter) {...};
// 异步模块的加载,主要是动态创建script标签挂载到页面上,并返回一个promise给调用者
__webpack_require__.e = function requireEnsure(chunkId) {...}
// ... __webpack_require__.xxx 等其他方法的定义
// 设置 window["webpackJsonp"],并改写push方法指向webpackJsonpCallback
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
jsonpArray.push = webpackJsonpCallback; // 注意:实际指向webpackJsonpCallback
})([]);
首先我们看到的是一个立即执行函数,好处是让入参modules
在当前runtime的运行环境中为私有变量,如果有同时存在多个多个runtime.js,避免了全局作用域的污染,保证了隔离性和安全性。
介绍下内部关键的缓存对象:
modules
: 存储当前已经加载过的模块的定义,map结构
- key:moduleId
- value:`(function(module, __webpack_exports__, __webpack_require__) {...})`,此时只是缓存模块的定义,该模块尚未执行和注册
代码语言:txt复制- key:moduleId
- value:对象,该对象的`exports`属性是关键,用来存储当前模块对外暴露的变量。 区别于上面的modules,installModules中的value就是执行modules中的value得到的结果
代码语言:txt复制- key:chunkId
- value:该chunk关联的文件的加载状态(有:尚未加载、加载中、加载完成、预加载),
另外看到在window
对象上添加webpackJsonp
属性指向一个数组,改写push方法指向webpackJsonpCallback
思考:
- webpackJsonp初始为数组的作用是什么❓
- 是否会同时存在多个runtime.js的场景,webpack是怎么处理的❓
chunkMain.js的加载和执行
执行完runtimeChunk.js
后执行chunkMain.js
,这里调用了window"webpackJsonp".push即webpackJsonpCallback
,下面看下该方法的实现
webpackJsonpCallback
代码语言:javascript复制function webpackJsonpCallback(data) {
// 可以看到push的入参数数组的各个元素的含义
// 当前文件关联的chunkId
var chunkIds = data[0];
// 当前文件包含了哪些模块,
// 此时模块内容并未真正执行,实际的内容外面包了一层function(...){...}
var moreModules = data[1];
var executeModules = data[2];
var moduleId, chunkId, i = 0, resolves = [];
for (; i < chunkIds.length; i ) {
chunkId = chunkIds[i];
if (/*如果当前有正在异步加载的chunk*/) {
// 保存异步模块加载时生成的resolve,即__webpack_require__.e中设置的resolve
resolves.push(installedChunks[chunkId][0]);
}
// chunk成功加载完成的标识
installedChunks[chunkId] = 0;
}
// 遍历 moreModules变缓存到modules
// modules[moduleId] = moreModules[moduleId];
// 调用父jsonFunction,有特定的场景,后面再说
while (resolves.length) {
// 异步模块加载的【关键】:执行 __webpack_require__.e 设置的 resolve
resolves.shift()();
}
return checkDeferredModules(); // 执行 executeModules 中的模块
};
function checkDeferredModules() {
var result;
for(var i = 0; i < deferredModules.length; i ) {
var deferredModule = deferredModules[i];
var fulfilled = true;
// 通过installedChunks判断依赖的chunks是否已经加载完成
// 注意:从第2个(j = 1)元素开始算作依赖chunk
for(var j = 1; j < deferredModule.length; j ) {
var depId = deferredModule[j];
if(installedChunks[depId] !== 0) fulfilled = false;
}
if(fulfilled) {
deferredModules.splice(i--, 1);
// 加载第1个元素
result = __webpack_require__(__webpack_require__.s = deferredModule[0]);
}
}
return result;
}
主要步骤:
- 通过
installedChunks
记录加载过的chunk,这里的chunkId是1,所以会记录installedChunks[1] = 0
表明该chunk加载完成。然后遍历第二个元素是各个moduleId(对应数组索引)与模块定义的映射,缓存到modules
上。 - 如果存在异步加载的Chunk,则获取
__webpack_require__.e
设置的resolve
并执行 - 调用
checkDeferredModules
方法:- 校验依赖的Chunks 是否都已经加载完成;
- 调用
__webpack_require__
执行并注册模块;这里就是[0,0]
,第一个元素是需要执行并注册的moduleId,第二个(及其后面的)元素该模块依赖的chunkIds,只有这些chunk安装后才能执行并注册该模块。moduleId为0在这里指向chunkMain.js
,chunkId为0在这里指向runtimeChunk.js
。
webpack_require
下面看下__webpack_require__
方法以及执行和注册chunkMain.js
逻辑
// 根据moduleId执行并注册模块
function __webpack_require__(moduleId) {
// 1. 已经安装过则直接返回
// 2. 创建一个对象用来存储模块的信息,主要是exports存储对外暴露的变量
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
};
// 3. 执行模块
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
// 4. 设置
module.l = true;
// 5. 返回当前模块对外暴露的变量
return module.exports;
}
逻辑很清楚了,构造一个对象用来存储moduleId
对应的模块信息,主要是exports
用来存储模块对外暴露的变量,关注下call
方法的入参。下面是chunkMain.js
的内容。
// 原始main.js构建后的内容
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _custom_loaders_custom_inline_loader_js_share_opts_a_c_d__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);
console.log('pre');
function logAB() {
Object(_custom_loaders_custom_inline_loader_js_share_opts_a_c_d__WEBPACK_IMPORTED_MODULE_0__["logA"])();
__webpack_require__.e(/* import() | ChunkB */ 2).then(__webpack_require__.bind(null, 3)).then(asyncModule => asyncModule.logB())
}
logAB();
console.log('normal');
console.log('post')
/***/ }),
执行这段代码,这里我们关注两个点
- 由于原始main.js
同步
引用了a.js,所以我们看到这里继续使用__webpack_require__
获取moduleId为 1 的模块(对应a.js),这个过程是同步的,逻辑同上不在赘述 - 由于原始main.js
异步
引用了b.js,这里通过调用__webpack_require__.e
来进行模块的异步加载
异步加载chunkB.js
看下__webpack_require__.e
逻辑
webpack_require.e
代码语言:javascript复制// 加载异步模块
__webpack_require__.e = function requireEnsure(chunkId) {
var promises = [];
var installedChunkData = installedChunks[chunkId];
if (installedChunkData !== 0) { // 0 means "already installed".
if (installedChunkData) {
// 避免同一个Chunk被多次加载
promises.push(installedChunkData[2]);
} else {
var promise = new Promise(function (resolve, reject) {
// 关键:将promise的resolve/reject和chunkId关联起来
// webpackJsonpCallback 就可以通过chunkId找到resolve/reject对决定promise状态
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
// 所以:installedChunkData [resolve, reject, promise]
promises.push(installedChunkData[2] = promise); // 添加了一个数组元素
}
// 动态创建script标签挂载到document上
// 设置定时器放置无限等待,reject
// 添加onlaod事件 onScriptComplete: 清理定时器 处理异常
}
return Promise.all(promises)
}
步骤如下:
异步模块加载的入口,返回一个promise
,主要实现是动态创建一个<script/>
标签挂载到页面上.
这里的巧妙出在于通过installedChunks[chunkId]
保存了该promise
的[resolve,reject]
当文件chunkB.js
加载完成后,浏览器会执行该js,立即执行webpackJsonpCallback
,而对于异步加载的chunk来说,这里有个特殊的点,就是这里会执行resolves.shift()();
来结束promise
的pending
状态从而进入后面的then
逻辑, __webpack_require__.e
只是创建<script/>
用来加载并执行chunkB.js
,b.js的模块的获取
还得交给__webpack_require__
__webpack_require__.e(/* import() | ChunkB */ 2).then(__webpack_require__.bind(null, 3)).then(asyncModule => asyncModule.logB())
__webpack_require__.d
最后我们看下__webpack_require__.d
方法,在a.js和b.js中构建后内容中,都是通过该方法在module.exports
对象上定义对外暴露的变量
// chunkB.js,导出 logB
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "logB", function() { return logB; });
__webpack_require__.d
逻辑如下,逻辑很简单就是调用Object.defineProperty
在exports
对象上定义一个属性,值得注意的是,这里只定义了getter
__webpack_require__.d = function (exports, name, getter) {
// __webpack_require__.o => Object.prototype.hasOwnProperty.call(obj, prop)
if (!__webpack_require__.o(exports, name)) {
Object.defineProperty(exports, name, {enumerable: true, get: getter});
}
};
esm
规范中,模块导出的值是只读
的,重新赋值会报错;因此这里没有定义setter
,默认是undefined
在严格模式下给exports
的属性赋值会报错,用来对齐esm
规范。
思考:webpack是如何支持其他模块化规范的❓
总结
- 主要给出来一个具体的案例,并对构建前后的内容进行对比,引出一些问题
- 对于webpack生成的运行时给出了详细的分析。