前端开发中的二进制数据
在计算机中,二进制数据是最基础的数据。但是在前端开发中,我们更多时候接触的是高级数据类型 number,需要直接使用二进制数据的场景比较少,下面是几个典型的在前端需要二进制数据的场景:
canvas 图像数据处理
WebGL 中与显卡通信
字符串编码 TextEncoder
Ajax responseType arraybuffer
WebAssembly 中与其他编程语言通信
JS 中提供了 ArrayBuffer 来控制二进制数据。ArrayBuffer 表示的是一段固定长度的二进制数据缓冲区,new ArrayBuffer(8)
即可创建一个长度为 8 个字节的二进制数组。但是我们不能直接操作这块数据,ArrayBuffer 是一块二进制数据的缓冲区,它只是简单地存储数据本身,真正如何控制取决于我们使用怎样的方式来读取它,JS 提供了 TypedArray 和 DataView 两种方式读写 ArrayBuffer。
TypedArray
TypedArray 是 JS 内置的一系列类型,目前有 11 种:Int8Array、Uint8Array、Uint8ClampedArray、Int16Array、Uint16Array、Int32Array、Uint32Array、Float32Array、Float64Array、BigInt64Array、BigUint64Array。TypedArray 顾名思义它的特点就是与类型相关,从每种类型的名字上可以很容易地看出他们对应的数据类型,使用对应的 TypedArray 类就可以实现对 ArrayBuffer 读取或写入对应类型的数据。
Type | Value Range | Size in bytes | Description | Web IDL type | Equivalent C type |
---|---|---|---|---|---|
Int8Array | -128 to 127 | 1 | 8-bit two's complement signed integer | byte | int8_t |
Uint8Array | 0 to 255 | 1 | 8-bit unsigned integer | octet | uint8_t |
Uint8ClampedArray | 0 to 255 | 1 | 8-bit unsigned integer (clamped) | octet | uint8_t |
Int16Array | -32768 to 32767 | 2 | 16-bit two's complement signed integer | short | int16_t |
Uint16Array | 0 to 65535 | 2 | 16-bit unsigned integer | unsigned short | uint16_t |
Int32Array | -2147483648 to 2147483647 | 4 | 32-bit two's complement signed integer | long | int32_t |
Uint32Array | 0 to 4294967295 | 4 | 32-bit unsigned integer | unsigned long | uint32_t |
Float32Array | -3.4E38 to 3.4E38 and 1.2E-38 is the min positive number | 4 | 32-bit IEEE floating point number (7 significant digits e.g., 1.234567 ) | unrestricted float | float |
Float64Array | -1.8E308 to 1.8E308 and 5E-324 is the min positive number | 8 | 64-bit IEEE floating point number (16 significant digits e.g., 1.23456789012345 ) | unrestricted double | double |
BigInt64Array | -2^63 to 2^63 - 1 | 8 | 64-bit two's complement signed integer | bigint | int64_t (signed long long) |
BigUint64Array | 0 to 2^64 - 1 | 8 | 64-bit unsigned integer | bigint | uint64_t (unsigned long long) |
构造函数:
new TypedArray(); // ES2017中新增,创建一个空的 TypedArray new TypedArray(length); // 创建一个长度为 langth 的 TypedArray new TypedArray(typedArray); // 从一个 typedArray 复制一个新的 typedArray new TypedArray(object); // 调用 TypedArray.from 创建 typedArray,效果同 Array.from new TypedArray(buffer [, byteOffset [, length]]); // 使用指定 buffer 或 buffe 的一部分创建 typedArray,buffer 长度要能够被单个元素占的字节数整除 复制代码
来看一个例子:
const buffer = new ArrayBuffer(16); const typedArray = new Uint8Array(buffer); console.log(typedArray); // output: Uint8Array(16) [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] typedArray[0] = 10; typedArray[1] = 16; console.log(typedArray[0]); // output: 10 console.log(typedArray); // output: Uint8Array(16) [10, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] 复制代码
入上表:Uint8Array 每个元素占一个字节,因此我们从长度为 16 字节的 ArrayBuffer 中可以创建一个长度为 16 的 Uint8Array,我们可以像数组一样通过下标取值,可以为其设置值,可以使用大部分 Array 上的方法。 TypedArray 与普通 Array 的区别在于它是固定长度且内存空间连续的,因此能够改变数组长度的 push、pop、splice 方法不能在 TypedArray 上使用。
表中 Uint8Array 能存储的数据范围为 0-255,我们试着存储一个更大的数据进来:
const buffer = new ArrayBuffer(2); const uint8Array = new Uint8Array(buffer); uint8Array[0] = 300; console.log(uint8Array); // output: Uint8Array(2) [44, 0] 复制代码
我们写入了 300,但是读到的是 44,这显然是不符合预期的,因此为了避免内存超出限制,我们需要选择合适的数据格式,这里换成 Uint16Array 即可解决。
const buffer = new ArrayBuffer(2); const uint16Array = new Uint16Array(buffer); uint16Array[0] = 300; console.log(uint16Array); // output: Uint16Array [300] 复制代码
由于 Uint16Array 占用两个字节,这里长度为 2 的 ArrayBuffer 创建的 uint16Array 只有一个值。下图可以理解通过 TypedArray 控制 ArrayBuffer 的原理:
从图中可以看出,实际上 ArrayBuffer 表示的内存空间是一样的,TypedArray 提供的是一个读取内存的单位,我们使用 Uint8Array 时下标 0 控制的是内存中第一个字节,换成 Uint16Array 的下标 0 控制的是前两个字节,以此类推。
那么根据这个原理,我们通过 Uint8Array,写入两个字节是不是可以通过 Uint16Array 读取呢?
const buffer = new ArrayBuffer(2); const uint8Array = new Uint8Array(buffer); uint8Array[0] = 10; uint8Array[1] = 16; const uint16Array = new Uint16Array(buffer); 复制代码
考虑一下这里 uint16Array 是什么?
由于我们在第一个字节中写入了 10,第二个字节写入 16,一个字节占 8 位,看起来应该是 (10 << 8) + 16 = 2576,我们试着打印一下:
console.log(uint16Array); // output: Uint16Array [4106] 复制代码
好像和预期的不一样,我们看到结果并不是 2576 而是 4106,是哪里出了问题呢?
这里涉及到一个字节存储顺序的问题,计算机在存储多字节数据时有两种方式,低位存储在低地址称为小端,高位存储在低地址称为大端。
使用 TypedArray 设置的值以小端方式进行存储的,因此 Uint16Array 在读取时低地址上的数据作为低位,高地址上的数据作为高位,即 10 为低位,16 为高位,因此上面的值为 (16 << 8) + 10 = 4106。
我们回过来看上面的 300 为什么会变成 44。300 转为二进制为 100101100,超出了一个字节,因此按照小堆的方式应该把低位放入第一个字节即 00101100,高位 1 应该放入第二个字节,由于 Uint8Array 只有一个字节,因此高位的 1 丢失,只剩下 00101100 即 44。
我们可以验证一下:
const buffer = new ArrayBuffer(2); const uint16Array = new Uint16Array(buffer); uint16Array[0] = 300; const uint8Array = new Uint8Array(buffer); console.log(uint8Array); // output: Uint8Array(2) [44, 1] 复制代码
Uint8ClampedArray 比较特殊,它在数据超限时不会进行舍弃,而是直接存入 0 或 255,rgb 数据值在 0-255 之间,因此常用于 imageData。
使用 TypedArray 在 buffer 上写入的是同一种类型的数据,但是我们也看到了,实际上写入的都是无差别的二进制信息,实际上数据是什么取决于我们以何种方式读取内存。因此我们对同一块 bffer,我们可以写入和读取多种不同格式的数据。但是由于大小端问题我们写入不同类型数据时会比较麻烦,因此对于不同类型的数据,我们可以使用 DataView 来操作。
DataView
使用 DataView 读写 ArrayBuffer 很简单,我们只需要关注每次写入的字节大小和偏移量即可,DataView 默认以大端方式存储,我们可以在写入值时设置最后一个参数为 true 来指定使用小端,注意写入时如果指定小端读取时也要指定小端读取。
const buffer = new ArrayBuffer(20); const dataView = new DataView(buffer); dataView.setInt8(0, 10); dataView.setInt16(1, 300); dataView.setInt16(3, 400, true); // 使用小端 dataView.setInt8(5, 20); console.log(dataView.getInt8(0), dataView.getInt16(1), dataView.getInt16(3, true /* 使用小端 */), dataView.getInt8(5)); // output: 10 300 400 20 复制代码
为什么 TypedArray 使用小端而 DataView 默认用大端?
因为目前大多计算机 CPU 和内存中使用的都是小端存储的方式,TypedArray 提供的是一组直接操作二进制数据的接口,直接使用小端可以带来更好的性能。而 DataView 可以组装多种类型数据,这种场景多用于数据序列化反序列化和传输,而在传输中,对于流式数据,我们只需要在数据到来时按照内存顺序存储即可还原大端序列,这时大端明显更高效,在 TCP/IP 中使用的也是大端。当然 DataView 可以指定使用小端,因此实际使用时 TypedArray 做的事情也可以使用 DataView 来完成。
作者:丨隋堤倦客丨
链接:https://juejin.cn/post/7025818772531314724