JavaScript类型化数组(二进制数组)

简介

JavaScript类型化数组是一种类似数组的对象,并提供了一种用于访问原始二进制数据的机制。 正如你可能已经知道,Array 存储的对象能动态增多和减少,并且可以存储任何JavaScript值。JavaScript引擎会做一些内部优化,以便对数组的操作可以很快。然而,随着Web应用程序变得越来越强大,尤其一些新增加的功能例如:音频视频编辑,访问WebSockets的原始数据等,很明显有些时候如果使用JavaScript代码可以快速方便地通过类型化数组来操作原始的二进制数据将会非常有帮助。

但是,不要把类型化数组与正常数组混淆,因为在类型数组上调用 Array.isArray() 会返回false。此外,并不是所有可用于正常数组的方法都能被类型化数组所支持(如 push 和 pop)。

0、前言

对于前端程序员来说,平时很少和二进制数据打交道,所以基本上用不到ArrayBuffer,大家对它很陌生,但是在使用WebGL的时候,ArrayBuffer无处不在。浏览器通过WebGL和显卡进行通信,它们之间会发生大量的、实时的数据交互,对性能的要求特别高,它们之间的数据通信必须是二进制的才能满足性能要求,而不能是传统的文本格式。文本格式传递一个 32 位整数,两端的 JavaScript 脚本与显卡都要进行格式转化,将非常耗时。类型化数组的诞生就是为了能够让开发者通过类型化数组来操作内存,大大增强了JavaScript处理二进制数据的能力。

为了达到最大的灵活性和效率,JavaScript 类型数组(Typed Arrays)将实现拆分为缓冲和视图两部分。一个缓冲(由 ArrayBuffer 对象实现)描述的是一个数据块。缓冲没有格式可言,并且不提供机制访问其内容。为了访问在缓冲对象中包含的内存,你需要使用视图。视图提供了上下文 — 即数据类型、起始偏移量和元素数 — 将数据转换为实际有类型的数组。缓冲和视图的工作方式如下图所示:
  JavaScript类型化数组(二进制数组)

1、缓冲(ArrayBuffer)和视图

  • 首先,我们创建一个16字节固定长度的缓冲:
var buffer = new ArrayBuffer(16);
  • 上面代码生成了一段16字节的内存区域,每个字节的值默认都是0。1 字节(Byte) = 8 比特(bit),1比特就是一个二进制位(0 或 1)。上面代码生成的16个字节的内存区域,一共有 16*8 比特,每一个二进制位都是0。
  • 为了读写这个buffer,我们需要为它指定视图。视图有两种,一种是TypedArray视图,它一共包括9种类型,还有一种是DataView视图,它可以自定义复合类型。
  • 现在我们有了一段初始化为0的内存,目前还做不了什么太多操作。让我们确认一下数据的字节长度:
if (buffer.byteLength === 16) {
  console.log("Yes, it's 16 bytes.");
} else {
  console.log("Oh no, it's the wrong size!");
}
  • 在实际开始操作这个缓冲之前,需要创建一个视图。我们将创建一个视图,此视图将把缓冲内的数据格式化为一个32位的有符号整数数组:
var int32View = new Int32Array(buffer);
  • 现在我们可以像普通数组一样访问该数组中的元素:
for (var i = 0; i < int32View.length; i++) {
  int32View[i] = i * 2;
}

该代码会将数组以0, 2, 4和6填充 (一共4个4字节元素,所以总长度为16字节)。
完整代码如下

// 创建一个16字节长度的缓冲
var buffer = new ArrayBuffer(16);
// 创建一个视图,此视图把缓冲内的数据格式化为一个32位(4字节)有符号整数数组
var int32View = new Int32Array(buffer);
// 我们可以像普通数组一样访问该数组中的元素
for (var i = 0; i < int32View.length; i++) {
  int32View[i] = i * 2;
}
// 运行完之后 int32View 为[0,2,4,6]
// 创建另一个视图,此视图把缓冲内的数据格式化为一个16位(2字节)有符号整数数组
var int16View = new Int16Array(buffer);

for (var i = 0; i < int16View.length; i++) {
  console.log(int16View[i]);
}
// 打印出来的结果依次是0,0,2,0,4,0,6,0

那么,这样呢?

int16View[0] = 32;
console.log("Entry 0 in the 32-bit array is now " + int32View[0]);
// 这次的输出是"Entry 0 in the 32-bit array is now 32"。也就是,这2个数组都是同一数据的以不同格式展示出来的视图。你可以使用任何一种 view types 中的定义的视图。

以上代码看不明白,请结合图和字节数观察
JavaScript类型化数组(二进制数组)

  • 相信图片已经很直观的表达了这段代码的意思。这里应该有人会疑问,为什么2、4、6这三个数字会排在0的前面,这是因为x86的系统都是使用的小端字节序来存储数据的,小端字节序就是在内存中,数据的高位保存在内存的高地址中,数据的低位保存在内存的低地址中。就拿上面这段代码举例,上图中内存大小排列的顺序是从左向右依次变大,int32View[1]对应的4个字节,它填入的值是 10 (2的2进制表示),把0补齐的话就是 00000000 00000000 00000000 00000010(中间的分隔方便观看),计算机会倒过来填充,最终会成为 00000010 00000000 00000000 00000000。与小端字节序对应的就是大端字节序,它就是我们平时读数字的顺序。

2、实际场景

在WebGL中有这么一个需求,我要绘制一个带颜色的三角形,这个三角形有三个顶点,每个点有3个坐标和一个RGBA颜色,现在有了三角形的顶点和颜色数据,需要创建一个缓冲,把三角形的数据按顺序填入,然后传输给WebGL。目前的三角形数据是这样的:

var triangleVertices = [
      // (x,   y,   z)  (r,   g,   b,   a)
        0.0,  0.5, 0.0, 255,   0,   0, 255, // V0
        0.5, -0.5, 0.0,   0, 250,   6, 255, // V1
       -0.5, -0.5, 0.0,   0,   0, 255, 255  // V2
      ];

目标格式是一个ArrayBuffer,它的格式是这样的:
JavaScript类型化数组(二进制数组)
表示坐标的浮点数是32位的,占4个字节,表示颜色的正整数是8位的,占1个字节,因此我们需要创建两个视图来对这个缓冲进行赋值。

var triangleVertices = [
      // (x,   y,   z)  (r,   g,   b,   a)
        0.0,  0.5, 0.0, 255,   0,   0, 255, // V0
        0.5, -0.5, 0.0,   0, 250,   6, 255, // V1
       -0.5, -0.5, 0.0,   0,   0, 255, 255  // V2
      ];

      var nbrOfVertices = 3; // 顶点数量
      var vertexSizeInBytes = 3 * Float32Array.BYTES_PER_ELEMENT + 4 * Uint8Array.BYTES_PER_ELEMENT; // 一个顶点所占的字节数 3*4+4*1 = 16

      var buffer = new ArrayBuffer(nbrOfVertices * vertexSizeInBytes); // 3 * 16 = 48 三个顶点一共需要的字节数
      var positionView = new Float32Array(buffer); 
      var colorView = new Uint8Array(buffer);

      var positionOffsetInFloats = 0;
      var colorOffsetInBytes = 12;
      var k = 0;
      // 用三角形数据填充arrayBuffer
      for (var i = 0; i < nbrOfVertices; i++) {
        positionView[positionOffsetInFloats] = triangleVertices[k];         // x
        positionView[1 + positionOffsetInFloats] = triangleVertices[k + 1]; // y
        positionView[2 + positionOffsetInFloats] = triangleVertices[k + 2]; // z
        colorView[colorOffsetInBytes] = triangleVertices[k + 3];            // r
        colorView[1 + colorOffsetInBytes] = triangleVertices[k + 4];            // g
        colorView[2 + colorOffsetInBytes] = triangleVertices[k + 5];            // b
        colorView[3 + colorOffsetInBytes] = triangleVertices[k + 6];            // a

        positionOffsetInFloats += 4; // 4个字节的浮点数循环一次要偏移4位
        colorOffsetInBytes += 16;    // 1个字节的整数循环一次要偏移16位
        k += 7;                      // 原数组一次处理七个数值(三个坐标四个颜色)
      }

JavaScript类型化数组(二进制数组)

  • 这段代码运行完,就可以得到我们想要的ArrayBuffer。希望大家可以在浏览器控制台运行一下,然后看看positionView和colorView里面的数据验证一下。细心的小伙伴会发现,如果使用positionView访问颜色数据,或者colorView访问位置数据,得到的数据是“奇怪”的,不知道原因的读者朋友可以去了解一下原码、补码、IEEE浮点数相关的知识。

3、总结

类型化数组的内容还有很多,在这里我只重点介绍了一下缓冲和视图是如何一起合作来管理内存的。

类型化数组的出现最大的作用就是提升了数组的性能,js中Array的内部实现是链表,可以动态增大减少元素,但是元素多的话,性能会比较差,类型化数组管理的是连续内存区域,知道了这块内存的起始位置,可以通过起始位置+N * 偏移量(一次加法一次乘法操作)访问到第N个位置的元素,而Array的话就需要通过链表一个一个的找下去。

类型化数组的使用场景并不多,可以说是为WebGL量身定做的,不过还是希望你能在以后遇到大量数据的场景能够想起来JS的类型化数组这个功能。

上一篇:再解 JavaScript 原型与原型链


下一篇:javascript:void(0)含义