yangkean / blog

official blog
5 stars 0 forks source link

用 ArrayBuffer 来截取二进制数据中的字符 #5

Open yangkean opened 6 years ago

yangkean commented 6 years ago

前言

大多数时候,用户选取文件后只需要把文件提交给后台,但是,如果需要在选取一个二进制文件后再根据该文件末尾表示的 2 字节短整型数字大小然后往前偏移该数值大小截取所需字符串,该怎么做呢?这些信息是二进制数据,与平常处理的字符串有很大不同。一种方法是,我们将该文件传给后台,后台处理完再发给前台显示。但自从 ArrayBuffer 获得了广泛的浏览器支持,在前端处理二进制数据便不再是一件难事了。ArrayBuffer 的应用场景更多是在网页游戏中的二进制数据交换,但是我们也可以用它来处理文件中的二进制数据。也许上面的场景听起来有点晕,我们一步步地来分解过程。

概述

在讲如何用 ArrayBuffer 操作二进制数据之前,我们需要明确一些与 ArrayBuffer 相关的知识。

我们通常使用 ArrayBuffer 得到系统分配的一段长度为 length 的可以存放二进制数据的连续的内存区域,但是要操作这段内存区域,还得借助 Int8Array、Uint8Array 等九种 TypedArray 的构造器函数来创建这段内存区域的类数组视图,然后就可以借助数组下标来访问内存中的二进制数据,TypedArray 中的 type 指的并不是 JavaScript 中的原生数据类型,而是视图的类型,比如 Int8Array 指的是类数组的每个元素是一个 8 位有符号整数。TypedArray 的类型是需要特别注意的,接下来的文章会告诉你这是为什么。

const arrayBuffer = new ArrayBuffer(10);
arrayBuffer.byteLength; // 10

const uInt8Array = new Uint8Array(arrayBuffer); 
uInt8Array; // Uint8Array(10) [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

读取文件

在前端的应用场景中,我们通常会利用 type 为 file 的表单读取一个文件,然后在 onload 事件中获取到该文件对象并对文件进行操作。我们也采取这种做法,不过我们要将文件对象读取为 ArrayBuffer 对象,方便我们后面对字节数组的操作。

const file = document.querySelector('input[type="file"]');
const reader = new FileReader();

reader.addEventListener('load', (event) => {
  const buffer = event.target.result; // 我们得到了转化为 ArrayBuffer 对象的文件对象
});

reader.readAsArrayBuffer(file.files[0]);

截取文件对象中的字节

现在,我们得到了转化为 ArrayBuffer 对象的文件对象,接下来就是从对象中截取字节了。假定我们要截取文件对象的最后两个字节。

const uInt8Array = new Uint8Array(buffer);
const size = uInt8Array.length;

const lastTwoBytes = uInt8Array.slice(size - 2, size);

类型数组的大多方法与普通数组是类似的,所以我们可以借助 slice 截取字节数组中的最后两个字节。现在,我们得到了包含两个元素且每个元素是一个字节大小的数组,需要注意的是,我们这里说的一个字节的大小,指的是每个元素的数值最大不能大于 1 个字节 (即每个元素的值的范围在 0 ~ 255),并不是说每个元素是二进制的表示法,恰恰相反,每个元素是十进制的表示法。还有一个容易忽略的点是,我们使用 slice 截取数组的一部分并作为新数组返回,而这个新数组依然是原类型的类型数组,而不是普通数组,不注意这个的话,你就会无形之中被坑了。

console.log(lastTwoBytes); // Uint8Array(2) [1, 126]

从十进制元素到二进制字符串元素

上面我们说了类型数组中的数值是十进制的表示法,因为我们要获取的是最后两个字节表示的短整型 (也就是 16 位的整型) 数字,我们需要把最后 2 个元素转换为二进制字符串再合并并最终转换为十进制数字,就是我们要的短整型数字。

const shortIntString = Array.from(lastTwoBytes, (item) => item.toString(2).padStart(8, '0')).join('');

我们来解释下这行代码所做的事,首先,我们用 Array.from 将 lastTwoBytes 这个类型数组对象转换为普通数组,因为我们说过,TypedArray 存储元素时使用的是十进制的形式,我们需要将十进制表示的数转换为字符串表示并合并,而 Uint8Array 会将字符串表示在合并之前又转换成十进制表示,并且还有其他麻烦的问题 (见下面的附注),所以要先转换为普通数组 (普通数组每个元素可以存储包括 4 字节大小的数字)。

附注:

如果给 TypedArray 构造器函数传入一个字符串数组,字符串元素会按照十进制被转换为数字。如果类型数组的一个元素大于这个类型数组的每个元素允许的最大值,比如 Uint8Array 的每个元素最大为 255,则存储时存的是这个数求余 2^8 得到的数,比如,10000100 存的就是 10000100 % 256 = 228,所以 new Uint8Array(['10000100']) 得到的会是 Uint8Array [228] 而不是 Uint8Array [132]。

其次,Array.from 的第二个参数容易被人忽略,它是一个 map 函数,可以对生成的数组进行 map 操作,非常方便。对每个元素,我们使用数字的 toString 方法转换为二进制字符串。但是还没完,数字的 toString 方法有个极其坑爹的地方,如果一个转换好的二进制字符串有前缀 0,这个前缀 0,乍一看,对单个二进制数来说是没有什么问题的,但当两个二进制数作为一个整体时,这少掉的前缀 0 将是致命的,直接导致合并后的二进制数转换的十进制数是错误的,因此我们要给二进制字符串补前缀 '0',麻烦的方法是根据字符串应有的长度进行遍历补 '0'。好消息是,ES8 中有个很棒的方法 padStart,传入应达到的长度 8 和前置补充的字符 '0',就解决这个问题了。最后用 join 将两个字符串合并,便得到了短整型数字的二进制字符串表示,离我们的目标更近了!

得到短整型数和我们需要的字符串

上面我们得到了短整型数字的二进制字符串表示,要转换为十进制数字很容易,用 parseInt 即可。

const goToPosition = Number.parseInt(shortIntString, 2);

现在 goToPosition 就是我们要的偏移值了,借助这个偏移值,便可以得到指定范围的字符串了。

const sliceStart = size - 2 - goToPosition;
const sliceEnd = size - 2;
const resultArray = uInt8Array.slice(sliceStart, sliceEnd);
const result = String.fromCharCode(...resultArray);

String.fromCharCode 可以将范围在 0 ~ 65535 的最常用的 Unicode 码转换为对应的字符。终于,我们得到了隐藏在二进制中的字符串信息。下面是完整的处理代码:

  /**
   * 给定 ArrayBuffer 对象,返回该对象转换为 Uint8Array 类数组对象时其内部指定范围的字符串表示
   * 
   * @param {ArrayBuffer} buffer - 操作的 ArrayBuffer 对象
   * @return {string} 给定 ArrayBuffer 对象的内部指定范围的字符串表示
   */
  function getStringFromArrayBuffer(buffer) {
    const uInt8Array = new Uint8Array(buffer);

    // 这里也许有解压操作...

    // 取得末尾两个字节的二进制字符串表示
    const lastTwoBytes = uInt8Array.slice(size - 2, size);

    const shortIntString = Array.from(lastTwoBytes, (item) => item.toString(2).padStart(8, '0')).join('');

    // 将二进制字符串转换为十进制数字
    const goToPosition = Number.parseInt(shortIntString, 2);

    const sliceStart = size - 2 - goToPosition;
    const sliceEnd = size - 2;
    const resultArray = uInt8Array.slice(sliceStart, sliceEnd);
    const result = String.fromCharCode(...resultArray);

    return result;
  }

值得一提的是,二进制数据有时是经过压缩的,这种情况下我们可以借助 pako 这样的解压缩库解压一波再操作,赞!