分享

JavaScript 操作二进制数据简单指北-ArrayBuffer

 金刚光 2022-08-19 发布于天津

JavaScript 操作二进制数据的简单指北

一句话概括:

使用 ArrayBuffer 对象保存二进制数据,然后使用 TypedArray 或 DataView 视图来读写数据。

这篇文章不是一个完整的文档,只是一个简单的入门指北

更详细的说明可以参考:

https://wizardforcel.gitbooks.io/es6-tutorial-3e/content/docs/arraybuffer.html

ArrayBuffer

创建 ArrayBuffer

ArrayBuffer 代表内存中的一段数据。它可以由我们手动创建,也可以从其他数据转换而来。

例如创建一个指定长度的 ArrayBuffer

const buff = new ArrayBuffer(4)

这样就创建了一个 4 字节(Byte)长度的内存片段,初始值都是 0。

此外,还有一些其他来源的数据可以转换成 ArrayBuffer

常见的如 XHR 和 Fetch 请求:

const res = await fetch(url)const buff = await res.arrayBuffer()

从 Blob 对象和 File 对象也可以获得 ArrayBuffer

const blob = new Blob(['123'])const buff = await blob.arrayBuffer()

此外,Canvas 和 WebSockets API 也可以获得 ArrayBuffer 数据。

ArrayBuffer 的属性和方法

ArrayBuffer 自身只有一个属性:byteLength,返回它里面数据的字节数(也就是内存使用量)。

const buff = new ArrayBuffer(4)buff.byteLength// 4

ArrayBuffer 自身只有一个方法:slice,用法和数组的 slice 一样,用于拷贝一段内存。

buff.slice(1,3)// ArrayBuffer(2)// 拷贝了 buff 里下标 1、2 的内存数据

slice 方法不会修改原数组。拷贝的数据里包含 start 参数的下标,不包含 end 参数的下标。

ArrayBuffer 不能直接读写

ArrayBuffer 只是存放数据的容器,不能直接对里面的内存数据进行读写。

这是因为操作二进制数据时可以使用多种数据类型,它们的字节长度、值的范围都不尽相同。如果不指定类型,就不能读写内存数据。

例如 Uint8 是无符号整数,值范围是 0 到 255,长度为 1 Byte。

而 Int32 是有符号整数,值范围是 -2,147,483,648 到 +2,147,483,647,长度为 4 Byte。

如果我们用十六进制查看器来查看文件数据,就能直观的看到不同数据类型的值。


ArrayBuffer 支持使用以下 9 种类型来读写内存数据:

  • Int8 8位带符号整数 signed char
  • Uint8 8位不带符号整数 unsigned char
  • Uint8C 8位不带符号整数(自动过滤溢出) unsigned char
  • Int16 16位带符号整数 short
  • Uint16 16位不带符号整数 unsigned short
  • Int32 32位带符号整数 int
  • Uint32 32位不带符号的整数 unsigned int
  • Float32 32位浮点数 float
  • Float64 64位浮点数 double

我们需要使用 TypedArray 或 DataView 视图来指定类型,这样才能读写 ArrayBuffer 里的数据。

TypedArray

TypedArray 可以将一段 ArrayBuffer 的数据全部使用我们设定的类型来操作。

创建 TypedArray

TypedArray 可以使用 9 种类型,每个类型有对应的构造函数:

  • Int8Array:8位有符号整数,长度1个字节。
  • Uint8Array:8位无符号整数,长度1个字节。
  • Uint8ClampedArray:8位无符号整数,长度1个字节,溢出处理不同。
  • Int16Array:16位有符号整数,长度2个字节。
  • Uint16Array:16位无符号整数,长度2个字节。
  • Int32Array:32位有符号整数,长度4个字节。
  • Uint32Array:32位无符号整数,长度4个字节。
  • Float32Array:32位浮点数,长度4个字节。
  • Float64Array:64位浮点数,长度8个字节。

构造函数接收一个 ArrayBuffer 对象,将其转换成指定类型的二进制数组。

new (array: ArrayBufferLike | ArrayLike<number>, byteOffset?: number | undefined, byteLength?: number | undefined) => TypedArray

同一个 ArrayBuffer 可以生成多个不同类型的 TypedArray

例如:

const buff = new ArrayBuffer(4)// 申请了长度为 4 字节的内存const uInt8 = new Uint8Array(buff)// 创建了长度为 4 的数组 (因为 Uint8 的单位长度是 1 字节)const int32 = new Int32Array(buff)// 创建了长度为 1 的数组(因为 Int32Array 的单位长度是 4 字节)// 如果有需要,也可以设定起始位置的偏移量,以及从起始位置开始的内存长度const uInt8 = new Uint8Array(buff, 1, 2)


这样,我们之后读写数据时就可以不用再指定类型了,数据会自动转换成我们设定的类型,很方便。

操作 TypedArray

TypedArray 是类数组对象,我们可以使用数组的方式来操作,如:

// 读uInt8[0]// 写uInt8[0] = 1// 数组方法uInt8.findIndex(val=>val===0)

注意:

使用 ArrayBuffer 数据创建 TypedArray 时,生成的 TypedArray 对象数组只是对 ArrayBuffer 的引用。

所以多个不同类型的 TypedArray 可以操作同一个 ArrayBuffer。例如:

// 用 Int32Array 写入数据:int32[0] = 123456// 这个数字比较大,导致 4 个字节的内存都发生了变化// 之后用 Uint8Array 读取,可以看到值也发生了变化uInt8// [64, 226, 1, 0]


TypedArray 的属性

  • buffer:保存着这个 TypedArray 操作的 ArrayBuffer 对象。所以从 TypedArray 对象里返回其数据时,要使用它的 buffer 属性。
  • byteOffset:起始位置的偏移量
  • byteLength:字节长度,也就是内存使用量。
  • length:数组长度,根据类型不同,数组长度也不同。

例如 4 字节的 byteLength,以 Uint8Array 读取则 length 为 4,以 Int32Array 读取则 length 为 1。

DataView

DataView 和 TypedArray 的区别

DataView 和 TypedArray 有一些区别:

  • TypedArray 把整个 ArrayBuffer 全部视为某种指定的类型,而 DataView 每次操作都必须手动指明类型,所以它可以灵活使用多种类型。
  • TypedArray 是类数组对象,但 DataView 不是类数组对象,所以不能使用数组的方法。
  • TypedArray 不能设定字节序(总是小端),而 DataView 可以设定字节序(大端或小端)(默认小端)。

创建 DataView

使用 DataView 构造函数来创建一个 DataView 对象。

语法:

new (buffer: ArrayBufferLike, byteOffset?: number | undefined, byteLength?: number | undefined) => DataView

简单示例:

const view = new DataView(buff)// 如果有需要,也可以设定起始位置的偏移量,以及从起始位置开始的内存长度const view = new DataView(buff, 2, 2)


由于创建 DataView 对象时不能指定类型,所以我们在操作时必须手动指定类型。

DataView 只有对内存的读、写操作,而且要使用指定的方法。它不能像 TypedArray 那样使用数组下标和数组方法。

DataView 读内存

DataView 实例提供 8 个方法读取内存。

  • getInt8 读取 1 个字节,返回一个 8 位整数。
  • getUint8 读取 1 个字节,返回一个无符号的 8 位整数。
  • getInt16 读取 2 个字节,返回一个 16 位整数。
  • getUint16 读取 2 个字节,返回一个无符号的 16 位整数。
  • getInt32 读取 4 个字节,返回一个 32 位整数。
  • getUint32 读取 4 个字节,返回一个无符号的 32 位整数。
  • getFloat32 读取 4 个字节,返回一个 32 位浮点数。
  • getFloat64 读取 8 个字节,返回一个 64 位浮点数。
const view = new DataView(buff)view.getUint8(0)view.getUint16(1)// DataView.getUint16(byteOffset: number, littleEndian?: boolean | undefined): number// 使用大端字节序view.getUint32(2, false)

第一个参数是读取的内存的位置;

第二个参数是可选参数,用来指定字节序。只有当一次性读取超过 1 字节时才有这个参数。

DataView 默认使用小端字节序。如果你要使用大端字节序,必须把第二个参数设置为 false

DataView 写内存

DataView 写内存的方法也是 8 个,与读内存的 8 个方法对应。

  • setInt8 写入 1 个字节的 8 位整数。
  • setUint8 写入 1 个字节的 8 位无符号整数。
  • setInt16 写入 2 个字节的 16 位整数。
  • setUint16 写入 2 个字节的 16 位无符号整数。
  • setInt32 写入 4 个字节的 32 位整数。
  • setUint32 写入 4 个字节的 32 位无符号整数。
  • setFloat32 写入 4 个字节的 32 位浮点数。
  • setFloat64 写入 8 个字节的 64 位浮点数。
const view = new DataView(buff)// DataView.setInt8(byteOffset: number, value: number): voidview.setInt8(0, 0xbb)// DataView.setInt16(byteOffset: number, value: number, littleEndian?: boolean | undefined): voidview.setInt16(4, 1, true)view.setInt32(8, 520, true)

DataView 的属性

  • buffer:保存着这个 DataView 操作的 ArrayBuffer 对象。所以从 DataView 对象里返回其数据时,要使用它的 buffer 属性。
  • byteOffset:起始位置的偏移量
  • byteLength:字节长度,也就是内存使用量。

DataView 不是类数组对象,所以没有 length 属性。

一些应用示例

UTF8BOM 头

有一次,我把导出的数据生成 CSV 文档时,文档里包含中文,但是打开后显示的是乱码。

解决办法是在文件的开头添加 UTF8BOM 标记。

UTF8BOM 标记是 3 个字节长度的固定值:EF BB BF。

这里使用了 TypedArray

const buff = new ArrayBuffer(3)const int8 = new Int8Array(buff)int8[0] = 0xefint8[1] = 0xbbint8[2] = 0xbf

icon 文件的图像入口

icon 文件里可以包含多个 png 图像的数据,有一段区域用来存放图像的元数据,每个图像的元数据占用 16 字节,有 3 种数据类型。

因为存在不同的数据类型,所以这适合使用 DataView

const buff = new ArrayBuffer(16)const v2 = new DataView(buff)v2.setInt8(0, png.width) // Width, in pixels, of the imagev2.setInt8(1, png.height) // Height, in pixels, of the imagev2.setInt8(2, 0) // Number of colors in image (0 if >=8bpp)v2.setInt8(3, 0) // Reserved ( must be 0)v2.setInt16(4, 1) // Color Planesv2.setInt16(6, 32) // Bits per pixelv2.setInt32(8, png.buffer.byteLength) // How many bytes in this resource?v2.setInt32(12, fileOffset) // Where in the file is this image?

合并 ArrayBuffer

有一个大文件是分段加载的,多个片段接收完成后,获取了多个 ArrayBuffer,最后需要把它们拼接起来。

但是 ArrayBuffer 不能直接相加,我们可以建立一个空的 TypedArray 作为容器,把多个 ArrayBuffer 填充进去,返回填充后的值。

private appendBuff(target: ArrayBuffer, newBuff: ArrayBuffer) {
  // 总长度
  const totalLength = target.byteLength + newBuff.byteLength

  // 在 TypedArray 构造函数里传入一个数字,构建指定长度的内存片段
  const uint8 = new Uint8Array(totalLength)

  // 使用 set 方法把 ArrayBuffer 依次添加进去
  // Uint8Array.set(array: ArrayLike<number>, offset?: number | undefined): void
  uint8.set(new Uint8Array(target))
  uint8.set(new Uint8Array(newBuff), target.byteLength)

  // 返回 TypedArray 的 ArrayBuffer 数据
  return uint8.buffer}

从 Zip 文件里提取图像数据

Pixiv 这个网站的动图源文件是 Zip 压缩包(压缩级别为“仅存储”),里面存放着每一帧 jpg 图像。

用十六进制查看器打开文件,可以看到图像内容是按顺序存放的:


每张图片的文件名格式固定,都是从 000000.jpg 开始自增。

文件名后面紧跟着的就是图像内容。

粗略示意:

000000.jpg
...文件内容
000001.jpg
...文件内容
000002.jpg
...文件内容

我在 Zip 文件的数据里查找每个文件名的位置。以下是代码仅为示意,不是完整代码。

// 每次查找时,开始的位置let offset = 0     // 保存每个 jpg 文件的开始位置 const indexList = []while (true) {
  let data: Uint8Array
  if (offset === 0) {
    // 一开始从压缩包的开头开始查找
    data = new Uint8Array(zipFileBuffer)
  } else {
    // 每次查找之后,从上次查找结束的位置开始查找
    // 这样可以避免重复查找前面的数据
    data = new Uint8Array(zipFileBuffer, offset)
  }

  // 查找以 0 开头,长度为 10,以 jpg 结束的值的索引
  const index = data.findIndex((val, index2, array) => {
    // 0 j p g
    if (
      val === 48 &&
      array[index2 + 7] === 106 &&
      array[index2 + 8] === 112 &&
      array[index2 + 9] === 103
    ) {
      return true
    }
    return false
  })

  if (index !== -1) {
    // 如果找到了,则会开始下一轮查找
    // 10 是文件名的长度
    const fileContentStart = offset + index + 10
    indexList.push(fileContentStart)
    offset = fileContentStart
  } else {
    break
  }}

在找到了所有图像的开始位置之后,就可以从 Zip 文件的 ArrayBuffer 上用 slice 方法取出文件的内容,显示到页面上。

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多