搞懂JavaScript类型化数组

简介

所谓类型化数组就是一种类似数组的对象,它提供了一种用于访问原始二进制数据的机制,WebGL中经常会用到,并且必须用到,但这并不是说它的出现只是因为WebGL,随着Web程序的越来强大,类型化数组还可以用在很多地方,让开发者可以方便的操作内存

首先来看下为什么会有类型化数组这个东西,JS已经有了Array对象为什么还有搞出来这样一个麻烦的东西,存在既有其合理性

  • 浏览器通过WebGL与显卡进行通信,因此对性能要求较高,传统的Array动态数组无法满足
  • 传统的Array可以存储任何类型的值,因此无法直接操作内存确定数据所占字节大小
  • 有一些程序数据的交互通过二进制数据会更加快速,比如上面的显卡通信以及webSockets

缓冲

何为缓冲

首先,需要了解以下什么是缓冲区

缓冲区又称为缓存,它是内存空间的一部分,也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,缓冲区根据其对应的设备是输入还是输出,又分为输入缓冲区和输出缓冲区。

例如在C语言中的内存管理机制,有两个标准的内存管理函数:malloc()free(),用来申请固定大小的内存和动态释放内存空间,而在JavaScript中内存的管理是由浏览器来控制的,当创建变量时内存会被分配,使用变量时内存会被读写,然后当这些变量不再使用的时候内存会被回收,这就是JavaScript的垃圾自动回收机制。

缓冲的作用

然后,看下为什么需要缓冲区

了解了缓冲区是内存空间的一部分,它等待着数据的存取,那为什么不直接进行数据的存取还费那么大劲申请一块缓冲区再存取数据,因为缓冲区相当于一种媒介,介于输入和输出之间,就比如自动售货机,你买东西不需要要直接面对商家,即方便了你也方便了商家,并且你可以在售货机买到各种类型的东西,因此缓冲区它不是一种数据类型,在缓冲区这段内存区域中,可以放置整型数和浮点数以及别的类型

因此,JavaScript提供了一种可以缓冲区的类型,Arraybuffer,当然这里的类型并不是类型化数组

ArrayBuffer

为了达到最大的灵活性和效率,类型数组将实现拆分为 缓冲视图两部分,一个缓冲描述的是一个数据块,缓冲没有格式可言,并且不提供机制访问内容

接口定义

ArrayBuffer是一种数据类型,用来表示一个通用的、固定长度的二进制数据缓冲区,下面的代码是其接口定义

interface ArrayBuffer {
    /**
     * Read-only. The length of the ArrayBuffer (in bytes).
     */
    readonly byteLength: number;

    /**
     * Returns a section of an ArrayBuffer.
     */
    slice(begin: number, end?: number): ArrayBuffer;
}

由此可见ArrayBuffer接口有一个只读属性和一个实例方法

  • byteLength:数组的字节大小。在数组创建时确定,并且不可变更。只读
  • slice:返回一个新的 ArrayBuffer ,它的内容是这个 ArrayBuffer 的字节副本,从begin(包括),到end(不包括)。如果begin或end是负数,则指的是从数组末尾开始的索引,而不是从头开始

静态方法

ArrayBuffer还有一个静态的方法,其构造函数定义如下

interface ArrayBufferConstructor {
    readonly prototype: ArrayBuffer;
    new(byteLength: number): ArrayBuffer;
    isView(arg: any): arg is ArrayBufferView;
}
  • isView:如果参数是 ArrayBuffer视图实例则返回 true,否则返回false,至于什么是视图实例后面会讲解

下面来使用一下ArrayBuffer

// 创建8个字节的缓冲区
let buffer = new ArrayBuffer(8);
console.log(buffer.byteLength); // 返回字节长度8

let buffer2 = buffer.slice(0, 4);
console.log(buffer2.byteLength); // 4个字节数

上面创建一个8字节的内存缓冲区,并且通过byteLength获取字节长度

每个字节的默认值都是0,1字节等于8比特,1比特就是一个二进制位(0或1)
在这里插入图片描述

DataView

上面创建了一个8字节的二进制缓冲区域,要想操作这块区域,ArrayBuffers不能直接读写,但可以将其传递给一个 类型化数组 TypedArrayDataView对象,进而操作缓冲区
首先,我们来看一下 DataView

DataVIew是一个可以从 二进制ArrayBuffer 对象中读写多种数值类型的底层接口,使用它时,不用考虑不同平台的字节序 的问题。

接口定义

看一下其接口定义

interface DataView {
    readonly buffer: ArrayBuffer;
    readonly byteLength: number;
    readonly byteOffset: number;

    getFloat32(byteOffset: number, littleEndian?: boolean): number;
    getFloat64(byteOffset: number, littleEndian?: boolean): number;
    
    getInt8(byteOffset: number): number;
    getInt16(byteOffset: number, littleEndian?: boolean): number;
    getInt32(byteOffset: number, littleEndian?: boolean): number;
    
    getUint8(byteOffset: number): number;
    getUint16(byteOffset: number, littleEndian?: boolean): number;
    getUint32(byteOffset: number, littleEndian?: boolean): number;
    
    setFloat32(byteOffset: number, value: number, littleEndian?: boolean): void;
    setFloat64(byteOffset: number, value: number, littleEndian?: boolean): void;
    
    setInt8(byteOffset: number, value: number): void;
    setInt16(byteOffset: number, value: number, littleEndian?: boolean): void;
    setInt32(byteOffset: number, value: number, littleEndian?: boolean): void;
    
    setUint8(byteOffset: number, value: number): void;
    setUint16(byteOffset: number, value: number, littleEndian?: boolean): void;
    setUint32(byteOffset: number, value: number, littleEndian?: boolean): void;
}

实例属性

实例属性

  • buffer:类型是 ArrayBuffer,也就是被view引入的buffer只读属性
  • byteLength:从 ArrayBuffer中读取的字节长度
  • byteOffset:从 ArrayBuffer读取时的偏移字节长度

实例方法

实例方法
各个类型的set/get方法,表示从DataView起始位置以byte为计数的指定偏移量(byteOffset)处获取一个多少-bit数(类型).具体的类型和字节数如下表

视图类型 说明 字节大小
Uint8Array 8位无符号整数 1字节
Int8Array 8位有符号整数 1字节
Uint8ClampedArray 8位无符号整数(溢出处理不同) 1字节
Uint16Array 16位无符号整数 2字节
Int16Array 16位有符号整数 2字节
Uint32Array 32位无符号整数 4字节
Int32Array 32位有符号整数 4字节
Float32Array 32位IEEE浮点数 4字节
Float64Array 64位IEEE浮点数 8字节

然后再看一下如果创建view对象,其构造函数定义如下

interface DataViewConstructor {
    new(buffer: ArrayBufferLike, byteOffset?: number, byteLength?: number): DataView;
}

需要传递三个参数,后面两个是可选参数,参数详解

  • buffer:一个 已经创建的ArrayBuffer也就是DataVIew的数据源
  • byteOffset:此 DataView 对象的第一个字节在 buffer 中的字节偏移。如果未指定,则默认从第一个字节开始
  • byteLength:此 DataView 对象的字节长度。如果未指定,这个视图的长度将匹配buffer的长度

代码示例

具体如果使用 DataView来看一下示例代码

// 创建8个字节的缓冲区
let buffer = new ArrayBuffer(8);

// 创建一个视图,从第一个字节开始到buffer的长度为止,通过视图可以操作缓冲区
let view = new DataView(buffer);

view.setUint8(0, 31);// 在 0 偏移位置存储一个 8 位无符号整数31 占一个字节
view.setUint8(1, 32);// 在 1 偏移位置存储一个 8 位无符号整数31 占一个字节

view.setInt16(2, 67);// 在 2 偏移位置也就是第3个字节开始 存储一个 16位有符号整数 占两个字节

view.setFloat32(4, 12.5)// 在 4 偏移位置存储一个有符号32位浮点数占 4 个字节

console.log(view.getUint16(2)); // 67
console.log(view.getUint8(0));  // 31

由上面的代码可以看到,通过DataView可以操作缓冲区,并且可以设置不同类型的数,在第一个字节和第二个字节处设置了无符号8位的整数,每个数占一个字节,然后第3个位置设置了一个有符号整数,一个有符号整数占两个字节,因此最后还有4个字节没有使用,所以最后设置了一个4字节的有符号浮点数,此时这块缓冲区已经填满,可以通过get方法来获取对应字节位置的数

TypedArray

操作二进制缓冲区,不仅可以使用DataView对象同时最好用也是最长用的方式就是类型化数组,基本上在WebGL中使用32位浮点数来表示顶点的属性信息,使用无符8位整数来表示颜色值在0到255之间,使用适合的数据类型可以优化内存提升渲染速度

取值范围

类型 单个元素值的范围 大小(bytes) 描述 Web IDL 类型 C 语言中的等价类型
Int8Array -128 to 127 1 8 位二进制有符号整数 byte int8_t
Uint8Array 0 to 255 1 8 位无符号整数(超出范围后从另一边界循环) octet uint8_t
Uint8ClampedArray 0 to 255 1 8 位无符号整数(超出范围后为边界值) octet uint8_t
Int16Array -32768 to 32767 2 16 位二进制有符号整数 short int16_t
Uint16Array 0 to 65535 2 16 位无符号整数 unsigned short uint16_t
Int32Array -2147483648 to 2147483647 4 32 位二进制有符号整数 long int32_t
Uint32Array 0 to 4294967295 4 32 位无符号整数 unsigned long uint32_t
Float32Array 1.2×10-38 to 3.4×1038 4 32 位 IEEE 浮点数(7 位有效数字,如 1.1234567 unrestricted float float
Float64Array 5.0×10-324 to 1.8×10308 8 64 位 IEEE 浮点数(16 有效数字,如 1.123...15) unrestricted double double
BigInt64Array -263 to 263-1 8 64 位二进制有符号整数 bigint int64_t (signed long long)
BigUint64Array 0 to 264-1 8 64 位无符号整数 bigint uint64_t (unsigned long long)

一个类型化数组对象描述了一个底层的二进制数据缓冲区的一个类数组视图,事实上,没有名为 TypedArray的全局属性,也没有一个名为TypedArray的构造函数,相反,有许多不同的全局属性,它们的值使特定元素类型的类型化数组的构造函数

接口定义

例如下面代码Float32Array构造方法

interface Float32ArrayConstructor {
    readonly prototype: Float32Array;
    new(length: number): Float32Array;
    new(arrayOrArrayBuffer: ArrayLike<number> | ArrayBufferLike): Float32Array;
    new(buffer: ArrayBufferLike, byteOffset: number, length?: number): Float32Array;

    /**
     * The size in bytes of each element in the array.
     */
    readonly BYTES_PER_ELEMENT: number;

    /**
     * Returns a new array from a set of elements.
     * @param items A set of elements to include in the new array object.
     */
    of(...items: number[]): Float32Array;

    /**
     * Creates an array from an array-like or iterable object.
     * @param arrayLike An array-like or iterable object to convert to an array.
     */
    from(arrayLike: ArrayLike<number>): Float32Array;

    /**
     * Creates an array from an array-like or iterable object.
     * @param arrayLike An array-like or iterable object to convert to an array.
     * @param mapfn A mapping function to call on every element of the array.
     * @param thisArg Value of 'this' used to invoke the mapfn.
     */
    from<T>(arrayLike: ArrayLike<T>, mapfn: (v: T, k: number) => number, thisArg?: any): Float32Array;

}
declare var Float32Array: Float32ArrayConstructor;

创建方式

由上面构造函数接口的定义可知,Float32Array数组的构造可以有4种方式,下面示例代码所示

方式一

/**
  * 1. new(length: number): Float32Array; 传递一个 length
  * 创建一个可以存储8个浮点数的类型化数组
  * 当传入length时,一个内部的 buffer 会被创建,该缓冲区的大小
  * 是传入的length乘以数组中每个元素的字节数,并且每个元素的值都是0
  */
 let float32Array = new Float32Array(8);

 console.log(float32Array.BYTES_PER_ELEMENT);// 4每个数所占的字节
 console.log(float32Array.byteLength);       // 总字节数 4 * 8 = 64
 console.log(float32Array.length);           // 数组中元素的数量
 console.log(float32Array.byteOffset);       // 0

方式二

/**
  * 2. new(arrayOrArrayBuffer: ArrayLike<number>): Float32Array;
  * 第二种方法是传递一个普通的数组,然后通过这个数组来创建实例
  * 此时类型化数组中的值是已经赋值过的
  */

 let vertices = [1.0, 1.0, 1.0, 2.0, 2.0, 2.0];

 let float32Array = new Float32Array(vertices);

 console.log(float32Array.BYTES_PER_ELEMENT);// 4每个数所占的字节
 console.log(float32Array.byteLength);       // 总字节数 4 * 6 = 24
 console.log(float32Array.length);           // 数组中元素的数量 6
 console.log(float32Array.byteOffset);       // 0

方式三

/**
  * 3. new(ArrayBufferLike): Float32Array;
  * 第三种方法是传递一个缓冲区对象
  * 此时类型化数组中的元素都是0
  */

 // 创建8个字节的缓冲区
 let buffer = new ArrayBuffer(24);

 let float32Array = new Float32Array(buffer);

 console.log(float32Array.BYTES_PER_ELEMENT);// 4每个数所占的字节
 console.log(float32Array.byteLength);       // 总字节数 4 * 6 = 24
 console.log(float32Array.length);           // 数组中元素的数量 6
 console.log(float32Array.byteOffset);       // 0

方式四

/**
  * 4. new(TypeArray): Float32Array;
  * 第四种方法是传递一个类型化数组,然后通过这个数组来创建实例
  * 传入一个任意类型的类型化数组会被转换位当前构造函数对应的类型
  * 如果当前数的范围超过此构造函数的范围则超出范围被截取
  */

 let vertices = [1.0, 1.0, 1.0, 2.0, 2.0, 256];

 let float32Array = new Float32Array(vertices);

 console.log(float32Array.BYTES_PER_ELEMENT);// 每个数所占的字节  4
 console.log(float32Array.byteLength);       // 总字节数 4 * 6 = 24
 console.log(float32Array.length);           // 数组中元素的数量  6
 console.log(float32Array.byteOffset);       // 0


 let uint8Array = new Uint8Array(float32Array);
 console.log(uint8Array.BYTES_PER_ELEMENT);// 每个数所占的字节 1
 console.log(uint8Array.byteLength);       // 总字节数 1 * 6 = 6
 console.log(uint8Array.length);           // 数组中元素的数量 6
 console.log(uint8Array.byteOffset);       // 0

 console.log(float32Array);  // [1.0, 1.0, 1.0, 2.0, 2.0, 256]
 console.log(uint8Array);    // [1.0, 1.0, 1.0, 2.0, 2.0, 0]

方式五

/**
  * 5. new(buffer: ArrayBufferLike, byteOffset: number, length?: number): Float32Array;
  * 第五种方法和方式三类似只是多后面两个参数,传递一个缓冲区对象
  *  byteOffset和length(可选)参数,指定了类型化数组将要暴露的内存范围,length可以省略
  * length省略则默认到可支持大小
  */

 // 创建8个字节的缓冲区
 let buffer = new ArrayBuffer(24);

 let float32Array = new Float32Array(buffer, 2 * 4, 2);

 console.log(float32Array.BYTES_PER_ELEMENT);// 每个数所占的字节 4
 console.log(float32Array.byteLength);       // 总字节数 4 * 2 = 2
 console.log(float32Array.length);           // 数组中元素的数量 2
 console.log(float32Array.byteOffset);       // 8 第八个字节开始偏移
 console.log(float32Array);                  //[0,0]

实例特性

不能动态增长
上面是构造类型化数组的几种方式,需要注意的是,类型化数组不能向普通数组 Array一样动态的增长,一旦设置好了长度以及大小,里面缓冲区的大小则不会动态的改变,看下面的示例

let vertices = [1.0, 2.0, 3.0];

 let float32Array = new Float32Array(vertices);

 console.log(float32Array); // [1.0, 2.0, 3.0]

 // 增加数组元素
 vertices[vertices.length] = 44;
 vertices[vertices.length] = 55;
 console.log(vertices);// [1, 2, 3, 44, 55]
 // 类型化数组中的数据并没有增加
 console.log(float32Array);        // [1.0, 2.0, 3.0]
 console.log(float32Array.length); // 3
 float32Array[3] = 44;
 // 依然无法添加
 console.log(float32Array);        // [1.0, 2.0, 3.0]

属性访问
可以像一般数组一样来访问类型化数组中的元素

let vertices = [1.0, 2.0, 3.0];
let float32Array = new Float32Array(vertices);

console.log(float32Array);                      // [1.0, 2.0, 3.0]
console.log(float32Array[0]);                   // 1
float32Array[float32Array.length] = 4;
console.log(float32Array[float32Array.length]); // undefined

可以给动态数组添加属性

let vertices = [1.0, 2.0, 3.0];
let float32Array = new Float32Array(vertices);

console.log(float32Array);                      // [1.0, 2.0, 3.0]
float32Array.name = 'float32Array';
console.log(float32Array.name);// float32Array

静态属性

通过构造函数可以得知其静态属性和方法如下
静态属性

readonly BYTES_PER_ELEMENT: number;
name:string = "Float32Array";
readonly length:number = 3;

静态方法

of(...items: number[]): Float32Array;

// 使用类数组(array-like)或迭代对象创建一个新的类型化数组
from(arrayLike: ArrayLike<number>): Float32Array;
// 使用类数组(array-like)或迭代对象创建一个新的类型化数组
from<T>(arrayLike: ArrayLike<T>, mapfn: (v: T, k: number) => number, thisArg?: any): Float32Array;

实例方法

类型化数组的实例方法大部分都与基本的Array类似,但是也有一定的区别,具体实例方法如下代码所示

interface Float32Array {
   
    readonly BYTES_PER_ELEMENT: number;
    readonly buffer: ArrayBufferLike;
    readonly byteLength: number;
    readonly byteOffset: number;
    
    copyWithin(target: number, start: number, end?: number): this;
    every(callbackfn: (value: number, index: number, array: Float32Array) => unknown, thisArg?: any): boolean;
    fill(value: number, start?: number, end?: number): this;
    filter(callbackfn: (value: number, index: number, array: Float32Array) => any, thisArg?: any): Float32Array;
    find(predicate: (value: number, index: number, obj: Float32Array) => boolean, thisArg?: any): number | undefined;
    findIndex(predicate: (value: number, index: number, obj: Float32Array) => boolean, thisArg?: any): number;
    forEach(callbackfn: (value: number, index: number, array: Float32Array) => void, thisArg?: any): void;
    indexOf(searchElement: number, fromIndex?: number): number;
    join(separator?: string): string;
    lastIndexOf(searchElement: number, fromIndex?: number): number;

    /**
     * The length of the array.
     */
    readonly length: number;
    
    map(callbackfn: (value: number, index: number, array: Float32Array) => number, thisArg?: any): Float32Array;
    reduce(callbackfn: (previousValue: number, currentValue: number, currentIndex: number, array: Float32Array) => number): number;
    reduce(callbackfn: (previousValue: number, currentValue: number, currentIndex: number, array: Float32Array) => number, initialValue: number): number;
    reduce<U>(callbackfn: (previousValue: U, currentValue: number, currentIndex: number, array: Float32Array) => U, initialValue: U): U;
    reduceRight(callbackfn: (previousValue: number, currentValue: number, currentIndex: number, array: Float32Array) => number): number;
    reduceRight(callbackfn: (previousValue: number, currentValue: number, currentIndex: number, array: Float32Array) => number, initialValue: number): number;
    reduceRight<U>(callbackfn: (previousValue: U, currentValue: number, currentIndex: number, array: Float32Array) => U, initialValue: U): U;
    reverse(): Float32Array;
    set(array: ArrayLike<number>, offset?: number): void;
    slice(start?: number, end?: number): Float32Array;
    some(callbackfn: (value: number, index: number, array: Float32Array) => unknown, thisArg?: any): boolean;
    sort(compareFn?: (a: number, b: number) => number): this;
    subarray(begin?: number, end?: number): Float32Array;
    toLocaleString(): string;
    toString(): string;

    [index: number]: number;
}

具体使用方法 查看 API文档 TypedArray

实际案例

一般在WebGL中使用类型化数组来向显卡传递顶点的属性数据,例如下面这个示例,在缓冲区中创建顶点的座标和颜色以及索引数据等

let verticesColors = new Float32Array([
    // Three triangles on the right side
    0.75, 1.0, -4.0, 0.4, 1.0, 0.4, // The back green one
    0.25, -1.0, -4.0, 0.4, 1.0, 0.4,
    1.25, -1.0, -4.0, 1.0, 0.4, 0.4,

    0.75, 1.0, -2.0, 1.0, 1.0, 0.4, // The middle yellow one
    0.25, -1.0, -2.0, 1.0, 1.0, 0.4,
    1.25, -1.0, -2.0, 1.0, 0.4, 0.4,

    0.75, 1.0, 0.0, 0.4, 0.4, 1.0,  // The front blue one 
    0.25, -1.0, 0.0, 0.4, 0.4, 1.0,
    1.25, -1.0, 0.0, 1.0, 0.4, 0.4,

    // Three triangles on the left side
    -0.75, 1.0, -4.0, 0.4, 1.0, 0.4, // The back green one
    -1.25, -1.0, -4.0, 0.4, 1.0, 0.4,
    -0.25, -1.0, -4.0, 1.0, 0.4, 0.4,

    -0.75, 1.0, -2.0, 1.0, 1.0, 0.4, // The middle yellow one
    -1.25, -1.0, -2.0, 1.0, 1.0, 0.4,
    -0.25, -1.0, -2.0, 1.0, 0.4, 0.4,

    -0.75, 1.0, 0.0, 0.4, 0.4, 1.0,  // The front blue one 
    -1.25, -1.0, 0.0, 0.4, 0.4, 1.0,
    -0.25, -1.0, 0.0, 1.0, 0.4, 0.4
]);
let n = 18; // 顶点数量

// 创建颜色缓冲区
let vertexColorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexColorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, verticesColors, gl.STATIC_DRAW);

let FSIZE = verticesColors.BYTES_PER_ELEMENT;

// 传递顶点座标数据
let a_Position = gl.getAttribLocation(gl.program, 'a_Position');
gl.vertexAttribPointer(a_Position, 3, gl.FLOAT, false, FSIZE * 6, 0);
gl.enableVertexAttribArray(a_Position);

// 传递颜色数据
let a_Color = gl.getAttribLocation(gl.program, 'a_Color');
gl.vertexAttribPointer(a_Color, 3, gl.FLOAT, false, FSIZE * 6, FSIZE * 3);
gl.enableVertexAttribArray(a_Color);

// 缓冲区解绑
gl.bindBuffer(gl.ARRAY_BUFFER, null);

上面的示例通过类型化数组向显卡传递数据,有些时候我们不断操作和修改缓冲区中数据,此时类型化数组的作用就显现了出来,数据的传输非常

总结

通过对缓冲区ArrarBuffer和类型化数组以及一些具体的属性和方法介绍,了解到它与一般的Array相比起来使用虽然麻烦,但是它带来的好处是显而易见的,类型化数组不能动态增长可以很好的规避内存溢出的问题,它可以通过缓冲区来操作内存,给数据的传输带来了极大的方便等等

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章