前言
在日常开发的过程中,大家或多或少都接触或者听说过类型化数组,本文主要介绍类型化数组的历史、背景、与普通数组的对比,并结合一些使用场景,方便大家了解如何操作二进制数据。
什么是类型化数组
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 到 127 | 1 | 8 位有符号整型(补码) | byte | int8_t |
Uint8Array | 0 到 255 | 1 | 8 位无符号整型 | octet | uint8_t |
Uint8ClampedArray | 0 到 255 | 1 | 8 位无符号整型(一定在 0 到 255 之间) | octet | uint8_t |
Int16Array | -32768 到 32767 | 2 | 16 位有符号整型(补码) | short | int16_t |
Uint16Array | 0 到 65535 | 2 | 16 位无符号整型 | unsigned short | uint16_t |
Int32Array | -2147483648 到 2147483647 | 4 | 32 位有符号整型(补码) | long | int32_t |
Uint32Array | 0 到 4294967295 | 4 | 32 位无符号整型 | unsigned long | uint32_t |
Float32Array | -3.4E38 到 3.4E38 并且 1.2E-38 是最小的正数 | 4 | 32 位 IEEE 浮点数(7 位有效数字,例如 1.234567 ) | unrestricted float | float |
Float64Array | -1.8E308 到 1.8E308 并且 5E-324 是最小的正数 | 8 | 64 位 IEEE 浮点数(16 位有效数字,例如 1.23456789012345 ) | unrestricted double | double |
BigInt64Array | -263 到 263 - 1 | 8 | 64 位有符号整数(补码) | bigint | int64_t (signed long long) |
BigUint64Array | 0 到 264 - 1 | 8 | 64 位无符号整型 | bigint | uint64_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
的大小是固定的,通过不同的数据类型解析成了不同的数。
例如: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);
综上所述:
- 缓冲(ArrayBuffer): 描述的是一个数据块,不能直接访问和操作
- 类型化数组(TypedArray): 描述了一个底层的
ArrayBuffer
对象的类数组视图,包含 11 种数据视图类型 - 视图(DataView): 一个可以从二进制
ArrayBuffer
对象中读写多种数值类型的底层接口,使用时,不用考虑不同平台的字节序问题
为什么要用类型化数组
随着 Web 应用程序越来越强大,尤其是一些新增加的功能,例如:Canvas、WebGL、音视频的编辑、访问 WebSocket 的原始数据等,在这些应用中,如果使用传统的数组,就显得力不从心了。
通过 JavaScript
以极高的效率操纵原始的二进制缓冲数据时类型化数组很有用,有助于传递大量数据。引入类型化数组是为了让 JavaScript
能够在媒体处理和图形处理应用程序中直接使用格式化的二进制缓冲数据。
从本质上讲,类型化数组是一个 C 风格的数组,通过 JavaScript API 操纵。
C 语言中的数组
C 语言中数组的定义:数组是有序的并且具有相同类型的数据的集合。数组的特性:
- 连续的内存位置
- 可以通过索引快速访问
- 添加/删除元素时需要移动大量元素
下图中定义了Int类型的数组 arr[4]。每个 Int 占用 4 个字节,由此可见数组元素的存储在内存中是连续的。
JavaScript 中的数组
JavaScript 中数组的定义:
- 数组是一种类列表对象,原型中提供了遍历和修改元素的操作
- 数组的长度和元素类型不固定
- 数据在内存中可以是不连续的
在 JavaScript
中,数组是哈希映射(HashMap)。它可以使用各种数据结构来实现,其中之一是链表。
例如:定义一个长度为 4 的数组
const arr = new Array(4);
实际结构如下图所示:
要获取 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;
};
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;
};
交替组织数据
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 个字节。
以一个顶点数据为例:
存储在内存中,共需要的字节大小为:(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。
同样以一个顶点数据为例:
存储在内存中,共需要的字节大小为: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;
}
最终需要的字节大小为: (3 * 4 + 4) * 3 = 48