23
头图

程序中的常用数据集合无非两类,列表 (List) 和映射 (Map)。在 JavaScript 的语言基础中就提供了这两种集合结构的支持 —— 用数组 (Array) 表示列表,用直接对象 (Plain Object) 表示映射(属性键值对映射)。

今天我们只说数组。

Array 类中提供的实例方法可以看出来,数组涵盖了一般的列表操作,增删改查俱全,更提供了 shift()/unshift()push()/pop() 这样的方法,使数组具有队列和栈的基本功能。

除了日常的 CRUD 之外,最重要的就是对列表进行完全或部分遍历,拿到预期的结果,这些遍历操作包括

  1. 逐一遍历:forforEach()map() 等;
  2. 筛选/过滤:filter()find()findIndex()indexOf() 等;
  3. 遍历计算(归约):reduce()some()every()includes() 等。

Array 对象提供来用于遍历的实例方法,大多数都是接收一个处理函数在遍历过程中对每个元素进行处理。而且处理函数通常会具有三个参数:(el, index, array),分别表示当前处理的元素、当前元素的索引以及当前处理的数组(即原数组)。当然,这里说的是大多数,也有一些例外,比如 includes() 就不是这样,而 reduce 的处理函数会多一个表示中间结果的参数。具体情况不用多说,查阅 MDN 即可。

一、简单遍历

大家都知道 for 语法在 JavaScript 中除了基本的 for ( ; ; ) 之外,还包含了两种 for each 遍历。一种是 for ... in 用来遍历键/索引;另一种是 for ... of 用来遍历值/元素。两种 for each 结构都不能同时拿到键/索引和值/元素,而 forEach() 方法可以拿到,这是 forEach() 的便利所在。不过在 for each 结构中要终止循环,可以使用 break,而在 forEach() 中要想终止循环只能通过 throw。使用 throw 来终止循环需要在外面进行 try ... catch 处理,不够灵活。举例:

try {
    list.forEach(n => {
        console.log(n);
        if (n >= 3) { throw undefined; }
    });
} catch {
    console.log("The loop is broken");
}

如果没有 try ... catch,里面的 throw 会直接中断程序运行。

当然,其实也有更简单的方法。注意到 some()every() 这两个方法都是对数组进行遍历,直到遇到符合条件/不符合条件的元素。简单地说它们是根据处理函数的返回值来判断是否中断遍历。对于 some() 来说,是要找到一个符合条件的元素,处理函数如果返回 true,就中断遍历;而 every() 正好相反,它是要判断每个元素都符合条件,所以只要遇到返回 false 就会中断遍历。

根据我们对一般 for 循环和 while 循环的理解,都是条件为真是进行循环,所以看起来 every() 更符合习惯。上面的示例用 every() 改写:

list.every(n => {
    console.log(n);
    return n < 3;
});

使用 some()every() 特别需要注意一点:它不需要精确返回 boolean 类型的值,只需要判断真值 (truthy) 和 假值(falsy) 即可。 JavaScript 函数在没有显式返回值的情况下等同于 return undefined,也就是返回假值,效果和 return false 等同。

关于 JavaScript 的假值,可以查阅 MDN - Falsy。除了假值,都是真值。

二、遍历映射

有时候我们需要对一个数组进行遍历,根据其每个元素提供的信息,产生另一个数值和对象,而结果仍然放在一个数组中。前端开发中这种操作最常见的场景就是将从后端拿到的模型数据列表,处理成前端呈现需要的视图数据列表。常规操作是这样:

// 源数据
const source = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

// 创建目标数组容器
const target = [];
// 循环处理每一个源数据元素,并将结果添加到目标数组中
for (const n of source) {
    target.push({ id: n, label: `label${n}` });
}

// 消费目标数组
console.log(target);

map() 就是用来封装这样的遍历的,它可以用来处理一对一的元素数据映射。上例改用 map() 只需要一句话代替循环:

const source = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const target = source.map(n => ({ id: n, label: `label${n}` }));
console.log(target);

除了减少语句之外,使用 map() 还把原来的若干语句,变成了一个表达式,可以灵活地用于上下逻辑衔接。

三、处理多层结构 - 展开 (flat 和 flatMap)

展开,即 flat() 操作可以把多维度的数组减少 1 个或多个维度。举例来说

const source = [1, 2, [3, 4], [5, [6, 7], 8, 9], 10];
console.log(source.flat());
// [ 1, 2, 3, 4, 5, [ 6, 7 ], 8, 9, 10 ]

这个例子是个包含了三个维度(虽然不整齐)的数组,使用 flat() 减少了一个维度,其结果变成了两个维度。flat() 可以通过参数指定展开的维度层数,这里只需要指定一个大于等于 2 的值,它就能把所有元素全部展平到一个一维数组中:

const source = [1, 2, [3, 4], [5, [6, 7], 8, 9], 10];
console.log(source.flat(10));
// [ 1, 2, 3, 4,  5,  6, 7, 8, 9, 10 ]

有了这个东西,我们在处理一些子项的时候就会比较方便。比如一个常见问题:

有一个二层的菜单数据,我想拿到所有菜单项列表,应该怎么办?数据如下

const data = [
 {
     label: "文件",
     items: [
         { label: "打开", id: 11 },
         { label: "保存", id: 12 },
         { label: "关闭", id: 13 }
     ]
 },
 {
     label: "帮助",
     items: [
         { label: "查看帮助", id: 91 },
         { label: "关于", id: 92 }
     ]
 }
];

怎么办?毫无悬念应该是使用一个双层循环来处理。不过利用 map()flat() 可以简化代码:

const allItems = data.map(({ items }) => items).flat();
//                   ^^^^                      ^^^^^

第一步 map(){ label, items } 类型的元素映射成为 [...items] 这种形式的数组,映射结果是一个二维数组(示意):

[
    [...文件菜单项],
    [...帮助菜单项]
]

再用 flat() 展平,就得到了 [...文件菜单项, ...帮助菜单项],也就是预期的结果。

通常我们直接拿到二维数组来处理的情况极少,一般都需要先 map()flat(),所以 JavaScript 为这两个常用组合逻辑提供了 flatMap() 方法。要理解 flatMap() 的作用,就理解为先 map(...)flat() 即可。上面的示例可改为

const allItems = data.flatMap(({ items }) => items);
//                   ^^^^^^^^

这里解决了一个两层结构的数据,如果是多层结构呢?多层结构不就是普通的树形结构,使用递归对所有子项进行 flatMap() 处理即可。代码先不提供,请读者动动脑。

四、过滤

如果我们有一组数据,需要把其中符合某种条件的筛选出来使用,就会用到过滤,filter()filter() 接收一个用于判断的处理函数,并对每个元素使用该处理函数进行判断。如果该函数对某个元素的判断结果是真值,该元素会被保留;否则不会收录到结果中。filter() 的结果是原数组的子集。

filter() 的用法很好理解,比如下面这个示例筛选出能被 3 整除的数:

const a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const r = a.filter(n => n % 3 === 0);
// [ 3, 6, 9 ]

有两点需要强调:

第一,如果所有元素都不符合条件,会得到一个空数组。既不是 null 也不是 undefined,而是 []

第二,如果所有元素都符合条件,会得到一个包含所有元素的数组。它与原数组进行 ===== 比较均会得到 false

过滤虽然简单,但是要注意灵活运用。比如说需要统计某组数据中符合条件的个数,一般会想到遍历计数。但我们也可以先按指定条件过滤,再取结果数组的 length

五、查找

查找和过滤的区别在于:查找是找到一个符合条件的元素即可,而过滤是找到全部。从实现效果上来说,arr.filter(fn)[0] 就可以达到查找效果。只不过 filter() 一定会遍历完整个数组。

而专业的 find() 则会在找到第一个符合条件的元素之后立即终止遍历,节约时间和计算资源。从结果上来说,find() 可以看作是 filter()[0] 的便捷实现(当然性能也更好),其参数(处理函数)和 filter() 相同。

find() 的结果是找到的元素,或者在什么都没找到的情况下返回 undefined。所以在使用 find() 的时候一定要注意其结果有可能是 undefined,使用前应该进行有效性判断。当然,如果结合可选链运算符 (?.)空值合并运算符 (??),也很容易参与表达式。

不过有时候,我们查找一个元素并不是想使用它,而是想替换或者删除它。这时候拿到元素本身是很难办的,我们更需要索引号。查 MDN 很容易就能查到 findIndex() 方法。它的用法和 find() 相同,只是返回的是元素索引而不是元素本身。如果没有找到符合条件的元素,findIndex() 会返回 -1

说到 findIndex() 就很容易联想到 indexOf()indexOf() 的参数是一个值(或对象),它会在数组中去寻找这个值的位置并返回出来。对于基本类型的值来说,很好用。但是对于对象元素,就要小心了,看看下面这个例子

const m = { v: 1 };
const a = [m, { v: 2 }, { v: 3 }];

console.log(a.indexOf(m));          // 0
console.log(a.indexOf({ v: 1 }));   // -1

同样表示为 { v: 1 },但他们真的不是同一个对象!

顺便提一提,有人喜欢用 arr.indexOf(v) >= 0 来判断数组中是否包含某个元素,其实不妨使用专业的 includes() 方法。includes() 直接返回一个布尔值,而且它允许通过第 2 个参数指定开始查找的位置。详见 MDN

那么,如果想根据某个判断方法(函数)来判断数据中是否存在某个符合条件的元素,是不是要用 arr.find(fn) !== undefined 来判断呢?一般情况下是可以,但如果遇到特殊情况 ——

// 查找判断是否包含假值
const a = [undefined, 1, 2, 3];
const hasFalsy = a.find(it => !it) !== undefined;  // false

很不幸,这个结果是错的,肉眼可见,它确实包含假值!

按条件查找是否存在的正确做法是使用 some() 方法(之前提到过,忘了没?):

const hasFalsy = a.some(it => !it);

六、归约

归约是对 reduce 的直译,而 reduce() 也是数组的一个方法。

之所以需要归约,因为有时候我们需要进行的处理并不会像前面提到的那么简单,比如一个常见的应用 —— 累加。想想看,使之前的处理方式,只能通过 for 或者 forEach() 循环累加。这两种方式都需要额外的临时变量,对函数式编程不太友好。如果用 reduce(),大概是这样:

const a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const sum = a.reduce((sum, n) => sum + n);

再复杂一点,如果想把数组中的奇偶数分离出来,分别放在两个数组中:

const a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const [even, odd] = a.reduce(
    (acc, n) => {
        acc[n % 2].push(n);
        return acc;
    },
    [[], []]
);

和上面一段代码不同,这里使用 reduce() 时给了第二 个参数 [[], []]。这是一个初始值,会在第一次调用处理函数的时候作为第 1 个参数传入函数,也就是上例中的 acc 参数。

那么有心的读者会提出疑问,为什么第一个示例不需要初始值,那种情况下初始的 sum 参数是什么东西?

这里不得不说 reduce() 的两个特点:

  1. reduce() 每一次的处理结果,也就是处理函数的返回值,会作为下一次处理的第一个参数;
  2. 调用 reduce() 时如果给了第二个参数,即初始值,它会用作第一次处理时的第一个参数;但如果没给初始值,在第一次处理时,数组中的前两个元素会分别作为传入处理函数的前两个参数。

看到第 2 条,是不是又产生了一个疑问:如果数组里只有一个元素数呢?—— 那么处理函数会被忽略,这个元素就是 reduce() 的结果。

那么……数组是空的会怎样?这个问题能把 reduce() 问哭 —— 我报错还不行吗?!

学会 reduce() 会发现前面提到的所有遍历过程都可以用 reduce() 来实现,毕竟它应用起来很灵活 —— 但是何必呢?何况,reduce() 也只能通过 throw 来中断 —— 当然不用担心 throw 中断拿不到结果,把结果作为 throw 的对象抛出来,外面不就拿到了吗?hiahiahia~~

七、截取

从数据中截取一部分,毫无疑问,当然是用 slice() 方法。该方法两个参数表示要截取的索引起点和终点,其中终点索引对应的元素不包含在内,用数学语言来说,这是一个左闭右开区间。需要注意的是起点必须小于终点才有可能取到元素,否则结果一定是一个空数组。这表示,如果使用 arr.slice(-Infinity, Infinity) 可以取到所有有元素 —— 但谁会这么干呢,直接 arr.slice(0) 不香么(不给第二个参数表示一直截取到最后一个元素)?

另外,slice() 还有两个有意思的特点:

  • 不管是起点还是终点,如果给出了超出数组索引范围的值,不会引起错误,它会取数组索引范围和指定范围相交的部分;
  • 如果给的索引是负数,比如 -n,它会按照 arr.length - n 来计算索引。这样一来,想根据结尾位置来获取元素就变得容易了。

示例就不写了,slice() 文档说得很明白,不难理解。

有人要问,很多集合的流式处理都会有 take()skip() 方法,用于截取指定位置一定数量的元素,JavaScript 有吗?—— slice() 可不就是?它和 .skip().take() 基本等价。说基本等价,是因为 take() 的参数表示的是一个长度,而 slice() 的第二个参数表示的是一个位置,所以(下面的 .skip().take() 是伪代码,仅示意):

  • arr.skip(m).take(n) 等价于 arr.slice(m, m + n)
  • arr.skip(m) 等价于 arr.slice(m)
  • arr.take(n) 等价于 arr.slice(0, n)

不过在使用 slice() 的同时,别忘了 JavaScript 的解构赋值语法也可以用于简单地截取数组。比如说

const a = [1, 2, 3, 4, 5, 6];
const [,, ...rest] = a;
// rest = [3, 4, 5, 6]

它和 a.slice(2) 的结果一样的,但是相比之外,slice() 语义更明确,怎么都比数逗号个数强吧。但是解构语法在某些情况下还是挺方便的,比如在 CLI 中拆分命令和参数的时候:

const args = "git config --list --global".split(/\s+/);
const [exec, cmd, ...params] = args;
// exec: "git"
// cmd: "config"
// params: ["--list", "--global"]

八、创建数组

虽然本文主要是讲基于遍历的数组操作,但既然都说到了 slice() 这个非遍历类的操作,不妨顺便再提一下创建数组。

  • 使用 [] 创建空数组,或已知少量元素的数组,最常用;
  • 可迭代对象使用展开运算符 (...) 生成数组,比如 [..."abc"] 能得到 ["a", "b", "c"]
  • Array(n) 创建指定长度的数组,但注意这个数组虽然有长度,却没有元素,也不能遍历。想让它有元素 —— 往后看 ——
  • Array.from(Array(n)),得到一个长度 n,所有元素都是 undefined 的数组;
  • [...Array(n)] 和上面一条结果一样;[...Array(n).values()] 也是一样;
  • Array(n).fill(1024),创建一个长度 n,元素均是 1024 的数组。当然也可以指定其他元素值;
  • Array.from 的第二个参数是个 mapper,所以 Array.from(Array(n), (_, i) => i) 可以创建一个元素是从 0n - 1 的数组;
  • [...Array(n).keys()] 可以创建和上一条一样的数组;
  • ……

现在有一个问题,想创建一个 7x4 的二维数组,默认元素填 0,该怎么办?那还不简单,这样

const matrix = Array(4).fill(Array(7).fill(0));
// [
//   [ 0, 0, 0, 0, 0, 0, 0 ],
//   [ 0, 0, 0, 0, 0, 0, 0 ],
//   [ 0, 0, 0, 0, 0, 0, 0 ],
//   [ 0, 0, 0, 0, 0, 0, 0 ]
// ]

似乎没毛病,来进行一个操作,看看效果如何?

matrix[0][4] = 4;
// [
//   [ 0, 0, 0, 0, 4, 0, 0 ],
//   [ 0, 0, 0, 0, 4, 0, 0 ],
//   [ 0, 0, 0, 0, 4, 0, 0 ],
//   [ 0, 0, 0, 0, 4, 0, 0 ]
// ]

所有第二层数组索引为 4 的元素值都变成了 4 …… Why?

我们把上面的初始化语句拆分一下,可能就明白了 ——

const row = Array(7).fill(0);
const matrix = Array(4).fill(row);

你看,这 4 行引用的都是一个数组,所以不管改变哪个,输出来 4 行数据都会完全一样(同一个数组能不一样吗)。

这是在初始化多维数组时最常见的坑。所以 Array(n).fill(v) 虽然好用,但一定要谨慎。这里如果使用带映射的 Array.from() 就没问题了:

const matrix = Array.from(
    Array(4),
    () => Array.from(Array(7), () => 0)
);

小结

由于 JavaScript 的动态特性,不需要定义一大堆的数据类型来表示不同的列表,就一个数组搞定。虽然还是有一定的局限性,但是已经能适应大部分应用场景了。本文主要介绍了数组的基本操作,更多更详细的内容可以参阅 MDN - Array 文档。下次我会再讲讲映射表(对象)的基本操作,以及数组和对象之间的联合应用。关于 JavaScript 的数据处理,读者们也可以去了解一下 Lodash,提供了非常多的工具。


边城
59.8k 声望29.6k 粉丝

一路从后端走来,终于走在了前端!