Web中的二进制数据

346 阅读12分钟

背景

前端开发中大多处理的都是文本类型的数据,例如数字、字符串等,但有时候也会接触到二进制的数据处理,比如文件、音频、视频相关的操作。今天的分享就是对二进制数据及其相关操作的整理。

我们先整体看一下有哪些二进制相关的类

我们至上而下,一个一个看,首先是File类。

File

上传文件的需求,大家肯定都遇到过。像Element或者Ant Design都提供了Upload组件去解决文件上传的问题,但其实HTML原生就已经提供了文件上传的能力。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <input type="file" id="fileInput" multiple="multiple" />

    <script>
      const fileInput = document.getElementById("fileInput");
      fileInput.addEventListener("change", (e) => {
        const files = e.target.files;
        console.log({ files, file: files[0] });
      });
    </script>
  </body>
</html>

只需要把input元素的type设置为file,就可以支持上传了,通过设置multiple属性,可以控制是否能上传多个元素。上传之后,就能拿到e.target.files,这个就是一个File类型的数组,可以看到File类型有这些属性,可以从中获知这个文件的基本信息,比如:

  • lastModified:引用文件最后修改日期,为自1970年1月1日0:00以来的毫秒数;
  • lastModifiedDate:引用文件的最后修改日期;
  • name:引用文件的文件名;
  • size:引用文件的文件大小;
  • type:文件的媒体类型(MIME);

File本身并没有提供额外的方法去操作它,想了解怎么去操作一个File,就需要了解第二个类Blob,Blob是File的父类,这一点从File的原型上也能看出来。所以我们了解了怎么操作一个Blob对象,也就知道了怎么去操作一个File对象。

Blob

api

Blob的全称是Binary Large Object,表示二进制类型的大对象,名称来源于数据库。Blob 通常是视频、音频或其他多媒体文件。在JavaScript中Blob表示二进制数据,但是不一定是大量数据,几个字符串也可以生成一个Blob对象。

var aBlob = new Blob(blobParts, options);

blob的构造函数有两个参数,

  • 第一个参数是blob对象的内容,它是一个数组,数组的内容可以是字符串、Blob、ArrayBuffer等
  • 第二个参数是一个对象,可以在这个对象中指定blob的MIME类型,这个值指定了blob对象的媒体类型,这个类型和请求/响应中的Content-Type是一样的。通常包含类型(type)和子类型(subtype)两个部分。
    • 类型代表数据类型所属的大致分类,例如 video 或 text。
    • 子类型标识了 MIME 类型所代表的指定类型的确切数据类型。以 text 类型为例,它的子类型包括:plain(纯文本)、html(HTML 源代码)。
    • 像text/plain、text/html、application/json都是我们经常接触到的类型。

我们现在用简单使用字符串去生成一个Blob,去看看Blob具体有什么。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      const blob = new Blob(["Hello, world!", "Hello JavaScript"], {
        type: "text/plain",
      });

      console.log({ blob });
    </script>
  </body>
</html>

可以看到有两个属性:

  • size:blob对象的字节数
  • type:blob对象的媒体类型

然后它有几个方法:

  • slice([start[, end[, contentType]]]):类似数组的slice方法,可以截取这个对象的某一块生成一个新的blob对象
  • text():返回一个 Promise 对象且包含 blob 所有内容的 UTF-8 格式的 USVString。像我们刚刚用字符串生成blob,就可以用text去读取它的内容
  • arrayBuffer():返回一个 Promise 对象且包含 blob 所有内容的二进制格式的 ArrayBuffer。这个类后面会讲
  • stream():返回一个能读取 blob 内容的可读流。

基本的api介绍完之后,我们来看一下Blob有哪些使用场景

应用场景

分片上传

第一个场景是分片上传。大文件传输可能会遇到内存或者性能的限制,我们对大文件进行切割,然后分片进行上传。这里只是一个简化的分片上传的函数,只是用到了blob.slice这一个api,把大文件分割成一个、一个的chunk,然后后台再组合起来。

async function chunkedUpload(file, chunkSize, url) {
  for (let start = 0; start < file.size; start += chunkSize) {
    const chunk = file.slice(start, start + chunkSize + 1);
    const fd = new FormData();
    fd.append("data", chunk);

    await fetch(url, { method: "post", body: fd }).then((res) => res.text());
  }
}

用作URL

Blob的第二个应用场景就是作为URL来使用。这里需要先介绍一下Blob URL这个概念。

Blob URL/Object URL

Blob URL/Object URL 是一种伪协议,在浏览器中我们使用 URL.createObjectURL 方法来创建 Blob URL,该方法接收一个 Blob 对象,并为其创建一个唯一的 URL,其形式为 blob:<origin>/<uuid>,对应的示例如下:

blob:https://example.org/40a5fb5a-d56d-4a33-b4e2-0acf6a8e5f641

浏览器内部为每个创建的URL存储了一个 URL → Blob 映射。因此,此类 URL 较短,但可以访问 Blob。

伪协议,也被称为伪URL协议或者伪URL方案,是一种特殊的URL方案,它并不指向网络上的资源,而是在浏览器中执行特定的操作或访问特定的资源。伪协议的URL通常以冒号结束,没有指定的主机名或路径。

基于这个特性,可以衍生出图片预览和文件下载这两个用处。我们先来看一下图片预览:

图片预览

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <input type="file" class="upload" />
    <img src="" alt="" class="img" />
    <script>
      const input = document.querySelector(".upload");
      const img = document.querySelector(".img");
      input.addEventListener("change", function (e) {
        const file = e.target.files[0];
        const blobUrl = URL.createObjectURL(file);
        img.src = blobUrl;
      });
    </script>
  </body>
</html>

下载文件

第二个衍生的用处是文件下载。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <button class="button">下载</button>
    <script>

      function downloadText() {
        const blob = new Blob(["Hello, world!"], {
          type: "text/plain",
        });

        const url = URL.createObjectURL(blob);
        const link = document.createElement("a");
        link.href = url;
        link.download = `blob.txt`;
        link.click();

        // 清理
        URL.revokeObjectURL(url);
      }

      const btn = document.querySelector(".button");
      btn.addEventListener("click", downloadText);
    </script>
  </body>
</html>

Blob有一个副作用,就是Blob URL生成之后,Blob会常驻内存中,直到文档卸载,Blob才会被自动清理,如果应用程序生命周期比较长,会造成内存不必要的浪费。因此如果不需要用了,就应该调用URL.revokeObjectURL手动清理掉。

上面这个场景是前端主动生成一个文件。更多的时候是从接口中下载一个文件,像MES系统经常会返回一个Excel类型的文件流,比如推广链接

const response = await fetch(API_INFO_FEEDS_LINK_EXPORT({}).url, {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
    },
    body: JSON.stringify(searchParams.value),
});
if (!response.ok) {
    ElMessage.error('请求失败');
    return;
}

const blobData = await response.blob();
const blob = new Blob([blobData], {type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `推广链接-${dayjs().format('YYYYMMDD')}.xlsx`;

// 模拟点击链接,触发下载
link.click();

// 清理
URL.revokeObjectURL(url);

除了blob url的方式可以用作url之外,也可以转成base64来当成url用

转成base64

Base64 是一种编码方案,它可以将二进制数据转换为 ASCII 字符串。

大多数现代浏览器都支持Data URLs这种特殊的URL方案,它允许你直接在 URL 中嵌入数据,可以减少一次网络请求。Data URL最常见的格式就是base64

这里需要引入一工具类FileReader,它可以把blob对象转成base64

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <input type="file" class="upload" />
    <img src="" alt="" class="img" />

    <script>
      const input = document.querySelector(".upload");
      const img = document.querySelector(".img");

      input.addEventListener("change", function (e) {
        const file = e.target.files[0];
        const reader = new FileReader();
        reader.onload = function (e) {
          img.src = e.target.result;
        };
        reader.readAsDataURL(file);
      });
    </script>
  </body>
</html>

读取文件内容

Blob对象的另一个应用场景是读取文件内容。FileReader的readAsText方法,将blob转成文本

<input type="file" class="file" />
<div class="show"></div>
const input = document.querySelector(".file");
const show = document.querySelector(".show");

input.addEventListener("change", function (e) {
  const file = e.target.files[0];
  const reader = new FileReader();
  reader.onload = function (e) {
    const text = e.target.result;
    console.log({ text });
    show.innerHTML = text;
  };
  reader.readAsText(file);
});

通过流的形式读取文件内容

  • FileReader 会将整个文件加载到内存中,适用于小型文件。对于大型文件,可能会导致内存消耗过大。这个时候就需要引入stream api。
  • stream它提供了一种流式处理数据的方式,可以逐块地读取和处理文件内容,而不必将整个文件加载到内存中。流的种类有可读流、可写流、转换流等。使用流的时候还需要考虑流上游生产和下游消费的速度。上游生产速度远大于下游消费速度,会造成缓冲区溢出,淹没下游。生产速度远小于消费速度,这样存在性能的浪费。

<input type="file" class="file" />

这里只介绍怎么读取,怎么解析chunk在此不作介绍。

const input = document.querySelector(".file");

input.addEventListener("change", (e) => {
  const file = e.target.files[0];
  const reader = file.stream().getReader();

  function readNextChunk() {
    reader.read().then(({ done, value }) => {
      if (done) {
        console.log("done");
        return;
      }

      console.log("读取到 " + value.byteLength / 1024 + " kb数据");

      // 继续读取下一块数据
      readNextChunk();
    });
  }

  // 开始读取第一块数据
  readNextChunk();
});

Blob对象就介绍到这里了,它可以用slice整段的裁剪,也可以整体转成一个新对象来使用,但它是不可编辑的,如果不需要编辑内容,使用blob就足够了,如果想对一段二进制数据的内容进行编辑,就需要了解ArrayBuffer这个类。

ArrayBuffer

api

ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区

这个对象的原始设计目的,与 WebGL 项目有关。所谓 WebGL,就是指浏览器与显卡之间的通信接口,为了满足 JavaScript 与显卡之间大量的、实时的数据交换,它们之间的数据通信必须是二进制的,而不能是传统的文本格式。文本格式传递一个 32 位整数,两端的 JavaScript 脚本与显卡都要进行格式转化,将非常耗时。这时要是存在一种机制,可以像 C 语言那样,直接操作字节,将 4 个字节的 32 位整数,以二进制形式原封不动地送入显卡,脚本的性能就会大幅提升。

我们先简单看一下ArrayBuffer的api

  • 构造函数:new ArrayBuffer(bytelength),指定缓冲区的字节数量,初始化内容是0
new ArrayBuffer(length)

除了构造函数之外,fileReader也能把一个blob对象转成ArrayBuffer

const reader = new FileReader();

reader.onload = function(e) {
  let arrayBuffer = reader.result;
}

reader.readAsArrayBuffer(file);
  • ArrayBuffer.prototype.byteLength,返回字节数量
  • ArrayBuffer.prototype.slice(start, end),支持裁剪
const buffer = new ArrayBuffer(16);
console.log({ buffer }); // 16
console.log({ buffer: buffer.slice(0, 8) }); // 8

视图

ArrayBuffer 的内容不能直接操作,只能通过视图对它们进行下标读写,这些改变最终都会反应到它所建立在的 ArrayBuffer 之上。

  • TypedArray 视图:固定数据类型的视图。共包括 9 种类型的视图,比如Uint8Array(无符号 8 位整数)数组视图, Int16Array(16 位整数)数组视图, Float32Array(32 位浮点数)数组视图等等。
  • DataView 视图:不固定数据类型,可以是上面TypedArray类型的组合。比如第一个字节是 Uint8(无符号 8 位整数)、第二、三个字节是 Int16(16 位整数)、第四个字节开始是 Float32(32 位浮点数)等等,此外还可以自定义字节序。

ArrayBuffer 本身只是一行 0 和 1 串。 ArrayBuffer 不知道该数组中第一个元素和第二个元素之间的分隔位置。

为了操作这段数据,要将其分解为多个盒子,这个盒子我们就可以理解为视图。可以使用TypedArray添加数据视图,并且你可以使用许多不同类型的类型数组。

例如,你可以有一个 Int8 类型的数组,它将把这个数组分成 8-bit 的字节数组。

Uint16Array

TypedArray可以直接通过下标进行读写,支持的api和数组几乎是一样的

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      // new Uint8Array(buffer [, byteOffset [, length]]);
      var buffer = new ArrayBuffer(8);
      var int8Arr = new Uint8Array(buffer, 1, 4);
      int8Arr[0] = 42;

      console.log({ buffer, bit: int8Arr[0] });
    </script>
  </body>
</html>

支持的api和数组几乎是一样的

DataView

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      var buffer = new ArrayBuffer(8);
      var dataview = new DataView(buffer, 1, 4);
      dataview.setInt8(0, 42);
      console.log(dataview);
    </script>
  </body>
</html>

应用

直接修改二进制数据具体内容的场景在前端应该比较少见,我们在这只介绍一个图片灰度化的应用场景。这个应用需要使用canvas完成,我们先介绍一些canvas的api。

getImageData:获取画布上的像素数据

const ctx = canvas.getContext("2d");
ctx.getImageData(sx, sy, sw, sh);

相应的参数说明如下:

  • sx:将要被提取的图像数据矩形区域的左上角 x 坐标。
  • sy:将要被提取的图像数据矩形区域的左上角 y 坐标。
  • sw:将要被提取的图像数据矩形区域的宽度。
  • sh:将要被提取的图像数据矩形区域的高度。

putImageData:修改画布上的像素数据

void ctx.putImageData(imagedata, dx, dy);
void ctx.putImageData(imagedata, dx, dy, dirtyX, dirtyY, dirtyWidth, dirtyHeight);
  • imageData: ImageData ,包含像素值的数组对象。
  • dx:源图像数据在目标画布中的位置偏移量(x 轴方向的偏移量)。
  • dy:源图像数据在目标画布中的位置偏移量(y 轴方向的偏移量)。
  • dirtyX(可选):在源图像数据中,矩形区域左上角的位置。默认是整个图像数据的左上角(x 坐标)。
  • dirtyY(可选):在源图像数据中,矩形区域左上角的位置。默认是整个图像数据的左上角(y 坐标)。
  • dirtyWidth(可选):在源图像数据中,矩形区域的宽度。默认是图像数据的宽度。
  • dirtyHeight(可选):在源图像数据中,矩形区域的高度。默认是图像数据的高度。

灰度化

在ImageData对象的数据属性中,每个像素所占据的信息是按照RGBA顺序排列的,也就是红色(R)、绿色(G)、蓝色(B)和Alpha(A)通道的信息。因此,数组中的每四个连续元素依次表示一个像素的RGBA信息。

将红绿蓝这三个颜色通道的值相加并取平均值,实际上是对彩色信息进行了平均化处理,使得每个像素的颜色趋向于中性灰色。使得图像呈现出灰度效果。

完整代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>获取远程图片并灰度化</title>
  </head>
  <body>
    <div>
      <button id="grayscalebtn">灰度化</button>
      <div style="display: flex">
        <div style="flex: 50%">
          <p>原图片</p>
          <img
            id="previewContainer"
            width="230"
            height="230"
            style="border: 2px dashed blue"
          />
        </div>
        <div style="flex: 50%">
          <p>灰度图片</p>
          <canvas
            id="canvas"
            width="230"
            height="230"
            style="border: 2px dashed grey"
          ></canvas>
        </div>
      </div>
    </div>
    <script>
      const image = document.querySelector("#previewContainer");
      const canvas = document.querySelector("#canvas");

      fetch("https://avatars3.githubusercontent.com/u/4220799")
        .then((response) => response.blob())
        .then((blob) => {
          const objectURL = URL.createObjectURL(blob);
          image.src = objectURL;
          image.onload = () => {
            const ctx = canvas.getContext("2d");
            ctx.drawImage(image, 0, 0, 230, 230);
          };

          const grayscalebtn = document.querySelector("#grayscalebtn");
          grayscalebtn.addEventListener("click", grayscale);
        });

      const grayscale = function () {
        const ctx = canvas.getContext("2d");
        ctx.drawImage(image, 0, 0, 230, 230);
        const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
        const data = imageData.data;
        console.log({ data });
        // 每个像素
        for (let i = 0; i < data.length; i += 4) {
          const avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
          data[i] = avg; // red
          data[i + 1] = avg; // green
          data[i + 2] = avg; // blue
        }
        ctx.putImageData(imageData, 0, 0);
      };
    </script>
  </body>
</html>

参考

你不知道的Blob

玩转二进制

阮一峰 es6入门

谈谈js二进制