WebGL Tutorial
and more

ArrayBuffer

撰写时间:2024-11-17

修订时间:2024-12-14

ArrayBuffer

JavaScript处理二进制数据的核心对象是ArrayBuffer

ArrayBuffer是以字节为单位所组成的原始二进制 (raw binary)数据缓冲区。

初始化

let buffer = new ArrayBuffer(5); pc.log(buffer);

使用5个字节初始化了ArrayBuffer的一个实例。

需注意的是,ArrayBuffer名字中虽出现了Array,但其原型并不是一个数组,而是一个以字节为单位来存储数据的对象。作为对比,下面是一个数组对象的原型情况。

let arr = [1, 2, 3, 4, 5]; pc.log(arr);

差别很大,因为属于不同的原型。

byteLength属性指明该缓冲区的字节数量。

我们注意到,ArrayBuffer并未对外提供访问内在数据的接口。它已经开辟了内存空间并已存储一定的数据,但如何使用,还依赖于我们准备如何使用这块内存区域。这就是视图(view)的作用。

通过视图访问ArrayBuffer

以Uint8Array为视图

可以将上面的buffer作为构造参数,创建Uint8Array的一个实例。

let buffer = new ArrayBuffer(5); let tarr = new Uint8Array(buffer); pc.log(tarr);

在这种关系下,变量tarr是变量buffer的一个视图。或者说,我们通过tarr来查看、操控buffer的数据。

Uint8Array,正如其名,是一个数组,每个数组元素的数据类型为unsigned int,每个元素为8位(即1个字节)。

Uint8Array有几个重要的属性,根据它们在prototype链路中的位置,列举如下:

  • Uint8Array
    • BYTES_PER_ELEMENT: 1
    • TypedArray
      • buffer
      • byteLength: 5
      • byteOffset
      • length: 5

Uint8Array的父级prototype链是TypedArray,我们可以简单地、不够语义严谨地认为,Uint8ArrayTypedArray的子类。这样,当我们说每个TypedArray时,即可以指代Uint8Array,也可以指代Float32Array,等等。

每个TypedArray都自带一个属性BYTES_PER_ELEMENT,指明该类型所构成的数组中,每个元素的字节长度。因此,Uint8Array每个元素均只有1个字节。

buffer, byteLength, byteOffset, 及length4个属性,均属于TypedArrayprototype链路中的属性。

length是指整个数组共有5个元素。

byteLength是指整个数组共有5个字节。它由以下公式计算得出:

byteLength = length × BYTES_PER_ELEMENT = 5 × 1 = 5

上面,tarrbuffer属性与byteOffset属性均与视图所在的数据源有关。其中,buffer属性引用了其数据源即变量buffer

let buffer = new ArrayBuffer(5); let tarr = new Uint8Array(buffer); pc.log(tarr.buffer === buffer); pc.log(tarr.buffer); pc.log(tarr.byteOffset);

buffer通过其byteLength属性说明其字长共有5个字节,与tarrbyteLength完全一样。tarrbyteOffset属性指明该视图所引用的数据在数据源中的开始位置,以字节为单位。

上面,tarrbyteOffset属性值为0,且其byteLength值也与bufferbyteLength值一样,说明tarr这个视图独占了、或说铺满了buffer的整个区域范围。

分割为多个视图

我们可以通过多个视图来分割同一数据源。

let arrayBuffer = new ArrayBuffer(10); let tarr1 = new Uint8Array(arrayBuffer, 0, 8); let tarr2 = new Uint8Array(arrayBuffer, 8, 2); pc.log(tarr1); pc.log(tarr2);

此时,Uint8Array构造函数原型如下:

new Uint8Array
  • ArrayBufferbuffer
  • NumberbyteOffset
  • Numberlength
buffer
数据源缓冲区。
byteOffset
从数据源的哪个字节开始。
length
共有多少个数据元素。

使用这种构造方法时需注意,视图不能越界。即,如果出现以下情况:

byteOffset + length * TypedArray.BYTES_PER_ELEMENT > buffer.byteLength

则会抛出RangeError的异常。

上面,视图tarr1从数据源的第0个字节的位置开始,共有8个字节。视图tarr2从数据源的第8个字节的位置开始,共有2个字节。这样,buffer这个数据源就完全由tarr1tarr2分割了。其内存示意图如下:

digraph { node [] edge [colorscheme=set312] ArrayBuffer [shape=plaintext, colorscheme=brbg9, label=<
arrayBuffer0000000000
0123456789
>] tarr1 [shape=plaintext, colorscheme=brbg9, label=<
tarr1bufferarrayBuffer
byteOffset0
length8
>] tarr2 [shape=plaintext, colorscheme=brbg9, label=<
tarr2bufferarrayBuffer
byteOffset8
length2
>] ArrayBuffer:f1 -> tarr1:f1 [color=1 dir=back]; ArrayBuffer:f2 -> tarr1:f2 [color=3 dir=back]; ArrayBuffer:f1 -> tarr2:f1 [color=1 dir=back]; ArrayBuffer:f3 -> tarr2:f2 [color=4 dir=back]; }

通过TypedArray创建内置ArrayBuffer

上面我们根据ArrayBuffer来创建TypedArray的实例,但也可以直接创建TypedArray的实例。

let tarr1 = new Float32Array(5); /* init with 5 elements */ pc.log(tarr1); pc.log(tarr1.buffer); let tarr2 = new Uint8Array([1, 2, 3]); /* init with an array */ pc.log(tarr2); pc.log(tarr2.buffer);

这种方式,每个TypedArray都有一个独立的ArrayBuffer对象,并且独占这个对象。

视图操控数据

TypedArray视图可以正常读写缓冲区中的数据。并且,它是一个类似于数组的对象,因此在大部分情况下也将其当作数组来操作。

let tarr = new Uint8Array(5); tarr[0] = 1; tarr[1] = [5]; tarr[4] = [3]; pc.log(tarr); let filtered = tarr.filter(ele => ele >= 3); pc.log(filtered);

TypedArray虽类似于Array,但与后者并非完全相同。例如,TypedArray就没有concat方法。下面可方便地比较两者的原型。

let tarr = new Uint8Array(); pc.log(tarr); pc.log([1, 2, 3]);

当然,这样还是很难精准地判断。下面的代码可以:

function getProptoTypeFuncNames(protoType) { let funcNamesArr = []; let propNames = Object.getOwnPropertyNames(protoType); for (let propName of propNames) { const desc = Object.getOwnPropertyDescriptor(protoType, propName); if (desc.hasOwnProperty('value')) { if (typeof(desc.value) === 'function' && propName !== 'constructor') { funcNamesArr.push(propName); } } } return funcNamesArr; } let typedArrayPrototype = Uint8Array.__proto__.prototype; let tarrFuncNames = getProptoTypeFuncNames(typedArrayPrototype); let arryPrototype = Array.prototype; let arrFuncNames = getProptoTypeFuncNames(arryPrototype); let filterA = tarrFuncNames.filter(ele => arrFuncNames.includes(ele)); let filterB = tarrFuncNames.filter(ele => !arrFuncNames.includes(ele)); let filterC = arrFuncNames.filter(ele => !tarrFuncNames.includes(ele)); pc.log('Methods that exists in both:'); pc.log(filterA.sort()); pc.log('\n'); pc.log('Methods that exists only in TypedArray:'); pc.log(filterB.sort()); pc.log('\n'); pc.log('Methods that exists only in Array:'); pc.log(filterC.sort());

从上面的结果可以得知,双方都有的方法共有29个。

如果我们只关心TypedArray的情况,则第二种结果显示,它自身独有的方法只有2个,setsubarray

而数组原型有,但TypedArray却没有的方法,共有9个,分别为:

  1. concat
  2. flat
  3. flatMap
  4. pop
  5. push
  6. shift
  7. splice
  8. toSpliced
  9. unshift

现在,TypedArray能调用什么方法,不能调用什么方法,就很清晰了。

大尾小尾问题

发现问题

对于类型化数组,如果该数组的单个元素的字长大于1时,则会出现大尾小尾问题。

const { getHexStr, getHexAddrStrFromTypedArray } = await import('/js/esm/BinUtils.js'); let tarr = new Uint16Array([0x1234]); pc.log('%s', getHexStr(tarr[0])); pc.log('%s', getHexAddrStrFromTypedArray(tarr));

上面,使用共2个字节的值0x1234作为唯一的数组元素,创建了Uint16Array的一个实例tarr。显示其值时,值为0x1234。但当查看其内存存储的字节顺序时,发现值为0x34的字节排在了值为0x12的前面。

为何不按0x12 0x34的顺序来存储?

当代表一个数值的字节数量大于1时,在内存中如何依序存储各个字节,这就是大尾小尾问题。

数值的权重位

int num = 123;

num的数值为123。根据我国的教学,1百位2十位3个位。这里,百位十位个位统称为数位百位十位的左边,十位个位的左边。

为何百位应排十位的左边,十位应排在个位的左边?

因为在百位的位置上,它所代表的数值比在十位的位置所代表的数值要大,所以百位应排在十位的左边;在十位的位置上,它所代表的数值比在个位的位置所代表的数值要大,所以十位应排在个位的左边。

上面通过描述的方式,回答了该问题。很明显,我们的教学中缺乏权重的称谓。如果引入权重的称谓,我们就可以说,权重高的数位,排在权重低的数位的左边。在这里,3为权重最低 (the least significant) 的数位,或称最低权重位;而1为权重最高 (the most significant) 的数位,或称最高权重位

大尾小尾的定义

引入了最高权重位及最低权重位的概念后,我们就可以很方便地给大尾小尾下定义了。

如果一个数值,需要使用多个字节来表示,根据该数值在计算机内存中从低位地址到高位地址依序存储的字节的顺序:

  • 如果依照最低权重位到最高权重位的顺序来存储各个字节,称为小尾 (little endian)。
  • 如果依照最高权重位到最低权重位的顺序来存储各个字节,称为大尾 (big endian)。

我们再回看上面的例子:

const { getHexAddrStrFromTypedArray } = await import('/js/esm/BinUtils.js'); let tarr = new Uint16Array([0x1234]); pc.log('%s', getHexAddrStrFromTypedArray(tarr));

00列为低位地址,01列为高位地址,最低权重位排在最前面,因此这里为小尾方式。

独立地进行大尾小尾操作

DataView可不依赖于所在系统的设置,独立地进行大尾小尾操作。

DataView的构造函数

let arrayBuffer = new ArrayBuffer(2); let dataView = new DataView(arrayBuffer); pc.log(dataView);

DataView的构造函数原型如下:

new DataView
  • ArrayBufferbuffer
  • Number[byteOffset]
  • Number[byteLength]
buffer
数据源缓冲区。
[byteOffset]
从数据源的哪个字节开始。
[byteLength]
共有多个少字节。

小尾存储,读取为小尾

可以看到DataView原型的方法分为get...set...两大类,分别用于读取与改写相应的数据类型的数据。

const { getHexStr, getHexAddrStrFromTypedArray } = await import('/js/esm/BinUtils.js'); let arrayBuffer = new ArrayBuffer(2); let dataView = new DataView(arrayBuffer); dataView.setUint16(0, 0x1234, true); pc.log('%s', getHexAddrStrFromTypedArray(new Uint16Array(arrayBuffer))); let num = dataView.getUint16(0, true); pc.log('%s', getHexStr(num));

setUint16的原型如下:

undefined setUint16
  • NumberbyteOffset
  • Numbervalue
  • BooleanisLittleEndian = false
byteOffset
从数据源的哪个字节开始。
value
要存储的数值。
isLittleEndian = false
要否依小尾来存储指定的数值。默认值为false,即默认情况下以大尾方式来存储数值。

setUint16方法在指定的字节偏移处,按unsigned int的数据类型,使用2个字节来存储指定的数值。参数isLittleEndian指定是否按小尾方式来存储。

getUint16的原型如下:

Number getUint16
  • NumberbyteOffset
  • BooleanisLittleEndian = false
byteOffset
从数据源的哪个字节开始。
isLittleEndian = false
要否依小尾方式来读取。默认值为false,即默认情况下以大尾方式来读取。

上面的代码,依据小尾方式来存储数值0x1234,打印其内存中的十六进制数据,然后再依据小尾方式将该数值读取出来。

小尾存储,读取为大尾

我们甚至可以按小尾来存储,按大尾来读取。

const { getHexStr, getHexAddrStrFromTypedArray } = await import('/js/esm/BinUtils.js'); let arrayBuffer = new ArrayBuffer(2); let dataView = new DataView(arrayBuffer); let numStored = 0x1234; dataView.setUint16(0, numStored, true); // set as little endian pc.log('%s', getHexAddrStrFromTypedArray(new Uint16Array(arrayBuffer))); let numRead = dataView.getUint16(0, false); // read as big endian pc.log('%s', getHexStr(numRead)); pc.log(numStored === numRead);

当然,这种方式在绝大多数的情况下都不是我们希望的,除非有特别需求。尽管如此,上面的例子演示了DataView对象确实十分灵活。

判断系统是大尾还是小尾

利用DataView可自由设置大小尾的特点,我们结合其他类型化数组,可判断系统是大尾还是小尾。

const { getHexStr, getHexAddrStrFromTypedArray } = await import('/js/esm/BinUtils.js'); let arrayBuffer = new ArrayBuffer(2); let dataView = new DataView(arrayBuffer); dataView.setUint16(0, 0x00FF, true); pc.log('%s', getHexAddrStrFromTypedArray(new Uint16Array(arrayBuffer))); let u16Arr = new Uint16Array(arrayBuffer); pc.log('%s', getHexStr(u16Arr[0])); if (u16Arr[0] === 0xFF) { pc.log('Little endian'); } else { pc.log('Big endian'); }

我们先使用小尾方式来存储0x00FF,则此时最低权重位0xFF在内存中的位置是确定的,肯定排在地址低位。

接着,我们使用依据系统大小尾设置来读写的Uint16Array类来取出对应的2个字节,如果首个字节值为0xFF,则系统为小尾;否则系统为大尾。

了解大尾小尾的意义

当我们需与二进制数据打交道时,通常需要查看该数据在计算机内存中存储的细节。例如:

const { getHexAddrStrFromTypedArray, isLittleEndian } = await import('/js/esm/BinUtils.js'); let tarr = new Uint16Array([0x1234, 0x5678, 0x9ABC]); pc.log('%s', getHexAddrStrFromTypedArray(tarr)); pc.log(isLittleEndian());

当看到此原始二进制数据时,我们就可以快速识别了。

该数组类型为Uint16Array,因此数组中每个元素各占2个字节。从上面所看到的结果中取出第一个元素相应的字节为0x34 0x12,因为是小尾方式,因此应正确地解读为0x1234。余此类推。

这样,我们明白了相应字节为何颠倒,对此类问题就不会感到突兀了。

各种类型化数组

类型化数组共有12种,详见下表。

类名值域字长Web IDL
Int8Array[-128, 127]1byte
Uint8Array[0, 255]1octet
Uint8ClampedArray[0, 255]1octet
Int16Array[-32768, 32767]2short
Uint16Array[0, 65535]2unsigned short
Int32Array[-2147483648, 2147483647]4long
Uint32Array[0, 4294967295]4unsigned long
Float16Array[-65504, 65504]2N/A
Float32Array[-3.4E38, 3.4E38]4unrestricted float
Float64Array[-1.8E308, 1.8E308]8unrestricted double
BigInt64Array[-263, 263-1]8bigint
BigUint64Array[0, 264-1]8bigint

上表最后一列列出了Web IDL (Web Interface Definition Language, Web接口定义语言)中对应的数据类型。之前我们在接触到JavaScript新的数据类型时,为加深理解,往往将其与C语言中相应的数据类型相比较。现在不需要了,因为Web IDL已为Web上所有的数据类型制订了专门的规范。这是其一。

其二,我们有时会看到如下的代码:

application/octet-stream

现在,我们知道,octet指的就是无符号8位整数

参考资源

Specifications

  1. Streams
  2. Fetch

General

  1. Web IDL
  2. 数位 (百度百科)
  3. MIME types (MDN)
  4. URL.createObjectURL (MDN)
  5. Streams API (MDN)
  6. Blob (MDN)
  7. Using readable streams (MDN)

TypedArray

  1. JavaScript typed arrays (MDN)
  2. Faster Canvas Pixel Manipulation with Typed Arrays
  3. Endianness (MDN)