随着产品的不断演化发展,整个应用常常不可避免的变得越来越庞大,此时首屏渲染时间或者首次加载时间都会加长。然后性能优化就会被排上日程。在众多的性能优化方案中,对模块做按需加载,无疑是最有效的方法之一。使用 webpack 的 import() 方法即可轻松实现模块的按需加载。

我们先使用一个最简单的示例来对 webpack 如何实现模块的按需加载有一个整体的认识。

最简单的示例

第一步:定义一个需要按需加载的模块 a.js

  1. export default function a() {
  2. console.log('我是模块 a');
  3. }

第二步:在主模块(index.js)中引入模块 a

  1. import('./a').then(({ default: a }) => {
  2. console.log(a);
  3. });

第三步:使用如“npm run start”(不同的配置可能略有不同)启动服务
笔者在这里启动了 webpack-dev-server ,使用 localhost 浏览可以看下在浏览器的 Console 版面输出一下内容:
image.png
然后在浏览器的 Network 版面,使用 JS 筛选后会看到加载了两个 js 文件。一个是 index.js (主模块),另一个是
0.async.js (按需加载文件)。如此就轻松实现了模块的按需加载,无特殊需求无需修改 webpack 配置,直接用即可。如何在 React 中使用 Component 的按需加载,请参阅 React.Lazy 方法的使用

注:载入的js文件名,会因 webpack 的配置而有所不同。

我们的需求总是千变万化,基本的使用方法常常无法满足我们产品经理丰富的想象力。下面带大家一起看看 import() 还有什么更有趣的用法,然后可以跟产品经理自信满满的说“OK,没问题”。

import() 使用方法

动态文件名(路径)

前面的示例采用的是静态文件名(‘./a’,没有变量),但现实需求中常常需要使用动态文件名。比如页面加载的模块是可配置的,配置结果由服务端返回。庆幸的是,webpack 支持动态文件名。但在使用时,我们需要注意两点:
一、 至少需要部分文件的路径信息
webpack 不支持完全的动态文件名,也就是说使用下面语句编译后会出现异常

  1. const path = './a';
  2. import(path).then(({ default: a }) => {
  3. console.log(a);
  4. });

服务请求找不到 ‘./a’ 模块:
image.png
编译出现以下警告信息,编译结果中缺少按需请求文件 “0.async.js”:
image.png
不支持完全的动态文件名的原因是,webpack 的工作原理是对文件进行静态扫描,然后根据一定规则处理的。webpack 在扫描到“import()”语法时,会将变量转换成正则表达式的“.*”,然后根据这个规则匹配文件名,对匹配上的文件独立 chunk 输出。如果文件路径名只有一个变量,那么就是匹配目录下的所有文件,这明显是不合理的,所以 webpack 直接就输出了 WARNING 并且不做处理。

二、动态文件名规则匹配到的文件必须是可能被使用到的
有了前面的解释做铺垫,那么理解这条注意点就容易得多了,我们顺着前面的内容继续往下说。如果扫描到的语句是 import(./locale/${language}.json) ,那么 ‘./locale/‘ 目录下的 .json 文件编译后都会生成一个独立的按需请求文件。也就是如果匹配生成的文件不会被使用到,那么就浪费了。所以我们写文件名,将变量转换成“.*”后所匹配到的文件都必须是可能被使用到的。

配置编译后的文件名

通常情况下使用按需加载模块是不用做配置的,但有些时候我们可能对编译生成的文件名有所要求(比如为了方便识别),这时我们就可能需要调整一些配置了。

在 webpack 中,可以通过 output 的 chunkFileName 子项来调整输出的文件名。chunkFileName 的命名方式是和 filename 是一样的,但为了保证输出文件名的唯一性,推荐使用 [name]、[id] 或 [chunkhash] 其中之一的变量。这里的 [name] 通常情况下和 [id] 表现一直,只有在 import 设置 webpackChunkName 才表现不一致。定义方式如下:

  1. import(
  2. // 通过定义 webpackChunkName 可以调整 [name] 值
  3. /* webpackChunkName: "a-async" */
  4. // webpackMode 可同选择的值有多个,有兴趣的小伙伴请自行前往帮助文档进一步了解:
  5. // https://www.webpackjs.com/api/module-methods/#import-
  6. /* webpackMode: "lazy" */
  7. './a').then(({ default: a }) => {
  8. console.log(a);
  9. });

这时再配合下面 chunkFileName 的配置内容,就能输出 a-async.js 文件名了,如下:

  1. const path = require('path');
  2. module.exports = {
  3. entry: './src/index.js',
  4. output: {
  5. filename: 'bundle.js',
  6. path: path.resolve(__dirname, 'dist'),
  7. chunkFilename: `[name].js`,
  8. }
  9. };

如果没有配置 chunkFilename,将根据 filename 导出按需加载文件名的规则。具体规则如下:

  1. 如果 filename 规则可以保证生成的文件名唯一,则按照 filename 的规则生成;
  2. 如果 filename 规则不能保证生成的文件名唯一,则会在 filename 规则前统一加上“[id].”来保证唯一

例如配置内容如下:

  1. const path = require('path');
  2. module.exports = {
  3. entry: './src/index.js',
  4. output: {
  5. filename: 'bundle.js',
  6. path: path.resolve(__dirname, 'dist'),
  7. }
  8. };

那么实际生成按需加载的文件名为 [id].bundle.js

如何实现按需加载

webpack 扫描源文件识别 import/export ,再根据识别结果重新打包组装生成新的文件,新生成的文件代码就已经按照浏览器能识别的方式重新组装了。所以想要弄清楚 import() 是如何实现按需加载的,那么只要阅读 weipack 处理之后生成的代码即可。现在我们从按需加载 ‘./a’ 文件开始理解:

  1. /*!**********************!*\
  2. !*** ./src/index.js ***!
  3. \**********************/
  4. /*! dynamic exports provided */
  5. /*! all exports used */
  6. /***/ (function(module, exports, __webpack_require__) {
  7. __webpack_require__.e/* import() */(0/*! a-async */).then(__webpack_require__.bind(null, /*! ./a */ 82)).then(function (_ref) {
  8. var a = _ref.default;
  9. console.log(a);
  10. });
  11. /***/ })

上面代码的含义是:

  1. webpack_require.e 对应 import() 方法,即异步加载的主体方法;参数是 0 对应 a-async 这个chunk
  2. 异步加载之后首先执行 webpack_require 方法,第一个入参是 82 对应 ‘./a’ 文件。然后执行的就是自己的代码了

接下来一起来看下webpack_require.e 的方法体内容(解释请看中文注释):

  1. /******/ // This file contains only the entry chunk.
  2. /******/ // The chunk loading function for additional chunks
  3. /******/ __webpack_require__.e = function requireEnsure(chunkId) {
  4. /******/ var installedChunkData = installedChunks[chunkId];
  5. // 0 表示已经加载成功,无需再做任何处理
  6. /******/ if(installedChunkData === 0) {
  7. /******/ return new Promise(function(resolve) { resolve(); });
  8. /******/ }
  9. /******/
  10. /******/ // a Promise means "currently loading".
  11. // 正在加载中
  12. /******/ if(installedChunkData) {
  13. /******/ return installedChunkData[2];
  14. /******/ }
  15. /******/
  16. /******/ // setup Promise in chunk cache
  17. // 将 promise 对象的相关内容存入 installedChunks[chunkId],待后面使用
  18. /******/ var promise = new Promise(function(resolve, reject) {
  19. /******/ installedChunkData = installedChunks[chunkId] = [resolve, reject];
  20. /******/ });
  21. /******/ installedChunkData[2] = promise;
  22. /******/
  23. /******/ // start chunk loading
  24. // 生成一个 script 标签,用于异步加载 js 文件
  25. /******/ var head = document.getElementsByTagName('head')[0];
  26. /******/ var script = document.createElement('script');
  27. /******/ script.type = "text/javascript";
  28. /******/ script.charset = 'utf-8';
  29. /******/ script.async = true;
  30. /******/ script.timeout = 120000;
  31. /******/
  32. /******/ if (__webpack_require__.nc) {
  33. /******/ script.setAttribute("nonce", __webpack_require__.nc);
  34. /******/ }
  35. // __webpack_require__.p 就是 __webpack_public_path__ 对应的地址
  36. /******/ script.src = __webpack_require__.p + "" + ({"0":"a-async"}[chunkId]||chunkId) + ".async.js";
  37. // 超时之后执行 onScriptComplete
  38. /******/ var timeout = setTimeout(onScriptComplete, 120000);
  39. /******/ script.onerror = script.onload = onScriptComplete;
  40. /******/ function onScriptComplete() {
  41. /******/ // avoid mem leaks in IE.
  42. /******/ script.onerror = script.onload = null;
  43. /******/ clearTimeout(timeout);
  44. /******/ var chunk = installedChunks[chunkId];
  45. // 如果文件加载成功,chunk就被设置为 0;后面只处理了加载失败的情况
  46. /******/ if(chunk !== 0) {
  47. /******/ if(chunk) {
  48. /******/ chunk[1](new Error('Loading chunk ' + chunkId + ' failed.'));
  49. /******/ }
  50. /******/ installedChunks[chunkId] = undefined;
  51. /******/ }
  52. /******/ };
  53. /******/ head.appendChild(script);
  54. /******/
  55. /******/ return promise;
  56. /******/ };

动态创建的 script 标签生成加载成功后是先执行标签对应的文件内容,然后再执行 onload 事件的。所以 onScriptComplete 主要处理的是加载失败的情况。

script 标签对应的 ‘./a’ 文件内容:

  1. webpackJsonp([0],{
  2. /***/ 34:
  3. /*!******************!*\
  4. !*** ./src/a.js ***!
  5. \******************/
  6. /*! exports provided: default */
  7. /*! all exports used */
  8. /***/ (function(module, __webpack_exports__, __webpack_require__) {
  9. "use strict";
  10. Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
  11. /* harmony export (immutable) */ __webpack_exports__["default"] = a;
  12. console.log('加载文件 a');
  13. function a() {
  14. console.log('我是模块 a');
  15. }
  16. /***/ })
  17. });

‘./a’ 文件一进来就执行了 webpackJsonp 方法,并将自身文件内容作为第二个入参值传入。webpackJsonp 方法的定义如下:

  1. /******/ window["webpackJsonp"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {
  2. /******/ // add "moreModules" to the modules object,
  3. /******/ // then flag all "chunkIds" as loaded and fire callback
  4. /******/ var moduleId, chunkId, i = 0, resolves = [], result;
  5. /******/ for(;i < chunkIds.length; i++) {
  6. /******/ chunkId = chunkIds[i];
  7. /******/ if(installedChunks[chunkId]) {
  8. /******/ resolves.push(installedChunks[chunkId][0]);
  9. /******/ }
  10. /******/ installedChunks[chunkId] = 0;
  11. /******/ }
  12. /******/ for(moduleId in moreModules) {
  13. /******/ if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
  14. /******/ modules[moduleId] = moreModules[moduleId];
  15. /******/ }
  16. /******/ }
  17. /******/ if(parentJsonpFunction) parentJsonpFunction(chunkIds, moreModules, executeModules);
  18. /******/ while(resolves.length) {
  19. /******/ resolves.shift()();
  20. /******/ }
  21. /******/
  22. /******/ };

webpackJsonp 方法依次做了三件事情:

  1. 将 installedChunks[chunkId] 设置为0,标识加载成功 (L5-11)
  2. 执行 ‘./a’ 文件内的内容 (L12-16)
  3. 执行异步加下文件后的内容 (L18-20)

至此整个加载过程就完成了。

异常情况 Webpack 的表现

上面我们看的是最最简单的按需加载情况,如果一个公共模块即被主文件引用了,又被异步加载模块引用时 webpack 是如何处理的呢?请看下面的示例:
模块 a, 同时被模块 b 和 c 引用,b 以同步方式被主文件引入,c 以异步加载的形式被引入。

  1. // 主文件内容 index.js
  2. import b from './b';
  3. b();
  4. import(
  5. /* webpackChunkName: "c-async" */
  6. /* webpackMode: "lazy" */
  7. './c').then(({ default: c }) => {
  8. console.log('异步加载 c', c);
  9. });
  10. // b 模块内容 b.js
  11. import a from './a';
  12. export default function b() {
  13. a();
  14. console.log('我是模块 b');
  15. }
  16. // c 模块内容 c.js
  17. import a from './a';
  18. export default function c() {
  19. a();
  20. console.log('我是模块 c');
  21. }
  22. // a 模块内容 a.js
  23. console.log('加载文件 a');
  24. export default function a() {
  25. console.log('我是模块 a');
  26. }

执行结果:
image.png
我们看到 “加载文件 a” 只被输出了一次,也就是 a 模块只被加载了一次,符合预期。我们查阅 webpack 生成的模块 b 和 模块 c 的代码:

  1. /* 83 */
  2. /*!******************!*\
  3. !*** ./src/b.js ***!
  4. \******************/
  5. /*! exports provided: default */
  6. /*! exports used: default */
  7. /***/ (function(module, __webpack_exports__, __webpack_require__) {
  8. "use strict";
  9. /* harmony export (immutable) */ __webpack_exports__["a"] = b;
  10. /* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__a__ = __webpack_require__(/*! ./a */ 34);
  11. function b() {
  12. Object(__WEBPACK_IMPORTED_MODULE_0__a__["a" /* default */])();
  13. console.log('我是模块 b');
  14. }
  15. /***/ })
  16. /***/ 84:
  17. /*!******************!*\
  18. !*** ./src/c.js ***!
  19. \******************/
  20. /*! exports provided: default */
  21. /*! all exports used */
  22. /***/ (function(module, __webpack_exports__, __webpack_require__) {
  23. "use strict";
  24. Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
  25. /* harmony export (immutable) */ __webpack_exports__["default"] = c;
  26. /* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__a__ = __webpack_require__(/*! ./a */ 34);
  27. function c() {
  28. Object(__WEBPACK_IMPORTED_MODULE_0__a__["a" /* default */])();
  29. console.log('我是模块 c');
  30. }
  31. /***/ })

上面两段代码我们很容易在第11和第32找到加载模块 a 都使用了 webpack_require,然后我们找到 _webpack _require 方法体:

  1. /******/ // The require function
  2. /******/ function __webpack_require__(moduleId) {
  3. /******/
  4. /******/ // Check if module is in cache
  5. /******/ if(installedModules[moduleId]) {
  6. /******/ return installedModules[moduleId].exports;
  7. /******/ }
  8. /******/ // Create a new module (and put it into the cache)
  9. /******/ var module = installedModules[moduleId] = {
  10. /******/ i: moduleId,
  11. /******/ l: false,
  12. /******/ exports: {},
  13. /******/ hot: hotCreateModule(moduleId),
  14. /******/ parents: (hotCurrentParentsTemp = hotCurrentParents, hotCurrentParents = [], hotCurrentParentsTemp),
  15. /******/ children: []
  16. /******/ };
  17. /******/
  18. /******/ // Execute the module function
  19. /******/ modules[moduleId].call(module.exports, module, module.exports, hotCreateRequire(moduleId));
  20. /******/
  21. /******/ // Flag the module as loaded
  22. /******/ module.l = true;
  23. /******/
  24. /******/ // Return the exports of the module
  25. /******/ return module.exports;
  26. /******/ }

以上代码的含义是:

  1. 如果模块已经加载就直接返回 (L5-7)
  2. 如果模块未加载,则构建模块对象数据,并执行模块内容并输出 (L9-25);注:这里的模块都是同步加载的(已经加载进来),所以没有加载过程。

所以 a 模块被再次执行到时,就直接返回了,不会走到 _webpack _require 方法的第二步(L9)。

以上为 weibpack import() 相关的一些内容,笔者如有表述不当之处请批评指正。