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
这里说的
image.png

其他问题

看到几篇文章,大都是说 script标签的加载是异步的,并没有说import是异步 的吧

image.png

image.png

补充内容

image.png
异步是说这个?

阅读 4.1k
3 个回答

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);
}

这里我们隐去了路径解析、依赖分析、模块缓存、模块实例化、解决循环引用、包装模块代码避免模块里的变量污染全局、解析模块代码的导出值使其变为函数的返回值等等这些“细枝末节”(其实都很重要,但跟我们要讨论的同步异步无关),剩下的最关键的两行代码其实就是上面这两行:

  1. 读取文件内容;
  2. 把上面读到的文件内容当作 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 的 importexport 语法要求必须写在 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. 上述一些内容的措辞其实是不严谨的,只是为了方便你理解所以做了大量简化。

异步不只在 JS 内部有,在浏览器网页环境与 JS 引擎之间也有。

ESM 的 JS 不会阻塞网页渲染,所以是异步的。

新手上路,请多包涵

异步是指依赖模块的解析与下载异步,等到所有模块都解析完毕了才开始以 dfs 的形式由下往上开始执行代码。模块中的代码执行当然还是按顺序同步执行

撰写回答
你尚未登录,登录后可以
  • 和开发者交流问题的细节
  • 关注并接收问题和回答的更新提醒
  • 参与内容的编辑和改进,让解决方法与时俱进
推荐问题
宣传栏