ESM 的“异步”到底异步在哪里? 为什么import的模块都是同步执行,却说ESM是异步的?
问题
import a from 'module-a';import b from 'module-b';console.log(a);console.log(b);这几行代码是同步执行的,为什么却说 ESM 是异步的。
谁说ESM是异步的?
https://nodejs.org/api/packages.html这里说的
其他问题
看到几篇文章,大都是说 script标签的加载是异步的,并没有说import是异步 的吧
补充内容
异步是说这个?
ESM 的异步指的是加载过程(Load)是异步的,而不是说一个 ESM 里面的代码执行过程是异步的。
对于开发者来说确实基本没什么感知。除了像下面这样跟加载顺序有关的(实际上你的代码压根不应该依赖于这种加载顺序),大部分场景下都不影响你写代码。
// module-a.js
console.log('a');
module.exports = {};
// module-b.js
console.log('b');
module.exports = {};
// index.js
console.log(1);
const a = require('./module-a');
console.log(2);
const b = require('./module-b');
console.log(3);
// module-a.js
console.log('a');
export default {};
// module-b.js
console.log('b');
export default {};
// index.js
console.log(1);
import a './module-a';
console.log(2);
import b './module-b';
console.log(3);
上面的两段代码分别是 CommonJS 和 ESM 的,你可以分别自己建好三个文件运行看看输出的 1、2、3、a、b 的顺序,体会一下区别。
但结合上一个问题来看,感觉题主纠结的点现在变成“为什么 CommonJS 不能 require() 一个 ESM”了。
先忽略 ESM,我们来看 CommonJS。
为啥要有模块?码农最朴素的愿望就是代码隔离+复用嘛,毕竟你肯定既不想所有代码都写在一个文件里、也不想相同功能的代码到处复制粘贴好几遍。那么文件 A 怎么引用文件 B 里的代码呢?一开始 JS 本身没提供这样的能力,于是上古时代各路大神们就只能自己想各种招数来实现这个事情。
上古时代的事情咱们按下不表,如果你感兴趣可以看我之前写的这篇 《JavaScript 模块化的历史进程》。咱们直接快进到 CommonJS。
CommonJS 里所谓的 require()
其实就是一个函数而已,只是这个函数是 Node 里内置的、全局的。那么这个函数干了啥,才实现了我们上面所提的“文件 A 引用文件 B 里的代码呢”?其实很简单,就两步:
function require(filePath) {
const content = fs.readFileSync(filePath);
return eval(content);
}
这里我们隐去了路径解析、依赖分析、模块缓存、模块实例化、解决循环引用、包装模块代码避免模块里的变量污染全局、解析模块代码的导出值使其变为函数的返回值等等这些“细枝末节”(其实都很重要,但跟我们要讨论的同步异步无关),剩下的最关键的两行代码其实就是上面这两行:
- 读取文件内容;
- 把上面读到的文件内容当作 JS 代码去执行一遍。
所以 CommonJS 里所谓的模块导入,其实就是执行一下 require()
这个函数,然后拿到它的返回值而已:
const modA = require('module-a');
const modB = require('module-b');
// use modA & modB
而这个过程,即所谓的模块加载,是同步的 —— 因为 require()
它是一个同步函数嘛。但是到了浏览器里,事情开始有了问题 —— Node 是基于运行在本地磁盘上考虑的,同步读取一个文件内容是可以被接受的;但浏览器里可是要从远程下载文件的,它可没有类似 fs.readFileSync
这种同步下载文件的 API(你可能会说 XMLHttpRequest 里不是支持同步发起 AJAX 么?确实,但代价是它请求过程中其他请求都阻塞、整个页面卡死、EventLoop 停止响应)。所以在浏览器里如果要实现 require()
,就只能是:
function require(filePath) {
return ajax({ url: filePath }).then((content) => eval(content));
}
但这样模块加载就变成异步的了,要用到这个模块的时候你就得:
require('module-a').then((modA) => {
require('module-b').then((modB) => {
// use modA & modB
});
});
于是大家就想,反正无论如何浏览器里都得变成异步的,干嘛非得继续用 require()
这种形式呢?就算用了它也跟 Node 里写法不兼容(一个是同步拿返回值即可、一个却得异步拿结果),不如干脆另起炉灶吧。这才有了 ESM。
那么回到问题上来,为啥 CommonJS 不在 ESM 提出以后继续改进自身,让 require()
也能导入一个 ESM 呢?
原因很简单,因为做不到。
为啥做不到?第一点,前面提到了,require()
的实质是 Node 提供的一个内置的、全局的函数,它跟你自定义的 function 没什么区别。而 ESM 的 import
和 export
语法要求必须写在 Top-Level、是不能被函数包裹的,也就是说你不能这么写:
function foo() {
import modA from 'module-a';
import modB from 'module-b';
}
foo();
当然你也可以说这是先有鸡还是先有蛋的问题,如果 ESM 一开始设计成不是 Top-Level 的,是不是 CommonJS 就能去模拟了。那确实,但人家不是这么设计的不是?而且还有第二点问题,CommonJS 同样还是解决不了,那就是 ESM 支持 Top-Level Await:
// module.js
export const data = await fetch({ url: '/some-where' });
// index.js
import data from './module.js';
这 CommonJS 可就更抓瞎了,毕竟 CommonJS 提出的时候,连 Promise 都没有呢,别提什么 await 了。它怎么也想不到以后还有异步导出这种骚操作。
基于以上两点主要原因(当然还有对于命名导出的处理方式不同、循环引用的处理方式不同等等其他一些原因),因此 CommonJS 无法使 require()
支持导入一个 ESM,你只能用 dynamic import
这种方式来导入。
GitHub 上对此问题曾经有过一些讨论,感兴趣的话可以去看看:https://github.com/nodejs/modules/issues/454
P.S. 上述一些内容的措辞其实是不严谨的,只是为了方便你理解所以做了大量简化。
- 经过验证的有效解决办法
- 自己的经验指引,对解决问题有帮助
- 遵循 Markdown 语法排版,代码语义正确
- 询问内容细节或回复楼层
- 与题目无关的内容
- “赞”“顶”“同问”“看手册”“解决了没”等毫无意义的内容