我妈都看得懂的 Buffer 基础

5,649 阅读8分钟

在 JS 世界里,有一类看上去很高大上实质上很基础的东西:Buffer。今天好好介绍一下这厮。

什么是二进制对象

如果你曾经搜索过 buffer 相关的知识,你应该见过以下这几兄弟:ArrayBufferUint8ArrayUint16ArrayUint32ArrayTypedArrayBlobDataView

...
...
...

然后没耐心的同学可能就关掉了搜索的一堆 tab 🙂
所以这几个东西到底是啥?在讲 buffer 之前,我们有必要搞清楚这几个东西的来龙去脉

ArrayBuffer 和它的兄弟们

ArrayBuffer 是最基础的二进制对象,是对固定长度的连续内存空间的引用

它虽然叫做 Array,但本质上和数组并没有什么关系。这玩意是申请了一段内存区域,但是里面放的是啥,我们并不知道,它只是存了一堆字节,也无法直接操作它。如果我们想操作这段内存空间怎么办呢?这时候就需要一个称之为视图的家伙

Uint8ArrayUint16ArrayUint32Array,这几个东西就是视图。你可以把它理解成 ArrayBuffer 的翻译器,只不过他们的翻译方式有点不同:

  • Uint8ArrayArrayBuffer 中的每个字节视为一个单位。每个单位是 0 到 255 之间的数字。之所以是255,是因为每个单位最多是 8 位,即 2^8 次方。
  • Uint16ArrayArrayBuffer 中每 2 个字节视为一个单位。每个单位是 0 到 65535 之间的整数。原理同上。
  • Uint32ArrayArrayBuffer 中每 4 个字节视为一个单位。每个单位是 0 到 4294967295 之间的整数。原理同上。
// 我们可以通过 BYTES_PER_ELEMENT 静态属性来得之视图单位的大小
const buf8 = new Uint8Array();
const buf16 = new Uint16Array();
const buf32 = new Uint32Array();
console.log(buf8.BYTES_PER_ELEMENT); // 1
console.log(buf16.BYTES_PER_ELEMENT); // 2
console.log(buf32.BYTES_PER_ELEMENT); // 4

TypedArray

TypedArray 就是上面提到的 Uint8Array 那几个家伙的统称。他们都是 TypedArray (类型数组)的一种形式罢了。

需要注意的是,事实上并不存在 TypedArray 这个构造函数。这只是一个统称。
除了上述的类型外,还有其他类型数组,比如 Float64Array 等。可以查一下 MDN,这里不多说了。

Blob

Blob浏览器环境上提供的一种承载原始数据的二进制对象,它和 ArrayBuffer 没有必然联系,但是又可以互相转化。可以简单理解成另一种形式的,没这么底层的 ArrayBuffer

DataView

DataView 是一种底层的,更灵活的读取 ArrayBuffer 的视图。 从上文中我们知道诸如 Unit8Array 之类的 “翻译器” (视图)可以用来翻译 ArrayBuffer,但有的时候我们并不想固化 “翻译” 类型,比如我有的时候想用 8 翻译,有的时候想用 16 翻译。DataView 就是一种更灵活的视图,他就能满足这个无理的需求。

const buffer = new ArrayBuffer(16); // 分配一个内存空间
const view = new DataView(buffer); // 创建 DataView 视图
view.setUint32(0, 4294967295); // 从第 0 个空间开始,以 32 位的形式写入数据

// 有时候我想以 8 位的形式 “翻译” 这个内存空间,从偏移量 0 开始翻译
console.log(view.getUint8(0)); // 255
// 今天心情好,想以 16 位的形式 “翻译” 这个内存空间,从偏移量 0 开始读
console.log(view.getUint16(0)); // 65535
// 今天心情超好,想以 32 位的形式 “翻译” 这个内存空间,从偏移量 0 开始读
console.log(view.getUint32(0)); // 4294967295

理清这几兄弟

为什么需要二进制对象呢,因为计算机就是用二进制来交流的。举个不恰当的例子,你总不能对一个法国人飙中文,不翻译一波他是听不懂的。计算机同理,不是二进制的话它也听不懂,一切语言最终也是要翻译一波。这也是为什么二进制效率高的原因,因为天生不需要翻译,机器听得懂。总结一下上面的几个概念:

  • ArrayBuffer 是对内存的连续引用,这玩意没法直接操作。所以我们需要诸如 Unit8Array 等多种视图来 “翻译” 这厮
  • 我们将那堆视图称之为 TypedArray 类型数组。但事实上并不存在 TypedArray 这个构造函数,它只是那堆视图的统称
  • Blob 是另一种形式的二进制对象,主要在浏览器环境
  • DataView 是一种更灵活的视图,更灵活的操作 ArrayBuffer

什么是 buffer

言归正传,前面铺垫了这么多,相信大家对二进制对象已经有所了解了。那么到底什么是 buffer 呢?

buffer 即缓存,是对二进制数据处理的一种方式。 有计算机基础的同学应该都知道,计算机只能处理二进制数据,没错就是你看电影时看的 010101 那堆看上去很高大上的东西。 无论是数字,字符串,图片,音视频,其实电脑最终存起来的都是一堆 0101。有的时候我们想处理或者搬运一些数据,就意味着我们要 “挪动” 这堆 0101

一般来说我们一边挪,cpu 一边处理,这样并行就不会浪费时间。那么就涉及一个问题:如果挪的比 cpu 用的快,那么意味着会有一部分数据在等着被 cpu 用;如果挪的比 cpu 用的慢,那么 cpu 就要等待足够多的数据(构成有意义的能解读的整体)才能继续运算。

理想情况下,我们当然希望挪数据的时间和 cpu 处理的时间能一致,挪完一批 cpu 正好处理完上一批来接手新的一批。但事实上并不可能这么的巧。传输数据有可能比 cpu 快,也可能比 cpu 慢。无论哪种情况,那总归要一个位置去存储这些暂时用不上的数据(传的比 cpu 快,这个位置就存着提前到达的数据;比 cpu 慢,这个位置就存够一批足够能用的数据再给 cpu)这个区域就称之为缓存区(Buffer),一般是内存空间作为缓存空间。

可能有人会疑惑,为什么要从磁盘中读 IO 到内存缓存住,而不是 cpu 直接从磁盘中读数据呢?这是因为内存的读写速度比磁盘要快很多很多,归根结底缓存在更高速的内存中是为了让 cpu 用数据的时候更快,不用等待 IO。

Node 中的 Buffer

早年间的 js 并没有 Buffer,直到 ES6 推出才正式有了 ArrayBuffer 这个东西。但是 Node 是在服务端运行的,Buffer 对 Node 来说是刚需。早期的 Node 自己实现了一套非标准规范的 Buffer。通过 Buffer 的构造函数可以创建一个 Buffer 对象。而随着 Buffer 被纳入 js 标准规范,node 中的 Buffer 也逐渐适配新规范。现在的 Buffer 是在全局作用域中的,无需引入,开箱即用,且是 Uint8Array 类的子类。

node 中 Buffer 对象有很多 API,可以看官方文档了解。除了 Buffer 对象之外,Stream 和 Readline 也是比较常用的 node 内置二进制处理模块。简单介绍一下 Buffer 的基础 API

  • Buffer.alloc(size[, fill[, encoding]]) 创建一个 buffer 空间,可以填充制定元素,也可以指定编码类型
  • Buffer.from() 以 buffer 方式存储内容
    • Buffer.from(array) 创建一个 buffer 数组 buf,buf.values() 返回一个可以迭代的对象
    • Buffer.from(string[, encoding]) 创建一个 buffer 字符串,可以指定编码类型
    • Buffer.from(buffer) 拷贝一个 buffer 对象

有什么用

乍一眼看上去,Buffer 好像和我们常见的应用场景没什么关系。事实上这玩意体现在我们开发应用的方方面面。

前端人眼中的 Buffer

先从前端比较常见的说起。在 Web 开发中,我们可能需要对文件做一些处理。比如导出excel,下载文件,上传头像等等。 这些操作其实都是操作二进制数据。

// 伪代码示例
// Blob 上传图片

// fileHandler 为前置工作伪代码
// FileReader 加载图片,cavans 压缩图片等
const canvas = fileHandler()

// 创建 Blob 对象和 FormData
canvas.toBlob(blob => {
  this.imgBlob = blob
}, 'image/jpeg')
let formdata = new FormData()
formdata.append('file', this.imgBlob, 'img.jpeg')

// 上传图片
axios({
  headers: {
    "Content-Type": "multipart/form-data"
  },
  method: "post",
  url: uploadUrl,
  data: formdata
})
  .then(res => {
    // do something
  })
  .catch(err => {
    // do something
  });

大前端眼中的 Buffer

大前端可能就涉及一些前端工程化,脚手架,打包工具等。这时候就可以通过流式处理操作 Buffer。比如按行读取某个配置文件,处理 webpack 的一些工作流,和 cli 交互读取和写入文件等等。

// 伪代码示例
// 逐行读取配置文件,个性化配置

const fs = require("fs");
const readline = require("readline");

// 创建可读流
const rl = readline.createInterface({
  input: fs.createReadStream("theme.less"),
});

rl.on("line", (line: string) => {
  if (line.trim().startsWith("configStart")) {
    // 伪代码,处理变量
    themeHandlerStart()
  }
  if (line.trim().startsWith("configStart")) {
    // 伪代码,处理变量
    themeHandlerEnd()
  }
});

服务端眼中的 Buffer

对于服务端的同学来说,buffer 的应用就更广泛了
比如压缩和解压缩,比如加密解密,信息脱敏等等,其实都和 buffer 脱不了干系

此外因为 buffer 是操作二进制对象,所以他的性能和灵活性比常规的 js API 会强很多

  • 比如要求更快速的响应时,http 直接传输 buffer 会比传输字符串的效率更高
  • 比如日志持久化需要节省空间时,用不同的编码来压缩空间等等

用 Buffer 能更自由灵活的去调和时间复杂度和空间复杂度之间的关系

// 伪代码示例
// 压缩文件

const { createGzip } = require("zlib");
const { pipeline } = require("stream");
const { createReadStream, createWriteStream } = require("fs");

const gzip = createGzip();
const source = createReadStream("./package.json");
const destination = createWriteStream("./package.json.gz");

pipeline(source, gzip, destination, (err) => {
  if (err) {
    console.error("发生错误:", err);
    process.exitCode = 1;
  }
});