深入理解es module

5,914 阅读6分钟

模块系统的作用

传统script标签的代码加载容易导致全局作用域污染,而且要维系一系列script的书写顺序,项目一大,维护起来越来越困难。模块系统通过声明式的暴露和引用模块使得各个模块之间的依赖变得明显。

es module如何工作的

这部分推荐去看es-modules-a-cartoon-deep-dive,原文里有图,以下的内容是个人理解整理。

分三步:

  1. 构造,寻找并且下载所有的文件并且解析成模块记录(Module Records)(包含当前模块代码的抽象语法树,当前模块的依赖模块的信息)。
  2. 实例化,将模块记录实例化将各个模块之间的import,export部分对应的都在内存中指向到一起(linking)
  3. 执行,将import, export内存里指向的地址填上实际的值。

构造阶段(Construction)

构造阶段要做三件事情:

解释(interpret)import后的模块指示符(module specifier)成实际url或者文件地址

不同平台根据自己平台的模块解析算法(Module Resolution Algorithm)解释模块指示符,浏览器端目前只接受url做为指示符。不过浏览器将来会同样支持内置模块比如kv-storage

模块指示符里的变量

模块指示符里不能有变量但是node中commonJS是可以有的,因为在commonJS的模块代码里,require声明前的代码是会先执行的,es module是最后一步再去执行,这一步才知道各个变量的具体值是多少。所以可以在node中有如下写法:

    require(`${path}/sum.js`);

不过es module里有另一种写法动态引入import()可以支持在代码执行时动态引入模块,可以在指示符里携带变量

    import(`${path}/sum.js`);

浏览器根据url下载文件或者node根据文件地址去加载文件

将文件解析成模块记录

浏览器解析常规js文件时会解析完后再执行。和模块的解析策略不一样,这里要告诉浏览器解析的是个模块。在html中:

  <script type="module">
    import {sum} from "./sum.js"
  </script>

ps: 在node中因为没有浏览器这种类似打tag的形式,有种方案是模块文件是.mjs后缀结尾的方案,不过目前尚未敲定。

解析模块文件为模块记录,找到依赖的模块再去下载模块然后解析成模块记录,直到所有的模块都解析成模块记录为止。模块记录会存在当前全局的一个模块映射里(Module Map),可以理解成一个缓存,下次再有相同url的模块请求就直接从模块映射里拿出模块记录即可。

实例化阶段

将上面得到的模块记录类实例化。 首先在内存中指定位置给各个模块的export导出的变量或者函数,接着将模块中对应的import部分同样指向对应的export的内存地址。 举个🌰

// main.js
import {obj} from "./obj.js"

// obj.js
const obj = {a: 123};
export {obj}

obj.js文件里导出的objmain.js文件里引用的obj是指向同一个内存地址的,这中方法就是动态绑定(live binding)。

    <script type='module'>
      import {obj} from "./obj.js"
      console.log(obj);  //{a: 123}
      setTimeout(() => {
       console.log(obj)  //{b: 233}
      }, 2000);
    </script>
    let obj = {
        a: 123
    };
    setTimeout(() => {
        obj = { b: 233 };
    }, 1000);
    export { obj };

下面我们看下node中同样的代码的效果。

// test1.js
    var obj = require("./test2.js");
    console.dir(obj); // {a: 123}
    setTimeout(() => {
        console.dir(obj); // {a: 123}
    }, 2000);
// test2.js
    let obj = { a: 123 };
    setTimeout(() => {
    obj = { b: 233 };
    }, 1000);
    module.exports = obj;

在commonJS中require一个对象是在内存中复制一份导出模块的对象。动态绑定主要解决的问题就是循环引用的问题,循环引用在下面的执行阶段进行解释。 注意: es module中可以在模块导出的部分更改导出值如上面代码所示,但是不能在引入部分更改。

    import {obj} from "./sum.js"
    obj = '233'  // Uncaught TypeError: Assignment to constant variable.

如上报错会提示不能给常量赋值,不过如果是对象的话可以更改内部的key,由于动态绑定的原因,导出部分也会发生改变

// main.js
    import {obj} from "./obj.js"
    setTimeout(() => {
        obj.a = '嘻嘻'
    }, 1000);
// obj.js
    let obj = { a: 123 };
    console.log(obj); // {a: 123}
    setTimeout(() => {
        console.log(obj); // {a: "嘻嘻"}
    }, 2000);
    export { obj };

执行阶段(evaluate)

原文中是evaluate,我这里理解成了执行,如有不对欢迎指出。引擎开始执行模块了,每个模块只会被执行一次。在上面提到过的module map里的模块记录里会存有当前模块的状态是实例化中还是实例完成还是执行完成等。可以避免同一个模块文件被多次执行。

循环引用问题

如下在node中,两个模块互相引用。

// test1.js
    var b = require("./test2").b;
    console.dir("test1: " + b);  // 'test1: test2' 🥈
    var a = "test1";
    exports.a = a;
// test2.js
    var a = require("./test1").a;
    console.log("test2: " + a);  // test2: undefined 🥇
    var b = "test2";
    setTimeout(() => {
        console.log("test2: " + a); // test2: undefined 🥉
    }, 1000);
    exports.b = b;

    node test1.js // 启动

ps: emoji里表示打印顺序 node执行某个模块时会将当前模块的代码放入函数中,向这个函数传递module, module.exports, __dirname等参数。初始的module就是一个空对象。 test1.js执行遇到require('./test2)时会进入test2模块开始执行,这个时候又碰到引用test1模块的东西;因为test1模块没有执行完成,它的module.exports还是空对象,所以这个时候test2里的aundefined。因为commonJS不是动态绑定的,so等到test1模块执行完a变量里还是undefined es module

// es1 
    import { b } from "./es2.js";
    console.log("es1: " + b); // es1: es2 🥈
    var a = "es1";
    export { a };
// es2
    import { a } from "./es1.js";
    console.log("es2: " + a); // es2: undefined 🥇
    var b = "es2";
    setTimeout(() => {
        console.log("es2: " + a); // es2: es1 🥉
    }, 1000);
    export { b };

以上代码入口是es1文件。根据打印顺序来看先是执行的es2模块,之后es1里的a填充了实际值,由于是动态绑定es2中的a中的值也在之后能取到值了。

es module的好处

  1. 动态绑定解决了循环调用的问题(见上文)
  2. 静态分析(statically analysis) 因为在代码未执行阶段就已经知道当前模块导入了什么,导出了什么,所以有些工具就可以进行静态分析。比如vscode中引入模块代码时会提示当前模块里导出的内容。

es module的坏处

  1. 兼容性
  2. 尚未有针对node的解决方案

参考资料

es-modules-a-cartoon-deep-dive

module-exports

ecma262