类型化数组 - 浏览器中的二进制数据

Ilmari Heikkinen

简介

类型化数组是浏览器相对较新的产品,其诞生是为了满足在 WebGL 中处理二进制数据的有效方式。类型化数组是内存的 Slab,其中包含类型化视图,与数组在 C 语言中的工作方式非常相似。由于类型化数组由原始内存提供支持,因此 JavaScript 引擎可以将内存直接传递给原生库,而无需费心地将数据转换为原生表示形式。因此,类型化数组在将数据传递给 WebGL 以及其他处理二进制数据的 API 时性能要优于 JavaScript 数组。

对于 ArrayBuffer 的某个细分,类型化数组视图的作用类似于单类型数组。这里有适用于所有常见数值类型的视图,名称采用自描述性名称,例如 Float32Array、Float64Array、Int32Array 和 Uint8Array。还有一个特殊的视图替换了 Canvas 的 ImageData 中的像素数组类型:Uint8ClampedArray。

DataView 是第二种类型的视图,用于处理异构数据。DataView 对象不提供类似于数组的 API,而是为您提供获取/设置 API 来以任意字节偏移量读取和写入任意类型的数据。DataView 非常适合读取和写入文件标头以及其他类似结构体的数据。

使用类型化数组的基础知识

类型化数组视图

要使用类型化数组,您需要创建一个 ArrayBuffer 和一个指向该数组的视图。最简单的方法是创建所需大小和类型的类型化数组视图。

// Typed array views work pretty much like normal arrays.
var f64a = new Float64Array(8);
f64a[0] = 10;
f64a[1] = 20;
f64a[2] = f64a[0] + f64a[1];

有几种不同类型的数组视图。它们都共用相同的 API,所以一旦您知道如何使用某个 API,就能充分了解它们的使用方式。在下一个示例中,我将为每个当前现有的类型数组视图分别创建一个。

// Floating point arrays.
var f64 = new Float64Array(8);
var f32 = new Float32Array(16);

// Signed integer arrays.
var i32 = new Int32Array(16);
var i16 = new Int16Array(32);
var i8 = new Int8Array(64);

// Unsigned integer arrays.
var u32 = new Uint32Array(16);
var u16 = new Uint16Array(32);
var u8 = new Uint8Array(64);
var pixels = new Uint8ClampedArray(64);

最后一个有点特殊,它会将输入值限制在 0 到 255 之间。这对于 Canvas 图片处理算法来说尤其方便,因为现在您无需手动限制图片处理数学运算,以免溢出 8 位范围。

例如,下面展示了如何对 Uint8Array 中存储的图片应用灰度系数。不太美观:

u8[i] = Math.min(255, Math.max(0, u8[i] * gamma));

借助 Uint8ClampedArray,您可以跳过手动取值范围限制:

pixels[i] *= gamma;

创建类型化数组视图的另一种方法是先创建一个 ArrayBuffer,然后创建指向它的视图。用于获取外部数据的 API 通常在 ArrayBuffers 中处理,因此您可以通过这种方式获取类型化数组视图。

var ab = new ArrayBuffer(256); // 256-byte ArrayBuffer.
var faFull = new Uint8Array(ab);
var faFirstHalf = new Uint8Array(ab, 0, 128);
var faThirdQuarter = new Uint8Array(ab, 128, 64);
var faRest = new Uint8Array(ab, 192);

您还可以为同一个 ArrayBuffer 设置多个视图。

var fa = new Float32Array(64);
var ba = new Uint8Array(fa.buffer, 0, Float32Array.BYTES_PER_ELEMENT); // First float of fa.

如需将某个类型化数组复制到另一个类型化数组,最快的方式是使用类型化数组集方法。像 memcpy 一样,创建 Uint8Arrays 到视图的缓冲区,并使用 set 复制数据。

function memcpy(dst, dstOffset, src, srcOffset, length) {
  var dstU8 = new Uint8Array(dst, dstOffset, length);
  var srcU8 = new Uint8Array(src, srcOffset, length);
  dstU8.set(srcU8);
};

DataView

若要使用包含异构类型数据的 ArrayBuffers,最简单的方法是使用 DataView 访问相应缓冲区。假设我们的一种文件格式包括一个 8 位无符号 int,后跟两个 16 位整数,后跟一个由 32 位浮点数组成的有效负载数组。使用类型化数组视图重新阅读此内容是可以的,但有点麻烦。借助 DataView,我们可以读取标头并为浮点数组使用类型化数组视图。

var dv = new DataView(buffer);
var vector_length = dv.getUint8(0);
var width = dv.getUint16(1); // 0+uint8 = 1 bytes offset
var height = dv.getUint16(3); // 0+uint8+uint16 = 3 bytes offset
var vectors = new Float32Array(width*height*vector_length);
for (var i=0, off=5; i<vectors.length; i++, off+=4) {
  vectors[i] = dv.getFloat32(off);
}

在上面的示例中,我读取的所有值都是 big-endian。如果缓冲区中的值采用小端字节序,您可以将可选的 smallEndian 参数传递给 getter:

...
var width = dv.getUint16(1, true);
var height = dv.getUint16(3, true);
...
vectors[i] = dv.getFloat32(off, true);
...

请注意,类型化数组视图始终采用原生字节顺序。这是为了提高速度。您应使用 DataView 来读取和写入会造成字节序问题的数据。

DataView 还有用于将值写入缓冲区的方法。这些 setter 的命名方式与 getter 相同,即“set”后跟数据类型。

dv.setInt32(0, 25, false); // set big-endian int32 at byte offset 0 to 25
dv.setInt32(4, 25); // set big-endian int32 at byte offset 4 to 25
dv.setFloat32(8, 2.5, true); // set little-endian float32 at byte offset 8 to 2.5

有关字节序的讨论

字节顺序是多字节数字在计算机内存中的存储顺序。big-endian 一词用于描述最高有效字节最先存储的 CPU 架构,小端字节,即最低有效字节最先存储。在给定的 CPU 架构中使用哪种字节顺序完全是任意的;选择任意一种有充分的理由。事实上,某些 CPU 可以配置为同时支持大端字节序和小端字节序数据。

为什么需要关注字节顺序?原因很简单。从磁盘或网络读取或写入数据时,必须指定数据的字节顺序。这可确保无论处理数据的 CPU 采用什么字节顺序,都能正确解读数据。在我们日益普及的网络世界中,必须正确支持所有类型的设备(大端或小端),这些设备可能需要处理来自服务器或网络上其他对等设备的二进制数据。

DataView 接口专门用于从文件和网络读写数据。DataView 会处理具有指定字节序的数据。每次访问每个值时,都必须指定字节序(大或小),确保无论浏览器在哪个 CPU 上运行,在读取或写入二进制数据时都能获得一致且正确的结果。

通常,当您的应用从服务器读取二进制数据时,您需要扫描一次该数据,才能将其转换为您的应用在内部使用的数据结构。在此阶段应使用 DataView。我们不建议将多字节类型数组视图(Int16Array、Uint16Array 等)直接用于通过 XMLHttpRequest、FileReader 或任何其他输入/输出 API 提取的数据,因为类型化数组视图使用 CPU 的原生字节顺序。稍后会详细介绍。

我们来看几个简单的示例。Windows BMP 文件格式在 Windows 早期阶段是用于存储图片的标准格式。上面链接的文档明确指出文件中的所有整数值均以小端格式存储。以下代码段使用本文随附的 DataStream.js 库解析 BMP 标头的开头:

function parseBMP(arrayBuffer) {
  var stream = new DataStream(arrayBuffer, 0,
    DataStream.LITTLE_ENDIAN);
  var header = stream.readUint8Array(2);
  var fileSize = stream.readUint32();
  // Skip the next two 16-bit integers
  stream.readUint16();
  stream.readUint16();
  var pixelOffset = stream.readUint32();
  // Now parse the DIB header
  var dibHeaderSize = stream.readUint32();
  var imageWidth = stream.readInt32();
  var imageHeight = stream.readInt32();
  // ...
}

下面是另一个示例,这个示例来自 WebGL 示例项目中的高动态范围渲染演示。此演示会下载表示高动态范围纹理的原始小端序浮点数据,并需要将其上传到 WebGL。以下代码段可在所有 CPU 架构上正确解释浮点值。假设变量“arrayBuffer”是刚刚通过 XMLHttpRequest 从服务器下载的 ArrayBuffer:

var arrayBuffer = ...;
var data = new DataView(arrayBuffer);
var tempArray = new Float32Array(
  data.byteLength / Float32Array.BYTES_PER_ELEMENT);
var len = tempArray.length;
// Incoming data is raw floating point values
// with little-endian byte ordering.
for (var jj = 0; jj < len; ++jj) {
  tempArray[jj] =
    data.getFloat32(jj * Float32Array.BYTES_PER_ELEMENT, true);
}
gl.texImage2D(...other arguments...,
  gl.RGB, gl.FLOAT, tempArray);

一般而言:在从网络服务器接收二进制数据时,使用 DataView 进行一次传递。读取各个数值并将其存储在其他一些数据结构中,这些数据结构可以是 JavaScript 对象(适用于少量结构化数据)或类型化数组视图(适用于大型数据块)。这样可以确保您的代码在所有类型的 CPU 上都能正常运行。此外,您还可以使用 DataView 将数据写入文件或网络,并确保为各种 set 方法适当地指定 littleEndian 参数,以便生成您要创建或使用的文件格式。

请记住,网络的所有数据都会隐式具有格式和字节序(至少对于任何多字节值而言)。请务必明确定义并记录您的应用通过网络发送的所有数据的格式。

使用类型化数组的浏览器 API

我会简要介绍目前使用类型化数组的各种浏览器 API。当前剪裁范围包括 WebGL、Canvas、Web Audio API、XMLHttpRequests、WebSocket、Web Worker、Media Source API 和 File API。从 API 列表中可以看出,类型化数组非常适合对性能敏感的多媒体工作,以及高效传递数据。

WebGL

类型化数组首次使用是在 WebGL 中,它用于传递缓冲区数据和图片数据。要设置 WebGL 缓冲区对象的内容,请结合使用 gl.bufferData() 调用和类型化数组。

var floatArray = new Float32Array([1,2,3,4,5,6,7,8]);
gl.bufferData(gl.ARRAY_BUFFER, floatArray);

类型化数组也用于传递纹理数据。下面是一个使用类型化数组传入纹理内容的基本示例。

var pixels = new Uint8Array(16*16*4); // 16x16 RGBA image
gl.texImage2D(
  gl.TEXTURE_2D, // target
  0, // mip level
  gl.RGBA, // internal format
  16, 16, // width and height
  0, // border
  gl.RGBA, //format
  gl.UNSIGNED_BYTE, // type
  pixels // texture data
);

您还需要使用类型化数组从 WebGL 上下文读取像素。

var pixels = new Uint8Array(320*240*4); // 320x240 RGBA image
gl.readPixels(0, 0, 320, 240, gl.RGBA, gl.UNSIGNED_BYTE, pixels);

2D 画布

最近推出了 Canvas ImageData 对象,以符合类型化数组规范。现在,您可以获取画布元素上像素的类型化数组表示法。这非常有用,因为现在您还可以创建和修改画布像素数组,而无需费心处理画布元素。

var imageData = ctx.getImageData(0,0, 200, 100);
var typedArray = imageData.data // data is a Uint8ClampedArray

XMLHttpRequest2

XMLHttpRequest 获得了类型化数组提升,现在您可以接收类型化数组响应,而不必将 JavaScript 字符串解析为类型化数组。将提取的数据直接传递到多媒体 API,以及解析从网络提取的二进制文件时,这真的非常方便。

您只需将 XMLHttpRequest 对象的 responseType 设置为“arraybuffer”。

xhr.responseType = 'arraybuffer';

回想一下,从网络下载数据时,您必须注意字节顺序问题!请参阅上面有关字节顺序的部分。

文件 API

FileReader 可以将文件内容作为 ArrayBuffer 读取。然后,您可以将类型化数组视图和 DataView 附加到缓冲区,以操纵其内容。

reader.readAsArrayBuffer(file);

在这里,您也应注意字节顺序。如需了解详情,请查看字节顺序部分。

可传输对象

postMessage 中的可传输对象可以显著加快将二进制数据传递到其他窗口和 Web Worker 的速度。当您将对象作为 Transferable 向 worker 发送时,该对象在发送线程中变得无法访问,并且接收 worker 将获得对象的所有权。这样可以实现高度优化的实现,其中发送的数据不会被复制,而类型化数组的所有权会转移给接收器。

如需将 Transferable 对象与 Web Workers 搭配使用,您需要在 worker 上使用 webkitPostMessage 方法。webkitPostMessage 方法的工作原理与 postMessage 相同,但前者使用两个参数,而不是一个。添加的第二个参数是要传递给 worker 的一系列对象。

worker.webkitPostMessage(oneGBTypedArray, [oneGBTypedArray]);

要从 worker 获取对象,Worker 可以以相同的方式将它们传递回主线程。

webkitPostMessage({results: grand, youCanHaveThisBack: oneGBTypedArray}, [oneGBTypedArray]);

没有任何副本,哇!

媒体来源 API

最近,媒体元素还以 Media Source API 的形式获得了类型化数组的一些优势。您可以使用 webkitSourceAttach 直接将包含视频数据的类型化数组传递给视频元素。这样,视频元素会将视频数据附加到现有视频之后。SourceAttach 非常适用于插页式广告、播放列表、流式传输以及您可能想使用单个视频元素播放多个视频的其他用途。

video.webkitSourceAppend(uint8Array);

二进制 WebSocket

您还可以将类型化数组与 WebSocket 结合使用,以避免对所有数据进行字符串化处理。非常适合编写高效协议和最大限度地减少网络流量。

socket.binaryType = 'arraybuffer';

呼!API 审核到此结束。接下来,我们看看用于处理类型化数组的第三方库。

第三方库

jDataView

jDataView 会为所有浏览器实现 DataView shim。DataView 过去是仅限 WebKit 的功能,但现在大多数其他浏览器都支持此功能。Mozilla 开发者团队正在着力安装一个补丁,以便同时在 Firefox 上启用 DataView。

Chrome 开发技术推广团队的 Eric Bidelman 编写了一个使用 jDataView 的小型 MP3 ID3 ID3 标签读取器示例。以下是这篇博文中的用法示例:

var dv = new jDataView(arraybuffer);

// "TAG" starts at byte -128 from EOF.
// See http://en.wikipedia.org/wiki/ID3
if (dv.getString(3, dv.byteLength - 128) == 'TAG') {
  var title = dv.getString(30, dv.tell());
  var artist = dv.getString(30, dv.tell());
  var album = dv.getString(30, dv.tell());
  var year = dv.getString(4, dv.tell());
} else {
  // no ID3v1 data found.
}

字符串编码

目前,在类型化数组中使用字符串有点麻烦,但 stringencoding 库可以帮到你。Stringencoding 实现了建议的类型化数组字符串编码规范,因此这也是了解即将推出的内容的好方法。

以下是 stringencoding 的基本用法示例:

var uint8array = new TextEncoder(encoding).encode(string);
var string = new TextDecoder(encoding).decode(uint8array);

BitView.js

我为类型化数组编写了一个名为 BitView.js 的小型运算库。顾名思义,它的工作方式与 DataView 非常相似,只不过它与位相关。利用 BitView,您可以获取和设置 ArrayBuffer 中给定位偏移的某个位值。BitView 还提供按任意位偏移存储和加载 6 位和 12 位整数的方法。

12 位整数非常适合处理屏幕坐标,因为显示屏在较长的维度上往往少于 4096 像素。通过使用 12 位整数而非 32 位整数,可将大小缩减 62%。举一个更极端的例子,我使用的是使用 64 位浮点数作为坐标的 Shapefile 文件,但我不需要精确度,因为模型只会在尺寸的屏幕上显示。切换到使用 6 位增量的 12 位基本坐标以对上一个坐标的变化进行编码后,文件大小缩减了十分之一。您可以在此处观看演示。

以下是 BitView.js 的使用示例:

var bv = new BitView(arrayBuffer);
bv.setBit(4, 1); // Set fourth bit of arrayBuffer to 1.
bv.getBit(17); // Get 17th bit of arrayBuffer.

bv.getBit(50*8 + 3); // Get third bit of 50th byte in arrayBuffer.

bv.setInt6(3, 18); // Write 18 as a 6-bit int to bit position 3 in arrayBuffer.
bv.getInt12(9); // Read a 12-bit int from bit position 9 in arrayBuffer.

DataStream.js

类型化数组最令人兴奋的一点就是,它们让我们能够更轻松地在 JavaScript 中处理二进制文件。现在,您可以使用 XMLHttpRequest 获取 ArrayBuffer,然后直接使用 DataView 对其进行处理,而无需逐个字符地解析字符串并手动将字符转换为二进制数。这样,您就可以轻松加载 MP3 文件并读取元数据标记,以便在音频播放器中使用。或者,加载 Shapefile 文件并将其转换为 WebGL 模型。或者读取 JPEG 文件的 EXIF 标签,并在幻灯片演示应用中显示这些标签。

ArrayBuffer XHR 的问题是,从缓冲区读取类结构的数据有点麻烦。DataView 适合以字节序安全的方式一次读取多个数字,类型化数组视图适合读取元素大小对齐的原生字节序的数组。我们觉得缺少一种以方便的字节序安全方式读取数据数组和结构体的方法。输入 DataStream.js。

DataStream.js 是一个类型化数组库,能够以类似于文件的方式读取和写入 ArrayBuffer 中数据的标量、字符串、数组和结构体。

从 ArrayBuffer 读取浮点数数组的示例:

// without DataStream.js
var dv = new DataView(buffer);
var f32 = new Float32Array(buffer.byteLength / 4);
var littleEndian = true;
for (var i = 0; i<f32.length; i++) {
  f32[i] = dv.getFloat32(i*4, littleEndian);
}

// with DataStream.js
var ds = new DataStream(buffer);
ds.endianness = DataStream.LITTLE_ENDIAN;
var f32 = ds.readFloat32Array(ds.byteLength / 4);

DataStream.js 真正的用处是读取更复杂的数据。假设您有一种在 JPEG 标记中读取数据的方法:

// without DataStream.js
var dv = new DataView(buffer);
var objs = [];
for (var i=0; i<buffer.byteLength;) {
  var obj = {};
  obj.tag = dv.getUint16(i);
  i += 2;
  obj.length = dv.getUint16(i);
  i += 2;
  obj.data = new Uint8Array(obj.length - 2);
  for (var j=0; j<obj.data.length; j++,i++) {
    obj.data[j] = dv.getUint8(i);
  }
  objs.push(obj);
}

// with DataStream.js
var ds = new DataStream(buffer);
ds.endianness = ds.BIG_ENDIAN;
var objs = [];
while (!ds.isEof()) {
  var obj = {};
  obj.tag = ds.readUint16();
  obj.length = ds.readUint16();
  obj.data = ds.readUint8Array(obj.length - 2);
  objs.push(obj);
}

或者使用 DataStream.readStruct 方法读取数据的结构。readStruct 方法接受一个结构体定义数组,其中包含结构体成员的类型。它具有用于处理复杂类型的回调函数,还可处理数据数组和嵌套结构体:

// with DataStream.readStruct
ds.readStruct([
  'objs', ['[]', [ // objs: array of tag,length,data structs
    'tag', 'uint16',
    'length', 'uint16',
    'data', ['[]', 'uint8', function(s,ds){ return s.length - 2; }], // get length with a function
  '*'] // read in as many struct as there are
]);

如您所见,结构体定义是 [名称, 类型] 对的平面数组。嵌套结构体是通过为该类型使用一个数组来实现的。数组是使用三个元素的数组定义的,其中第二个元素是数组元素类型,第三个元素是数组长度(作为数字、对先前读取的字段的引用或回调函数)。数组定义的第一个元素未使用。

该类型的可能值如下:

Number types

Unsuffixed number types use DataStream endianness.
To explicitly specify endianness, suffix the type with
'le' for little-endian or 'be' for big-endian,
e.g. 'int32be' for big-endian int32.

  'uint8' -- 8-bit unsigned int
  'uint16' -- 16-bit unsigned int
  'uint32' -- 32-bit unsigned int
  'int8' -- 8-bit int
  'int16' -- 16-bit int
  'int32' -- 32-bit int
  'float32' -- 32-bit float
  'float64' -- 64-bit float

String types

  'cstring' -- ASCII string terminated by a zero byte.
  'string:N' -- ASCII string of length N.
  'string,CHARSET:N' -- String of byteLength N encoded with given CHARSET.
  'u16string:N' -- UCS-2 string of length N in DataStream endianness.
  'u16stringle:N' -- UCS-2 string of length N in little-endian.
  'u16stringbe:N' -- UCS-2 string of length N in big-endian.

Complex types

  [name, type, name_2, type_2, ..., name_N, type_N] -- Struct

  function(dataStream, struct) {} -- Callback function to read and return data.

  {get: function(dataStream, struct) {}, set: function(dataStream, struct) {}}
  -- Getter/setter functions to reading and writing data. Handy for using the
     same struct definition for both reading and writing.

  ['', type, length] -- Array of given type and length. The length can be either
                        a number, a string that references a previously-read
                        field, or a callback function(struct, dataStream, type){}.
                        If length is set to '*', elements are read from the
                        DataStream until a read fails.

您可以点击此处查看读取 JPEG 元数据的实时示例。该演示使用 DataStream.js 读取 JPEG 文件的标记级结构(以及一些 EXIF 解析),并使用 jpg.js 以 JavaScript 解码和显示 JPEG 图片。

类型化数组的历史

类型化数组是从 WebGL 的早期实现阶段开始的,当时我们发现将 JavaScript 数组传递给图形驱动程序会导致性能问题。使用 JavaScript 数组时,WebGL 绑定必须分配原生数组,并通过遍历 JavaScript 数组来填充该数组,并将该数组中的每个 JavaScript 对象转换为所需的原生类型。

为了解决数据转换瓶颈,Mozilla 的 Vladimir Vukicevic 编写了 CanvasFloatArray:带有 JavaScript 接口的 C 样式浮点数组。现在,您可以使用 JavaScript 修改 CanvasFloatArray 并将其直接传递给 WebGL,而无需在绑定中做任何额外的工作。在进一步的迭代中,CanvasFloatArray 已重命名为 WebGLFloatArray,它进一步重命名为 Float32Array,并拆分为后备 ArrayBuffer 和类型化 Float32Array-view 以访问缓冲区。还针对其他整数和浮点大小以及有符号/无符号变体添加了类型。

设计考虑事项

从一开始,类型化数组的设计就围绕着将二进制数据高效地传递给原生库的需求。因此,类型化数组视图会针对主机 CPU 的原生字节顺序中的对齐数据进行操作。这些决策可让 JavaScript 在执行向显卡发送顶点数据等操作期间达到最高性能。

DataView 专为文件和网络 I/O 而设计,其中数据始终具有指定的字节序,并且可能为了获得最佳性能而不一致。

内存中数据汇编(使用类型化数组视图)和 I/O(使用 DataView)的设计是我们有意识的。现代 JavaScript 引擎会大量优化类型化数组视图,并使用该类视图在数值运算方面实现高性能。该设计决策实现了类型化数组视图的当前性能水平。

参考编号