由同步加载的打包我们发现将不同的打包进一个main.js文件。main.js会集中消耗太多网络资源,导致用户需要等待很久才可以开始与网页交互。

    一般的解决方式是:根据需求降低首次加载文件的体积,在需要时(如切换前端路由器,交互事件回调)异步加载其他文件并使用其中的模块。

    Webpack推荐用ESimport()规范来异步加载模块,我们根据ES规范修改一下入口模块的import方式,让其能够异步加载模块:

    修改文件src/index.js

    1. console.log('Hello webpack!');
    2. window.setTimeout(() => {
    3. import('./utils/math').then(mathUtil => {
    4. console.log('1 + 2: ' + mathUtil.plus(1, 2));
    5. });
    6. }, 2000);

    工具模块(src/utils/math.js)依然不变,在webpack配置里,我们指定一下资源文件的公共资源路径(publicPath),后面的探索过程中会遇到。

    1. const path = require('path');
    2. module.exports = {
    3. mode: 'development',
    4. devtool: 'source-map',
    5. entry: './src/index.js',
    6. output: {
    7. path: path.resolve(__dirname, 'dist'),
    8. publicPath: '/dist/'
    9. }
    10. };

    接着执行打包命令,打包之后出现了两个文件:

    image.png

    dist文件夹中可以看到,除了main.js外,又多了一个0.js文件,./src/utils/math.js模块从main chunk迁移到了0 chunk中。而与同步加载代码不同的是,main chunk中添加了一些用于异步加载的代码,我们概览一下:

    1. // webpackBootstrap
    2. (function(modules) {
    3. // 加载其他 chunk 成功后的 JSONP 回调函数
    4. function webpackJsonpCallback(data) {
    5. ...
    6. }
    7. // 模块缓存对象
    8. var installedModules = {};
    9. // 记录正在加载和已经加载的 chunk 的对象,0表示已经加载成功
    10. // 1是当前模块的编号,已加载完成
    11. var installedChunks = {
    12. main: 0
    13. };
    14. // 拼接 chunk 的请求地址
    15. function jsonpScriptSrc(chunkId) {
    16. // ...
    17. }
    18. // require 函数
    19. function __webpack_require__(moduleId) {
    20. ...
    21. }
    22. // 异步加载 chunk,通过动态添加 script 标签实现,返回封装加载过程的 promise
    23. __webpack_require__.e = function requireEnsure(chunkId) {
    24. ...
    25. }
    26. // 根据配置文件确定的 publicPath
    27. __webpack_require__.p = "/dist/";
    28. ...
    29. /**** JSONP 初始化 ****/
    30. var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
    31. var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
    32. jsonpArray.push = webpackJsonpCallback;
    33. jsonpArray = jsonpArray.slice();
    34. for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
    35. var parentJsonpFunction = oldJsonpFunction;
    36. /**** JSONP 初始化 ****/
    37. // 加载入口模块
    38. return __webpack_require__(__webpack_require__.s = "./src/index.js");
    39. })({
    40. "./src/index.js": (function(module, exports, __webpack_require__) {
    41. document.write('Hello webpack!\n');
    42. window.setTimeout(() => {
    43. __webpack_require__.e(/*! import() */ 0).then(__webpack_require__.bind(null, /*! ./utils/math */ "./src/utils/math.js")).then(mathUtil => {
    44. console.log('1 + 2: ' + mathUtil.plus(1, 2));
    45. });
    46. }, 2000);
    47. })
    48. })

    可以看到**webpackBootstrap**的函数体部分增加了一些内容,参数部分移除了**./src/utils/math.js**模块

    我们可以通俗的理解为webpack内置了对import()语句的支持,当webpack遇到了import()语句时会这样处理:

    • 首先,以./src/utils/math.js为入口新生成一个Chunk文件:0.js
    • 然后,将import()语句转换为__webpack_require__.e /* import() */(0)语句。当代码执行到import()语句时,就相当于执行了__webpack_require__.e /* import() */(0)语句。

    异步加载的核心就是__webpack_require__.e()方法:

    1. __webpack_require__.e = function requireEnsure(chunkId) {
    2. var promises = [];
    3. // JSONP chunk loading for javascript
    4. var installedChunkData = installedChunks[chunkId];
    5. if(installedChunkData !== 0) { // 0 means "already installed".
    6. // a Promise means "currently loading".
    7. if(installedChunkData) {
    8. promises.push(installedChunkData[2]);
    9. } else {
    10. // setup Promise in chunk cache
    11. var promise = new Promise(function(resolve, reject) {
    12. installedChunkData = installedChunks[chunkId] = [resolve, reject];
    13. });
    14. promises.push(installedChunkData[2] = promise);
    15. // start chunk loading
    16. var head = document.getElementsByTagName('head')[0];
    17. var script = document.createElement('script');
    18. var onScriptComplete;
    19. script.charset = 'utf-8';
    20. script.timeout = 120;
    21. if (__webpack_require__.nc) {
    22. script.setAttribute("nonce", __webpack_require__.nc);
    23. }
    24. script.src = jsonpScriptSrc(chunkId);
    25. onScriptComplete = function (event) {
    26. // avoid mem leaks in IE.
    27. script.onerror = script.onload = null;
    28. clearTimeout(timeout);
    29. var chunk = installedChunks[chunkId];
    30. if(chunk !== 0) {
    31. if(chunk) {
    32. var errorType = event && (event.type === 'load' ? 'missing' : event.type);
    33. var realSrc = event && event.target && event.target.src;
    34. var error = new Error('Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')');
    35. error.type = errorType;
    36. error.request = realSrc;
    37. chunk[1](error);
    38. }
    39. installedChunks[chunkId] = undefined;
    40. }
    41. };
    42. var timeout = setTimeout(function(){
    43. onScriptComplete({ type: 'timeout', target: script });
    44. }, 120000);
    45. script.onerror = script.onload = onScriptComplete;
    46. head.appendChild(script);
    47. }
    48. }
    49. return Promise.all(promises);
    50. };

    代码大致逻辑如下:

    • 缓存查找:从缓存installedChunks中查找是否有缓存模块,如果缓存标识为0,则表示模块已加载过,直接返回promise;如果缓存为数组,表示缓存正在加载中,则返回缓存的promise对象。
    • 如果没有缓存,则创建一个promise,并将promiseresolvereject缓存在installedChunks中。
    • 构建一个script标签,appendhead标签中,src指向加载的模块脚本资源,实现动态加载js脚本。
    • 添加script标签onloadonerror事件,如果超时或者模块加载失败,则会调用reject返回模块加载失败异常。
    • 如果模块加载成功,则返回当前模块promise,所以import()返回一个Promise,当文件加载成功时可以在Promisethen方法中获取到logger.js导出的内容。

    在使用import()分割代码后,你的浏览器要支持Promise API才能让代码正常运行,因为import()返回一个Promise,它依赖Promise。对于不原生支持Promise的浏览器,你可以注入Promise polyfill

    script加载完成chunk文件,就开始执行模块代码了,0.js中的代码为:

    1. (window["webpackJsonp"] = window["webpackJsonp"] || []).push([[0], {
    2. "./src/utils/math.js":
    3. (function (module, __webpack_exports__, __webpack_require__) {
    4. "use strict";
    5. __webpack_require__.r(__webpack_exports__);
    6. /* harmony export (binding) */
    7. __webpack_require__.d(__webpack_exports__, "plus", function () {
    8. return plus;
    9. });
    10. const plus = (a, b) => {
    11. return a + b;
    12. };
    13. })
    14. }]);

    这段代码开始执行,把异步加载相关的chunk id与模块传给push函数。而前面已经提到过,window[“webpackJsonp”]数组的push函数已被重写为webpackJsonpCallback函数。

    代码非常好理解,加载成功后立即调用webpackJsonp方法,将chunkId和模块内容传入。这里要分清2个概念,一个是chunkId,一个是moduleId。这个chunkchunkId0,则里面只包含一个modulemoduleId1,则一个chunk里面可以包含多个module

    其实这里的webpackJsonp类似于jsonp中的callback,作用是作为模块加载和执行完成的回调,从而触发importresolve

    具体细看webpackJsonpCallback代码来分析:

    1. function webpackJsonpCallback(data) {
    2. var chunkIds = data[0];
    3. var moreModules = data[1];
    4. // then flag all "chunkIds" as loaded and fire callback
    5. var moduleId, chunkId, i = 0, resolves = [];
    6. // 将 chunk 标记为已加载
    7. for(;i < chunkIds.length; i++) {
    8. chunkId = chunkIds[i];
    9. if(installedChunks[chunkId]) {
    10. resolves.push(installedChunks[chunkId][0]);
    11. }
    12. installedChunks[chunkId] = 0;
    13. }
    14. // 把 "moreModules" 加到 webpackBootstrap 中的 modules 闭包变量中。
    15. for(moduleId in moreModules) {
    16. if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
    17. modules[moduleId] = moreModules[moduleId];
    18. }
    19. }
    20. // parentJsonpFunction 是 window["webpackJsonp"] 的原生 push
    21. // 将 data 加入全局数组,缓存 chunk 内容
    22. if(parentJsonpFunction) parentJsonpFunction(data);
    23. // 执行 resolve 后,加载 chunk 的 promise 状态变为 resolved,then 内的函数开始执行。
    24. while(resolves.length) {
    25. resolves.shift()();
    26. }
    27. };

    走进这个函数中,意味着异步加载的chunk内容已经拿到,这个时候我们要完成两件事,一是让依赖这次异步加载结果的模块继续执行,二是缓存加载结果。

    这里我们先只讨论第一点,我们回忆一下之前__webpack_require__.e的内容,此时chunk还处于「加载中」的状态,也就是说对应的installedChunks[chunkId]的值此时为[resolve, reject, promise]。而进入到这个函数,说明chunk已经加载,但promise还未决议,于是webpackJsonpCallback内部定义了一个resolves变量用来收集installedChunks上的resolve并执行它。

    代码中installedChunks用来记录用户异步模块加载状态的对象。当chunk加载完成后,对应chunkId的值是0。在加载过程中,对应的值是一个数组,数组内保存了promise的相关信息。

    等执行完chunk中的moreModules被合入入口文件的modules中的操作,执行resolve,这样就可以供下一个微任务中的 __webpack_require__同步加载模块。

    我们看webpackBootstrap的参数部分:

    1. ({
    2. "./src/index.js":
    3. (function (module, exports, __webpack_require__) {
    4. console.log('Hello webpack!');
    5. window.setTimeout(() => {
    6. __webpack_require__.e(/*! import() */ 0).then(__webpack_require__.bind(null, "./src/utils/math.js")).then(mathUtil => {
    7. console.log('1 + 2: ' + mathUtil.plus(1, 2));
    8. });
    9. }, 2000);
    10. })
    11. });

    __webpack_require__.e(/*! import() */ 0)返回promise决议后,__webpack_require__.bind(null, "./src/utils/math.js") 可以加载到chunk携带的模块,并返回模块作为下一个微任务函数的入参,接下来就是Webpack Loader翻译过的其他业务代码了。

    所以webpack异步加载的原理是:

    1. webpack打包遇到import()语句时,以参数文件为入口新生成一个chunk文件(而不会打包到一个文件中),文件内容是一个window['webpackJsonp'].webpackJsonpCallback()调用。
    2. 同时将import()语句转换成__webpack_require__.e()语句。webpack启动代码中会增加__webpack_require__.e函数的定义,以及在window['webpackJsonp']下面挂载webpackJsonpCallback函数的定义。
    3. 当执行到import()语句时,就相当于执行__webpack_require__.e()语句,会动态创建一个script标签来实现代码的异步加载,同时对应的chunkIdpromisepending状态。
    4. script资源加载完毕,将异步模块代码加入到modules参数数组中,同时将对应模块的promise决议后,标记chunk完成。最后在下一个微任务中__webapck_require__可以加载到异步模块代码,继续执行。

    执行流程如下图所示:

    image.png