你知道webpack是如何处理代码分割的吗?
我们知道,如果写import xxx from 'xxx.js'
所有的资源都会被打包进一个资源文件里面。
可以看👉这里,index.js 和 sum.js 最后被打包出来成为一个main.js 建议没看过 webpack打包cjs与esm产物分析 的先看这篇(没看也影响不大
这样做的优点就是能减少最终页面的HTTP请求数,但是缺点也同样存在:
- 随着项目的不断增大,最终形成的代码量过大,影响首屏渲染
- 因为所有的产物都在一起,就无法有效的利用上webpack的缓存构建及浏览器的缓存加载的机制
想要让某个模块在自己需要的时候再加载,我们可以利用import(module)
来动态导入模块,这就是code splitting
。但是一般来讲webpack
打包后并不会直接使用import(module)
。
我们经常在优化项目时会配置webpack
的optimization.splitChunks
,它的本质其实就是利用import(module)
的这一点完成的代码分割。我们下面来看下code splitting
的运行时是怎样的。
实验代码
业务代码如下👇
// index.js 内容
import('./sum').then(m => {
console.log(m.default(3, 4))
})
// sum.js 内容
const sum = (...args) => args.reduce((x, y) => x + y, 0)
export default sum
编写webpack
配置文件 如下👇
// build.js
const webpack = require('webpack')
const path = require('path')
function f1() {
return webpack({
entry: './index.js',
mode: 'none',
output: {
filename: 'main.[contenthash].js', // 主文件名规则
chunkFilename: '[name].chunk.[contenthash].js', // 模块文件名规则
path: path.resolve(__dirname, 'dist/contenthash'), // 打包出口
clean: true
}
})
}
f1().run((err, stat) => {
console.log('🚀构建完成')
})
进行打包
node build.js
打包结果如下👇:
$ ls -lh dist/contenthash
total 32
-rw-r--r-- 1 jerry staff 523B 9 9 16:42 1.chunk.f64a38ddcbde85380cf2.js
-rw-r--r-- 1 jerry staff 11K 9 9 16:42 main.c09a77a27e9d6bdf91d7.js
调试
调试之前需要新建一个index.html
,引入main.xxx.js
。通过 serve 开启静态资源服务,在浏览器中打开进行调试。
$ npx serve .
chunk 代码分析
回顾一下 webpack打包cjs与esm产物分析中几个重要的数据结构及方法:
- 模块函数数组
__webpack_modules__
:用于存放模块内容的数组,其数组下标表示对应的模块函数。 - 模块缓存对象
__webpack_module_cache__
:通过模块函数数组的下标缓存模块的对象。 - 模块加载函数
__webpack_require__
:该函数接收一个模块id(对应的就是模块函数数组的下标),如果该模块在缓存中存在则直接返回module.exports
。否则将要加载的模块缓存到模块缓存对象__webpack_module_cache__
中,并返回module.exports
。 __webpack_require__.d
:该方法在模块加载时通过Object.defineProperty
的getter
定义exports
导出的值。__webpack_require__.o
:Object.prototype.hasOwnProperty
的缩写。__webpack_require__.r
:用来标记是ESM。
在main.xxx.js
的最后几行其实就是我们写的import('./sum').then ...
可以对比一下:
// 编译之前
import('./sum').then(m => {
console.log(m.default(1, 2))
})
// 编译之后
__webpack_require__.e(/* import() */ 1)
.then(__webpack_require__.bind(__webpack_require__, 1))
.then(m => {
console.log(m.default(1, 2))
})
其中__webpack_require__.e(1)
中的1
为chunkId,加载的对应的文件就是1.chunk.xxx.js
,内容如下👇
"use strict";
// JSONP callback, 该script文件加载后执行 self.webpackChunk.push
// 将模块内容收集到 __webpack_modules__ 中
// self["webpackChunk"].push:push 方法并不是Array的push,属于自定义方法
(self["webpackChunk"] = self["webpackChunk"] || []).push([[1], [
/* 0 */,
/* 1 */
((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.r(__webpack_exports__);
__webpack_require__.d(__webpack_exports__, {
"default": () => (__WEBPACK_DEFAULT_EXPORT__)
});
const sum = (...args) => args.reduce((x, y) => x + y, 0)
const __WEBPACK_DEFAULT_EXPORT__ = (sum);
})
]]);
整个过程如下:
- 调用
__webpack_require__.e
,加载chunk。new 一个Promise,通过script
标签加载chunk
- 加载 script,执行 JSONP callback
self["webpackChunk"].push
,将传参中的模块收集到__webpack_modules__
中,并将__webpack_require__.e
的 Promise 进行 resolve __webpack_require__.e
的 Promise resolve 后就会执行then中的回调函数——__webpack_require_
函数,这个过程与之前分析esm一致。
在 script 上还有一个 onload 事件(宏任务),触发 onload 时会在 document 中将这个 script 标签删除
运行时分析
main.js 运行时分析
// 将 __webpack_modules__ 放到 __webpack_require__ 上
__webpack_require__.m = __webpack_modules__;
__webpack_require__.f = {};
// 用以加载 code spliting 的 chunk
__webpack_require__.e = (chunkId) => {
return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
// 实际上是 __webpack_require__.f.j
__webpack_require__.f[key](chunkId, promises);
return promises;
}, []));
};
// 得到 chunk 地址,用以加载
__webpack_require__.u = (chunkId) => {
// 这里的 chunk 脚本的地址被写死,意味着每当 chunk 的文件名发生改变,运行时代码也会发生改变
return "" + chunkId + "." + chunkId + ".chunk." + "f64a38ddcbde85380cf2" + ".js";
};
// 封装全局变量
__webpack_require__.g = (function () {
if (typeof globalThis === 'object') return globalThis;
try {
return this || new Function('return this')();
} catch (e) {
if (typeof window === 'object') return window;
}
})();
以下是 webpack 实现加载 chunk 的 加载脚本。
var inProgress = {};
__webpack_require__.l = (url, done, key, chunkId) => {
// done 为脚本加载完时的回调函数
if (inProgress[url]) { inProgress[url].push(done); return; }
// script:script 标签,用以加载 chunk,如 <script src="xxx.chunk.js">
var script, needAttach;
if (key !== undefined) {
// 判断该 script 标签是否已存在在 document 中
var scripts = document.getElementsByTagName("script");
for (var i = 0; i < scripts.length; i++) {
var s = scripts[i];
if (s.getAttribute("src") == url) { script = s; break; }
}
}
// 如果 script 标签不存在在 document 中,则新建 script 标签,加载脚本
if (!script) {
needAttach = true;
script = document.createElement('script');
script.charset = 'utf-8';
script.timeout = 120;
// nonce,用以配置 CSP 策略,见 https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
if (__webpack_require__.nc) {
script.setAttribute("nonce", __webpack_require__.nc);
}
script.src = url;
}
inProgress[url] = [done];
var onScriptComplete = (prev, event) => {
// avoid mem leaks in IE.
script.onerror = script.onload = null;
clearTimeout(timeout);
var doneFns = inProgress[url];
delete inProgress[url];
script.parentNode && script.parentNode.removeChild(script);
// 脚本加载结束后,回调 done 函数,并传递 event 参数s
doneFns && doneFns.forEach((fn) => (fn(event)));
if (prev) return prev(event);
}
var timeout = setTimeout(onScriptComplete.bind(null, undefined, { type: 'timeout', target: script }), 120000);
// script.onload 事件,脚本加载完成时,执行 onScriptComplete(script.onerror)
script.onerror = onScriptComplete.bind(null, script.onerror);
// script.onload 事件,脚本加载完成时,执行 onScriptComplete(script.onload)
script.onload = onScriptComplete.bind(null, script.onload);
// 1. 将 script 附在 DOM 中,加载脚本
// 2. chunk 脚本加载后执行 chunk 脚本中的 self["webpackChunk"].push 函数,即下边的 webpackJsonpCallback 函数
needAttach && document.head.appendChild(script);
};
维护模块加载状态的方法及JSONP Callback的方法
// key 为 chunkId,value 为加载状态
// 0: 代表 chunk 加载成功
// [resolve, reject, Promise]: 代表 chunk 正在加载
// undefined: 代表 chunk 尚未加载
// null: chunk 为 preloaded/refetched
var installedChunks = {
0: 0 // main 默认是 0
};
__webpack_require__.f.j = (chunkId, promises) => {
// JSONP chunk loading for javascript
var installedChunkData = __webpack_require__.o(installedChunks, chunkId) ? installedChunks[chunkId] : undefined;
if (installedChunkData !== 0) { // 0 means "already installed".
// a Promise means "currently loading".
if (installedChunkData) {
// chunk 正在加载
promises.push(installedChunkData[2]);
} else {
if (true) { // all chunks have JS
// setup Promise in chunk cache
var promise = new Promise((resolve, reject) => (installedChunkData = installedChunks[chunkId] = [resolve, reject]));
promises.push(installedChunkData[2] = promise);
// 获取 chunk 的脚本地址
var url = __webpack_require__.p + __webpack_require__.u(chunkId);
// create error before stack unwound to get useful stacktrace later
var error = new Error();
var loadingEnded = (event) => {
if (__webpack_require__.o(installedChunks, chunkId)) {
installedChunkData = installedChunks[chunkId];
if (installedChunkData !== 0) installedChunks[chunkId] = undefined;
if (installedChunkData) {
var errorType = event && (event.type === 'load' ? 'missing' : event.type);
var realSrc = event && event.target && event.target.src;
error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
error.name = 'ChunkLoadError';
error.type = errorType;
error.request = realSrc;
installedChunkData[1](error);
}
}
};
// 加载脚本
__webpack_require__.l(url, loadingEnded, "chunk-" + chunkId, chunkId);
} else installedChunks[chunkId] = 0;
}
}
};
// JSONP callback,也就是 self.webpackChunk.push 方法
var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
var [chunkIds, moreModules, runtime] = data;
// add "moreModules" to the modules object,
// then flag all "chunkIds" as loaded and fire callback
var moduleId, chunkId, i = 0;
if (chunkIds.some((id) => (installedChunks[id] !== 0))) {
for (moduleId in moreModules) {
if (__webpack_require__.o(moreModules, moduleId)) {
__webpack_require__.m[moduleId] = moreModules[moduleId];
}
}
if (runtime) var result = runtime(__webpack_require__);
}
if (parentChunkLoadingFunction) parentChunkLoadingFunction(data);
for (; i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if (__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
// 加载成功,回调 resolve(),Promise 被 resolve 掉,成功回调
installedChunks[chunkId][0]();
// resolve 以后就会执行 __webpack_require__ 加载模块了
// 到这里也就是说模块的代码拿到了,后面就可以执行使用了
}
installedChunks[chunkId] = 0;
}
}
var chunkLoadingGlobal = self["webpackChunk"] = self["webpackChunk"] || [];
chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));
当使用code splitting后,会存在以下变量:
__webpack_require__.m
:维护所有模块的数组__webpack_require__.g
:全局变量,一般为 globalThis__webpack_require__.p
:publicPath__webpack_require__.u
:获取某个 chunk 的脚本路径,注意,这里的 chunk 脚本的地址被写死,意味着每当 chunk 的文件名发生改变,运行时代码也会发生改变,这将破坏缓存!__webpack_require__.e
:加载某个 chunk__webpack_require__.f
:储存加载模块函数的对象__webpack_require__.l
:加载某个 chunk 的脚本,加载成功后进行 JSONP Callback__webpack_require__.nc
:script 脚本的 nonce 属性,用以配置 scp
总结
可以看到,webpack 处理 code splitting 使用的是 JSONP + Promise 的方式实现的。 当然,对于支持模块化的现代浏览器,可以不使用 JSONP 的方式加载,webpack 配置如下👇
function f2() {
return webpack({
entry: './index.js',
mode: 'none',
output: {
filename: 'main.[contenthash].js',
chunkFilename: '[name].chunk.[contenthash].js',
path: path.resolve(__dirname, 'dist/import'),
clean: true,
chunkLoading: 'import',
chunkFormat: 'module'
}
})
}
f2().run((err, stat) => {
console.log('🚀 构建完成~')
})
构建出来的产物基本上与JSONP 的形式差不多,区别是直接使用的import()
进行动态加载chunk文件。如果你觉得JSONP的形式比较难以理解,也可以尝试使用这种形式来调试产物代码。
参考文章: q.shanyue.tech/engineering…
参与山月哥哥的训练营涨姿势多多~
转载自:https://juejin.cn/post/7142815443630178335