先说说数组初始化,这一步看着简单,其实门道不少。最常用的[]语法糖const arr = [1,2,3],背后是 JS 引擎创建了一个 Array 实例,在堆内存中开辟连续的存储空间,每个索引位置直接映射内存地址,元素值 1、2、3 被分别写入对应地址。你可以通过Object.getOwnPropertyDescriptors(arr)看到,索引 0、1、2 都有明确的 value 和 writable 属性。
但如果用new Array(3),情况就不一样了 ------JS 引擎会创建一个 length 为 3 的数组对象,但不会为未赋值的索引创建属性描述符,也就是所谓的 "稀疏数组"。现代 JS 引擎(如 V8)对稀疏数组有专门优化,并非完全不分配内存,而是用特殊结构标记未初始化的索引范围。实际测试下:
js
const sparseArr = new Array(3);
console.log(sparseArr[0]); // undefined
console.log(Object.hasOwn(sparseArr, 0)); // false(无属性描述符)
这和[undefined, undefined, undefined]完全不同,后者会为每个索引创建属性描述符并赋值 undefined:
js
const denseArr = [undefined, undefined, undefined];
console.log(Object.hasOwn(denseArr, 0)); // true
Array.of()是 ES6 新增的方法,专门解决new Array()的参数歧义。它的底层实现通过 arguments 对象处理参数,它的伪代码是:
js
Array.of = function() {
return Array.prototype.slice.call(arguments);
};
这种实现天然支持所有参数类型,比如:
js
Array.of(3); // [3]
Array.of(1, 2, 3); // [1,2,3]
Array.of(...[4,5,6]); // [4,5,6]
相比new Array(),它完全消除了参数类型带来的歧义,处理动态参数时更可靠。
数组遍历
数组遍历方法的底层机制差别很大。for 循环是最直接的索引访问:
js
const arr = [1,2,3];
for (let i = 0; i < arr.length; i++) {
console.log(arr[i]); // 直接通过内存地址读取值
}
它能直接操作索引,性能接近原生汇编的数组访问,所以大型数组遍历中速度最快。
除了 for 循环,for...in和for...of也是常用的遍历方式,但两者的底层逻辑截然不同。for...in是为遍历对象属性设计的,它会遍历数组所有可枚举属性(包括自定义属性和原型链属性),返回的索引是字符串类型:
js
const arr = [1, 2, 3];
arr.customProp = 'test'; // 添加自定义属性
for (const key in arr) {
console.log(key, typeof key); // 0 string, 1 string, 2 string, customProp string
}
这就是为什么它不适合遍历数组 ------ 不仅会拿到多余的属性,还得手动把索引转成数字才能正常使用。
而for...of是 ES6 新增的迭代器遍历方式,专门针对可迭代对象(数组、字符串、Set 等)设计。它的底层会调用对象的Symbol.iterator方法获取迭代器,然后不断调用next()方法直到遍历结束:
js
const arr = [1, 2, 3];
// 手动模拟for...of的底层过程
const iterator = arr[Symbol.iterator]();
let result = iterator.next();
while (!result.done) {
console.log(result.value); // 1, 2, 3
result = iterator.next();
}
// 实际使用for...of
for (const value of arr) {
console.log(value); // 1, 2, 3(直接拿到元素值)
}
for...of只会遍历数组的元素值,不会受自定义属性影响,也不用处理索引转换,这让它成为遍历数组的理想选择。不过它没法直接获取索引,需要索引时可以配合entries()使用:
js
for (const [index, value] of arr.entries()) {
console.log(index, value); // 0 1, 1 2, 2 3
}
forEach 的底层实现则是封装了遍历逻辑的高阶函数,V8 引擎的伪代码类似:
js
Array.prototype.forEach = function(callback, thisArg) {
const len = this.length;
if (IS_PACKED(this)) {
// 密集数组优化:直接按索引顺序遍历
for (let i = 0; i < len; i++) {
callback.call(thisArg, this[i], i, this);
}
} else {
// 带空洞数组:先获取所有有效索引再遍历
const keys = Object.keys(this);
for (let i = 0; i < keys.length; i++) {
const index = Number(keys[i]);
callback.call(thisArg, this[index], index, this);
}
}
};
这就是为什么它会跳过稀疏数组的空槽:
js
[1,,3].forEach(item => console.log(item)); // 只打印1和3
而且因为遍历逻辑被封装在函数内部,无法通过 break 或 return 中断,除非抛出异常(不推荐这么做)。
map 方法的底层会先创建等长数组,再逐个填充值:
js
Array.prototype.map = function(callback, thisArg) {
const len = this.length;
const result = new Array(len); // 预先分配内存
for (let i = 0; i < len; i++) {
if (Object.hasOwn(this, i)) {
result[i] = callback.call(thisArg, this[i], i, this);
}
}
return result;
};
所以稀疏数组的空槽会保留:
js
const mapped = [1,,3].map(x => x*2);
console.log(mapped); // [2,,6]
console.log(Object.hasOwn(mapped, 1)); // false
find 方法的底层用了短路逻辑,找到匹配项立即终止:
js
Array.prototype.find = function(callback, thisArg) {
const len = this.length;
for (let i = 0; i < len; i++) {
if (Object.hasOwn(this, i) && callback.call(thisArg, this[i], i, this)) {
return this[i]; // 找到后直接返回,终止循环
}
}
return undefined;
};
splice 方法之所以能修改原数组,是因为它直接操作数组的内存布局。底层步骤大致是:
-
计算需要删除的元素范围,将这些元素从内存中移除
-
把删除位置后的元素整体向前移动,填补空缺
-
在删除位置插入新元素,后面的元素再整体后移
这个过程在大型数组上开销很大
js
const arr = Array.from({length: 100000}, (_, i) => i);
console.time('splice-begin');
arr.splice(0, 1, 'new'); // 修改开头,后面10万个元素都要移动
console.timeEnd('splice-begin');
console.time('splice-end');
arr.splice(arr.length-1, 1, 'new'); // 修改末尾,几乎不移动元素
console.timeEnd('splice-end');
V8 引擎的 sort 方法采用混合排序策略,当数组长度≤64 时用插入排序(稳定排序,适合小数据集),>64 时切换为 TimSort 算法(结合归并排序和插入排序的优势)。默认排序会先调用每个元素的 toString () 方法,再比较 Unicode 码点:
js
const arr = [10, 2, 30];
arr.sort();
// 实际执行了: ['10', '2', '30'].sort((a,b) => a.localeCompare(b))
console.log(arr); // [10, 2, 30]
传入比较函数时,引擎会直接按数值比较:
js
arr.sort((a, b) => a - b); // 按数值大小排序
console.log(arr); // [2, 10, 30]
flat 方法的底层实现使用递归而非显式栈,简化的伪代码如下:
js
Array.prototype.flat = function(depth = 1) {
const result = [];
const flatRecursive = (array, currentDepth) => {
for (const item of array) {
if (Array.isArray(item) && currentDepth > 0) {
flatRecursive(item, currentDepth - 1);
} else {
result.push(item);
}
}
};
flatRecursive(this, depth);
return result;
};
测试空槽处理:
js
const arr = [1,, [2, , [3]]];
console.log(arr.flat(2)); // [1, 2, 3] 空槽被跳过
includes 方法能识别 NaN,是因为它使用 SameValueZero 算法,该算法的比较逻辑如下:
js
function sameValueZero(a, b) {
if (typeof a === 'number' && typeof b === 'number') {
// 处理NaN情况:NaN和NaN视为相等
return a === b || (a !== a && b !== b);
}
return a === b;
}
测试对比:
js
const arr = [1, NaN, 3];
console.log(arr.includes(NaN)); // true (用SameValueZero)
console.log(arr.indexOf(NaN)); // -1 (用===,NaN !== NaN)
V8 引擎将数组元素类型划分为更细粒度的分类,主要包括:
-
PACKED(密集型):所有索引都有属性描述符,如 PACKED_SMI_ELEMENTS(存储小整数)、PACKED_DOUBLE_ELEMENTS(存储双精度浮点数)
-
HOLEY(带空洞型):存在未赋值的索引,如 HOLEY_SMI_ELEMENTS、HOLEY_DOUBLE_ELEMENTS
PACKED 类型使用连续内存存储,访问时直接计算内存地址(index × 元素大小 + 起始地址),速度极快;HOLEY 类型由于存在空洞,需要额外的检查逻辑,访问速度通常慢 30%-50%。
测试两种类型的性能差异:
js
// PACKED类型(密集数组)
const packedArr = new Array(1000000).fill(0);
console.time('packed-access');
for (let i = 0; i < 1000000; i++) {
packedArr[i];
}
console.timeEnd('packed-access'); // 约6ms
// HOLEY类型(稀疏数组)
const holeyArr = new Array(1000000);
holeyArr[999999] = 0;
console.time('holey-access');
for (let i = 0; i < 1000000; i++) {
holeyArr[i];
}
console.timeEnd('holey-access'); // 约25ms
reduce 方法的底层实现需要处理空数组和初始值的边界情况:
js
Array.prototype.reduce = function(callback, initialValue) {
// 处理空数组且无初始值的情况
if (this.length === 0 && initialValue === undefined) {
throw new TypeError('Reduce of empty array with no initial value');
}
let accumulator = initialValue;
let startIndex = 0;
// 如果没传初始值,用数组第一个元素作为初始累加器
if (accumulator === undefined) {
accumulator = this[0];
startIndex = 1;
}
for (let i = startIndex; i < this.length; i++) {
if (Object.hasOwn(this, i)) {
accumulator = callback(accumulator, this[i], i, this);
}
}
return accumulator;
};
用它实现数组去重的效率比 indexOf 高很多:
js
const arr = [1, 2, 2, 3, 3, 3];
const unique = arr.reduce((acc, item) => {
if (!acc.includes(item)) acc.push(item);
return acc;
}, []);
console.log(unique); // [1,2,3]
copyWithin 方法的底层使用类似 memmove 的优化,能高效地在数组内部复制数据块,避免额外的内存分配:
js
const arr = [1,2,3,4,5];
arr.copyWithin(0, 3);
// 底层直接复制内存块:将索引3-4的数据复制到0-1位置
console.log(arr); // [4,5,3,4,5]
这比先 slice 再 splice 的组合方式快得多:
js
// 等效但低效的实现
const copy = arr.slice(3);
arr.splice(0, copy.length, ...copy);
最后再看 length 属性的特殊性,它本质是一个访问器属性,修改时会触发引擎的内部调整:
js
const arr = [1,2,3];
arr.length = 2; // 触发截断,删除索引2的属性描述符
console.log(arr); // [1,2]
arr.length = 5; // 触发扩展,新增索引3、4的空洞
console.log(Object.hasOwn(arr, 3)); // false
其实数组的很多特性都和 JS 的动态类型有关,它不像 Java 数组那样有固定类型和长度。这种灵活性带来了便利,但也让我们必须更深入地理解每个方法的底层行为。多写测试代码真的很重要,比如比较不同方法的性能差异,观察内存使用情况,这些实践能帮你建立更直观的认知。