JavaScript 类型化数组

JavaScript类型化数组是类似数组的对象,提供了一种在内存缓冲区中读取和写入原始二进制数据的机制。

类型化数组并非旨在替代任何功能的数组。相反,它们为开发人员提供了一个熟悉的界面来操作二进制数据。这在与平台功能(例如音频和视频处理、使用WebSockets访问原始数据等)交互时非常有用。JavaScript类型化数组中的每个条目都是多种受支持格式之一的原始二进制值,从8位整数到64位浮点数。

类型化数组对象与数组共享许多相同的具有相似语义的方法。但是,类型化数组应与普通数组混淆,因为在类型化数组上调用Array.isArray()将返回false。此外,并非所有可用于普通数组的方法都受类型化数组支持(例如push和pop)。

为了实现最大的灵活性和效率,JavaScript类型化数组将实现分为缓冲区视图。缓冲区是一个表示数据块的对象;它没有格式可言,也不提供访问其内容的机制。为了访问缓冲区中包含的内存,您需要使用视图。视图提供了一个上下文——即数据类型、起始偏移量和元素数量。

A diagram showing how different typed arrays may be views of the same underlying buffer. Each one has a different element number and width.

缓冲区

缓冲区有两种类型:ArrayBufferSharedArrayBuffer。两者都是内存范围的低级表示。它们的名称中都有“数组”,但它们与数组的关系不大——您不能直接读取或写入它们。相反,缓冲区是仅包含原始数据的通用对象。为了访问缓冲区表示的内存,您需要使用视图。

缓冲区支持以下操作

  • 分配:创建新的缓冲区后,会立即分配新的内存范围并初始化为0
  • 复制:使用slice()方法,您可以有效地复制内存的一部分,而无需创建视图来手动复制每个字节。
  • 传输:使用transfer()transferToFixedLength()方法,您可以将内存范围的所有权传输到新的缓冲区对象。这在无需复制即可在不同执行上下文之间传输数据时非常有用。传输后,原始缓冲区将不再可用。SharedArrayBuffer无法传输(因为缓冲区已由所有执行上下文共享)。
  • 调整大小:使用resize()方法,您可以调整内存范围的大小(只要不超过预设的maxByteLength限制,就可以申请更多内存空间,或者释放一些内存空间)。SharedArrayBuffer只能增长,而不能缩小。

ArrayBufferSharedArrayBuffer之间的区别在于,前者始终由单个执行上下文拥有。如果您将ArrayBuffer传递给不同的执行上下文,则会将其传输,并且原始ArrayBuffer将变得不可用。这确保了每次只有一个执行上下文可以访问内存。SharedArrayBuffer在传递给不同的执行上下文时不会被传输,因此可以同时被多个执行上下文访问。当多个线程访问相同的内存范围时,这可能会导致竞争条件,因此诸如Atomics方法之类的操作变得有用。

视图

目前主要有两种视图:类型化数组视图和DataView。类型化数组提供实用程序方法,允许您方便地转换二进制数据。DataView更低级,允许您细粒度地控制如何访问数据。使用这两种视图读取和写入数据的方式非常不同。

这两种视图都会导致ArrayBuffer.isView()返回true。它们都具有以下属性

buffer

视图引用的底层缓冲区。

byteOffset

视图与其缓冲区起始位置之间的偏移量(以字节为单位)。

byteLength

视图的长度(以字节为单位)。

虽然类型化数组构造函数接受length作为元素数量而不是字节数量,但这两个构造函数都将以上三个作为单独的参数接受。

类型化数组视图

类型化数组视图具有自描述名称,并为所有常用的数字类型(如Int8Uint32Float64等)提供视图。有一个特殊的类型化数组视图Uint8ClampedArray,它将值钳位在0255之间。例如,这对于Canvas数据处理很有用。

类型 值范围 大小(以字节为单位) Web IDL类型
Int8Array -128 到 127 1 byte
Uint8Array 0 到 255 1 octet
Uint8ClampedArray 0 到 255 1 octet
Int16Array -32768 到 32767 2 short
Uint16Array 0 到 65535 2 unsigned short
Int32Array -2147483648 到 2147483647 4 long
Uint32Array 0 到 4294967295 4 unsigned long
Float16Array -6550465504 2 N/A
Float32Array -3.4e383.4e38 4 unrestricted float
Float64Array -1.8e3081.8e308 8 unrestricted double
BigInt64Array -263 到 263 - 1 8 bigint
BigUint64Array 0 到 264 - 1 8 bigint

所有类型化数组视图都具有相同的方法和属性,如TypedArray类定义的那样。它们仅在底层数据类型和大小(以字节为单位)方面有所不同。这在值编码和规范化中进行了更详细的讨论。

原则上,类型化数组是固定长度的,因此可能更改数组长度的数组方法不可用。这包括poppushshiftspliceunshift。此外,flat不可用,因为没有嵌套的类型化数组,并且相关的concatflatMap方法没有很好的用例,因此不可用。由于splice不可用,因此toSpliced也不可用。其他所有数组方法都由ArrayTypedArray共享。

另一方面,TypedArray 拥有额外的 setsubarray 方法,这些方法优化了处理查看相同缓冲区的多个类型化数组的操作。set() 方法允许使用来自另一个数组或类型化数组的数据一次设置多个类型化数组索引。如果这两个类型化数组共享相同的底层缓冲区,则操作可能会更有效率,因为它是一个快速的内存移动操作。subarray() 方法创建一个新的类型化数组视图,该视图引用与原始类型化数组相同的缓冲区,但范围更窄。

无法直接更改类型化数组的长度,除非更改底层缓冲区。但是,当类型化数组查看可调整大小的缓冲区且没有固定的 byteLength 时,它就是长度跟踪的,并且会随着可调整大小的缓冲区大小调整而自动调整大小以适应底层缓冲区。有关详细信息,请参阅查看可调整大小的缓冲区时的行为

类似于常规数组,您可以使用方括号表示法访问类型化数组元素。检索底层缓冲区中相应的字节并将其解释为数字。任何使用数字(或数字的字符串表示形式,因为在访问属性时数字始终转换为字符串)的属性访问都将由类型化数组代理——它们永远不会与对象本身交互。例如,这意味着

  • 超出范围的索引访问始终返回 undefined,而不会实际访问对象上的属性。
  • 任何尝试写入此类超出范围的属性都不会产生任何效果:它不会抛出错误,也不会更改缓冲区或类型化数组。
  • 类型化数组索引似乎是可配置和可写的,但任何尝试更改其属性的操作都将失败。
js
const uint8 = new Uint8Array([1, 2, 3]);
console.log(uint8[0]); // 1

// For illustrative purposes only. Not for production code.
uint8[-1] = 0;
uint8[2.5] = 0;
uint8[NaN] = 0;
console.log(Object.keys(uint8)); // ["0", "1", "2"]
console.log(uint8[NaN]); // undefined

// Non-numeric access still works
uint8[true] = 0;
console.log(uint8[true]); // 0

Object.freeze(uint8); // TypeError: Cannot freeze array buffer views with elements

DataView

DataView 是一个低级接口,它提供了一个 getter/setter API 来读取和写入缓冲区的任意数据。例如,这在处理不同类型的数据时非常有用。类型化数组视图采用平台的本机字节序(请参阅字节序)。使用 DataView,可以控制字节序。默认情况下,它是大端字节序——字节从最高有效位到最低有效位排序。这可以使用 getter/setter 方法反转,字节从最低有效位到最高有效位排序(小端字节序)。

DataView 不需要对齐;多字节读写可以从任何指定的偏移量开始。setter 方法的工作方式相同。

以下示例使用 DataView 获取任何数字的二进制表示形式

js
function toBinary(
  x,
  { type = "Float64", littleEndian = false, separator = " ", radix = 16 } = {},
) {
  const bytesNeeded = globalThis[`${type}Array`].BYTES_PER_ELEMENT;
  const dv = new DataView(new ArrayBuffer(bytesNeeded));
  dv[`set${type}`](0, x, littleEndian);
  const bytes = Array.from({ length: bytesNeeded }, (_, i) =>
    dv
      .getUint8(i)
      .toString(radix)
      .padStart(8 / Math.log2(radix), "0"),
  );
  return bytes.join(separator);
}

console.log(toBinary(1.1)); // 3f f1 99 99 99 99 99 9a
console.log(toBinary(1.1, { littleEndian: true })); // 9a 99 99 99 99 99 f1 3f
console.log(toBinary(20, { type: "Int8", radix: 2 })); // 00010100

使用类型化数组的 Web API

以下是一些使用类型化数组的 API 示例;还有其他一些,并且不断添加更多。

FileReader.prototype.readAsArrayBuffer()

FileReader.prototype.readAsArrayBuffer() 方法开始读取指定BlobFile的内容。

fetch()

fetch()body 选项可以是类型化数组或ArrayBuffer,使您能够将这些对象作为POST 请求的有效负载发送。

ImageData.data

是一个Uint8ClampedArray,表示一个包含 RGBA 顺序数据的一维数组,整数值介于 0255(含)之间。

示例

使用视图与缓冲区

首先,我们需要创建一个缓冲区,这里使用固定长度的 16 个字节

js
const buffer = new ArrayBuffer(16);

此时,我们有一块内存,其所有字节都预先初始化为 0。不过,我们能做的事情不多。例如,我们可以确认缓冲区的大小是否正确

js
if (buffer.byteLength === 16) {
  console.log("Yes, it's 16 bytes.");
} else {
  console.log("Oh no, it's the wrong size!");
}

在我们真正能够使用此缓冲区之前,我们需要创建一个视图。让我们创建一个将缓冲区中的数据视为 32 位有符号整数数组的视图

js
const int32View = new Int32Array(buffer);

现在我们可以像普通数组一样访问数组中的字段

js
for (let i = 0; i < int32View.length; i++) {
  int32View[i] = i * 2;
}

这将数组中的 4 个条目(每个 4 个字节的 4 个条目总共 16 个字节)填充为 0246

同一数据的多个视图

当您考虑可以创建多个指向同一数据的视图时,事情开始变得非常有趣。例如,给定上面的代码,我们可以继续如下操作

js
const int16View = new Int16Array(buffer);

for (let i = 0; i < int16View.length; i++) {
  console.log(`Entry ${i}: ${int16View[i]}`);
}

这里我们创建了一个 16 位整数视图,它与现有的 32 位视图共享相同的缓冲区,并且我们将缓冲区中的所有值输出为 16 位整数。现在我们得到输出 00204060(假设是小端编码)

Int16Array  |   0  |  0   |   2  |  0   |   4  |  0   |   6  |  0   |
Int32Array  |      0      |      2      |      4      |      6      |
ArrayBuffer | 00 00 00 00 | 02 00 00 00 | 04 00 00 00 | 06 00 00 00 |

不过,您还可以更进一步。考虑一下这个

js
int16View[0] = 32;
console.log(`Entry 0 in the 32-bit array is now ${int32View[0]}`);

此输出为 "32 位数组中的条目 0 现在为 32"

换句话说,这两个数组确实是在同一个数据缓冲区上查看的,将它视为不同的格式。

Int16Array  |  32  |  0   |   2  |  0   |   4  |  0   |   6  |  0   |
Int32Array  |     32      |      2      |      4      |      6      |
ArrayBuffer | 20 00 00 00 | 02 00 00 00 | 04 00 00 00 | 06 00 00 00 |

您可以对任何视图类型执行此操作,尽管如果您设置一个整数然后将其读取为浮点数,您可能会得到一个奇怪的结果,因为位被解释的方式不同。

js
const float32View = new Float32Array(buffer);
console.log(float32View[0]); // 4.484155085839415e-44

从缓冲区读取文本

缓冲区并不总是表示数字。例如,读取文件可以为您提供文本数据缓冲区。您可以使用类型化数组从缓冲区中读取此数据。

以下使用TextDecoder Web API 读取 UTF-8 文本

js
const buffer = new ArrayBuffer(8);
const uint8 = new Uint8Array(buffer);
// Data manually written here, but pretend it was already in the buffer
uint8.set([228, 189, 160, 229, 165, 189]);
const text = new TextDecoder().decode(uint8);
console.log(text); // "你好"

以下使用String.fromCharCode() 方法读取 UTF-16 文本

js
const buffer = new ArrayBuffer(8);
const uint16 = new Uint16Array(buffer);
// Data manually written here, but pretend it was already in the buffer
uint16.set([0x4f60, 0x597d]);
const text = String.fromCharCode(...uint16);
console.log(text); // "你好"

处理复杂的数据结构

通过将单个缓冲区与不同类型的多个视图结合起来,从缓冲区中的不同偏移量开始,您可以与包含多种数据类型的数据对象交互。这使您能够例如与来自WebGL 的复杂数据结构或数据文件交互。

考虑以下 C 结构

cpp
struct someStruct {
  unsigned long id;
  char username[16];
  float amountDue;
};

您可以像这样访问包含此格式数据的缓冲区

js
const buffer = new ArrayBuffer(24);

// ... read the data into the buffer ...

const idView = new Uint32Array(buffer, 0, 1);
const usernameView = new Uint8Array(buffer, 4, 16);
const amountDueView = new Float32Array(buffer, 20, 1);

然后您可以访问例如应付金额,方法是使用 amountDueView[0]

注意:C 结构中的数据结构对齐 是依赖于平台的。请注意这些填充差异并采取预防措施。

转换为普通数组

处理类型化数组后,有时将其转换回普通数组以利用Array 原型会很有用。这可以使用Array.from() 完成

js
const typedArray = new Uint8Array([1, 2, 3, 4]);
const normalArray = Array.from(typedArray);

以及扩展语法

js
const typedArray = new Uint8Array([1, 2, 3, 4]);
const normalArray = [...typedArray];

另请参阅