「译」ES 2021 新特性: Top-level await | 掘金技术征文-双节特别篇

1,737 阅读6分钟

前言

原文地址:https://2ality.com/2020/09/ecmascript-2021.html
作者:Dr. Axel Rauschmayer

「ECMAScript」提案 Top-level await 由 Myles Borins 提出,它可以让你在模块的最高层中使用 await 操作符。在这之前,你只能通过在 async 函数或 async generators 中使用 await 操作符。

1 为什么要在模块的最高层级使用 await

为什么我们需要在模块的最高层级中使用 await 操作符?因为,这可以让我们初始化一个需要异步加载数据的模块。接下来的三个小节,将会向你展示在什么场景下 Top-level await 会非常实用。

1.1 动态加载模块

const params = new URLSearchParams(window.locaion.search);
const language = params.get('lang');
const messasges = await import(`./messages-${language}.mjs`); // {A}

console.log(messages.welcome);

在 A 行,我们动态地引入模块。得益于 Top-level await,这让我们使用起来和普通、静态地引入模块一样便捷。

1.2 如果模块加载失败调用对应回调

let lodash;
try {
  lodash = await import('https://primary.example.com/lodash');
} catch {
  lodash = await import('https://secondary.example.com/lodash');
}

1.3 使用最快加载好的资源

const resource = await Promise.any([
  fetch('http://example.com/first.txt')
    .then(response => response.text()),
  fetch('http://example.com/second.txt')
    .then(response => response.text()),
]);

使用了 Promise.any(),变量 resource 会初始化为最快加载好的那个资源,

2 为什么使用变通方法实现 Top-level await 不好

在本节中,我们尝试实现一个模块,该模块通过异步加载数据的形式初始化其导出。

我们先尝试使用变通方法实现 Top-level await。而,这些方法都会有缺点。因此,我们最终的 Top-level await 将是最佳的解决方案。

2.1 第一个尝试:立即执行的 top-level async 函数

下面这个模块会异步初始化 downloadedText1 并将其导出:

// async-lib1.mjs
export let downloadedText1;
async function main() {
   downloadedText1 = await asyncFunction()
}
main()

这里,我们不是声明和调用 async 函数,可以使用立即执行的箭头函数:

export let downloadedText;

(async () => {
  downloadedText = await asyncFunction();
})();

需要注意的是,我们必须始终将箭头函数包裹在括号中:

  • 调用的括号不能放在箭头函数主体外。
  • 即使在表达式上下文中,我们也不能去掉箭头函数周围的括号。

为了了解这种方法的缺点,我们来使用一下 async-lib.mjs

import {downloadedText1} from './async-lib1.mjs';
assert.equal(downloadedText1, undefined); // (A)
setTimeout(() => {
    assert.equal(downloadedText1, 'Downloaded!'); // (B)
  }, 100);

在正确地引入 async-lib.mjs 后,downloadedText1 会是 undefined(A 行)。在我们可以正常访问 downloaderText1 之前,必须等待异步函数执行完毕(B 行)。

我们需要找到一种可靠的方法来实现,目前的方法并不稳妥。例如,如果异步函数执行花费超过 100 毫秒,setTimeout 将不起作用。

2.2 第二个尝试:当导出模块可以正常使用时告知引入的程序

引入的程序需要知道什么时候是可以正常访问异步函数初始化并导出的模块。我们可以通过 Promise 已完成来让它们知道:

// async-lib2.mhs
export let downloadedText2;

export const done = (async () => {
  downloadedText2 = await asyncFunction();
})();

这个立即执行的异步箭头函数会同步地返回一个已完成(fulfilled)的值为 undefinedPromise。它的实现是隐式的,因为我们不返回任何东西。

引入的程序现在等待完成,就可以正常地访问 downloadedText2

// main2.mjs
import {done, downloadedText2} from './async-lib2.mjs';
export default done.then(() => {
  assert.equal(downloadedText2, 'Downloaded!');
});

这个方法存在几个缺点:

  • 引入的程序必须了解这种模式并且正确地使用。
  • 引入的程序很容易理解错这个模式,因为,在 done 结束前,downloadedText2 已经可以被访问。
  • 这种模式是有问题的:如果main2.mjs 也使用了这种模式并且导出自己的 Promise,则其只能被其他模块导入。

在我们接下来的尝试中,我们将会修复第二点。

2.3 第三个尝试:将导出模块放到一个通过 Promise 传递的对象

在导出模块初始化之前,我们想导入的程序是不能访问它的。我们通过 default-exporting 的形式导出一个已完成的(fulfilled)包含我们导出模块对象的 Promise

// async-lib3.mjs
export default (async () => {
  const downloadedText = await asyncFunction();
  return {downloadedText};
})();

async-lib3.mjs 的用法如下:

import asyncLib3 from './async-lib3.mjs';
asyncLib3.then(({downloadedText}) => {
  assert.equal(downloadedText, 'Downloaded!');
});

这个新的实现方式是最好的,但是我们的导出不再是静态的,它们是动态地创建。因此,我们失去了静态结构的所有好处(好的工具支持、更好的性能等等。)。

虽然,这种模式可以更容易地被正确使用,但是仍然存在问题。

2.4 最终的尝试:Top-level await

Top-level await 在保留优点的同时,消除了我们以上方法的所有缺点:

// async-lib4.mjs
export const downloadedText4 = await asyncFunction();

我们仍然异步地初始化我们的导出,但是我们可以通过 Top-level await 来正常地使用 downloadedText4

我们可以导入 async-lib4.mjs,而不需要知道它会异步初始化的导出:

import {downloadedText4} from './async-lib4.mjs';
assert.equal(downloadedText4, 'Downloaded!');

那么,下一节,我们将解释「JavaScript」是如何在幕后确保一切正常地运行。

3 Top-level await 在幕后是如何运行的

思考以下两个文件:

// first.mjs
const response = await fetch('http://example.com/first.txt');
export const first = await response.text();
// main.mjs
import {first} from './first.mjs';
import {second} from './second.mjs';

assert.equal(first, 'First!');
assert.equal(second, 'Second!');

这两者大致等于以下代码:

// first.mjs
export let first;

export const promise = (async () => {
  const response = await fetch('http://example.com/first.txt');
  first = await response.text();
})();
// main.mjs
import {promise as firstPromise, first} from './first.mjs';
import {promise as secondPromise, second} from './second.mjs';

export const promise = (async () => {
  await Promise.all([firstPromise, secondPromise]);
  assert.equal(first, 'First content!');
  assert.equal(second, 'Second content!');
})();

「JavaScript」会静态地确认哪些模块是异步的(即直接导入或间接导入都会有一个 Top-level await)。这些模块导出的 Promise 都会放到 Promise.all() 中。其余的导入仍然照常处理。

需要注意的是,拒绝(reject)和同步的异常都会被转为异步函数。

4 Top-level await 的利与弊

利是虽然大家可以通过各种模式来导入异步初始化模块(例如我们在文章中看到的那些),但是 Top-level await 更易于使用,并使得异步初始化对导入程序变得透明。

弊是 Top-level await 延迟了导入模块的初始化。因此,最好谨慎使用。对于需要花费很长时间的异步任务可以放到后面或者按需引入。

但是,即使没有使用 Top-level await 也会阻塞导入(例如,如果顶层的无限循环),因此,阻塞并不是反对使用它的理由。

往期文章回顾

深度解读 Vue3 源码 | 内置组件 teleport 是什么“来头”?

深度解读 Vue3 源码 | compile 和 runtime 结合的 patch 过程

深度解读 Vue3 源码 | 从编译过程,理解静态节点提升

❤️爱心三连击

通过阅读,如果你觉得有收获的话,可以爱心三连击!!!

前端问路人 —— 五柳(微信公众号: Code center)

🏆 掘金技术征文|双节特别篇