...是怎样改变JavaScript的

179 阅读9分钟

翻译:道奇
作者:Dmitri Pavlutin
原文:How Three Dots Changed JavaScript

当访问调用函数的参数时我不喜欢使用arguments关键字,它的硬编码形式使得在函数内部访问外部函数(有自己的arguments)的arguments变得很困难。

更糟糕的是arguments是个类数组对象,你不能像方法一样直接在它上面使用.map()forEach()

如果要在嵌套函数中访问外部函数的arguments,就需要将它存储在独立的变量上,要遍历这个类似数组的对象,必须使用duck typing(动态类型风格之一)并进行间接调用。看下面的例子:

function outerFunction() {
   // 将arguments存储到独立的变量上
   const argsOuter = arguments;
   function innerFunction() {
      // args是个类数组对象
      const even = Array.prototype.map.call(argsOuter, function(item) {
         // 用argsOuter做一些处理               
      });
   }
}

另外一种情况是函数调用接受动态数量的参数,往数组里塞参数可不是让人愉快的事。

例如.push(item1, ..., itemN)一个接一个向数组插入元素:这就需要我们自己枚举参数的每个元素,这经常会很不方便:经常会碰到需要在不创建新实例的情况下,将整个数组的元素推入另一个数组。

ES5中,通过.apply()解决:不友好且冗长的方法。可以看一下:

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

幸运的是,JavaScript的世界一直在变,三点运算符...解决了很多类似的问题,这个运算符是在ECMAScript 6中引入进来的,在我看来它是一个显著的提高。

这篇文章介绍了...运算符使用场景并且展示了如何解决类似的问题。

1. 三点

rest运算符用于在函数调用和数组解构时获取参数列表,一种场景就是当运算符在操作之后收集剩下的rest

function countArguments(...args) {
   return args.length;
}
// 获取参数的数量
countArguments('welcome', 'to', 'Earth'); // => 3
// 解构数组
let otherSeasons, autumn;
[autumn, ...otherSeasons] = ['autumn', 'winter'];
otherSeasons      // => ['winter']

扩展运算符用于数组的构造和解构,在调用时从数组中填充函数参数,一种场景就是当运算符扩展数组元素

let cold = ['autumn', 'winter'];
let warm = ['spring', 'summer'];
// 构造数组
[...cold, ...warm] // => ['autumn', 'winter', 'spring', 'summer']
// 来源于数组的函数参数
cold.push(...warm);
cold              // => ['autumn', 'winter', 'spring', 'summer']

以上两种场景相当于相反的过程。

2.优化参数访问

2.1 rest参数

正如在介绍中所提到的,复杂的场景中处理函数体中的arguments对象非常麻烦。

例如,JavaScript中的内部函数filterNumbers()要访问它的外部函数sumOnlyNumbers()arguments

function sumOnlyNumbers() {
  const args = arguments;
  const 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

这种方法有用,但是太啰嗦了,const args = arguments可以省略,Array.prototype.filter.call(args)也可以通过使用rest参数改成args.filter()。让我们在这节中对它进行优化。

rest运算符很优雅的解决了这个问题,它允许在函数声明中定义rest参数 ...args

function sumOnlyNumbers(...args) {
  const 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(),直接调用filter方法args.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但是可以访问到一个这样的参数,如果你需要获取所有参数,可以使用rest参数。在下面的例子中试一下:

(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这个rest参数包含数组内所有函数调用参数,封闭域内也可以拿到arguments对象,它等于outerArguments变量,所以它是无意义的。

3.优化函数调用

在本文的简介中,第二个问题需要有更好的方式用数组填充参数。

ES5在函数对象上提供了.apply()函数来解决这个问题,不幸的是,这种方法有3个问题:

  • 需要手动指定函数调用的上下文
  • 不能在构造函数调用中使用
  • 需要有更短的解决方式

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

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

就像前面提到的,在apply()中第二次引用上下文countries看起来是不相关的,属性访问器countries.push足以确定对象上的方法调用。上面整个调用看起来就有点冗长。

扩展运算符使用数组中的值填充函数调用的参数(或者更严格地从可迭代对象开始,可以看第5节)。 下面用扩展运算符优化一下上面的例子:

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

就像上面看到的,扩展运算符是一种更干净更直接的解决方法,唯一的额外字符是3个点(...)

扩展运算符从数组中配置构造函数调用参数,当在使用.apply()时就不可能很直接。可以看个例子:

class King {
   constructor(name, country) {
     this.name = name;
     this.country = country;     
   }
   getDescription() {
     return `${this.name} leads ${this.country}`;
   }
}
const details = ['Alexander the Great', 'Greece'];
const Alexander = new King(...details);
Alexander.getDescription(); // => 'Alexander the Great leads Greece'

更重要的是你可以在同一个调用中合并多个扩展运算符和常规参数,下面的例子将数组的现有元素移除,再添加另外的数组和元素:

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

4.优化数组操作

4.1数组结构

数组定量值[item1, item2, .., itemN]除了提供枚举数组初始化元素的功能外,不提供其他功能。

扩展运算符允许快速将其他数组(或者其他定量值)插入初始化实例中,这优化了数组定量值的操作,这种改进使得完成下面这种常见任务变得更加容易。

利用另外的数组的初始化元素创建一个数组:

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

number1number2数组是通过数组定量值创建的,与此同时使用initial中的项进行初始化。

连接两个或多个数组:

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

all数组创建于oddsevens数组的连接。

克隆数组实例:

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

otherWordswords数组的克隆版本,注意,克隆只发生在数组本身上,而不发生在包含的元素上(即它不是深度克隆)。

4.2数组解构

解构赋值,在ECMAScript 6可以用,是从数组和对象中提取数据的强大表达式。

作为解构的一部分,rest运算符提取数组中的一部分,提取的结果也经常是数组。

在语法方面,rest运算符应该在解构赋值语的最后一项:[extractedItem1, ...restArray] = destructuredArray

让我们看一下应用:

const seasons = ['winter', 'spring', 'summer', 'autumn'];
const head, restArray;
[head, ...restArray] = seasons;
console.log(head);      // => 'winter'
console.log(restArray); // => ['spring', 'summer', 'autumn']

[head, ...restArray]将第一个项'winter'提取到变量head中,剩下的元素提取到restArray中。

5. 扩展运算符和迭代协议

扩展运算符使用迭代协议导航集合上的每个元素。因为对象可以定义运算符怎样提取数据,这使得扩展运算符更加有用。

"当对象符合Iterable协议时,它就是可迭代的"

迭代协议需要对象包含特殊的属性,属性的名称必须是Symbol.iterator并且它的值是一个返回迭代对象的函数。

interface Iterable {
  [Symbol.iterator]() {
    //...
    return Iterator;
  }
}

"可迭代对象必须符合迭代协议"

需要提供一个属性next,该属性值是一个函数,它返回带done(指示迭代结束的布尔值)和value(迭代结果)属性的对象。

interface Iterator {
  next() {
     //...
     return {
        value: <value>,
        done: <boolean>
     };
  };
}

从口头描述上看起来很难理解迭代协议,但在协议后的代码是非常简单的。

对象或原始值必须是可以迭代的,扩展运算符才可以从中提到数据。

很多原先原始类型和对象是可迭代的:字符串, 数组, typed数组, setsmaps。对它们可以使用扩展运算符。

例如,让我们看看一个字符串如何遵守迭代协议的:

const str = 'hi';
const 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']

我喜欢扩展运算符使用对象常规的迭代实现,你可以控制扩展运算符如何使用对象-这是一种有效的coding技术。

下面的例子让一个类数组对象遵守迭代协议,然后使用扩展运算符将它转换成数组:

function iterator() {
  let index = 0;
  return {
    next: () => ({ // Conform to Iterator protocol
      done : index >= this.length,
      value: this[index++]
    })
  };
}
const arrayLike = {
  0: 'Cat',
  1: 'Bird',
  length: 2
};
// Conform to Iterable Protocol
arrayLike[Symbol.iterator] = iterator;
const array = [...arrayLike];
console.log(array); // => ['Cat', 'Bird']

arrayLike[Symbol.iterator]在包含迭代函数iterator()的对象上新建了一个属性,使得这个对象遵守迭代协议。

terator()返回一个带next属性的对象,这个next属性用于返回控制对象:{done: <boolean>, value:<item>}

因为arrayLike现在是可迭代的,扩展运算符用来将它的元素提取进数组:[...arrayLike]

最后

三个点运算符给JavaScript带来了一大波很棒的功能。

rest参数使得收集参数变得很简单,它是硬编码类数组对象arguments的合理替代方案,如果情况允许选择rest参数和arguments,建议选择前者。

.apply()方法的冗长的语法用起来很不方便。当需要从数组中获取调用参数时,扩展运算符是个不错的替代方案。

扩展运算符优化了数组定量值的使用,可以更简单的用于初始化、连接和克隆数组。

可以使用解构赋值来提取数组的一部分。与迭代协议相结合,扩展运算可以以更多的配置方式使用。

希望从现在起扩展运算符可以更频繁的出现在你的代码中。