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 方法取出文件的内容,显示到页面上。