数组:顺序、转换、查找与完整方法地图
数组保存有顺序的值。学数组不是背一排方法名,而是先回答:我要找一个、筛选一批、逐个转换,还是把整列合成一个结果?另一个始终要问的问题是:这个操作会不会改动原数组?
从位置和 length 开始
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;实际内存限制远在达到该值之前就可能成为问题。
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 falseArray(3) 创建三个空槽,Array.of(3) 创建一项。跨 iframe 等不同 realm 时,instanceof Array 可能不适合判断,Array.isArray 是数组品牌检查。
增删改明确返回值
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 的越界返回值。
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 表达两类不同变化
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)。这能解释一个常见错误:
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 承担哪部分工作
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接管的是“每项产生一个新值”:
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; },仍然会产生新数组,但其元素就是被修改的原对象。判断输入是否被改,必须同时查看数组方法与回调内容。
查找一个、判断是否存在、检查全部
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)); // truefind 找值,findIndex 找位置,找不到分别返回 undefined 和 -1;末尾版本反向查找。some 找到一个通过就停止,every 找到一个不通过就停止。空数组 some 为假、every 为真:没有反例满足“所有元素都符合”的逻辑定义,但业务上可能还需要检查非空。
精确查值用 includes/indexOf/lastIndexOf。includes 使用 SameValueZero,可以找到 NaN;后两者用严格相等语义,找不到 NaN。如果数组本来可以保存 undefined,单用 find 返回值不能区分找到还是未找到,改用索引。
遍历为动作,reduce 为累计
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, '')); // cbaforEach 总返回 undefined,回调的 return 只结束这一轮回调,不会结束整个遍历;需要 break 用 for...of。reduce 把累计结果交给下一次回调,缺省初始值时会拿第一个已有元素当累计起点,因此空数组会抛 TypeError。明确写初始值,可以保持返回类型与空输入行为一致。
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 |
|---|---|---|---|
| 0 | 8 | 8 | 8 |
| 8 | 15 | 23 | 23 |
| 23 | 4 | 27 | 无下一轮,27成为最终结果 |
这不是reduce在外面替你维护了一个神秘sum变量,而是回调的返回值被连续传递。忘记return会立刻破坏这个链条:
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。它与“函数打印了正确值却没返回”的问题完全相同。
累计器也可以是对象:
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。
切片、连接、扁平化各处理一层边界
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-4slice 浅复制指定区间;concat 默认展开数组一层,也受 Symbol.isConcatSpreadable 协议影响。flat(depth = 1) 按深度展开嵌套数组,flatMap 等价于特定意义上的先映射再展开一层,不会把普通类数组对象也拆开。
join 把元素转文本后连接;toString 对普通数组通常类似逗号连接,toLocaleString 调用元素的本地化表示。它们不是序列化格式,嵌套和空值可能丢失结构信息。
排序需要可靠的比较器
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 是原地工具
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) 在同一数组内部复制区间,长度不变,重叠区域按规范保证正确复制,不能简单按任意顺序逐项覆盖。
空槽不能笼统说“都会跳过”
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 生成稠密数组。原型上如果有对应数字属性,部分“洞”会被继承值填入,说明底层检查的是属性存在与读取,不是神秘空槽标签。
这些方法通常在开始时记录遍历长度;遍历中追加的新尾项不会被多数回调方法访问,删除或修改未访问位置却可能影响后续结果。保持遍历输入稳定,比依赖这些副作用容易理解。
遍历长度快照,不等于元素值快照
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实验推断所有迭代器完全一致。
类数组、异步与二进制的入口
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 容易到平方级。复杂度是选方法的理由,不是每行都手写循环的命令。
练习
从
[5, 12, 7]保留至少 7 的值,再乘 2,确保原数组不变。对
[1, , 3]比较map、filter和展开的结果,解释“新数组”为什么不保证行为一致。为什么
[].reduce((a, b) => a + b)抛错?写出和为 0 的空输入契约。为什么filter的手写for...of模型遇到空槽不完全等价?reduce回调忘记return后第二轮会得到什么?遍历时追加与修改已有位置为什么不同?