浅谈 ES module
1、ES模块是如何使用的
通常我们使用 ES module 都是
// app.tsx
import React, { Component } from 'react'
import { Provider } from 'react-redux'
import { store } from './store'
首先我们需要一个入口文件
(比如 app.tsx),之后任何 import
语句,都可以找到代码文件了。
2、ES模块具体做了什么
但是代码文件本身并不是浏览器可以使用的。它需要解析所有这些文件,将它们转换为称为模块记录
,之后,需要将模块记录
转换为模块实例
。
模块实例
将 code
(代码)与state
(所有变量的值)相结合。
我们需要为每个模块提供一个模块实例
。模块加载的过程是从这个入口文件
到有一个完整的模块实例图。
对于ES模块,这分为三个步骤。
- 构建 Construction — 查找、下载并将所有文件解析为模块的
模块记录
- 实例化 Instantiation — 在内存中查找模块所有导出的值,然后使导出和导入都指向内存中的那些模块。这叫做
链接
- 计算 Evaluation — 运行代码,给变量赋值。
3、ES模块是异步的吗
人们常说ES模块是异步的,你可以把它看作是异步的,因为ES模块工作的三个步骤可以分开进行。
然而,这些步骤本身并不一定是异步的。它们可以同步进行。这取决于加载程序
是什么。
这是因为并不是所有的东西都由ES模块规范控制,实际上有两部分工作,它们被不同的规范所覆盖。
ES Module 规范 只说明如何将文件解析、如何实例化和如何计算该模块。
但是,它并没有说明如何首先获取这些文件。
加载程序
获取文件,而加载程序
是在不同的规格中指定的。
对于浏览器,该规范是HTML规范.
但是你可以根据你使用的平台有不同的加载程序
。
加载程序
也控制模块的加载方式。
它调用ES模块方法 — 解析、实例化和计算。
有点像操纵JS引擎字符串的傀儡。
4、窥探每一个步骤
构建 Construction
在构建阶段,每个模块都会做三件事。
- 找出从哪里下载包含模块的文件(模块解析)
- 获取文件(通过从URL下载或从文件系统加载)
- 将文件解析为
模块记录
找到文件并获取
加载程序
将负责查找并下载文件。首先它需要找到入口文件
。
在HTML中,通过使用script
标签告诉加载程序在哪里可以找到它。
import
语句的一部分称为模块说明符
,它告诉加载程序
在哪里可以找到下一个模块。
关于模块说明符
有一点需要注意:它们有时需要在浏览器和节点之间进行不同的处理。
每个平台都有自己解释模块说明符字符串的方法。
为此,它使用了一种称为模块解析算法的方法,这种算法在不同的平台上有所不同。
在解析文件之前,我们并不知道模块需要获取哪些依赖项,并且在获取文件之前,也无法解析该文件。
这意味着我们必须逐层遍历树,解析一个文件,然后找出它的依赖项,然后找到并加载这些依赖项。
如果主线程等待这些文件中的每一个下载,那么许多其他任务将堆积在其队列中。
像这样阻塞主线程会使使用模块的应用程序速度太慢而无法使用。这是ES模块规范将算法分为多个阶段的原因之一。
将构造拆分到自己的阶段允许浏览器获取文件并在开始实例化的同步工作之前建立对模块图的理解。
这种将算法分成阶段的方法是ES模块和CommonJS模块的主要区别之一。
模块映射
,每个模块映射中单独跟踪。
当加载程序
去获取一个URL时,它将该URL放在模块映射中,并记录它当前正在获取该文件。然后,它将发出请求并继续获取下一个文件。
但是模块映射
不仅仅是跟踪正在获取的文件。模块映射
还充当模块的缓存。
解析
现在我们已经获取了这个文件,我们需要将它解析为一个模块记录
。
一旦模块记录
被创建,它就被放置在模块映射
中。这意味着无论何时有人从这里请求它,加载器都可以从模块映射
中拉出它。
解析过程中有一个细节可能看起来微不足道,但实际上却有相当大的含义。
所有模块都被解析为"use strict"在顶端。还有一些细微的区别。
例如,关键字 await 在模块的顶层代码中保留,并且 value 是 undefined
这种不同的解析方式称为解析目标
。如果解析同一个文件但使用了不同的目标,最终得到的结果将不同。
所以在开始解析之前,你应该知道你在解析什么样的文件 — 不管是不是模块。
在浏览器中这是相当容易的。你只要把type="module"在script
标签上。这将告诉浏览器该文件应作为模块进行解析。由于只能导入模块,浏览器知道任何导入也是模块。
现在,在加载过程的最后,我们已经从只有一个入口文件
变成了一堆模块记录
。
实例化 Instantiation
前面提到,模块实例
是将code
(代码)与state
(所有变量的值)相结合。state
存在于内存中,所以实例化步骤就是将东西链接
到内存。
首先,JS引擎创建一个模块环境记录
。模块环境记录
将管理模块记录
的变量。然后在内存中找到所有导出的模块。模块环境记录
将记录内存中与每个导出关联的模块。内存中的这些模块还不能得到它们的值。只有在计算之后,才会赋予它们的实际值。
为了实例化模块图,引擎将执行所谓的深度优先后序遍历。
由于深度遍历,优先加载底层不依赖其他模块的模块,建立他们的导出关系。
然后建立导入和导出关系,保证所有的导入都能和导出相匹配。
这与CommonJS模块不同。在CommonJS中,导出时会复制整个导出对象。这意味着导出的任何值都是副本。
如果导出模块稍后更改了该值,则导入模块不会看到该更改。
相反,ES模块使用称为活动绑定
的东西。两个模块都指向内存中的相同位置。
这意味着当导出模块更改某个值时,也更改在导入模块中。
导出值的模块可以随时更改这些值,但导入模块不能更改其导入的值。也就是说,如果一个模块导入一个对象,它可以更改该对象上的属性值
计算 Evaluation
最后一步是在内存中填充模块导出的值。JS引擎通过执行顶层代码来实现这一点。
除了在内存中填充外,计算代码也会引发副作用。例如,模块可能会调用服务器。
因为有可能产生副作用,所以只需要对模块求值一次。
模块映射
通过规范URL缓存模块,以便每个模块只有一个模块记录
。确保每个模块只执行一次。与实例化一样,这是作为深度优先的后序遍历来完成的。
观察下面的代码,输出的 count 和 message 各是什么
// message.js
import count from "./count";
console.log('count1:',count);
setTimeout(() => {
console.log('count2:',count);
}, 0);
const message = 'message'
export default message
// count.js
import message from "./message";
console.log('message1:',message);
setTimeout(() => {
console.log('message2:',message);
}, 0);
const count = 5
export default count
如果你的入口文件先引用的 count.js 你会得到下面的结果
count1: undefined
message1: message
count2: 5
message2: message
如果你的入口文件先引用的 message.js 你会得到下面的结果
message1: undefined
count1: 5
message2: message
count2: 5
这说明了引用顺序影响计算结果,并且当引用值未被计算时,用undefined占位。这与 CommonJS 表现一致