今天我们来了解下JavaScript中原始数组变异的数组方法,并且我们将编写一些可解决这些问题的函数去深入研究,目的是编写更干净的代码。
JavaScript中的数组突变
JavaScript中的数组只是对象,这意味着它们可以被突变。实际上,许多内置的数组方法都会使数组本身发生变异。这可能意味着仅通过使用一种内置方法就可以打破上面的黄金法则。
这是一个示例,显示了它可能如何引起一些问题:
const numbers = [1,2,3];
const countdown = numbers.reverse();
这段代码看起来不错。我们有一个叫做的数组numbers
,我们想要另一个叫countdown
以相反顺序列出数字的数组,而且似乎可行。如果您检查countdown
变量的值,这就是我们期望的结果:
countdown
<< [3,2,1]
但是,该操作的不幸副作用是该reverse()
方法也使numbers
数组发生了变异,而这根本不是我们想要的:
numbers
<< [3,2,1]
更糟糕的是,这两个变量都引用相同的数组,因此我们随后对一个变量所做的任何更改都会影响另一个变量。
例如,如果我们使用该Array.prototype.push()
方法0
在countdown
数组的末尾添加一个值,它将对数组执行相同的操作numbers
(因为它们都引用同一数组):
countdown.push(0)
<< 4
countdown
<< [3,2,1,0]
numbers
<< [3,2,1,0]
这些副作用可能不会引起注意,尤其是在大型应用程序的深度中,并且会导致某些非常难以跟踪的错误。
并且reverse
不是导致这种突变恶作剧的唯一数组方法。这是一个数组方法列表,这些方法会改变它们被调用的数组:
-
Array.prototype.pop()
-
Array.prototype.push()
-
Array.prototype.shift()
-
Array.prototype.unshift()
-
Array.prototype.reverse()
-
Array.prototype.sort()
-
Array.prototype.splice()
有点令人困惑的是,数组还有一些方法不会改变原始数组,而是返回一个新数组:
-
Array.prototype.slice()
-
Array.prototype.concat()
-
Array.prototype.map()
-
Array.prototype.filter()
这些方法将根据它们执行的操作返回一个新数组。例如,该map()
方法可用于将数组中的所有数字加倍:
const numbers = [1,2,3];
const evens = numbers.map(number => number * 2);
<< [2,4,6]
现在,如果我们检查numbers
数组,则可以看到该方法没有受到调用该方法的影响:
numbers
<< [1,2,3]
似乎没有任何原因可以解释为什么某些方法会改变数组,而另一些则不会(尽管最近添加的趋势是使它们不变),因此很难记住该做什么。
Ruby使用bang表示法对此有一个很好的解决方案。导致对对象进行永久更改的任何方法都以爆炸结束,因此[1,2,3].reverse!
将反转数组,而[1,2,3].reverse
将返回带有反转元素的新数组。
因此,既然我们已经确定了变异可能是潜在的不良后果,并且许多数组方法导致了变异,那么让我们看一下如何避免使用它们。
事实证明,编写一些功能与变异方法相同的方法并不难,但是可以返回一个新的数组对象而不是变异原始数组。
因为我们不准备补丁 Array.prototype
,所以这些函数将始终接受数组本身作为第一个参数。
Pop {#toc_2}
让我们从编写一个新pop
函数开始,该函数返回原始数组的副本,但没有最后一项。请注意,Array.prototype.pop()
返回从数组末尾弹出的值:
const pop = array => array.slice(0,-1);
此函数用于Array.prototype.slice()
返回数组的副本,但删除了最后一项(第二个参数-1表示停止在end之前切片1个位置)。我们可以在下面的示例中看到它的工作方式:
const food = ['苹果','香蕉','胡萝卜','面包'];
pop(food)
<< ['苹果','香蕉','胡萝卜']
push() {#toc_3}
接下来,让我们创建一个push()
函数,该函数将返回一个新数组,但在末尾附加一个新元素:
const push = (array, value) => [...array,value];
这使用散布运算符创建数组的副本,然后将作为第二个参数提供的值简单地添加到新数组的末尾,如下面的示例所示:
const food = ['苹果','香蕉','胡萝卜','面包'];
push(food,'茄子')
<< ['苹果','香蕉','胡萝卜','面包','茄子']
Shift and Unshift
我们可以用Array.prototype.shift()
和Array.prototype.unshift()
相似的方式编写替代:
const shift = array => array.slice(1);
对于我们的shift()
函数,我们只是从数组中切出第一个元素,而不是最后一个,如下面的示例所示:
const food = ['苹果','香蕉','胡萝卜','面包'];
shift(food)<< ['香蕉','胡萝卜','面包']
我们的unshift()
方法将返回一个新数组,并在数组的开头附加一个新值:
const unshift = (array,value) => [value,...array];
Spread运算符允许我们将值以任何顺序放置在数组中,因此我们只需将新值放置在原始数组的副本之前。我们可以在下面的示例中看到它的工作方式:
const food = ['苹果','香蕉','胡萝卜','面包'];
unshift(food,'茄子')
<< ['茄子','苹果','香蕉','胡萝卜','面包']
reverse()
现在让我们开始编写替代Array.prototype.reverse()
方法,该方法将以相反的顺序返回数组的副本,而不是变异原始数组:
const reverse = array => [...array].reverse();
该方法仍然使用该Array.prototype.reverse()
方法,但适用于我们使用散布运算符制作的原始数组的副本。创建对象后立即对其进行突变没有错,这就是我们在这里所做的。我们可以在下面的示例中看到它的工作原理:
const food = ['苹果','香蕉','胡萝卜','面包'];
reverse(food)
<< ['苹果','香蕉','胡萝卜','面包']
splice()
最后,让我们处理Array.prototype.splice()
。这是一个非常通用的函数,因此我们不会完全重写它的功能(提示:使用spread运算符和splice()
。相反,我们将专注于两种主要用途对于切片:从数组中删除项目并将项目插入到数组中。
让我们从一个将返回新数组的函数开始,但是删除给定索引处的项目:
const remove = (array, index) => [...array.slice(0, index),...array.slice(index + 1)];
这用于Array.prototype.slice()
将数组切成两半-我们要删除的项目的任一侧。第一个分片返回一个新数组,该数组从原始数组的开头复制所有元素,并到达作为参数提供的索引(但不包括它)。第二个切片返回一个数组,该数组在我们要删除的元素之后一直复制到元素的所有元素,直到原始数组的末尾。然后,我们使用散布运算符将它们放到一个新数组中。
我们可以通过尝试删除food
以下数组中索引2处的项目来检查此项是否有效:
const food = ['苹果','香蕉','胡萝卜','面包'];
remove(food,2)
<< ['苹果','香蕉','面包']
最后,让我们编写一个函数,该函数将返回一个新数组,并在特定索引处插入新值:
const insert = (array,index,value) => [...array.slice(0, index), value, ...array.slice(index)];
这与该remove()
函数的工作方式类似:它创建了数组的两个切片,但是这次包括了在提供的索引处的元素。当我们将两个切片放回一起时,我们将在两个切片之间插入作为参数提供的值。
我们可以通过尝试将蛋糕表情符号插入food
数组的中间位置来检查此作品:
const food = ['苹果','香蕉','胡萝卜','面包']
insert(food,2,'冰淇淋')
<< ['苹果','香蕉','冰淇淋','胡萝卜','面包']
现在,我们有了一组函数,这些函数可以转换数组而不改变原始数组。我已经将它们全部保存在CodePen上的一个位置中,因此可以随时复制它们并在项目中使用它们。您可以通过使它们成为单个对象的方法来命名它们,或者仅在需要时按原样使用它们。
这些对于大多数数组操作应该足够了,但是如果您需要执行其他操作,只需记住一条黄金法则:首先使用spread运算符创建原始数组的副本,然后立即将任何变异方法应用于此副本。