深入JavaScript数组:从“空洞”到“遍历”,你真的了解它吗?

引言:数组,前端开发的基石

在JavaScript的世界里,数组(Array)无疑是最常用、最基础的数据结构之一。无论是存储列表数据、处理集合操作,还是构建复杂的数据模型,数组都扮演着举足轻重的角色。然而,你是否曾被JavaScript数组的"灵活"特性所迷惑?它既可以像传统语言(如C++、Java)中的数组一样存储同类型数据,又可以存储不同类型的数据,甚至还能像哈希表一样通过字符串键访问元素。更令人困惑的是,new Array()创建的数组中那些神秘的"empty"槽位,以及for...infor...of在遍历时的不同行为,都可能让你对JavaScript数组的底层机制产生疑问。

本文旨在深入剖析JavaScript数组的底层实现、初始化方式、静态方法以及遍历机制。我们将从V8引擎对数组的设计理念出发,探讨new Array()与数组字面量[]的异同,揭示"空洞数组"的秘密,并详细讲解Array.of()Array.from()这两个强大的静态方法。此外,我们还将深入理解for...infor...of在数组遍历时的底层逻辑,以及hasOwnProperty在原型链中的作用。

通过本文的学习,你将不仅仅停留在"如何使用"数组的层面,更能理解其"为什么这样设计"以及"底层是如何工作"的原理,从而在实际开发中更加游刃有余,编写出更健壮、更高效的代码。

准备好了吗?让我们一起踏上这段探索之旅,揭开JavaScript数组的底层奥秘!

1. 认识JavaScript数组:灵活与"空洞"并存的结构

在计算机科学中,数组通常被定义为存储同类型元素的连续内存区域。然而,JavaScript中的数组却与此大相径庭。它更像是一个"可遍历的对象",融合了传统数组和哈希表的特性,这使得它既灵活又可能带来一些意想不到的行为。

1.1 new Array(length):预分配的"空洞"

当我们使用new Array(length)构造函数来创建一个指定长度的数组时,例如new Array(5),你可能会期望得到一个包含5个undefined元素的数组。然而,实际情况并非如此。在控制台中打印这个数组,你会看到类似[empty × 5]的输出。这些"empty"槽位,正是JavaScript数组的独特之处,我们称之为"空洞"(Holes)或"稀疏数组"(Sparse Arrays)。

底层思考:V8引擎对数组的设计

JavaScript引擎(如Chrome的V8引擎)对数组的内部实现进行了高度优化。对于像new Array(5)这样创建的数组,V8并不会立即为这5个槽位分配内存并填充undefined。相反,它只是简单地记录了这个数组的长度。这些"empty"槽位实际上是"未被赋值"的状态,它们在内存中并没有实际的存储空间。这种设计是为了优化内存使用和初始化性能,尤其是在创建大型数组时。

xml 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>初始化数组</title>
</head>
<body>
    <script>
        const arr = new Array(5); 
        console.log(arr); // 输出: [empty × 5]
        for(let key in arr){
            // 这里的循环不会执行,因为"empty"槽位没有对应的key
            console.log(key,arr[key]);
        }
        console.log(arr[0]); // 输出: undefined
    </script>
</body>
</html>

从上面的代码和输出可以看出,尽管arr[0]访问时会返回undefined,但for...in循环却不会遍历这些"empty"槽位。这是因为for...in循环遍历的是对象自身的以及原型链上可枚举的属性名(keys),而"empty"槽位并没有对应的属性名。

1.2 数组字面量[]:更常见的创建方式

相比于new Array(),使用数组字面量[]是更常见、更推荐的数组创建方式。通过数组字面量创建的数组,其槽位会被实际填充,例如const arr = [1, 2, 3];会创建一个包含3个元素的数组,每个槽位都有实际的值。

scss 复制代码
// 1.js
const arr =[1,2,3];
const arr2 = new Array(5).fill(undefined);
​
// arr2现在是一个包含5个undefined的数组,没有"空洞"
console.log(arr2); // 输出: [undefined, undefined, undefined, undefined, undefined]
​
// 即使给一个超出当前长度的索引赋值,JavaScript也会自动扩容
arr2[8]=undefined;
console.log(arr2); // 输出: [undefined, undefined, undefined, undefined, undefined, empty × 3, undefined]
​
for(let key in arr2){
    // for...in 会遍历所有存在的key,包括索引和非索引属性
    console.log(key,arr2[key]);
}
​
for(let item of arr2){
    // for...of 会遍历所有可迭代的值,包括undefined和"empty"槽位(在遍历时被视为undefined)
    console.log(item);
}

1.js的例子中可以看出:

  • new Array(5).fill(undefined)可以用来将"空洞"数组填充为实际的undefined值,使其成为一个"稠密数组"(Dense Array)。
  • JavaScript数组是动态的,当你给一个超出当前长度的索引赋值时,数组会自动扩容,并在中间产生新的"空洞"。
  • for...infor...of在遍历"空洞"数组时的行为不同。for...in会跳过"空洞",而for...of则会将"空洞"视为undefined进行遍历。

总结: JavaScript数组的"空洞"特性是其内部优化的一种体现。理解new Array()和数组字面量[]在创建数组时的差异,以及for...infor...of在处理"空洞"时的不同行为,对于避免潜在的bug和编写高效的代码至关重要。在大多数情况下,推荐使用数组字面量[]来创建数组,或者使用fill()方法来确保数组是"稠密"的。

2. Array的静态方法:创建与转换的利器

Array构造函数除了可以用来创建数组实例外,还提供了一些非常有用的静态方法,它们可以直接通过Array对象调用,用于创建新的数组实例或对现有数据进行转换。其中最常用的就是Array.of()Array.from()

2.1 Array.of():告别new Array()的歧义

Array.of()方法用于创建一个新的Array实例,它接收可变数量的参数,并将这些参数作为新数组的元素。它的主要目的是解决new Array()构造函数在处理单个数字参数时的歧义。

new Array()的歧义:

  • new Array(1, 2, 3):创建一个包含元素1, 2, 3的数组。
  • new Array(3):创建一个长度为3的"空洞"数组。

这种行为在某些情况下可能会导致混淆。Array.of()的出现,就是为了提供一个更清晰、更一致的数组创建方式。

javascript 复制代码
// 2.js
console.log(Array.of(1,2,3));   // 输出: [1, 2, 3]
console.log(Array.of(7));      // 输出: [7] - 解决了new Array(7)的歧义
console.log(Array.of(undefined)); // 输出: [undefined]

底层思考:Array.of()的实现

Array.of()的内部实现相对简单,它只是将传入的所有参数按顺序作为新数组的元素。它不会像new Array(length)那样创建"空洞",也不会因为参数数量而改变行为。这使得Array.of()成为一个更可靠、更直观的数组创建方法,尤其是在需要根据一组已知值创建数组时。

2.2 Array.from():强大的转换与映射工具

Array.from()方法允许你从一个类数组对象(拥有length属性和可索引元素的对象)或可迭代对象(如SetMapStringNodeList等)创建一个新的、浅拷贝的Array实例。它还可以接收一个可选的映射函数,在创建数组的同时对每个元素进行处理。

Array.from()的语法:

csharp 复制代码
Array.from(arrayLike, mapFn, thisArg)
  • arrayLike:要转换成数组的类数组对象或可迭代对象。
  • mapFn(可选):一个映射函数,新数组的每个元素都会经过该函数的处理。
  • thisArg(可选):执行mapFnthis的值。

应用场景:

  • 将类数组对象转换为数组: 常见的类数组对象包括arguments对象、NodeList(DOM查询结果)等。Array.from()可以方便地将它们转换为真正的数组,从而可以使用数组的所有方法。

    javascript 复制代码
    function sumArguments() {
      return Array.from(arguments).reduce((acc, val) => acc + val, 0);
    }
    console.log(sumArguments(1, 2, 3)); // 输出: 6
    ​
    // 将NodeList转换为数组
    // const divs = Array.from(document.querySelectorAll("div"));
  • 将可迭代对象转换为数组:

    lua 复制代码
    const mySet = new Set([1, 2, 3]);
    console.log(Array.from(mySet)); // 输出: [1, 2, 3]
    ​
    const myMap = new Map([["a", 1], ["b", 2]]);
    console.log(Array.from(myMap)); // 输出: [["a", 1], ["b", 2]]
    ​
    console.log(Array.from("hello")); // 输出: ["h", "e", "l", "l", "o"]
  • 结合映射函数进行数据转换: 这是Array.from()最强大的功能之一。你可以在创建数组的同时,对每个元素进行复杂的计算或转换。

    javascript 复制代码
    // 2.js
    // 创建一个包含26个大写字母的数组
    console.log(Array.from(new Array(26),
    (val,index)=>String.fromCodePoint(65 + index)));
    // 输出: ["A", "B", "C", ..., "Z"]
    ​
    // 模拟range函数
    console.log(Array.from({length: 5}, (v, i) => i + 1)); // 输出: [1, 2, 3, 4, 5]

底层思考:Array.from()的转换机制

Array.from()的底层机制是遍历传入的arrayLike或可迭代对象,并将其每个元素依次添加到新创建的数组中。如果提供了mapFn,则在添加之前,会对每个元素应用该映射函数。对于类数组对象,它会检查length属性和索引属性。对于可迭代对象,它会调用其Symbol.iterator方法来获取迭代器,然后遍历迭代器返回的值。

总结: Array.of()Array.from()是现代JavaScript中创建和转换数组的强大工具。Array.of()解决了new Array()的歧义,提供了更清晰的数组创建方式。而Array.from()则以其强大的转换和映射能力,使得处理类数组对象和可迭代对象变得异常便捷,极大地提升了JavaScript在数据处理方面的灵活性和表达力。

3. 数组遍历与hasOwnProperty:深入理解属性查找机制

在JavaScript中,遍历数组是常见的操作。我们通常会使用for循环、forEachmapfilter等方法。然而,当涉及到for...in循环和原型链时,对数组的遍历就需要更加深入的理解。hasOwnProperty方法在此时显得尤为重要,它能帮助我们区分对象自身的属性和继承自原型链的属性。

3.1 for...in循环:遍历"可枚举属性"的陷阱

for...in循环主要用于遍历对象的所有可枚举属性,包括其原型链上的属性。对于数组而言,for...in会遍历其所有可枚举的数字索引属性,以及可能存在的非数字属性。然而,它会跳过那些"空洞"(empty slots)和不可枚举的属性。

javascript 复制代码
// 3.js
const arr = new Array(5); // [empty × 5]
console.log(arr[0]); // undefined
​
// 即使给一个超出当前长度的索引赋值,JavaScript也会自动扩容
arr[8] = 10; // arr: [empty × 5, empty × 2, 10]
​
for (let key in arr) {
    // for...in 只会遍历到索引为8的属性,因为其他是"空洞"
    console.log(key, arr[key]); // 输出: 8 10
}
​
// 模拟原型链继承
let obj = {
    name: '葫芦娃'
}
​
let obj2 = {
    skill: '喷火'
}
obj.__proto__ = obj2; // 不推荐直接修改__proto__,这里仅为演示
​
console.log(obj.skill); // 输出: 喷火
​
for (let key in obj) {
    // for...in 会遍历到name和skill
    console.log(key, obj[key]);
}

从上面的例子可以看出,for...in在遍历arr时,只会输出8 10,因为它跳过了所有的"empty"槽位。而在遍历obj时,它不仅遍历了obj自身的name属性,还遍历了继承自obj2原型链上的skill属性。

底层思考:for...in与原型链

for...in循环的底层机制是遍历对象及其原型链上所有可枚举的属性。这意味着,如果你不加区分地使用for...in来遍历数组或对象,可能会意外地访问到原型链上的属性,这在某些情况下可能导致错误或不符合预期的行为。

3.2 hasOwnProperty():区分自身属性与继承属性

为了避免遍历到原型链上的属性,我们通常会结合Object.prototype.hasOwnProperty()方法来使用for...in循环。hasOwnProperty()方法会返回一个布尔值,指示对象自身是否包含(而不是继承)指定的属性。

javascript 复制代码
// 3.js (续)
console.log(obj.hasOwnProperty('name'));  // 输出: true (name是obj自身的属性)
console.log(obj.hasOwnProperty('skill')); // 输出: false (skill是继承的属性)

for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
        console.log(key, obj[key]); // 只会输出name 葫芦娃
    }
}

通过hasOwnProperty()的判断,我们可以确保只处理对象自身的属性,这在处理来自不同来源的数据或避免原型污染时非常有用。

3.3 for...of循环:遍历可迭代对象的"值"

相比于for...infor...of循环是ES6引入的,专门用于遍历可迭代对象(包括数组、字符串、Map、Set等)的"值"。它不会遍历原型链上的属性,也不会跳过数组中的undefined或"空洞"(在遍历时会将"空洞"视为undefined)。

javascript 复制代码
// 1.js (续)
const arr2 = new Array(5).fill(undefined);
arr2[8]=undefined;

for(let item of arr2){
    // for...of 会遍历所有可迭代的值,包括undefined和"空洞"槽位(在遍历时被视为undefined)
    console.log(item);
}
// 输出: undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined

1.js的例子中可以看出,for...of在遍历arr2时,即使中间有"空洞",也会将其视为undefined进行遍历,并最终输出9个undefined。这使得for...of成为遍历数组和可迭代对象的首选方式,因为它更直观地关注"值"本身,并且不会受到原型链的影响。

总结: 理解for...infor...of在遍历数组时的不同行为,以及hasOwnProperty()在区分自身属性和继承属性时的作用,是掌握JavaScript对象和数组底层机制的关键。在大多数情况下,推荐使用for...of来遍历数组,因为它更安全、更直观,并且专注于遍历"值"。只有在需要遍历对象的所有可枚举属性(包括原型链上的)时,才考虑使用for...in,并务必结合hasOwnProperty()进行判断。

4. 数组的"类数组"特性与转换

JavaScript中存在一些"类数组对象"(Array-like Objects),它们拥有length属性和可索引元素,但它们并不是真正的数组实例,因此不能直接使用数组的内置方法(如forEachmappush等)。常见的类数组对象包括arguments对象(函数内部的参数集合)和DOM集合(如document.querySelectorAll()返回的NodeList)。

4.1 类数组对象的特点

  • 拥有length属性: 表示元素的数量。
  • 可索引元素: 可以通过数字索引(obj[0]obj[1]等)访问元素。
  • 没有数组的内置方法: 这是它们与真正数组的主要区别。
xml 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>类数组对象</title>
</head>
<body>
    <div class="item">Item 1</div>
    <div class="item">Item 2</div>
    <script>
        function showArgs() {
            console.log(arguments); // arguments对象是一个类数组对象
            console.log(arguments.length); // 有length属性
            console.log(arguments[0]); // 可通过索引访问
            // arguments.forEach(arg => console.log(arg)); // 报错:arguments.forEach is not a function
        }
        showArgs('a', 'b', 'c');

        const items = document.querySelectorAll('.item');
        console.log(items); // NodeList是一个类数组对象
        console.log(items.length); // 有length属性
        console.log(items[0]); // 可通过索引访问
        // items.forEach(item => console.log(item)); // 在旧浏览器中可能报错,新浏览器NodeList已支持forEach
    </script>
</body>
</html>

4.2 类数组对象转换为真数组

为了能够使用数组的强大内置方法,我们通常需要将类数组对象转换为真正的数组。有几种常见的方法可以实现这一转换:

  • Array.from() 这是ES6推荐的转换方式,简洁且功能强大。

    javascript 复制代码
    function sumArgs() {
        return Array.from(arguments).reduce((acc, val) => acc + val, 0);
    }
    console.log(sumArgs(1, 2, 3)); // 输出: 6
    
    const itemsArray = Array.from(document.querySelectorAll('.item'));
    itemsArray.forEach(item => console.log(item.textContent));
  • 展开运算符(Spread Operator)... 同样是ES6引入的语法,非常简洁。

    javascript 复制代码
    function sumArgsSpread() {
        return [...arguments].reduce((acc, val) => acc + val, 0);
    }
    console.log(sumArgsSpread(4, 5, 6)); // 输出: 15
    
    const itemsArraySpread = [...document.querySelectorAll('.item')];
    itemsArraySpread.forEach(item => console.log(item.textContent));
  • Array.prototype.slice.call() 传统的方法,利用callapply改变slice方法的this指向。

    javascript 复制代码
    function sumArgsSlice() {
        return Array.prototype.slice.call(arguments).reduce((acc, val) => acc + val, 0);
    }
    console.log(sumArgsSlice(7, 8, 9)); // 输出: 24
    
    const itemsArraySlice = Array.prototype.slice.call(document.querySelectorAll('.item'));
    itemsArraySlice.forEach(item => console.log(item.textContent));

底层思考:转换的本质

这些转换方法的本质都是遍历类数组对象,然后将每个元素依次添加到新创建的真数组中。Array.from()和展开运算符是更现代、更推荐的方式,它们在内部处理了迭代逻辑,使得代码更简洁易读。slice.call()则是利用了slice方法在没有参数时会返回一个数组的浅拷贝的特性,并通过callthis指向类数组对象,从而实现转换。

总结: 理解类数组对象的特性以及如何将其转换为真数组,是JavaScript开发中的一项基本技能。Array.from()和展开运算符是现代JavaScript中进行这种转换的首选方式,它们提供了简洁高效的解决方案,让你能够充分利用数组的强大功能。

5. 数组的"高级"操作与方法

JavaScript数组提供了丰富的内置方法,用于执行各种操作,如添加、删除、查找、排序、迭代等。掌握这些方法能够极大地提高开发效率。这里我们简要回顾一些常用的高级操作。

5.1 常用迭代方法:forEach, map, filter, reduce

这些方法都是ES5引入的,它们提供了更函数式、更声明式的方式来处理数组。

  • forEach() 遍历数组的每个元素,并对每个元素执行提供的回调函数。它没有返回值,主要用于执行副作用。

    ini 复制代码
    const numbers = [1, 2, 3];
    numbers.forEach(num => console.log(num * 2)); // 输出: 2, 4, 6
  • map() 遍历数组的每个元素,并对每个元素执行提供的回调函数,然后将回调函数的返回值组成一个新的数组返回。它不会改变原数组。

    dart 复制代码
    const doubledNumbers = numbers.map(num => num * 2);
    console.log(doubledNumbers); // 输出: [2, 4, 6]
  • filter() 遍历数组的每个元素,并对每个元素执行提供的回调函数。如果回调函数返回true,则将当前元素包含在新数组中。它不会改变原数组。

    dart 复制代码
    const evenNumbers = numbers.filter(num => num % 2 === 0);
    console.log(evenNumbers); // 输出: [2]
  • reduce() 对数组中的所有元素执行一个由您提供的reducer函数(从左到右),将其结果汇总为单个返回值。它常用于计算总和、扁平化数组、分组数据等。

    javascript 复制代码
    const sum = numbers.reduce((acc, num) => acc + num, 0);
    console.log(sum); // 输出: 6

底层思考:这些方法的共同点与差异

这些迭代方法都接收一个回调函数作为参数,并在内部对数组进行遍历。它们的主要差异在于回调函数的返回值如何被处理,以及它们是否会返回一个新的数组。理解这些方法的用途和返回值,能够帮助你选择最适合特定场景的方法,从而编写出更简洁、更具表达力的代码。

5.2 查找方法:find, findIndex, indexOf, includes

  • find() 返回数组中满足提供的测试函数的第一个元素的值。否则返回undefined
  • findIndex() 返回数组中满足提供的测试函数的第一个元素的索引。否则返回-1
  • indexOf() 返回在数组中可以找到一个给定元素的第一个索引,如果不存在,则返回-1。它使用严格相等(===)进行比较。
  • includes() 判断一个数组是否包含一个指定的值,根据情况,如果包含则返回true,否则返回false

5.3 改变原数组的方法:push, pop, shift, unshift, splice, sort, reverse

这些方法会直接修改原数组,因此在使用时需要特别注意,以免产生副作用。

  • push():在数组末尾添加一个或多个元素,并返回新数组的长度。
  • pop():删除数组的最后一个元素,并返回该元素。
  • shift():删除数组的第一个元素,并返回该元素。
  • unshift():在数组开头添加一个或多个元素,并返回新数组的长度。
  • splice():通过删除或替换现有元素或者原地添加新的元素来修改数组,并以数组形式返回被修改的内容。这个方法非常强大和灵活。
  • sort():对数组的元素进行原地排序,并返回数组。默认排序顺序是在将元素转换为字符串,然后比较它们的UTF-16代码单元值序列时构建的。
  • reverse():颠倒数组中元素的顺序。第一个元素变为最后一个,最后一个元素变为第一个。它会改变原数组。

总结: JavaScript数组提供了丰富而强大的内置方法,它们涵盖了从简单的增删改查到复杂的迭代和转换。熟练掌握这些方法,并理解它们是否会改变原数组,是编写高效、健壮JavaScript代码的关键。在实际开发中,合理利用这些方法,能够大大提高开发效率和代码质量。

结语:驾驭数组,掌控数据世界

通过本文的深入探讨,我们全面剖析了JavaScript数组的底层机制和高级特性。我们从new Array()创建的"空洞"数组开始,理解了V8引擎对数组的优化设计;学习了Array.of()如何解决创建数组的歧义,以及Array.from()如何将类数组和可迭代对象转换为真正的数组,并进行强大的映射转换。

我们还深入理解了for...infor...of在遍历数组时的不同行为,以及hasOwnProperty()在区分自身属性和继承属性时的重要作用。最后,我们回顾了JavaScript数组丰富的内置方法,包括迭代、查找和改变原数组的方法,强调了理解它们底层工作原理和副作用的重要性。

掌握这些底层知识,你将不再仅仅是数组API的"使用者",更是能够洞悉数据结构本质、灵活应对各种数据处理场景的"驾驭者"。在构建高性能、可维护的JavaScript应用时,这些知识将成为你不可或缺的"内功"。

希望本文能为你提供一份宝贵的"武功秘籍",助你在前端开发的道路上越走越远,成为一名真正的数据处理大师!

相关推荐
胡gh29 分钟前
一篇文章,带你搞懂大厂如何考察你对Array的理解
javascript·后端·面试
胡gh38 分钟前
this 与 bind:JavaScript 中的“归属感”难题
javascript·设计模式·程序员
OEC小胖胖1 小时前
前端性能优化“核武器”:新一代图片格式(AVIF/WebP)与自动化优化流程实战
前端·javascript·性能优化·自动化·web
爬点儿啥1 小时前
[JS逆向] 微信小程序逆向工程实战
开发语言·javascript·爬虫·微信小程序·逆向
pe7er1 小时前
websocket、sse前端本地mock联调利器
前端·javascript·后端
OEC小胖胖2 小时前
告别项目混乱:基于 pnpm + Turborepo 的现代化 Monorepo 工程化最佳实践
前端·javascript·前端框架·web
Mintopia2 小时前
🌌 探索虚空中的结构:光线步进与 Marching Cubes 的奇幻冒险
前端·javascript·计算机图形学
Mintopia2 小时前
Three.js 光照系统进阶指南 —— 打造光明的舞台
前端·javascript·three.js
天天打码3 小时前
Esbuild-新一代极速前端构建打包工具
前端·javascript·前端框架
爱分享的程序员3 小时前
前端面试专栏-工程化:26.性能优化方案(加载优化、渲染优化)
前端·javascript·node.js