ECMAScript Modules(ES Modules)是ECMAScript 6(ES6)规范中引入的一项重要特性,于2015年发布。ES6是ECMAScript标准的第六个版本,也被称为ECMAScript 2015。

ES Modules提供了一种新的模块系统,以改进JavaScript中模块化的方式。它引入了importexport关键字,允许开发者更方便地组织和管理JavaScript代码。

ES6 标准的发布时间为 2015 年,但由于不同JavaScript引擎的实现进度不同,一些浏览器和环境可能在稍后才完全支持ES Modules。随着时间的推移,大多数现代的浏览器和Node.js 等环境已经广泛支持ES Modules。

1.ES Modules与CommonJS模块化的区别

在ES Module之前,JavaScript模块化一直流行的模块化规范有: CommonJS, AMD, CMD,Node.js的模块化之前一直采用CommonJS规范。

ES Modules与CommonJS模块化的不同之处,在语法、加载方式和一些概念上存在一些显著的区别。以下是它们之间的主要不同之处:

  1. 语法

    • ES Modules: 使用 importexport 关键字来导入和导出模块。例如:
      1// 导入模块
      2import { someFunction } from './module';
      3
      4// 导出模块
      5export function myFunction() {
      6  // some code
      7}
      
    • CommonJS: 使用 require 函数来导入模块,而使用 module.exportsexports 对象来导出模块。例如:
      1// 导入模块
      2const someFunction = require('./module');
      3
      4// 导出模块
      5module.exports = { myFunction };
      
  2. 加载方式

    • ES Modules: 支持异步加载,可以通过 import() 函数在运行时动态加载模块。例如:
      1import('./module')
      2  .then((module) => {
      3    // 使用导入的模块
      4  })
      5  .catch((error) => {
      6    // 处理加载错误
      7  });
      
    • CommonJS: 通常是同步加载模块的。在运行时动态加载模块的能力相对有限。
  3. 运行时特性

    • ES Modules: 在模块级别上有更严格的作用域,每个模块都是单独的作用域,不会将变量泄漏到全局作用域。默认情况下是严格模式。
    • CommonJS: 模块是同步执行的,变量是在模块的顶层定义的,会被添加到模块的 exports 对象上,可能导致变量泄漏到全局作用域。
  4. 动态导入

    • ES Modules: 支持动态导入,可以在运行时动态决定导入哪个模块。
    • CommonJS: 不太适用于动态导入,通常在模块的顶部进行静态导入。
  5. 循环依赖:

    • ES Modules: 在处理循环依赖时,会创建一个未初始化的引用,直到模块完全加载并执行。
    • CommonJS: 允许循环依赖,但可能导致未定义或未初始化的值。

2.开始使用ES Modules

2.1 基本使用

index.html:

 1<!DOCTYPE html>
 2<html lang="en">
 3<head>
 4    <meta charset="UTF-8">
 5    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 6    <title>Document</title>
 7</head>
 8<body>
 9    <script src="./mod1.js" type="module"></script>
10    <script src="./main.js" type="module"></script>
11</body>
12</html>

mod1.js:

 1const sname = 'foo'
 2const score = 100
 3
 4function showMessage() {
 5    console.log('hello world')
 6}
 7
 8export {
 9    sname,
10    score,
11    showMessage
12}

main.js:

1import { showMessage } from "./mod1.js";
2
3showMessage()

2.2 其他导入导出方式

导出时使用别名:

 1const sname = 'foo'
 2const score = 100
 3
 4function showMessage() {
 5    console.log('hello world')
 6}
 7
 8export {
 9    sname as studentName,
10    score,
11    showMessage
12}

定义变量时直接导出:

1export const sname = 'foo'
2export const score = 100
3
4export function showMessage() {
5    console.log('hello world')
6}

导入时使用别名:

1import { showMessage, sname as studentName } from "./mod1.js";
2
3showMessage()
4console.log(studentName)

导入时为整个模块使用别名:

1import * as mod1 from "./mod1.js";
2
3mod1.showMessage()
4console.log(mod1.sname)

2.3 export from

mod1.jsmod2.js

mod2.js:

1// 导出一个变量
2export const foo = 'Hello from mod2!';

mod1.js:

1// 导入 mod2.js 中的 foo,并将其重命名为 bar, 同时将bar导出
2export { foo as bar } from './mod2.js';
3
4// 在当前模块中使用重命名后的标识符 bar
5console.log(bar); // 输出:Hello from mod2!

在这个例子中,mod2.js 导出了一个变量 foo。然后,在 mod1.js 中使用 export { foo as bar } from './mod2.js' 导入了 foo,并将其重命名为 bar。最后,通过 console.log(bar) 在当前模块中使用了重命名后的标识符 bar

这样的语法对于在当前模块中使用更具描述性的标识符,或者解决命名冲突问题非常有用。

export from相当于export和import的结合使用。export from的使用场景如下:

  1. 重新导出模块内容: 当你有一个模块,想要在另一个模块中重新导出其中的一些内容时,可以使用 export from。这样可以简化导出的过程,而不需要逐一列举每个成员。

    1// module1.js
    2export const a = 1;
    3export const b = 2;
    4
    5// module2.js
    6export { a, b } from './module1';
    

    在这个例子中,module2.js 重新导出了 module1.js 中的 ab

  2. 模块合并: 当你想要将多个模块的内容合并到一个新的模块中时,export from 也很有用。

    1// module1.js
    2export const a = 1;
    3
    4// module2.js
    5export const b = 2;
    6
    7// combinedModule.js
    8export { a } from './module1';
    9export { b } from './module2';
    

    在这个例子中,combinedModule.js 合并了 module1.jsmodule2.js 中的内容。通常在开发和封装一个功能库时,通常我们希望将暴露的所有接口放到一个文件中。

  3. 模块重命名: 可以使用 as 关键字为导出的成员取一个新的名称。

    1// module1.js
    2export const a = 1;
    3
    4// module2.js
    5export { a as renamedA } from './module1';
    

    在这个例子中,module2.jsmodule1.js 中的 a 导出为 renamedA

export from 提供了一种简便的语法,使得模块的导出和合并更加灵活和清晰。

2.4 export default用法

在ES模块中,export default语法用于导出模块的默认值。一个模块只能有一个默认导出。当其他模块导入这个模块时,可以选择不使用大括号,直接引用默认导出的值。

以下是export default的用法示例:

在模块中:

1// module.js
2
3// 导出默认值
4const myDefault = 'Default Value';
5export default myDefault;
6
7// 导出其他值
8export const otherValue = 'Other Value';
9export const anotherValue = 'Another Value';

在导入模块的其他地方:

1// importDefault.js
2
3// 导入默认值
4import myDefault1 from './module';
5
6console.log(myDefault1); // 输出: Default Value

在上面的例子中,module.js模块使用export default导出了一个默认值 myDefault。在importDefault.js模块中,我们可以直接导入并使用这个默认值,而不需要使用大括号。

总结一下:

  • 一个模块中只能有一个export default
  • export default默认导出时不需要指定名字
  • 在导入默认导出的值,不需要使用{},并且可以自定义导入的名字
  • export default方便我们与现有的CommonJS等规范相互操作

2.5 import函数

在ES模块中,import()函数是动态导入模块的方式。与静态的import语句不同,import()函数允许你在运行时根据条件加载模块,这对于按需加载模块或在特定条件下加载模块非常有用。

基本语法:

1import(modulePath)
2  .then((module) => {
3    // 使用加载的模块
4  })
5  .catch((error) => {
6    // 处理加载模块时的错误
7  });

例子:

 1// 普通的静态导入
 2import { myFunction } from './myModule';
 3
 4// 使用import()函数动态导入
 5const modulePath = './myModule';
 6
 7import(modulePath)
 8  .then((myModule) => {
 9    myModule.myFunction();
10  })
11  .catch((error) => {
12    console.error('模块加载失败:', error);
13  });

主要特点:

  1. 返回Promise: import()函数返回一个Promise,因此可以使用thencatch来处理异步加载的模块。

  2. 动态路径: import()的参数可以是一个包含模块路径的表达式,这使得模块路径可以在运行时动态计算。

  3. 按需加载: 通过使用import(),你可以在需要的时候按需加载模块,而不是一开始就加载所有模块。

  4. 适用于异步场景: 当需要在异步场景中加载模块时,import()是一个有用的工具,例如在异步函数中加载模块。

  5. 不支持静态分析: 由于import()是在运行时执行的,而不是在静态分析阶段,在一些工具中可能无法完全支持静态分析和代码智能提示。

3.ES Module的加载流程

《ES modules: A cartoon deep-dive》这篇文章详细介绍了ES Module是如何被浏览器解析并且让模块之间可以相互引用的。

当使用ES Modules进行开发时,实际上以入口文件(如main.js)为根节点创建出一张依赖图。其中不同依赖之间的连接通过import声明。

04_import_graph-500x291.png

这些导入声明就是让浏览器或者Node知道需要加载哪些代码。给定一个文件作为依赖图的入口,浏览器就会按照导入声明来依次加载代码。

但是浏览器不会使用文件本身。它需要将所有的文件解析为一种叫做模块记录Module Records的数据结构才能让浏览器了解文件内容。

hicc_me-270170697_05_module_record-768x441.png

然后,模块记录需要转变为模块实例。

ES Module的加载解析流程分为3个阶段:

  • 阶段一:构造(Construction),查找、下载并解析所有文件到模块记录中(Module Record)
  • 阶段二:实例化(Instantiation),对模块记录进行实例化为模块环境记录,并且分配内存空间,在内存中寻找一块区域来存储所有导出的变量(但还没有填充值)。然后让exportimport都指向这些内存块。这个过程叫做链接(linking),解析模块的导入和导出语句,把模块指向对应的内存地址
  • 阶段三:求值(Evaluation),运行代码,计算值,并且将值填充到内存地址中

07_3_phases.png

所有的模块,在任何一个阶段都只有一个实例。浏览器会缓存模块实例,这样可以避免当一个模块被多个模块依赖的时候被下载和解析多次。

参考