详解-定型数组-DataView-ArrayBuffer

1,166 阅读12分钟

由来

  1. 在webGL的早期版本中,因为JavaScript的数组和原生数组之间不匹配,所以出现了性能问题。图形驱动程序通常不需要以JavaScript默认双精度浮点格式传递他们的数值,而这恰恰是JavaScript的数组在内存中的格
  2. 因此在webGL与JavaScript运行时之间传递数组时,webGL绑定都需要在目标环境分配新数组,以其当前格式迭代数组,然后将数值转化为新数组中的适当格式,而这要花费很多时间
  3. 这当然是难以接受的,Mozilla为了解决这个问题而实现了CanvasFloatArray。这是一个提供JavaScript的接口,C语言风格的浮点值数组。JavaScript的运行时使用这个类型可以分配,读取,写入数组。这个数组可以直接传给底层图形,驱动程序也可以直接从底层获取到
  4. 最终CanvasFloatArray变成了Float32Array,这就是今天定型数组中第一个类型
  5. 而这只是一个起点,后面前辈们又在这块做了很多改进,我们来一一看看有哪些改进吧

ArrayBuffer

这是一个数组,不能设置值,不能直接访问的数组。设置或访问其中的数据,需要视图的帮助。上面提到的Aloat32Array就是一个视图。ArrayBuffer是视图操作的基本单位

我们来看下如何创建一个ArrayBuffer

// 在内存中分配了8个字节长度的ArrayBuffer
const buffer = new ArrayBuffer(8);
console.log(buffer.byteLength);		//8

生成了之后,数组的每项都是0。不能给他设置上初始值

它除了可以访问长度之外,还可以切割生成的新的函数

// 可以通过slice,使用旧的ArrayBuffer来生成新的ArrayBuffer
const bufferNew = buffer.slice(2);
console.log(bufferNew.byteLength); //6

好像每种数组都有slice方法

这样的一个数组,存不了数据,也看不了数据,有什么用?

DataView

这是一个视图,有了它就可以对ArrayBuffer设置值,并且查看里面的数据了

创建

// 通过ArrayBuffer创建了一个视图
const bufferArray = new ArrayBuffer(8);
const dataView = new DataView(bufferArray);

看看它有哪些属性

视图可以看成一个观察和处理数据的窗口,这个窗口的长度是小于等于ArrayBuffer的长度的,并且起点也可以是任意的,不一定要从ArrayBuffer第0个位置开始

我们来看代码

// 打印出视图所能看见的ArrayBuffer的长度
console.log(dataView.byteLength); //8
// 视图查看ArrayBuffer的起点
console.log(dataView.byteOffset); //0

这个起点和查看的范围是创建的时候确定的,上面的创建过程并没有指定offset,和观察的范围,所以就默认用第0个位置开始,范围整个的ArrayBuffer

下面我在创建的时候设置一下

// 生成DataView的同时,指定查看的起点和长度
const dataView2 = new DataView(bufferArray, 2, 4);

// 结果是符合预期的
console.log(dataView2.byteLength); //4
console.log(dataView2.byteOffset); //2

构造器的第2个参数,指定开始的位置;第3个参数指定可观测的范围

OK,现在有了视图,我们再看看如何对ArrayBuffer的数据进行操作

修改和查看ArrayBuffer中的数据

// 通过视图,向ArrayBuffer写入数据,第0个位置填入1
dataView2.setInt8(0, 1);

// 这里以一个字节为单位,来读取: 
// 二进制:0000 0001
console.log(dataView2.getInt8(0));  //1

这里有几个点需要注意:

  1. 用视图写入数据时的位置,是相对于视图来说的,并不是相对于ArrayBuffer。如果视图的offset是2,意味着视图开始观察的位置相对于ArrayBuffer是从下标为2的位置开始的。这时通过视图填入数据,填入数据的位置是2,那么相对于ArrayBuffer,只从下标为4的位置开始的

  2. 上面的dataView2填入数据时的位置偏移是0,那么相对于ArrayBuffer就是从下标为2的位置开始的。

    所以当设值完成之后,dataView2对应的ArrayBuffer的数据内容就是:

console.log(dataView2);
// DataView {
//   byteLength: 4,
//   byteOffset: 2,
//   buffer: ArrayBuffer {
//     [Uint8Contents]: <00 00 01 00 00 00 00 00>,
//     byteLength: 8
//   }
// }

我们可以看到下标为2的字节的值变成了1

  1. 同理可得,我们读取数据时的位置,也是相对于视图开始观察的位置
  2. setInt8 和 getInt8 中的“Int8”的含义是指以一个字节的长度为单位操作数据。其他的还有getInt16,其中的“Int16”的含义是指,以两个字节的长度为单位操作数据

下面我们看下示例

// 这里以两个字节为单位,来读取:
// 二进制: 0000 0001 0000 0000
console.log(dataView2.getInt16(0));  // 256

这里还是从视图开始观察的位置读取数据,但是以两个字节为单位读取数据,那就读取到了0000 0001 0000 0000,值为2^8

我们继续来看几个例子,来加深你的理解

// 通过视图,以一个字节为单位,向ArrayBuffer写入数据
// 往第1个位置填入1
dataView2.setInt8(1, 1);

// 这里以两个字节为单位,
// 读取到: 0000 0001 0000 0001
console.log(dataView2.getInt16(0));  // 257

2^8 + 1 = 257

符合预期

// 通过视图,以2个字节为单位,向ArrayBuffer写入数据
// 第0个位置填入1
dataView2.setInt16(0, 1);

// 这里以两个字节为单位
// 读取到: 0000 0000 0000 0001
console.log(dataView2.getInt16(0));  // 1
// 这里以一个字节为单位
// 读取到: 0000 0000
console.log(dataView2.getInt8(0));  // 0
// 读取到: 0000 0001
console.log(dataView2.getInt8(1));  // 1

符合预期

不止int8、int16,还有int32,更多的信息可以查阅相关文档😁

这里插点大小端的知识点

大端字节序也被称为网络字节序,意思是最高有效位保存在第一个字节,最低有效位保存在最后一个字节。小端字节序正好相反,即最低有效位保存在第一个字节,最高有效位呢保存在最后一个字节。

注意:大小端是以字节为单位的,不是以字位为单位的

举个例子:

// 清空数据
dataView2.setInt16(0, 0);

// 默认大端
// 1000 0000 0000 0001
dataView2.setInt8(0, 0x80);
dataView2.setInt8(1, 0x01);
console.log(dataView2.getUint16(0)); // 32769

2^15 + 1 = 32769

不做任何处理的时候,是默认大端模式的。即,高位在前面字节,底位在后面字节

// 小端读取
// 大端:1000 0000 0000 0001 -> 小端: 0000 0001 1000 0000 
console.log(dataView2.getUint16(0, true));  // 384

2^8 + 2^7 = 384

第二个参数是true的时候,就是小端模式

我们可以明显地看到,当变成了小端模式时,后面的字节被当成了高位,前面的字节被当成了低位

大小端不仅仅读取的时候区分,写入的时候也是区分的。我就不写例子了,同学们自己尝试吧

超过范围的读取或者写入

console.log(dataView2.getUint16(8));
//RangeError: Offset is outside the bounds of the DataView

读取的范围明显地超过了范围,所以报RangeError

在写入数据的时候,DataView会尽努力把不是number类型的值转成number类型

dataView2.setInt8(0, '4');
console.log(dataView2.getInt8(0)); // 4

定型数组

定型数组和DataView功能相同,也是用来操作ArrayBuffer的。不过它比DataView多了类型约束。DataView在写入或者读取数据的时候,需要指定格式:Int8、Int16。而定型数组在创建的时候,就已经定好了类型。

挺神奇的哈,我们往下看,好好了解一下😎

创建

创建有三种方式:数字,数组,散装参数

1. 数字
const unit16Array1 = new Uint16Array(3);
console.log(unit16Array1.length); // 3
console.log(unit16Array1); // Uint16Array(3) [ 0, 0, 0 ]
  • 这里我们创建了一个视图,并且操作数据的单位被定成了两个字节的长度。
  • Uint16表示,操作数据的时候,每个单位的数据范围为0 ~ 2^ 16-1。 U表示没有单位;
  • Int16表示,每个单位的数据范围为-2^15 ~ 2^15-1。

负数的读取是以补码的格式

  • 创建的定型数组里面的内容被初始化为0。
  • 定型数组可以访问length属性来获取定型数组的单位。例子中的unit16Array1中有3个可供操作的单位,也可以说数组的长度为3。不过其中的每个单位都是2个字节的长度。所以如果换算成字节的长度,那么长度为6
console.log(unit16Array1.buffer.byteLength); //6
  • 定型数组实例访问了buffer属性,这个属性指向视图所对应的ArrayBuffer

千万不要忘记,视图是依托于ArrayBuffer而存在的

  • 拿到了对应的ArrayBuffer之后,再去访问其长度,就可以拿到定型数组换算成字节后的长度了
  • buffer属性是视图共有的,所以DataView实例对象也有,只不过我上面没讲😁
2. 数组

数组有三种:普通数组、定型数组、ArrayBuffer

是的,定型数组也可以创建其他的定型数组

ArrayBuffer可以创建定型数组。开玩笑?定型数组和DataView一样,也是视图好不好,当然可以通过ArrayBuffer来创建了

  1. 普通数组
const unit16Array2 = new Uint16Array([1, 2, 3]);

console.log(unit16Array2.length); // 3
console.log(unit16Array2[1]); //2

unit16Array2[1] = 17;
console.log(unit16Array2[1]); // 17

这里我们通过普通数组创建了一个定型数组,长度为3。并且访问下标为1的项,值为2。而后修改其中的值,为17。

我们可以看到,有了定型数组,修改ArrayBuffer中的值,是如此的简单,就像普通数组一样

  1. 定型数组
// 定型数组
const unit16Array4 = new Uint16Array(unit16Array1);
console.log(unit16Array4.length); // 3
console.log(unit16Array4[1]); //17

const uint8Array3 = new Uint8Array(unit16Array4);
console.log(uint8Array3); //Uint8Array(3) [ 4, 5, 6 ]

我们通过之前创建好的定型数组,来创建新的定型数组,里面的数据都是一模一样的,相当于复制。

并且,可以用不同类型的定型数组来创建新的类型数组

  1. ArrayBuffer
const bufferArray = new ArrayBuffer(8);
// ArrayBuffer
const unit16Array = new Uint16Array(bufferArray);

// 两个字节一个单位,总共4个单位
console.log(unit16Array.length); // 4
// 也可以访问byteLength,byteOffset,像DataView一样
console.log(unit16Array.byteLength); // 8

我们通过ArrayBuffer实例来创建了一个定型数组,并且数组类型为uint16,所以length为4

这里我们看到了已字节为单位计算定型数组的长度,得到8。之前也有一种方法,只不过从buffer属性上面绕了一圈。

既然得到结果都是一样的,那之前的做法完全是画蛇添足

是的,我是为了讲buffer这个属性而这么干的😉

3. 散装参数

这个创建方式只能通过构造函数的of方法来实现

const unit16Array3 = Uint16Array.of(4, 5, 6);
console.log(unit16Array3.length); // 3
console.log(unit16Array3[1]); //5

创建过程,给of传了3个参数,而不是一个数组。所以我把这种方式称为散装函数😄

啊,好麻烦啊,定型数组的创建方式有3种之多。搞得我讲了这么久🥲

看看它有哪些属性

定型数组也是视图,所以属性和DataView一样。有byteLength,byteOffset,buffer。也有它没有的属性:length

并且定型数组可以向普通数组一样来访问和修改数据,这是DataView办不到的

还有,定型数组不能设置视图的观察偏移位置,和观察的范围。

也好,省得麻烦

定型数组和普通数组的区别

刚才我们看见了,定型数据和普通数组,在操作数据的时候是很像的。它们还有哪些区别或者相似的地方呢

  1. 创建方式的不同

    定型数组创建的方式必须要通过构造函数,并且参数的花样也蛮多的,不过通过数组来创建是大头

  2. 定型数组不能改变数组的长度,而普通数组是可以的

  3. 定型数组支持的操作符方法和属性,和普通数组相同

    像entries(), every(), fill(), filter(), map(), reduce(), reduceRight(), some()等等,这些方法都有一个特点,就是不会改变数组本身的长度。

    所以可以这样记忆,凡是不会改变数组长度的方法,普通数组上面有的,定型数组上面也会有

两个定型数组之间如何快速复制数据

定型数组提供了set(),来实现复制操作

/批量将数组内的数据,导入到另外一个数组里面
const unit8Array1 = new Uint8Array(8);
unit8Array1.set([1, 2, 3, 4]);
console.log(unit8Array1); // Uint8Array(8) [ 1, 2, 3, 4, 0, 0, 0, 0 ]

这里新生成了一个长度为8的数组,然后将普通数组里的数据全部赋到定型数组里面

// 偏移从索引为4的位置开始
const unit8Array2 = new Uint8Array([5, 6, 7, 8]);
unit8Array1.set(unit8Array2, 4);
console.log(unit8Array1); // Uint8Array(8) [ 1, 2, 3, 4, 5, 6, 7, 8 ]

赋值的来源也可以是定型数组。 并且赋值的时候可以指定偏移位置

切割定型数组

定型数组提供了subarray(),来实现切割操作

const subArray1 = unit8Array1.subarray(0, 4);
console.log(subArray1); //Uint8Array(4) [ 1, 2, 3, 4 ]

const subArray2 = unit8Array1.subarray(4);
console.log(subArray2);	//Uint8Array(4) [ 5, 6, 7, 8 ]

console.log(unit8Array1); // Uint8Array(8) [ 1, 2, 3, 4, 5, 6, 7, 8 ]

切割的时候需要指定切割的位置,然后生成新的定型数组并返回。而且不会影响原有的定型数组

看到这里,不知道你发现了没有,这个API和slice()很像啊

我立马把上面的代码换成了slice

const subArray1 = unit8Array1.slice(0, 4);
console.log(subArray1); //Uint8Array(4) [ 1, 2, 3, 4 ]

const subArray2 = unit8Array1.slice(4);
console.log(subArray2);	//Uint8Array(4) [ 5, 6, 7, 8 ]

console.log(unit8Array1); // Uint8Array(8) [ 1, 2, 3, 4, 5, 6, 7, 8 ]

结果是完全一样的

更多

关于定型数组还有:上溢和下溢、实战两部分内容,这个我们放在下一章再讲,有点累了

你看看几点了,呜呜呜~~

总结:

学这些东西,在术的层面有三个点,抓住了就没问题了:如何创建实例、实例有哪些属性、实例有哪些方法;

在道的层面也有三个点:这个东西是用来解决什么问题的、是怎么解决的问题的、问题完全解决了吗,有没有产生新的问题。

  1. 定型数组的由来
  2. ArrayBufer
  3. DataView
  4. 定型数组
  5. 这篇是ArrayBuffer的应用篇--ArrayBuffer实战-识别文件类型