你尚未登录,仅允许查看本站部分内容。请登录使用邀请码注册
小胡子哥

你所不知道的JavaScript数组 14个回复 专栏 @ Javascript

小胡子哥 发布于 3 年前

以前写过的一些文章,转载于此,交流学习。
作者:小胡子哥 ( Barret Lee )

相信每一个 javascript 学习者,都会去了解 JS 的各种基本数据类型,数组就是数据的组合,这是一个很基本也十分简单的概念,他的内容没多少,学好它也不是件难事情。但是本文着重要介绍的并不是我们往常看到的 Array,而是 ArrayBuffer。

我写的很多东西都是因为要完成某些特定的功能而刻意总结的,可以算是备忘,本文也是如此!前段时间一直在研究 Web Audio API 以及语音通信相关的知识,内容侧重于音频流在 AudioContext 各个节点之间的流动情况,而现在要摸清楚音频到流底是个什么样的数据格式,所以对 ArrayBuffer 的研究就显得格外重要了。

Array 在内存中的堆栈模型

Array 的获取

Javascript 中如何产生 Array:

[element0, element1, ..., elementN]
new Array(element0, element1, ..., elementN)
new Array(arrayLength)

直接定义,或者通过构造函数创建一个 Array,当然也可以使用其他的手段:

"array".split("");
"array".match(/a|r/g);

等等,方式有很多。但是 Array 内部是个什么样的结构,恐怕很多人还不是很清楚。

堆栈模型

在数组中我们可以放很多不同数据类型的数据,如:

var arr = [21, "李靖", new Date(), function(){}, , null];

上面这个数组中一次放入了 数字、字符串、对象、函数、undefined 和 null,对于上面的数据接口我们可以具象的描述下:

    栈
+---------+                  堆
|   21    |         +-------------------+
+---------+         |                   |
|  "李靖" |         |                   |
+---------+         |  +--------+       |
| [refer] |----------->| Object |       |
+---------+         |  +--------+       |
| [refer] |----------------->+--------+ |
+---------+         |        |function| |
|undefined|         |        +--------+ |
+---------+         |                   |
|   null  |         +-------------------+
+---------+         Created By Barret Lee

JavaScript 的数据类型分为两种,一种是值类型,一种是引用类型,常见的引用类型有 Object 和 Array,数组的储存模型中,如果是诸如 Number、String 之类的值类型数据会被直接压入栈中,而引用类型只会压入对该值的一个索引,用 C 语言的概念来解释就是只保存了数据的指针,这些数据是储存在堆中的某块区间中。栈堆并不是独立的,栈也可以在堆中存放。

好了,对 Array 的说明就到这里,下面具体说说 ArrayBuffer 的相关知识。

ArrayBuffer

web 是个啥玩意儿,web 要讨论的最基本问题是什么?我觉得有两点,一个是数据,一个是数据传输,至于数据的展示,纷繁复杂,这个应该是 web 上层的东西。而本文要讨论的 ArrayBuffer 就是最基础的数据类型,甚至不能称之为数据类型,它是一个数据容易,需要通过其他方式来读写。

官方点的定义:

The ArrayBuffer is a data type that is used to represent a generic, fixed-length binary data buffer. You can't directly manipulate the contents of an ArrayBuffer; instead, you create an ArrayBufferView object which represents the buffer in a specific format, and use that to read and write the contents of the buffer.
表示二进制数据的原始缓冲区,该缓冲区用于存储各种类型化数组的数据。 无法直接读取或写入 ArrayBuffer,但可根据需要将其传递到类型化数组或 DataView 对象 来解释原始缓冲区。

他是一个二进制数据的原始缓冲区,虽然 JavaScript 是弱类型语言,但是他本身是对数据的类型和大小都有限制的,我们需要通过某种数据结构将缓冲区的内容有序的读取出来(写进去)。

原始缓冲区的创建

通过 ArrayBuffer 这个构造函数可以创建一个原始缓冲区:

var buffer  = new ArrayBuffer(30);

从 chrome 控制台可以看到:

arrayBuffer

buffer 实例拥有一个 byteLength 的属性,用于获取 buffer 的 size,一个只有 IE11+ 以及 ios6+ 支持的 slice 方法,用于对 buffer 长度进行截取操作。

ArrayBuffer slice(
unsigned long begin
unsigned long end Optional
);

可以测试这个 DEMO:

var buffer = new ArrayBuffer(12);
var x = new Int32Array(buffer);
x[1] = 1234;
var slice = buffer.slice(4);
var y = new Int32Array(slice);
console.log(x[1]); 
console.log(y[0]);
x[1] = 6789;
console.log(x[1]); 
console.log(y[0]);

数据化数组

类型化数组类型表示可编制索引和操纵的 ArrayBuffer 对象 的各种视图。 所有数组类型的长度均固定。

名称 大小(以字节为单位) 描述
Int8Array 1 8 位二补码有符号整数
Uint8Array 1 8 位无符号整数
Int16Array 2 16 位二补码有符号整数
Uint16Array 2 16 位无符号整数
Int32Array 4 32 位二补码有符号整数
Uint32Array 4 32 位无符号整数
Float32Array 4 32 位 IEEE 浮点数
Float64Array 8 64 位 IEEE 浮点数

Int 就是整型,Uint 为无符号整形,Float 为浮点型,这些是 C 语言中的基本概念,我就不具体解释了。由于这些视图化结构都是大同小异,本文只对 Float32Array 类型作说明,读者可以举一反三。

Float32Array 跟 Array 是十分类似的,只不过他每一个元素都是都是一个 32位(4字节) 的浮点型数据。Float32Array 一旦创建其大小不能再修改。

我们可以直接创建一个 Float32Array:

var x = new Float32Array(2);
x[0] = 17;
console.log(x[0]); // 17
console.log(x[1]); // 0
console.log(x.length); // 2

需要有这么一个概念,他依然是一个数组,只不过该数组中的每个元素都是 Float 32 位的数据类型,再如:

var x = new Float32Array([17, -45.3]);
console.log(x[0]);  // 17
console.log(x[1]);  // -45.29999923706055
console.log(x.length); // 2

我们把一个数组的值直接赋给了 x 这个 Float32Array 对象,那么在储存之前会将它转换成一个 32位浮点数。

由于该类数组的每个元素都是同一类型,所以在堆栈模型中,他们全部会被压入到栈之中,因此数据化数组都是值类型,他并不是引用类型!这个要引起注意,从下面的例子中也可以反映出来:

var x = new Float32Array([17, -45.3]);
var y = new Float32Array(x);
console.log(x[0]); // 17
console.log(x[1]); //-45.29999923706055
console.log(x.length); // 2
x[0] = -2;
console.log(y[0]); // 17, y的值没变

将 x 的值复制给 y,修改 x[0], y[0] 并没有变化。

除了上面的方式,我们还可以通过其他方式来创建一个数据化数组:

var buffer = new ArrayBuffer(12);
var x = new Float32Array(buffer, 0, 2);
var y = new Float32Array(buffer, 4, 1);
x[1] = 7;
console.log(y[0]); // 7

解释下这里为什么返回 7.

       ArrayBuffer(12)
+-+-+-+-+-+-+-+-+-+-+-+-+-+
|0|1|2|3|4|5|6|7|8| | | | |
+-+-+-+-+-+-+-+-+-+-+-+-+-+
\                /           
  x (Float32Array)
  offset:0
  byteLength:4
  length:2
       ArrayBuffer(12)
+-+-+-+-+-+-+-+-+-+-+-+-+-+
|0|1|2|3|4|5|6|7|8| | | | |
+-+-+-+-+-+-+-+-+-+-+-+-+-+
        \         /           
             y

      Created By Barret Lee

看了上面的图解还有疑问么?我觉得我不用继续解释了。可以把 ArrayBuffer 的单位看成 1,而 Float32Array 的单位是 4.

DataView对象

DataView 对象对数据的操作更加细致,不过我觉得没啥意思,上面提到的各种数据化数组已经可以基本满足应用了,所以这里就一笔带过,一个简单的示例:

var buffer = new ArrayBuffer(12);
var x = new DataView(buffer, 0);
x.setInt8(0, 22);
x.setFloat32(1, Math.PI);
console.log(x.getInt8(0)); // 22
console.log(x.getFloat32(1)); // 3.1415927410125732

如果感兴趣,可以移步http://www.javascripture.com/DataView,作详细了解。

XHR2 中的 ArrayBuffer

ArrayBuffer 的应用特别广泛,无论是 WebSocket、WebAudio 还是 Ajax等等,前端方面只要是处理大数据或者想提高数据处理性能,那一定是少不了 ArrayBuffer 。

XHR2 并不是什么新东西,可能你用到了相关的特性,却不知这就是 XHR2 的内容。最主要的一个东西就是 xhr.responseType,他的作用是设置响应的数据格式,可选参数有:"text"、"arraybuffer"、"blob"或"document"。请注意,设置(或忽略)xhr.responseType = '' 会默认将响应设为"text"。这里存在一个这样的对应关系:

请求            响应
text            DOMString
arraybuffer     ArrayBuffer
blob            Blob
document        Document

举个栗子:

var xhr = new XMLHttpRequest();
xhr.open('GET', '/path/to/image.png', true);
xhr.responseType = 'arraybuffer';

xhr.onload = function(e) {
// this.response == uInt8Array.buffer
var uInt8Array = new Uint8Array(this.response); 
};

xhr.send();

我们在 xhr.responseType 中设置了属性为 arraybuffer,那么在拿到的数据中就可以用数据化数组来接受啦!

小结

本文主要介绍了 Array 在堆栈模型中的存放方式,也详细描述了 ArrayBuffer 这个原始缓冲区的二进制数据类型,在 web 开发中,数据以及数据的储存是一个重要的部分,希望引起注意!

本文叙述上可能存在错误,请多多斧正!

参考资料

  • jimnox

    由于该类数组的每个元素都是同一类型,所以在堆栈模型中,他们全部会被压入到栈之中,因此数据化数组都是值类型,他并不是引用类型!这个要引起注意,从下面的例子中也可以反映出来:
    ...
    将 x 的值复制给 y,修改 x[0], y[0] 并没有变化。

    这个是因为TypedArray以另一个TypedArray为参数的构造函数重载new TypedArray(another_typedarray),和它以数组为参数的构造函数类似,它会开辟新的ArrayBuffer来存,这个和是值类型还是引用类型没什么关系,把这个重载当做“拷贝构造函数”看待就行了。

    TypedArray是一个View,它的目的是为ArrayBuffer提供以当前平台的字节序方式的二进制数字的访问,真正的数据,是ArrayBuffer。MDN上的原话叫做“Multiple views on the same data”。

    实际使用的时候不管是TypedArray还是DataView用起来都有点头疼

    上面说到new TypedArray(typedarray)这个构造函数会开辟新的ArrayBuffer,而不是在原有ArrayBuffer上建立一个新View,这个很不理解设计,因为本身TypedArray设计理念就是“Multiple views on the same data”,现在你就开辟新数据。但就当它本身就是这么设计的好了,这个只是表示不爽,不批判。

    如果需要在当前的TypedArray范围内重新建立一个其他类型的View,需要借助这个TypedArray背后的ArrayBuffer

    var arraybuffer = i8array.buffer;
    var i32array = new Int32Array(arraybuffer, u8array.byteOffset, u8array.byteLength);
    

    而TypedArray的(buffer [, byteOffset [, length]])这个重载里面,要求byteOffset是对应数字类型的字节数的倍数,也就是说这要求原来的数据是对齐好的。
    如果数据本身是C结构体,就还好说,编译的时候就对齐了,如果是紧缩过的数据,就跪了,连TypedArray都构造不起来。

    这种时候只有用DataView了,它的所有get/set方法都不需要对齐。
    DataView使用也有不便,构造DataView只能在ArrayBuffer上,所以还是需要取出原始ArrayBuffer,这个不是问题,上面那个例子都做到了。
    第二个,DataView总是在完整的ArrayBuffer上工作,而非像TypedArray一样可以为ArrayBuffer提供一个“局部View”,所以如果为一个TypedArray构建一个DataView,调用它的get/set方法的时候必须要考虑到View自身在这条ArrayBuffer上的偏移量,很不方便(这个可以通过继承DataView自己实现一个封装)。
    第三个,最头疼的一点,就是TypedArray是使用平台自身的字节序,且无法干预。而DataView竟然是默认使用大端序,而且字节序参数居然是放在get/set函数里的,没有一个构造函数去一次性选择,坑爹么(当然这还是可以通过继承一个类自己封装)。

    反正就是上面说的这些东西,看MDN的话感觉还不错啊,设计理念挺不错的~不实际用过的话不能体会设计者在设计这堆API的时候是多么不接地气了。

    #1
  • 小胡子哥

    学习了,还有这么一套底层的实现,看来仅仅从数据层面是看不到问题实质的。thx.

    #2
  • markate

    学习了

    #3
  • 巫书轶

    最近一直在 研究 js 各种 array 晕头转向的

    #4
  • webxy

    json去重问题

    问题:当一个json中键上有数字的时候,内存中是如何存储的?

    比如定义一个

         var j ={a:1,b:2,c:0,d:1,f:'abc',0:'abc'}
    

    然后在chrome控制台上打印j, {0: "abc"} 竟然位置改变了。。。求解释。

        j
        Object {0: "abc", a: 1, b: 2, c: 0, d: 1, f: "abc"}
    

    下面是真实环境中遇到的问题。。。

    function uniqueJson(j){
      var i,n,newJson={};
      for( i in j){
          for(n in newJson){
             if(newJson[n] == j[i]){
               delete newJson[n];
             }
          }
          newJson[i] = j[i];
      }
      return newJson;
    }
    uniqueJson({a:1,b:2,c:0,d:1,f:'abc',0:'abc'})
    //Object {b: 2, c: 0, d: 1, f: "abc"}
    
    #5
  • webxy
    function uniqueJson(j){
      var i,n,newJson={};
      for( i in j){
    
          for(n in newJson){
             if(newJson[n] == j[i]){
               delete newJson[n];
             }
          }
          newJson[i] = j[i];
      }
      return newJson;
    }
    uniqueJson({a:1,b:2,c:0,d:1,f:'abc',0:'abc'})
    //Object {b: 2, c: 0, d: 1, f: "abc"}
    
    #6
  • igin

    @webxy 不要在意,那只不过是浏览器把keys进行了排序而已。key为0或正整数,就前置并以数值大小排列。(这样做是为了展示更美观易读)。

    但是,对于object来说,keys的顺序是无关紧要的,obj={a:1,b:2} 和 obj={b:2,a:1} 是语法等价的,程序逻辑不应当对keys顺序有任何依赖。对keys顺序有依赖的情况,那就应该改用常规数组。

    #7
  • igin

    @jimnox 的理解有误。

    类型数组并不是一个DataView,存放的并不是裸二进制数据,而是高级的定长的类型值了,因此是字节序无关的,字节序只对于底层DataView有意义。请想象一下独立使用类型数组的时候,字节序完全毫无意义。

    至于说DataView竟然默认使用大尾序(大尾比大端更准确,端不能明确表明方向,首端、尾端都是端),也是误解。DataView本身也不需要关心字节序的,只是提供一个裸二进制数据的窗口,只有当你需要从窗口取值(get)或向窗口赋值(set)的时候,指定字节序才有意义,当前的实现是默认为大尾,如果要小尾,需要明确指定,这样好处是能够保持一致性,否则不带字节序参数的set/get的行为就不统一了。

    综上,目前的设计是合理充分的。设计者都是超级大牛,不至于那么弱智,批判之前多理解清楚。

    #8
  • igin

    @小胡子哥 这个不知道你从哪里粘贴来的,格式太乱了,建议还是编辑一下格式吧。

    这篇文章,总体而言,既不够深刻完整,又不够通俗易懂,建议读者直接看MDN,绝对更好。

    #9
  • jimnox

    @igin TypedArray并不是字节序无关的,而是字节序相关的。

    要证明这个非常容易,可以使用如下代码来检验

    var buf = new ArrayBuffer(2);
    var i16 = new Int16Array(buf);
    var view = new DataView(buf);
    view.setInt16(0, 256, true);
    console.log(i16[0]); // 256
    
    view.setInt16(0, 256, false);
    console.log(i16[0]); // 1
    

    这说明DataView可以精确的控制写入的字节序,而TypedArray则有固定的字节序——通过这个方法也可以用来检测机器是big-endian还是little-endian——只需要用DataView明确写入的字节序,而用TypedArray再读出来看看对不对就行了。

    当然上述代码我是在Intel CPU上运行的,所以TypedArray默认使用的是little-endian,我没有别的架构的机器所以暂时没法实验。
    但在MDN里有如下描述

    Typed array views are in the native byte-order (see Endianness) of your platform. With a DataView you are able to control the byte-order. It is big-endian by default and can be set to little-endian in the getter/setter methods.

    可以明确的知道TypedArray使用的是平台自身的字节序,而DataView使用人为指定的字节序(默认big-endian)。

    于是你所说的“请想象一下独立使用类型数组的时候,字节序完全毫无意义。”我不敢苟同,例如如果在little-endian机器上读一段big-endian的raw bytes进来,直接用TypedArray来取数是会错的。这种问题和在C里取raw bytes是一样的。

    不严谨的说,如果不需要考虑跨平台(这里不是指的操作系统,而是指硬件平台),那么直接用TypedArray就可以了,因为我们常用的x86是little-endian,ARM据说是可配的(而我见过的也都是LE),即使数据要以raw bytes方式传输,也几乎不会遇到问题。如果要考虑更多硬件平台,直接用TypedArray可能会踩坑。

    另外,TypedArray的确只是一个View,因为它并不是raw data。

    它所有的操作都是执行到它背后的ArrayBuffer身上的,TypedArray只是提供了一个固定窗口长度、固定字节序的操作窗口。

    这相当于C里面本身有一个byte*,然后把它转成int*来操作一样。

    #10
  • exolution

    最喜欢这种评论中的论战了~能学到不少知识 ^_^

    #11
  • 绯村月

    @jimnox jim棒棒的

    #12
  • arck

    赞!回去自己再研究一下

    #13
  • steelli

    @jimnox 的理解相当深刻,赞!!!

    #14
登录后回复,如无账号,请使用邀请码注册