浅析 JavaScript 类型化数组

1,209 阅读10分钟

前言

在日常开发的过程中,大家或多或少都接触或者听说过类型化数组,本文主要介绍类型化数组的历史、背景、与普通数组的对比,并结合一些使用场景,方便大家了解如何操作二进制数据。

什么是类型化数组

JavaScript 类型化数组(TypedArray)是一种类似数组的对象,并提供了一种用于访问原始二进制数据的机制。用一句话解释类型化数组:它是 JavaScript 操作二进制数据的接口。

一个 TypedArray 对象描述了底层二进制数据缓冲区的类数组视图,在 JavaScript 集合对象中,没有名为 TypedArray 的全局属性,也没有直接可用的 TypedArray 构造函数。可以把它理解为一个 11 种特定类型视图的集合。

类型化数组的历史

TypedArray 在 WebGL 的早期实现阶段就存在了,当时发现将 JavaScript 数组传递给图形驱动程序会导致性能问题。对于 JavaScript 数组,WebGL 绑定必须分配一个原生数组并通过遍历 JavaScript 数组来填充它,并将数组中的每个 JavaScript 对象转换为所需要的原生类型。

为了修复数据转换瓶颈,Mozilla 的 Vladimir Vukicevic 编写了 CanvasFloatArray:一个带有 JavaScript 接口的 C 风格浮点数组。可以在 JavaScript 中编辑 CanvasFloatArray 并将其直接传递给 WebGL,无需在绑定中做任何额外的工作。后来 CanvasFloatArray 被重命名为 WebGLFloatArray,后者被重命名为 Float32Array 并拆分为支持 ArrayBuffer 和类型化的 Float32Array-view 以访问缓冲区。

TypedArray 对象

类型值范围字节大小描述Web IDL 类型等价的 C 类型
Int8Array-128 到 12718 位有符号整型(补码)byteint8_t
Uint8Array0 到 25518 位无符号整型octetuint8_t
Uint8ClampedArray0 到 25518 位无符号整型(一定在 0 到 255 之间)octetuint8_t
Int16Array-32768 到 32767216 位有符号整型(补码)shortint16_t
Uint16Array0 到 65535216 位无符号整型unsigned shortuint16_t
Int32Array-2147483648 到 2147483647432 位有符号整型(补码)longint32_t
Uint32Array0 到 4294967295432 位无符号整型unsigned longuint32_t
Float32Array-3.4E38 到 3.4E38 并且 1.2E-38 是最小的正数432 位 IEEE 浮点数(7 位有效数字,例如 1.234567unrestricted floatfloat
Float64Array-1.8E308 到 1.8E308 并且 5E-324 是最小的正数864 位 IEEE 浮点数(16 位有效数字,例如 1.23456789012345unrestricted doubledouble
BigInt64Array-263 到 263 - 1864 位有符号整数(补码)bigintint64_t (signed long long)
BigUint64Array0 到 264 - 1864 位无符号整型bigintuint64_t (unsigned long long)

类型化数组的属性和方法

属性/方法功能描述
buffer只读属性,返回类型化数组视图对象的内存区域对应的 ArrayBuffer 对象
byteOffset只读属性,返回类型化数组从底层 ArrayBuffer 对象的哪个字节开始
byteLength只读属性,返回类型化数组占据的内存总长度,单位为字节
get()用于获取指定索引处的元素
set()用于复制数组,也就是将一段内容完全复制到另一段内存区域中
subarray()基于数组缓冲器的子集,创建一个新的视图,参数为开始元素的索引和结束元素的索引,返回的类型与源视图类型相同

类型化数据视图对象还具有一个常量 BYTES_PER_ELEMENT,表示这种数据类型的元素所占据的字节数。

和普通数组的区别

  • TypedArray 中的元素都是数字,Array 可以存储任意元素
  • TypedArray 是连续的不会有空位,Array 可以有空位
  • TypedArray 定义时长度固定不可动态增加或减小,Array 长度可变
  • TypedArray 默认值为0,Array 默认值为空
  • TypedArray Array.isArray() 返回 false
  • TypedArray 以下标的形式,直接操作内存,不需要数据类型转换,速度快

ArrayBuffer、TypedArray、DataView 的关系

ArrayBuffer 对象、TypedArray 视图和 DataView 视图是 JavaScript 操作二进制数据的接口,因为它们都是以数组的语法处理二进制数据,所以统称为二进制数据。

下图是一段长度为 16 的 ArrayBuffer 对象,可以理解为一段内存,里面的数据不能直接访问,需要通过不同的视图来描述,可以通过 TypedArray 类数组视图描述,也可以通过 DataView 对象描述。不同数据类型的视图对于描述 ArrrayBuffer 的大小是固定的,通过不同的数据类型解析成了不同的数。

image.png 例如:Unit8Array 字节大小为 1,也就是每个元素占 1 个字节,描述的元素长度是 16。而Unit32Array 字节大小为 4,也就是每个元素占 4 个字节,描述的元素长度是 4。

我们可以通过 new DataView 构造 DataView 实例,同样可以通过 new TypedArray 来将 buffer 实例转化为 TypedArray 进行操作。同样,也可以通过它们各自的 buffer 属性来获取对应 ArrayBuffer 的内容。

// 创建一个 16 字节固定长度的缓冲
const buffer = new ArrayBuffer(16);
// 获取对应的buffer内容
const dataView = new DataView(buffer);
console.log(dataView.buffer)
// 获取对应的buffer内容
const typedArray = new Uint8Array(buffer);
console.log(typedArray.buffer);

image.png

综上所述:

  • 缓冲(ArrayBuffer): 描述的是一个数据块,不能直接访问和操作
  • 类型化数组(TypedArray): 描述了一个底层的 ArrayBuffer 对象的类数组视图,包含 11 种数据视图类型
  • 视图(DataView): 一个可以从二进制 ArrayBuffer 对象中读写多种数值类型的底层接口,使用时,不用考虑不同平台的字节序问题

为什么要用类型化数组

随着 Web 应用程序越来越强大,尤其是一些新增加的功能,例如:Canvas、WebGL、音视频的编辑、访问 WebSocket 的原始数据等,在这些应用中,如果使用传统的数组,就显得力不从心了。

通过 JavaScript 以极高的效率操纵原始的二进制缓冲数据时类型化数组很有用,有助于传递大量数据。引入类型化数组是为了让 JavaScript 能够在媒体处理和图形处理应用程序中直接使用格式化的二进制缓冲数据。

从本质上讲,类型化数组是一个 C 风格的数组,通过 JavaScript API 操纵。

C 语言中的数组

C 语言中数组的定义:数组是有序的并且具有相同类型的数据的集合。数组的特性:

  • 连续的内存位置
  • 可以通过索引快速访问
  • 添加/删除元素时需要移动大量元素

下图中定义了Int类型的数组 arr[4]。每个 Int 占用 4 个字节,由此可见数组元素的存储在内存中是连续的。 image.png

JavaScript 中的数组

JavaScript 中数组的定义:

  • 数组是一种类列表对象,原型中提供了遍历和修改元素的操作
  • 数组的长度和元素类型不固定
  • 数据在内存中可以是不连续的

JavaScript 中,数组是哈希映射(HashMap)。它可以使用各种数据结构来实现,其中之一是链表。

例如:定义一个长度为 4 的数组

const arr = new Array(4);

实际结构如下图所示:

image.png 要获取 arr[2] 元素,需要从 1201 开始查找到 arr[2] 元素在内存中的索引位置。 由此可见,JS 数组的特性:

  • 在内存中不连续
  • 链表结构增加/删除速度快
  • 查找元素性能相对较慢

如何应用类型化数组

好,关于类型化数组的基础知识介绍完了,下面我们一起来看一下,类型化数组的应用场景。

类型化数组转换为普通数组

在处理完一个类型化数组后,有时需要把它转为普通数组,以便可以像普通数组一样去操作

const typedArray = new Uint8Array([1, 2, 3, 4]);
// 使用 Array.from()实现
const normalArray = Array.from(typedArray);
// 展开语法
const normalArray = [...typedArray];
// 不支持 Array.from()
const normalArray = Array.prototype.slice.call(typedArray);

数据格式转换

利用类型化数组,可以转换数据类型。

ArrayBuffer 转 Float32Array

// 文件下载返回二进制数据流
const covertData = stream => {
    console.log('buffer:', stream);
    // create a uint8 view
    const data = new Uint8Array(stream);
    console.log('data:', data);
    // create the Float32Array
    const outputData = new Float32Array(data.length);
    console.log('outputData:', outputData);
    data.forEach((item, index) => {
        // convert data to float
        outputData[index] = (item - 128) / 128.0;
    });
    console.log('covertData:', outputData);
    return outputData;
};

image.png

Float32Array 转 Uint8Array

const float32ToUint8 = float32Array => {
    console.log('float32Array:', float32Array);
    const output = new Uint8Array(float32Array.length);
    console.log('Uint8Array:', output);
    float32Array.forEach((item, index) => {
        let temp = Math.max(-1, Math.min(1, item));
        // 0x7fff是short类型的最大正值,0x8000 是最小负值
        temp = temp < 0 ? temp * 0x8000 : temp * 0x7fff;
        temp = temp / 256;
        output[index] = temp + 128;
    });
    console.log('float32ToUint8:', output);
    return output;
};

image.png

交替组织数据

WebGL 绘制一个三角形,传统的做法是用两个 Float32Array 数组,分别是顶点坐标和顶点颜色:

const vertex = new Float32Array([
  // X    Y   Z
    -.5, -.2, 0,
     .5, -.2, 0,
      0,  .6, 0
]);
const color = new Float32Array([
 // R  G  B  A
    1, 0, 0, 1,
    0, 1, 0, 1,
    0, 0, 1, 1
]);

分别用两个缓冲区上传到 WebGL 着色器代码中,这两个缓冲区的类型为 float32,每个数组占用 4 个字节。 以一个顶点数据为例: image.png 存储在内存中,共需要的字节大小为:(3 * 4 + 4 * 4)* 3 = 84

接下来我们从缓冲区的个数上进行优化

优化 1: 将位置信息和颜色信息合并到一个 Float32Array 数组中,降低 Buffer 的个数,减少 Buffer 切换的开销

const positions = new Float32Array([
    // X   Y  Z  R  G  B  A
    -.5, -.2, 0, 1, 0, 0, 1,
     .5, -.2, 0, 0, 1, 0, 1,
      0,  .6, 0, 0, 0, 0, 1
]);

单个 float32 占用 4 个字节,共有 3 组相关数据,每组 7 个 float32。

同样以一个顶点数据为例:

image.png

存储在内存中,共需要的字节大小为:7 * 4 * 3 = 84

可以看出这个优化占用的字节数未变,只是减少了切换 Buffer 的开销,接下来我们从传输内容上进行优化

优化 2: 颜色 RGBA 每个通道如果用 float32 表示,每个通道占用 4 个字节,会比较浪费。RGBA 的每个通道转为 0 ~ 255 之间,用 Uint8Array 表示,每个通道只需要占用 1 个字节,RGBA 一共占用 4 个字节就可以了。

const positions = [
    // X   Y  Z    R    G    B   A
    -.5, -.2, 0, 255,   0,   0, 255,
     .5, -.2, 0,   0, 255,   0, 255,
      0,  .6, 0,   0,   0, 255, 255 
];
// 3个顶点
const vertexNumber = 3;
// 一个顶点所占的字节数
const vertexSizeInBytes = 3 * Float32Array.BYTES_PER_ELEMENT + 4 * Uint8Array.BYTES_PER_ELEMENT;
const arrayBuffer = new ArrayBuffer(vertexSizeInBytes * vertexNumber);
const float32View = new Float32Array(arrayBuffer);
const uint8View = new Uint8Array(arrayBuffer);
let float32Offset = 0;
let uint8Offset = 12;
let k = 0;
for (let i = 0; i < positions.length; i++) {
    float32View[float32Offset] = positions[k];
    float32View[float32Offset + 1] = positions[k + 1];
    float32View[float32Offset + 2] = positions[k + 2];
    uint8View[uint8Offset] = positions[k + 3];
    uint8View[uint8Offset + 1] = positions[k + 4];
    uint8View[uint8Offset + 2] = positions[k + 5];
    uint8View[uint8Offset + 3] = positions[k + 6];
    // 4个字节的浮点数循环一次要偏移4位
    float32Offset += 4;
    // 1个字节的整数循环一次要偏移16位
    uint8Offset += 16;
    // 原数组一次处理七个数值(X Y Z R G B A)
    k += 7;
}

image.png 最终需要的字节大小为: (3 * 4 + 4) * 3 = 48

参考文档

MDN - JavaScript typed arrays

Diving deep into JavaScript array - evolution & performance