webpack import() 按需加载模块
前言
Webpack
被广泛应用于项目工程来编译 JavaScript
模块,理解 webpack 打包同步模块import
和异步模块import()
在浏览器上的运行原理,可以更好的帮助我们理解 JS 模块化理念,以及应对打包技术上的疑难问题。
阅读完本篇文章,你将收获:
import()
动态导入模块;路由懒加载
实战应用;同步模块
和异步模块
在浏览器上的运行机制;- 理解
webpack runtime
。
一、ES2020 import()
通常我们都采用 ESModule import
方式去引入模块。import 命令会被 JavaScript 引擎静态分析,先于模块内的其他语句执行,这要求 import 命令只能用在模块顶层。
显然,这样的设计无法像 Node require
方法那样在运行时加载模块。
在 ES2020提案
中引入了 import()
函数,支持动态加载模块。不过不同于 Node require 的同步加载,它是一个异步加载,返回一个 Promise
对象,语法结构如下:
import('path/to/module') -> Promise
import()
适用场景通常是在 条件加载
(if 语句中)和 动态加载
(需要的时候加载)。
而在 webpack
中,import()
语法可用作代码拆分。import() 的模块以及它引用的所有子模块,会分离到一个单独的 chunk
中。
一般我们会借助 import()
对路由页面进行动态加载,实现页面代码的拆分和按需加载,在一定程度上提升应用的首屏渲染速度。
下面,我们在 React
环境下,借助 webpack + import()
特性实现路由按需加载。
二、按需加载路由
通常,我们会在程序入口定义路由,所使用静态 import
语法大致如下:
import React from 'react';
import { HashRouter, Switch, Route } from 'react-router-dom';
import Home from '@/pages/Home';
const App = () => {
return (
<HashRouter basename="/">
<Switch>
<Route exact path='/' render={props => <Home {...props} />} />
...
</Switch>
</HashRouter>
)
}
现在,我们通过编写一个 高阶组件
,封装 import()
逻辑实现路由组件的动态加载。程序入口路由定义改造如下:
import React from 'react';
import { HashRouter, Switch, Route } from 'react-router-dom';
import lazyComponent from '@/components/HighComponents/LazyComponent';
const App = () => {
return (
<HashRouter basename="/">
<Switch>
...
<Route path='/' component={lazyComponent('home', () => import(/* webpackChunkName: "home" */ '@/pages/home'))} />
</Switch>
</HashRouter>
)
}
lazyComponent
第一个参数是模块名称(用于 cache
),第二参数是一个函数,用于执行并返回 webpakc import()
Promise 对象。具体实现如下:
// src/components/HighComponents/LazyComponent.tsx
import React from 'react';
const lazyCaches: { [key: string]: React.ComponentType<any> } = {};
function lazyComponent(lazyName: string, loadComponent: () => Promise<any>) {
lazyCaches[lazyName] = lazyCaches[lazyName] || (
class AsyncComponent extends React.PureComponent<any, { Component: React.ComponentType<any> | null }> {
constructor(props: any) {
super(props);
this.state = { Component: null };
}
async componentDidMount() {
const { default: Component } = await loadComponent();
this.setState({ Component });
}
render() {
const Component = this.state.Component;
return (
Component ? <Component {...this.props} /> : null
)
}
}
)
return lazyCaches[lazyName];
}
export default lazyComponent;
这样,经过打包后会生成一个 home.chunk.js
文件,在匹配到对应路由时,动态创建 script
标签来加载模块内容。
三、使用注意:
对于刚上手使用的同学,有以下几点需要注意:
3.1、自定义 chunk 名称
默认使用 import(modulePath)
一个模块时,webpack 会将模块 id 作为打包生成的 chunk
名称,在 mode=development 开发环境下可能是文件路径,如:src_pages_home.js。
import()
规范中不允许控制模块的名称或其他属性。不过,webpack 中可以通过注释作为参数来为 chunk
规定文件名称。
如上例我们通过注释 webpackChunkName
为 Home 模块定义的 chunk
名称为 home
。
import(
/* webpackChunkName: "home" */
'@/pages/home'
)
3.2、import() 动态引入未生效
有时候,我们使用 import() 动态导入一个模块,经过打包发现未生成单独的 chunk
文件。原因可能是这个模块被 ESModule import(静态
) 和 import()(动态
)两种导入方式都应用了。
比如,一个路由模块在注册 <Route />
时采用动态 import() 导入,但在这个模块对外暴露了一些变量方法供其他子模块使用,在这些子模块中使用了同步 ESModule import 方式引入,这就造成了 动态 import()
的失效。
所以,如果想要动态导入 import() 一个模块,这个模块就不能再出现被其他模块使用 同步 import
方式导入。
3.3、chunk 在动态加载时出现 404
从上面我们知道,每个路由模块经过 webpack + import()
打包处理后,生成一个独立的 chunk
文件。
chunk
文件的加载方式是通过动态创建 script
标签,资源地址 src 将指向打包目录 build
。
这部分的实现逻辑可以通过分析打包生成的 runtime.js
文件中看到。
在 runtime.js
中,会看到有这样一行代码来定义动态加载 chunk
的路径地址:
var url = __webpack_require__.p + __webpack_require__.u(chunkId);
__webpack_require__.u
执行后得到了chunk
文件相对于build
目录所在的相对路径(home.js)
;__webpack_require__.p
是什么呢?其实它就是__webpack_public_path__
,即 webpack 的publicPath
全局变量。
当你发现动态导入 chunk
时资源 404 了,可能是由于你在程序入口改写了 __webpack_public_path__
的值。
// src/index.js
let { origin, pathname } = window.location;
__webpack_public_path__ = origin + pathname;
经过上面分析,我们大致清楚 webpack 对 import()
的模块生成 chunk
文件后,通过动态创建 script
标签添加到 html
中加载并执行资源(在资源加载成功后,会将 script
标签从 DOM 树上移除)。
下面我们看看在源码中的具体实现。
四、原理分析
接下来我们准备一个简单打包环境,根据打包后的产物分析 import
和 import()
两种方式的实现和差异。
4.1、环境准备
- 依赖安装:
mkdir webpack-import && cd webpack-import && npm init -y && npm install webpack webpack-cli -D
- 入口模块文件:
// src/index.js
import sync from './sync';
console.log('sync module: ', sync);
const button = document.createElement('button');
button.innerText = '动态加载模块';
button.onclick = () => import('./async').then(res => {
console.log('async module: ', res.default)
});
document.body.appendChild(button);
// src/sync.js
console.log('load sync module.');
module.exports = 'sync';
// src/async.js
console.log('load async module.');
module.exports = 'async';
- 添加打包配置:
// webpack.config.js
const path = require('path');
module.exports = () => {
return {
mode: 'development',
devtool: false,
entry: path.resolve(__dirname, 'src/index.js'),
output: {
path: path.resolve(__dirname, 'build'),
filename: '[name].js',
chunkFilename: '[name].chunk.js',
},
}
}
- 添加执行脚本:
// package.json
"scripts": {
"build": "webpack"
},
在示例中,我们在入口文件分别采用 import
和 import()
引入 sync.js
和 async.js
两个模块,执行 npm run build
,打包输出的 assets
如下:
build/main.js
build/src_async_js.chunk.js
其中,src_async_js.chunk.js
是 webpack 对 import('src/async.js')
按需加载模块所生成的 chunk
文件:
// build/src_async_js.chunk.js
// self 在浏览器上代表 Window 全局对象
(self["webpackChunkwebpack_import"] = self["webpackChunkwebpack_import"] || []).push([["src_async_js"], {
"./src/async.js": ((module) => {
console.log('load async module.');
module.exports = 'async';
})
}]);
4.2、同步模块(静态)
对于同步方式引入的模块 sync.js
,和 webpack 运行模块代码
一起打包在了 main.js
中,你看到的代码结构应该如下:
(() => {
// 1、入口文件依赖的同步模块集合对象
var __webpack_modules__ = ({
"./src/sync.js": ((module) => {
console.log('load sync module.');
module.exports = 'sync';
})
});
var __webpack_module_cache__ = {};
// 2、webpack 实现的 **静态** 模块查找方法 - require()
function __webpack_require__(moduleId) {
var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
// Create a new module (and put it into the cache)
var module = __webpack_module_cache__[moduleId] = {
exports: {}
};
// Execute the module function
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
// Return the exports of the module
return module.exports;
}
// ... __webpack_require__ 上扩展的一系列方法
// 3、入口文件的代码
(() => {
var _sync__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/sync.js");
var _sync__WEBPACK_IMPORTED_MODULE_0___default = __webpack_require__.n(_sync__WEBPACK_IMPORTED_MODULE_0__);
console.log('sync module: ', (_sync__WEBPACK_IMPORTED_MODULE_0___default()));
const button = document.createElement('button');
button.innerText = '动态加载模块';
button.onclick = () => __webpack_require__.e("src_async_js").then(__webpack_require__.t.bind(__webpack_require__, "./src/async.js", 23)).then(res => {
console.log('async module: ', res.default)
});
document.body.appendChild(button);
})();
})();
从上面代码中,我们可以得到三点重要信息:
- 对于使用
import
同步引入的模块,Webapck 将其存放在了一个叫__webpack_modules__
的 Map 对象中,key 为文件路径,value 为可执行函数,函数体内容就是模块中导出的代码; - webpack 实现了一个类似于
Node require()
模块查找方法__webpack_require__
,查找来源其实就是存储import
模块的__webpack_modules__
对象; - 在最下方会直接执行入口文件
src/index.js
中的内容,调用__webpack_require__
方法读取到sync.js
模块内容并进行输出。
__webpack_require__.n
是一个封装模块默认导出的一个方法,此外,webpack 为 __webpack_require__
函数身上扩展了许多类似功能方法,称之为 Webpack 运行时代码
。
(() => {
__webpack_require__.n = (module) => {
return module && module.__esModule ?
() => (module['default']) :
() => (module);
};
})();
此时,你将代码运行在 html
文件,会看到输出打印:sync module: sync
,同步模块的实现原理如此简单。
4.3、异步模块(动态)
当我们点击按钮时,会执行加载异步模块 async.js
。从打包后的产物可以看出:我们代码中的 import('./async')
被编译成了:
__webpack_require__.e("src_async_js").then(__webpack_require__.t.bind(__webpack_require__, "./src/async.js", 23))
接下来我们从 __webpack_require__.e
作为突破口看一看 webpack
对动态模块的处理实现。
(() => {
var installedChunks = {
"main": 0
};
__webpack_require__.f = {};
__webpack_require__.f.j = (chunkId, promises) => { ... }
__webpack_require__.e = (chunkId) => {
return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
__webpack_require__.f[key](chunkId, promises);
return promises;
}, []));
};
})();
__webpack_require__.e
返回一个 Promise.all
,其目的是为后续能够调用 .then
做铺垫。
从上面代码可以看出:__webpack_require__.f
下只有一个 j
方法(JSONP),用于动态加载 js
脚本,我们来看看具体实现。
__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop));
var installedChunks = {
"main": 0
};
__webpack_require__.f.j = (chunkId, promises) => {
var installedChunkData = __webpack_require__.o(installedChunks, chunkId) ? installedChunks[chunkId] : undefined;
// 1、避免重复加载。0 说明加载完成,避免重复创建 HTTP 请求加载
if (installedChunkData !== 0) {
// 2、installedChunkData 存在(为一个 promise),说明正在加载中
if (installedChunkData) {
promises.push(installedChunkData[2]);
} else {
// 3、初次加载,创建一个 promise 实例
var promise = new Promise((resolve, reject) => (installedChunkData = installedChunks[chunkId] = [resolve, reject]));
promises.push(installedChunkData[2] = promise);
// 根据 publicPath 和 chunk 输入路径拼接得到 chunk 的加载 url
var url = __webpack_require__.p + __webpack_require__.u(chunkId);
var error = new Error();
var loadingEnded = (event) => {
if (__webpack_require__.o(installedChunks, chunkId)) {
// 通常,如果 chunk 加载成功,installedChunkData 将得到一个 0
installedChunkData = installedChunks[chunkId];
if (installedChunkData !== 0) installedChunks[chunkId] = undefined;
if (installedChunkData) {
// ... error 属性信息设置
installedChunkData[1](error); // 执行 reject 更新 Promise 状态
}
}
};
__webpack_require__.l(url, loadingEnded, "chunk-" + chunkId, chunkId);
}
}
}
installedChunks
记录了所有 chunk
是否被成功加载,key
为 chunk
名称,value === 0
说明加载(installed)完成,默认记录了入口文件 main。
从代码可以看出,__webpack_require__.f.j
主要是为 chunk
的加载操作创建一个 Promise
,待 chunk 加载完成之后更新 Promise 状态(resolve、reject)以便后续调用 .then
。
- 如果
chunk
处于加载中,直接返回上次创建的Promise
; - 如果初次加载,为
chunk
创建一个Promise
实例并将resolve
和reject
一起存储在installedChunks
中; - 接着根据
publicPath
和chunk
相对于打包目录的路径拼接得到完整的url
地址; - 最后执行
__webpack_require__.l
去动态创建script
标签去加载和执行chunk
文件中的代码。
__webpack_require__.p
默认取自当前脚本的运行目录(如:http://localhost:52330/webpack-import/build/
):
/* webpack/runtime/publicPath */
(() => {
var scriptUrl = document.currentScript.src;
scriptUrl = scriptUrl.replace(/#.*$/, "").replace(/\?.*$/, "").replace(/\/[^\/]+$/, "/");
__webpack_require__.p = scriptUrl;
})();
__webpack_require__.u
用于获取 chunk
文件名称:
/* webpack/runtime/get javascript chunk filename */
(() => {
__webpack_require__.u = (chunkId) => {
return "" + chunkId + ".chunk.js";
};
})();
有了 chunk
文件完整的 url 地址,我们来看 __webpack_require__.l
(load script)是如何加载的:
/* webpack/runtime/load script */
(() => {
var inProgress = {};
var dataWebpackPrefix = "webpack-import:";
__webpack_require__.l = (url, done, key, chunkId) => {
var script, needAttach;
// 1、如果 DOM 树上提供了加载此 chunk 的 script 标签,则无需再创建
if (key !== undefined) {
var scripts = document.getElementsByTagName("script");
for (var i = 0; i < scripts.length; i++) {
var s = scripts[i];
if (s.getAttribute("src") == url || s.getAttribute("data-webpack") == dataWebpackPrefix + key) { script = s; break; }
}
}
// 2、创建 script 标签
if (!script) {
needAttach = true;
script = document.createElement('script');
script.setAttribute("data-webpack", dataWebpackPrefix + key);
script.src = url;
}
inProgress[url] = [done];
var onScriptComplete = (prev, event) => {
var doneFns = inProgress[url];
delete inProgress[url];
script.parentNode && script.parentNode.removeChild(script); // 加载完成,从 DOM 树上进行 remove
doneFns && doneFns.forEach((fn) => (fn(event)));
if (prev) return prev(event);
}
script.onerror = onScriptComplete.bind(null, script.onerror);
script.onload = onScriptComplete.bind(null, script.onload);
// 3、添加到 DOM 树上
needAttach && document.head.appendChild(script);
};
})();
这样一看,__webpack_require__.l
就是创建 script 标签并添加到 DOM 树上,然后代码线路就结束中断了。但是,为 chunk
所创建的 Promise 实例 resolve
状态何时被执行呢?
我们猜想 resolve
的执行时机应该是在 script
标签加载完成之后,不过在上面的 loadingEnded
方法中却只看到了 reject
的执行时机。
其实,执行 resolve
的时机发生在:加载 script 标签成功后浏览器去执行 chunk
里面的文件内容,我们来分析 src_async_js.chunk.js
:
(self["webpackChunkwebpack_import"] = self["webpackChunkwebpack_import"] || []).push([["src_async_js"], {
"./src/async.js": ((module) => {
console.log('load async module.');
module.exports = 'async';
})
}]);
self
代表 Window
对象,Window 上有个属性 webpackChunkwebpack_import
(数组)调用了 push
方法添加一个 Map 对象(第二个元素),这里和同步模块 Map 对象 __webpack_modules__
结构一致。
不过,这就是一个常见的数组调用 push 方法,没发现有啥不同呀,其实关键的一步就出在 push
方法上。
在执行 main.js
过程中,会加载这样一段代码来重写 webpackChunkwebpack_import.push
方法:
/* webpack/runtime/jsonp chunk loading */
(() => {
var installedChunks = {
"main": 0
};
__webpack_require__.f.j = (chunkId, promises) => { ... };
var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
...
}
var chunkLoadingGlobal = self["webpackChunkwebpack_import"] = self["webpackChunkwebpack_import"] || [];
chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));
})();
webpackChunkwebpack_import
的 push
方法原来被改写成了 webpackJsonpCallback
,而这个方法所接收的 data
属性正是 src_async_js.chunk.js
文件中看到的 push 所添加的内容。
var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
var [chunkIds, moreModules, runtime] = data;
var moduleId, chunkId, i = 0;
// 1、判断模块是第一次被加载,将 moduleId 作为 key,模块内容作为 value 赋值给了 __webpack_require__.m
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__);
}
// 2、调用数组原生 push 方法,向 webpackChunkwebpack_import 中添加数据
if (parentChunkLoadingFunction) parentChunkLoadingFunction(data);
// 3、执行 chunk promise 的 resolve,并标记 chunk 加载完成
for (; i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if (__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
installedChunks[chunkId][0](); // 执行 resolve 更改状态
}
installedChunks[chunkId] = 0; // chunk 加载完成
}
}
这里有很关键的一行代码:__webpack_require__.m[moduleId] = moreModules[moduleId]
,其中 moreModules[moduleId]
就是 async.js
的模块内容:
{ // moreModules
"./src/async.js": ((module) => {
console.log('load async module.');
module.exports = 'async';
})
}
那么 __webpack_require__.m
又是什么呢?为什么要将 异步模块 信息存储在它之中呢?
其实它就是 同步模块 的 Map 对象 __webpack_modules__
,在 main.js
中可以看到这样一行代码:
// expose the modules object (__webpack_modules__)
__webpack_require__.m = __webpack_modules__;
可见,到这一步,异步模块虽然通过 script
标签动态加载,但模块里的内容其实并未执行,只是存放在了 __webpack_modules__
集合之中,最后,调用了 resolve 来更改 Promise 的状态。
installedChunks[chunkId][0](); // 执行 resolve 更改状态
更改了 chunk Promise
的状态,我们最终将模块的执行定位在了 .then
之后的 __webpack_require__.t
之中:
__webpack_require__.e("src_async_js").then(__webpack_require__.t.bind(__webpack_require__, "./src/async.js", 23))
__webpack_require__.t = function (value, mode) {
if (mode & 1) value = this(value);
if (mode & 8) return value;
if (typeof value === 'object' && value) {
if ((mode & 4) && value.__esModule) return value;
if ((mode & 16) && typeof value.then === 'function') return value;
}
var ns = Object.create(null);
Object.defineProperty(exports, '__esModule', { value: true });
Object.defineProperty(ns, 'default', { enumerable: true, get: () => (value)});
return ns;
};
- 首先是
bind __webpack_require__
,那在函数之中 this 就指向了__webpack_require__
方法; - 在函数内看到
this(value)
其实就是执行__webpack_require__(./src/async.js)
去__webpack_modules__
中查找模块并执行代码; - 最后创建一个
esModule
对象并设置default
属性来返回模块导出内容。
这样,我们就可以通过 .then
拿到返回的 default 模块导出内容:
.then(res => {
console.log('async module: ', res.default)
});
4.4、小结
到这里,异步模块的加载流程就走完了,我们总结一下:
- 既然是异步加载模块,肯定需要一个
Promise
在等待加载到模块内容后,执行resolve
来进入后续逻辑; - 根据
publicPath
拼接得到一个完整的chunk url
,动态创建script
标签并加载这个 chunk 文件; - 加载成功后执行
chunk
文件中的代码,进入webpackJsonpCallback
将chunk
文件中存储的模块内容添加到__webpack_modules__
对象中; - 最后采用同步模块的加载方式,通过
__webpack_require__
方法去__webpack_modules__
中读取并执行模块代码。
我们知道,浏览器不能像 Node Fs
模块那样直接读取文件内容,我想这里也是借助 script
标签在浏览器加载到文件内容,最终将模块内容添加到内存对象 __webpack_modules__
中提供使用。
4.5、webpack runtime
另外在这里介绍一下什么是 webpack runtime
。
我们改造一下 webpack 配置:
module.exports = () => {
return {
mode: 'development',
...
optimization: {
runtimeChunk: { // 运行时代码(webpack执行时所需的代码)从主文件中抽离
name: entrypoint => `runtime-${entrypoint.name}`,
},
}
}
}
执行 npm run build 打包后会得到 runtime-main.js
文件,可以看到,__webpack_require__
相关的代码实现,都打包在了这个文件中。
所以,runtime
其实就是 webpack
所实现的一套属于自己的模块化规则的 运行代码,用于串联各个模块之间的引入关系,使其能够正常运行在浏览器环境中。
五、使用总结
1. 合并多个 import() 按需加载模块
我们知道,在真实业务场景中使用 import()
最多的场景是路由懒加载。
但有时我们对路由拆分太细了会造成体验感降低。
比如某个列表页和编辑页它们之间存在相互跳转,如果对它们拆分成两个 import()
js 资源加载模块,在跳转过程中视图会出现白屏切换过程。
因为在跳转期间,浏览器会动态创建 script 标签来加载这个 chunk
文件,在这期间,页面是没有任何内容的。
基于此场景,我们可以将它们生成在一个 chunk
文件中,即将 webpackChunkName
绑定为同名,使用如下:
const ManageConsole = lazyComponent('list', () => import(/* webpackChunkName: "list" */ '@/pages/list'));
const TaskFlow = lazyComponent('edit', () => import(/* webpackChunkName: "list" */ '@/pages/edit'));
... 其他
最后
感谢阅读,如有不足之处,欢迎指正👏。
转载自:https://juejin.cn/post/7119647017009152013