探讨 Webpack import 动态参数的四种方案

本文主要围绕动态 import 的参数做扩展,import 函数导入是一个大家都非常熟悉的功能,我们可以使用它来做各种懒加载优化方案,通过 import 的动态参数可以实现更为复杂的懒加载。

我们可以使用动态表达式来代表文件路径中的一段字符串。

import('./folder/' + 'staicFile' + '.js')
import('./animals/' + dynamicFile + '.js')
复制代码

这里的 dynamicFile 就是 import 的动态参数部分,下文将探讨不同配置的动态参数在 Webpack 中的不同效果。

image.png

动态参数如何影响编译

Webpack 本身是静态模板打包工具,他不会关心运行时到底发生了什么,所以即使我们要用到 import 的动态参数,我们也要从编译时这个角度来思考。

尽管在编译时该动态参数是未知的,但通过使用 import() 带有动态参数的函数,我们仍然可以实现延迟加载

Webpack 不能在运行时再去加载任何模块,但是这个 import 值又必须在运行时已经知晓,那么我们就只能在编译时就找到符合这个动态导入功能可能性的所有值。

我们在这里可以做一个简单的示例,有一个名为 mainFolder 的目录,其中有各种文件:

├── mainFolder
│   ├── file1.js
│   ├── file2.js
│   ├── file3.js
├── index.js
复制代码

每个示例都使用如下 import 函数:

import(`./mainFolder/${fileName}.js`)
复制代码

就这段而言, 每个 ${fileName} 指一个动态部分,默认情况下会被替换为 /.*/。另外 import 里的动态模板表达式也可以有多个动态部分。

我们最终会把提供的参数生成一个可以决定 Webpack 引入那些文件的正则对象。然后我们会从路径中第一段静态部分开始不断遍历,然后读取出来的值都会去和正则匹配,Webpack 在这个读取匹配的过程中有很多种模式可以使用,这部分我们后面再说。

在此示例中,生成的 RegExp对象 /^./.*.js$/mainFolder/ 目录中的所有文件进行扫描一遍 regExp.test('./cat.js') 找到合适的文件之后再进行打包,这部分都是再编译时完成的。

Webpack 关于这部分的配置其实是在模块上下文中确定的。比如 /.*/ 就可以在 wrappedContextRegExp 字段中修改

module.exports = {
  //...
  module: {
    exprContextCritical: true,
    exprContextRecursive: true,
    exprContextRegExp: false,
    exprContextRequest: '.',
    unknownContextCritical: true,
    unknownContextRecursive: true,
    unknownContextRegExp: false,
    unknownContextRequest: '.',
    wrappedContextCritical: false,
    wrappedContextRecursive: true,
    wrappedContextRegExp: /.*/,
    strictExportPresence: false,
  },
};
复制代码

我们也没必要全部记住,这里列出比较重的几个字段放在 webpack.config.js

// wepback.config.js
module: {
  parser: {
    javascript: {
      wrappedContextRegExp: /.*/, // wrappedContextRegExp我们可以告诉 webpack 用什么替换表达式的动态部分
      wrappedContextRecursive: true // wrappedContextRecursive指定是否应该遍历嵌套目录
    }
  }
}
复制代码

那知道了配置参数,我们还要了解动态参数是如何影响 Webpack 打包的,这里主要涉及三个参数,我们依次说明。

lazy 模式

lazy 就是默认的模式,在我们的应用程序 import 代码中使用该函数:

import(/* webpackChunkName: 'mainFolder' */ `./mainFolder/${fileName}.js`)
  .then( => {
    console.warn(res);
    res.default();
  })
  .catch(console.warn);
复制代码

假设我们在 mainFolder 中有三个模块,那 dist 目录中就会出现 mainFloder0.jsmainFloder1.jsmainFloder2.js 三个块。这是 lazy 选项的行为。

├── dist
│   ├── mainFolder0.js
│   ├── mainFolder1.js
│   ├── mainFolder2.js
│   ├── index.js
复制代码

如果在目录中找不到文件的名字则会抛出错误。

但是如果 Webpack 创建多个 chunk,最终只有一个 chunk 匹配路径,这不是浪费资源吗?

实际上其实没问题,因为所有这些可能的块只是保存在服务器上。除非浏览器需要它们,否则它们不会发送到浏览器,所以这种情况对用户还是无感的,不会影响用户体验。

另外动态参数与编译时就知道路径的静态参数一样,加载完的块将被缓存,因此他也不会浪费请求资源,以防多次需要请求相同的块。

Webpack 将加载的块存储在一个映射中,这样如果请求的块已经被加载,它将立即从映射中检索。映射的键是块的 ID,值取决于块的状态:

缓存码 含义
0 块已经被加载
Promise 模块正在被加载
undefined 模块之前还没被请求过

我们可以从该图中注意到已创建的 3 个子块以及父块。我们从父模块来获取其他子模块的路径来源信息。

Webpack 在内部处理这种路径的方式也是通过一个映射,其中键是文件名( mainFolder 目录中的文件名),值是数组。

数组的模式将是 { filename: [moduleId, moduleExportsMode?, chunkId] }

  • 模块ID: 他能知道模块加载完成时需要什么 chunk 去加载。
  • 块ID:会去通过浏览器请求相应的文件, 在相应文件的 HTTP 请求中使用。
  • moduleExportsMode: 模块类型: 主要是为了实现兼容性,9 表示一个简单的 ES 模块,需要带有 moduleId 的模块。7表示一个 CommonJS 模块。

无论我们使用何种模式,都会使用这种用于跟踪模块及其特征的映射概念。

var map = {
    "./file1.js": [
            2,
            0
    ],
    "./file2.js": [
            3,
            1
    ],
    "./file3.js": [
            4,
            2
    ],
};
复制代码

如果用户需要导入 file2,带有 id 的块 3 将被加载,一旦块准备好,它将使用带有 id 的模块 0 来发送 HTTP 请求。

eager 模式

这里我们通过魔法注释来指定载入模式。

import(/* webpackChunkName: 'mainFolder',webpackMode: 'eager'  */ `./mainFolder/${fileName}.js`)
  .then( => {
    console.warn(res);
    res.default();
  })
  .catch(console.warn);
复制代码

使用 eager 模式时,不会创建任何额外的块,所有匹配 import 模式的模块都将成为同一个主块的一部分。

├── dist
│   ├── index.js
复制代码

当前模块将直接需要 mainFolder 目录内的模块,但实际上没有任何模块会被执行。它们只会被放置到模块的对象/数组中,当它单击按钮时,它将执行并检索该模块,而无需额外的网络请求或任何其他异步操作。

仔细看 main.js 中会有如下 map 对象↓

(module, __unused_webpack_exports, __webpack_require__) => {
  var map = {
    './file1.js': 2,
    './file2.js': 3,
    './file3.js': 4,
  };
  function webpackAsyncContext(req) {
    return webpackAsyncContextResolve(req).then(__webpack_require__);
  }
  function webpackAsyncContextResolve(req) {
    return Promise.resolve().then(() => {
      if (!__webpack_require__.o(map, req)) {
        var e = new Error("Cannot find module '" + req + "'");
        e.code = 'MODULE_NOT_FOUND';
        throw e;
      }
      return map[req];
    });
  }
  webpackAsyncContext.keys = () => Object.keys(map);
  webpackAsyncContext.resolve = webpackAsyncContextResolve;
  webpackAsyncContext.id = 1;
  module.exports = webpackAsyncContext;

  /***/
},
/* 2 */ // -> The `file1.js` file
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {},
/* 3 */ // -> The `file2.js` file
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {}
复制代码

因此,这种方法的优点是模块在需要时会立即被检索,而不是为每个模块发出额外的 HTTP 请求。

lazy-once 模式

lazy-once 是一个相对不常见的配置,所有与import 表达式匹配的模块都将添加到子块而不是主块中。

import(/* webpackChunkName: 'mainFolder',webpackMode: 'lazy-once'  */ `./mainFolder/${fileName}.js`)
  .then( => {
    console.warn(res);
    res.default();
  })
  .catch(console.warn);
复制代码
├── dist
│   ├── mainFolder
│   │   ├── file1.js
│   │   ├── file2.js
│   │   ├── file3.js
│   ├── index.js
复制代码

npm run build 运行后,该目录 dist 应该有 2 个文件:main.js 主块,以及 mainFolder

这种加载模块的方式的好处将它们放在另一个可以延迟加载的块中。当用户按下按钮加载模块时,将通过网络请求整个 mainFolder 块,当它准备好时将执行并检索用户请求的模块。此外,这个新加载的块包含的所有模块都将由 Webpack 注册。

如果现在用户需要一个不同的模块,它也属于刚刚加载的块,网络上不会有任何额外的请求。这是因为块将从 Webpack 内部维护的缓存中提供服务,并且所需的模块将从 Webpack 记录它们的模块的数组/对象中检索。

weak模式

weak 这个模块就更陌生了,如果该模块函数已经以其他方式加载则尝试加载模块(即另一个 chunk 导入过此模块,或包含模块的脚本被加载)。这个模块仍会返回 Promise, 不过只有在客户端上已经有该 chunk 时才会成功解析。

如果该模块不可用,则返回 rejected 状态的 Promise,且网络请求永远都不会执行。

需要的 chunks 始终在(嵌入在页面中的)初始请求中提供,而不是在应用程序导航在最初没有提供的模块导入的情况下触发,这个除了 SSR 模式一般不常见。

Webpack 中的缓存模块

还记得上面 Webpack 会将加载的状态存储起来嘛,接下来会科普 Webpack 中具体的几种缓存方法。此过程相对冗长,熟练切图仔可以直接跳过了。

modules 字段

Webpack 打包后的产物无论是采用什么样的 sourcemap,他都有一个统一的 IIFE 作为起点。

(function (modules){
  function __webpack__require__(moduleId) {
    // ... 
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    // 当方法调用完成后,就可以修改l表示内容加载完成
    module.l = true;
    return module.exports;
  }
  return __webpack__require__('./src/index.js')
})({
   './src/index.js':
   (function (module, exportm __webpack__require) {
    // ... 文件 index.js 中被 webpack 处理后的源代码
   })
 },{
   './src/mainFolder/file1.js':
   (function (module, exportm __webpack__require) {
    // ... 文件 file1.js 中被 webpack 处理后的源代码
   })
 },{
   './src/mainFolder/file2.js':
   (function (module, exportm __webpack__require) {
    // ... 文件 file2.js 中被 webpack 处理后的源代码
   })
 },)
复制代码

仔细看他的结构就能明白 module 的作用就是缓存模块代码,从入口文件开始执行并且通过自执行函数引入不同的模块。

缓存的 module 中,每个 module 都会有一个 id,当设置开发环境时会默认以 module 所在文件的文件名为标识,生产环境默认以一个数字标识。modules 对象中的键名就是 moduleId,值就是模块经过 Webpack 打包后的源代码。

installedModules 字段

Webpack 中还有专门用来缓存已经加载过的模块的对象就是 installedModules,它的作用是缓存代码中 export 的内容。

如果我们看一下代码,会发现 WebpackIIFE 以一个数组作为参数。每个数组元素都代表我们代码中的一个模块。

/******/ (function(modules) { // webpackBootstrap
/******/ ...
/******/ })
/************************************************************************/
/******/ ([
/* 0 */
/***/ (function(module, exports, __webpack_require__) {
....
/***/ ])
复制代码

installedModules 是一个缓存,可以在代码执行时存储所有模块。

/******/ // 定义对象来缓存模块
/******/ let installedModules = {};
复制代码

Webpack 从函数声明 __webpack_require__ 开始。它 moduleId 作为一个参数,并且检查调用此函数时缓存中是否已加载模块。如果是,则从缓存中返回模块,否则加载模块。

这里要注意 .exports 不是我们写的 module export。它是由 Webpack 运行时定义的自定义属性。

/******/  // webpack IIFE 执行此函数
/******/  function __webpack_require__(moduleId) {
/******/
/******/    // 判断当前缓存中是否存在要被加载的模块内容,如果存在直接返回
/******/    if(installedModules[moduleId]) {
/******/      return installedModules[moduleId].exports;
/******/    }
/******/  ...
复制代码

如果在缓存中找不到模块就加载它。还需要定义一个名为 module 的变量,它是一个对象并且具有三个属性。

  1. i 指的是 moduleId
  2. l 是一个布尔标志,表示模块是否已经加载。
  3. exports 是一个对象。

同时,我们会把当前 module 放入 installedModules[moduleId] 中。

/******/    // 缓存如果不存在就自己定义对象{},执行被导入的模块内容加载
/******/    var module = installedModules[moduleId] = {
/******/      i: moduleId,
/******/      l: false,
/******/      exports: {}
/******/    };
/******/    ...
复制代码

然后我们调用刚刚放入缓存中的模块。

  1. 我们通过 moduleId 获取模块。
  2. 每个模块都是一个 IIFE。我们使用带有调用上下文 module.exports 的方法的 .call 调用它。
  3. 我们要为提供模块所需的必要参数。
  4. 我们传递现有 __webpack_require__ 函数,以便当前模块在需要时可以调用其他模块。
/******/    // 执行当前模块
/******/    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/    ...
复制代码

再然后我们将 l 标志设置为 true 这意味着模块现在已加载过,然后返回 module.exports

这样就完成了 __webpack_require__ 功能。

/******/    // 当前模块设置成已加载
/******/    module.l = true;
/******/
/******/    // 返回 module.exports
/******/    return module.exports;
/******/    ...
复制代码

我们在 IIFE 的末尾通过 __webpack_require__ 调用第一个入口模块。它会启动并且在缓存中找到模块或将其加载到缓存中,最后在返回 module.exports

/******/ // 加载入口模块
/******/ return __webpack_require__((__webpack_require__.s = 0));
复制代码

installedChunks 字段

缓存已经加载过的 chunk,在导入某些文件时,Webpack 会通过 __webpack_require__.e 来判断当前文件是否已经加载完成。

  • 如果 installChunks[chunkId] 为 0 则已经加载完成
  • 如果 installChunks[chunkId] 不为 0 && 不是 undefined 则判断为正在加载中
  • 如果 installChunks[chunkId]undefined 则是没有加载过,此时会通过 JSONP 请求资源

这部分现在网上一大堆解释没必要过多阐述了,webpack_require.e(chunkId) 就是建立 Promise 对象用来跟踪按需加载模块的加载状态,并且他也会设置一个超时阙值,如果加载超时就抛出异常。

// 以下都是在自执行函数中

// 定义对象用于标识某个 chunId 对应的 chunk 是否完成加载
let installChunks = {
    main: 0
} // 0 加载过,promise 正在加载,undefined 还没加载
__webpack_require__.e = function (chunkId) {
    // 定义数组存放 Promise
    let _promises = [];
    // 获取 chunkId 对应的 chunk 是否已经完成了加载
    let _installChunkData = installChunks[chunkId];
    // 依据当前是否完成加载执行后续逻辑
    if (_installChunkData !== 0) {
        if (_installChunkData) {
            _promises.push(_installChunkData[2])
        } else {
            let promise = new Promise((resolve,reject) => {
                _installChunkData = installChunks[chunkId] = [resolve, reject]
            })
            _promises.push(_installChunkData[2] = promise);
            // 创建标签
            let script = document.createElement('script');
            script.src = jsonpScriptSrc(chunkId);
            // 写入 script 标签
            document.head.appendChild(script);
        }
    }
    // 执行promise
    return Promise.all(_promises)
}
复制代码

.e 模块就是在闭包的传参源代码中调用的,可以参考如下示例

obt.addEventListener('click', function () {
  // .e 用于实现 jsonp 来加载内容,利用 Promise 实现异步加载
    __webpack_require__.e(/*! import() | login */ "login")
        // .t 用于加载指定 value 的模块内容
        .then(__webpack_require__.t.bind(null, /*! ./login.js */ "./src/login.js", 7))
        .then(res => {
            console.log(res)
        });
})
复制代码

完整 Webpack 打包后 import 相关产物分析

(function (modules) {
    // 定义对象来缓存模块
    let installedModules = {};
    // 定义对象用于标识某个 chunId 对应的 chunk 是否完成加载
    let installChunks = {
        main: 0
    } 
    // 定义懒加载的 webpackJsonpCallback,实现合并模块定义,改变 Promise 状态执行后续行为
    function webpackJsonpCallback(data) {
        // 获取要被动态加载的模块 id
        let _chunkIds = data[0];
        // 获取要被动态加载模块的依赖关系对象
        let _moreModules = data[1];
        // 循环判断 chunkIds 里对应的模块内容是否已经完成加载
        let chunkId, resolves = []
        for (let i = 0; i < _chunkIds.length; i++) {
            chunkId = _chunkIds[i];
            // 判断 installChunks 有没有,是否正在加载
            if (Object.property.hasOwnProperty.call(installChunks, chunkId) && installChunks[chunkId]) {
                resolves.push(installChunks[chunkId][0]) // 把resolve放进去
            }
            // 更新 chunk 状态
            installChunks[chunkId] = 0; // 整完了就完全加载了
        }
        for (moduleId in _moreModules) {
            if (Object.hasOwnProperty.call(_moreModules, moduleId)) {
                modules[moduleId] = _moreModules[moduleId];
            }
        }
        while (resolves.length) {
            resolves.shift()()
        }
    }
    // 定义 jsonpScriptSrc 实现 src 处理
    function jsonpScriptSrc(chunkId) {
        return __webpack_require__.p + '' + chunkId + '.built.js';
    }
    // 定义 m 来保存modules
    __webpack_require__.m = modules;
    // 定义 c 属性用来保存缓存
    __webpack_require__.c = installedModules;
    // 定义 e 方法用于实现 jsonp 来加载内容,利用 Promise 实现异步加载
    __webpack_require__.e = function (chunkId) {
        // ...
    }
    // 定义 __webpack_require__ 来替换 import, require 操作
    function __webpack_require__(moduleId) {
        // 判断当前缓存中是否存在要被加载的模块内容,如果存在直接返回
        if (installedModules[moduleId]) {
            return installedModules[moduleId].exports;
        }
        // 缓存如果不存在就自己定义对象,执行被导入的模块内容加载
        let module = installedModules[moduleId] = {
            i: moduleId,
            l: false,
            exports: {}
        }
        // 调用对应模块完成加载
        modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
        // 当方法调用完成后,就可以修改l表示内容加载完成
        module.l = true;
        return module.exports;
    }
    // 定义变量,存放数组
    let jsonpArray = window['webpackJsonp'] = window['webpackJsonp'] || [];
    // 保存原生 push 方法
    let oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
    // 重写原生 push 方法
    jsonpArray.push = webpackJsonpCallback
    // 调用 __webpack_require__ 方法执行模块导入和加载
    return __webpack_require__(__webpack_require__.s = './src/index.js')
})({
    "./src/index.js":
        (function (module, exports, __webpack_require__) {
            let obt = document.getElementById('btn');
            obt.addEventListener('click', function () {
                __webpack_require__.e(/*! import() | login */ "login")
                    .then(__webpack_require__.t.bind(null, /*! ./login.js */ "./src/login.js", 7))
                    .then(res => {
                        console.log(res)
                    });
            })
            console.log('index.js work')
        })
})
复制代码

希望本文对 import 的动态参数探讨能够对你的工作有所帮助,完结。

image.png

猜你喜欢

转载自juejin.im/post/7083676342872440845
今日推荐