Webpack 模块加载动态引入机制源码示例解析

TL;DR

本文基于 Webpack 5 进行讲解,适合不了解 Webpack 把资源编译成什么样子的同学,读完本文,你将理解下面几个问题的来龙去脉:

  • Webpack 静态引入的实现逻辑,如 import App from './App'
  • Webpack 的动态引入原理,也就是动态 import 是怎么实现的,如 import('./App')
  • 模块联邦的原理(目前只给了大体的逻辑,超过 20 个赞会补充这部分的内容)

不仅如此,我们还将在每一个部分与 Vite 的实现进行对比,让大家能在更高的层次上掌握这部分知识。大多数讲解 Webpack 源码的内容都是截图源码,而笔者在阅读这些文章的时候就觉得体验不是特别好,往往看了几行便退出了。

本文会在保留原始函数名的基础上,抽离出主要的逻辑实现,相信这肯定能让大家更清晰的理解。

准备阶段

对某些同学来说,今天内容可能会稍微有一点难,在读完本文之后,可能还需要自己调试一下代码才能真正的理解,不过大家不用担心,笔者会尽力做到讲解清晰。最开始,先请大家和笔者一同配置下 Webpack 环境。

  • 安装 pnpm(非必须,不喜欢的同学请把后面的 pnpm 替换为 npmpnpx 替换为 npx
npm install -g pnpm
  • 初始化
mkdir webpack-demo
pnpm init
pnpm i webpack
  • 生成模板(命令行提示缺什么,按照提示安装即可)
pnpm webpack init -f
  • 使用下面的配置替换 webpack.config.js
const config = {
    entry: './src/index.js',
    mode: 'development',
    output: {
        path: path.resolve(__dirname, 'dist'),
    },
    devServer: {
        open: true,
        host: 'localhost',
    },
    devtool: 'source-map',
    optimization: {
        runtimeChunk: 'single'
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: 'index.html',
        }),
    ],
    module: {
        rules: [
            {
                test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/i,
                type: 'asset',
            },
        ],
    },
};
module.exports = config
  • package.json build 命令替换,不保留指定 mode 为 production。
"scripts": {
-    "build": "webpack --mode=production --node-env=production",
-    "build:dev": "webpack --mode=development",
-    "build:prod": "webpack --mode=production --node-env=production",
+    "build": "webpack",
     "serve": "webpack serve"
 },
  • 测试 pnpm buildpnpm serve 都能正常运行,并且打包目录能看到独立的 runtime.js,说明已经配置好了。

Runtime

Runtime 又叫做运行时,它的作用是串联起各个模块,包括引入模块、下载模块、一些基础的公共方法。通过 Runtime 作为桥梁,我们就能把各个模块联系起来,最终让被 Webpack 打包的应用在浏览器跑起来。

除此之外,HMR 的能力也需要 Runtime 的支持,我们可以通过预先注入一系列 HMR 的工具函数(包括 WebSockect 通信,HMR API),来实现此功能。

如果你按照我们的准备阶段的提示成功把项目跑起来了,可以运行一下 pnpm build 命令,然后去 dist 目录查看 runtime.js 文件。搜索 __webpack_require__ 关键词,它下面会有很多方法或对象,包括 __webpack_require__.m__webpack_require__.o__webpack_require__.e, 这些就是我们今天要谈论的主角。

模块被打包成了什么样子?

这一部分我们不使用 Webpack 打包,而是模拟一下。

对于模块被打包要解决的问题,笔者有一些思考,认为有以下几个方面:

  • 对 ES Module 出于兼容性的考虑,在 Webpack 出现的那个时代,ES Module 的支持性并不理想。
  • 在 HTTP 1.X 的场景下,ES Module 带来的请求量不可预估,而 HTTP 层面队头阻塞的缺点,使得项目可能会造成网络阻塞的现象。除此之外, 在现在 ESM 支持性已经很好的场景下,即便我们使用了 HTTP 2 可以不用考虑并行的请求数,但是 import 的层级嵌套依然会带来网络层面上额外的 Road Trip 的消耗,同时依然存在 TCP 层面的队头阻塞。
  • 对于一些相似性很高的内容,多个文件压缩到一块压缩效果也不差,可能会比两者分开请求要好。

模块被打包后可能需要考虑下面三点:

  • 独立的模块作用域,两个模块之间不应该互相影响
  • 缓存机制,模块被加载过一次就不用再发起请求了
  • 环依赖问题

在上面的基础上,我们来看看 Webpack 把 ESM 的代码编译成立什么样子。

首先,我们的有三个文件:index.js、message.js、name.js,依赖关系如下面代码所示:

// filename: index.js 
// ** 入口文件 ** 
import message from './message.js';
console.log(message);
// filename: message.js
import {name} from './name.js';
export default `hello ${name}!`;
// filename: name.js
export const name = 'world';

最后的执行结果便是输出 hello world。

我们来看一下最后编译成的样子:

const modules = {
    0: [
        function (require, module, exports) {
            "use strict";
            var _message = require("./message.js");
            var _message2 = _interopRequireDefault(_message);
            function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
            console.log(_message2.default);
        },
        { "./message.js": 1 },
    ],
    1: [
        function (require, module, exports) {
            "use strict";
            Object.defineProperty(exports, "__esModule", {
                value: true
            });
            var _name = require("./name.js");
            exports.default = "hello " + _name.name + "!";
        },
        { "./name.js": 2 },
    ],
    2: [
        function (require, module, exports) {
            "use strict";
            Object.defineProperty(exports, "__esModule", {
                value: true
            });
            var name = exports.name = 'world';
        },
        {},
    ],
}
function load(modules) {
    function require(id) {
        const [fn, mapping] = modules[id];
        const module = { exports: {} };
        function localRequire(name) { return require(mapping[name]); }
        fn(localRequire, module, module.exports);
        return module.exports;
    }
    require(0);
}
load(modules)

可以看到,我们所有模块的内容都被维护到了 modules 这个大对象里,import 语句被转换成立 require 语句,当调用 load 函数的时候,整个加载过程就开始了。如果第一次了解上面的格式,可能需要大家好好的品味一下。

再次说明,上面这段代码值得花时间好好看一下。

如果你有兴趣想了解是怎么转成这种格式的,推荐 minipack 这个库,如果不喜欢看英文,可以看笔者的这篇 mini webpack打包基础解决包缓存和环依赖

静态引入

自从社区涌现了了 Vite、Snowpack 等打包工具之后,Webpack 则被分到了一个新的营地 —— Bundler,与之相对的,Vite 则是 No-Bundler。在讲解完本小节内容最后,笔者会为大家对比 Vite 和 Webpack 在引用模块机制上的区别,届时大家可能从引用模块的这个角度对 Bundler 和 No-Bundler 有更深刻的理解,可能也会知道,No-Bundler 并非一定是银弹。

在此之前,让我们先聚焦于 Webpack 的模块引用机制。我们使用的例子依然是上一小节的例子,只不过打包工具换成了 Webpack。

首先,我们先运行一下 pnpm build, 发现 dist 目录有两个 JS 文件:main.js、runtime.js。运行时的代码都在 runtime.js,而我们模块内容相关的都在 main.js。由 index.html 控制二者的下载:



可以注意到先下载了 runtime.js, 再下载 main.js。这是必须的,因为首先我们需要在注册一些全局变量,注册好了之后,main.js 才可以通过全局变量来和运行时进行交互。加 defer 的作用是可以不阻塞 DOM 树的解析,异步下载内容,可以减少白屏时间(First Content Paint)。

最开始的,定义的 webpackChunkmy_webpack_project 这个全局变量,如下所示:

self["webpackChunkmy_webpack_project"] 
    = self["webpackChunkmy_webpack_project"] || [];
const chunkLoadingGlobal = self["webpackChunkmy_webpack_project"]

接着重写 webpackChunkmy_webpack_project 上的 push 方法:

chunkLoadingGlobal.push = 
    webpackJsonpCallback.bind(
        null, 
        chunkLoadingGlobal.push.bind(chunkLoadingGlobal)
    );

上面这句话的含义是:

  • push 重置为 webpackJsonpCallback 函数
  • webpackJsonpCallback 绑定参数,thisnull ,但是函数的第一个参数为chunkLoadingGlobal 数组原来的的 push 方法,也就是说,调用此方法可以往 chunkLoadingGlobal 这个数组里加值。

我们可以在 window 打印这个值:

Webpack 模块加载动态引入机制源码示例解析_第1张图片

接下来我们看一下 main.js ,各位请注意,为了方便大家阅读,把 main.js 格式修改了,如果大家看源码建议搜索变量名。

const chunkIds = ["main"];
const moreModules = {
    "./src/index.js":
        ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
            // 内容暂时省略
        }),
    "./src/message.js":
        ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
            // 内容暂时省略
        }),
    "./src/name.js":
        ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
            // 内容暂时省略
        })
}
const runtime = __webpack_require__ => {
    var __webpack_exec__ = (moduleId) => (__webpack_require__(__webpack_require__.s = moduleId))
    var __webpack_exports__ = (__webpack_exec__("./src/index.js"));
}
self["webpackChunkmy_webpack_project"].push([
    ["main"],
    moreModules,
    runtime
]);

所以关键点还是来到了调用 webpackChunkmy_webpack_projectpush 方法, 也就是 runtime 里的 webpackJsonpCallback,接下来我们看这个函数做了什么,你可以先大概浏览一下。

var webpackJsonpCallback = (
    parentChunkLoadingFunction,
    data
) => {
    var [chunkIds, moreModules, runtime] = data;
    var moduleId, chunkId, i = 0;
    if (chunkIds.some((id) => (installedChunks[id] !== 0))) {
        for (moduleId in moreModules) {
            if (__webpack_require__.o(moreModules, moduleId)) {
                __webpack_require__.m[moduleId] = moreModules[moduleId];
            }
        }
        if (runtime) var result = runtime(__webpack_require__);
    }
    if (parentChunkLoadingFunction) parentChunkLoadingFunction(data);
    for (; i < chunkIds.length; i++) {
        chunkId = chunkIds[i];
        if (__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
            installedChunks[chunkId][0]();
        }
        installedChunks[chunkId] = 0;
    }
    return __webpack_require__.O(result);
}

接下来逐行解释:

1. 函数的第一个参数是绑定数组原始的 push 方法,最开始就被 bind 了; 第二个参数是我们调用此函数入的参数,可以回看一下 main.js 最后的调用,是一个数组结构。

self["webpackChunkmy_webpack_project"].push([
    ["main"],
    moreModules,
    runtime
]);

2. var [chunkIds, moreModules, runtime] = data;

解构出这些参数,其中 chunkIds["main"],剩下的以此类推。

3.

if (chunkIds.some((id) => (installedChunks[id] !== 0))) {
    for (moduleId in moreModules) {
        if (__webpack_require__.o(moreModules, moduleId)) {
            __webpack_require__.m[moduleId] = moreModules[moduleId];
        }
    }
    if (runtime) var result = runtime(__webpack_require__);
}

installedChunk 是用来缓存模块的加载状态的,其中 0 代表已经加载好了。所以 if 语句的意思就是,如果 chunkIds 有模块还没有加载好。

继续往下我们需要介绍两个函数。

a. __webpack_require__.o:这个就是判断判断 key 值有没有在对象本身上:

__webpack_require__.o = 
    (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))

b. __webpack_require__.m: 它维护的是所有的模块,因为我们可能有 main.js,main-1.js 都有模块需要管理,这时候就通过它去统一的注册上我们的模块里去。

var __webpack_modules__ = ({});
__webpack_require__.m = __webpack_modules__;

明白了上面两个工具函数,我们上面那段代码的含义就是把 moreModules 里的模块都注册到 __webpack_require__.m 上去。

注册完了之后剩下的就是执行了,也就是 runtime 函数做的事情 runtime 函数可以简化为 :

const runtime = __webpack_require__ => {
   __webpack_require__("./src/index.js"));
}

__webpack_require__ 的作用可以理解为和我们在 「模块被打包成了什么样子?」这一小节最后给出的 require 函数作用一样了,也就是说,执行 __webpack_require__.m 这个对象里 key 对应的 函数。具体的,就是执行刚才我们省略了的 moreModules 里的某一项:

"./src/index.js":
        ((_, _1, __webpack_require__) => {
            var _message_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/message.js");
            console.log(_message_js__WEBPACK_IMPORTED_MODULE_0__["default"]);
        })

走到这一步,其实就已经可以串联起所有的模块了。不知你是否有种拨开云雾见日出的感觉。

4. 首先是 把 data push 到我们的 webpackChunkmy_webpack_project 数组里,再接下来把加载好的做缓存,存储到 installedChunks 中去,返回值我们没有用到,所以这里就略过。

if (parentChunkLoadingFunction) parentChunkLoadingFunction(data);
for (; i < chunkIds.length; i++) {
    chunkId = chunkIds[i];
    // 这一步是动态引入的关键,这里暂时不分析
    if (__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
        installedChunks[chunkId][0]();
    }
    installedChunks[chunkId] = 0;
}
return __webpack_require__.O(result);

通过上面我们可以感受到 Webpack 是怎么加载模块的,那在 Vite 中会是怎么样呢?还是举例子来说。

假设我们要请求一个 index.tsx 文件。在没有发起请求之前,Vite 的 Dev Server 不会帮我们预编译这个模块。当我们发起请求,此次 Vite 才会调用 ESBuild 编译这个模块,在这个过程中,会把 index.tsx 文件转译成 ESM 格式的 JS 文件,如果此时有 bare import ,还会有路径名重写成预编译的路径;有配置 alias ,也会把路径名改写成我们配置的 alias。最后,会把这个文件(index.tsx)的编译结果记录到 moduleGraph 中,moduleGraph 是 Vite 内部的模块图,是实现模块缓存、HMR 的关键。

接着,它会返回当前请求,当浏览器收到当前请求之后,当前文件可能有多个 import 请求,浏览器将并行的发出这些请求,Vite 也将重复上面操作,继续使用 ESBuild 的编译,然后记录到 moduleGraph 中,一直递归的进行到当前入口文件都请求完毕。

等到再次请求,我们就可以没有这么麻烦了,直接从 moduleGraph 中读取缓存的内容了。

大家可能发现了,从加载模块的这个角度,moduleGraph 和我们上面的 __webpack_require__.m 基本是一样的。只不过,Webpack 事先计算好了所有的内容,而 Vite 则按需计算。如果我们不做分包,一个很大的文件嵌套很多层,在 Dev Server 阶段最开始启动服务的时候, Vite 也并不一定比 Webpack 要快。

由于目前 Vite 没有对 moduleGraph 做缓存,重启 Server 则又会重新走一步编译-存储的流程,所以每次重启,在极端情况下,第一次加载可能都会比较慢;与之相对的,Webpack 现在对编译的结果做文件系统级别的缓存,这样子做了之后,甚至可以逼近 No-Bundler 的速度了。

module.exports = {
  cache: {
    type: 'filesystem',
    allowCollectingMemory: true,
  },
};

动态引入

通过上面那一小节的讲解,或许你会发现,假设我们动态引入的模块叫做 a.js,只要它也是形如这样去调用的:

// ... 前面省略
self["webpackChunkmy_webpack_project"].push([
    ["a"],
    moreModules,
    runtime
]);

我们好像不用额外做什么,就能把这些模块注册到主模块并且运行了。又可以说,某种程度上我们的静态引入其实也算是「动态引入」。当然了,其实我们还是要做一些额外的工作的,因为我们的模块引入之后,往往有一些回调函数。但是主要的逻辑基本是一致的。

其实动态引入(Dynamic Import)的实现一般都是通过 JSONP 的形式,基本的实现原理如下所示:

function importModule(url) {
  return new Promise((resolve, reject) => {
    const script = document.createElement("script");
    // 注册一个随机的全局变量,后面值挂上去
    const tempGlobal = "__tempModuleLoadingVariable" + Math.random().toString(32).substring(2);
    script.type = "module";
    script.textContent = `import * as m from "${url}"; window.${tempGlobal} = m;`;
    script.onload = () => {
      // 请求结束 resolve 掉。
      resolve(window[tempGlobal]);
      delete window[tempGlobal];
      script.remove();
    };
    script.onerror = () => {
      reject(new Error("Failed to load module script with URL " + url));
      delete window[tempGlobal];
      script.remove();
    };
    document.documentElement.appendChild(script);
  });
}

动态 Import 提案

接下来我们改一下例子,运行 pnpm build 看看在 Webpack 中是怎么实现的:

// filename: index.js
- import message from './message.js';
- console.log(message);
+ import ('./message.js').then(message => {
+     console.log(message)
+ })

首先我们发现多了一个文件 src_message_js.js。

再观察 main.js,import 语句被编译成了:

__webpack_require__.e(
    "src_message_js"
).then(
    __webpack_require__.bind(
        __webpack_require__,
        "./src/message.js"
   )
).then(message => {
    console.log(message)
})

看这段代码我们根据前面的知识可以进行猜想:

  • __webpack_require__.e 的作用就是下载 src_message_js 的内容,并把它注册到全局的 __webpack_require__.m 上,这一步应该可以静态引入一样,不过它是异步完成,下载完了才注册。
  • 调用__webpack_require__(./src/message.js) 可以从全局模块对象里拿到它对应的导出值
  • 打印出结果

接下来我们开始实际调试验证对不对。

首先是 __webpack_require__.e,它的作用就是遍历执行 __webpack_require__.f 上的方法,并且要再它上面所有的方法都执行完了才解决:

__webpack_require__.e = (chunkId) => {
    return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
        __webpack_require__.f[key](chunkId, promises);
        return promises;
    }, []));
};

值得高兴的是,__webpack_require__.f就挂了一个方法,__webpack_require__.f.j。它的作用就是通过 JSONP 的形式下载模块。

在这里先和大家讲解一下 installedChunks 的数据结构,讲完这个,大家应该可以结合着笔者给的注释看明白代码了。它也是一个缓存的对象,为了避免多次动态引入而发起多次请求。

  • 第一种情况,已经安装的模块,value 会被置为 0,这样再引入便直接使用缓存。
const installedChunks = {
    runtime: 0
}
  • 第二种情况,还在加载中的模块,value 是一个数组,三项分别是同一个 promise 对象的 resolve、reject、本身:
const installedChunks = {
    'src_message_js': [resolve, reject, promise]
}

接下来大家看代码不要纠结于细节,而是抓住主干:在请求到资源后,调用 onload 事件,整个流程就完了。

__webpack_require__.f.j = (chunkId, promises) => {
    var installedChunkData = __webpack_require__.o(installedChunks, chunkId) ? installedChunks[chunkId] : undefined;
    if (installedChunkData !== 0) { // 0 代表已经下载好了.
        // 有值说明还在 loading 中,已经开始下载了,没必要再次下载
        // 但是把 promise 状态给出去
        if (installedChunkData) {
            promises.push(installedChunkData[2]);
        } else {
            if ("runtime" != chunkId) {
                // 组装好 promise, 填入 installedChunks 中
                var promise = new Promise((resolve, reject) => (installedChunkData = installedChunks[chunkId] = [resolve, reject]));
                promises.push(installedChunkData[2] = promise);
                // 根据 publicPath 拼出 合适的 URL
                var url = __webpack_require__.p + __webpack_require__.u(chunkId);
                var error = new Error();
                var loadingEnded = (event) => {
                    // 意义不大,暂时删掉
                };
                __webpack_require__.l(url, loadingEnded, "chunk-" + chunkId, chunkId);
            } else installedChunks[chunkId] = 0; // 标识已 loaded
        }
    }
};

接下来就是 __webpack_require__.l 这个工具函数,它的作用就是根据传入的 URL 去请求,简化后如下:

__webpack_require__.l = (url, done, key, chunkId) => {
    var scripts = document.getElementsByTagName("script");
    script.timeout = 120;
    script.src = url;		
    document.head.appendChild(script)
}

执行到这里 就回去下载 src_message_js.js 文件,你可能会好奇,怎么还没 resolve ? 其实 src_message_js.js 的加载方式和我们刚开始说的静态引入一样,也是调用 push 方法:

self["webpackChunkmy_webpack_project"] || []).push([
    ["src_message_js"], 
    // 后面省略
 )

而此时就又会走到 webpackJsonpCallback,在调用这个函数的时候,我们回顾一下,会先把模块注册到 __webpack_require__.m 上去,接下来有一点我们没有讲:

if (
    __webpack_require__.o(installedChunks, chunkId) 
    && installedChunks[chunkId]
) {
    installedChunks[chunkId][0]();
}

上面代码就是关键,在这里去 resolve 掉了我们的异步引入,此时我们再把整理的函数放过来,你是不是对这个函数的每一行都理解了呢:

var webpackJsonpCallback = (
    parentChunkLoadingFunction,
    data
) => {
    var [chunkIds, moreModules, runtime] = data;
    var moduleId, chunkId, i = 0;
    if (chunkIds.some((id) => (installedChunks[id] !== 0))) {
        for (moduleId in moreModules) {
            if (__webpack_require__.o(moreModules, moduleId)) {
                __webpack_require__.m[moduleId] = moreModules[moduleId];
            }
        }
        if (runtime) var result = runtime(__webpack_require__);
    }
    if (parentChunkLoadingFunction) parentChunkLoadingFunction(data);
    for (; i < chunkIds.length; i++) {
        chunkId = chunkIds[i];
        if (__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
            installedChunks[chunkId][0]();
        }
        installedChunks[chunkId] = 0;
    }
    return __webpack_require__.O(result);
}

好了,这就是 Webpack 动态引入的原理。笔者认为实现过程非常美丽、优雅。而在 Vite 中其实就没什么好说的了,它就是使用的原生的 import 。

模块联邦引入原理

这个的实现逻辑和动态引入很像,不同的是,动态引入只有一个工具函数,叫做 __webpack_require__.f.j,而它有三个,分别是:

__webpack_require__.f.consumes

__webpack_require__.f.j

__webpack_require__.f.remotes

由这三个一起完成了模块联邦的神奇功能。

这部分容笔者留一个坑,其实,再讲解完上面两部分之后,同学们可以自己尝试一下是否可以明白原理了,如果有同学想看这部分原理,不妨留言,笔者择期进行补充。

和各位读者预告一下,下一篇,笔者将更新 Immer 的源码解读,这是一个笔者很喜欢的库,敬请期待。

更多关于Webpack 模块加载动态引入的资料请关注脚本之家其它相关文章!

你可能感兴趣的:(Webpack 模块加载动态引入机制源码示例解析)