lihongxun945 / myblog

言川的博客-前端工程师的笔记
1.41k stars 128 forks source link

你不知道的JS数组 #53

Open lihongxun945 opened 2 years ago

lihongxun945 commented 2 years ago

数组不是数组

为什么数组不是数组呢?因为JS中一切皆对象,所以数组其实是对象。当然这只是个段子。这里说的数组不是数组的含义是:JS数组在内存中的存储方式不同于我们在数据结构课程上学习的数组。 数据结构中的数组是指存储在一个连续的内存空间中的具有相同类型的数据集合

首先我们可以看看TC39中对数组的定义:

Arrays are exotic objects that give special treatment to a certain class of property names 数组是一个对一类属性做了特殊处理的奇异对象 https://tc39.es/ecma262/#sec-array-objects 简化一下,可以理解为“数组是对象”,不过这个对象是“奇异对象”。这也比较符合我们的直观理解,因为我们可以给数组存放不同类型的数据,也可以随意添加属性,这些显然是一个对象的性质,比如:

const a = [1, 'x', {a:1}]; // 不同类型
a.foo = 'foo'; // 随意添加属性

数组和普通对象最大的区别是 length属性,这个属性有两个特点:

  1. 属性不是一个静态值,而是一个 getter返回的值,返回的是比所有数字类型的key都大1的一个值。所以严格来说不是修改数组的同时改了length,而是length每次都是根据最大的index动态计算的结果。 a. The "length" property of an Array instance is a data property whose value is always numerically greater than the name of every configurable own property whose name is an array index.
  2. length属性有副作用,如果修改 length值,会使数组元素也发生对应的增减变化。

    内存中的数组

    虽然是这么定义的,那么数组在内存中到底是如何存储的呢?这个底层实现方式并不属于 ECMA 的规范定义,因此不同的引擎有不同的实现方式,我们以最熟悉的 V8 为例。 因为数组是一个特殊对象,他的存储方式和对象是没有区别的,我们可以通过 Node 内置命令查看任意数据的内存结构,这样启动 node --allow-natives-syntax即可启动内置命令,然后我们查看一个数组的内存结构,在 Node 交互终端中执行如下代码:

    const a = ['x','y','z'];
    a.foo = 'fff'
    %DebugPrint(a)

    显示如下: pic1 为了方便看懂,我直接画了一个图: pic2

V8对对象存储做了诸多优化,不是一言两语可以讲清楚的(当然我也没这个能力)。比如elements会根据不同情况选择FixedArray或者 NumberDictionary,以获得最好的性能。为什么要有这些不同的结构呢?哈希表比数组查询速度慢一些,因为数组的index不用计算可以直接寻址,而哈希表的哈希值是需要计算的,但是哈希表却可以方便进行增删,而且可以不用按数字顺序存储。

有兴趣的同学可以把这个对象的结构打印出来,和上面的数组对比一下,看看是不是几乎一样:

const b = {0: 'x', 1: 'y', 2: 'z', 'foo': 'xxx'}

稀疏数组

JS中有一种特殊数组叫“稀疏数组”,也就是数组的 index不是连续的,存在一些空白。我们可以用如下几种方式创建稀疏数组:

a=[1,,3]; // 方式1
a=[1,2,3]; delete a[1]; // 方式2
a=[1]; a[3]=4; // 方式3
a=new Array(3); // 方式4

稀疏数组和插入了 null值的数组是不同的,比如下面这两个:

a=[1,3]
b=[1, null, 3]

pic3 打印出来可以看到稀疏数组中存储的不是 undefined或者 null,而是一个V8内部的特殊值 the_hole。 为什么专门说稀疏数组,是因为跟下面要讲的数组遍历关系很大。

遍历数组的4种姿势

先来看一段数组遍历的代码:

const a = ['a', ,'c']; // 稀疏数组
// 不可遍历属性
Object.defineProperty(a, 'foo1', {
  value: 'foo1',
  enumerable: false
});
// 可遍历属性
Object.defineProperty(a, 'foo2', {
  value: 'foo2',
  enumerable: true
});
// 原型上加属性
Array.prototype.foo3 = 'foo3';

// 下面四个输出的分别是什么?
a.forEach((i) => console.log(i));
for(let i in a) console.log(a[i]);
for(let i of a) console.log(i);
Object.keys(a).forEach(i => console.log(a[i]));

先别急着回答,为了搞清楚这四种方式的输出,我们分别看看对应方法的实现 先看第1个,forEach的定义如下: pic4 forEach是通过递增index来实现的,只会输出数字类型的值,而且是会用 HasProperty判断当前的 index是不是存在,所以这个方法不会输出的是 a[1],因为 a并没有1这个属性,那么输出的最终结果就是 a, c

第2个方法,for in在 TC39上的定义实在太长看不懂,直接看MDN的说明:

The for...in statement iterates over all enumerable properties of an object that are keyed by strings (ignoring ones keyed by Symbols), including inherited enumerable properties. for...in 语句会遍历一个对象所有的非Symbols的、可以被遍历的key,包括继承来的 那么上面的例子中,除了 foo1无法被遍历外,其他都可以输出,最终输出结果是 a, c, foo2, foo3

第3个方法,for of实际上是对 iterator进行遍历,iterator的实现如下所示,注意关键的一行伪代码是 Set index to index+1pic5 因此for of循环在遍历数组的时候,会从 0一直遍历到 length-1为止,并且并不会判断 index是否存在,因此会输出 a[1]的值,显然就是 undefined,那么最终输出结果就是 a, undefined, c

最后第4个方法, Object.keys(),这个函数的定义是这样的:

Returns an array containing the names of all of the given object's own enumerable string properties. 所以他不会输出原型上的 foo3,不会输出不能遍历的 foo1,不会输出不存在的 1,最终输出结果是 a,c,foo2

所以总结一下,这4种不同的遍历姿势,出来的结果全都不一样:

const a = ['a', ,'c']; // 稀疏数组
// 不可遍历属性
Object.defineProperty(a, 'foo1', {
  value: 'foo1',
  enumerable: false
});
// 可遍历属性
Object.defineProperty(a, 'foo2', {
  value: 'foo2',
  enumerable: true
});
// 原型上加属性
Array.prototype.foo3 = 'foo3';

// 下面四个输出的分别是什么?
a.forEach((i) => console.log(i)); // a, c
for(let i in a) console.log(a[i]); // a, c, foo2, foo3
for(let i of a) console.log(i); // a, undefined, c
Object.keys(a).forEach(i => console.log(a[i])); // a, c, foo2

参考文献

legend80s commented 11 months ago

https://gist.github.com/totherik/3a4432f26eea1224ceeb