三个点怎样改变了 JavaScript

当访问函数调用参数值的时候,我总是对 arguments 对象感觉不舒服。它硬编码的名字使得在一个内部函数中访问外部函数的 arguments 变得困难。
更糟糕的是 JS 中它作为类数组对象,但是不能使用数组的方法像 .map().forEach()

在封闭函数中访问 arguments ,你不得不把它存储在一个临时变量中。遍历类数组对象,你不得不用鸭子类型间接调用。来看下面的例子:

1
2
3
4
5
6
7
8
9
10
function outerFunction() {  
// store arguments into a separated variable
var argsOuter = arguments;
function innerFunction() {
// args is an array-like object
var even = Array.prototype.map.call(argsOuter, function(item) {
// do something with argsOuter
});
}
}

另外一种情形是函数调用接收动态数量的参数。用一个数组作为参数是非常令人讨厌的。

.push(item1, ..., itemN) 将元素一个个的插入到一个数组为例:
你必须枚举每个作为参数的元素。这样做有时候是不方便:经常整个数组的元素需要被添加。
在 ES5 中的解决方法是 .apply() :一个不友好而且繁琐的方法。让我们看看:

1
2
3
4
var fruits = ['banana'];  
var moreFruits = ['apple', 'orange'];
Array.prototype.push.apply(fruits, moreFruits);
console.log(fruits); // ['banana', 'apple', 'orange']

幸运的是 JS 世界正在变化。三点操作符 ... 能解决很多这种情形的问题。ES6 引入了这个操作符,对 JS 是一个显著的改善。

这篇文章将通过 ... 操作符的使用场景展示怎么解决类似问题。

Three dots

可变操作符被用来获得通过函数调用的变量列表。

1
2
3
4
5
function countArguments(...args) {  
return args.length;
}
// get the number of arguments
countArguments('welcome', 'to', 'Earth'); // => 3

展开操作符被用于数组的解构,以及用数组填充函数调用参数。

1
2
3
4
5
6
7
8
9
10
11
let cold = ['autumn', 'winter'];  
let warm = ['spring', 'summer'];
// construct an array
[...cold, ...warm] // => ['autumn', 'winter', 'spring', 'summer']
// destruct an array
let otherSeasons, autumn;
[autumn, ...otherSeasons] = cold;
otherSeasons // => ['winter']
// function arguments from an array
cold.push(...warm);
cold // => ['autumn', 'winter', 'spring', 'summer']

改良的参数存取

可变参数

在简介中提到的,在复杂场景中函数体内处理 arguments 对象变得麻烦。

例如 JS 内部函数 filterNumbers() 想访问外部函数 sumOnlyNumbers()arguments

1
2
3
4
5
6
7
8
9
10
11
function sumOnlyNumbers() {  
var args = arguments;
var numbers = filterNumbers();
return numbers.reduce((sum, element) => sum + element);
function filterNumbers() {
return Array.prototype.filter.call(args,
element => typeof element === 'number'
);
}
}
sumOnlyNumbers(1, 'Hello', 5, false); // => 6

为了让 filterNumbers() 访问 sumOnlyNumbers()arguments,你必须创建一个临时变量 args 。这是因为 filterNumbers() 定义自己的 arguments 对象会重写外部的 arguments 对象。
这种方法可行,但是太繁琐了。

可变操作符优雅的解决了这个问题。它允许在函数声明中定义一个可变参数 ...args

1
2
3
4
5
6
7
8
function sumOnlyNumbers(...args) {  
var numbers = filterNumbers();
return numbers.reduce((sum, element) => sum + element);
function filterNumbers() {
return args.filter(element => typeof element === 'number');
}
}
sumOnlyNumbers(1, 'Hello', 5, false); // => 6

函数声明 function sumOnlyNumbers(...args) 表示 args 接收调用参数放在一个数组中。名字冲突解决了, args 就能够被内部的 filterNumbers() 使用了。
并且忘记类数组对象:args是一个数组,非常棒的结果。 filterNumbers() 也能摆脱 Array.prototype.filter.call() 了,筛选方法直接调用 args.filter()

注意可变参数应该是函数的最后一个参数。

有选择的可变参数

如果不是所有的参数都包含在可变参数内,你可以一开始用逗号分隔的参数定义他们。明确的定义参数不包含在可变参数内。
让我们看个例子:

1
2
3
4
5
function filter(type, ...items) {  
return items.filter(item => typeof item === type);
}
filter('boolean', true, 0, false); // => [true, false]
filter('number', false, 4, 'Welcome', 7); // => [4, 7]

arguments 对象没有可选的特性并且总是包含所有的值。

箭头函数

箭头函数不能在函数体内定义 arguments 对象,但是能访问封闭作用域的 arguments 对象。如果你想要获取所有的参数,可以用可变参数。让我们在这个例子中尝试:

1
2
3
4
5
6
7
8
(function() {
let outerArguments = arguments;
const concat = (...items) => {
console.log(arguments === outerArguments); // => true
return items.reduce((result, item) => result + item, '');
};
concat(1, 5, 'nine'); // => '15nine'
})();

items 可变参数在一个数组中包含了函数调用的所有参数。并且 arguments 对象是从封闭作用域中取得与 outerArguments 变量相等,所以它没有实际意义。

改良的函数调用

在文章简介中,第二个问题提出了有没有更好的方式用一个数组填充调用参数。
ES5 在函数对象上提供了 .apply() 方法解决这个问题。不幸的是,这个方法有三个问题:

  • 必须手动指定函数调用的上下文
  • 不能用做构造函数调用
  • 最好有一个更短的解决方案

让我们来看 .apply() 的用例:

1
2
3
let countries = ['Moldova', 'Ukraine'];  
countries.push.apply(countries, ['USA', 'Japan']);
console.log(countries); // => ['Moldova', 'Ukraine', 'USA', 'Japan']

上面例子中, .apply() 中表示的第二次上下文 countries 看起来是不相干的。在对象上 countries.push 决定方法调用已经足够了。
整个调用看起来很繁琐。

展开运算符用数组中的值填充函数调用的参数。让我们用展开运算符改良上面的例子:

1
2
3
let countries = ['Moldova', 'Ukraine'];  
countries.push(...['USA', 'Japan']);
console.log(countries); // => ['Moldova', 'Ukraine', 'USA', 'Japan']

正如所见,展开运算符是更清晰简单的解决方案。只附加了三点(...)字符。

展开运算符能让一个数组填充构造函数的调用参数,用 .apply() 的时候这事不可能的。让我们看个例子:

1
2
3
4
5
6
7
8
9
10
11
12
class King {  
constructor(name, country) {
this.name = name;
this.country = country;
}
getDescription() {
return `${this.name} leads ${this.country}`;
}
}
var details = ['Alexander the Great', 'Greece'];
var Alexander = new King(...details);
Alexander.getDescription(); // => 'Alexander the Great leads Greece'

此外你也可以在同一个调用中联合多个展开操作符和普通参数。下面的例子移除数组中已经存在的元素,然后添加另一个数组和一个元素:

1
2
3
4
5
var numbers = [1, 2];  
var evenNumbers = [4, 8];
const zero = 0;
numbers.splice(0, 2, ...evenNumbers, zero);
console.log(numbers); // => [4, 8, 0]

改良的数组操作

数组结构

数组字面量 [item1, item2, ...] 除了枚举不能提供的功能。

展开运算符通过允许插入其他数组(或任何可迭代的类型)到初始化实例中改良数组字面量。
这个改良使下面描述的任务更容易实现。

创建一个初始化元素来自其他数组的数组:

1
2
3
4
5
var initial = [0, 1];  
var numbers1 = [...initial, 5, 7];
console.log(numbers1); // [0, 1, 5, 7]
let numbers2 = [4, 8, ...initial];
console.log(numbers2); // => [4, 8, 0, 1]

number1number2 数组用数组字面量创建,同时 initial 中的项被用来初始化。

连结两个或更多的数组:

1
2
3
4
var odds = [1, 5, 7];  
var evens = [4, 6, 8];
var all = [...odds, ...evens];
console.log(all); // [1, 5, 7, 4, 6, 8]

all 数组通过连结 oddsevens 数组创建。

克隆数组实例:

1
2
3
4
var words = ['Hi', 'Hello', 'Good day'];  
var otherWords = [...words];
console.log(otherWords); // => ['Hi', 'Hello', 'Good day']
console.log(otherWords === words); // => false

otherWordswords 数组的一个克隆版本。注意克隆只发生在数组本身,不是内部元素(即不是深度克隆)。

数组解构

在 ES6 中可以使用的解构赋值是很强大的从数组对象中提取数据的表达式。

作为解构的一部分,展开操作符提取数组的一部分。提取结果总是一个数组。

按照语法,展开操作符应该是解构赋值的最后一个:[extractItem1, ...extractedArray] = destructedArray

让我们看一些应用:

1
2
3
4
5
var seasons = ['winter', 'spring', 'summer', 'autumn'];  
var coldSeason, otherSeasons;
[coldSeason, ...otherSeasons] = seasons;
console.log(coldSeason); // => 'winter'
console.log(otherSeasons); // => ['spring', 'summer', 'autumn']

[coldSeason, ...otherSeasons] 提取了第一项 wintercoldSeason 变量,余下的元素放在 otherSeason 数组。

展开操作符和迭代协议

展开操作符用迭代协议在元素上操作并收集结果。这使得展开操作符非常有用,因为任意的对象能定义操作符怎么提取数据。

一个遵守迭代协议的对象是可迭代的。
迭代协议要求对象包含一个特殊属性。这个属性名叫 Symbol.iterator ,它的值是一个返回迭代对象的函数。这个迭代对象应该遵守迭代协议。它需要提供一个 next 属性,属性的值是一个函数,函数的返回值是包含 done (表示迭代结束的布尔值)和 value (迭代结果)属性的对象。
看起来通过文字描述理解了迭代协议,然后后面的代码相当简单。

操作符从对象或原生类型取出数据必须被有序迭代。

很多原生类型和对象能被迭代:字符串、数组、类型数组、sets 和 maps。所以他们默认能使用展开操作符。
我们来看遵守迭代协议的字符串怎么被展开的:

1
2
3
4
5
6
7
var str = 'hi';  
var iterator = str[Symbol.iterator]();
iterator.toString(); // => '[object String Iterator]'
iterator.next(); // => { value: 'h', done: false }
iterator.next(); // => { value: 'i', done: false }
iterator.next(); // => { value: undefined, done: true }
[...str]; // => ['h', 'i']

我喜欢用对象自定义迭代执行展开操作符。你能控制怎样展开你的对象——一个实用的代码技巧。

下面的例子制造一个符合迭代协议的类数组对象,然后用展开操作符转换成数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function iterator() {  
var index = 0;
return {
next: () => ({ // Conform to Iterator protocol
done : index >= this.length,
value: this[index++]
})
};
}
var arrayLike = {
0: 'Cat',
1: 'Bird',
length: 2
};
arrayLike[Symbol.iterator] = iterator; //Conform to Iterable Protocol
var array = [...arrayLike];
console.log(array); // => ['Cat', 'Bird']

arrayLike[Symbol.iterator] 在对象上创建一个包含迭代函数 iterator() 的属性使对象符合迭代协议。 iterator() 返回一个包含返回一个控制对象 {done: <boolean>, value: <item>}next 函数的对象(符合迭代协议)。
由于现在 arrayLike 是可迭代的,展开操作符就能取出它的元素放入一个数组: [...arrayLike]

最后

三点操作符为 JS 添加了一堆非常好的特性。

可变操书似的收集参数更容易了。它理应替代硬编码的类数组对象 arguments。如果情形允许在可变参数和 arguments 之间选择,那就选择第一个。

.apply() 的冗长语法是不方便的。当调用参数取自一个数组的时候,展开操作符是一个很好的选择。

展开操作符改良了数组字面量。你能更简单的初始化、连结和克隆数组。

你能用解构赋值取出数组的一部分。结合迭代协议在更多可配置的方式中使用展开操作符变得可能。

我希望从现在开始展开操作符经常出现在你的代码中!