前言
Hello~大家好。我是秋天的一阵风
作为前端工程师,我们每天都在用 import
和 export
,但你知道它们背后到底发生了什么吗?本文将带你深入了解 ES Module 的运行机制,探索浏览器中模块加载的细节。别眨眼,干货满满!
Tips:
如果你对CommonJS感兴趣,还可以阅读我的这篇文章: 一道Commonjs的笔试题,让我彻底搞懂require机制!
一、什么是ES Module ?
ES Module 是 ECMAScript(JavaScript 的标准)
中用于模块化代码的规范。它是 JavaScript
原生支持的模块化语法,旨在解决代码组织、复用和依赖管理的问题。ES Module 最初在 ECMAScript 2015(ES6)
中引入,并在后续版本中不断完善。
1. ES Module 的特点
1.1 原生支持
ES Module
是 JavaScript 语言标准的一部分,由浏览器和 Node.js 原生支持,无需额外的构建工具(如 Webpack 或 Babel)即可使用。
1.2 静态导入
ES Module
使用静态导入(import
)和导出(export
)语法,这意味着模块的依赖关系在代码加载时就已经确定。这与 CommonJS(Node.js 中的模块系统)的动态 require
不同,使得工具(如 Webpack)能够进行静态分析和优化。
1.3 单例模式
每个模块文件被视为一个独立的作用域,模块中的变量和函数不会污染全局作用域。模块的导出内容是单例的,即多次导入同一个模块时,返回的是同一个实例。
1.4 支持异步加载
ES Module 支持动态导入(import()
),返回一个 Promise,可用于按需加载模块,优化应用性能。
2. ES Module 的语法
2.1 导出(Export)
在模块中,可以使用 export
关键字导出变量、函数或类。
JavaScript
// myModule.js
export const name = "Kimi";
export function sayHello() {
console.log("Hello, I'm Kimi!");
}
export class Animal {
constructor(name) {
this.name = name;
}
}
也可以使用默认导出(default
),每个模块只能有一个默认导出。
JavaScript
// myModule.js
const name = "Kimi";
function sayHello() {
console.log("Hello, I'm Kimi!");
}
export default { name, sayHello };
2.2 导入(Import)
在其他模块中,可以使用 import
关键字导入导出的内容。
JavaScript
// 使用命名导出
import { name, sayHello, Animal } from "./myModule.js";
console.log(name); // Kimi
sayHello(); // Hello, I'm Kimi!
const dog = new Animal("Buddy");
对于默认导出,可以使用任意名称导入:
JavaScript
// 使用默认导出
import myModule from "./myModule.js";
console.log(myModule.name); // Kimi
myModule.sayHello(); // Hello, I'm Kimi!
2.3 动态导入
ES Module 支持动态导入,返回一个 Promise,可用于按需加载模块。
JavaScript
// 动态导入模块
async function loadModule() {
const myModule = await import("./myModule.js");
myModule.sayHello(); // Hello, I'm Kimi!
}
loadModule();
二、ES Module 与 CommonJS 的区别
特性 | ES Module | CommonJS |
---|---|---|
导入方式 | 静态导入(import ) |
动态导入(require ) |
导出方式 | 命名导出和默认导出 | 默认导出(module.exports ) |
单例模式 | 是 | 是 |
浏览器支持 | 原生支持 | 需要构建工具(如 Browserify) |
Node.js 支持 | v12+,需设置 "type": "module" |
默认支持 |
性能优化 | 支持静态分析和树摇优化 | 动态加载,难以优化 |
三、ES Module 的运行原理
ES Module 的运行过程可以分为以下几个阶段。
1. 补全路径和下载文件
当代码中出现 import
语句时,JavaScript 引擎会首先解析模块路径,将其转换为模块的绝对路径 。这个过程与 require
类似,但 ES Module 支持更多路径格式,如相对路径(./
或 ../
)和绝对路径(/
)。
2. 解析文件
ES Module拿到文件以后会怎么解析文件呢?
它会先去找文件中的所有顶级静态导入语句 ,这里有两个关键词,一个是静态 ,一个是顶级。
静态导入 这个非常简单,除了使用import函数
导入以外的都属于静态导入,例如:
css
import a from "./a.js";
顶级是啥意思呢,也就是写在全局中的导入语句,像if语句或者for循环里面的都不属于顶级。
我们以上面的代码案例中main.js
文件来举例,
javascript
//main.js
import a from "./a.js";
import("./d.js").then((val) => {
console.log("main.js val:", val.default);
});
console.log("main:", a, b);
import b from "./b.js";
在main.js
文件中,符合顶级静态导入语句这个限定词的就只有两个导入语句:
javascript
import a from "./a.js";
import b from "./b.js";
3. 递归处理
拿到了以上两个导入语句以后,就会递归进行进一步的解析,也就是对a.js
和b.js
文件,把之前的补全路径和下载文件流程再递归走一遍
4. 建立Module Map映射表
EsModule会 生成一份名为Module Map的映射,以url为key,每个url对应一份Module Record
每份Module Record记录了当前模块导入和导出的信息:
- 导入信息:当前模块依赖的其他模块、当前模块导入的变量
- 导出信息:当前模块导出的变量
这样做是为了对模块信息进行缓存:当某个模块a被多个模块依赖时,我们能在Module Map中找到下载模块a的url,从而使得模块a最终只被下载一次
5. 模块执行
模块加载完成后,JavaScript 引擎会执行模块代码。ES Module 的执行顺序是 按导入顺序 执行的,这意味着如果模块 A 导入了模块 B 和模块 C,那么模块 B 和模块 C 会先于模块 A 执行。
在执行过程中,模块的导出内容会被缓存,多次导入同一个模块时,返回的是同一个实例。
下面,我们通过一个案例代码中来探究执行打印顺序是怎么样的。
四、 案例代码
我们创建几个文件,其中在main.js
文件中,d.js
文件是通过动态导入的方式,代码和结构图如下:
index.html
javascript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="./main.js" type="module"></script>
</head>
<body></body>
</html>
main.js
javascript
//main.js
import a from "./a.js";
import("./d.js").then((val) => {
console.log("main.js val:", val.default);
});
console.log("main:", a, b);
import b from "./b.js";
a.js
javascript
import c from "./c.js";
console.log("a:", c);
export default "a";
b.js
javascript
console.log("b.js");
export default "b";
c.js
javascript
console.log("c.js");
export default "c";
d.js
javascript
console.log("d.js");
export default "d";
结构图
- 首先
main.js
中的第一条导入语句会先执行import a from "./a.js";
, - 在
a.js
文件中又导入了c.js
,所以会先执行c.js
中的内容,所以第一个打印的就是 "c.js" - 回到
a.js
文件,执行a.js
自己的打印语句,也就是 "a: c"
a.js
文件处理完了,回到main.js
文件,继续处理b.js
的导入语句import b from "./b.js";
- 进入
b.js
文件,执行b.js
自己的打印语句,也就是 "b.js"
b.js
文件处理完了,回到main.js
文件,处理main.js
自己的打印语句,也就是 "main: a b"
- 最后会处理动态导入语句,进入到
d.js
,执行它的打印语句,也就是 "d.js"
- 处理
d.js
动态导入回调函数中的打印语句:"main.js val:d"
控制台结果如下:
五、 循环依赖问题
1. Module Map
ES Module 使用模块地图来记录每个模块的加载状态。当模块被导入时,模块地图会标记该模块为"正在加载(Fetching)"状态。如果在加载过程中遇到循环依赖,模块地图会检查目标模块是否已经被标记为"正在加载"。如果是,则不会再次进入该模块的加载流程,从而避免了死循环。
2. Module Record
每个模块都有一个模块记录,其中包含了模块的导出变量的内存地址。当一个模块被导入时,ES Module 不会立即执行模块代码,而是先将模块记录中的导出变量绑定到当前模块的作用域中。这意味着即使在模块代码尚未完全执行时,其他模块也可以访问到这些导出变量的初始值。
3. 动态绑定
ES Module 的导出是动态绑定的,即导出的内容指向的是内存地址,而不是值的拷贝。这意味着即使在循环依赖的情况下,模块之间的变量更新也会保持同步。
虽然 ES Module 提供了一些机制来处理循环依赖,但最好的方法是通过重新设计代码来避免循环依赖。如果无法避免,可以使用动态导入、延迟执行或事件机制等技术来解决循环依赖问题
六、总结
ES Module 是 JavaScript 原生支持的模块化规范,具有 原生支持 、静态导入 、单例模式 和 支持异步加载 等特点。ES Module 的运行原理包括 模块解析 、模块加载 和 模块执行 三个阶段。