webpack 按需加载模块 import()

12,333 阅读8分钟

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

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

最简单的示例

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

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

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

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

第三步:使用如“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 不支持完全的动态文件名,也就是说使用下面语句编译后会出现异常

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

服务请求找不到 './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 才表现不一致。定义方式如下:

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

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

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
    chunkFilename: `[name].js`,
  }
};

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

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

例如配置内容如下:

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  }
};

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

如何实现按需加载

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

  /*!**********************!*\
    !*** ./src/index.js ***!
    \**********************/
  /*! dynamic exports provided */
  /*! all exports used */
  /***/ (function(module, exports, __webpack_require__) {

  __webpack_require__.e/* import() */(0/*! a-async */).then(__webpack_require__.bind(null, /*! ./a */ 82)).then(function (_ref) {
    var a = _ref.default;
    console.log(a);
  });
  
  /***/ })

上面代码的含义是:

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

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

/******/ 	// This file contains only the entry chunk.
/******/ 	// The chunk loading function for additional chunks
/******/ 	__webpack_require__.e = function requireEnsure(chunkId) {
/******/ 		var installedChunkData = installedChunks[chunkId];
  					// 0 表示已经加载成功,无需再做任何处理
/******/ 		if(installedChunkData === 0) {
/******/ 			return new Promise(function(resolve) { resolve(); });
/******/ 		}
/******/
/******/ 		// a Promise means "currently loading".
  					// 正在加载中
/******/ 		if(installedChunkData) {
/******/ 			return installedChunkData[2];
/******/ 		}
/******/
/******/ 		// setup Promise in chunk cache
  					// 将 promise 对象的相关内容存入 installedChunks[chunkId],待后面使用
/******/ 		var promise = new Promise(function(resolve, reject) {
/******/ 			installedChunkData = installedChunks[chunkId] = [resolve, reject];
/******/ 		});
/******/ 		installedChunkData[2] = promise;
/******/
/******/ 		// start chunk loading
  					// 生成一个 script 标签,用于异步加载 js 文件
/******/ 		var head = document.getElementsByTagName('head')[0];
/******/ 		var script = document.createElement('script');
/******/ 		script.type = "text/javascript";
/******/ 		script.charset = 'utf-8';
/******/ 		script.async = true;
/******/ 		script.timeout = 120000;
/******/
/******/ 		if (__webpack_require__.nc) {
/******/ 			script.setAttribute("nonce", __webpack_require__.nc);
/******/ 		}
  					// __webpack_require__.p 就是 __webpack_public_path__ 对应的地址
/******/ 		script.src = __webpack_require__.p + "" + ({"0":"a-async"}[chunkId]||chunkId) + ".async.js";
  					// 超时之后执行 onScriptComplete
/******/ 		var timeout = setTimeout(onScriptComplete, 120000);
/******/ 		script.onerror = script.onload = onScriptComplete;
/******/ 		function onScriptComplete() {
/******/ 			// avoid mem leaks in IE.
/******/ 			script.onerror = script.onload = null;
/******/ 			clearTimeout(timeout);
/******/ 			var chunk = installedChunks[chunkId];
  						// 如果文件加载成功,chunk就被设置为 0;后面只处理了加载失败的情况
/******/ 			if(chunk !== 0) {
/******/ 				if(chunk) {
/******/ 					chunk[1](new Error('Loading chunk ' + chunkId + ' failed.'));
/******/ 				}
/******/ 				installedChunks[chunkId] = undefined;
/******/ 			}
/******/ 		};
/******/ 		head.appendChild(script);
/******/
/******/ 		return promise;
/******/ 	};

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

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

webpackJsonp([0],{

/***/ 34:
/*!******************!*\
  !*** ./src/a.js ***!
  \******************/
/*! exports provided: default */
/*! all exports used */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
/* harmony export (immutable) */ __webpack_exports__["default"] = a;
console.log('加载文件 a');
function a() {
  console.log('我是模块 a');
}

/***/ })

});

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

/******/ 	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]) {
/******/ 				resolves.push(installedChunks[chunkId][0]);
/******/ 			}
/******/ 			installedChunks[chunkId] = 0;
/******/ 		}
/******/ 		for(moduleId in moreModules) {
/******/ 			if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
/******/ 				modules[moduleId] = moreModules[moduleId];
/******/ 			}
/******/ 		}
/******/ 		if(parentJsonpFunction) parentJsonpFunction(chunkIds, moreModules, executeModules);
/******/ 		while(resolves.length) {
/******/ 			resolves.shift()();
/******/ 		}
/******/
/******/ 	};

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

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

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

异常情况 Webpack 的表现

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

// 主文件内容 index.js
import b from './b';

b();

import(
  /* webpackChunkName: "c-async" */
  /* webpackMode: "lazy" */
  './c').then(({ default: c }) => {
  console.log('异步加载 c', c);
});


// b 模块内容 b.js
import a from './a';

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


// c 模块内容 c.js
import a from './a';

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


// a 模块内容 a.js
console.log('加载文件 a');

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


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

  /* 83 */
  /*!******************!*\
    !*** ./src/b.js ***!
    \******************/
  /*! exports provided: default */
  /*! exports used: default */
  /***/ (function(module, __webpack_exports__, __webpack_require__) {
  
  "use strict";
  /* harmony export (immutable) */ __webpack_exports__["a"] = b;
  /* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__a__ = __webpack_require__(/*! ./a */ 34);
  
  function b() {
    Object(__WEBPACK_IMPORTED_MODULE_0__a__["a" /* default */])();
    console.log('我是模块 b');
  }
  
  /***/ })
  
  
/***/ 84:
/*!******************!*\
  !*** ./src/c.js ***!
  \******************/
/*! exports provided: default */
/*! all exports used */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
/* harmony export (immutable) */ __webpack_exports__["default"] = c;
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__a__ = __webpack_require__(/*! ./a */ 34);

function c() {
  Object(__WEBPACK_IMPORTED_MODULE_0__a__["a" /* default */])();
  console.log('我是模块 c');
}

/***/ })

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

/******/ 	// The require function
/******/ 	function __webpack_require__(moduleId) {
/******/
/******/ 		// Check if module is in cache
/******/ 		if(installedModules[moduleId]) {
/******/ 			return installedModules[moduleId].exports;
/******/ 		}
/******/ 		// Create a new module (and put it into the cache)
/******/ 		var module = installedModules[moduleId] = {
/******/ 			i: moduleId,
/******/ 			l: false,
/******/ 			exports: {},
/******/ 			hot: hotCreateModule(moduleId),
/******/ 			parents: (hotCurrentParentsTemp = hotCurrentParents, hotCurrentParents = [], hotCurrentParentsTemp),
/******/ 			children: []
/******/ 		};
/******/
/******/ 		// Execute the module function
/******/ 		modules[moduleId].call(module.exports, module, module.exports, hotCreateRequire(moduleId));
/******/
/******/ 		// Flag the module as loaded
/******/ 		module.l = true;
/******/
/******/ 		// Return the exports of the module
/******/ 		return module.exports;
/******/ 	}

以上代码的含义是:

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

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

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