2022年7月11日

ArrayBuffer,二进制数组

在 Web 开发中,我们主要在处理文件(创建、上传、下载)时遇到二进制数据。另一个典型的用例是图像处理。

所有这些在 JavaScript 中都是可能的,并且二进制操作的性能很高。

不过,这里有点混乱,因为有很多类。举几个例子:

  • ArrayBufferUint8ArrayDataViewBlobFile 等。

与其他语言相比,JavaScript 中的二进制数据以非标准的方式实现。但是,当我们理清思路后,一切都变得相当简单。

基本的二进制对象是 ArrayBuffer - 对固定长度连续内存区域的引用。

我们是这样创建它的

let buffer = new ArrayBuffer(16); // create a buffer of length 16
alert(buffer.byteLength); // 16

这会分配一个连续的 16 字节内存区域,并用零预先填充它。

ArrayBuffer 不是一个数组

让我们消除一个可能的混淆来源。ArrayBufferArray 没有任何共同之处

  • 它具有固定长度,我们不能增加或减少它。
  • 它在内存中占用恰好那么多的空间。
  • 要访问单个字节,需要另一个“视图”对象,而不是 buffer[index]

ArrayBuffer 是一个内存区域。里面存储了什么?它不知道。只是一系列原始字节。

要操作 ArrayBuffer,我们需要使用“视图”对象。

视图对象本身不存储任何东西。它是“眼镜”,可以解释存储在 ArrayBuffer 中的字节。

例如

  • Uint8Array – 将 ArrayBuffer 中的每个字节视为一个独立的数字,可能的取值范围为 0 到 255(一个字节是 8 位,因此它只能容纳那么多)。这样的值被称为“8 位无符号整数”。
  • Uint16Array – 将每 2 个字节视为一个整数,可能的取值范围为 0 到 65535。这被称为“16 位无符号整数”。
  • Uint32Array – 将每 4 个字节视为一个整数,可能的取值范围为 0 到 4294967295。这被称为“32 位无符号整数”。
  • Float64Array – 将每 8 个字节视为一个浮点数,可能的取值范围为 5.0x10-3241.8x10308

因此,16 字节 ArrayBuffer 中的二进制数据可以解释为 16 个“小数字”,或 8 个更大的数字(每个 2 字节),或 4 个更大的数字(每个 4 字节),或 2 个具有高精度的浮点数(每个 8 字节)。

ArrayBuffer 是核心对象,一切的根源,原始二进制数据。

但是,如果我们要写入它,或者遍历它,基本上对于几乎所有操作,我们都必须使用视图,例如

let buffer = new ArrayBuffer(16); // create a buffer of length 16

let view = new Uint32Array(buffer); // treat buffer as a sequence of 32-bit integers

alert(Uint32Array.BYTES_PER_ELEMENT); // 4 bytes per integer

alert(view.length); // 4, it stores that many integers
alert(view.byteLength); // 16, the size in bytes

// let's write a value
view[0] = 123456;

// iterate over values
for(let num of view) {
  alert(num); // 123456, then 0, 0, 0 (4 values total)
}

TypedArray

所有这些视图(Uint8ArrayUint32Array 等)的通用术语是 TypedArray。它们共享相同的集合方法和属性。

请注意,没有名为 TypedArray 的构造函数,它只是一个常见的“总称”,用于表示 ArrayBuffer 上的视图之一:Int8ArrayUint8Array 等等,完整的列表将在后面给出。

当您看到类似 new TypedArray 的内容时,它意味着任何 new Int8Arraynew Uint8Array 等。

类型化数组的行为类似于普通数组:具有索引并且可以迭代。

类型化数组构造函数(无论是 Int8Array 还是 Float64Array,都无关紧要)的行为取决于参数类型。

参数有 5 种变体

new TypedArray(buffer, [byteOffset], [length]);
new TypedArray(object);
new TypedArray(typedArray);
new TypedArray(length);
new TypedArray();
  1. 如果提供了 ArrayBuffer 参数,则会在其上创建视图。我们已经使用过这种语法。

    我们可以选择提供 byteOffset 来指定起始位置(默认值为 0)和 length(默认值为缓冲区的末尾),然后视图将只覆盖 buffer 的一部分。

  2. 如果给出了 Array 或任何类似数组的对象,它将创建一个具有相同长度的类型化数组并复制内容。

    我们可以用它来预先填充数组数据。

    let arr = new Uint8Array([0, 1, 2, 3]);
    alert( arr.length ); // 4, created binary array of the same length
    alert( arr[1] ); // 1, filled with 4 bytes (unsigned 8-bit integers) with given values
  3. 如果提供了另一个 TypedArray,它也会执行相同的操作:创建一个具有相同长度的类型化数组并复制值。如果需要,值将在过程中转换为新的类型。

    let arr16 = new Uint16Array([1, 1000]);
    let arr8 = new Uint8Array(arr16);
    alert( arr8[0] ); // 1
    alert( arr8[1] ); // 232, tried to copy 1000, but can't fit 1000 into 8 bits (explanations below)
  4. 对于数字参数 length - 创建一个包含该数量元素的类型化数组。它的字节长度将是 length 乘以单个项目 TypedArray.BYTES_PER_ELEMENT 中的字节数。

    let arr = new Uint16Array(4); // create typed array for 4 integers
    alert( Uint16Array.BYTES_PER_ELEMENT ); // 2 bytes per integer
    alert( arr.byteLength ); // 8 (size in bytes)
  5. 没有参数,创建一个零长度的类型化数组。

我们可以直接创建一个 TypedArray,而无需提及 ArrayBuffer。但是,视图不能没有底层的 ArrayBuffer 存在,因此在所有这些情况下(除了第一个情况,即提供 ArrayBuffer 时)都会自动创建。

要访问底层的 ArrayBufferTypedArray 中有以下属性:

  • buffer - 引用 ArrayBuffer
  • byteLength - ArrayBuffer 的长度。

因此,我们始终可以从一个视图移动到另一个视图。

let arr8 = new Uint8Array([0, 1, 2, 3]);

// another view on the same data
let arr16 = new Uint16Array(arr8.buffer);

以下是类型化数组的列表:

  • Uint8ArrayUint16ArrayUint32Array - 用于 8 位、16 位和 32 位的整数。
    • Uint8ClampedArray - 用于 8 位整数,在赋值时会“钳位”(见下文)。
  • Int8ArrayInt16ArrayInt32Array - 用于带符号整数(可以为负数)。
  • Float32ArrayFloat64Array - 用于 32 位和 64 位的带符号浮点数。
没有 int8 或类似的单值类型。

请注意,尽管有像 Int8Array 这样的名称,但 JavaScript 中没有像 intint8 这样的单值类型。

这是合乎逻辑的,因为 Int8Array 不是这些单个值的数组,而是对 ArrayBuffer 的视图。

越界行为

如果我们尝试将越界值写入类型化数组会怎样?不会出现错误。但额外的位会被截断。

例如,让我们尝试将 256 放入 Uint8Array。在二进制形式中,256 是 100000000(9 位),但 Uint8Array 每个值只提供 8 位,这使得可用范围从 0 到 255。

对于更大的数字,只有最右边的(最低有效)8 位被存储,其余的被截断。

所以我们会得到零。

对于 257,二进制形式是 100000001(9 位),最右边的 8 位被存储,所以我们将在数组中得到 1

换句话说,保存了该数字模 28 的值。

以下是演示

let uint8array = new Uint8Array(16);

let num = 256;
alert(num.toString(2)); // 100000000 (binary representation)

uint8array[0] = 256;
uint8array[1] = 257;

alert(uint8array[0]); // 0
alert(uint8array[1]); // 1

Uint8ClampedArray 在这方面很特殊,它的行为不同。它将大于 255 的任何数字保存为 255,将任何负数保存为 0。这种行为对图像处理很有用。

TypedArray 方法

TypedArray 具有常规的 Array 方法,但有一些显著的例外。

我们可以迭代、mapslicefindreduce 等。

不过,有些事情我们做不到。

  • 没有 splice - 我们不能“删除”一个值,因为类型化数组是缓冲区的视图,而这些缓冲区是内存中固定、连续的区域。我们所能做的就是分配一个零。
  • 没有 concat 方法。

还有两种额外的方法。

  • arr.set(fromArr, [offset])fromArr 中的所有元素复制到 arr 中,从位置 offset(默认值为 0)开始。
  • arr.subarray([begin, end])beginend(不包括 end)创建一个相同类型的新视图。这类似于 slice 方法(也受支持),但不会复制任何内容 - 只会创建一个新视图,用于操作给定的数据片段。

这些方法允许我们复制类型化数组、混合它们、从现有数组创建新数组等等。

DataView

DataView 是一个特殊的超灵活的“无类型”视图,用于 ArrayBuffer。它允许以任何格式访问任何偏移量的数据。

  • 对于类型化数组,构造函数决定了格式。整个数组应该是一致的。第 i 个数字是 arr[i]
  • 使用DataView,我们可以通过方法(如.getUint8(i).getUint16(i))访问数据。我们可以在方法调用时选择格式,而不是在构造时选择。

语法

new DataView(buffer, [byteOffset], [byteLength])
  • buffer – 底层的ArrayBuffer。与类型化数组不同,DataView不会自行创建缓冲区。我们需要准备好它。
  • byteOffset – 视图的起始字节位置(默认值为 0)。
  • byteLength – 视图的字节长度(默认值为到buffer末尾)。

例如,这里我们从同一个缓冲区中以不同的格式提取数字

// binary array of 4 bytes, all have the maximal value 255
let buffer = new Uint8Array([255, 255, 255, 255]).buffer;

let dataView = new DataView(buffer);

// get 8-bit number at offset 0
alert( dataView.getUint8(0) ); // 255

// now get 16-bit number at offset 0, it consists of 2 bytes, together interpreted as 65535
alert( dataView.getUint16(0) ); // 65535 (biggest 16-bit unsigned int)

// get 32-bit number at offset 0
alert( dataView.getUint32(0) ); // 4294967295 (biggest 32-bit unsigned int)

dataView.setUint32(0, 0); // set 4-byte number to zero, thus setting all bytes to 0

当我们在同一个缓冲区中存储混合格式的数据时,DataView非常有用。例如,当我们存储一系列对(16 位整数,32 位浮点数)时,DataView允许我们轻松地访问它们。

总结

ArrayBuffer是核心对象,是对固定长度连续内存区域的引用。

要对ArrayBuffer执行几乎所有操作,我们需要一个视图。

  • 它可以是TypedArray
    • Uint8ArrayUint16ArrayUint32Array – 用于 8 位、16 位和 32 位无符号整数。
    • Uint8ClampedArray – 用于 8 位整数,在赋值时对其进行“钳位”。
    • Int8ArrayInt16ArrayInt32Array - 用于带符号整数(可以为负数)。
    • Float32ArrayFloat64Array - 用于 32 位和 64 位的带符号浮点数。
  • 或者DataView – 使用方法指定格式的视图,例如getUint8(offset)

在大多数情况下,我们直接创建和操作类型化数组,将ArrayBuffer隐藏在幕后,作为“共同点”。我们可以将其访问为.buffer,并在需要时创建另一个视图。

还有两个额外的术语,用于描述对二进制数据进行操作的方法

  • ArrayBufferView是所有这些类型视图的总称。
  • BufferSourceArrayBufferArrayBufferView的总称。

我们将在下一章中看到这些术语。BufferSource是最常见的术语之一,因为它表示“任何类型的二进制数据” – 一个ArrayBuffer或对其的视图。

这是一个速查表

任务

给定一个Uint8Array数组,编写一个函数concat(arrays),该函数将它们连接成一个数组。

打开一个带有测试的沙盒。

function concat(arrays) {
  // sum of individual array lengths
  let totalLength = arrays.reduce((acc, value) => acc + value.length, 0);

  let result = new Uint8Array(totalLength);

  if (!arrays.length) return result;

  // for each array - copy it over result
  // next array is copied right after the previous one
  let length = 0;
  for(let array of arrays) {
    result.set(array, length);
    length += array.length;
  }

  return result;
}

在沙盒中打开带有测试的解决方案。

教程地图

评论

在评论之前阅读…
  • 如果您有改进建议,请提交 GitHub 问题或拉取请求,而不是评论。
  • 如果您不理解文章中的某些内容,请详细说明。
  • 要插入几行代码,请使用<code>标签,对于多行代码,请将其包装在<pre>标签中,对于超过 10 行的代码,请使用沙盒(plnkrjsbincodepen…)