前言
入行写 JavaScript 也有一段时间了,数组这东西天天用,但说句实话,前些年我一直觉得自己"会用"数组就够了,直到有几次在线上被 sort() 的默认排序坑得焦头烂额,被稀疏数组搞得怀疑人生,才意识到自己对这玩意儿的理解其实一直浮在表面。
后来花了相当长的时间,把规范、V8 源码相关的文章、各种边界情况全部过了一遍,才算真正建立起对 JS 数组完整的认知。这篇文章就是那次梳理的产物,后来又陆续补充了不少内容。写出来一是给自己做个备忘,二是如果你也曾经被数组的某些行为搞得困惑,希望这里能给你一个系统性的解答。
数组到底是个什么东西
这个问题听起来很基础,但真正想清楚的人并不多。
在 C 语言里,数组是一块连续的内存空间,元素类型固定,长度在声明时就确定了。在 Java 里,数组是定长的同类型容器。但 JavaScript 的数组跟这些完全不是一回事。
JS 的数组本质上就是一个对象。 一个特殊的、带 length 属性的、原型链上挂着 Array.prototype 的普通对象。你可以验证:
const arr = [1, 2, 3];
typeof arr; // "object"
arr instanceof Object; // true
arr instanceof Array; // true
所谓的"索引",其实就是对象的属性名。arr[0] 和 arr["0"] 访问的是同一个东西。你甚至可以给数组挂一些乱七八糟的属性:
const arr = [10, 20, 30];
arr.name = "my array";
arr.customMethod = function() { return "hello"; };
arr.name; // "my array"
arr.length; // 3,自定义属性不影响 length
当然没人会在生产代码里这么干,但理解这一点能帮你解释很多看似诡异的行为——比如为什么 for…in 遍历数组时会把自定义属性也带出来。
JS 数组还有两个跟传统数组截然不同的特征:长度是动态的,你随时可以 push、pop、直接给一个很大的索引赋值;元素类型是混合的,同一个数组里可以同时塞数字、字符串、对象、函数、null、undefined,甚至嵌套数组。
const anything = [42, "hello", true, null, undefined, { x: 1 }, [1, 2], () => {}];
anything.length; // 8,完全合法
这在静态类型语言里是不可想象的,但 JS 就是这么随性。引擎在底层会针对不同类型的元素做不同的存储策略,这是引擎的事,我们写业务代码不需要操心。
V8 是怎么存数组的
既然数组是对象,那性能岂不是很拉胯?还真不是。V8 对数组做了非常精细的优化,理解这些优化能帮你写出对引擎更友好的代码。
V8 内部把数组的元素存储分成好几种模式,统称 Elements Kind:
PACKED_SMI_ELEMENTS —— 最理想的状态。所有元素都是小整数(SMI,Small Integer),没有空槽。V8 会把它们直接存在一个连续的内存块里,访问速度接近 C 数组。
PACKED_DOUBLE_ELEMENTS —— 所有元素都是双精度浮点数,没有空槽。比 SMI 稍慢,但依然很快。
PACKED_ELEMENTS —— 元素可以是任意类型(字符串、对象等),但没有空槽。
HOLEY_SMI_ELEMENTS / HOLEY_DOUBLE_ELEMENTS / HOLEY_ELEMENTS —— 带空槽(hole)的版本。所谓空槽就是数组里"没有赋值"的位置。带空槽的数组在访问时多一层检查(需要判断这个位置到底有没有值),性能会打折扣。
这里有一条铁律:Elements Kind 只能降级,不能升级。 一旦数组从 PACKED 变成了 HOLEY,就永远回不去了。看个例子:
const arr = [1, 2, 3]; // PACKED_SMI_ELEMENTS
arr.push(4); // 还是 PACKED_SMI_ELEMENTS
arr.push(3.14); // 降级 → PACKED_DOUBLE_ELEMENTS
arr.push("hello"); // 降级 → PACKED_ELEMENTS
arr[100] = 0; // 降级 → HOLEY_ELEMENTS(4~99 全是空槽)
每一步都是不可逆的。所以如果你在意性能(比如在做大规模数值计算),尽量做到:
- 保持元素类型一致,别往数字数组里突然塞个字符串
- 避免跳跃式赋值产生空槽,用 push 按顺序添加
- 初始化时就把数组填满
不过话说回来,对于绝大多数业务场景——处理个几百上千条接口数据、渲染个列表——这些优化细节完全不需要刻意关注。V8 的 JIT 编译器已经足够聪明了。知道有这回事就行,别魔怔。
创建数组:看着简单,坑不少
字面量
const a = [];
const b = [1, 2, 3];
const c = [1, , 3]; // 中间那个逗号之间是空槽,不是 undefined,后面会细说
字面量是最推荐的方式。没有歧义,没有歧义,没有歧义。重要的事说三遍。
Array 构造函数
new Array(3); // 创建一个 length 为 3 的空数组,注意不是 [3]!
new Array(1, 2, 3); // 等价于 [1, 2, 3]
new Array("hello"); // ["hello"],一个元素的数组
看到问题了吧?当 new Array() 只接收一个参数时,如果这个参数是数字,它被当成数组长度;如果不是数字,它被当成唯一的元素。这个重载行为太反直觉了,我见过不止一次有人写出 new Array(count) 然后困惑为什么 map 不执行的事故。
我的建议是:永远不要用 new Array()。 没有例外。
Array.of()
ES6 专门搞了这个方法来填 new Array() 的坑:
Array.of(3); // [3],就是把 3 作为元素
Array.of(1, 2, 3); // [1, 2, 3]
Array.of(); // []
Array.of(undefined); // [undefined]
Array.of(1, "two", { three: 3 }); // [1, "two", { three: 3 }]
语义清晰得不能再清晰了:你传什么,数组里就有什么。
Array.from()
这个方法我用得特别多,它把类数组对象或可迭代对象转成真正的数组,还支持第二个参数作为映射函数:
// 类数组 → 数组
function foo() {
const args = Array.from(arguments);
// 现在 args 是真正的数组,可以用 map、filter 等
}
// 可迭代对象 → 数组
const set = new Set([1, 2, 2, 3]);
Array.from(set); // [1, 2, 3]
const map = new Map([["a", 1], ["b", 2]]);
Array.from(map); // [["a", 1], ["b", 2]]
// 带映射函数
Array.from([1, 2, 3], x => x * x); // [1, 4, 9]
// 生成序列——这个用法极其好用
Array.from({ length: 5 }, (_, i) => i); // [0, 1, 2, 3, 4]
Array.from({ length: 5 }, (_, i) => i * 2); // [0, 2, 4, 6, 8]
Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i));
// ["A", "B", "C", …, "Z"]
最后那个生成字母表的写法,比写个 for 循环优雅太多了。
展开运算符
const original = [1, 2, 3];
const copy = […original]; // 浅拷贝
const extended = […original, 4, 5]; // [1, 2, 3, 4, 5]
const merged = […arr1, …arr2]; // 合并
const fromString = […"hello"]; // ["h", "e", "l", "l", "o"]
const fromSet = […new Set([1,1,2,3])]; // [1, 2, 3]
其他零散的方式
// split
"a,b,c".split(","); // ["a", "b", "c"]
// keys 迭代器
[…Array(5).keys()]; // [0, 1, 2, 3, 4]
// fill 初始化
new Array(10).fill(0); // [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
// Object.values / Object.keys 间接生成
Object.values({ a: 1, b: 2, c: 3 }); // [1, 2, 3]
length:一个不普通的属性
大多数语言里数组长度是只读的,但 JS 的 length 是可写的,而且写入它会产生实际的副作用:
const arr = [1, 2, 3, 4, 5];
// 截断:直接砍掉后面的元素
arr.length = 3;
console.log(arr); // [1, 2, 3]
// 清空
arr.length = 0;
console.log(arr); // []
// 扩展:产生空槽
const arr2 = [1, 2];
arr2.length = 5;
console.log(arr2); // [1, 2, empty × 3]
console.log(arr2[3]); // undefined(但 3 in arr2 是 false)
length 的值始终等于"最大索引 + 1"。这意味着:
const arr = [];
arr[999] = "surprise";
console.log(arr.length); // 1000!中间 0~998 全是空槽
所以永远不要对大数组做跳跃式赋值,除非你真的知道自己在干什么。
增删改查:所有核心方法详解
这部分是文章的重头。我按"是否修改原数组"来分类,这个区分在实际开发中非常关键——尤其在 React、Vue 这类响应式框架里,你修改的是原数组还是返回新数组,直接决定了视图会不会更新。
会修改原数组的方法
push 和 pop
const stack = [1, 2, 3];
stack.push(4); // 返回新长度 4
console.log(stack); // [1, 2, 3, 4]
stack.push(5, 6, 7); // 一次推多个,返回新长度 7
console.log(stack); // [1, 2, 3, 4, 5, 6, 7]
stack.pop(); // 返回被弹出的元素 7
console.log(stack); // [1, 2, 3, 4, 5, 6]
stack.pop(); // 6
stack.pop(); // 5
时间复杂度 O(1),这是数组上最高效的操作。用 push + pop 模拟栈是最经典的做法。
unshift 和 shift
const queue = [1, 2, 3];
queue.unshift(0); // 返回新长度 4
console.log(queue); // [0, 1, 2, 3]
queue.shift(); // 返回被移除的元素 0
console.log(queue); // [1, 2, 3]
功能上没问题,但有一个性能上的大坑:unshift 和 shift 是 O(n) 操作。每次调用都要把后面所有元素往前或往后挪一个位置。数组越大越慢。
如果你的场景是频繁地在头部增删(比如实现一个队列),要么用索引模拟:
const arr = [];
let head = 0;
function enqueue(item) { arr.push(item); }
function dequeue() {
if (head >= arr.length) return undefined;
return arr[head++];
}
// 定期清理:当 head 很大时,arr = arr.slice(head); head = 0;
要么直接用链表或者其他数据结构。
splice
splice 是数组方法里功能最杂的一个,也是我觉得 API 设计得最烂的一个(参数含义全靠位置记)。但没办法,它确实什么都能干。
签名:arr.splice(start, deleteCount, item1, item2, …)
- start:从哪个索引开始操作。支持负数,-1 是最后一个。
- deleteCount:删除几个元素。传 0 就是不删。
- item1, item2, …:在 start 位置插入的新元素。
- 返回值:被删除的元素组成的数组。
const arr = [10, 20, 30, 40, 50];
// 纯删除:从索引 1 开始删 2 个
const removed = arr.splice(1, 2);
console.log(removed); // [20, 30]
console.log(arr); // [10, 40, 50]
// 纯插入:在索引 1 处插入,不删除
arr.splice(1, 0, "a", "b");
console.log(arr); // [10, "a", "b", 40, 50]
// 替换:从索引 2 开始,删 1 个,插入 "x"
arr.splice(2, 1, "x");
console.log(arr); // [10, "a", "x", 40, 50]
// 负索引
arr.splice(–1, 1); // 删除最后一个
console.log(arr); // [10, "a", "x", 40]
// 删除从某位置到末尾的所有元素
arr.splice(2); // 从索引 2 删到最后
console.log(arr); // [10, "a"]
splice 的问题在于可读性。当你在代码里看到 arr.splice(2, 0, item) 的时候,得在脑子里转一下才知道这是"在索引 2 处插入"。如果操作语义比较简单,我倾向于用更明确的方式:
- 尾部增删用 push/pop
- 头部增删用 unshift/shift(注意性能)
- 只有真正需要"在中间位置增删改"时才用 splice
sort
这个方法我要单独拿出来大讲特讲,因为它是 JS 数组里坑最深的地方,没有之一。
const nums = [3, 1, 10, 2, 20];
nums.sort();
console.log(nums); // [1, 10, 2, 20, 3]
等等,10 排在 2 前面?没错。sort() 默认把所有元素转成字符串,然后按 UTF-16 码元值排序。 "10" 的第一个字符 "1" 的码元值小于 "2",所以 "10" 排在 "2" 前面。
数字排序必须传比较函数:
// 升序
nums.sort((a, b) => a – b); // [1, 2, 3, 10, 20]
// 降序
nums.sort((a, b) => b – a); // [20, 10, 3, 2, 1]
比较函数的规则:返回负数,a 排前面;返回正数,b 排前面;返回 0,顺序不变。
几个重要的细节:
1. sort 是原地排序。 它直接修改原数组,返回值也是排序后的同一个数组。如果你不想改原数组,用 toSorted()(后面会讲)或者先拷贝再排。
2. 字符串排序。 纯英文字符串用默认排序就行(按码元排)。但如果涉及大小写混合或国际化场景,用 localeCompare:
const names = ["banana", "Apple", "cherry"];
// 默认排:大写在小写前面(码元值更小)
names.sort(); // ["Apple", "banana", "cherry"]
// 忽略大小写
names.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
// 中文按拼音排
const cn = ["张三", "李四", "王五"];
cn.sort((a, b) => a.localeCompare(b, "zh-CN"));
3. 对象数组按属性排序:
const users = [
{ name: "Alice", age: 30, score: 85 },
{ name: "Bob", age: 25, score: 92 },
{ name: "Charlie", age: 30, score: 78 },
];
// 按 age 升序
users.sort((a, b) => a.age – b.age);
// 多条件:先按 age,age 相同再按 score 降序
users.sort((a, b) => {
if (a.age !== b.age) return a.age – b.age;
return b.score – a.score;
});
4. 稳定性。 ES2019 开始,规范明确要求 sort 必须是稳定排序——相等的元素保持原始相对顺序。在此之前,V8 对超过 10 个元素的数组用的是不稳定的快速排序。现在你不需要担心这个问题了,所有主流引擎都实现了稳定排序。
5. 不要搞"随机排序":
// ❌ 这是未定义行为,不同引擎结果不同,不要这么干
arr.sort(() => Math.random() – 0.5);
想随机打乱请用 Fisher-Yates 算法(后面实用代码片段里有)。
reverse
const arr = [1, 2, 3, 4, 5];
arr.reverse(); // [5, 4, 3, 2, 1],原地修改
没什么好说的,原地反转,O(n)。不想改原数组的话用 toReversed()。
fill
const arr = [1, 2, 3, 4, 5];
arr.fill(0); // [0, 0, 0, 0, 0]
arr.fill(9, 1, 3); // [0, 9, 9, 0, 0],只填索引 1 和 2
常见用法是初始化一个全是相同值的数组:
const zeros = new Array(100).fill(0);
const ones = Array(50).fill(1);
但这里有个引用类型的大坑:
// ❌ 三个位置是同一个数组的引用!
const matrix = new Array(3).fill([]);
matrix[0].push(1);
console.log(matrix[1]); // [1] ← 被污染了!
console.log(matrix[0] === matrix[1]); // true
// ✅ 正确做法:用 Array.from 或 map 生成
const matrix = Array.from({ length: 3 }, () => []);
matrix[0].push(1);
console.log(matrix[1]); // [] ← 互不影响
fill 填进去的是同一个值的引用。如果填的是原始值(数字、字符串),没问题;如果填的是对象或数组,所有位置指向同一个引用。这个坑我踩过的次数多到不想回忆。
copyWithin
这个方法用得很少,但规范里有,简单提一下:
const arr = [1, 2, 3, 4, 5];
// 把索引 3 到末尾的元素,复制到索引 0 开始的位置
arr.copyWithin(0, 3);
console.log(arr); // [4, 5, 3, 4, 5]
// 把索引 3 到 4(不含)的元素,复制到索引 1
const arr2 = [1, 2, 3, 4, 5];
arr2.copyWithin(1, 3, 4);
console.log(arr2); // [1, 4, 3, 4, 5]
在数组旋转之类的算法题里偶尔用得到,日常业务基本碰不上。
不修改原数组的方法
map
const nums = [1, 2, 3, 4];
const doubled = nums.map(n => n * 2); // [2, 4, 6, 8]
const strings = nums.map(String); // ["1", "2", "3", "4"]
const objects = nums.map((n, i) => ({ value: n, index: i }));
回调接收三个参数:(element, index, array)。返回的新数组长度和原数组一样。
一个经典面试题:
["1", "2", "3"].map(parseInt);
// 期望 [1, 2, 3]
// 实际 [1, NaN, NaN]
为什么?因为 map 传了三个参数给回调,而 parseInt 接收两个参数(值和进制)。所以实际执行的是:
parseInt("1", 0) → 1 (进制为 0 等同于 10)
parseInt("2", 1) → NaN (1 进制不存在)
parseInt("3", 2) → NaN (2 进制里没有数字 3)
正确写法:
["1", "2", "3"].map(s => parseInt(s, 10));
// 或者
["1", "2", "3"].map(Number);
filter
const nums = [1, 2, 3, 4, 5, 6, 7, 8];
const evens = nums.filter(n => n % 2 === 0); // [2, 4, 6, 8]
const big = nums.filter(n => n > 5); // [6, 7, 8]
返回满足条件的元素组成的新数组。没有匹配的就返回 [],不会返回 null 或 undefined。
一个技巧:filter(Boolean) 可以过滤掉所有 falsy 值:
[0, 1, false, 2, "", 3, null, undefined, NaN].filter(Boolean);
// [1, 2, 3]
reduce 和 reduceRight
reduce 是数组方法里最灵活的一个,也是我觉得最值得花时间掌握的一个。它能把"把数组变成某个东西"的所有操作统一成一个模式。
arr.reduce(callback(accumulator, currentValue, currentIndex, array), initialValue)
// 求和
[1, 2, 3, 4, 5].reduce((acc, cur) => acc + cur, 0); // 15
// 执行过程(假设初始值为 0):
// acc=0, cur=1 → 1
// acc=1, cur=2 → 3
// acc=3, cur=3 → 6
// acc=6, cur=4 → 10
// acc=10, cur=5 → 15
永远传初始值。 不传初始值的话,第一轮回调会拿数组第一个元素当 acc,第二个元素当 cur,从第二个元素开始迭代。如果数组是空的,直接抛 TypeError。传了初始值就没有这些问题。
reduce 能做的事情太多了,举几个典型的:
// 数组扁平化(一层)
[[1, 2], [3, 4], [5]].reduce((acc, cur) => acc.concat(cur), []);
// [1, 2, 3, 4, 5]
// 计数
["apple", "banana", "apple", "cherry", "banana", "apple"]
.reduce((acc, word) => {
acc[word] = (acc[word] || 0) + 1;
return acc;
}, {});
// { apple: 3, banana: 2, cherry: 1 }
// 分组
const people = [
{ name: "Alice", dept: "eng" },
{ name: "Bob", dept: "hr" },
{ name: "Charlie", dept: "eng" },
{ name: "Diana", dept: "hr" },
];
people.reduce((groups, person) => {
const key = person.dept;
if (!groups[key]) groups[key] = [];
groups[key].push(person);
return groups;
}, {});
// { eng: [Alice, Charlie], hr: [Bob, Diana] }
// 函数管道
const pipe = (…fns) => (input) => fns.reduce((acc, fn) => fn(acc), input);
const process = pipe(
x => x * 2,
x => x + 10,
x => `结果: ${x}`
);
process(5); // "结果: 20"
// 数组去重(不推荐,用 Set 更好,只是展示 reduce 的能力)
[1, 2, 2, 3, 3, 3].reduce((acc, cur) => {
if (!acc.includes(cur)) acc.push(cur);
return acc;
}, []);
// [1, 2, 3]
// 实现 map
[1, 2, 3].reduce((acc, cur) => {
acc.push(cur * 2);
return acc;
}, []);
// [2, 4, 6]
// 实现 filter
[1, 2, 3, 4, 5].reduce((acc, cur) => {
if (cur > 3) acc.push(cur);
return acc;
}, []);
// [4, 5]
reduceRight 就是从右往左迭代,其他完全一样。
不过我要说一句大实话:reduce 虽然强大,但不要滥用。 如果一个 map 加 filter 就能清晰表达意图的操作,你非要写成一个 20 行的 reduce,那不叫炫技,叫给同事挖坑。代码首先是写给人看的,其次才是给机器执行的。
find 和 findIndex
const users = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
{ id: 3, name: "Charlie" },
];
users.find(u => u.id === 2); // { id: 2, name: "Bob" }
users.find(u => u.id === 999); // undefined
users.findIndex(u => u.id === 2); // 1
users.findIndex(u => u.id === 999); // -1
找到第一个满足条件的就停止,不会继续遍历。如果什么都不传或者回调始终返回 false,find 返回 undefined,findIndex 返回 -1。
findLast 和 findLastIndex(ES2023)
从右往左找:
const nums = [1, 2, 3, 4, 5, 4, 3];
nums.findLast(n => n === 4); // 4(最后一个 4)
nums.findLastIndex(n => n === 4); // 5(索引 5,不是 3)
以前要实现这个功能得先 reverse 再 find 或者手动倒序循环,现在一个方法搞定。
some 和 every
const nums = [2, 4, 6, 8];
nums.every(n => n % 2 === 0); // true,全部是偶数
nums.some(n => n > 7); // true,至少有一个大于 7
nums.some(n => n > 10); // false
短路求值:some 找到第一个 true 就停,every 找到第一个 false 就停。
一个容易忽略的边界情况:空数组的 every 返回 true,some 返回 false。 这是数学上"空真"(vacuous truth)的体现。[].every(() => false) 也是 true。第一次碰到这个行为的人多少会有点懵,但逻辑上是自洽的。
includes、indexOf、lastIndexOf
const arr = [1, 2, 3, NaN, 2, undefined];
arr.includes(2); // true
arr.includes(NaN); // true ✅
arr.includes(undefined); // true
arr.indexOf(2); // 1(第一个 2)
arr.lastIndexOf(2); // 4(最后一个 2)
arr.indexOf(NaN); // -1 ❌ 因为用的是严格相等,NaN !== NaN
includes 用的是 SameValueZero 算法,能正确识别 NaN。indexOf 用的是严格相等(===),所以搞不定 NaN。判断元素是否存在,用 includes,别用 indexOf() !== -1。
indexOf 还有一个用途:指定起始搜索位置:
[1, 2, 3, 2, 1].indexOf(2, 2); // 3,从索引 2 开始找
concat
const a = [1, 2];
const b = [3, 4];
const c = a.concat(b, [5, 6], 7, 8);
console.log(c); // [1, 2, 3, 4, 5, 6, 7, 8]
console.log(a); // [1, 2],原数组不变
concat 是浅拷贝。如果元素是对象,合并后新旧数组里的对象是同一个引用。
现在更多人用展开运算符:
const c = […a, …b, [5, 6], 7, 8];
效果一样,写法更简洁。concat 在老代码里见得比较多,新代码用展开运算符就好。
slice
const arr = [0, 1, 2, 3, 4, 5];
arr.slice(1, 3); // [1, 2],含头不含尾
arr.slice(2); // [2, 3, 4, 5]
arr.slice(–2); // [4, 5]
arr.slice(1, –1); // [1, 2, 3, 4]
arr.slice(); // 浅拷贝,等价于 […arr]
arr.slice(–3, –1); // [3, 4]
不修改原数组,返回新数组。支持负索引。slice(0) 或 slice() 是浅拷贝的经典写法,不过现在用 […arr] 更直观。
join
[1, 2, 3].join(", "); // "1, 2, 3"
[1, 2, 3].join(""); // "123"
["a", "b", "c"].join(" → "); // "a → b → c"
[].join(","); // ""
null 和 undefined 会被转成空字符串:
[1, null, undefined, 2].join("-"); // "1—2"
flat 和 flatMap
const nested = [1, [2, 3], [4, [5, [6]]]];
nested.flat(); // [1, 2, 3, 4, [5, [6]]],默认展平一层
nested.flat(2); // [1, 2, 3, 4, 5, [6]]
nested.flat(Infinity); // [1, 2, 3, 4, 5, 6],不管多深全展平
// flatMap = map + flat(1)
["hello world", "foo bar baz"].flatMap(s => s.split(" "));
// ["hello", "world", "foo", "bar", "baz"]
// 一对多映射
[1, 2, 3].flatMap(n => [n, n * 10]);
// [1, 10, 2, 20, 3, 30]
// 用空数组实现"过滤"效果(比 filter 更灵活)
[1, 2, 3, 4, 5].flatMap(n => n % 2 === 0 ? [n] : []);
// [2, 4]
flatMap 在"一个输入产生零个或多个输出"的场景下特别方便,省去了先 map 再 flat 的两步操作。
遍历数组:选择比你想象的多
JavaScript 遍历数组的方式多得有点过分了,每种都有细微的差别。我把它们摊开来讲。
for 循环
for (let i = 0; i < arr.length; i++) {
console.log(i, arr[i]);
}
最原始,也最快。没有函数调用开销,没有迭代器开销,就是纯粹的索引访问。如果你在处理百万级的数据,这是唯一的选择。
一个小技巧——倒序遍历(在遍历中删除元素时避免索引错乱):
for (let i = arr.length – 1; i >= 0; i—) {
if (shouldRemove(arr[i])) {
arr.splice(i, 1);
}
}
for…of
for (const item of arr) {
console.log(item);
}
// 需要索引
for (const [index, item] of arr.entries()) {
console.log(index, item);
}
// 只要索引
for (const index of arr.keys()) {
console.log(index);
}
这是我最推荐的遍历方式。它支持 break、continue、return(在函数中),正确处理稀疏数组(空槽变成 undefined),语义清晰。
forEach
arr.forEach((item, index, array) => {
console.log(item, index);
});
limitations:
- 不能 break,不能 return 跳出。 这是最致命的。如果你需要"找到第一个满足条件的就停",forEach 做不到,得用 for…of 或 some。
- 跳过空槽。 [1, , 3].forEach(x => console.log(x)) 只打印 1 和 3,中间的 empty 被跳过了。
- 如果你 forEach 里面 return,只是相当于 continue,跳过当前这一轮,不会终止整个循环。
for…in(别用来遍历数组)
const arr = [10, 20, 30];
arr.customProp = "oops";
for (const key in arr) {
console.log(key, arr[key]);
}
// "0" 10
// "1" 20
// "2" 30
// "customProp" "oops" ← 这玩意也出来了
问题一堆:键是字符串不是数字;会遍历自定义属性和原型链上的可枚举属性;遍历顺序在某些边缘情况下不保证。for…in 是给遍历对象属性用的,数组请用别的方式。
while / do…while
偶尔在特殊场景下用,比如不确定终止条件时:
let i = 0;
while (i < arr.length && arr[i] !== target) {
i++;
}
日常开发中不如 for…of 直观。
各方法对空槽的处理一览
这个差异在实际开发中偶尔会造成困惑,列个表:
| for 循环 | 访问得到 undefined,但不区分 undefined 和空槽 |
| for…of | 空槽当作 undefined 处理,会执行循环体 |
| forEach | 跳过,不执行回调 |
| map | 跳过,结果中保留 empty |
| filter | 跳过 |
| some / every | 跳过 |
| reduce | 跳过 |
| entries() / keys() / values() | 空槽当作 undefined |
| […arr] / Array.from(arr) | 空槽变成 undefined |
记住一个大致规律:高阶函数(forEach、map、filter 等)跳过空槽;迭代器相关的方法(for…of、展开、Array.from)把空槽当 undefined。
稀疏数组:一个你最好别碰的东西
什么是空槽
空槽(empty / hole)不是 undefined,不是 null,是真的什么都没有。那个位置在对象上压根不存在这个属性。
const sparse = [1, , , 4];
console.log(sparse.length); // 4
console.log(sparse[1]); // undefined(访问不存在的属性)
console.log(1 in sparse); // false ← 索引 1 根本不存在
console.log(sparse.hasOwnProperty(1)); // false
console.log(Object.keys(sparse)); // ["0", "3"]
怎么产生的
new Array(5); // 5 个空槽
[1, , 3]; // 字面量中间留空
const a = [1, 2];
a[10] = 99; // 2~9 全是空槽
a.length = 20; // 扩展到 20,新位置是空槽
delete a[1]; // 删除后留下空槽
为什么它是麻烦
因为不同方法对空槽的处理不一致。map 跳过,展开运算符变成 undefined,JSON.stringify 变成 null:
const sparse = [1, , 3];
sparse.map(x => x * 2); // [2, empty, 6]
[…sparse]; // [1, undefined, 3]
JSON.stringify(sparse); // "[1,null,3]"
sparse.forEach(x => console.log(x)); // 只打印 1 和 3
建议
能不用稀疏数组就不用。 如果你需要一个"预分配但还没有值"的数组,用 fill(null) 或 fill(undefined) 显式填充:
// ❌
const arr = new Array(100);
// ✅
const arr = new Array(100).fill(null);
// 或
const arr = Array.from({ length: 100 }, () => null);
类数组对象
什么是类数组
有 length 属性、有数字索引,但没有数组原型上的方法:
// arguments(非箭头函数)
function foo() {
console.log(arguments.length); // 有
console.log(arguments[0]); // 有
// arguments.map(x => x); // ❌ TypeError
}
// DOM 查询结果
const nodes = document.querySelectorAll(".item"); // NodeList
// nodes.map(…) // ❌
转成真正的数组
// 推荐
const arr = Array.from(arguments);
const arr = […arguments];
const arr = Array.from(nodeList);
const arr = […nodeList];
// 老式写法(能跑但没必要了)
const arr = Array.prototype.slice.call(arguments);
const arr = [].slice.call(nodeList);
现代代码里,用 Array.from 或展开运算符就够了。另外,箭头函数没有 arguments,用 rest 参数代替:
const foo = (…args) => {
// args 本身就是数组
args.forEach(arg => console.log(arg));
};
浅拷贝和深拷贝
这个话题在面试里被问烂了,但实际开发中也是真真切切会踩坑的地方。
浅拷贝
const original = [{ name: "Alice" }, { name: "Bob" }, 42];
const copy1 = […original];
const copy2 = original.slice();
const copy3 = original.concat();
const copy4 = Array.from(original);
const copy5 = original.map(x => x);
以上全是浅拷贝。它们只复制了第一层:
copy1[0].name = "Eve";
console.log(original[0].name); // "Eve" ← 被影响了
copy1[2] = 100;
console.log(original[2]); // 42 ← 不受影响(原始值)
copy1[0] = { name: "Charlie" };
console.log(original[0].name); // "Alice" ← 不受影响(整个引用被替换了)
总结:浅拷贝后,修改嵌套对象的属性会互相影响,但替换整个元素不会。
深拷贝
// 方法 1:structuredClone(首选)
const deep = structuredClone(original);
structuredClone 是 2022 年左右浏览器和 Node 17+ 开始支持的。它能处理循环引用、Date、RegExp、Map、Set、ArrayBuffer、Blob 等等,是真正意义上的深拷贝。唯一的限制是不能拷贝函数和 DOM 节点。
// 方法 2:JSON 序列化(有局限)
const deep = JSON.parse(JSON.stringify(original));
这个老方法的局限很多:
- 函数会丢失
- Symbol 会丢失
- undefined 会丢失(在对象中)或变成 null(在数组中)
- Date 变成字符串
- RegExp 变成空对象
- Map、Set 变成空对象
- 循环引用直接报错
- NaN、Infinity 变成 null
数据简单的时候能用,复杂数据结构别碰。
// 方法 3:手写递归
function deepClone(value) {
if (value === null || typeof value !== "object") return value;
if (value instanceof Date) return new Date(value.getTime());
if (value instanceof RegExp) return new RegExp(value.source, value.flags);
if (value instanceof Map) {
return new Map([…value].map(([k, v]) => [deepClone(k), deepClone(v)]));
}
if (value instanceof Set) {
return new Set([…value].map(v => deepClone(v)));
}
if (Array.isArray(value)) {
return value.map(item => deepClone(item));
}
const obj = {};
for (const key of Reflect.ownKeys(value)) {
obj[key] = deepClone(value[key]);
}
return obj;
}
// 方法 4:lodash
import { cloneDeep } from "lodash-es";
const deep = cloneDeep(original);
实际项目中,如果浏览器/运行环境支持 structuredClone,就用它;否则用 lodash.cloneDeep;自己手写递归除非是面试或者极端场景,否则没必要。
ES2023 的不可变数组方法
这是近几年我觉得最实用的数组新特性。传统方法里 sort、reverse、splice 都是原地修改,在函数式编程和 React 这类"不可变数据驱动视图更新"的框架里非常别扭。你每次都得先拷贝一份再操作。
ES2023 加了四个方法,全部返回新数组,原数组纹丝不动:
const arr = [3, 1, 4, 1, 5, 9, 2, 6];
// toSorted:排序
const sorted = arr.toSorted((a, b) => a – b);
console.log(sorted); // [1, 1, 2, 3, 4, 5, 6, 9]
console.log(arr); // [3, 1, 4, 1, 5, 9, 2, 6] ← 没变
// toReversed:反转
const reversed = arr.toReversed();
// toSpliced:splice 的不可变版本
const spliced = arr.toSpliced(2, 1, 99); // 索引 2 删 1 个,插入 99
console.log(spliced); // [3, 1, 99, 1, 5, 9, 2, 6]
console.log(arr); // 没变
// with:替换某个索引的值
const replaced = arr.with(0, 100);
console.log(replaced[0]); // 100
console.log(arr[0]); // 3 ← 没变
在 React 里的体感提升非常明显:
// 以前:先拷贝再操作
const handleClick = () => {
setItems(prev => […prev].sort((a, b) => a.priority – b.priority));
};
// 现在:一步到位
const handleClick = () => {
setItems(prev => prev.toSorted((a, b) => a.priority – b.priority));
};
少了一层 […prev] 的噪音,意图也更明确。这四个方法目前在 Chrome 110+、Firefox 115+、Safari 16.4+、Node 20+ 都可用了。
数组解构
ES6 的解构赋值在数组上用得特别多,几个常见模式:
// 基本解构
const [a, b, c] = [1, 2, 3];
// 跳过
const [first, , third] = [10, 20, 30]; // first=10, third=30
// 默认值
const [x, y = 0, z = 0] = [5]; // x=5, y=0, z=0
// rest 收集
const [head, …tail] = [1, 2, 3, 4, 5]; // head=1, tail=[2,3,4,5]
// 交换变量(不需要临时变量)
let a = 1, b = 2;
[a, b] = [b, a]; // a=2, b=1
// 函数多返回值
function getBounds(arr) {
return [Math.min(…arr), Math.max(…arr)];
}
const [min, max] = getBounds([3, 1, 4, 1, 5, 9]);
// 嵌套解构
const [[x1, y1], [x2, y2]] = [[0, 0], [10, 20]];
// 配合正则
const match = "2026-09-01".match(/(\\d{4})-(\\d{2})-(\\d{2})/);
const [, year, month, day] = match; // 第一个是完整匹配,跳过
解构赋值让很多以前需要临时变量的操作变得非常简洁,用习惯了就回不去了。
多维数组
JS 没有真正的多维数组,只有"数组里套数组"。创建二维数组时有个经典坑:
// ❌ 错误:所有行共享同一个引用
const bad = new Array(3).fill(new Array(4).fill(0));
bad[0][0] = 99;
console.log(bad[1][0]); // 99 ← 所有行都被改了
// ✅ 正确
const grid = Array.from({ length: 3 }, () => new Array(4).fill(0));
grid[0][0] = 99;
console.log(grid[1][0]); // 0 ← 互不影响
原因跟前面 fill 的引用问题一样:fill 填进去的是同一个数组的引用。
遍历:
for (let i = 0; i < grid.length; i++) {
for (let j = 0; j < grid[i].length; j++) {
process(grid[i][j]);
}
}
// 或者
for (const row of grid) {
for (const cell of row) {
process(cell);
}
}
性能:什么时候该在意,什么时候不该
先说一句大前提:不要过早优化。 99% 的业务场景下,数组操作的性能差异完全可以忽略。只有当你确实在处理大规模数据(几十万、上百万条),并且通过性能分析工具确认了瓶颈在数组操作上时,才需要考虑下面的建议。
遍历性能排序(大数组场景):
for 循环 > for…of > forEach ≈ map > filter + map 链式
for 循环没有函数调用开销,在百万级数据上能比 forEach 快 2~3 倍。但写起来啰嗦,日常开发用 for…of 或高阶函数就够了。
避免头部操作:
unshift 和 shift 是 O(n) 的。如果你需要频繁在头部操作,用索引模拟或者换数据结构。
push 的扩容机制:
V8 对 push 做了类似 std::vector 的倍增扩容策略,所以连续 push 的均摊复杂度是 O(1)。如果你事先知道数组大小,预分配可以略微减少扩容次数:
const arr = new Array(knownSize);
for (let i = 0; i < knownSize; i++) {
arr[i] = compute(i);
}
但说实话,现代引擎下这个优化的收益非常小。
TypedArray:
如果你的数组只存数字,并且数据量大、计算密集(图像处理、音频、物理模拟、WebGL),用 TypedArray:
const pixels = new Uint8ClampedArray(1920 * 1080 * 4); // RGBA
const audio = new Float32Array(44100); // 1 秒 44.1kHz 采样
TypedArray 内存连续、无装箱、可以直接操作 ArrayBuffer,跟 WebAssembly 和 WebGL 无缝对接。但它长度固定,没有 push、pop、splice 这些方法,也没有空槽的概念。适合数值计算,不适合当通用容器。
大数组展开运算符的栈溢出:
const huge = new Array(200000).fill(0);
Math.max(…huge); // ❌ RangeError: Maximum call stack size exceeded
展开运算符会把所有元素作为函数参数传入,参数过多会爆栈。大数组求最值用 reduce:
const max = huge.reduce((a, b) => Math.max(a, b), –Infinity);
常见坑汇总
把前面散落在各处的坑集中列一下,方便你踩坑前先看一眼:
1. sort 默认按字符串排
[10, 9, 8, 7].sort(); // [10, 7, 8, 9]
数字排序永远传 (a, b) => a – b。
2. map(parseInt) 不是你想的那样
["1", "2", "3"].map(parseInt); // [1, NaN, NaN]
用 .map(Number) 或 .map(s => parseInt(s, 10))。
3. fill 填引用类型是同一个引用
new Array(3).fill([]); // 三个元素是同一个数组
用 Array.from({ length: 3 }, () => [])。
4. delete 留空槽
const arr = [1, 2, 3];
delete arr[1]; // arr 变成 [1, empty, 3],length 还是 3
// 用 arr.splice(1, 1) 才是真删除
5. 数组比较是引用比较
[1, 2, 3] === [1, 2, 3]; // false
比较内容需要自己写逻辑,或者用 JSON.stringify(有局限)。
6. new Array(n) 的歧义
new Array(3); // 长度 3 的空数组
Array.of(3); // [3]
7. 空数组的 every 返回 true
[].every(x => x > 0); // true
[].some(x => x > 0); // false
逻辑上是对的,但第一次见容易懵。
8. forEach 不能中断
arr.forEach(x => {
if (x === target) return; // 这只是跳过当前迭代,不是终止循环
});
// 需要中断就用 for…of 或 some/find
9. flat 和 flatMap 只展平一层(默认)
[1, [2, [3, [4]]]].flat(); // [1, 2, [3, [4]]]
[1, [2, [3, [4]]]].flat(Infinity); // [1, 2, 3, 4]
10. splice 返回的是被删除的元素
const arr = [1, 2, 3, 4, 5];
const removed = arr.splice(1, 2); // removed = [2, 3]
// 不是返回新数组,是返回被删掉的那些
实用代码片段
最后放一批开发中高频使用的数组操作,可以直接抄。
// ========== 去重 ==========
const unique = […new Set(arr)];
// ========== 交集 ==========
const intersection = a.filter(x => b.includes(x));
// 大数组优化:
const setB = new Set(b);
const intersection = a.filter(x => setB.has(x));
// ========== 差集(a 有 b 没有) ==========
const setB = new Set(b);
const diff = a.filter(x => !setB.has(x));
// ========== 并集 ==========
const union = […new Set([…a, …b])];
// ========== 随机打乱(Fisher-Yates) ==========
function shuffle(arr) {
const result = […arr];
for (let i = result.length – 1; i > 0; i—) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
// ========== 分块 ==========
function chunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
chunk([1, 2, 3, 4, 5], 2); // [[1,2], [3,4], [5]]
// ========== 求和 / 平均 / 最值 ==========
const sum = arr.reduce((a, b) => a + b, 0);
const avg = sum / arr.length;
const max = arr.reduce((a, b) => Math.max(a, b), –Infinity);
const min = arr.reduce((a, b) => Math.min(a, b), Infinity);
// ========== 完全扁平化 ==========
const flat = arr.flat(Infinity);
// 或手写递归(兼容性更好):
const flatDeep = arr =>
arr.reduce((acc, val) =>
Array.isArray(val) ? acc.concat(flatDeep(val)) : acc.concat(val), []);
// ========== 按条件分组 ==========
function groupBy(arr, keyFn) {
return arr.reduce((groups, item) => {
const key = typeof keyFn === "function" ? keyFn(item) : item[keyFn];
(groups[key] ??= []).push(item);
return groups;
}, {});
}
groupBy(users, u => u.department);
// ========== 对象数组多字段排序 ==========
function sortBy(arr, …criteria) {
return arr.toSorted((a, b) => {
for (const c of criteria) {
const desc = c.startsWith("-");
const key = desc ? c.slice(1) : c;
const va = a[key], vb = b[key];
if (va < vb) return desc ? 1 : –1;
if (va > vb) return desc ? –1 : 1;
}
return 0;
});
}
sortBy(users, "-age", "name"); // 先按 age 降序,再按 name 升序
// ========== 数组元素出现次数 ==========
function countBy(arr, keyFn) {
return arr.reduce((acc, item) => {
const key = typeof keyFn === "function" ? keyFn(item) : item[keyFn];
acc[key] = (acc[key] || 0) + 1;
return acc;
}, {});
}
// ========== 两个数组按某字段做 join ==========
function joinBy(left, right, leftKey, rightKey) {
const rightMap = new Map(right.map(r => [r[rightKey], r]));
return left.map(l => ({
…l,
…rightMap.get(l[leftKey]),
}));
}
// ========== 数组旋转 ==========
function rotate(arr, k) {
const n = arr.length;
k = ((k % n) + n) % n; // 处理负数和超过长度的情况
return […arr.slice(n – k), …arr.slice(0, n – k)];
}
rotate([1, 2, 3, 4, 5], 2); // [4, 5, 1, 2, 3]
// ========== 安全取值(避免越界报错的辅助) ==========
function getAt(arr, index, fallback = undefined) {
if (index < 0) index += arr.length;
return index >= 0 && index < arr.length ? arr[index] : fallback;
}
写在最后
JavaScript 的数组是个矛盾体。它足够灵活——动态长度、混合类型、方法丰富——但也正因为这种灵活,埋了无数细微的坑。sort 的默认排序、稀疏数组的诡异行为、fill 的引用陷阱、for…in 遍历数组的意外……这些东西不踩一次你记不住,但踩了之后又觉得"这设计也太随意了吧"。
但抱怨归抱怨,数组毕竟是 JS 里用得最多的数据结构,没有之一。把它吃透了,日常开发中绝大部分数据处理的问题都不在话下。
几个我给自己定的规矩,分享给你:
- 创建数组只用字面量、Array.from、Array.of。new Array() 从代码里彻底消失。
- 数字排序永远写比较函数,形成肌肉记忆。
- 在响应式框架里,优先用 toSorted、toReversed、with 这些不可变方法。
- 遍历用 for…of,需要函数式就用 map/filter/reduce,for…in 永远不碰数组。
- 涉及嵌套对象时,先想清楚需要浅拷贝还是深拷贝。
- reduce 是瑞士军刀,但如果 map + filter 能说清楚的事,就别写 reduce。
希望这篇梳理对你有用。如果你读完了,说明你是真的有耐心,也真的想把基础打扎实。共勉。






