webpack之module、chunk和缓存机制

前言

webpack是最炙手可热的前端构建打包框架,从一个入口文件开始,webpack分析出整个依赖视图,将所有被依赖的资源打包到一个或多个文件中,是前端模块化的利器。

用了那么久的webpack, 想以最基础的例子记录下webpack runtime时的模块加载机制,以及使用webpack实现前端缓存, 所以有了这篇文章。

我们先npm init一个空项目,然后加入以下三个依赖,然后就开始吧!

1
2
3
4
5
"devDependencies": {
"html-webpack-plugin": "^3.2.0",
"rimraf": "^2.6.2",
"webpack": "^3.12.0"
}

webpack打包

module

什么是webpack module? 个人理解: 凡是能被"引入"的文件,都称之为module。比如:

  • es6 import
  • commonjs require
  • amd define 和 require 语句块
  • css、img、font等(依赖于额外的loader来处理,可能会自主增加module)

chunk

什么是webpack chunk? 个人理解: 打包完毕后,任何一个独立的js文件都称之为chunk。一个chunk可以包含多个module。

webpack runtime代码

在本篇中,暂时只讨论只有js的情况,毕竟比如style-loader会自主添加module(为了插入style标签等特定功能)

我们来创建一个初始示例项目,结构如下:

1
2
3
4
5
6
7
8
9
10
11
├── dist
├── index.html
├── package.json
├── src
│   ├── entry
│   │   └── chunk1.js
│   └── module
│   ├── module.amd.js
│   ├── module.common.js
│   └── module.esm.js
└── webpack.config.js

chunk1.js

1
2
3
4
5
6
7
8
9
10

import _1 from '../module/module.common.js'
import _2 from '../module/module.amd.js'
import _3 from '../module/module.esm.js'

_1()
_2()
_3()

console.log('chunk1 entry js')

module.common.js

1
2
3
4

module.exports = function commonjsMethod() {
console.log('this is a commonjs method')
}

module.amd.js

1
2
3
4
5
define(function(){
return function amdMethod () {
console.log('this is a amd module method')
}
})

module.esm.js

1
2
3
4
5
6
7
8

export default function esmMethod () {
console.log('this is a es module method')
}

export function esmMethod2 () {
console.log('this is a es module method2')
}

webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const path = require('path')
const rm = require('rimraf')
const webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')

rm.sync('./dist') // 清除dist目录

module.exports = {
entry: {
'chunk1': './src/entry/chunk1.js'
},
output: {
filename: '[id].[name].[chunkHash].js',
path: path.resolve(__dirname, 'dist')
},
plugins: [
new HtmlWebpackPlugin({
template: 'index.html',
inject: true
})
]
}

只有一个chunk

按照我们初始的项目结构, 只会构建出一个chunk文件, chunk文件的形式大致如下:

1
2
3
4
5
(function(modules) { // webpackBootstrap

...blabla

})([module_0_Func, module_1_Func....module_n_Func])

匿名函数加载的module函数都是将模块文件转变为commonjs风格, 每个模块id按照数组中的顺序从0开始标识,并且将第0个module作为entry module执行__webpack_require__。下面我们来看看bootstrap这块代码:

1
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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65

/******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {}; // 已经加载了的module
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) { // webpack引入文件的方法, 一切加载方式都最终转为该函数调用
/******/
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId]) { // 已经加载过的module, 返回缓存
/******/ return installedModules[moduleId].exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ i: moduleId, // 模块id
/******/ l: false, // 是否已经load
/******/ exports: {} // exports接口
/******/ };
/******/
/******/ // Execute the module function
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); // commonjs风格的包裹方式
/******/
/******/ // Flag the module as loaded
/******/ module.l = true; // 已经加载
/******/
/******/ // Return the exports of the module
/******/ return module.exports; // 返回exports接口
/******/ }
/******/
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = modules; // 初始传入的modules数组
/******/
/******/ // expose the module cache
/******/ __webpack_require__.c = installedModules; // 已经加载了的module
/******/
/******/ // define getter function for harmony exports
/******/ __webpack_require__.d = function(exports, name, getter) { // 为exports定义属性
/******/ if(!__webpack_require__.o(exports, name)) {
/******/ Object.defineProperty(exports, name, {
/******/ configurable: false,
/******/ enumerable: true,
/******/ get: getter
/******/ });
/******/ }
/******/ };
/******/
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) { // 兼容非es6 module的模块
/******/ var getter = module && module.__esModule ? // es module风格
/******/ function getDefault() { return module['default']; } :
/******/ function getModuleExports() { return module; };
/******/ __webpack_require__.d(getter, 'a', getter);
/******/ return getter;
/******/ };
/******/
/******/ // Object.prototype.hasOwnProperty.call
/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/ // __webpack_public_path__
/******/ __webpack_require__.p = ""; // output.publicPath
/******/
/******/ // Load entry module and return exports
/******/ return __webpack_require__(__webpack_require__.s = 0); // module 0作为入口文件
/******/ })

amd & esm & commonjs

刚共计定义了三种风格模块, 下面来看看webpack把它们转成了什么样。

module.common.js: 这个没啥好说的,恰好就是符合webpack转换后的加载方式

1
2
3
4
5
6
7
(function(module, exports) {

module.exports = function commonjsMethod() {
console.log('this is a commonjs method')
}

/***/ })

module.amd.js: amd可以在方法内部去修改exports, 也可以直接返回module值,这种情况下回重定义module.exports

1
2
3
4
5
6
7
8
9
10
(function(module, exports, __webpack_require__) {

var __WEBPACK_AMD_DEFINE_RESULT__;!(__WEBPACK_AMD_DEFINE_RESULT__ = (function(){
return function amdMethod () {
console.log('this is a amd module method')
}
}).call(exports, __webpack_require__, exports, module), // amd可以直接返回module.exports
__WEBPACK_AMD_DEFINE_RESULT__ !== undefined && (module.exports = __WEBPACK_AMD_DEFINE_RESULT__))

/***/ })

module.esm.js: esm模块, webpack会为export的接口替换为另一个变量名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

(function(module, __webpack_exports__, __webpack_require__) {

"use strict";
/* harmony export (immutable) */ __webpack_exports__["a"] = esmMethod; // 为esm的default取名
/* unused harmony export esmMethod2 */ // tree-shaking, esmMethod2会在uglify的时候被删除
function esmMethod () {
console.log('this is a es module method')
}

function esmMethod2 () {
console.log('this is a es module method2')
}

/***/ })

最后再看看入口文件chunk1.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(function(module, __webpack_exports__, __webpack_require__) {

"use strict";
Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__module_module_common_js__ = __webpack_require__(1);
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__module_module_common_js___default = __webpack_require__.n(__WEBPACK_IMPORTED_MODULE_0__module_module_common_js__);
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_1__module_module_amd_js__ = __webpack_require__(2);
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_1__module_module_amd_js___default = __webpack_require__.n(__WEBPACK_IMPORTED_MODULE_1__module_module_amd_js__);
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_2__module_module_esm_js__ = __webpack_require__(3);




__WEBPACK_IMPORTED_MODULE_0__module_module_common_js___default.a.x()
__WEBPACK_IMPORTED_MODULE_1__module_module_amd_js___default()()
Object(__WEBPACK_IMPORTED_MODULE_2__module_module_esm_js__["a" /* default */])()

console.log('chunk1 entry js')


/***/ })

多个chunk

上面只提到了一个chunk的情况,但是在实际工作中,我们肯定不止一个chunk。不仅有异步加载chunk, 也会提取一些稳定的三方库到单独的chunk中。这里我们修改下webpack.config.js:

1
2
3
4
5
6
7
8
9
10
11

plugins: [
new HtmlWebpackPlugin({
template: 'index.html',
inject: true
}),
+ new webpack.optimize.commonChunkPlugin({
+ name: 'manifest',
+ minChunks: Infinity // 只有runtime代码
+ })
]

上述配置将会把webpack bootstrap的代码单独抽离到一个名为manifest的chunk中, 我们打包后得到:

1
2
3
4
├── dist
│   ├── 0.chunk1.73283c1da4741d8f2892.js
│   ├── 1.manifest.cb74b5686e91aca99484.js
│   └── index.html

打开manifest, 会发现代码比之前多了一坨。由于不止一个chunk, webpack加入了webpackJsonp函数来加载chunk:

1
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
// install a JSONP callback for chunk loading
/******/ var parentJsonpFunction = window["webpackJsonp"]; // 不知道用来干啥的
// chunkIds: 加载的chunk id, 数组
// moreModules: chunk包含的module,数组或者key为module id的对象
// executeModules: 立即加载的module, 数组
/******/ window["webpackJsonp"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {
/******/ // add "moreModules" to the modules object,
/******/ // then flag all "chunkIds" as loaded and fire callback
/******/ var moduleId, chunkId, i = 0, resolves = [], result;
/******/ for(;i < chunkIds.length; i++) {
/******/ chunkId = chunkIds[i];
/******/ if(installedChunks[chunkId]) { // 这里用于异步chunk
/******/ resolves.push(installedChunks[chunkId][0]);
/******/ }
/******/ installedChunks[chunkId] = 0; // 表示chunk已经加载
/******/ }
/******/ for(moduleId in moreModules) { // chunk包含的module, 赋值到modules中, __webpack_require__方法会使用
/******/ if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
/******/ modules[moduleId] = moreModules[moduleId];
/******/ }
/******/ }
/******/ if(parentJsonpFunction) parentJsonpFunction(chunkIds, moreModules, executeModules); // 不知道干啥滴
/******/ while(resolves.length) {
/******/ resolves.shift()(); // 异步chunk的promise resolve
/******/ }
/******/ if(executeModules) { // 立即执行的module, 调用__webpack_require__加载
/******/ for(i=0; i < executeModules.length; i++) {
/******/ result = __webpack_require__(__webpack_require__.s = executeModules[i]);
/******/ }
/******/ }
/******/ return result;
/******/ };

/******/ // objects to store loaded and loading chunks
/******/ var installedChunks = { // 已经加载的chunk, bootstrap所在的chunk作为入口执行肯定是已经加载了的
/******/ 1: 0
/******/ };

然后我们再看看生成的0.chunk1.73283c1da4741d8f2892.js文件,已经变成了下面的样式:

1
2
3
4
5
6
webpackJsonp([0],[ // chunk id为0
module_0_func, // chunk所包含的module
module_1_func,
...
module_n_func
], [0]) // 执行直接的module id (入口)

当多于一个chunk后,bootstrap之外的代码, 都会调用webpackJsonp来注入,声明其chunkid, 包含的模块, 此外还可能指定直接调用的模块id。

异步chunk

刚才在webpackJsonP的实现中,还有一段代码我标识了注释,用于异步chunk,也就是懒加载的chunk,所以我们来修改一下chunk1.js:

1
2
3
4
5
6
7
8
9
10
11
  import _1 from '../module/module.common.js'
import _2 from '../module/module.amd.js'
- import _3 from '../module/module.esm.js'

+ import(/* webpackChunkName: "dynamic chunk" */ '../module/module.esm.js').then(m => m.default())

_1()
_2()
- _3()

console.log('chunk1 entry js')

webpack.config.js:

1
2
3
4
5
output: {
+ chunkFilename: '[id].[name].[chunkHash].js',
filename: '[id].[name].[chunkHash].js',
path: path.resolve(__dirname, 'dist')
}

打包后, 我们可以看到如下生成:

1
2
3
4
5
6

├── dist
│   ├── 0.dynamic.6ee8099329cf4fd23e43.js
│   ├── 1.chunk1.2424be36725471e94943.js
│   ├── 2.manifest.3cfb3b644210fa58e310.js
│   └── index.html

好了, 现在我们生成了一个单独的异步dynamic chunk,再来看看run time代码有什么变化吧。

chunk1:

1
__webpack_require__.e/* import() */(0).then(__webpack_require__.bind(null, 3)).then(m => m.default())

加载module.esm.js的部分已经变了, webpack新增了__webpack_require__.e方法用来加载异步chunk, 返回promise, 在promise resolve后, 调用__webpack_require__函数加载module id 3的模块(module.esm.js), 由于then的传递性, __webpack_require__返回的结果会来到最后的then callback, 也就是m => m.default()

然后再到manifest中看看__webpack_require__.e的实现:

1
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
42
43
44
45
46
47
48
49
/******/ 	// The chunk loading function for additional chunks
/******/ __webpack_require__.e = function requireEnsure(chunkId) { // 加载异步chunk
/******/ var installedChunkData = installedChunks[chunkId];
/******/ if(installedChunkData === 0) { // 已经加载过了, 直接resolve
/******/ return new Promise(function(resolve) { resolve(); });
/******/ }
/******/
/******/ // a Promise means "currently loading".
/******/ if(installedChunkData) { // 如果没加载过但是有值,说明pending,则返回正在pending的promise
/******/ return installedChunkData[2];
/******/ }
/******/
/******/ // setup Promise in chunk cache
/******/ var promise = new Promise(function(resolve, reject) {
/******/ installedChunkData = installedChunks[chunkId] = [resolve, reject];
/******/ });
/******/ installedChunkData[2] = promise; // 缓存成一个数组,结构是[resolve, reject, promise]
/******/
/******/ // start chunk loading
/******/ var head = document.getElementsByTagName('head')[0];
/******/ var script = document.createElement('script');
/******/ script.type = "text/javascript";
/******/ script.charset = 'utf-8';
/******/ script.async = true; // 异步下载,不阻塞html解析,但是下载完成后立即执行
/******/ script.timeout = 120000;
/******/
/******/ if (__webpack_require__.nc) { // 不知道干嘛滴
/******/ script.setAttribute("nonce", __webpack_require__.nc);
/******/ }
// 异步chunk路径
/******/ script.src = __webpack_require__.p + "" + chunkId + "." + ({"0":"dynamic"}[chunkId]||chunkId) + "." + {"0":"6ee8099329cf4fd23e43"}[chunkId] + ".js";
/******/ var timeout = setTimeout(onScriptComplete, 120000); // 超时回调
/******/ script.onerror = script.onload = onScriptComplete; //onerror & onload
/******/ function onScriptComplete() {
/******/ // avoid mem leaks in IE.
/******/ script.onerror = script.onload = null;
/******/ clearTimeout(timeout);
/******/ var chunk = installedChunks[chunkId];
/******/ if(chunk !== 0) { // 异步chunk还没加载好,没有调用webpackJsonP方法完成注册
/******/ if(chunk) { // 说明加载超时了、或者是onerror了(之所以if判断,是因为可能同时有多个对同一个异步chunk的import, 共享一个promise, 只需要reject一次)
/******/ chunk[1](new Error('Loading chunk ' + chunkId + ' failed.')); // chunk[1]就是promise reject
/******/ }
/******/ installedChunks[chunkId] = undefined; // 清空缓存,等同于还没加载过这个异步chunk
/******/ }
/******/ };
/******/ head.appendChild(script); // 添加到head
/******/
/******/ return promise; // 返回promise
/******/ };

看了这段代码,回过头来我们现在应该明白了webpackJsonPresolves数组的作用了吧, 其实就是将异步chunk加载的promise resolve。

缓存机制

不用多说,现在前端代码部署的最佳方案是: 文件摘要命名+非覆盖式发布。在这点上,webpack已经支持的非常好了, 利用我们一开始的代码, 我们就已经构建出了最佳方案。

但是,将所有代码打包到一个chunk, 会不利于前端JS的缓存。任何一行代码的修改,都会导致hash值的改变,从而基本没有缓存功能可言。毕竟,至少稳定的第三方库是基本不可能改变的不是么?

分割代码

我们大致把模块类型分为以下几个部分:

类型 公用率 更新频率 栗子🌰
第三方库 vue、react、jquery等
UI库、util库 echarts、vux、项目ui库、util库等
业务模块 业务逻辑代码、各种view

其实,如何切割chunk块,是非常灵活的,完全可以根据项目的实际情况来做,在当前例子下,我们假设src/module下的模块是第三方稳定库,我们就把src/module模块下的文件抽取到一个vendor chunk。

修改webpack.config.js:

1
2
3
4
5
6
7
8
9
10
+ new webpack.optimize.CommonsChunkPlugin({
+ name: 'vendor',
+ minChunks (m) {
+ return /src\/module/.test(m.context)
+ }
+ }),
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest',
minChunks: Infinity // 只有runtime代码
})

minChunks作为函数回调时, 可以让我们自行决定该module是否被打入新的chunk。根据以上的配置再次打包,我们可以得到如下文件:

1
2
3
4
5
├── dist
│   ├── 0.vendor.8c5a985857625bc38626.js
│   ├── 1.chunk1.cd501014d521665f1d21.js
│   ├── 2.manifest.b7aa9a2c470a1db60cce.js
│   └── index.html

在插件提供的功能下,根据项目的实际情况,我们可以自由划分chunk,一切看起来都完美了?接着往下看:

不稳定的模块id

我们的项目突然多了一个自行开发的模块,在chunk1.js中引入, 现在项目结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
    ├── dist
├── index.html
├── package.json
├── src
│   ├── entry
│   │   └── chunk1.js
│   ├── module
│   │   ├── module.amd.js
│   │   ├── module.common.js
│   │   └── module.esm.js
+ │   └── non-vendor-module
+ │   └── module.js
└── webpack.config.js

chunk1.js :

1
2
3
4
+   import _4 from '../non-vendor-module/module.js'
import _1 from '../module/module.common.js'
import _2 from '../module/module.amd.js'
import _3 from '../module/module.esm.js'

然后我们打包:

1
2
3
4
5
├── dist
│   ├── 0.vendor.00542b72fb49c9979366.js
│   ├── 1.chunk1.a4bb52f80aad175082cc.js
│   ├── 2.manifest.b7aa9a2c470a1db60cce.js
│   └── index.html

我们发现,vendor和chunk1这两个chunk的hash值都变了, chunk1我们可以理解, 但是vendor为什么变了呢?这是因为,模块的id改变了! 我们看看两次打包的结果图:

我们可以清楚的看到, amd esm模块的id由于新的模块的加入而被排到了后面,这导致了vendor chunk代码结构的些许变化。之前说过, 调用webpackJsonP的chunk代码块的结构大致为以下两种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
webpackJsonp([0],[ // 当前chunk id    
module_0_func, // chunk所包含的module
module_1_func,
/** */, // id为2的模块不在这个chunk
...
module_n_func
], [0]) // 执行直接的module id (入口)

webpackJsonp([0],{ // 当前chunk id
"0": module_0_func, // chunk所包含的module
"1": module_1_func,
...
n: module_n_func
}, [0]) // 执行直接的module id (入口)

所以, module id的变化,会影响到chunk代码的内容,从而改变hash值。这里,我们只需要再使用webpack提供的插件new webpack.HashedModuleIdsPlugin(),即可将module id从默认的数字,转化为根据module内容而生成的hash值, 并且让chunk代码都采用上述的第二种方式加载chunk的module。如此一来,就不用再担心这种情况了。

异步chunk和manifest

之前我们已经看到了, 在bootstrap的代码里的__webpack_require__.e实现中, webpack把所有异步chunk的相关信息(id, name等)储存到了里面,这也意味着,每当新增加异步chunk,也会导致manifest文件的改变。所以可以再吹毛求疵一下,既然manifest代码极少(压缩了可能2kb),根据自己项目的情况(比如异步chunk频繁变更),我们可以利用插件inline-manifest-webpack-plugin把manifest inline到html中去,毕竟html每次都要发http请求,附加一个小文件也是可行的。

总结

然而多页面情况下, webpack中CCP插件的使用就有点怪怪了,结合webpack4中chunk graph算法的修改,之后有时间再写。