类型化数组

类型化数组是有特殊用途的数组,被设计用来处理数值类型数据(而不像名称暗示的那样,能处理所有类型)。类型化数组的起源可以追溯到 WebGL —— Open GL ES 2.0 的一个接口,设计用于配合网页上的 <canvas> 元素。类型化数组作为该接口实现的一部分,为 JS 提供了快速的按位运算能力。

对于 WebGL 的需求来说,JS 原生的数学运算实在太慢,因为它使用 64 位浮点数格式来存储数值,并在必要时将其转换为 32 位整数。引入类型化数组突破了格式限制并带来了更好的数学运算性能,其设计概念是:单个数值可以被视为由 “位” 构成的数组,并且可以对其使用与 JS 数组现有方法类似的方法。

ES6 采纳了类型化数组,将其作为语言的一个正式部分,以确保在 JS 引擎之间有更好的兼容性,并确保与 JS 数组有更好的互操作性。尽管 ES6 的类型化数组与 WebGL 的类型化数组并不完全一样,但它们已足够相似,使得前者可以被视为后者的进化版本,而不至于是完全不同的。

数值数据类型

JS 数值使用 IEEE 754 标准格式存储,使用 64 位来存储一个数值的浮点数表示形式,该格式在 JS 中被同时用来表示整数与浮点数;当值改变时,可能会频繁发生整数与浮点数之间的格式转换。而类型化数组则允许存储并操作八种不同的数值类型:

  1. 8 位有符号整数(int8)

  2. 8 位无符号整数(uint8)

  3. 16 位有符号整数(int16)

  4. 16 位无符号整数(uint16)

  5. 32 位有符号整数(int32)

  6. 32 位无符号整数(uint32)

  7. 32 位浮点数(float32)

  8. 64 位浮点数(float64)

如果你将一个 int8 范围内的数表示为常规的 JS 数值,你就浪费了 56 个位,而这些浪费的位本可用来存储额外的 int8 值、或任意需求小于 56 位的数值。更有效地利用 “位” 是类型化数组处理的用例之一。

所有与类型化数组相关的操作和对象都围绕着这八种数据类型。为了使用它们,你首先需要创建一个数组缓冲区用于存储数据。

在本书中,我将使用上述列表中括号内的缩写词来表示这些类型,不过这些缩写并不会出现在实际的 JS 代码中,因为它们仅仅是对超长描述信息的速记。

数组缓冲区

数组缓冲区(array buffer)是内存中包含一定数量字节的区域,而所有的类型化数组都基于数组缓冲区。创建数组缓冲区类似于在 C 语言中使用 malloc() 来分配内存,而不需要指定这块内存包含什么。你可以像下例这样使用 ArrayBuffer 构造器来创建一个数组缓冲区:

let buffer = new ArrayBuffer(10);   // allocate 10 bytes

调用 ArrayBuffer 构造器时,只需要传入单个数值用于指定缓冲区包含的字节数,而本例就创建了一个 10 字节的缓冲区。当数组缓冲区被创建完毕后,你就可以通过检查 byteLength 属性来获取缓冲区的字节数:

let buffer = new ArrayBuffer(10);   // allocate 10 bytes
console.log(buffer.byteLength);     // 10

你还可以使用 slice() 方法来创建一个新的、包含已有缓冲区部分内容的数组缓冲区。该 slice() 方法类似于数组上的同名方法,可以使用起始位置与结束位置参数,返回由原缓冲区元素组成的一个新的 ArrayBuffer 实例。例如:

let buffer = new ArrayBuffer(10);   // allocate 10 bytes


let buffer2 = buffer.slice(4, 6);
console.log(buffer2.byteLength);    // 2

此代码创建了 buffer2 数组缓冲区,提取了原缓冲区索引值为 4 与 5 的元素。与数组的同名方法一样,结束参数所对应的元素是不会包含在结果中的。

当然,仅仅创建一个存储区域而不能写入数据,没有什么意义。为了写入数据,你需要创建一个视图(view):

数组缓冲区总是保持创建时指定的字节数,你可以修改其内部的数据,但永远不能修改它的容量。

使用视图操作数组缓冲区

数组缓冲区代表了一块内存区域,而视图(views)则是你操作这块区域的接口。视图工作在数组缓冲区或其子集上,可以读写某种数值数据类型的数据。DataView 类型是数组缓冲区的通用视图,允许你对前述所有八种数值数据类型进行操作。

使用 DataView,首先需要创建 ArrayBuffer 的一个实例,再在上面创建一个新的 ArrayBuffer 视图。这里有个例子:

let buffer = new ArrayBuffer(10),
    view = new DataView(buffer);

本例中的 view 对象可以使用 buffer 对象的所有 10 个字节。而你也可以在缓冲区的一个部分上创建视图,只需要指定可选参数——字节偏移量、以及所要包含的字节数。当未提供最后一个参数时,该 DataView 视图会默认包含从偏移位置开始、到缓冲区末尾为止的元素。例如:

let buffer = new ArrayBuffer(10),
    view = new DataView(buffer, 5, 2);      // cover bytes 5 and 6

此例中的 view 只能使用索引值为 5 与 6 的字节。使用这种方式,你可以在同一个数组缓冲区上创建多个不同的视图,这样有助于将单块内存区域供给整个应用使用,而不必每次在有需要时才动态分配内存。

获取视图信息

你可以通过查询以下只读属性来获取视图的信息:

  • buffer:该视图所绑定的数组缓冲区;

  • byteOffset:传给 DataView 构造器的第二个参数,如果当时提供了的话(默认值为 0);

  • byteLength:传给 DataView 构造器的第三个参数,如果当时提供了的话(默认值为该缓冲区的 byteLength 属性)。

使用这些属性,你就可以查出所操作视图的准确位置,例如:

let buffer = new ArrayBuffer(10),
    view1 = new DataView(buffer),           // cover all bytes
    view2 = new DataView(buffer, 5, 2);     // cover bytes 5 and 6

console.log(view1.buffer === buffer);       // true
console.log(view2.buffer === buffer);       // true
console.log(view1.byteOffset);              // 0
console.log(view2.byteOffset);              // 5
console.log(view1.byteLength);              // 10
console.log(view2.byteLength);              // 2

此代码创建了包含整个缓冲区的 view1 视图,并创建了包含缓冲区一小部分的 view2 视图。这两个视图拥有相同的 buffer 属性值,因为它们是在同一个数组缓冲区上工作的;而二者的 byteOffset 与 byteLength 属性就不相等了。这些属性反映出视图使用了缓冲区的哪些部分。

当然,仅仅读取缓冲区的内存信息不太有用,你还需要能向其写入数据并重新读出数据。

读取与写入数据

对应于 JS 所有八种数值数据类型,DataView 视图的原型分别提供了在数组缓冲区上写入数据的一个方法、以及读取数据的一个方法。所有方法名都以 “set” 或 “get” 开始,其后跟随着对应数据类型的缩写。下面列出了能够操作 int8 或 uint8 类型的读取/写入方法:

  • getInt8(byteOffset, littleEndian):从 byteOffset 处开始读取一个 int8 值;

  • setInt8(byteOffset, value, littleEndian):从 byteOffset 处开始写入一个 int8 值;

  • getUint8(byteOffset, littleEndian):从 byteOffset 处开始读取一个无符号 int8 值;

  • setUint8(byteOffset, value, littleEndian):从 byteOffset 处开始写入一个无符号 int8 值。

“get” 方法接受两个参数:开始进行读取的字节偏移量、以及一个可选的布尔值, 后者用于指定读取的值是否采用低字节优先方式(注:默认值为 false)。 “set”方法则接受三个参数:开始进行写入的字节偏移量、需要写入的数据值、以及一个可选的布尔值用于指定是否采用低字节优先方式存储数据。

译注:低字节优先(Little-endian)也被翻译作 “小端字节序”,指的是在存储数据的多个内存字节中,第一个内存字节存储着数据的最低字节数据,而最后一个内存字节存储着最高字节数据。

例如:十进制数 5882 用十六进制表示是 16FA,如果采用低字节优先方式、并使用 4 字节(即 32 位)存储,则该数字在内存中会被存储为 FA 16 00 00 。而如果采用相反的存储方式:高字节优先(Big-endian,大端字节序),那么该数字则会被存储为 00 00 16 FA。

尽管上面只列出了操作 8 位值的方法,但只要将方法名中的 8 替换为 16 或 32 ,便可以用来操作 16 位或 32 位值。而除了这些整数类方法之外,DataView 也提供了下列读写方法以便处理浮点数:

  • getFloat32(byteOffset, littleEndian):从 byteOffset 处开始读取一个 32 位的浮点数;

  • setFloat32(byteOffset, value, littleEndian):从 byteOffset 处开始写入一个 32 位的浮点数;

  • getFloat64(byteOffset, littleEndian):从 byteOffset 处开始读取一个 64 位的浮点数;

  • setFloat64(byteOffset, value, littleEndian):从 byteOffset 处开始写入一个 64 位的浮点数。

为了弄懂 “set” 与 “get” 方法如何使用,可研究下面的例子:

let buffer = new ArrayBuffer(2),
    view = new DataView(buffer);

view.setInt8(0, 5);
view.setInt8(1, -1);

console.log(view.getInt8(0));       // 5
console.log(view.getInt8(1));       // -1

该代码使用一个双字节的数组缓冲区来存储两个 int8 值。第一个值被存储在位置 0,而第二个值则被存储在位置 1,表示每个值占用了一个完整的字节(8 位),此后还使用 getInt8() 方法来将这些值从对应位置读取出来。尽管这个例子只使用了 int8 类型的值,但你却可以使用八种数值数据类型的所有对应方法。

视图允许你使用任意格式对任意位置进行读写,而无须考虑这些数据此前是使用什么格式存储的,这非常有意思。例如,向缓冲区写入两个 int8 值,并将其作为一个 int16 值读取出来,这是完全可行的,如同下面这个例子:

let buffer = new ArrayBuffer(2),
    view = new DataView(buffer);

view.setInt8(0, 5);
view.setInt8(1, -1);

console.log(view.getInt16(0));      // 1535
console.log(view.getInt8(0));       // 5
console.log(view.getInt8(1));       // -1

该代码使用 view.getInt16(0) 读取了该视图的所有字节,并将其解析为数值 1535。为了理解这个范例,可以参阅下面的示意图,它揭示了每个 setInt8() 操作对缓冲区造成的变化:

new ArrayBuffer(2)      0000000000000000
view.setInt8(0, 5);     0000010100000000
view.setInt8(1, -1);    0000010111111111

开始时,该数组缓冲区 16 个位均为 0;使用 setInt8() 向第一个字节写入 5 之后,该字节的内容就出现了一对 1(因为 5 可以写为 8 位二进制数 00000101 );向第二个字节写入 -1 会使得该字节的所有位都变成 1(即 -1 的二进制补码形式)。接下来使用 getInt16() 就能将前面写入的 16 位数据以单个 16 位整数的方式读取出来,其十进制值就是 1535 。

在混用不同的数据类型时,使用 DataView 对象是一种完美方式。不过,若仅想使用特定的一种数据类型,那么特定类型视图会是更好的选择。

类型化数组即为视图

ES6 的类型化数组实际上也是针对数组缓冲区的特定类型视图,你可以使用这些数组对象来处理特定的数据类型,而不必使用通用的 DataView 对象。一共存在八种特定类型视图,对应着八种数值数据类型,为处理 uint8 值提供了额外的选择。

特定类型视图被包含在 ES6 规范的 22.2 小节中,下表列出了它们的概要:

image 2024 05 18 21 39 06 747

左边一列列出了类型化数组的构造器,而其他列则描述了对应的类型化数组所能包含的数据。Uint8ClampedArray 的特性与 Uint8Array 基本相同,只有当缓冲区包含的值小于 0 或者大于 255 的时候才有区别:当值小于 0 时,Uint8ClampedArray 会将该值转换为 0 进行存储(例如 -1 会被存储为 0 );而当值大于 255 时,会被转换为 255(例如 300 会被存储为 255)。

类型化数组只能在特定的一种数据类型上工作,例如:Int8Array 的所有操作都只能处理 int8 值。每种类型化数组的单个元素大小也都取决于对应类型,Int8Array 中每个元素都是单字节的,而 Float64Array 则使用了八个字节来存储单个元素。幸运的是,类型化数组的元素可以使用数值型的索引位置来访问,就像常规数组那样,从而规避了使用 DataView 存取方法时的某些尴尬情况。

元素大小

每一种类型化数组都由一定数量的元素构成,而 “元素大小” 则代表每个类型的单个元素所包含的字节数。这个数字被存储在类型化数组每个构造器与每个实例的 BYTES_PER_ELEMENT 属性中,方便你查询元素的大小:

console.log(UInt8Array.BYTES_PER_ELEMENT); // 1
console.log(UInt16Array.BYTES_PER_ELEMENT); // 2

let ints = new Int8Array(5);
console.log(ints.BYTES_PER_ELEMENT); // 1

创建特定类型视图

类型化数组的构造器可以接受多种类型的参数,因此存在几种创建类型化数组的方式。 第一种方式是使用与创建 DataView 时相同的参数,即:一个数组缓冲区、一个可选的字节偏移量、以及一个可选的字节数量。例如:

let buffer = new ArrayBuffer(10),
    view1 = new Int8Array(buffer),
    view2 = new Int8Array(buffer, 5, 2);

console.log(view1.buffer === buffer);       // true
console.log(view2.buffer === buffer);       // true
console.log(view1.byteOffset);              // 0
console.log(view2.byteOffset);              // 5
console.log(view1.byteLength);              // 10
console.log(view2.byteLength);              // 2

此代码在 buffer 对象上创建了两个 Int8Array 类型的视图:view1 与 view2,而这两个视图拥有相同的 buffer、byteOffset 与 byteLength 属性。如果你的操作只针对一种数值类型,那么很容易就能把代码从使用 DataView 视图切换到使用某种类型化数组。

第二种方式是传递单个数值给类型化数组的构造器,此数值表示该数组包含的元素数量(而不是分配的字节数)。构造器将会创建一个新的缓冲区,分配正确的字节数以便容纳指定数量的数组元素,而你也可以使用 length 属性来获取这个元素数量。例如:

let ints = new Int16Array(2),
    floats = new Float32Array(5);

console.log(ints.byteLength);       // 4
console.log(ints.length);           // 2

console.log(floats.byteLength);     // 20
console.log(floats.length);         // 5

示例中的 ints 数组创建时包含了两个元素,而每个 16 位整数需要使用两个字节,因此该数组一共被分配了 4 个字节。floats 数组则包含五个元素,因此它就需要 20 个字节(每个元素占用四个字节)。这两个数组都创建了对应的数组缓冲区,而在必要时都可以使用 buffer 属性来访问各自的缓冲区。

如果调用类型化数组构造器时没有传入参数,构造器会认为传入了 0,这种方式创建的类型化数组不会被分配任何存储空间,因此也就不能被用于保存数据。

第三种方式是向构造器传递单个对象参数,可以是下列四种对象之一:

  • 类型化数组:数组所有元素都会被复制到新的类型化数组中。例如,如果你传递一个 int8 类型的数组给 Int16Array 构造器,这些 int8 的值会被复制到 int16 数组中。新的类型化数组与原先的类型化数组会使用不同的数组缓冲区。

  • 可迭代对象:该对象的迭代器会被调用以便将数据插入到类型化数组中。如果其中包含了不匹配视图类型的值,那么构造器就会抛出错误。

  • 数组:该数组的元素会被插入到新的类型化数组中。如果其中包含了不匹配视图类型的值,那么构造器就会抛出错误。

  • 类数组对象:与传入数组的表现一致。

在上述任意可能中,新的类型化数组都会从原对象获取数据。若想用一些值来初始化一个类型化数组,这种方式就特别有用,就像这样:

let ints1 = new Int16Array([25, 50]),
    ints2 = new Int32Array(ints1);

console.log(ints1.buffer === ints2.buffer);     // false

console.log(ints1.byteLength);      // 4
console.log(ints1.length);          // 2
console.log(ints1[0]);              // 25
console.log(ints1[1]);              // 50

console.log(ints2.byteLength);      // 8
console.log(ints2.length);          // 2
console.log(ints2[0]);              // 25
console.log(ints2[1]);              // 50

该例使用了一个包含两个值的数组来创建一个 Int16Array 并初始化它,之后又利用该 Int16Array 创建了一个 Int32Array。25 与 50 这两个值从 ints1 数组中被复制到 ints2 数组中,但两个数组使用了全然不同的缓冲区。虽然二者都包含了相同的数值,但后者占用了 8 个字节,而前者只占用了 4 字节。