【转载】改变JavaScript的三个点: spread运算符与rest参数

发布于 大漠

本文转载自:众成翻译 译者:loveky 链接:http://www.zcfy.cc/article/580 原文:http://rainsoft.io/how-three-dots-changed-javascript/

当在函数调用中通过arguments对象访问参数时,我总是感觉很不爽。它那硬编码的名字使得要想在内层函数(它拥有自己的arguments)中访问外层函数的arguments变得很困难。

更糟糕的是它是一个类数组对象。这意味着你不能直接在它身上调用类似.map()或是.forEach()这样的方法。

要想在内层函数里访问外层的arguments,你需要采用把它保存在一个单独变量里这样的变通方法。并且当需要遍历这个类数组对象时,你不得不使用鸭子类型并进行间接调用。看看下面的例子:

function outerFunction() {
    // 把arguments保存在单独变量中
    var argsOuter = arguments;
    function innerFunction() {
        // args is an array-like object
        var even = Array.prototype.map.call(argsOuter, function(item) {
            // 访问argsOuter
        });
    }
}

另一种情况是当函数调用接收可变数量的参数时。把数组元素填充到参数列表是很讨厌的。

例如,.push(item1, ..., itemN)会把元素一个接一个的插入数组:你不得不循环每个元素将其作为参数。但这并不总是很方便:有时需要把一整个数组的元素push到目标数组。

在ES5中这可以通过.apply()做到:用一种不友好且繁琐的方式。让我们看看:

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

幸运的是JavaScript的世界在不断改变。三点运算符...解决了很多这样的问题。这个操作符在ECMAScript 6中被引入,在我看来这是一个显著的改进。

本文将逐一浏览...运算符的使用场景并看看它是如何解决类似问题的。

1. 三个点

rest运算符用于获取函数调用时传入的参数。

function countArguments(...args) {
    return args.length;
}
// 获取参数的数量
countArguments('welcome', 'to', 'Earth'); // => 3

spread运算符用于数组的构造,析构,以及在函数调用时使用数组填充参数列表。

let cold = ['autumn', 'winter'];
let warm = ['spring', 'summer'];
// 构造数组
[...cold, ...warm] // => ['autumn', 'winter', 'spring', 'summer']
// 析构数组
let otherSeasons, autumn;
[autumn, ...otherSeasons] = cold;
otherSeasons      // => ['winter']
// 将数组元素用于函数参数
cold.push(...warm);
cold              // => ['autumn', 'winter', 'spring', 'summer']

2. 改进的参数访问

2.1 Rest参数

像在文章开始描述的那样,在复杂情景中的函数体内操作arguments对象是很麻烦的。

举例来说,一个内层函数filterNumbers()想要访问外层函数sumOnlyNumbers()arguments对象:

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

这种方式可以工作,但是太繁琐了。

rest运算符可以优雅的解决这个问题。它允许你在函数声明时定义一个rest参数...args

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以数组的形式接受调用参数。由于不存在命名冲突,现在可以在filterNumbers()中访问args了。

同样的,忘掉类数组对象吧:args就是一个数组。因此filterNumbers()可以摒弃Array.prototype.filter.call()而像args.filter()这样直接调用filter方法。

注意:rest参数需要是参数列表中的最后一个参数。

2.2 选择性的rest参数

如果不是所有的值都要包含在rest参数中,那么你需要在参数列表开始处把它们定义成逗号分隔的参数。明确定义的参数不会出现在rest参数中。

一起看看下面的例子:

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对象不具有这种选择性并且始终包含所有的参数值。

2.3 箭头函数

箭头函数并不定义自己的arguments而是会访问外层作用域中的arguments对象。

让我们通过一个例子看看:

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

rest参数items包含了所有函数调用的参数。同时,arguments对象来自外层作用域并且和outerArguments变量指向同一个对象,因此并没有什么实际意义。

3. 改进的函数调用

在本文的引言中提出的第二个问题是如何更好的把数组中的元素作为参数填充到函数调用中。

ES5在函数对象上提供了 .apply()来解决这个问题。不幸的是这项技术有3个问题:

  • 它需要手工的指定函数调用的上下文
  • 它不能使用在构造器函数调用中
  • 人们倾向于一个更短的解决方案

让我们看一个.apply()的使用案例:

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

像前面提到的那样,在.apply()调用中两次指明上下文countries是无关紧要的。属性访问器countries.push已经足够判定该方法是在哪个对象上调用的了。

同时,整个调用看起来非常繁琐。

spread运算符使用来自数组(更确切的说是一个迭代器对象)中的值填充函数调用时的参数。

让我们使用spread运算符优化上边的示例:

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

如你所见,spread运算符是一种更简单,直接的解决方案。唯一的额外字符就是三个点(...)。

Spread运算符可以在构造器调用中使用数组元素作为参数,而这并不能通过直接使用.apply()做到。让我们看一个例子:

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'

此外,你还可以在一次调用中组合使用多个spread运算符以及普通参数。下面的例子从数组中移除现有元素并插入其它数组的元素以及一个普通变量:

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

4. 改进的数组操作

4.1 构造数组

数组字面量[item1, item2, ...]除了枚举数组元素以外并没有提供其它功能。

Spread运算符允许把其它数组(或是其它的迭代器)的内容动态的插入到一个数组字面量中。

这项改进使得要完成以下描述的常见操作变得更容易。

使用来自其他数组的元素创建数组

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中的元素进行了初始化。

串联2个及以上的数组

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的结合。

复制一个数组

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

otherWords是数组words的一份拷贝。要注意的是复制只发生在数组自身上,而不会复制数组内的元素(换句话说,这不是深拷贝)。

4.2 析构数组

在ECMAScript 6中引入的析构赋值表达式可以很容易的从数组,对象中提取数据。

作为析构的一部分,spread运算符会提取数组中的部分数据。提取的结果是一个数组。

在语法方面,spread运算符应该出现在析构赋值表达式的末尾:[extractItem1, ...extractedArray] = destructedArray

让我们看看具体应用:

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

表达式[winter, ...otherSeasons]把第一个元素'winter'提取到coldSeason变量并把剩余元素提取到otherSeasons数组。

5. Spread运算符与迭代协议

Spread运算符使用迭代协议遍历元素并收集结果。这使得spread运算符变得更有价值,因为这使得任何对象都可以定义运算符以何种方式提取数据。

一个对象只要符合迭代协议就是可迭代的。

迭代协议要求对象包含一个特殊属性。该属性名需要是Symbol.iterator而属性值需要是一个返回迭代器对象的函数。

迭代器对象需要符合迭代器协议。它需要包含一个名为next的属性,其值是一个返回带有done属性(一个标识迭代是否结束的布尔值)和value属性(迭代的结果)的对象的函数。

从字面上要理解迭代协议看起来很困难,但是在它背后的代码其实很简单。

对象和基础值(primitive)必须是可迭代的才能使用spread运算值提取数据。

许多内置的基础类型和对象都是可迭代的:字符串,数组,typed arrays, sets以及maps。因此它们默认就可以被spread运算符操作。

例如,让我们看看字符串是如何遵循迭代协议的:

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']

我喜欢spread运算符可以使用对象自定义的迭代规则这一点。你可以控制spread运算符如何读取你的对象 —— 这是一个非常有效的编程技术。

下面的例子使得一个类数组对象符合迭代协议,并利用spread运算符将其转变为一个数组:

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()返回了一个带有next属性的对象(符合迭代器协议)。该属性是一个会返回控制对象{done: , value: }的函数。

由于arrayLike现在是可迭代的,spread运算符就可以把它的元素提取到数组中了:[...arrayLike]

6. 尾声

三点运算符为JavaScript添加了许多很棒的特性。

Rest参数使得收集参数变得非常简单。它是硬编码的类数组对象arguments一个合理的替代品。如果情况允许你在rest参数和arguments中选择一个,那么选择前者。

由于其繁琐的语法,.apply()方法使用起来并不是很方便。当需要拿一个数组的元素作为函数调用的参数时,spread运算符是一个不错的选择。

Spread运算符改善了数组字面量的操作。你可以更方便的初始化,连接,复制数组了。

使用析构赋值你可以提取数组的一部分。通过与迭代器协议的组合,你可以以一种更灵活的方式使用该表达式。

我希望从现在开始spread运算符可以更频繁的出现在你的代码里!Footwear Friday Nike, Jordan & More