webpack如何通过import函数异步加载模块

前言

否有过这样的经历,当打开一个大型网页的时候,发现它加载得非常慢,甚至卡顿或者崩溃。在现代的前端开发中,随着网页的功能越来越复杂,依赖的模块也越来越多,如果我们将所有的模块都打包到一个文件中,那么就会导致文件过大,加载时间过长,用户体验不佳,甚至影响网页的性能和稳定性。

上篇文章已经讲述了webpack加载同步模块的原理,但是异步加载模块相对更加重要,因为它一般都会涉及到网页的性能,例如:vue的路由懒加载就是利用了异步模块加载逻辑,当用户点击了对应的路由才去加载模块页面。

那么,webpack是如何实现异步加载模块的,它又有哪些原理和技巧呢? 本文将结合最基础的案例详细解答这些问题。

一、webpack异步加载模块

1. 异步加载优势

  1. 代码分割(Code Splitting):Webpack允许将代码拆分为多个块(chunks),并在需要时动态加载这些块。这意味着可以将应用程序划分为更小的模块,只在需要时加载,而不是一次性加载整个应用程序。这可以减少初始加载时间,提高性能。
  1. 动态导入语法:Webpack提供了动态导入语法,例如使用import()函数或require.ensure()函数来异步加载模块。这些函数返回一个Promise,可以使用then方法处理加载成功的回调,或使用catch方法处理加载失败的回调。
  1. 按需加载:通过异步加载模块,可以根据需要加载特定的模块,而不是将所有模块打包到同一个文件中。这样可以减少初始加载时间,并在用户需要时动态加载额外的模块。
  1. 代码并行加载:Webpack可以同时加载多个模块,利用浏览器的并行加载能力,从而加快加载速度。这对于大型应用程序和复杂的依赖关系特别有用。

2. 项目配置和入口代码

(一) webpack.config.js
const path = require("path");
const HtmlWebpackPlugin =  require("html-webpack-plugin");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");

module.exports = {
    mode: "development",
    devtool: "source-map",
    entry: "./src/index.js",
    output: {
        path: path.resolve(__dirname, "./bundle"),
        filename: "[name]_bundle.js",
        // 异步加载模块命名规则
        chunkFilename: "[name]_chunk.js"
    },
    module: {},
    plugins: [
        new HtmlWebpackPlugin({
            template: "./src/index.html",
            filename: "index.html"
        }),
        new CleanWebpackPlugin()
    ]
}
(二) 入口代码
// index.js 入口文件
import(/* webpackChunkName: "header" */'./header.js').then(res => {
    console.log(res);
})

// header.js 异步模块
export default "header-com";
export const header = "header";
(三) 打包后执行结果
  1. 打包后会产生两个包,一个入口文件包一个就是header的异步加载包

webpack如何通过import函数异步加载模块

  1. 执行过程中会去请求header_chunk.js包

webpack如何通过import函数异步加载模块

  1. 浏览器输出结果

webpack如何通过import函数异步加载模块

二、分析webpack异步加载流程

1. 同步模块加载回顾

上篇已经介绍了webpack加载同步模块的过程,并且介绍了webpack是如何转化es模块为commonjs规范的;

这里简单回顾下引入同步模块后webpack打包后的结果以及执行过程

(() => {
    // 模块1: 模块依赖关系
    let modules = ({
        "./header.js": (module) => {
            module.exports = "header-com"
        }
    })

    // 模块2: 依赖记录
    let modules_cache = {};

    // 模块3: require函数引入模块
    function require(moduleId) {
        let cacheModule = modules_cache[moduleId];
        if (cacheModule !== undefined) {
            return cacheModule;
        }
        let module = modules_cache[moduleId] = {
            exports: {}
        }
        modules[moduleId](module, module.exports, require);
        return  module.exports;
    }

    // 扩展require方法
    // 判断属性值是否在对象上
    require.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
    // 给exports属性定义getter方法(es模块处理)
    require.d = (exports, definition) => {
        for (let key in definition) {
            if (require.o(definition, key) && !require.o(exports, key)) {
                Object.defineProperty(exports, key, {
                    enumerable: true,
                    get: definition[key]
                })
            }
        }
    }
    // 标记为es模块
    require.r = (exports) => {
        if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
            Object.defineProperty(exports, Symbol.toStringTag, {value: 'Module'});
        }
        Object.defineProperty(exports, '__esModule', {value: true});
    };


    //  模块4:入口函数执行
    (() => {
        const header = require("./header.js");
        console.log(header);
    })();

})();

2. 异步模块流程解析

通过打包后的结果来分析基本的步骤流程

(一) 分析header_chunk.js结构

这里的self指的就是window,可以看这个脚本实际上就是执行了window上webpackChunkwebpack_s的一个push方法并且传入了这个模块名以及对应的模块依赖关系;

这个脚本一加载就会执行这个方法,所以这是一个JSONP;

(self["webpackChunkwebpack_s"] = self["webpackChunkwebpack_s"] || []).push([["header"], {
    "./src/header.js":
        ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

            __webpack_require__.r(__webpack_exports__);
            __webpack_require__.d(__webpack_exports__, {
                "default": () => (__WEBPACK_DEFAULT_EXPORT__),
                header: () => (/* binding */ header)
            });
            const __WEBPACK_DEFAULT_EXPORT__ = ("header-com");
            const header = "header";
        })
}]);
(二) 入口打包后的执行逻辑
__webpack_require__.e(/*! import() | header */ "header")
  .then(__webpack_require__.bind(__webpack_require__, "./src/header.js"))
  .then(res => {
    console.log(res);
})

通过入口执行逻辑可以分析出大致的步骤:

  1. 执行require.e方法返回一个promise;
  2. 这个promise的结果就是header_chunk.js里模块的依赖关系(通过JSONP获取),并且把它挂载到全局modules模块依赖关系中;
  3. 然后继续返回了一个promise,这个promise里的值就是通过require函数执行header.js模块的依赖关系后获取的;

3. 逐步流程解析

必须先理解入口执行逻辑和header_chunk.js异步模块相关逻辑

// 编译后入口执行逻辑
require.e( "header").then(require.bind(require, "./src/header.js")).then(res => {
    console.log(res);
})

// header_chunk.js文件信息
(self["webpackChunkwebpack_s"] = self["webpackChunkwebpack_s"] || []).push([["header"], {
    "./src/header.js":
        ((module, exports, require) => {
            require.r(exports);
            require.d(exports, {
                "default": () => (__WEBPACK_DEFAULT_EXPORT__),
                header: () => (header)
            });
            const __WEBPACK_DEFAULT_EXPORT__ = ("header-com");
            const header = "header";
        })
}]);
(一) require.e方法

可以看出它会返回一个Promise, 但是执行过程中会进入require.f.j内部方法中。这里源码就是这么定义的,只需要了解因为属性无法通过前端工具(Terset)进行压缩,所以webpack通过一个字母的形式减少代码体积,但是字母又不够用所以通过类似命名空间的形式进行扩展;

// webpack打包后入口文件
(() => {
  // ... 

  // 定义e函数
  require.e = (chunkId) => {
    // 记录所有的promise
    let promises = [];
    require.f.j(chunkId, promises);
    return Promise.all(promises);
  }

  // 入口文件执行
  require.e( "header").then(require.bind(require, "./src/header.js")).then(res => {
    console.log(res);
	})
})();
(二) require.f.j方法

这里需要重点理解这个方法;

  1. installedChunks用来记录已经安装模块和待安装模块的resolve和reject函数
  2. 当require.e方法传入了chunkId也就是header字符串和一个装载promise的数组后,j函数执行的时候会处理三个步骤
// webpack打包后入口文件
(() => {
  // ... 

  // 定义j函数相关逻辑
   require.f = {};
  // 理解installedChunks作用
  // 记录已经安装的代码块,值0表示已经安装
  // index:0是因为index为入口文件
   let installedChunks = {
        index: 0,
        // j函数之后的结果,会把chunkId和创建的promise的resolve, reject进行存放
        // header: [resolve, reject] 
    }
    require.f.j = (chunkId, promisesArr) => {
      // 1. 步骤1 创建promise并且把它和chunkId关联记录到installedChunks中
        let promise = new Promise((resolve, reject) => {
            installedChunks[chunkId] = [resolve, reject];
        })
    	// 2. 步骤2 将这个promise传入到require.e函数定义的数组中;
        promisesArr.push(promise);

      // 3. 步骤3 获取当前header_chunk.js文件地址发起请求
        let url = require.p + require.u(chunkId);
        require.l(url);
    }

  // 入口文件执行
  require.e( "header").then(require.bind(require, "./src/header.js")).then(res => {
    console.log(res);
	})
})();
(三) header_chunk.js文件地址获取逻辑

这一步就是开始发起JSONP请求这个异步加载包的脚本,需要定义require.p 获取公共资源路径 、 require.u 获取异步模块名称、 require.l jsonp请求 三个方法

// webpack打包后入口文件
(() => {
  // ... 

  // 地址方法
  // public公共资源访问路径webpack会根配置项自动填充,因为没有配置默认为空;
  require.p = "";
  // 返回文件名称: 因为配置项中是 chunkFilename: "[name]_chunk.js"所以这个函数也是webpack根据配置生成
  require.u = (chunkId) => chunkId + "_chunk.js";
  // 请求方法: 本地地址为 http://127.0.0.1:8080/header_chunk.js
  // 一旦加载到了dom文件中浏览器会立即加载并执行脚本
  require.l = (url) => {
      let script = document.createElement("script");
      script.src = url;
      document.head.appendChild(script);
  }
  
  // 入口文件执行
  require.e( "header").then(require.bind(require, "./src/header.js")).then(res => {
    console.log(res);
	})
})();
(四) 定义JSONP回调函数

上面获取到了异步脚本会立刻执行,所以还需要先给脚本定义一个回调函数;

chunkLoadingGlobal会挂载到window对象中并且它是一个数组重写了push方法为jsonp回调

// webpack打包后入口文件
(() => {
  // ... 
  // jsonp回调
  let webpackJsonpCallback = ([chunkIds, moreModules]) => {
        // chunksIds对应的异步模块的实参["header"]
        // moreModules对应这异步模块的依赖函数 {"./src/header.js": fn(module)}

        // 1. 根据所有的模块ID获取所有对应的resolve函数
        let allAsyncModuleResolve = chunkIds.map(chunkId => installedChunks[chunkId][0]);
        // 2. 标识异步模块加载完毕
        chunkIds.forEach(chunkId => installedChunks[chunkId] = 0);
        // 3. 把所有异步模块加载的依赖关系挂载到全局的modules依赖上
        for (let moduleId in moreModules) {
            modules[moduleId] = moreModules[moduleId];
        }
        // 4. 执行所有的resolve方法, 也就是执行了require.e方法中所有的promises数组
        // 这样它内部的promise.all才会执行才会走到下一个then中去
        allAsyncModuleResolve.forEach(r => r());
    }
  

  // 定义webpack全局变量和重新push方法
  // webpackChunkwebpack_s这个名称是webpack根据项目名称自动生成的
  var chunkLoadingGlobal = self['webpackChunkwebpack_s'] = [];
  chunkLoadingGlobal.push = webpackJsonpCallback;

  // 入口文件执行
  require.e( "header").then(require.bind(require, "./src/header.js")).then(res => {
    console.log(res);
	})
})();
(五) 打印结果

jsonp的回调函数webpackJsonpCallback干了两件重要的事,才能触发下一个then执行

  1. 将异步模块的结果挂载到了全局的modules模块依赖对象上;
  2. 执行所有installedChunks记录的异步模块中resolve方法,是的require.e的Primose.all执行;

.then(require.bind(require, “./src/header.js”))可以把括号里面的看为是一个resolve函数,其实就是执行了require(“./src/header.js”),因为之前的步骤header.js模块函数信息已经挂载到全局中了,所以此时可以获取到header.js模块结果,并且返回到下一个then中;

原文链接:https://juejin.cn/post/7329341975207723046 作者:新新coder

(1)
上一篇 2024年1月30日 上午10:27
下一篇 2024年1月30日 上午10:37

相关推荐

发表回复

登录后才能评论