JavaScript 数据处理 – 列表篇

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

今天我们只说数组。 从 ​​Array​​​ 类中提供的实例方法可以看出来,数组涵盖了一般的列表操作,增删改查俱全,更提供了 ​​shift()/unshift()​​​ 和 ​​push()/pop()​​ 这样的方法,使数组具有队列和栈的基本功能。 除了日常的 CRUD 之外,最重要的就是对列表进行完全或部分遍历,拿到预期的结果,这些遍历操作包括

  1. 逐一遍历:​​for​​​、​​forEach()​​​、​​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)​​​ 可以创建一个元素是从 ​​0​​​ 到 ​​n - 1​​ 的数组;
  • 用 ​​[...Array(n).keys()]​​ 可以创建和上一条一样的数组;
  • ……

现在有一个问题,想创建一个 7×4 的二维数组,默认元素填 ​​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​​,提供了非常多的工具。

文章版权声明

 1 原创文章作者:0008,如若转载,请注明出处: https://www.52hwl.com/35956.html

 2 温馨提示:软件侵权请联系469472785#qq.com(三天内删除相关链接)资源失效请留言反馈

 3 下载提示:如遇蓝奏云无法访问,请修改lanzous(把s修改成x)

 免责声明:本站为个人博客,所有软件信息均来自网络 修改版软件,加群广告提示为修改者自留,非本站信息,注意鉴别

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2023年7月15日 下午2:39
下一篇 2023年7月15日 下午2:39