文章为在下以前开发时的一些记录与当时的思考, 学习之初的内容总会有所考虑不周, 如果出错还请多多指教.

TL;DR

在浏览器中处理二进制数据,需要使用 Typed ArrayArrayBufferDataView.

二进制数据使用的数据类型:Typed Array

在浏览器环境中使用的二进制数据类型一般为 Typed Array(类型数组) ,它和普通的数组很像,只不过里面的成员类型是严格要求,并且长度固定的.

类型数组拥有以下几种:

  • Int8Array:每个成员是有符号的 8 位整形,取值范围 -128 - 127.
  • Uint8Array:每个成员是无符号的 8 位整形,取值范围 0 - 255.
  • Uint8ClampedArray:每个成员是无符号的 8 位整形,取值范围 0 - 255,和上面的类型不同的是,若成员超过 255 或小于 0,则取相应最大值 255 或 最小值 0,而 Uint8Array 会进行类推取一个越界后的映射值. 当在处理色彩相关逻辑时非常有用.
  • Int16Array:每个成员为有符号的 16 位整形,取值范围 -32768 - 32767.
  • Uint16Array:每个成员为无符号的 16 位整形,取值范围 0 - 65535.
  • Int32Array:每个成员为有符号的 32 位整形,取值范围 -2147483648 - 2147483647.
  • Uint32Array:每个成员为无符号的 32 位整形,取值范围 0 - 4294967295.
  • Float32Array:浮点数版本的 Int32Array.
  • Float64Array:64 位版本的 Float32Array.

简单举例:

const uInt8Array = new Uint8Array(10)
uInt8Array.length  // 10
uInt8Array[0] = 255  // 可以操作下标.
复制代码

类型数组的详细文档您可以在这里查阅.

存放数据的容器:ArrayBuffer

一个类型数组是需要存放到一个容器中的,这个容器叫做 ArrayBuffer.

ArrayBuffer 用来向浏览器申请一块区域存放类型数组,作用有点像 malloc 的感觉.

创建类型数组时可以先创建一个 ArrayBuffer 然后传入,也可以直接创建指定长度的类型数组;如果直接创建,则浏览器会自动创建一个 ArrayBuffer 来存储此类型数组:

const int8 = new Int8Array(10)
int8.buffer  // 这个就是存储这个类型数组的 ArrayBuffer.

// 当然也可以显式创建:
const buffer = new ArrayBuffer(10)  // 申请 10 字节长度.
const int8 = new Int8Array(buffer)
复制代码

ArrayBuffer 的详细说明请看这里.

方便操作二进制数据的工具:DataView

实际上类型数组可以使用下标的方式来读写数组,只不过,太痛苦了点吧……还有大小端问题……

因此对于复杂的逻辑,我们可以使用 DataView 这个对象来对类型数组进行操作:

const buffer = new ArrayBuffer(16)
const dataView = new DataView(buffer, 0)
dataView.setInt16(2, 20)  // 在第二个 16 位数的位置上以大端写入 20.
dataView.getInt16(2)  // 20

// 将 buffer 数据映射至一个 Int8Array 中查看 buffer 结构:
const int8Array = new Int8Array(buffer)  // 类型数组也可以传入一个 ArrayBuffer 来创建,将直接映射这个 ArrayBuffer.
console.log(int8Array) // [0, 0, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

// 以小端的方式再写一次:
dataView.setInt16(2, 20, true)
console.log(int8Array) // [0, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
复制代码

DataView 提供了一些 API,详细的还请各位慢慢查阅文档.

使用案例:拼一个 goim 弹幕协议的数据包

goim 是 B 站搞的一个弹幕协议,在 WebSocket 上同样可以根据此协议进行数据设计,不过在 WebSocket 上使用它的话是就要使用二进制方式传输数据,而非文本,所以数据包是需要在浏览器进行拼接的.

根据 Github 的文档可以看到其数据包格式:



四个字节表示包长度,两个字节表示头部长度,两个字节表示协议版本,四个字节表示当前操作,四个字节为顺序 ID 标记,剩下的为数据本体.

那么就可以写一个简单的创建代码:

// 根据文档定义 offset.
const packetOffset = 0
const headerOffset = 4
const verOffset = 6
const opOffset = 8
const seqOffset = 12
const bodyOffset = 16

// 弹幕协议包头的基础长度为 16.
const headerLength = 16

/**
 * 创建一个数据包.
 *
 * @param {IPacketOption} option
 * @returns {ArrayBuffer}
 */
function createPacket (option: IPacketOption): ArrayBuffer {
  const headerBuffer = new ArrayBuffer(headerLength)
  const headerView = new DataView(headerBuffer, 0)

  const bodyBuffer = stringToArrayBuffer(option.body)

  headerView.setInt32(packetOffset, headerLength + bodyBuffer.byteLength)  // 设置包长度, 长度 4 字节。
  headerView.setInt16(headerOffset, headerLength)  // 设置头部度. 4 字节.
  headerView.setInt16(verOffset, option.version)  // 设置版本. 2 字节.
  headerView.setInt32(opOffset, option.operation)  // 设置操作标识符, 4 字节.
  headerView.setInt32(seqOffset, option.sequence)  // 设置序列号, 4 字节.
  return mergeArrayBuffer(headerBuffer, bodyBuffer)
}

/**
 * Packet 创建参数.
 *
 * @interface IPacketOption
 */
interface IPacketOption {
  version: number
  operation: number
  sequence: number
  body: string
}

/**
 * 将字符串转换为基于 Int8Array 的 ArrayBuffer.
 *
 * @param {string} content
 * @returns {ArrayBuffer}
 */
function stringToArrayBuffer (content: string): ArrayBuffer {
  const buffer = new ArrayBuffer(content.length)
  const bufferView = new Int8Array(buffer)

  for (let i = 0, length = content.length; i < length; i++) {
    bufferView[i] = content.charCodeAt(i)
  }

  return buffer
}

/**
 * 合并多个 ArrayBuffer 至同一个 ArrayBuffer 中.
 *
 * @param {...ArrayBuffer[]} arrayBuffers
 * @returns {ArrayBuffer}
 */
function mergeArrayBuffer (...arrayBuffers: ArrayBuffer[]): ArrayBuffer {
  let totalLength = 0
  arrayBuffers.forEach(item => {
    totalLength += item.byteLength
  })

  const result = new Int8Array(totalLength)
  let offset = 0
  arrayBuffers.forEach(item => {
    result.set(new Int8Array(item), offset)
    offset += item.byteLength
  })

  return result.buffer
}
复制代码

好像可以减少操作?

这里有一段好像可以减少操作?

// 这段代码的大致意思是将 "存储了像素信息的数组中的数据绘制在 Canvas 中".

const buffer = new ArrayBuffer(imageData.data.length)
const buffer8 = new Uint8ClampedArray(buffer)
const data = new Uint32Array(buffer)

for (let y = 0; y < canvasHeight; y++) {
  for (let x = 0; x < canvasWidth; x++) {
    if (typeof pixelArr[y] === 'undefined') { continue }

    const value = pixelArr[y][x]

    if (typeof value === 'undefined' || value === null) { continue }

    data[y * canvasWidth + x] =
      255 << 24 |
      value[2] << 16 |
      value[1] << 8 |
      value[0]
  }
}

imageData.data.set(buffer8)
context.putImageData(imageData, 0, 0)
复制代码