ES Module从语法到原理详解

1,762 阅读12分钟

ES Module使用详解

ES Module 系列:

一、什么是ES Module

历史上,JavaScript 一直没有模块(module)体系,无法将一个大程序拆分成互相依赖的小文件,再用简单的方法拼装起来。其他语言都有这项功能,比Python 的import,甚至就连 CSS 都有@import,但是 JavaScript 任何这方面的支持都没有,这对开发大型的、复杂的项目形成了巨大障碍。

在 ES6 之前,社区制定了一些模块加载方案,最主要的有 **CommonJS 和 AMD **两种。前者用于服务器,后者用于浏览器。

ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。

CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。

Can I Use

截止本篇文章为止(2022-05-15),通过参看Can I Use上ES Module的使用情况可知:在浏览器中的整体使用率为 93%,在各大主流浏览器都得到支持(除IE外)。

ES Module 和 CommonJS

区别

  • ES Module 输出的是值的引用,而 CommonJS 输出的是值的拷贝;
  • ES Module 是编译时执行,而 CommonJS 模块是在运行时加载;
  • ES6 Module可以导出多个值,而CommonJs 是单个值导出;
  • ES6 Module 静态语法只能写在顶层,而CommonJs 是动态语法可以写在判断里;
  • ES6 Module的 this 是 undefined,而CommonJs 的 this 是当前模块;

使用比较

CommonJS模块
// CommonJS模块
let { stat, exists, readfile } = require('fs');

// 等同于
let _fs = require('fs');
let stat = _fs.stat;
let exists = _fs.exists;
let readfile = _fs.readfile;

CommonJS模块代码的实质是整体加载fs模块(即加载fs的所有方法),生成一个对象(_fs),然后再从这个对象上面读取 3 个方法。

这种加载称为**“运行时加载”**,因为只有运行时才能得到这个对象,导致完全没办法在编译时做“静态优化”。

ES6模块

ES6 模块不是对象,而是通过export命令显式指定输出的代码,再通过import命令输入。

// ES6模块
import { stat, exists, readFile } from 'fs';

ES6模块代码的实质是从fs模块加载 3 个方法,其他方法不加载。这种加载称为**“编译时加载”或者静态加载**,即 ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。当然,这也导致了没法引用 ES6 模块本身,因为它不是对象。

由于 ES6 模块是编译时加载,使得静态分析成为可能。有了它,就能进一步拓宽 JavaScript 的语法,比如引入宏(macro)和**类型检验(type system)**这些只能靠静态分析实现的功能。

除了静态加载带来的各种好处,ES6 模块还有以下好处。

  • 不再需要UMD模块格式了,将来服务器和浏览器都会支持 ES6 模块格式。
  • 浏览器的新 API 就能用模块格式提供,不再必须做成全局变量或者navigator对象的属性。
  • 不再需要对象作为命名空间(比如Math对象),这些功能可以通过模块提供。

二、ES Module 语法

1、export 命令

  • export命令用于规定模块的对外接口,即允许外部引用的部分;
  • export命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系;
  • export语句输出的接口,与其对应的值是动态绑定关系(可以理解为引用类型),即通过该接口,可以取到模块内部实时的值
  • export命令可以出现在模块顶层作用域的任何位置。如果处于块级作用域内,就会报错;
// export

// export方式1
export var/let/const a = 'hello'/1/function(){}/class/{}/true; //导出的可以是类型类型,包括类(class)

// export方式2
var/let/const b = 'hello'/1/function(){}/class/{}/true;
export {b};

// export方式3:export命令除了输出变量,还可以输出函数或类(class)
export function addNum(x, y) {
  return x + y;
};

2、import 命令

  • import命令用于输入其他模块提供的功能,通过import命令加载引入模块;
  • import命令输入的变量都是只读的,它的本质是输入接口,不允许在加载模块的文件中修改进入变量的值;但是如果引入变量是一个对象,改写引入变量的属性是允许的;
  • import后面的from指定模块文件的位置,可以是相对路径,也可以是绝对路径;
  • import命令具有提升效果,会提升到整个模块作用域的头部,首先执行;
  • import是静态执行,引入语句中不能使用表达式和变量(表达式和变量是在运行时才能得到结果的语法结构);
  • import语句会执行所加载的模块,重复执行同一句import语句,那么只会执行一次;
  • import除了指定加载某个输出值,还可以使用整体加载,即用星号(*)指定一个引入对象,所有输出值都加载在这个对象上;
// 引入提升
a();
import { a } from 'test';

// 整体引入
import * as b from 'test';
b.fn();

// 修改引入对象的属性
b.c = [];

静态执行特点

// 报错:引入使用 表达式
import { 'he' + 'llo' } from 'sayHi';

// 报错:引入使用 变量
let module = 'sayHi';
import { hello } from module;

// 报错:引入使用 代码块结构
if (x === 1) {
  import { hello } from 'sayHi';
} else {
  import { baybay } from 'sayHi';
}

上面引入都会报错,因为在静态分析阶段,这些语法都是没法得到值的

报错原因

import命令会被 JavaScript 引擎静态分析,先于模块内的其他语句执行;

引擎处理import语句是在编译时,这时不会去分析或执行表达式、变量和代码块结构,所以import语句中包含表达式、变量或者放在代码块结构没有意义。

3、export default

按照正常逻辑,使用import命令的时候,开发者需要知道所要加载的变量名或函数名,否则无法加载,但是为了方便开发者使用,不拘泥于文档说明,于是提供了export default命令,为模块指定默认输出。

// export-default
export default function () {
  console.log('sayHi');
}

// import-default
import dfName from './export-default';
dfName(); // 'sayHi'

区别

一个模块只能有一个默认输出,export default 命令只能使用一次;对于import命令后面不用加大括号,因为只有唯一对应export default命令。

本质:export default输出了一个叫做default的变量或方法,引入时系统允许你为它取任意名字;

import语句中,可以同时输入默认方法其他接口

import _, { hello } from 'sayHi';

对应export:

export default function (obj) {
  // ···
}

export function hello() {
  // ···
}

export { hello };

4、export 与 import 的复合

在一个模块之中,先输入一个模块,最后再输出同一个模块:

注意:写成一行的方式中,hello实际上并没有被导入当前模块,只是相当于对外转发了这个接口,于是当前模块不能使用hello

export { hello } from 'sayHi';

// 理解为
import { hello } from 'sayHi';
export { hello };

5、import()

import的不足

import静态分析有利于编译器提高效率,但导致无法在运行时加载模块。因此,条件加载就无法实现。

因此import无法取代 Node 的require方法。因为require是运行时加载模块,import命令无法取代require的动态加载功能。

const path = './' + fileNamePath;
const myModual = require(path);

import()函数

import()函数在ES2020提案 中被引入,用以支持动态加载模块。

import(from)
  • import函数的参数from,即加载的模块的位置。

  • import命令能够接受什么参数,import()函数就能接受什么参数,两者区别主要是后者为动态加载。

  • import()返回一个 Promise 对象

特点
  • import()函数可以用在任何地方,模块,非模块的脚本都可以使用;
  • 运行时执行(什么时候运行到这一句,就会加载指定的模块);
  • import()函数与所加载的模块不是静态连接关系,与import语句完全不同;
  • import()更像require方法,主要区别是import()是异步加载,require是同步加载;

参考

ES Module原理详解

ES Module 系列:

一、ES Modules如何工作

当前,在浏览器中通过 <script type="module"> 已原生支持 ESM。以vite创建的Vue3项目为例:

// index.html文件
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <link rel="icon" href="favicon.png" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>xxxx</title>
    </head>
    <body>
        <div id="app"></div>
        <script type="module" src="/src/main.ts"></script>
    </body>
</html>

其中/src/main.ts是Vue3的入口文件。

注意:浏览器中ES Module是异步加载,不会堵塞浏览器,即等到整个页面渲染完,再执行模块脚本。如果网页有多个ESM,它们会按照在页面出现的顺序依次执行。

流程简析

1、创建AST

当使用ES Modules进行开发时,实际上以入口节点为根节点(如main.js)创建出一张依赖关系图。不同依赖项之间通过export\import语句来进行关联。

2、生成 Module Record

浏览器无法直接使用文件本身,但是浏览器会解析这些文件,根据 import/export 语句构成模块记录(Module Record),每个模块文件对应生成一个 Module Record,记录当前模块的信息:

3、转化 Module Instance

模块记录转化为模块实例,浏览器最终能够读取也就是Module Instance

二、模块加载

模块加载的过程就是从入口文件到拥有一个完整的模块实例图的过程,对于 ES Module 来说,分三步进行:

构造:查找、下载并解析所有文件到模块记录中。

实例化:在内存中寻找一块区域来存储所有导出的变量(但还没有填充值)。然后让 export 和 import 都指向这些内存块。这个过程叫做链接(linking)

求值:在内存块中填入变量的实际值。

1、构造

在构造阶段,每个模块都会经历三件事情:

  • 查找:找出从哪里下载包含该模块的文件(也称为模块解析);

    通常会有一个入口文件,然后通过import代码去寻找与之关联的其他模块,形成一个依赖关系树(AST)

  • 下载:获取文件(从 URL 下载或从文件系统加载);

    解析文件前,需要一层一层地遍历树,找出它的依赖项,然后找到并加载这些依赖项;

  • 解析:将文件解析为模块记录;

    把解析出来的模块构成表 称为 Module Record (模块记录)。

    Module Record 包含了当前模块的 AST,引用了哪些模块的变量,以及一些特定属性和方法。

    一旦 Module Record 被创建,它会被记录在模块映射Module Map中。被记录后,如果再有对相同 URL 的请求,Loader 将直接采用 Module MapURL 对应的Module Record

在构造过程结束时,从主入口文件变成了一堆模块记录Module Record

2、实例化

实例化阶段:将构造的模块实例化并将所有实例链接在一起。

模块实例包含两部分:代码、状态

状态存在于内存中,因此实例化步骤就是写入内存。

首先,JS引擎创建一个模块环境记录(Module Enviroment Record)来管理 Module Record 的变量。然后它在内存中找到所有导出内容对应的位置。模块环境记录将跟踪内存中导出内容对应的位置与导出内容间的联系。

此时内存中的这些位置中还不会存放值,只有在计算后才会有值。

注意,导出和导入都指向内存中的同一位置。首先链接导出,可确保所有导入都可以链接到匹配的导出。

ES Module 的这种连接方式被称为 Live Bindings(动态绑定);

ES 模块使用称为动态绑定的东西。两个模块都指向内存中的相同位置。这意味着当导出模块更改值时,该更改将显示在导入模块中。导出值的模块可以随时更改这些值,但导入模块不能更改其导入的值,虽然有此限制,但是如果一个模块导入一个对象,导入模块中可以更改该对象上的属性值。

拥有这样的动态绑定可以使我们在不运行任何代码的情况下连接所有模块。

实例化结束时,已经连接了export/import变量的所有实例和内存位置。

3、求值

最后一步,在内存区中填充绑定的数据的值。

JS 引擎通过执行顶层代码(函数之外的代码,此处可以理解为模块文件中顶层作用域中的代码)来给内存区的引用赋值。

总结

  • ES Module执行分为三个阶段:构造阶段、实例化阶段、求值阶段

  • 构造阶段:

    • 1、根据入口创建依赖关系的AST;
    • 2、下载module文件,用于解析;
    • 3、解析每个module文件,生成 Module Record(包含当前module的AST、变量等);
    • 4、将Module Record 映射到 Module Map中,保持每个module文件的唯一性;

    构造阶段最后生成根据依赖关系AST的 Module Record的依赖树,同时将每个Module Record映射保存到Module Map中。

  • 实例化阶段:

    • 1、生成模每个Module Record的块环境记录(Module Enviroment Record),用来管理 Module Record 的变量等;
    • 2、在内存中写入每个Module的数据,同时 Module文件的导出export和引用文件的 import指向该地址;

    实例化阶段确定了 export和import内存中的指向,同时该内存空间中定义了Module文件的变量(但是还未赋值);

  • 求值阶段:

    • 1、执行对应Module文件中顶层作用域的代码,确定实例化阶段中定义变量的值,放入内存中;

    求值阶段确定了Module文件中变量的值,由于 ES Module使用的是动态绑定(指向内存地址),export中修改数据会映射到内存中,import数据相应也会改变。

参考

ES modules: A cartoon deep-dive