ES2019 到 ES2020 新增了哪些东西

295 阅读9分钟

ES2019 到 ES2020 新增了哪些东西

ES2015 是 JavaScript 自上一版本之后最大的一次更新。自 ES6 之后,很多小伙伴把 JS 新增的或修正内容,都统一认为是 『ES6 新特性』。这没太大问题,不过实际上自从 2016 年之后,JS 保持了每年一个新版本的更新。最近这两年(2020年已经过去一半了),JS 继续新增和修正了部分内容。有的特性已经被应用到项目中很长时间了,有的修正则似乎对代码运行无特别影响。不过,再去熟悉一下,总是没错的。

修正 Function.prototype.toString()

Function.prototype.toString() 目前返回完整的源代码,包括注释和空格。

// 留意一下注释和方法名前的空格
function /* a comment */ foo () {}

// Previously, in V8:
foo.toString();
// → 'function foo() {}'
//             ^ 没有注释
//                ^ 没有空格

// Now:
foo.toString();
// → 'function /* comment */ foo () {}'

String.prototype.trimStart / String.prototype.trimEnd

之前已经有了 trim 方法,现在更加方便;为了保证兼容性,之前非标准化的 trimLefttrimRight 也可继续使用。

const string = '  hello world  ';
string.trimStart();
// → 'hello world  '
string.trimLeft();
// → 'hello world  '
string.trimEnd();
// → '  hello world'
string.trimRight();
// → '  hello world'
string.trim(); // ES5
// → 'hello world'

catch 参数可省略

之前,即使用不到 catch 里的 error,也必须加上:

try {
  doSomethingThatMightThrow();
} catch (exception) {
  //     ^^^^^^^^^
  // We must name the binding, even if we don’t use it!
  handleException();
}

现在如果不需要根据异常对象做出相应错误处理,可省略:

try {
  doSomethingThatMightThrow();
} catch { // → No binding!
  handleException();
}

运行正常的 JSON.stringify

之前 JSON.stringify 处理一些特殊 unicode 字符时,表现怪异:

JSON.stringify('\uD800');
// → '"�"'

目前,这一情况已经得到改善;遗憾的是,JSON.parse 仍然无法返回正确结果:

JSON.stringify('\uD800');
// → '"\\ud800"'
JSON.parse(JSON.stringify('\uD800'));
// → '"�"'

Array.prototype.flat/Array.prototype.flatMap

之前拍平一个多层数组,一般通过递归或者引入 lodash 来解决:

const array = [1, [2, [3]]];

array.flat();
// → [1, 2, [3]]

// 等于
array.flat(1);
// → [1, 2, [3]]

// 递归 flat,直到数组内不再包裹数组
array.flat(Infinity);
// → [1, 2, 3]

Array.prototype.flatMap 相当于 map + flat,但效率更高:

const duplicate = (x) => [x, x];
[2, 3, 4].map(duplicate).flat(); // 🐌
// → [2, 2, 3, 3, 4, 4]
[2, 3, 4].flatMap(duplicate); // 🚀
// → [2, 2, 3, 3, 4, 4]

Object.fromEntries

Object.fromEntries 就是 Object.entries 的反响操作。Object.entries 返回对象的键值对组成的数组,而 Object.fromEntries 能根据键值对数组组合成新对象。

const object = { x: 42, y: 50 };
const entries = Object.entries(object);
// → [['x', 42], ['y', 50]]

const result = Object.fromEntries(entries);
// → { x: 42, y: 50 }

Object vs. Map

Object.entries 让对象转换为 Map 非常简单:

const object = { language: 'JavaScript', coolness: 9001 };

// Convert the object into a map:
const map = new Map(Object.entries(object));

某些情况下,我们需要把 Map 转换为对象,比如需要序列化为 JSON 的时候:

const objectCopy = Object.fromEntries(map);
// → { language: 'JavaScript', coolness: 9001 }

注意:当把 Map 转换为对象的时候,要注意 Map 的键值可以为对象,而对象的键值始终会被转换为字符串,所以以下这种情况下会出错:

const map = new Map([
  [{}, 'a'],
  [{}, 'b'],
]);
Object.fromEntries(map);
// → { '[object Object]': 'b' }

Symbol.prototype.description

在使用 Symbol 的时候,我们习惯于传递一个参数,起到解释说明的作用:

const symbol = Symbol('foo');
//                    ^^^^^

在之前,没有直接的方法能拿到 foo

const symbol = Symbol('foo');
//                    ^^^^^
symbol.toString();
// → 'Symbol(foo)'
//           ^^^
symbol.toString().slice(7, -1); // 🤔
// → 'foo'

以上代码能实现,但看起来就是一段魔法代码。并且,区分不了 Symbol()Symbol('')。而 Symbol.prototype.description 简明扼要地解决了所有问题:

const symbol = Symbol('foo');
//                    ^^^^^
symbol.description; // foo

const s2 = Symbol();
s2.description; // undefined

稳定的 Array.prototype.sort 排序

我们经常用到 sort 函数来排序,但不知你注没注意到,之前地函数某些情况下返回值有点诡异:

const doggos = [
  { name: 'Abby',   rating: 12 },
  { name: 'Bandit', rating: 13 },
  { name: 'Choco',  rating: 14 },
  { name: 'Daisy',  rating: 12 },
  { name: 'Elmo',   rating: 12 },
  { name: 'Falco',  rating: 13 },
  { name: 'Ghost',  rating: 14 },
];
doggos.sort((a, b) => b.rating - a.rating);

虽然 Choco 和 Ghost 的 rating 值相同,不过 Choco 在原始数组中就排在前面,所以你期望的返回值应该是:

[
  { name: 'Choco',  rating: 14 },
  { name: 'Ghost',  rating: 14 },
  { name: 'Bandit', rating: 13 },
  { name: 'Falco',  rating: 13 },
  { name: 'Abby',   rating: 12 },
  { name: 'Daisy',  rating: 12 },
  { name: 'Elmo',   rating: 12 },
]

然而实际返回值可能是:

[
  { name: 'Ghost',  rating: 14 }, // 😢
  { name: 'Choco',  rating: 14 }, // 😢
  { name: 'Bandit', rating: 13 },
  { name: 'Falco',  rating: 13 },
  { name: 'Abby',   rating: 12 },
  { name: 'Daisy',  rating: 12 },
  { name: 'Elmo',   rating: 12 },
]

相当长的一段时间里,JS 并没有要求 Array#sort 的排序稳定性,JS 开发者因此也就不能依赖 sort 方法来实现排序功能。更可怕的是,某些 JS 引擎在处理较小数据量时,使用稳定排序;而在处理大数据量的数组时,使用不稳定排序。开发者在开发过程中,写了测试用例并运行通过,结果在生产环境下,数据量一大,就出现了莫名奇妙的错误。

好消息是,Array#sort 稳定排序的提案已经被接受。

JSON 成为 ES 语法的子集

你可能以为 JSON 早就是 ECMAScript 的语法子集。别惊讶,很多人都是这么认为的。

在早前的 ES 版本中,如果你的代码中出现了 U+2018 字符或者 U+2019 字符,那这些字符会被当成行终止符,代码会报出语法错误;而这些字符,是符合 JSON 语法规范的:

// A string containing a raw U+2028 character.
const LS = '
';
// → ES2018: SyntaxError

// A string containing a raw U+2029 character, produced by `eval`:
const PS = eval('"\u2029"');

在 ES2018 之后,JSON 语法和 ES 语法之间的这种差异被抹平了:

// A string containing a raw U+2028 character.
const LS = '
';
// → ES2018: SyntaxError
// → ES2019: no exception

// A string containing a raw U+2029 character, produced by `eval`:
const PS = eval('"\u2029"');
// → ES2018: SyntaxError
// → ES2019: no exception

动态 import()

Chrome 从 61 版本开始支持静态引入:

// utils.mjs
// Default export
export default () => {
  console.log('Hi from the default export!');
};

// Named export `doStuff`
export const doStuff = () => {
  console.log('Doing stuff…');
};
<script type="module">
  import * as module from './utils.mjs';
  module.default();
  // → logs 'Hi from the default export!'
  module.doStuff();
  // → logs 'Doing stuff…'
</script>

注意:以上代码中 mjs 只是为了表明 utils.mjs 是一个模块。在 web 端,使用其他的扩展名也没有问题;不过在 Node.js 中,就必须要使用 .mjs 扩展名了。

静态导入协助优化了 tree-shaking 和打包工具,不过在面对按需引入的需求时,就束手无策了(webpack 已经提供了按需引入的方法)。

上面的代码,改写成按需引入之后的形式为:

<script type="module">
  const moduleSpecifier = './utils.mjs';
  import(moduleSpecifier)
    .then((module) => {
      module.default();
      // → logs 'Hi from the default export!'
      module.doStuff();
      // → logs 'Doing stuff…'
    });
</script>

因为 import() 返回的是一个 Promise,以上代码也可用 async/await 语法来改写:

<script type="module">
  (async () => {
    const moduleSpecifier = './utils.mjs';
    const module = await import(moduleSpecifier)
    module.default();
    // → logs 'Hi from the default export!'
    module.doStuff();
    // → logs 'Doing stuff…'
  })();
</script>

注意:虽然 import() 的使用方式看起来像是一个方法,但实际上,它是一个用到括号的特殊语法(类似于 super())。这意味着 import 并不是继承自 Function,你也就不能对它应用 apply bind 等方法;const alias = import 也是不行的;

下面的代码展示了在导航到不同页面时,如何用 import() 来实现懒加载:

<!DOCTYPE html>
<meta charset="utf-8">
<title>My library</title>
<nav>
  <a href="books.html" data-entry-module="books">Books</a>
  <a href="movies.html" data-entry-module="movies">Movies</a>
  <a href="video-games.html" data-entry-module="video-games">Video Games</a>
</nav>
<main>This is a placeholder for the content that will be loaded on-demand.</main>
<script>
  const main = document.querySelector('main');
  const links = document.querySelectorAll('nav > a');
  for (const link of links) {
    link.addEventListener('click', async (event) => {
      event.preventDefault();
      try {
        const module = await import(`/${link.dataset.entryModule}.mjs`);
        // 模块导出了一个命名方法 `loadPageInto`.
        module.loadPageInto(main);
      } catch (error) {
        main.textContent = error.message;
      }
    });
  }
</script>

注意:被导入的 JS 模块不能跨域;如果跨域,需要为其添加 Access-Control-Allow-Origin: * 消息头。

BigInt

BigInt 是 JS 最新添加的一类基础类型。之前,如果我们要操作超出 Number.MIN_SAFE_INTEGER-Number.MAX_SAFE_INTEGER 范围的数值,需要借助 bn.js 一类的库,否则就会丢失精度;现在 BigInt 原生支持,一方面减小了引入包的大小,另一方面,性能更高。

Number 的问题:

const max = Number.MAX_SAFE_INTEGER;
// → 9_007_199_254_740_991
max + 1;
// → 9_007_199_254_740_992 ✅
max + 2;
// → 9_007_199_254_740_992 ❌

BigInt 基础使用:

BigInt(Number.MAX_SAFE_INTEGER) + 2n;
// → 9_007_199_254_740_993n ✅
1234567890123456789n * 123n;
// → 151851850485185185047n ✅

此外,需要注意的是,BigInt 类型的数值不能与 Number 类型的数字进行直接运算;在数值相等的情况下,BigIntNumber 类型数值不严格相等;Number 类型的整数可以转换为BigInt 类型后进行计算:

let big = 1n;
let n = 1;
big == n; // true
big === n; // false
big + BigInt(n); // 2n
big + n; // Uncaught TypeError: Cannot mix BigInt and other types, use explicit conversions

模块命名空间导出

JS 模块之前已经实现了命名导入的功能:

import * as utils from './utils.mjs';

现在语法进一步升级,实现命名导出:

export * as utils from './utils.mjs';

上面代码相当于以下代码的简写:

import * as utils from './utils.mjs';
export { utils };

String.prototype.matchAll

String.prototype.match 其实已经能够满足我们的大部分需求了,不过该方法只能返回匹配的字符串,而丢失了其他信息:

const string = 'Magic hex numbers: DEADBEEF CAFE';
const regex = /\b\p{ASCII_Hex_Digit}+\b/gu;
for (const match of string.match(regex)) {
  console.log(match);
}
// Output:
//
// 'DEADBEEF'
// 'CAFE'

为了获取其他信息,可以使用 exec 来循环,就是有些麻烦:

const string = 'Magic hex numbers: DEADBEEF CAFE';
const regex = /\b\p{ASCII_Hex_Digit}+\b/gu;
let match;
while (match = regex.exec(string)) {
  console.log(match);
}

// Output:
//
// [ 'DEADBEEF', index: 19, input: 'Magic hex numbers: DEADBEEF CAFE' ]
// [ 'CAFE',     index: 28, input: 'Magic hex numbers: DEADBEEF CAFE' ]

有了String#matchAll之后,实现相同功能就简单多了:

const string = 'Magic hex numbers: DEADBEEF CAFE';
const regex = /\b\p{ASCII_Hex_Digit}+\b/gu;
for (const match of string.matchAll(regex)) {
  console.log(match);
}

// Output:
//
// [ 'DEADBEEF', index: 19, input: 'Magic hex numbers: DEADBEEF CAFE' ]
// [ 'CAFE',     index: 28, input: 'Magic hex numbers: DEADBEEF CAFE' ]

String#matchAll 能返回每一个匹配的详细信息,包括分组信息:

const string = 'Favorite GitHub repos: tc39/ecma262 v8/v8.dev';
const regex = /\b(?<owner>[a-z0-9]+)\/(?<repo>[a-z0-9\.]+)\b/g;
for (const match of string.matchAll(regex)) {
  console.log(`${match[0]} at ${match.index} with '${match.input}'`);
  console.log(`→ owner: ${match.groups.owner}`);
  console.log(`→ repo: ${match.groups.repo}`);
}

// Output:
//
// tc39/ecma262 at 23 with 'Favorite GitHub repos: tc39/ecma262 v8/v8.dev'
// → owner: tc39
// → repo: ecma262
// v8/v8.dev at 36 with 'Favorite GitHub repos: tc39/ecma262 v8/v8.dev'
// → owner: v8
// → repo: v8.dev

注意:String#matchAll 只能与有 g 标志的全局匹配正则一起使用;如果没有 g 标志,会报出语法错误。

Promise.allSettle

方法 描述
Promise.allSettled 不会短路(已经加入到ES2020)
Promise.all 任意一个 rejected,则短路
Promise.race 任意一个完成(非pending),则短路
Promise.any 任意一个resolved,则短路(处于提案阶段)

以下为这四个方法的一些用例:

Promise.all

const promises = [
  fetch('/component-a.css'),
  fetch('/component-b.css'),
  fetch('/component-c.css'),
];
try {
  const styleResponses = await Promise.all(promises);
  enableStyles(styleResponses);
  renderNewUi();
} catch (reason) {
  displayError(reason);
}

只有在所有资源都加载成功的情况下,才去做展示;否则就抛出错误信息。

Promise.race

Promise.race 使用场景:

  • 拿到第一个 resolved 的结果,就继续执行;
  • 有一个rejected,就继续执行;
try {
  const result = await Promise.race([
    performHeavyComputation(),
    rejectAfterTimeout(2000),
  ]);
  renderResult(result);
} catch (error) {
  renderError(error);
}

上面的代码中,如果某个耗时操作两秒钟还未完成,就抛出错误。

Promise.allSettled

Promise.allSettled 不去关注结果是 resolved 或是 rejected,它只是去表示所有动作已经执行完成。

const promises = [
  fetch('/api-call-1'),
  fetch('/api-call-2'),
  fetch('/api-call-3'),
];
// 以下请求,有成功,有失败.

await Promise.allSettled(promises);
// 不管成功还是失败,请求完成就关掉 loading
removeLoadingIndicator();

Promise.any

Promise.anyPromise.race 类似,区别为只有当所有的状态都为 rejected 时,Promise.any 才会走到 catch。

const promises = [
  fetch('/endpoint-a').then(() => 'a'),
  fetch('/endpoint-b').then(() => 'b'),
  fetch('/endpoint-c').then(() => 'c'),
];
try {
  const first = await Promise.any(promises);
  // 任意一个 promises 为 fulfilled.
  console.log(first);
  // → e.g. 'b'
} catch (error) {
  // 所有 promises rejected.
  console.log(error);
}

globalThis

在 ES2020 之前,为了获取不同环境下的全局对象,我们可能要借助类似于下面代码的操作:

// A naive attempt at getting the global `this`. Don’t use this!
const getGlobalThis = () => {
  if (typeof globalThis !== 'undefined') return globalThis;
  if (typeof self !== 'undefined') return self;
  if (typeof window !== 'undefined') return window;
  if (typeof global !== 'undefined') return global;
  // Note: this might still return the wrong result!
  if (typeof this !== 'undefined') return this;
  throw new Error('Unable to locate global `this`');
};
const theGlobalThis = getGlobalThis();

注意,大多数情况下,我们都不需要去操作全局对象;globalThis 对 polyfills 或者是库开发最有用。

可选链操作符

这个功能你可能早就在项目中使用过了:?.,主要功能就是省略了之前需要写的一堆逻辑判断:

// 从 db 到 name,只要有其中一个属性值为空,则 nameLength 值为 undefined
const nameLength = db?.user?.name?.length;

搭配使用空值合并运算符,可以实现 lodash 的 get方法:

const object = { id: 123, names: { first: 'Alice', last: 'Smith' }};

{ // With lodash:
  const firstName = _.get(object, 'names.first');
  // → 'Alice'

  const middleName = _.get(object, 'names.middle', '(no middle name)');
  // → '(no middle name)'
}

{ // With optional chaining and nullish coalescing:
  const firstName = object?.names?.first ?? '(no first name)';
  // → 'Alice'

  const middleName = object?.names?.middle ?? '(no middle name)';
  // → '(no middle name)'
}

空值合并运算符

空值合并运算符 ?? 当左侧操作数为 null 或者 undefined 时,就返回右侧的值(其他false值不可):

let a = null ?? '123'; //123
a = undefined ?? 'abc'; //123
a = false ?? 'abc'; // false