Skip to content

数组:顺序、转换、查找与完整方法地图 ​

数组保存有顺序的值。学数组不是背一排方法名,而是先回答:我要找一个、筛选一批、逐个转换,还是把整列合成一个结果?另一个始终要问的问题是:这个操作会不会改动原数组?

从位置和 length 开始 ​

js
const topics = ['变量', '函数', '对象'];
console.log(topics[0], topics.at(-1)); // 变量 对象
console.log(topics.length); // 3
console.log(topics[9]); // undefined

索引从 0 开始,最后一项索引是 length - 1。at(-1) 支持倒数,topics[-1] 却读取名字为 '-1' 的普通属性。越界读取返回 undefined,这与元素本来就存了 undefined 需要区分。

数组是带特殊 length 规则的对象。写入较大的索引会扩展长度;缩小 length 会删除高位索引,增大它会产生空槽。合法数组长度最多为 2 ** 32 - 1;实际内存限制远在达到该值之前就可能成为问题。

js
const values = [1, 2, 3];
values.length = 1;
console.log(values); // [1]
console.log(Array.isArray(values), Array.isArray({ length: 1 })); // true false
console.log(Array.of(3)); // [3]
console.log(Array(3).length, 0 in Array(3)); // 3 false

Array(3) 创建三个空槽,Array.of(3) 创建一项。跨 iframe 等不同 realm 时,instanceof Array 可能不适合判断,Array.isArray 是数组品牌检查。

增删改明确返回值 ​

js
const queue = ['变量', '函数'];
console.log(queue.push('对象')); // 3:新长度
console.log(queue.shift()); // 变量:移出的值
console.log(queue); // ['函数', '对象']
const removed = queue.splice(1, 1, '数组', '字符串');
console.log(removed, queue); // ['对象'] ['函数', '数组', '字符串']

push/pop 操作尾端,unshift/shift 操作首端。添加方法返回新长度,移除方法返回被移除值;空数组移除得到 undefined。splice(start, deleteCount, ...items) 原地删除和插入,返回被删除的数组。省略 deleteCount 会删到末尾,显式传 undefined 则按 0 处理,不是同一情况。

delete queue[1] 只删属性,会留下洞而不移动其他项。需要删掉一项并收拢,用 splice;希望保留输入,用 toSpliced。with(index, value) 按位置替换并返回新数组,支持负索引,越界抛 RangeError,不同于 at 的越界返回值。

js
const pages = [1, 2, 3];
console.log(pages.toSpliced(1, 1)); // [1, 3]
console.log(pages.with(-1, 9)); // [1, 2, 9]
console.log(pages); // [1, 2, 3]

map 与 filter 表达两类不同变化 ​

js
const minutes = [8, 15, 4];
const doubled = minutes.map(value => value * 2);
const longEnough = minutes.filter(value => value >= 8);
console.log(doubled);   // [16, 30, 8]
console.log(longEnough); // [8, 15]

map 为每个已有位置生成新值,通常保持长度;filter 保留通过条件的元素,可以改变长度。二者创建新数组,但不会自动复制每个对象元素。回调里修改对象属性,原数据仍可能被改动。

回调通常接收 (value, index, array)。这能解释一个常见错误:

js
console.log(['10', '10', '10'].map(parseInt)); // [10, NaN, 2]
console.log(['10', '10', '10'].map(text => parseInt(text, 10))); // [10, 10, 10]

map(parseInt) 把索引交给 parseInt 当进制:0 按默认规则,1 不是有效进制,2 按二进制。修复不是记忆特例,而是让回调签名与接收函数匹配。

先写一次筛选循环,看 filter 承担哪部分工作 ​

js
const minutes = [6, 14, 9];
const selected = [];
for (const value of minutes) {
  if (value >= 9) selected.push(value);
}
console.log(selected); // [14, 9]
console.log(minutes.filter(value => value >= 9)); // [14, 9]

循环把三项工作写在一起:按顺序读取、判断是否保留、将通过者加入新数组。filter接管第一项和第三项,把会变化的条件留给回调。于是阅读代码时可以先识别“筛选”,再看筛选条件,而不必每次重新审查一段通用循环。

这段循环只是理解稠密数组筛选的模型,不是完整filter实现。for...of会读到空槽产生的undefined,内建filter通常跳过缺失索引;内建还会在开始时保存长度,并定义回调参数和thisArg等规则。用小实现解释职责很有用,用它宣称完全等价则需要补齐这些分支。

map接管的是“每项产生一个新值”:

js
const notes = [{ minutes: 6 }, { minutes: 14 }];
const changed = notes.map(note => ({ ...note, minutes: note.minutes + 1 }));
console.log(changed[0].minutes, notes[0].minutes); // 7 6
console.log(changed[0] === notes[0]); // false

这里回调显式新建了元素对象,所以第一层元素也独立;若回调改成 note => { note.minutes += 1; return note; },仍然会产生新数组,但其元素就是被修改的原对象。判断输入是否被改,必须同时查看数组方法与回调内容。

查找一个、判断是否存在、检查全部 ​

js
const pages = [3, 8, 12, 8];
console.log(pages.find(n => n > 5)); // 8
console.log(pages.findIndex(n => n > 5)); // 1
console.log(pages.findLast(n => n > 5)); // 8
console.log(pages.findLastIndex(n => n > 5)); // 3
console.log(pages.some(n => n > 10)); // true
console.log(pages.every(n => n > 0)); // true

find 找值,findIndex 找位置,找不到分别返回 undefined 和 -1;末尾版本反向查找。some 找到一个通过就停止,every 找到一个不通过就停止。空数组 some 为假、every 为真:没有反例满足“所有元素都符合”的逻辑定义,但业务上可能还需要检查非空。

精确查值用 includes/indexOf/lastIndexOf。includes 使用 SameValueZero,可以找到 NaN;后两者用严格相等语义,找不到 NaN。如果数组本来可以保存 undefined,单用 find 返回值不能区分找到还是未找到,改用索引。

遍历为动作,reduce 为累计 ​

js
const chapters = ['值', '函数', '对象'];
chapters.forEach((title, index) => console.log(index, title));
const total = [8, 15, 4].reduce((sum, value) => sum + value, 0);
console.log(total); // 27
console.log(['a', 'b', 'c'].reduceRight((text, letter) => text + letter, '')); // cba

forEach 总返回 undefined,回调的 return 只结束这一轮回调,不会结束整个遍历;需要 break 用 for...of。reduce 把累计结果交给下一次回调,缺省初始值时会拿第一个已有元素当累计起点,因此空数组会抛 TypeError。明确写初始值,可以保持返回类型与空输入行为一致。

js
for (const [index, title] of ['值', '函数'].entries()) {
  console.log(index, title);
}
console.log([...['值', '函数'].keys()]);   // [0, 1]
console.log([...['值', '函数'].values()]); // ['值', '函数']

entries/keys/values 返回迭代器,for...of 和展开可以消费;默认 [Symbol.iterator]() 与值迭代一致。for...in 枚举属性,包括自定义或继承属性,不是按数组元素遍历的首选。

reduce 的返回值就是下一轮的累计值 ​

以 [8, 15, 4] 求和,明确提供初始值0时:

本轮输入sum当前value回调返回下一轮收到的sum
0888
8152323
23427无下一轮,27成为最终结果

这不是reduce在外面替你维护了一个神秘sum变量,而是回调的返回值被连续传递。忘记return会立刻破坏这个链条:

js
const seen = [];
const result = [8, 15].reduce((sum, value) => {
  seen.push(sum);
  sum + value;
}, 0);
console.log(seen, result); // [0, undefined] undefined

第一轮算过 0 + 8,却把结果丢弃;函数默认返回undefined,于是第二轮的sum变成undefined。它与“函数打印了正确值却没返回”的问题完全相同。

累计器也可以是对象:

js
const summary = [8, 15, 4].reduce((state, minutes) => {
  state.total += minutes;
  state.count += 1;
  return state;
}, { total: 0, count: 0 });
console.log(summary); // { total: 27, count: 3 }

这里修改的是专为这次累计创建的对象,不是输入数组。局部累计对象的变更与修改外部共享状态不能混为一谈。若每轮都用展开创建越来越大的数组或对象,可能增加大量复制成本;若规则用一个清楚的for循环更易表达,也无需强行reduce。

切片、连接、扁平化各处理一层边界 ​

js
const pages = [1, 2, 3, 4];
console.log(pages.slice(1, 3)); // [2, 3],不含结束位置
console.log(pages.slice(-2)); // [3, 4]
console.log([1].concat([2, 3], 4)); // [1, 2, 3, 4]
console.log([1, [2, [3]]].flat()); // [1, 2, [3]]
console.log(['ab', 'c'].flatMap(text => text.split(''))); // ['a', 'b', 'c']
console.log(pages.join('-')); // 1-2-3-4

slice 浅复制指定区间;concat 默认展开数组一层,也受 Symbol.isConcatSpreadable 协议影响。flat(depth = 1) 按深度展开嵌套数组,flatMap 等价于特定意义上的先映射再展开一层,不会把普通类数组对象也拆开。

join 把元素转文本后连接;toString 对普通数组通常类似逗号连接,toLocaleString 调用元素的本地化表示。它们不是序列化格式,嵌套和空值可能丢失结构信息。

排序需要可靠的比较器 ​

js
const values = [10, 2, 1];
console.log(values.toSorted()); // [1, 10, 2],默认按字符串
console.log(values.toSorted((a, b) => a - b)); // [1, 2, 10]
console.log(values.toReversed()); // [1, 2, 10]
console.log(values); // [10, 2, 1]

比较器返回负数表示 a 在前,正数表示 b 在前,0 表示排序关系相等。返回布尔值不能完整表达前后关系;随机比较器也不能实现可靠洗牌。比较应具有一致性、反对称性与传递性。

sort/reverse 修改原数组,toSorted/toReversed 返回新数组。现代规范要求排序稳定:比较结果为 0 的元素保持相对顺序。排序复杂度属于实现选择,不能把特定引擎某年的排序源码当成永远的语言保证。

fill 与 copyWithin 是原地工具 ​

js
const marks = [0, 0, 0, 0];
marks.fill(1, 1, 3);
console.log(marks); // [0, 1, 1, 0]
marks.copyWithin(2, 0, 2);
console.log(marks); // [0, 1, 0, 1]
const rows = Array(2).fill({ done: false });
rows[0].done = true;
console.log(rows[1].done); // true:共享同一个对象

fill 不会为每个位置重新创建对象;独立对象用 Array.from({ length: 2 }, () => ({ done: false }))。copyWithin(target, start, end) 在同一数组内部复制区间,长度不变,重叠区域按规范保证正确复制,不能简单按任意顺序逐项覆盖。

空槽不能笼统说“都会跳过” ​

js
const sparse = [1, , 3];
console.log(1 in sparse); // false
console.log(sparse.map(n => n * 2)); // [2, <空槽>, 6]
console.log(sparse.filter(() => true)); // [1, 3]
console.log([...sparse]); // [1, undefined, 3]
console.log(sparse.includes(undefined)); // true
console.log(sparse.indexOf(undefined)); // -1

对于普通、原型上无数字属性的数组:map/forEach/filter/reduce/some/every/indexOf 跳过缺失位置;map 保留洞,filter 收拢结果。find/findIndex/findLast/findLastIndex、值迭代和 includes 会把缺失位置读取为 undefined。slice 常保留洞,展开、Array.from、toSorted/toReversed/toSpliced/with 生成稠密数组。原型上如果有对应数字属性,部分“洞”会被继承值填入,说明底层检查的是属性存在与读取,不是神秘空槽标签。

这些方法通常在开始时记录遍历长度;遍历中追加的新尾项不会被多数回调方法访问,删除或修改未访问位置却可能影响后续结果。保持遍历输入稳定,比依赖这些副作用容易理解。

遍历长度快照,不等于元素值快照 ​

js
const values = [1, 2, 3];
const visited = [];
values.forEach((value, index) => {
  visited.push(value);
  if (index === 0) {
    values[1] = 20;
    values.push(4);
  }
});
console.log(visited); // [1, 20, 3]
console.log(values); // [1, 20, 3, 4]

开始时记录的长度为3,所以后来追加的索引3不在本次范围;索引1尚未访问,访问时读取到已经改成20的值。保存长度并不等于先复制所有元素。删除未访问的索引,还可能让跳过空槽的方法根本不调用那一项回调。

这也解释了为什么迭代中修改数组特别难读:读者必须同时追踪“开始时的范围”和“访问时的内容”。先计算结果再应用修改,或者明确写一个有状态的循环,通常更便于检查。不同方法对空槽及顺序的规则不同,不能从forEach实验推断所有迭代器完全一致。

类数组、异步与二进制的入口 ​

js
console.log(Array.from({ 0: '值', 1: '函数', length: 2 })); // ['值', '函数']
console.log(Array.from('😀')); // ['😀'],按码点消费字符串迭代器

类数组有索引属性和 length,可迭代对象有迭代协议,两者不同;Array.from 可以接收二者并选用相应路径。TypedArray 是数值二进制视图,没有普通数组空槽,也不是普通 Array,第六章继续讲。

Array.fromAsync 收集异步来源并顺序等待;forEach(async...) 不会等待回调完成。这里是后续阅读入口,先学完第五章 Promise 和第六章迭代协议再实践。较新方法先检查目标环境,例如 typeof Array.fromAsync === 'function',不要把“Node 22”理解成所有小版本能力完全相同。

完整方法地图 ​

任务API输入是否被修改
创建与识别Array、of、from、fromAsync、isArray创建新值
读取与协议at、keys、values、entries、Symbol.iterator否
原地调整push、pop、shift、unshift、splice、sort、reverse、fill、copyWithin是
查找与判定includes、indexOf、lastIndexOf、find、findIndex、findLast、findLastIndex、some、every方法本身不改;回调可以改
遍历与转换forEach、map、filter、reduce、reduceRight、flat、flatMap方法本身不改;回调可以改
复制与连接slice、concat、toSorted、toReversed、toSpliced、with、join、toString、toLocaleString否,副本通常浅

constructor、prototype、Symbol.species、Symbol.unscopables 属于构造或协议元数据,第四章和规范协议专题再读。数组按位置访问通常快;开头或中间增删通常涉及移动,线性搜索要扫描,反复嵌套 find 容易到平方级。复杂度是选方法的理由,不是每行都手写循环的命令。

练习 ​

  1. 从 [5, 12, 7] 保留至少 7 的值,再乘 2,确保原数组不变。

  2. 对 [1, , 3] 比较 map、filter 和展开的结果,解释“新数组”为什么不保证行为一致。

  3. 为什么 [].reduce((a, b) => a + b) 抛错?写出和为 0 的空输入契约。

  4. 为什么filter的手写for...of模型遇到空槽不完全等价?reduce回调忘记return后第二轮会得到什么?遍历时追加与修改已有位置为什么不同?

查看答案

用心记录每一步成长