整理 | 丁晓昀、核子可乐
ECMAScript 2023 规范最近已经定稿,其中提出的 Array 对象新方法将为 JavaScript 带来更好的可预测性和可维护性。toSorted、toReversed、toSpliced 和 with 方法允许用户在不更改数据的情况下对数据执行操作,实质是先制造副本再更改该副本。
变异与副作用
Array 对象总是有点自我分裂。sort、reverse 和 splice 等方法会就地更改数组,concat、map 和 filter 等其他方法则是先创建数组副本,再对副本执行操作。当我们通过操作让对象产生变异时,则会产生一种副作用,导致系统其他位置发生意外行为。
举例来说,当 reverse 一个数组时会发生如下情况。
代码语言:javascript复制const languages = ["JavaScript", "TypeScript", "CoffeeScript"];
const reversed = languages.reverse();
console.log(reversed);
// => [ 'CoffeeScript', 'TypeScript', 'JavaScript' ]
console.log(languages);
// => [ 'CoffeeScript', 'TypeScript', 'JavaScript' ]
console.log(Object.is(languages, reversed));
// => true
可以看到,原始数组已经反转,但即使我们将反转数组的结果分配给一个新变量,两个变量也仍指向同一数组。
变异数组和 React
数组变异方法中一个最著名的问题,就是在 React 组件中使用时的异常。我们无法变异数组,之后尝试将其设置为新状态,因为数组本身是同一个对象且不会触发新的渲染。相反,我们需要先复制该数组,然后改变副本再将其设置为新状态。因此,React 文档专门有一整页解释了如何更新状态数组。
先复制,后变异
解决这个问题的方法,是先复制数组,之后再执行变异。我们可以通过几种不同方法来生成数组副本,包括:Array.from,展开运算符,或者调用不带参数的 slice 函数。
代码语言:javascript复制const languages = ["JavaScript", "TypeScript", "CoffeeScript"];
const reversed = Array.from(languages).reverse();
// => [ 'CoffeeScript', 'TypeScript', 'JavaScript' ]
console.log(languages);
// => [ 'JavaScript', 'TypeScript', 'CoffeeScript' ]
console.log(Object.is(languages, reversed));
// => false
有办法能解决当然很好,总之请千万注意不同复制操作间是有区别的。
新方法可随副本变化
此次公布的新方法正是为此而生。toSorted、toReversed、toSpliced 和 with 都能复制原始数组、变更副本再返回结果。如此一来,每项操作都更易于编写,开发者只需调用一个函数即可,代码阅读起来也更容易、不必预先考虑到底要用具体哪种数组复制方法。下面,我们来看这几种新方法的区别。
Array.prototype.toSorted
其中 toSorted 函数会返回一个新的、经过排序的数组。
代码语言:javascript复制const languages = ["JavaScript", "TypeScript", "CoffeeScript"];
const sorted = languages.toSorted();
console.log(sorted);
// => [ 'CoffeeScript', 'JavaScript', 'TypeScript' ]
console.log(languages);
// => [ 'JavaScript', 'TypeScript', 'CoffeeScript' ]
除了复制之外,sort 函数还会引发一些意想不到的行为,toSorted 也继承了这种特点。所以在对带有重音字符的数字或字符串进行排序时,大家仍然要小心。比如准备一个 comparator 比较器函数(例如 String's localeCompare)来生成当前查找的结果。
代码语言:javascript复制const numbers = [5, 3, 10, 7, 1];
const sorted = numbers.toSorted();
console.log(sorted);
// => [ 1, 10, 3, 5, 7 ]
const sortedCorrectly = numbers.toSorted((a, b) => a - b);
console.log(sortedCorrectly);
// => [ 1, 3, 5, 7, 10 ]
代码语言:javascript复制const strings = ["abc", "äbc", "def"];
const sorted = strings.toSorted();
console.log(sorted);
// => [ 'abc', 'def', 'äbc' ]
const sortedCorrectly = strings.toSorted((a, b) => a.localeCompare(b));
console.log(sortedCorrectly);
// => [ 'abc', 'äbc', 'def' ]
Array.prototype.toReversed
使用 toReversed 函数,会返回一个按相反顺序排序的新数组。
代码语言:javascript复制const languages = ["JavaScript", "TypeScript", "CoffeeScript"];
const reversed = languages.toReversed();
console.log(reversed);
// => [ 'CoffeeScript', 'TypeScript', 'JavaScript' ]
之前将 reverse 的结果分配给新变量时会出问题,因为原始数组也发生了变异。但现在,大家可以使用 toReversed 或者 toSorted 来复制数组并更改副本。
Array.prototype.toSpliced
toSpliced 函数与原始版本的 splice 略有不同。splice 是在提供的索引处删除和添加元素来更改现有数组,再返回一个包含数组中所删除元素的数组。toSpliced 则直接返回一个新数组,其中不含被删除的元素,且包含所添加的元素。其工作方式如下:
代码语言:javascript复制const languages = ["JavaScript", "TypeScript", "CoffeeScript"];
const spliced = languages.toSpliced(2, 1, "Dart", "WebAssembly");
console.log(spliced);
// => [ 'JavaScript', 'TypeScript', 'Dart', 'WebAssembly' ]
如果我们使用 splice 作为返回值,那么 toSpliced 就不能直接作为替代使用。换言之,如果大家想在不改变原始数组的情况下知晓被删除的元素是什么,就应使用 slice 复制方法。
更麻烦的是,splice 和 slice 使用的参数也有不同。splice 使用的是一个索引加该索引之后待删除的元素数量;slice 则使用两个索引,分别对应开始和结束。如果要使用 toSpliced 代替 splice,但又想获取被删除的元素,则可对原始数组应用 toSpliced 和 slice,如下所示:
代码语言:javascript复制const languages = ["JavaScript", "TypeScript", "CoffeeScript"];
const startDeletingAt = 2;
const deleteCount = 1;
const spliced = languages.toSpliced(startDeletingAt, deleteCount, "Dart", "WebAssembly");
const removed = languages.slice(startDeletingAt, startDeletingAt deleteCount);
console.log(spliced);
// => [ 'JavaScript', 'TypeScript', 'Dart', 'WebAssembly' ]
console.log(removed);
// => [ 'CoffeeScript' ]
Array.prototype.with
with 函数所代表的复制方法,等同于使用方括号表示方来更改数组内的一个元素。因此,与其通过以下方式直接更改数组:
代码语言:javascript复制const languages = ["JavaScript", "TypeScript", "CoffeeScript"];
languages[2] = "WebAssembly";
console.log(languages);
// => [ 'JavaScript', 'TypeScript', 'WebAssembly' ]
可以复制该数组再执行更改:
代码语言:javascript复制const languages = ["JavaScript", "TypeScript", "CoffeeScript"];
const updated = languages.with(2, "WebAssembly");
console.log(updated);
// => [ 'JavaScript', 'TypeScript', 'WebAssembly' ]
console.log(languages);
// => [ 'JavaScript', 'TypeScript', CoffeeScript' ]
不只是数组
此次发布的新方法不仅适用于常规的数组对象。您可以在任意 TypedArray 上使用 toSorted、toReversed 和 with 方法,包括 Int8Array 到 BigUint64Array 等各种类型。但因为 TypedArrays 没有 splice 方法,因此无法使用 toSpliced 方法。
注意事项
前文提到,map、filter 和 concat 等方法也都采取先复制再更改的思路,但这些方法与新的复制方法间仍有不同。如果对内置的 Array 对象进行扩展,并在实例上使用 map、flatMap、filter 或 concat,则会返回相同类型的新实例。但如果您扩展一个 Array 并使用 toSorted、toReversed、toSpliced 或者 with,则返回的仍是普通 Array。
代码语言:javascript复制class MyArray extends Array {}
const languages = new MyArray("JavaScript", "TypeScript", "CoffeeScript");
const upcase = languages.map(language => language.toUpperCase());
console.log(upcase instanceof MyArray);
// => true
const reversed = languages.toReversed();
console.log(reversed instanceof MyArray);
// => false
可以使用 MyArray.from 将其转回您的自定义 Array:
代码语言:javascript复制class MyArray extends Array {}
const languages = new MyArray("JavaScript", "TypeScript", "CoffeeScript");
const reversed = MyArray.from(languages.toReversed());
console.log(reversed instance of MyArray);
// => true
支持
虽然 ECMAScript 2023 的规范刚刚成形,但已经为本文提到的新数组方法提供了良好支持。Chrome 110、Safari 16.3、Node.js 20 和 Deno1.31 都支持这四种新方法,尚不支持的平台也有 polyfills 和 shims 作为过渡方案。
JavaScript 仍在不断改进
很高兴看到 ECMAScript 标准新增了这么多有意义的内容,让我们能轻松编写出可预测性更好的代码。其他一些提案也已被纳入 ES2023,感兴趣的朋友可以移步此处:https://github.com/tc39/proposals/blob/HEAD/finished-proposals.md
至于未来的规范发展方向,推荐大家参考整个 TC39 提案库:https://github.com/tc39/proposals
附录:ES2023 新特性概述
数组倒序查找
代码语言:javascript复制Array.prototype.findLast 和 Array.prototype.findLastIndex
let nums = [5,4,3,2,1];
let lastEven = nums.findLast((num) => num % 2 === 0); // 2
let lastEvenIndex = nums.findLastIndex((num) => num % 2 === 0); // 3
Hashbang 语法
#! for JS
此脚本的第一行以 #!开头,表示可在注释中包含任意文本。
代码语言:javascript复制#!/usr/bin/env node
// in the Script Goal
'use strict';
console.log(1);
将符号作为 WeakMap 键
在弱集合和注册表中使用符号
注意:注册的符号不可作为 weakmap 键。
代码语言:javascript复制let sym = Symbol("foo");
let obj = {name: "bar"};
let wm = new WeakMap();
wm.set(sym, obj);
console.log(wm.get(sym)); // {name: "bar"}
代码语言:javascript复制sym = Symbol("foo");
let ws = new WeakSet();
ws.add(sym);
console.log(ws.has(sym)); // true
代码语言:javascript复制sym = Symbol("foo");
let wr = new WeakRef(sym);
console.log(wr.deref()); // Symbol(foo)
代码语言:javascript复制sym = Symbol("foo");
let cb = (value) => {
console.log("Finalized:", value);
};
let fr = new FinalizationRegistry(cb);
obj = {name: "bar"};
fr.register(obj, "bar", sym);
fr.unregister(sym);
通过副本更改数组
返回更改后的 Array 和 TypeArray 副本。
注意:类型数组不可 tospliced。
代码语言:javascript复制const greek = ['gamma', 'aplha', 'beta']
greek.toSorted(); // [ 'aplha', 'beta', 'gamma' ]
greek; // [ 'gamma', 'aplha', 'beta' ]
const nums = [0, -1, 3, 2, 4]
nums.toSorted((n1, n2) => n1 - n2); // [-1,0,2,3,4]
nums; // [0, -1, 3, 2, 4]
代码语言:javascript复制const greek = ['gamma', 'aplha', 'beta']
greek.toReversed(); // [ 'beta', 'aplha', 'gamma' ]
greek; // [ 'gamma', 'aplha', 'beta' ]
代码语言:javascript复制const greek = ['gamma', 'aplha', 'beta']
greek..toSpliced(1,2); // [ 'gamma' ]
greek; // [ 'gamma', 'aplha', 'beta' ]
greek.toSpliced(1,2, ...['delta']); // [ 'gamma', 'delta' ]
greek; // [ 'gamma', 'aplha', 'beta' ]
代码语言:javascript复制const greek = ['gamma', 'aplha', 'beta']
greek..toSpliced(1,2); // [ 'gamma' ]
greek; // [ 'gamma', 'aplha', 'beta' ]
greek.toSpliced(1,2, ...['delta']); // [ 'gamma', 'delta' ]
greek; // [ 'gamma', 'aplha', 'beta' ]
代码语言:javascript复制const greek = ['gamma', 'aplha', 'beta'];
greek.with(2, 'bravo'); // [ 'gamma', 'aplha', 'bravo' ]
greek; // ['gamma', 'aplha', 'beta'];
参考链接:https://h3manth.com/ES2023/