2022年7月17日

数组方法

数组提供的方法有很多。为了方便起见,在本章中,我们将按组讲解。

添加/移除数组元素

我们已经学了从数组的首端或尾端添加和删除元素的方法:

  • arr.push(...items) —— 从尾端添加元素,
  • arr.pop() —— 从尾端提取元素,
  • arr.shift() —— 从首端提取元素,
  • arr.unshift(...items) —— 从首端添加元素。

这里还有其他几种方法。

splice

如何从数组中删除元素?

数组是对象,所以我们可以尝试使用 delete

let arr = ["I", "go", "home"];

delete arr[1]; // remove "go"

alert( arr[1] ); // undefined

// now arr = ["I",  , "home"];
alert( arr.length ); // 3

元素被删除了,但数组仍然有 3 个元素,我们可以看到 arr.length == 3

这很正常,因为 delete obj.key 是通过 key 来移除对应的值。对于对象来说是可以的。但是对于数组来说,我们通常希望剩下的元素能够移动并占据被释放的位置。我们希望得到一个更短的数组。

所以应该使用特殊的方法。

arr.splice 方法可以说是处理数组的瑞士军刀。它可以做所有事情:添加,删除和插入元素。

语法是:

arr.splice(start[, deleteCount, elem1, ..., elemN])

它从索引 start 开始修改 arr:删除 deleteCount 个元素并在当前位置插入 elem1, ..., elemN。最后返回被删除的元素所组成的数组。

通过例子我们可以很容易地掌握这个方法。

让我们从删除开始:

let arr = ["I", "study", "JavaScript"];

arr.splice(1, 1); // 从索引 1 开始删除 1 个元素

alert( arr ); // ["I", "JavaScript"]

简单,对吧?从索引 1 开始删除 1 个元素。(译注:当只填写了 splicestart 参数时,将删除从索引 start 开始的所有数组项)

在下一个例子中,我们删除了 3 个元素,并用另外两个元素替换它们:

let arr = ["I", "study", "JavaScript", "right", "now"];

// 删除数组的前三项,并使用其他内容代替它们
arr.splice(0, 3, "Let's", "dance");

alert( arr ) // 现在 ["Let's", "dance", "right", "now"]

在这里我们可以看到 splice 返回了被删除的元素所组成的数组:

let arr = ["I", "study", "JavaScript", "right", "now"];

// 删除前两个元素
let removed = arr.splice(0, 2);

alert( removed ); // "I", "study" <-- 被从数组中删除了的元素

我们可以将 deleteCount 设置为 0splice 方法就能够插入元素而不用删除任何元素:

let arr = ["I", "study", "JavaScript"];

// 从索引 2 开始
// 删除 0 个元素
// 然后插入 "complex" 和 "language"
arr.splice(2, 0, "complex", "language");

alert( arr ); // "I", "study", "complex", "language", "JavaScript"
允许负向索引

在这里和其他数组方法中,负向索引都是被允许的。它们从数组末尾计算位置,如下所示:

let arr = [1, 2, 5];

// 从索引 -1(尾端前一位)
// 删除 0 个元素,
// 然后插入 3 和 4
arr.splice(-1, 0, 3, 4);

alert( arr ); // 1,2,3,4,5

slice

arr.slice 方法比 arr.splice 简单得多。

语法是:

arr.slice([start], [end])

它会返回一个新数组,将所有从索引 startend(不包括 end)的数组项复制到一个新的数组。startend 都可以是负数,在这种情况下,从末尾计算索引。

它和字符串的 str.slice 方法有点像,就是把子字符串替换成子数组。

例如:

let arr = ["t", "e", "s", "t"];

alert( arr.slice(1, 3) ); // e,s(复制从位置 1 到位置 3 的元素)

alert( arr.slice(-2) ); // s,t(复制从位置 -2 到尾端的元素)

我们也可以不带参数地调用它:arr.slice() 会创建一个 arr 的副本。其通常用于获取副本,以进行不影响原始数组的进一步转换。

concat

arr.concat 创建一个新数组,其中包含来自于其他数组和其他项的值。

语法:

arr.concat(arg1, arg2...)

它接受任意数量的参数 —— 数组或值都可以。

结果是一个包含来自于 arr,然后是 arg1arg2 的元素的新数组。

如果参数 argN 是一个数组,那么其中的所有元素都会被复制。否则,将复制参数本身。

例如:

let arr = [1, 2];

// 从 arr 和 [3,4] 创建一个新数组
alert( arr.concat([3, 4]) ); // 1,2,3,4

// 从 arr、[3,4] 和 [5,6] 创建一个新数组
alert( arr.concat([3, 4], [5, 6]) ); // 1,2,3,4,5,6

// 从 arr、[3,4]、5 和 6 创建一个新数组
alert( arr.concat([3, 4], 5, 6) ); // 1,2,3,4,5,6

通常,它只复制数组中的元素。其他对象,即使它们看起来像数组一样,但仍然会被作为一个整体添加:

let arr = [1, 2];

let arrayLike = {
  0: "something",
  length: 1
};

alert( arr.concat(arrayLike) ); // 1,2,[object Object]

……但是,如果类数组对象具有 Symbol.isConcatSpreadable 属性,那么它就会被 concat 当作一个数组来处理:此对象中的元素将被添加:

let arr = [1, 2];

let arrayLike = {
  0: "something",
  1: "else",
  [Symbol.isConcatSpreadable]: true,
  length: 2
};

alert( arr.concat(arrayLike) ); // 1,2,something,else

遍历:forEach

arr.forEach 方法允许为数组的每个元素都运行一个函数。

语法:

arr.forEach(function(item, index, array) {
  // ... do something with item
});

例如,下面这个程序显示了数组的每个元素:

// 对每个元素调用 alert
["Bilbo", "Gandalf", "Nazgul"].forEach(alert);

而这段代码更详细地介绍了它们在目标数组中的位置:

["Bilbo", "Gandalf", "Nazgul"].forEach((item, index, array) => {
  alert(`${item} is at index ${index} in ${array}`);
});

该函数的结果(如果它有返回)会被抛弃和忽略。

在数组中搜索

现在,让我们介绍在数组中进行搜索的方法。

indexOf/lastIndexOf 和 includes

arr.indexOfarr.includes 方法语法相似,并且作用基本上也与字符串的方法相同,只不过这里是对数组元素而不是字符进行操作:

  • arr.indexOf(item, from) —— 从索引 from 开始搜索 item,如果找到则返回索引,否则返回 -1
  • arr.includes(item, from) —— 从索引 from 开始搜索 item,如果找到则返回 true(译注:如果没找到,则返回 false)。

通常使用这些方法时只会传入一个参数:传入 item 开始搜索。默认情况下,搜索是从头开始的。

例如:

let arr = [1, 0, false];

alert( arr.indexOf(0) ); // 1
alert( arr.indexOf(false) ); // 2
alert( arr.indexOf(null) ); // -1

alert( arr.includes(1) ); // true

请注意,indexOfincludes 使用严格相等 === 进行比较。所以,如果我们搜索 false,它会准确找到 false 而不是数字 0

如果我们想检查数组中是否包含元素 item,并且不需要知道其确切的索引,那么 arr.includes 是首选。

方法 arr.lastIndexOfindexOf 相同,但从右向左查找。

let fruits = ['Apple', 'Orange', 'Apple'];

alert( arr.indexOf('Apple') ); // 0(第一个 Apple)
alert( arr.lastIndexOf('Apple') ); // 2(最后一个 Apple)
方法 includes 可以正确的处理 NaN

方法 includes 的一个次要但值得注意的特性是,它可以正确处理 NaN,这与 indexOf 不同:

const arr = [NaN];
alert( arr.indexOf(NaN) ); // -1(错,应该为 0)
alert( arr.includes(NaN) );// true(正确)

这是因为 includes 是在比较晚的时候才被添加到 JavaScript 中的,并且在内部使用了更新了的比较算法。

find 和 findIndex/findLastIndex

想象一下,我们有一个对象数组。我们如何找到具有特定条件的对象?

这时可以用 arr.find 方法。

语法如下:

let result = arr.find(function(item, index, array) {
  // 如果返回 true,则返回 item 并停止迭代
  // 对于假值(falsy)的情况,则返回 undefined
});

依次对数组中的每个元素调用该函数:

  • item 是元素。
  • index 是它的索引。
  • array 是数组本身。

如果它返回 true,则搜索停止,并返回 item。如果没有搜索到,则返回 undefined

例如,我们有一个存储用户的数组,每个用户都有 idname 字段。让我们找到 id == 1 的那个用户:

let users = [
  {id: 1, name: "John"},
  {id: 2, name: "Pete"},
  {id: 3, name: "Mary"}
];

let user = users.find(item => item.id == 1);

alert(user.name); // John

在现实生活中,对象数组是很常见的,所以 find 方法非常有用。

注意在这个例子中,我们传给了 find 一个单参数函数 item => item.id == 1。这很典型,并且 find 方法的其他参数很少使用。

arr.findIndex 方法(与 arr.find)具有相同的语法,但它返回找到的元素的索引,而不是元素本身。如果没找到,则返回 -1

arr.findLastIndex 方法类似于 findIndex,但从右向左搜索,类似于 lastIndexOf

这是一个例子:

let users = [
  {id: 1, name: "John"},
  {id: 2, name: "Pete"},
  {id: 3, name: "Mary"},
  {id: 4, name: "John"}
];

// 寻找第一个 John 的索引
alert(users.findIndex(user => user.name == 'John')); // 0

// 寻找最后一个 John 的索引
alert(users.findLastIndex(user => user.name == 'John')); // 3

filter

find 方法搜索的是使函数返回 true 的第一个(单个)元素。

如果需要匹配的有很多,我们可以使用 arr.filter(fn)

语法与 find 大致相同,但是 filter 返回的是所有匹配元素组成的数组:

let results = arr.filter(function(item, index, array) {
  // 如果 true item 被 push 到 results,迭代继续
  // 如果什么都没找到,则返回空数组
});

例如:

let users = [
  {id: 1, name: "John"},
  {id: 2, name: "Pete"},
  {id: 3, name: "Mary"}
];

// 返回前两个用户的数组
let someUsers = users.filter(item => item.id < 3);

alert(someUsers.length); // 2

转换数组

让我们继续学习进行数组转换和重新排序的方法。

map

arr.map 方法是最有用和经常使用的方法之一。

它对数组的每个元素都调用函数,并返回结果数组。

语法:

let result = arr.map(function(item, index, array) {
  // 返回新值而不是当前元素
})

例如,在这里我们将每个元素转换为它的字符串长度:

let lengths = ["Bilbo", "Gandalf", "Nazgul"].map(item => item.length);
alert(lengths); // 5,7,6

sort(fn)

arr.sort 方法对数组进行 原位(in-place) 排序,更改元素的顺序。(译注:原位是指在此数组内,而非生成一个新数组。)

它还返回排序后的数组,但是返回值通常会被忽略,因为修改了 arr 本身。

语法:

let arr = [ 1, 2, 15 ];

// 该方法重新排列 arr 的内容
arr.sort();

alert( arr );  // 1, 15, 2

你有没有注意到结果有什么奇怪的地方?

顺序变成了 1, 15, 2。不对,但为什么呢?

这些元素默认情况下被按字符串进行排序。

从字面上看,所有元素都被转换为字符串,然后进行比较。对于字符串,按照词典顺序进行排序,实际上应该是 "2" > "15"

要使用我们自己的排序顺序,我们需要提供一个函数作为 arr.sort() 的参数。

该函数应该比较两个任意值并返回:

function compare(a, b) {
  if (a > b) return 1; // 如果第一个值比第二个值大
  if (a == b) return 0; // 如果两个值相等
  if (a < b) return -1; // 如果第一个值比第二个值小
}

例如,按数字进行排序:

function compareNumeric(a, b) {
  if (a > b) return 1;
  if (a == b) return 0;
  if (a < b) return -1;
}

let arr = [ 1, 2, 15 ];

arr.sort(compareNumeric);

alert(arr);  // 1, 2, 15

现在结果符合预期了。

我们思考一下这儿发生了什么。arr 可以是由任何内容组成的数组,对吗?它可能包含数字、字符串、对象或其他任何内容。我们有一组 一些元素。要对其进行排序,我们需要一个 排序函数 来确认如何比较这些元素。默认是按字符串进行排序的。

arr.sort(fn) 方法实现了通用的排序算法。我们不需要关心它的内部工作原理(大多数情况下都是经过 快速排序Timsort 算法优化的)。它将遍历数组,使用提供的函数比较其元素并对其重新排序,我们所需要的就是提供执行比较的函数 fn

顺便说一句,如果我们想知道要比较哪些元素 —— 那么什么都不会阻止 alert 它们:

[1, -2, 15, 2, 0, 8].sort(function(a, b) {
  alert( a + " <> " + b );
  return a - b;
});

该算法可以在此过程中,将一个元素与多个其他元素进行比较,但是它会尝试进行尽可能少的比较。

比较函数可以返回任何数字

实际上,比较函数只需要返回一个正数表示“大于”,一个负数表示“小于”。

通过这个原理我们可以编写更短的函数:

let arr = [ 1, 2, 15 ];

arr.sort(function(a, b) { return a - b; });

alert(arr);  // 1, 2, 15
箭头函数最好

你还记得 箭头函数 吗?这里使用箭头函数会更加简洁:

arr.sort( (a, b) => a - b );

这与上面更长的版本完全相同。

使用 localeCompare for strings

你记得 字符串比较 算法吗?默认情况下,它通过字母的代码比较字母。

对于许多字母,最好使用 str.localeCompare 方法正确地对字母进行排序,例如 Ö

例如,让我们用德语对几个国家/地区进行排序:

let countries = ['Österreich', 'Andorra', 'Vietnam'];

alert( countries.sort( (a, b) => a > b ? 1 : -1) ); // Andorra, Vietnam, Österreich(错的)

alert( countries.sort( (a, b) => a.localeCompare(b) ) ); // Andorra,Österreich,Vietnam(对的!)

reverse

arr.reverse 方法用于颠倒 arr 中元素的顺序。

例如:

let arr = [1, 2, 3, 4, 5];
arr.reverse();

alert( arr ); // 5,4,3,2,1

它也会返回颠倒后的数组 arr

split 和 join

举一个现实生活场景的例子。我们正在编写一个消息应用程序,并且该人员输入以逗号分隔的接收者列表:John, Pete, Mary。但对我们来说,名字数组比单个字符串舒适得多。怎么做才能获得这样的数组呢?

str.split(delim) 方法可以做到。它通过给定的分隔符 delim 将字符串分割成一个数组。

在下面的例子中,我们用“逗号后跟着一个空格”作为分隔符:

let names = 'Bilbo, Gandalf, Nazgul';

let arr = names.split(', ');

for (let name of arr) {
  alert( `A message to ${name}.` ); // A message to Bilbo(和其他名字)
}

split 方法有一个可选的第二个数字参数 —— 对数组长度的限制。如果提供了,那么额外的元素会被忽略。但实际上它很少使用:

let arr = 'Bilbo, Gandalf, Nazgul, Saruman'.split(', ', 2);

alert(arr); // Bilbo, Gandalf
拆分为字母

调用带有空参数 ssplit(s),会将字符串拆分为字母数组:

let str = "test";

alert( str.split('') ); // t,e,s,t

arr.join(glue)split 相反。它会在它们之间创建一串由 glue 粘合的 arr 项。

例如:

let arr = ['Bilbo', 'Gandalf', 'Nazgul'];

let str = arr.join(';'); // 使用分号 ; 将数组粘合成字符串

alert( str ); // Bilbo;Gandalf;Nazgul

reduce/reduceRight

当我们需要遍历一个数组时 —— 我们可以使用 forEachforfor..of

当我们需要遍历并返回每个元素的数据时 —— 我们可以使用 map

arr.reduce 方法和 arr.reduceRight 方法和上面的种类差不多,但稍微复杂一点。它们用于根据数组计算单个值。

语法是:

let value = arr.reduce(function(accumulator, item, index, array) {
  // ...
}, [initial]);

该函数一个接一个地应用于所有数组元素,并将其结果“搬运(carry on)”到下一个调用。

参数:

  • accumulator —— 是上一个函数调用的结果,第一次等于 initial(如果提供了 initial 的话)。
  • item —— 当前的数组元素。
  • index —— 当前索引。
  • arr —— 数组本身。

应用函数时,上一个函数调用的结果将作为第一个参数传递给下一个函数。

因此,第一个参数本质上是累加器,用于存储所有先前执行的组合结果。最后,它成为 reduce 的结果。

听起来复杂吗?

掌握这个知识点的最简单的方法就是通过示例。

在这里,我们通过一行代码得到一个数组的总和:

let arr = [1, 2, 3, 4, 5];

let result = arr.reduce((sum, current) => sum + current, 0);

alert(result); // 15

传递给 reduce 的函数仅使用了 2 个参数,通常这就足够了。

让我们看看细节,到底发生了什么。

  1. 在第一次运行时,sum 的值为初始值 initialreduce 的最后一个参数),等于 0,current 是第一个数组元素,等于 1。所以函数运行的结果是 1
  2. 在第二次运行时,sum = 1,我们将第二个数组元素(2)与其相加并返回。
  3. 在第三次运行中,sum = 3,我们继续把下一个元素与其相加,以此类推……

计算流程:

或者以表格的形式表示,每一行代表的是对下一个数组元素的函数调用:

sum current result
第 1 次调用 0 1 1
第 2 次调用 1 2 3
第 3 次调用 3 3 6
第 4 次调用 6 4 10
第 5 次调用 10 5 15

在这里,我们可以清楚地看到上一个调用的结果如何成为下一个调用的第一个参数。

我们也可以省略初始值:

let arr = [1, 2, 3, 4, 5];

// 删除 reduce 的初始值(没有 0)
let result = arr.reduce((sum, current) => sum + current);

alert( result ); // 15

结果是一样的。这是因为如果没有初始值,那么 reduce 会将数组的第一个元素作为初始值,并从第二个元素开始迭代。

计算表与上面相同,只是去掉第一行。

但是这种使用需要非常小心。如果数组为空,那么在没有初始值的情况下调用 reduce 会导致错误。

例如:

let arr = [];

// Error: Reduce of empty array with no initial value
// 如果初始值存在,则 reduce 将为空 arr 返回它(即这个初始值)。
arr.reduce((sum, current) => sum + current);

所以建议始终指定初始值。

arr.reduceRightarr.reduce 方法的功能一样,只是遍历为从右到左。

Array.isArray

数组是基于对象的,不构成单独的语言类型。

所以 typeof 不能帮助从数组中区分出普通对象:

alert(typeof {}); // object
alert(typeof []); // object(相同)

……但是数组经常被使用,因此有一种特殊的方法用于判断:Array.isArray(value)。如果 value 是一个数组,则返回 true;否则返回 false

alert(Array.isArray({})); // false

alert(Array.isArray([])); // true

大多数方法都支持 “thisArg”

几乎所有调用函数的数组方法 —— 比如 findfiltermap,除了 sort 是一个特例,都接受一个可选的附加参数 thisArg

上面的部分中没有解释该参数,因为该参数很少使用。但是为了完整性,我们需要讲讲它。

以下是这些方法的完整语法:

arr.find(func, thisArg);
arr.filter(func, thisArg);
arr.map(func, thisArg);
// ...
// thisArg 是可选的最后一个参数

thisArg 参数的值在 func 中变为 this

例如,在这里我们使用 army 对象方法作为过滤器,thisArg 用于传递上下文(passes the context):

let army = {
  minAge: 18,
  maxAge: 27,
  canJoin(user) {
    return user.age >= this.minAge && user.age < this.maxAge;
  }
};

let users = [
  {age: 16},
  {age: 20},
  {age: 23},
  {age: 30}
];

// 找到 army.canJoin 返回 true 的 user
let soldiers = users.filter(army.canJoin, army);

alert(soldiers.length); // 2
alert(soldiers[0].age); // 20
alert(soldiers[1].age); // 23

如果在上面的示例中我们使用了 users.filter(army.canJoin),那么 army.canJoin 将被作为独立函数调用,并且这时 this=undefined,从而会导致即时错误。

可以用 users.filter(user => army.canJoin(user)) 替换对 users.filter(army.canJoin, army) 的调用。前者的使用频率更高,因为对于大多数人来说,它更容易理解。

总结

数组方法备忘单:

  • 添加/删除元素:

    • push(...items) —— 向尾端添加元素,
    • pop() —— 从尾端提取一个元素,
    • shift() —— 从首端提取一个元素,
    • unshift(...items) —— 向首端添加元素,
    • splice(pos, deleteCount, ...items) —— 从 pos 开始删除 deleteCount 个元素,并插入 items
    • slice(start, end) —— 创建一个新数组,将从索引 start 到索引 end(但不包括 end)的元素复制进去。
    • concat(...items) —— 返回一个新数组:复制当前数组的所有元素,并向其中添加 items。如果 items 中的任意一项是一个数组,那么就取其元素。
  • 搜索元素:

    • indexOf/lastIndexOf(item, pos) —— 从索引 pos 开始搜索 item,搜索到则返回该项的索引,否则返回 -1
    • includes(value) —— 如果数组有 value,则返回 true,否则返回 false
    • find/filter(func) —— 通过 func 过滤元素,返回使 func 返回 true 的第一个值/所有值。
    • findIndexfind 类似,但返回索引而不是值。
  • 遍历元素:

    • forEach(func) —— 对每个元素都调用 func,不返回任何内容。
  • 转换数组:

    • map(func) —— 根据对每个元素调用 func 的结果创建一个新数组。
    • sort(func) —— 对数组进行原位(in-place)排序,然后返回它。
    • reverse() —— 原位(in-place)反转数组,然后返回它。
    • split/join —— 将字符串转换为数组并返回。
    • reduce/reduceRight(func, initial) —— 通过对每个元素调用 func 计算数组上的单个值,并在调用之间传递中间结果。
  • 其他:

    • Array.isArray(value) 检查 value 是否是一个数组,如果是则返回 true,否则返回 false

请注意,sortreversesplice 方法修改的是数组本身。

这些是最常用的方法,它们覆盖 99% 的用例。但是还有其他几个:

  • arr.some(fn)/arr.every(fn) 检查数组。

    map 类似,对数组的每个元素调用函数 fn。如果任何/所有结果为 true,则返回 true,否则返回 false

    这两个方法的行为类似于 ||&& 运算符:如果 fn 返回一个真值,arr.some() 立即返回 true 并停止迭代其余数组项;如果 fn 返回一个假值,arr.every() 立即返回 false 并停止对其余数组项的迭代。

    我们可以使用 every 来比较数组:

    function arraysEqual(arr1, arr2) {
      return arr1.length === arr2.length && arr1.every((value, index) => value === arr2[index]);
    }
    
    alert( arraysEqual([1, 2], [1, 2])); // true
  • arr.fill(value, start, end) —— 从索引 startend,用重复的 value 填充数组。

  • arr.copyWithin(target, start, end) —— 将从位置 startend 的所有元素复制到 自身target 位置(覆盖现有元素)。

  • arr.flat(depth)/arr.flatMap(fn) 从多维数组创建一个新的扁平数组。

  • Array.of(element0[, element1[, …[, elementN]]]) 基于可变数量的参数创建一个新的 Array 实例,而不需要考虑参数的数量或类型。

有关完整列表,请参阅 手册

乍看起来,似乎有很多方法,很难记住。但实际上这比看起来要容易得多。

浏览这个备忘单,以了解这些方法。然后解决本章中的习题来进行练习,以便让你有数组方法的使用经验。

然后,每当你需要对数组进行某些操作,而又不知道怎么做的时候,请回到这儿,查看这个备忘单,然后找到正确的方法。示例将帮助你正确编写它。用不了多久,你就自然而然地记住这些方法了,根本不需要你死记硬背。

任务

重要程度: 5

编写函数 camelize(str) 将诸如 “my-short-string” 之类的由短划线分隔的单词变成骆驼式的 “myShortString”。

即:删除所有短横线,并将短横线后的每一个单词的首字母变为大写。

示例:

camelize("background-color") == 'backgroundColor';
camelize("list-style-image") == 'listStyleImage';
camelize("-webkit-transition") == 'WebkitTransition';

提示:使用 split 将字符串拆分成数组,对其进行转换之后再 join 回来。

打开带有测试的沙箱。

function camelize(str) {
  return str
    .split('-') // splits 'my-long-word' into array ['my', 'long', 'word']
    .map(
      // capitalizes first letters of all array items except the first one
      // converts ['my', 'long', 'word'] into ['my', 'Long', 'Word']
      (word, index) => index == 0 ? word : word[0].toUpperCase() + word.slice(1)
    )
    .join(''); // joins ['my', 'Long', 'Word'] into 'myLongWord'
}

使用沙箱的测试功能打开解决方案。

重要程度: 4

写一个函数 filterRange(arr, a, b),该函数获取一个数组 arr,在其中查找数值大于或等于 a,且小于或等于 b 的元素,并将结果以数组的形式返回。

该函数不应该修改原数组。它应该返回新的数组。

例如:

let arr = [5, 3, 8, 1];

let filtered = filterRange(arr, 1, 4);

alert( filtered ); // 3,1(匹配值)

alert( arr ); // 5,3,8,1(未修改)

打开带有测试的沙箱。

function filterRange(arr, a, b) {
  // 在表达式周围添加了括号,以提高可读性
  return arr.filter(item => (a <= item && item <= b));
}

let arr = [5, 3, 8, 1];

let filtered = filterRange(arr, 1, 4);

alert( filtered ); // 3,1(匹配的值)

alert( arr ); // 5,3,8,1(未经改动的数组中的值)

使用沙箱的测试功能打开解决方案。

重要程度: 4

写一个函数 filterRangeInPlace(arr, a, b),该函数获取一个数组 arr,并删除其中介于 ab 区间以外的所有值。检查:a ≤ arr[i] ≤ b

该函数应该只修改数组。它不应该返回任何东西。

例如:

let arr = [5, 3, 8, 1];

filterRangeInPlace(arr, 1, 4); // 删除了范围在 1 到 4 之外的所有值

alert( arr ); // [3, 1]

打开带有测试的沙箱。

function filterRangeInPlace(arr, a, b) {

  for (let i = 0; i < arr.length; i++) {
    let val = arr[i];

    // 如果超出范围,则删除
    if (val < a || val > b) {
      arr.splice(i, 1);
      i--;
    }
  }

}

let arr = [5, 3, 8, 1];

filterRangeInPlace(arr, 1, 4); // 删除 1 到 4 范围之外的值

alert( arr ); // [3, 1]

使用沙箱的测试功能打开解决方案。

重要程度: 4
let arr = [5, 2, 1, -10, 8];

// ……你的代码以降序对其进行排序

alert( arr ); // 8, 5, 2, 1, -10
let arr = [5, 2, 1, -10, 8];

arr.sort((a, b) => b - a);

alert( arr );
重要程度: 5

我们有一个字符串数组 arr。我们希望有一个排序过的副本,但保持 arr 不变。

创建一个函数 copySorted(arr) 返回这样一个副本。

let arr = ["HTML", "JavaScript", "CSS"];

let sorted = copySorted(arr);

alert( sorted ); // CSS, HTML, JavaScript
alert( arr ); // HTML, JavaScript, CSS (no changes)

我们可以使用 slice() 来创建一个副本并对其进行排序:

function copySorted(arr) {
  return arr.slice().sort();
}

let arr = ["HTML", "JavaScript", "CSS"];

let sorted = copySorted(arr);

alert( sorted );
alert( arr );
重要程度: 5

创建一个构造函数 Calculator,以创建“可扩展”的 calculator 对象。

该任务由两部分组成。

  1. 首先,实现 calculate(str) 方法,该方法接受像 "1 + 2" 这样格式为“数字 运算符 数字”(以空格分隔)的字符串,并返回结果。该方法需要能够理解加号 + 和减号 -

    用法示例:

    let calc = new Calculator;
    
    alert( calc.calculate("3 + 7") ); // 10
  2. 然后添加方法 addMethod(name, func),该方法教 calculator 进行新操作。它需要运算符 name 和实现它的双参数函数 func(a,b)

    例如,我们添加乘法 *,除法 / 和求幂 **

    let powerCalc = new Calculator;
    powerCalc.addMethod("*", (a, b) => a * b);
    powerCalc.addMethod("/", (a, b) => a / b);
    powerCalc.addMethod("**", (a, b) => a ** b);
    
    let result = powerCalc.calculate("2 ** 3");
    alert( result ); // 8
  • 此任务中没有括号或复杂的表达式。
  • 数字和运算符之间只有一个空格。
  • 你可以自行选择是否添加错误处理功能。

打开带有测试的沙箱。

  • 请注意方法的存储方式。它们只是被添加到 this.methods 属性中。
  • 所有检测和数字转换都通过 calculate 方法完成。将来可能会扩展它以支持更复杂的表达式。
function Calculator() {

  this.methods = {
    "-": (a, b) => a - b,
    "+": (a, b) => a + b
  };

  this.calculate = function(str) {

    let split = str.split(' '),
      a = +split[0],
      op = split[1],
      b = +split[2];

    if (!this.methods[op] || isNaN(a) || isNaN(b)) {
      return NaN;
    }

    return this.methods[op](a, b);
  };

  this.addMethod = function(name, func) {
    this.methods[name] = func;
  };
}

使用沙箱的测试功能打开解决方案。

重要程度: 5

你有一个 user 对象数组,每个对象都有 user.name。编写将其转换为 names 数组的代码。

例如:

let john = { name: "John", age: 25 };
let pete = { name: "Pete", age: 30 };
let mary = { name: "Mary", age: 28 };

let users = [ john, pete, mary ];

let names = /* ... your code */

alert( names ); // John, Pete, Mary
let john = { name: "John", age: 25 };
let pete = { name: "Pete", age: 30 };
let mary = { name: "Mary", age: 28 };

let users = [ john, pete, mary ];

let names = users.map(item => item.name);

alert( names ); // John, Pete, Mary
重要程度: 5

你有一个 user 对象数组,每个对象都有 namesurnameid

编写代码以该数组为基础,创建另一个具有 idfullName 的对象数组,其中 fullNamenamesurname 生成。

例如:

let john = { name: "John", surname: "Smith", id: 1 };
let pete = { name: "Pete", surname: "Hunt", id: 2 };
let mary = { name: "Mary", surname: "Key", id: 3 };

let users = [ john, pete, mary ];

let usersMapped = /* ... your code ... */

/*
usersMapped = [
  { fullName: "John Smith", id: 1 },
  { fullName: "Pete Hunt", id: 2 },
  { fullName: "Mary Key", id: 3 }
]
*/

alert( usersMapped[0].id ) // 1
alert( usersMapped[0].fullName ) // John Smith

所以,实际上你需要将一个对象数组映射到另一个对象数组。在这儿尝试使用箭头函数 => 来编写。

let john = { name: "John", surname: "Smith", id: 1 };
let pete = { name: "Pete", surname: "Hunt", id: 2 };
let mary = { name: "Mary", surname: "Key", id: 3 };

let users = [ john, pete, mary ];

let usersMapped = users.map(user => ({
  fullName: `${user.name} ${user.surname}`,
  id: user.id
}));

/*
usersMapped = [
  { fullName: "John Smith", id: 1 },
  { fullName: "Pete Hunt", id: 2 },
  { fullName: "Mary Key", id: 3 }
]
*/

alert( usersMapped[0].id ); // 1
alert( usersMapped[0].fullName ); // John Smith

请注意,在箭头函数中,我们需要使用额外的括号。

我们不能这样写:

let usersMapped = users.map(user => {
  fullName: `${user.name} ${user.surname}`,
  id: user.id
});

我们记得,有两种箭头函数的写法:直接返回值 value => expr 和带主体的 value => {...}

JavaScript 在这里会把 { 视为函数体的开始,而不是对象的开始。解决方法是将它们包装在普通括号 () 中:

let usersMapped = users.map(user => ({
  fullName: `${user.name} ${user.surname}`,
  id: user.id
}));

这样就可以了。

重要程度: 5

编写函数 sortByAge(users) 获得对象数组的 age 属性,并根据 age 对这些对象数组进行排序。

例如:

let john = { name: "John", age: 25 };
let pete = { name: "Pete", age: 30 };
let mary = { name: "Mary", age: 28 };

let arr = [ pete, john, mary ];

sortByAge(arr);

// now: [john, mary, pete]
alert(arr[0].name); // John
alert(arr[1].name); // Mary
alert(arr[2].name); // Pete
function sortByAge(arr) {
  arr.sort((a, b) => a.age - b.age);
}

let john = { name: "John", age: 25 };
let pete = { name: "Pete", age: 30 };
let mary = { name: "Mary", age: 28 };

let arr = [ pete, john, mary ];

sortByAge(arr);

// 排序后的数组为:[john, mary, pete]
alert(arr[0].name); // John
alert(arr[1].name); // Mary
alert(arr[2].name); // Pete

译注:解决方案的代码还可以更短一些

function sortByAge(arr) {
  arr.sort((a, b) => a.age - b.age);
}

因为 sort() 方法的语法为 arr.sort([compareFunction]),如果没有指明 compareFunction,那么元素会被按照转换为的字符串的诸个字符的 Unicode 编码进行排序,如果指明了 compareFunction,那么数组会按照调用该函数的返回值排序。即 ab 是两个将要被比较的元素:

  • 如果 compareFunction(a, b) 小于 0,那么 a 会被排列到 b 之前;
  • 如果 compareFunction(a, b) 等于 0,那么 ab 的相对位置不变。备注:ECMAScript 标准并不保证这一行为,而且也不是所有浏览器都会遵守(例如 Mozilla 在 2003 年之前的版本);
  • 如果 compareFunction(a, b) 大于 0,那么 b 会被排列到 a 之前。

因此,升序排列的函数可以简写为:(a, b) => a.age - b.age

重要程度: 3

编写函数 shuffle(array) 来随机排列数组的元素。

多次运行 shuffle 可能导致元素顺序的不同。例如:

let arr = [1, 2, 3];

shuffle(arr);
// arr = [3, 2, 1]

shuffle(arr);
// arr = [2, 1, 3]

shuffle(arr);
// arr = [3, 1, 2]
// ...

所有元素顺序应该具有相等的概率。例如,可以将 [1,2,3] 重新排序为 [1,2,3][1,3,2][3,1,2] 等,每种情况的概率相等。

简单的解决方案可以是:

function shuffle(array) {
  array.sort(() => Math.random() - 0.5);
}

let arr = [1, 2, 3];
shuffle(arr);
alert(arr);

这样是可以的,因为 Math.random() - 0.5 是一个可能是正数或负数的随机数,因此排序函数会随机地对数组中的元素进行重新排序。

但是,由于排序函数并非旨在以这种方式使用,因此并非所有的排列都具有相同的概率。

例如,请考虑下面的代码。它运行 100 万次 shuffle 并计算所有可能结果的出现次数:

function shuffle(array) {
  array.sort(() => Math.random() - 0.5);
}

// 所有可能排列的出现次数
let count = {
  '123': 0,
  '132': 0,
  '213': 0,
  '231': 0,
  '321': 0,
  '312': 0
};

for (let i = 0; i < 1000000; i++) {
  let array = [1, 2, 3];
  shuffle(array);
  count[array.join('')]++;
}

// 显示所有可能排列的出现次数
for (let key in count) {
  alert(`${key}: ${count[key]}`);
}

示例结果(取决于 Javascript 引擎):

123: 250706
132: 124425
213: 249618
231: 124880
312: 125148
321: 125223

我们可以清楚地看到这种倾斜:123213 的出现频率比其他情况高得多。

使用不同的 JavaScript 引擎运行这个示例代码得到的结果可能会有所不同,但是我们已经可以看到这种方法是不可靠的。

为什么它不起作用?一般来说,sort 是一个“黑匣子”:我们将一个数组和一个比较函数放入其中,并期望其对数组进行排序。但是由于比较的完全随机性,这个黑匣子疯了,它发疯地确切程度取决于引擎中的具体实现方法。

还有其他很好的方法可以完成这项任务。例如,有一个很棒的算法叫作 Fisher-Yates shuffle。其思路是:逆向遍历数组,并将每个元素与其前面的随机的一个元素互换位置:

function shuffle(array) {
  for (let i = array.length - 1; i > 0; i--) {
    let j = Math.floor(Math.random() * (i + 1)); // 从 0 到 i 的随机索引

    // 交换元素 array[i] 和 array[j]
    // 我们使用“解构分配(destructuring assignment)”语法来实现它
    // 你将在后面的章节中找到有关该语法的更多详细信息
    // 可以写成:
    // let t = array[i]; array[i] = array[j]; array[j] = t
    [array[i], array[j]] = [array[j], array[i]];
  }
}

让我们以相同的方式测试一下:

function shuffle(array) {
  for (let i = array.length - 1; i > 0; i--) {
    let j = Math.floor(Math.random() * (i + 1));
    [array[i], array[j]] = [array[j], array[i]];
  }
}

// 所有可能排列的出现次数
let count = {
  '123': 0,
  '132': 0,
  '213': 0,
  '231': 0,
  '321': 0,
  '312': 0
};

for (let i = 0; i < 1000000; i++) {
  let array = [1, 2, 3];
  shuffle(array);
  count[array.join('')]++;
}

// 显示所有可能排列的出现次数
for (let key in count) {
  alert(`${key}: ${count[key]}`);
}

示例输出:

123: 166693
132: 166647
213: 166628
231: 167517
312: 166199
321: 166316

现在看起来不错:所有排列都以相同的概率出现。

另外,在性能方面,Fisher — Yates 算法要好得多,没有“排序”开销。

重要程度: 4

编写 getAverageAge(users) 函数,该函数获取一个具有 age 属性的对象数组,并返回平均年龄。

平均值的计算公式是 (age1 + age2 + ... + ageN) / N

例如:

let john = { name: "John", age: 25 };
let pete = { name: "Pete", age: 30 };
let mary = { name: "Mary", age: 29 };

let arr = [ john, pete, mary ];

alert( getAverageAge(arr) ); // (25 + 30 + 29) / 3 = 28
function getAverageAge(users) {
  return users.reduce((prev, user) => prev + user.age, 0) / users.length;
}

let john = { name: "John", age: 25 };
let pete = { name: "Pete", age: 30 };
let mary = { name: "Mary", age: 29 };

let arr = [ john, pete, mary ];

alert( getAverageAge(arr) ); // 28
重要程度: 4

arr 是一个数组。

创建一个函数 unique(arr),返回去除重复元素后的数组 arr

例如:

function unique(arr) {
  /* your code */
}

let strings = ["Hare", "Krishna", "Hare", "Krishna",
  "Krishna", "Krishna", "Hare", "Hare", ":-O"
];

alert( unique(strings) ); // Hare, Krishna, :-O

打开带有测试的沙箱。

让我们先遍历数字:

  • 对于每个元素,我们将检查结果数组是否已经有该元素。
  • 如果有,则忽略,否则将其添加到结果中。
function unique(arr) {
  let result = [];

  for (let str of arr) {
    if (!result.includes(str)) {
      result.push(str);
    }
  }

  return result;
}

let strings = ["Hare", "Krishna", "Hare", "Krishna",
  "Krishna", "Krishna", "Hare", "Hare", ":-O"
];

alert( unique(strings) ); // Hare, Krishna, :-O

代码有效,但其中存在潜在的性能问题。

方法 result.includes(str) 在内部遍历数组 result,并将每个元素与 str 进行比较以找到匹配项。

所以如果 result 中有 100 个元素,并且没有任何一项与 str 匹配,那么它将遍历整个 result 并进行 100 次比较。如果 result 很大,比如 10000,那么就会有 10000 次的比较。

这本身并不是问题,因为 JavaScript 引擎速度非常快,所以遍历一个有 10000 个元素的数组只需要几微秒。

但是我们在 for循环中对 arr 的每个元素都进行了一次检测。

因此,如果 arr.length10000,我们会有 10000 * 10000 = 1 亿次的比较。那真的太多了。

所以该解决方案仅适用于小型数组。

进一步,在后面的 Map and Set(映射和集合) 一章中,我们将看到如何对该方法进行优化。

使用沙箱的测试功能打开解决方案。

重要程度: 4

假设我们收到了一个用户数组,形式为:{id:..., name:..., age:... }

创建一个函数 groupById(arr) 从该数组创建对象,以 id 为键(key),数组项为值。

例如:

let users = [
  {id: 'john', name: "John Smith", age: 20},
  {id: 'ann', name: "Ann Smith", age: 24},
  {id: 'pete', name: "Pete Peterson", age: 31},
];

let usersById = groupById(users);

/*
// 调用函数后,我们应该得到:

usersById = {
  john: {id: 'john', name: "John Smith", age: 20},
  ann: {id: 'ann', name: "Ann Smith", age: 24},
  pete: {id: 'pete', name: "Pete Peterson", age: 31},
}
*/

处理服务端数据时,这个函数很有用。

在这个任务里我们假设 id 是唯一的。没有两个具有相同 id 的数组项。

请在解决方案中使用数组的 .reduce 方法。

打开带有测试的沙箱。

function groupById(array) {
  return array.reduce((obj, value) => {
    obj[value.id] = value;
    return obj;
  }, {})
}

使用沙箱的测试功能打开解决方案。

教程路线图