[关闭]
@gyyin 2019-11-30T12:38:03.000000Z 字数 9104 阅读 369

面向未来的 ECMAScript 提案

慕课专栏


1. 前言

随着 ES6 的发布,JavaScript 语法也越来越趋于成熟,新的提案也在不断地提出。在第一篇文章中,也有提到过 ECMA 提案的四个阶段,处于 Stage3 的都需要我们持续关注,以后很可能就会被纳入新标准中。
那么就来一起看看 ECMA 中的新特性以及一些有趣的新提案吧。

2. Dynamic Import

如果你写过 node,会发现和原生的 import/export 不一样的地方就是支持就近加载。
node 允许你可以在用到的时候再去加载这个模块,而不用全部放到顶部加载。
以下面这个 node 模块为例子,最后依次打印出来的是 mainnoop

  1. // noop.js
  2. console.log('noop');
  3. module.exports = function() {}
  4. // main.js
  5. console.log('main')
  6. const noop = require('./noop')

如果换成 import/export,不管你将 import 放到哪里,打印结果都是相反的。比如下面依次打印的是 noopmain

  1. // noop.js
  2. console.log('noop');
  3. export default function() {}
  4. // main.js
  5. console.log('main')
  6. import noop from './noop'

而在前端开发中,为了优化用户体验,往往需要用到懒加载。如果只想在用户进入某个页面的时候再去加载这个页面的资源,那么就可以配合路由去动态加载资源。

2.1 手动实现一个动态 import 函数

其实我们自己也完全可以通过 Promise 来封装这样一个 api,核心在于动态生成 script 标签,在 script 中导入需要懒加载的模块,将其挂载到 window 上面。

  1. function importModule(url) {
  2. return new Promise((resolve, reject) => {
  3. const script = document.createElement("script");
  4. script.type = "module";
  5. script.textContent = `import * as m from "${url}"; window.tempModule = m;`;
  6. })
  7. }

当 script 的 onload 事件触发之时,就把 tempModule 给 resolve 出去,同时删除 window 上面的 tempModule

  1. function importModule(url) {
  2. return new Promise((resolve, reject) => {
  3. const script = document.createElement("script");
  4. const tempGlobal = "__tempModuleLoadingVariable" + Math.random().toString(32).substring(2);
  5. script.type = "module";
  6. script.textContent = `import * as m from "${url}"; window.${tempGlobal} = m;`;
  7. script.onload = () => {
  8. resolve(window[tempGlobal]);
  9. delete window[tempGlobal];
  10. script.remove();
  11. };
  12. script.onerror = () => {
  13. reject(new Error("Failed to load module script with URL " + url));
  14. delete window[tempGlobal];
  15. script.remove();
  16. };
  17. document.documentElement.appendChild(script);
  18. });
  19. }

2.2 动态 import 提案

因此,自然而然的,一个动态 import 的提案就被提了出来,这个提案目前已经走到了 Stage4 阶段。而上面的 importModule 则是官方推荐的在不支持动态 import 的浏览器环境中的一种实现。
通过动态 import 允许我们按需加载 JavaScript 模块,而不会在最开始的时候就将全部模块加载。

  1. const router = new Router({
  2. routes: [{
  3. path: '/home',
  4. name: 'Home',
  5. component: () =>
  6. import('./pages/Home.vue')
  7. }]
  8. })

动态 import 返回了一个 Promise 对象,这也意味着可以在 then 中等模块加载成功后去做一些操作。

  1. <nav>
  2. <a href="books.html" data-entry-module="books">Books</a>
  3. <a href="movies.html" data-entry-module="movies">Movies</a>
  4. <a href="video-games.html" data-entry-module="video-games">Video Games</a>
  5. </nav>
  6. <main>Content will load here!</main>
  7. <script>
  8. const main = document.querySelector("main");
  9. for (const link of document.querySelectorAll("nav > a")) {
  10. link.addEventListener("click", e => {
  11. e.preventDefault();
  12. import(`./section-modules/${link.dataset.entryModule}.js`)
  13. .then(module => {
  14. module.loadPageInto(main);
  15. })
  16. .catch(err => {
  17. main.textContent = err.message;
  18. });
  19. });
  20. }
  21. </script>

3. Top-level await

前面讲了动态 import,但是如果想在动态引入某个模块之后再导出当前模块的数据,那么该怎么办呢?
如果在模块中我依赖了某个需要异步获取的数据之后再导出数据怎么办?

3.1 IIAFEs 的局限性

已知在 JS 中使用 await 都要在外面套一个 async 函数,传统的做法如下:

  1. // awaiting.mjs
  2. import { process } from "./some-module.mjs";
  3. let output;
  4. async function main() {
  5. const dynamic = await import(computedModuleSpecifier);
  6. const data = await fetch(url);
  7. output = process(dynamic.default, data);
  8. }
  9. main();
  10. export { output };

或者使用 IIAFE,由于这种模式和 IFEE 比较像,所以被叫做 Immediately Invoked Async Function Expression,简称 IIAFE。

  1. // awaiting.mjs
  2. import { process } from "./some-module.mjs";
  3. let output;
  4. (async () => {
  5. const dynamic = await import(computedModuleSpecifier);
  6. const data = await fetch(url);
  7. output = process(dynamic.default, data);
  8. })();
  9. export { output };

但是这两种做法有一个问题,如果导入这个模块后立即使用 output,那么拿到的是个 undefined,因为异步加载的数据还没有获取到。
想要拿到异步加载之后的数据,那么只能在一段时间之后再去获取这个 output,例如:

  1. import { output } from './awaiting'
  2. setTimeout(() => {
  3. console.log(output)
  4. }, 2000)

当然这种做法也很不靠谱,毕竟谁也不知道异步加载要经过多少秒才返回,所以就诞生了另外一种写法,直接导出 async 函数。

  1. // awaiting.mjs
  2. import { process } from "./some-module.mjs";
  3. let output;
  4. export default (async () => {
  5. const dynamic = await import(computedModuleSpecifier);
  6. const data = await fetch(url);
  7. output = process(dynamic.default, data);
  8. })();
  9. export { output };

导入之后,在 then 方法里面使用导入的变量,这样确保数据一定是动态加载之后的。

  1. // usage.mjs
  2. import promise, { output } from "./awaiting.mjs";
  3. export function outputPlusValue(value) { return output + value }
  4. promise.then(() => {
  5. console.log(outputPlusValue(100));
  6. setTimeout(() => console.log(outputPlusValue(100), 1000);
  7. });

3.2 Top-level await

Top-level await 允许你将整个 JS 模块视为一个巨大的 async 函数,这样就可以直接在顶层使用 await,而不必用 async 函数包一层。
那么来重写上面的例子吧。

  1. // awaiting.mjs
  2. import { process } from "./some-module.mjs";
  3. const dynamic = import(computedModuleSpecifier);
  4. const data = fetch(url);
  5. export const output = process((await dynamic).default, await data);

可以看到,直接在外层 使用 await 关键字来获取 dynamic 这个 Promise 的返回值,这种写法解决了原来因为 async 函数导致的各种问题。

Top-level await 现在处于 Stage3 阶段。

3.3 globalThis

如果你有看过 Underscore 之类的库的源码,经常能看到会有这么一个操作。

  1. var root = typeof self == 'object' && self.self === self && self || typeof global == 'object' && global.global === global && global || this;

这么一长串的操作,它到底是在做什么呢?其实它只是在取全局对象。
事实上,在不同的 JavaScript 环境中拿到全局对象是需要不同的语句的。
在 Web 中,可以通过 window、self 或者 frames 取到全局对象,但是在 Web Workers 中只有 self 可以。
在 Node.js 中,它们都无法获取,必须使用 global。
在松散模式下,可以在函数中返回 this 来获取全局对象,但是在严格模式下 this 会返回 undefined 。
因此,一个叫做 globalThis 的提案就这么诞生了,从此不必在去不同环境中判断全局对象。

  1. if (typeof globalThis.setTimeout !== 'function') {
  2. // no setTimeout in this environment!
  3. }

globalThis 现在还处于 Stage3 阶段。

4. Private instance methods and accessors

在 JavaScript 的类中一直都没有私有属性的概念,以往的做法是用下划线来指定私有属性,但依然能够被访问到,这个下划线也只起到了说明的作用。

  1. class Counter {
  2. _count = 0;
  3. increment() {
  4. this._count++;
  5. }
  6. decrement() {
  7. this._count--;
  8. }
  9. }
  10. const counter = new Counter();
  11. counter._counter; // 0

或者干脆放弃使用 class,转而利用闭包来实现私有属性,但是看起来总是没有那么简洁。

  1. const Counter = function() {
  2. let _count = 0;
  3. function Counter() {
  4. }
  5. Counter.prototype.increment = function() {
  6. return _count++;
  7. }
  8. Counter.prototype.decrement = function() {
  9. return _count--;
  10. }
  11. return Counter
  12. }()
  13. const counter = new Counter();
  14. counter._count; // undefined

其实,早在 TypeScript 中就已经实现了 Class 中的私有属性。只需要使用 private 关键字,就能够将属性设置为私有。这和很多语言保持了一致。

  1. class Counter {
  2. private count = 0;
  3. increment() {
  4. this.count++;
  5. }
  6. decrement() {
  7. this.count--;
  8. }
  9. }

后来,一个争议非常大的提案被提了出来。虽然已经走到了 Stage3,但依然有很多开发者对之嗤之以鼻。该提案允许你使用 # 当做私有变量的前缀,以此来声明一个私有变量。该私有属性只能在当前类中被访问到,无法在外部被访问到。

  1. class Counter {
  2. #_count = 0;
  3. increment() {
  4. this.#_count++;
  5. }
  6. decrement() {
  7. this.#_count--;
  8. }
  9. }
  10. const counter = new Counter();
  11. counter.#count; // error

# 还允许将静态属性设置为私有属性,也只能在当前类中被访问到。

  1. class Person {
  2. static #instance = null;
  3. static getInstance() {
  4. if (Person.#instance) {
  5. return Person.#instance;
  6. }
  7. return new Person()
  8. }
  9. }
  10. Person.#instance // error

由于这个提案的一些问题,以 hax 为首的广大中国开发者,在 GitHub 上也开启了对这个私有属性的讨论。
感兴趣的可以去围观一下:关于私有属性的中文讨论

5. Optional Chaining

根据统计 stackoverflow 上面的前端相关问题,有相当一部分是 JS 报错导致的,这个也是 TypeScript 诞生的初衷。
如果是你是个前端老司机,那么看到这个报错一眼就能知道问题是什么。

image_1dqru9mv2r3uhob1r80bvp1t1h9.png-16kB

在 JavaScript 中,深层取值是有很大风险的,因为你无法保证前面的返回是个对象。
为了解决深层取值的问题,经验丰富的前端老司机们也折腾出来了一堆骚操作。比如使用短路符号来避免 JS 报错。

  1. const city = country &&
  2. country.province &&
  3. country.province.city

后来大家一看,为了取个值搞的那么麻烦,如果有五层六层,岂不是要了命了?于是又有聪明的人想出了使用 reducer 来循环访问的形式。这就是 lodash 中的 get 方法。

  1. const city = _.get(country, 'province.city')

在前面讲解 Proxy 的时候,我带着大家手把手用 Proxy 实现了一个深层取值的 get 方法,那也是一个很不错的方法。

  1. const city = country.province.city();

当然了,今天我们的主角就是大名鼎鼎的 Optional Chaining ?.
Optional Chaining 也叫可选链、链判断运算符,主要是解决深层取值的问题。目前虽然只在 Stage1 阶段,但 TypeScript 已经支持。

  1. const city = country?.province?.city
  2. // or
  3. const city = country?.['province']?.['city']

可选链还能运用在函数上面。

  1. person.say?.()

但需要注意的是,如果 person.say 不是 null 也不是 undefined,但同时也不是函数,那么依然会报错。

6. Pipeline operator draft

如果你有使用过 lodash/underscore 之类的函数库,那么一定会对链式调用比较熟悉吧。这种链式调用在一定程度上提高了代码整体的简洁性。

  1. _.chain([1, 2, 3])
  2. .sort()
  3. .last()
  4. .isNumber()
  5. .value();

最近非常火热的 rxjs 中也有类似概念,基于 rxjs 编程,就像是在组装管道一样。

  1. from([2, 3]).pipe(
  2. scan((acc, item) => acc += item, 10))
  3. .subscribe(v => console.log(v))

管道操作符 |> (目前处于 stage 1 阶段)允许使用一种易读的方式去对函数进行链式调用。
本质上来说,管道操作符是单参数函数调用的语法糖,它允许你像这样执行一个调用:

  1. let url = '%21' |> decodeURI;
  2. // 等效于
  3. let url = decodeURI('%21');

如果链式调用多个函数的时候,使用管道操作符可以改善代码可读性。之所以叫管道操作符,就是因为数据可以在多个函数中像在管道中一样流动。

  1. const double = (n) => n * 2;
  2. const sqrt = (n) => Math.sqrt(n);
  3. // 没有用管道操作符
  4. double(sqrt(double(5))); // 22
  5. // 用上管道操作符之后
  6. 5 |> double |> sqrt |> double; // 22

7. Promise.allSettled

Promise.allSettled 已经被纳入 ES2020 的标准之中,它和 Promise.all 比较相似,都是接收多个 Promise,最后返回执行结果。
不一样的地方在于, all 会在某个 Promise 执行失败之后立即返回这个 error。
有时候我只想拿到所有的返回,并不关心某个 Promise 是否执行失败,那么该怎么办呢?

  1. // Promise.all
  2. const promise1 = Promise.resolve(3);
  3. const promise2 = new Promise((resolve, reject) => setTimeout(reject, 100, 'foo'));
  4. const promises = [promise1, promise2];
  5. Promise.all(promises).
  6. then((results) => results.forEach((result) => console.log(result.status))).
  7. catch(error => console.log('error', error));
  8. // 最终输出:error foo

很显然,Promise.all 并不是为了处理这种场景而生的。只要有一个执行失败了,那么就会走到 catch 里面,最终返回这一个执行失败的结果。因此,一个新的特性 Promise.allSettled 出现了。
Promise.allSettled 会将所有执行后的结果都返回,不管其中有没有执行失败的 Promise。
将上面的例子用 Promise.allSettled 修改后就可以实现我们想要的结果。

  1. const promise1 = Promise.resolve(3);
  2. const promise2 = new Promise((resolve, reject) => setTimeout(reject, 100, 'foo'));
  3. const promises = [promise1, promise2];
  4. Promise.allSettled(promises).
  5. then((results) => results.forEach((result) => console.log(result.status)));
  6. // "fulfilled"
  7. // "rejected"

8. Promise.any

在前端开发中,总是有各种各样奇怪的需求,比如上面那个 Promise.allSettled。所以也会有一种需求是,如果我传入的所有 Promise 都执行失败了,我想获取所有的失败结果,那么该怎么办?
聪明的你会说,对 Promise.allSettled 执行后的结果进行判断不也可以实现这个需求吗?
厉害了,但是如果还要求只要有一个 Promise 执行成功就返回呢?Promise.allSettled 就没法做到这点儿了。
但是现在有个新的提案 Promise.any 就是解决这个问题的(恭喜 Promise 家族再增加一位新成员)。

Promise.any 也接收多个 Promise 作为参数,返回情况有两种:
1. 只要有一个 Promise 执行成功后就立即返回这次执行结果。
2. 当所有的 Promise 都执行失败之后,就返回所有的失败结果。

  1. Promise.any([
  2. fetch('https://v8.dev/').then(() => 'home'),
  3. fetch('https://v8.dev/blog').then(() => 'blog'),
  4. fetch('https://v8.dev/docs').then(() => 'docs')
  5. ]).then((first) => {
  6. // Any of the promises was fulfilled.
  7. console.log(first);
  8. // → 'home'
  9. }).catch((error) => {
  10. // All of the promises were rejected.
  11. console.log(error);
  12. });

之前这个提案有一些争议,主要是针对执行失败之后返回的结果。到底是返回一个 Error 的数组呢,还是返回一个 AggregateError 呢?

AggregateError 是 Error 的一个子类,主要是将一个个 error 组在一起。每个 AggregateError 的实例都包含一个指针,这个指针指向了一个 error 的数组。

后来还是定下了返回一个 AggregateError,主要原因是考虑到现有的 JS 程序中,抛出的异常都是基于 Error 类型的实例,为了避免出现一些问题,使用 Error 实例或者基于 Error 的子类实例是最好的办法,所以最后选择了返回一个 AggregateError。

Promise.any 已经处于 Stage3 阶段。

9. 推荐阅读

  1. Promise 中的三兄弟 .all(), .race(), .allSettled()
  2. 阮一峰:最新提案
  3. Promise.all 缺陷
添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注