ESM的加载机制及Webpack下的ESM

众所周知,ES6引入了官方的模块化方案importexport。在此之前,js想要实现模块化开发,需要依赖于非官方方案。

模块化历史

最早期:IIFE

最开始为了避免在全局作用域执行代码,开发者使用IIFE将代码封装在函数作用域内。

var mathToolModule = (function () {
    var pi = 3.141592653
    function add (a, b) {
        return a + b
    }
    return {
        pi: pi,
        add: add
    }
})()

mathToolModule.add(1, 2)

CommonJs

比较常用的模块化方案,使用requiremodule.exports来导入/导出模块。现今的Node.js仍然使用该模块化标准。

// toolsModule.js
module.exports = {
    add: function (a, b) {
        return a + b
    }
}

// main.js
const mathToolModule = require('./toolsModule.js');
mathToolModule.add(1, 2)

缺点很明显,因为是同步加载,所以在浏览器上网络差的情况下会比较慢影响性能。而Node服务端因为都在本地所以不影响。

AMD(Async Module Definition)

CommonJs实现不了异步加载模块,所以出现了AMD标准。RequireJs是AMD的一种实现:

// 定义一个mathTools模块
define('mathTools', [], function () {
    var add = function (a, b) {
        return a + b;
    };
    return {
        add: add,
    };
});

// 引入math作为依赖模块
require(['mathTools'], function (math) {
    math.add(1, 2)
})

通过引入依赖数组,并异步加载全部的模块,等待加载完成后再运行内部代码。缺点是依赖数组前置了,所以用或者不用都会加载这些模块。没有按需加载。

CMD(Common Module Definition))

阿里巴巴提出的一种前端模块化规范。CMD规范主要用于Sea.js模块加载器。特点是按需加载,依赖就近(依赖可以写在代码任意地方,只有使用时才去解析依赖)。

// 定义math模块
define(function (require, exports, module) {
    exports.add = function (a, b) {
        return a + b;
    }
})

// 使用math模块
define(function (require, exports, module) {
    var mathTools = require('./math.js');
    mathTools.add(1, 2)
})

requireexports都作为define内函数的参数。分别代表自己要使用什么模块,以及导出哪些变量。

UMD(Universal Module Definition)

UMD规范的目的是让一个模块能同时在不同环境下运行。不论是浏览器、Node.js还是其他环境。它兼容了AMD、CommonJs以及全局变量。

// mathTools.js
(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD
        define([], factory);
    } else if (typeof module === 'object' && module.exports) {
        // CommonJS
        module.exports = factory();
    } else {
        // 浏览器全局变量
        root.mathTools = factory();
    }
}(this, function () {
    // 主体代码
    return {
        add: function (a, b) {
            return a + b
        }
    }
}));

// main.js
(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD
        define(['mathTools'], factory);
    } else if (typeof module === 'object' && module.exports) {
        // CommonJS
        module.exports = factory(require('mathTools'));
    } else {
        // 浏览器全局变量
        root.myModule = factory(root.mathTools);
    }
}(this, function (mathTools) {
    // 主体代码
    mathTools.add(1, 2)
}));

可以看到每个模块的主体代码都在factory函数里。模块执行时使用IFFE:判断当前使用哪种模块规范,并把自己导出为当前的模块规范。从而让每个模块都能运行在各种模块规范下。

ESM(EMCAScript Module)

ES6从语言层面实现了模块化。旨在统一标准。使用import导入模块,export导出模块。

// mathTools.js
export default {
    add: (a, b) => a + b
}

// main.js
import add from 'mathTools'

add(1, 2)

并且ESM慢慢成为浏览器的标配。使用<script type="module">就可以定义一个脚本使用该模块规范。在node端也支持ESM。通过在package.json里配置type: "module"或者使用.mjs后缀,就可以使用ESM。

ESM的加载运行机制

ESM在浏览器和Node中的加载运行机制,被设计为三个阶段:

  1. 构建阶段 - 加载并解析模块代码,生成一堆模块记录。

  2. 实例化阶段 - 模块将导出指向内存,此时的变量仅是声明。然后其他模块的导入也指向同一位置。这个过程称为“连接”。

  3. 执行阶段 - 运行代码,变量的值将填充内存。

参考mozilla上的ESM详细解析

构建阶段

首先,所有的工作会从入口模块开始。加载器会负责加载模块(从URL下载或文件系统加载)。加载完成后解析器会解析代码,生成模块记录。

模块记录

模块记录包含AST的模块代码、请求模块、导入实体对象、本地导出变量等。如果一个模块代码中使用到了import语句。那么也会进入上述重复的过程。所以这里的请求模块即一个模块依赖的其他模块。

模块记录会存到一个使用地址作为key的模块映射map中。这样就不会重复的加载和解析。

模块映射

我们知道非ESM情况下,解析器也会解析js代码为AST。ESM下由于多了模块所以如此复杂。

这一阶段完成后,我们就从入口文件,得到了一堆模块记录。

实例化阶段

实例化阶段主要就是将模块“连接”在一起,并将变量都指向相同的内存位置。

每个模块有自己的环境对象而相互隔离,因此各个模块可以存在相同的命名。对于模块记录中的所有导出,在内存中创建空间,并指向内存。由于此时代码还未执行,如果是变量,此时还是undefined。比如export var count = 1,此时count指向的内存的值为undefined。

如果是函数,指向的就是实际的函数内存区域,并不是undefined。

随后,引擎将根据模块记录,进行深度优先后序遍历(即最深的最先执行)。并将模块的导入也指向同一内存位置。这样的话,导入和导出指向同一内存。

这就是我们经常说的:ESM导出的是引用,而commonjs导出的是副本。

这一阶段完成后,我们就将所有模块,及导入/导出变量的内存地址都连接起来了。

执行阶段

最后一步就是执行代码,引擎将执行所有顶层代码(函数之外的代码)。并将变量的值填充到内存里。即上面的例子,count被设置为1。

要注意的是,正是由于模块映射的存在。能保证每个模块的代码,只执行一次,即使是在循环依赖的情况下。为什么模块代码必须只执行一次?这是因为执行代码是有副作用的。比如count++,每次执行都会变化。

实例化阶段一样,这个阶段也是深度优先后序遍历的。这是合理的,正因如此,前一个模块使用到的导入变量才会获取到正确的值。比如import val from "modA.js"modA.js先执行,所以val能获取到正确的值。

ESM和CommonJs的差异

接着对比一下ESM和CJS的模块加载机制带来的一些差异:

  • esm是静态的,即模块的依赖关系和引用在代码运行前就已经确定。而commonjs是动态的,模块在代码运行时解析,依赖关系无法提前确定。(所以import语句只能写在(top-leve)顶层作用域,不可在{}块级作用域中。而require则没有限制。这也导致了只有esm可以做treeshaking)。

  • esm可以认为是异步的,因为它整体分为了三阶段,并且每个阶段都可以单独完成。而commonjs则顺序的、同步的加载和运行模块,遇到require就去加载和运行新的模块,整个过程无法中断。

  • esm导出的是引用,而commonjs导出的是副本。(看下面的示例

  • esm代码执行是深度优先后序遍历执行的,而commonjs则是按代码顺序执行的。

比如以下情况:

// index.js
console.log("1");
import "./esm-a.js";
console.log("2");
import "./esm-b.js";
console.log("3");

// esm-a.js
console.log("a");
export default {}

// esm-b.js
console.log("b");
export default {}

输出的结果为:a => b => 1 => 2 => 3

如果在esm-a.js内部加上import "./esm-b.js",那输出的结果就变为:b => a => 1 => 2 => 3

同比之下,commonjs因为是同步顺序执行的,结果会比较符合常规。还是上面的例子,改为commonjs:

// index.js
console.log("1");
require("./esm-a.js");
console.log("2");
require("./esm-b.js");
console.log("3");

输出结果为:1 => a => 2 => b => 3

此外,esm支持动态导入模块,使用import()函数,可以在任意位置调用,无需top-level。比如import("./esm-a.js")会返回一个Promise,在Promise里可以获取到模块导出的内容。动态导入的模块只在调用import()函数时,才被解析、加载和执行。

// index.js
console.log("1");
import("./esm-a.js")
console.log("2");
import("./esm-b.js")
console.log("3");

输出结果为:1 => 2 => 3 => a => b

浏览器中ESM的特殊之处

浏览器中esm的加载一定是defer异步的。也就是说下面的代码是等同的:

<script type="module" src="./index.js"></script>
<script type="module" src="./index.js" defer></script>

BTW:defer属性的脚本会在html解析完成后执行并且保证defer脚本的执行顺序。而async脚本会在该脚本下载完成后立即执行,执行顺序不保证,执行时间也不保证。所以async适合与dom无关,独立运行的脚本。而defer适合不需要阻塞html解析的异步脚本。

执行顺序:html开始解析 => 同步script按顺序执行 => html解析完成 => defer脚本按顺序执行 => DOMContentLoaded事件 => 其他资源加载(link外部样式表,img的图片资源) => 全部完成触发onload事件。

Node中ESM的特殊之处

Node支持通过裸模块名加载本地模块。比如import fs from 'fs'。它会通过不断往上查找node_modules目录来加载对应模块。

Webpack上编译ESM和CommonJs

Webpack可以通过配置output.type将代码打包成各种模块规范的格式,我们可以根据库的目标运行环境来更改模块类型。

前面所讲的都是浏览器或Node对ESM模块的原生加载机制,而我们常常会使用Webpack这样的打包工具,对前端应用进行构建打包。Webpack目前对ESM支持还有限,它在构建ESM时,并不会保留ESM,而是转译成自己的实现,并最大限度的对齐ESM的规则和表现。

我们看一下Webpack在development模式下是如何实现esm模块的。(production模式下看不出来,因为做了代码压缩、treeshaking、模块合并等)。

拿下面的例子来看esm静态导入和动态导入模块,webpack编译后是什么样子:

// ========== index.js - 编译前
console.log("1");
{
    import("./esm-a.js").then(() => {
        console.log("esm-a loaded");
    })
}
console.log("2");
import "./esm-b.js"
console.log("3");

// ========== index.js - 编译后
__webpack_require__.r(__webpack_exports__);
var _esm_b_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./esm-b.js");
console.log("1");
{
    __webpack_require__.e("esm-a_js").then(__webpack_require__.bind(__webpack_require__, "./esm-a.js")).then(() => {
        console.log("esm-a loaded");
    })
}
console.log("2");
console.log("3");

可以看到静态import的模块,被提到了代码顶部,并通过__webpack_require__()函数加载模块。动态import()的模块还在原来的位置,通过__webpack_require__.ePromise化后再加载对应模块,并在模块加载后执行回调函数。

webpack处理CommonJs的就比较简单了。直接使用__webpack_require__()加载模块即可。

// ========== index.js - 编译前
console.log("1");
require("./esm-b.js");
console.log("2");

// ========== index.js - 编译后
console.log("1");
__webpack_require__("./esm-b.js");
console.log("2");

webpack_require

也顺便看看webpack的__webpack_require__()方法做了什么。

// __webpack_modules__是一个对象。
var __webpack_modules__ = ({
    "./esm-b.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
        eval(/** code省略 */);
    }),

    "./index.js": ((__unused_webpack_module, __unused_webpack_exports, __webpack_require__) => {
        eval("console.log(\"1\");\n__webpack_require__(/*! ./esm-b.js */ \"./esm-b.js\");\nconsole.log(\"2\");\n\n//# sourceURL=webpack://esm/./index.js?");
    })
});

// 模块缓存
var __webpack_module_cache__ = {};

// require函数实现,用来加载模块
function __webpack_require__(moduleId) {
  var cachedModule = __webpack_module_cache__[moduleId]
  if (cachedModule !== undefined) {
    return cachedModule.exports
  }

  var module = __webpack_module_cache__[moduleId] = {exports: {}}

  __webpack_modules__[moduleId](module, module.exports, __webpack_require__)

  return module.exports
}

// 执行导入入口文件
__webpack_require__("./index.js");

可以看到,__webpack_modules__用来存储所有的模块(不包含异步模块)。以模块路径为key,值为对应模块的工厂函数。

__webpack_require__函数用来加载模块。它会首先检查缓存,如果有缓存就直接返回缓存。否则调用__webpack_modules__中对应模块的工厂函数。

工厂函数内,直接eval执行了编译后的模块代码。模块代码的作用域限定在函数内,工厂函数提供的三个参数即模块代码的执行上下文。执行模块代码时:使用__webpack_require__导入其他模块。导出内容输出到__webpack_exports__上。

最后执行入口文件的require。从而形成了整个闭环。

其他

  1. ESM的特性之一就是导出的是引用,假如值被某模块修改了,其他模块获取的仍是最新值。ESM的实现导入和导出都指向同一块内存地址。Webpack在实现这个特性时,是通过Object.definedProperty设置get()函数。原始值始终是在原模块内,其他模块通过get()取到的始终是原模块内的值。

    // esm-b.js - 编译前
    export var a = 1
    export function setA() {
        a++
    }
    
    // esm-b.js - 编译后并展开__webpack_require__.d
    Object.defineProperty(__webpack_exports__, 'a', {
        get: () => (a),
    });
    Object.defineProperty(__webpack_exports__, 'setA', {
        get: () => (setA)
    });
    
    var a = 1
    function setA() {
        a++
    }
    
  2. 对于使用动态加载语法的模块,前面我们看到了是通过__webpack_require__.e处理的。简单说一下:

    • 动态模块会被单独提到一个chunk中,生成对应的bundle.js。动态模块不会出现在初始的__webpack_modules__里。

    • 生成的bundle.js里,会把自己执行代码和模块id都push到chunkLoadingGlobal

    • __webpack_require__.e会执行一系列promise。

    • 加载的promise函数内,会通过script标签加载bundle.js。

    • 加载成功后会执行webpackJsonpCallback函数,将该模块的id和函数代码添加到__webpack_modules__上。

    • promise执行下一步。调用__webpack_require__执行模块代码。

    详细参考https://blog.youkuaiyun.com/letterTiger/article/details/136977101

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值