7

JavaScript 提供了一些 API 来处理文件或原始文件数据,例如:File、Blob、FileReader、ArrayBuffer、base64 等。下面就来看看它们都是如何使用的,它们之间又有何区别和联系!

image.png

ArrayBuffer

ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区,是内存中一段固定长度的连续数据存储区的引用,你无法直接操作或修改它,只能通过 DataView 对象或 TypedArrray 对象来访问。这些对象用于读取和写入缓冲区内容。

ArrayBuffer不是一个Array类型,如果想要判断其类型,可以使用

toString.call(new ArrayBuffer()) === '[object, ArrayBuffer]'

ArrayBuffer 本身就是一个黑盒,不能直接读写所存储的数据,需要借助以下视图对象来读写:

  • TypedArray:用来生成内存的视图,通过9个构造函数,可以生成9种数据格式的视图。
  • DataViews:用来生成内存的视图,可以自定义格式和字节序。

image.png

TypedArray

首先要弄清楚 TypedArray 的概念, 这是 ES2015(又称ES6) 中新出的一个接口, 不能直接被实例化, 也就是说如下代码会报错。

new TypedArray()

因为这个接口就是一个抽象接口, 就像java中的抽象接口一样, 是不能被实例化的, 只能实例化实现该接口的子类. Uint8Array 就是实现 TypedArray 接口的一个子类。

就 Nodejs 而言, 可以使用 Buffer 操作二进制数据, 那对前端 JS 而言, 在 TypeArray 出现之前, 是没有可以直接操作二进制数据的类的, 这也与前端很少需要操作二进制数据相关。

所以 TypeArray 接口的作用是操作二进制数据。

TypeArray 是一个类数组结构, 也就是说数组可以用的函数, 比如 arr[0], slice, copy 等方法, TypeArray 也可以使用。

值编码

所有的类型化数组都是基于 ArrayBuffer 进行操作的,你可以借此观察到每个元素的确切字节表示,因此二进制格式中的数字编码方式具有重要意义。

  • 无符号整数数组(Uint8Array、Uint16Array、Uint32Array 和 BigUint64Array)直接以二进制形式存储数字。
  • 有符号整数数组(Int8Array、Int16Array、Int32Array 和 BigInt64Array)使用二进制补码存储数字。
  • 浮点数组(Float32Array 和 Float64Array)使用 IEEE 754浮点格式存储数字。Number 参考文档中有关于确切格式的更多信息。JavaScript 数字默认使用双精度浮点格式,这与 Float64Array 相同。Float32Array 将 23(而不是 52)位用于尾数,以及 8(而不是 11)位用于指数。请注意,规范要求所有的 NaN 值使用相同的位编码,但确切的位模式取决于实现。
  • Uint8ClampedArray 是一种特殊情况。它像 Uint8Array 一样以二进制形式存储数字,但是当你存储超出范围的数字时,它会将数字钳制(clamp)到 0 到 255 的范围内,而不是截断最高有效位。

除了 Int8Array、Unit8Array 和 Uint8ClampedArray 以外的其他类型数组都将每个元素存储为多个字节。这些字节可以按照从最高有效位到最低有效位(大端序)或从最低有效位到最高有效位(小端序)的顺序进行排序。请参阅字节序以了解更多。类型化数组始终使用平台的本机字节顺序。如果要在缓冲区中写入和读取时指定字节顺序,应该使用 DataView。

image.png

DataView

DataView 视图是一个可以从二进制 ArrayBuffer 对象中读写多种数值类型的底层接口,使用它时,不用考虑不同平台的字节序(endianness)问题。

DataView 访问器(accessor)提供了对如何访问数据的明确控制,而不管执行代码的计算机的字节序如何。

// dataview.setInt16(byteOffset, value [, littleEndian])
const littleEndian = (() => {
  const buffer = new ArrayBuffer(2);
  new DataView(buffer).setInt16(0, 256, true /* 小端对齐 */);
  // Int16Array 使用平台的字节序。
  return new Int16Array(buffer)[0] === 256;
})();
console.log(littleEndian); // true 或 false

TypedArrray 与 DataView 区别

TypedArray 视图的字节顺序与底层的计算机体系结构有关。在大多数计算机体系结构中,包括 x86 架构的处理器,字节顺序是 Little Endian。因此,当使用 TypedArray 视图时,它们默认采用 Little Endian 字节顺序。

然而,并非所有计算机体系结构都使用 Little Endian 字节顺序。例如,某些 ARM 架构的处理器使用的是 Big Endian 字节顺序。在这些体系结构上,TypedArray 视图会自动适应相应的字节顺序。

因此,需要注意的是,尽管 TypedArray 视图在大多数情况下默认采用与机器相关的字节顺序(通常是 Little Endian),但具体的字节顺序仍取决于底层的计算机体系结构。如果需要确保特定的字节顺序,可以使用 DataView 视图并显式指定字节顺序。

不深入讨论二进制数据的工作原理,让我们看一个简单的例子:

var buffer = new ArrayBuffer(2) // array buffer for two bytes
var bytes = new Uint8Array(buffer) // views the buffer as an array of 8 bit integers

bytes[0] = 65 // ASCII for 'A'
bytes[1] = 66 // ASCII for 'B'

现在我们可以将其转换为 Blob 对象,从中创建一个 Data URI,并将其作为一个新的文本文件打开:

var blob = new Blob([buffer], {type: 'text/plain'})
var dataUri = window.URL.createObjectURL(blob)
window.open(dataUri)

这将在一个新的浏览器窗口中显示文本 'AB'。

你可以看到在前面的例子中,我们先写入了表示 'A' 的字节,然后是表示 'B' 的字节,但我们也可以使用 Uint16Array,将这两个字节一次性写入一个 16 位的数字中:

var buffer = new ArrayBuffer(2) // array buffer for two bytes
var word = new Uint16Array(buffer) // views the buffer as an array with a single 16 bit integer

var value = (65 << 8) + 66 // we shift the 'A' into the upper 8 bit and add the 'B' as the lower 8 bit.
word[0] = value // write the 16 bit (2 bytes) into the typed array

// Let's create a text file from them:
var blob = new Blob([buffer], {type: 'text/plain'})
var dataUri = window.URL.createObjectURL(blob)
window.open(dataUri)

但等等?我们看到的是"BA"而不是之前的"AB"!发生了什么?

让我们仔细看一下我们写入数组的值:

65 decimal = 01 00 00 01 binary
66 decimal = 01 00 00 10 binary

// what we did when we wrote into the Uint8Array:
01 00 00 01 01 00 00 10
<bytes[0]-> <bytes[1]->

// what we did when we created the 16-bit number:
var value = (01 00 00 01 00 00 00 00) + 01 00 00 10
= 01 00 00 01 01 00 00 10

你可以看到我们写入 Uint8Array 和 Uint16Array 的 16 位数值是相同的,那为什么结果会不同呢?

答案是,一个超过一个字节的值的字节顺序取决于系统的字节序(大小端序)。让我们来验证一下:

var buffer = new ArrayBuffer(2)
// create two typed arrays that provide a view on the same ArrayBuffer
var word = new Uint16Array(buffer) // this one uses 16 bit numbers
var bytes = new Uint8Array(buffer) // this one uses 8 bit numbers

var value = (65 << 8) + 66
word[0] = (65 << 8) + 66
console.log(bytes) // will output [66, 65]
console.log(word[0] === value) // will output true

当我们查看各个字节时,我们发现 B 的值确实被写入了缓冲区的第一个字节,而不是 A 的值,但当我们读回这个 16 位数字时,它是正确的!

这是因为浏览器默认使用小端序(Little Endian)的数字。

这是什么意思?

让我们假设一个字节可以保存一个单个数字,因此数字123将占用三个字节:1、2和3。小端序(Little Endian)意味着多字节数值的较低位数字先存储,因此在内存中它将按照3、2、1的顺序存储。

还有一种大端序(Big Endian)格式,其中字节按照我们预期的顺序存储,从最高位数字开始,所以在内存中它将按照1、2、3的顺序存储。

只要计算机知道数据的存储方式,它就可以为我们进行转换,并从内存中得到正确的数字。

让我们看看另一种读写 ArrayBuffer 的方式:DataView,想象一下,您想要编写一个需要一些文件头的二进制文件,如下所示:

image.png

顺便说一下:这是 BMP 文件头的结构。

除了使用各种类型化数组进行操作,我们还可以使用DataView:

var buffer = new ArrayBuffer(14)
var view = new DataView(buffer)

view.setUint8(0, 66)     // Write one byte: 'B'
view.setUint8(1, 67)     // Write one byte: 'M'
view.setUint32(2, 1234)  // Write four byte: 1234 (rest filled with zeroes)
view.setUint16(6, 0)     // Write two bytes: reserved 1
view.setUint16(8, 0)     // Write two bytes: reserved 2
view.setUint32(10, 0)    // Write four bytes: offset

我们的ArrayBuffer现在包含以下数据:

Byte  |    0   |    1   |    2   |    3   |    4   |    5   | ... |
Type  |   I8   |   I8   |                I32                | ... |    
Data  |    B   |    M   |00000000|00000000|00000100|11010010| ... |

在上面的示例中,我们使用DataView将两个Uint8写入前两个字节,然后是占用接下来四个字节的Uint32,依此类推。

很酷。现在让我们回到我们的简单文本例子。

我们也可以使用DataView而不是之前使用的Uint16Array来写入一个Uint16,以保存我们的两个字符字符串'AB':

var buffer = new ArrayBuffer(2) // array buffer for two bytes
var view = new DataView(buffer)

var value = (65 << 8) + 66 // we shift the 'A' into the upper 8 bit and add the 'B' as the lower 8 bit.
view.setUint16(0, value)

// Let's create a text file from them:
var blob = new Blob([buffer], {type: 'text/plain'})
var dataUri = window.URL.createObjectURL(blob)
window.open(dataUri)

等一下,什么?我们得到期望的字符串'AB',而不是上次写入Uint16时得到的'BA'!也许setUint16默认为大端序(Big Endian)?

根据DataView的规范,setUint16方法的定义如下:

DataView.prototype.setUint16 ( byteOffset, value [ , littleEndian ] )

根据规范,如果没有明确指定littleEndian参数,则默认为false,即使用大端序(Big Endian)。因此,使用DataView的setUint16方法写入值时,默认情况下采用的是大端序。这就解释了为什么使用DataView写入Uint16时,我们得到了正确的字符串'AB',而不是'BA'。

Blob

Blob 全称为 binary large object ,即二进制大对象,它是 JavaScript 中的一个对象,表示原始的类似文件的数据。下面是 MDN 中对 Blob 的解释:

Blob 对象表示一个不可变、原始数据的类文件对象。它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream 来用于数据操作。

实际上,Blob 对象是包含有只读原始数据的类文件对象。简单来说,Blob 对象就是一个不可修改的二进制文件。

可以使用 Blob() 构造函数来创建一个 Blob:

new Blob(array, options);

其有两个参数:

  • array:由 ArrayBuffer、ArrayBufferView、Blob、DOMString 等对象构成的,将会被放进 Blob;
  • options:可选的 BlobPropertyBag 字典,它可能会指定如下两个属性

    • type:默认值为 "",表示将会被放入到 blob 中的数组内容的 MIME 类型。
    • endings:默认值为"transparent",用于指定包含行结束符\n的字符串如何被写入,不常用。

Blob 有哪些使用场景?

图片本地预览

这里整理 2 种图片本地预览的方式:

  • 使用 DataURL 方式;
  • 使用 Blob URL/Object URL 方式;
<body>
    <h1>1.DataURL方式:</h1>
    <input type="file" accept="image/*" onchange="selectFileForDataURL(event)">
    <img id="output1">

    <h1>2.Blob方式:</h1>
    <input type="file" accept="image/*" onchange="selectFileForBlob(event)">
    <img id="output2">

    <script>
        // 1.DataURL方式:
        async function selectFileForDataURL() {
            const reader = new FileReader();
            reader.onload = function () {
                const output = document.querySelector("#output1")
                output.src = reader.result;
            }
            reader.readAsDataURL(event.target.files[0]);
        }

        //2.Blob方式:
        async function selectFileForBlob(){
            const reader = new FileReader();
            const output = document.querySelector("#output2");
            const imgUrl = window.URL.createObjectURL(event.target.files[0]);
            output.src = imgUrl;
            reader.onload = function(event){
                window.URL.revokeObjectURL(imgUrl);
            }
        }
    </script>
</body>

分片上传

File对象继承了Blob对象的所有属性和方法,可以使用File对象的slice()方法进行文件切片操作,将大文件切割成较小的分片,并逐个上传这些分片。

// 分片上传对象
var ChunkUploader = function(file) {
  this.file = file;
  this.chunkSize = 1024 * 1024; // 每个分片的大小,这里设置为1MB
  this.totalChunks = Math.ceil(file.size / this.chunkSize);
  this.currentChunk = 0;
  this.uploadedChunks = [];
  this.isPaused = false;
};

// 上传下一个分片
ChunkUploader.prototype.uploadNextChunk = function() {
  if (this.currentChunk >= this.totalChunks) {
    console.log("文件上传完成");
    return;
  }
  
  if (this.isPaused) {
    console.log("上传已暂停");
    return;
  }
  
  var start = this.currentChunk * this.chunkSize;
  var end = Math.min(start + this.chunkSize, this.file.size);
  var chunk = this.file.slice(start, end);
  
  var formData = new FormData();
  formData.append("file", chunk);
  formData.append("chunkIndex", this.currentChunk);
  
  // 发起上传请求,这里使用XMLHttpRequest示例
  var xhr = new XMLHttpRequest();
  xhr.open("POST", "/upload", true);
  xhr.onload = () => {
    if (xhr.status >= 200 && xhr.status < 300) {
      // 上传成功,记录已上传分片信息
      this.uploadedChunks.push(this.currentChunk);
      
      // 继续上传下一个分片
      this.currentChunk++;
      this.uploadNextChunk();
    } else {
      // 上传失败,处理错误
      console.error("上传失败:", xhr.status, xhr.statusText);
    }
  };
  xhr.onerror = () => {
    console.error("上传出错");
  };
  xhr.send(formData);
};

// 暂停上传
ChunkUploader.prototype.pauseUpload = function() {
  this.isPaused = true;
};

// 继续上传
ChunkUploader.prototype.resumeUpload = function() {
  this.isPaused = false;
  this.uploadNextChunk();
};

// 创建文件上传实例
var fileInput = document.getElementById("fileInput");
var chunkUploader = new ChunkUploader(fileInput.files[0]);

// 启动上传
chunkUploader.uploadNextChunk();

// 暂停上传
chunkUploader.pauseUpload();

// 继续上传
chunkUploader.resumeUpload();

下载数据

要通过HTTP请求获取文件并下载,您可以使用XMLHttpRequest或Fetch API来执行请求,并使用Blob对象进行文件下载。以下是一个示例代码,演示如何请求并下载文件:

function downloadFile(url, fileName) {
  var xhr = new XMLHttpRequest();
  xhr.open("GET", url, true);
  xhr.responseType = "blob";

  xhr.onload = function () {
    if (xhr.status === 200) {
      var blob = xhr.response;
      var a = document.createElement("a");
      a.href = URL.createObjectURL(blob);
      a.download = fileName;

      var event = document.createEvent("MouseEvents");
      event.initEvent("click", true, false);
      a.dispatchEvent(event);
    }
  };

  xhr.send();
}

function downloadFile(url, fileName) {
  fetch(url)
    .then(function (response) {
      if (response.ok) {
        return response.blob();
      } else {
        throw new Error("File download failed");
      }
    })
    .then(function (blob) {
      var a = document.createElement("a");
      a.href = URL.createObjectURL(blob);
      a.download = fileName;

      var event = document.createEvent("MouseEvents");
      event.initEvent("click", true, false);
      a.dispatchEvent(event);
    })
    .catch(function (error) {
      console.error("File download error:", error);
    });
}

图片压缩

当我们希望本地图片在上传之前,先进行一定压缩,再提交,从而减少传输的数据量。
在前端我们可以使用 Canvas 提供的 toDataURL() 方法来实现,该方法接收 type 和 encoderOptions 两个可选参数:

  • type 表示「图片格式」,默认为 image/png ;
  • encoderOptions 表示「图片质量」,在指定图片格式为 image/jpeg 或 image/webp 的情况下,可以从 0 到 1 区间内选择图片质量。如果超出取值范围,将会使用默认值 0.92,其他参数会被忽略。
<body>
    <input type="file" accept="image/*" onchange="loadFile(event)" />
    <script>
      // 将base64转化为File对象
      const base64ToFile = (base64String, fileName, fileType) => {
        const byteCharacters = atob(base64String.split(",")[1]);
        const byteArrays = [];
        for (let i = 0; i < byteCharacters.length; i++) {
          byteArrays.push(byteCharacters.charCodeAt(i));
        }
        const byteArray = new Uint8Array(byteArrays);
        return new File([byteArray], fileName, { type: fileType });
      };

      const compress = (file, maxWidth, maxHeight, quality) => {
        return new Promise((resolve, reject) => {
          const image = new Image();
          const canvas = document.createElement("canvas");
          const ctx = canvas.getContext("2d");

          image.onload = () => {
            let width = image.width;
            let height = image.height;

            // 计算压缩后的尺寸
            if (maxWidth && width > maxWidth) {
              height *= maxWidth / width;
              width = maxWidth;
            }

            if (maxHeight && height > maxHeight) {
              width *= maxHeight / height;
              height = maxHeight;
            }

            // 设置 Canvas 的尺寸
            canvas.width = width;
            canvas.height = height;

            // 在 Canvas 上绘制压缩后的图片
            ctx.drawImage(image, 0, 0, width, height);

            // 转换为压缩后的图片数据
            const compressedDataUrl = canvas.toDataURL("image/jpeg", quality);
            // 将base64转化为File对象
            const compressedFile = base64ToFile(
              compressedDataUrl,
              encodeURIComponent(file.name),
              file.type
            );

            resolve(compressedFile);
          };

          // 加载图片
          image.src = URL.createObjectURL(file);
        });
      };

      // 通过 AJAX 提交到服务器
      const uploadFile = (url, file) => {
        let formData = new FormData();
        let request = new XMLHttpRequest();
        formData.append("image", file);
        request.open("POST", url, true);
        request.send(formData);
      }

      const loadFile = (event) => {
        const file = event.target.files[0];
        const compressedFile = compress(file);
        uploadFile("https://httpbin.org/post", compressedFile);
      };
    </script>
</body>

其实 Canvas 对象除了提供 toDataURL() 方法之外,它还提供了一个 toBlob() 方法,该方法的语法如下:

canvas.toBlob(callback, mimeType, qualityArgument)

和 toDataURL() 方法相比,toBlob() 方法是异步的,因此多了个 callback 参数,这个 callback 回调方法默认的第一个参数就是转换好的 blob文件信息。

ArrayBuffer 与 Blob

看定义的话,先翻翻 MDN:

ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区。
Blob 对象表示一个不可变、原始数据的类文件对象。它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream 来用于数据操作。

从定义可以知晓,两者都是对二进制数据进行操作。MDN描述的还比较模糊,《现代 JavaScript 教程》中写的比较清楚:“基本的二进制对象是 ArrayBuffer —— 对固定长度的连续内存空间的引用”。Blob支持的类型更加复合,既然也是操作二进制数据所以核心也是基于ArrayBuffer,但更主要是对文件进行操作。所以两者大部分情况下能够替换使用也就很容易理解了。但还不够解释以上其他问题。

继续查找资料,翻到Chrome设计文档Chrome's Blob Storage System Design中有对Blob详细描述。

之前的疑问在这里就有答案了:

If the in-memory space for blobs is getting full, or a new blob is too large to be in-memory, then the blob system uses the disk. This can either be paging old blobs to disk, or saving the new too-large blob straight to disk.

大意是说,当blob的内存空间占满时,或者新创建的blob太大,剩余的内存空间放不下了,blob会转存到磁盘中。可以是转存旧的blob数据,也可以是将新的blob直接存储到磁盘。

同时还提到了,在使用blob时应该避免快速创建非常多的blob,特别是数据量非常大的,这会导致浏览器要将blob写入到磁盘后才能渲染器才能继续处理后续数据。这样也就解释了为什么之前blob有出现卡顿的情况。

总结一下差异:

  • ArrayBuffer:仅对内存操作,是最基础的二进制对象。所有的数据都放在内存中,当有大量的ArrayBuffer时等于数据全在内存中,就容易导致浏览器标签页因内存超过限制而崩溃。
  • Blob:blob的数据存储比较复合,所引用的数据不仅仅在内存中,也可能存在磁盘上。当数据超过一定量时会将数据从内存转存到磁盘中。这也符合blob的名称二进制大数据对象(Binary Large Object),对大文件对象有做专门的优化。

综合看来,如果 Axios 处理文件数据,还是配置 blob 比较适合。

另一个问题,Axios 中为什么说 blob 仅浏览器可用?这个比较容易找到答案,贺师俊在知乎有个回答:

注意,Blob并不像ArrayBuffer是JS语言内置的,而是Web API,Node.js的API里就没有Blob。这也是为什么MDN说「Blobs can represent data that isn't necessarily in a JavaScript-native format」(中文版的翻译「Blob表示的不一定是JavaScript原生格式的数据」反而比英文原文难理解)。

不看这说明是真不理解 MDN 的那段描述,在《现代 JavaScript 教程》中其实也有提到,但只在 Blob 章节开头提了 ArrayBuffer 是 ECMA 标准的一部分,没提说 Blob 是不是,看着也是会觉得有些奇怪。

不过这个回答是2020年的,当时Node还不支持Blob,到Node18版本发布已经正式支持Blob类型了,详细的可以看Node官方文档class-blob中History表,所以现在Node中也是支持Blob了。

Stream

最后是 Stream,先说说 Stream 模式与 Arraybuffer(Node 中对应的是 Buffer)模式应用的差异。

在大文件读取的场景下,使用 Arraybuffer 会将所有数据全部写入内存后再处理,文件很大时很可能导致内存爆了。如果使用 Stream 数据依然是存入内存,但存入的数据会立即就开始处理,不必等到所有数据加载完再开始,这样只需要消耗极小的内存就能完成对文件的处理。

Node 中是有 Stream 模式相关的 API,那浏览器呢?也是有的,Chrome 从59版本开始其实是有 Stream API 的,网络请求需要配合 fetch 使用。

翻阅代码,可以发现Axios浏览器请求还是基于 XMLHttpRequest 的,axios/lib/adapters/xhr.js 源码中 responseType 数据没有处理直接传入 XMLHttpRequest 对象的。

那么 XMLHttpRequest 的 responseType 是否支持设置为 stream?来看看 WHATWG 对 XMLHttpRequest 支持的类型描述:

enum XMLHttpRequestResponseType {
  "",
  "arraybuffer",
  "blob",
  "document",
  "json",
  "text"
};

可知,XMLHttpRequest 是不支持的。咦?很奇怪,axios 文档怎么写的是支持?

翻了一圈 issue,发现有提到 axios 准备增加一个新的 adapter(使用的是 fetch)来支持 stream。回头又找了一圈代码,没发现有新增的模块。继续翻翻 issue 和 discussions,之前的相关信息都已经关闭了,但在Axios next的关联中有一个相关 issue 还是打开的,相关PR也还未合并,查看代码版本目前处于 beta5,也有半年没更新了。

也难怪主版本中没看到过相关代码,目前看来相关改动还没有确定下来。实际测试中 stream 也没支持成功,流数据返回的话会解析成字符串。如果非常想在 axios 中接收 stream 数据,可以尝试使用还在测试中的模块,将 adapter 配置更换一下。

总之目前为止,如果想使用 stream 传输数据还是转向用 fetch 吧。

以下是流式获取文本的示例:

const resp = await fetch(url);
const reader = resp.body.getReader();
const textDecoder = new TextDecoder();

while(1) {
  const { value, done } = await reader.read();
  if (done) {
    break;
  }
  console.log(textDecoder.decode(value));
}

Base64

Base64(radix-64)是一种基于64个可打印字符来表示二进制数据的表示方法。由于{\displaystyle \log _{2}64=6},所以每6个比特为一个单元,对应某个可打印字符。3个字节相当于24个比特,对应于4个 Base64 单元,即3个字节可由4个可打印字符来表示。这 64 个字符包括大小写字母(A-Z, a-z)、数字(0-9)以及两个特殊字符(+ 和 /)。

这意味着 Base64 格式的字符串或文件的尺寸约是原始尺寸的 133%(增加了大约 33%)。如果编码的数据很少,增加的比例可能会更高。例如:长度为 1 的字符串 "a" 进行 Base64 编码后是 "YQ==",长度为 4,尺寸增加了 3 倍。

Base64 常用于在通常处理文本数据的场合,表示、传输、存储一些二进制数据,包括 MIME 的电子邮件及 XML 的一些复杂数据。

Base64 编码在网络上的一个常见应用是对二进制数据进行编码,以便将其纳入 data: URL 中。

image.png

在 Base64 编码中,每三个字节的二进制数据被编码为四个字符,如果最后剩下的字节不足三个,则会进行填充。

具体的填充规则如下:

  • 如果最后一个分组有一个字节,编码结果为两个字符,然后在末尾添加两个 "="。
  • 如果最后一个分组有两个字节,编码结果为三个字符,然后在末尾添加一个 "="。
  • 如果最后一个分组有三个字节,编码结果为四个字符,不需要进行填充。

以下是一个示例,将一个字符串 "Hello, World!" 进行 Base64 编码:

将字符串转换为对应的二进制数据。例如,使用 UTF-8 编码将 "Hello, World!" 转换为字节数组:[72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33]。

将每三个字节的数据分组,并将其转换为对应的 Base64 字符。对于每个分组,将其转换为四个字符。

第一个分组:72, 101, 108 → 01001000, 01100101, 01101100 → 010010, 000110, 010101, 101100 → S, G, V, s
第二个分组:108, 111, 44 → 01101100, 01101111, 00101100 → 011011, 000110, 111100, 101100 → b, G, 9, s
第三个分组:32, 87, 111 → 00100000, 01010111, 01101111 → 001000, 000010, 101111, 101100 → I, F, v, s
第四个分组:114, 108, 100 → 01110010, 01101100, 01100100 → 011100, 100110, 110100 → c, m, Q
第五个分组:33 → 00100001 → 001000 010000 → I, Q 最后一个分组只有一个字节,编码结果为两个字符,然后在末尾添加两个 "="。

将每个分组得到的字符连接起来,得到最终的 Base64 编码字符串:SGVsbG8sIFdvcmxkIQ==

atob 与 btoa

在 JavaScript 中,有两个函数被分别用来处理解码和编码 Base64 字符串:

  • btoa():从二进制数据“字符串”创建一个 Base-64 编码的 ASCII 字符串(“btoa”应读作“binary to ASCII”)
  • atob():解码通过 Base-64 编码的字符串数据(“atob”应读作“ASCII to binary”)
    atob() 和 btoa() 使用的算法在 RFC 4648 第四节中给出。

“Unicode 问题”

由于 JavaScript 字符串是 16 位编码的字符串,在大多数浏览器中,在 Unicode 字符串上调用 window.btoa,如果一个字符超过了 8 位 ASCII 编码字符的范围,就会引起 Character Out Of Range 异常。

image.png

有两种可能的方法来解决这个问题:

  • 第一种是先对整个字符串转义,然后进行编码;
  • 第二种是将 UTF-16 字符串转换为 UTF-8 字符数组,然后进行编码。

方案 1——先转义字符串

encodeURIComponent 会将字符转换为 UTF-8 编码的字节序列

function utf8_to_b64(str) {
  return window.btoa(unescape(encodeURIComponent(str)));
}

function b64_to_utf8(str) {
  return decodeURIComponent(escape(window.atob(str)));
}

// Usage:
utf8_to_b64("✓ à la mode"); // "4pyTIMOgIGxhIG1vZGU="
b64_to_utf8("4pyTIMOgIGxhIG1vZGU="); // "✓ à la mode"

该方案由 Johan Sundström 提出。

另一个可能的解决方案是不利用现在已经废弃的 'unescape' 和 'escape' 函数。不过这个方案并没有对输入的字符串进行 base64 编码。注意,utf8_to_b64 和 b64EncodeUnicode 的输出结果的不同。采用这种方式可能会导致与其他应用程序的互操作性问题。

function b64EncodeUnicode(str) {
  return btoa(encodeURIComponent(str));
}

function UnicodeDecodeB64(str) {
  return decodeURIComponent(atob(str));
}

b64EncodeUnicode("✓ à la mode"); // "JUUyJTlDJTkzJTIwJUMzJUEwJTIwbGElMjBtb2Rl"
UnicodeDecodeB64("JUUyJTlDJTkzJTIwJUMzJUEwJTIwbGElMjBtb2Rl"); // "✓ à la mode"

方案 2——使用 TypedArray 和 UTF-8 重写 atob() 和 btoa() 方法

"use strict";
// Array of bytes to Base64 string decoding
function b64ToUint6(nChr) {
  return nChr > 64 && nChr < 91
    ? nChr - 65
    : nChr > 96 && nChr < 123
      ? nChr - 71
      : nChr > 47 && nChr < 58
        ? nChr + 4
        : nChr === 43
          ? 62
          : nChr === 47
            ? 63
            : 0;
}

function base64DecToArr(sBase64, nBlocksSize) {
  const sB64Enc = sBase64.replace(/[^A-Za-z0-9+/]/g, ""); // Remove any non-base64 characters, such as trailing "=", whitespace, and more.
  const nInLen = sB64Enc.length;
  const nOutLen = nBlocksSize
    ? Math.ceil(((nInLen * 3 + 1) >> 2) / nBlocksSize) * nBlocksSize
    : (nInLen * 3 + 1) >> 2;
  const taBytes = new Uint8Array(nOutLen);

  let nMod3;
  let nMod4;
  let nUint24 = 0;
  let nOutIdx = 0;
  for (let nInIdx = 0; nInIdx < nInLen; nInIdx++) {
    nMod4 = nInIdx & 3;
    nUint24 |= b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << (6 * (3 - nMod4));
    if (nMod4 === 3 || nInLen - nInIdx === 1) {
      nMod3 = 0;
      while (nMod3 < 3 && nOutIdx < nOutLen) {
        taBytes[nOutIdx] = (nUint24 >>> ((16 >>> nMod3) & 24)) & 255;
        nMod3++;
        nOutIdx++;
      }
      nUint24 = 0;
    }
  }

  return taBytes;
}

/* Base64 string to array encoding */
function uint6ToB64(nUint6) {
  return nUint6 < 26
    ? nUint6 + 65
    : nUint6 < 52
      ? nUint6 + 71
      : nUint6 < 62
        ? nUint6 - 4
        : nUint6 === 62
          ? 43
          : nUint6 === 63
            ? 47
            : 65;
}

function base64EncArr(aBytes) {
  let nMod3 = 2;
  let sB64Enc = "";

  const nLen = aBytes.length;
  let nUint24 = 0;
  for (let nIdx = 0; nIdx < nLen; nIdx++) {
    nMod3 = nIdx % 3;
    // To break your base64 into several 80-character lines, add:
    //   if (nIdx > 0 && ((nIdx * 4) / 3) % 76 === 0) {
    //      sB64Enc += "\r\n";
    //    }

    nUint24 |= aBytes[nIdx] << ((16 >>> nMod3) & 24);
    if (nMod3 === 2 || aBytes.length - nIdx === 1) {
      sB64Enc += String.fromCodePoint(
        uint6ToB64((nUint24 >>> 18) & 63),
        uint6ToB64((nUint24 >>> 12) & 63),
        uint6ToB64((nUint24 >>> 6) & 63),
        uint6ToB64(nUint24 & 63),
      );
      nUint24 = 0;
    }
  }
  return (
    sB64Enc.substring(0, sB64Enc.length - 2 + nMod3) +
    (nMod3 === 2 ? "" : nMod3 === 1 ? "=" : "==")
  );
}

/* UTF-8 array to JS string and vice versa */

function UTF8ArrToStr(aBytes) {
  let sView = "";
  let nPart;
  const nLen = aBytes.length;
  for (let nIdx = 0; nIdx < nLen; nIdx++) {
    nPart = aBytes[nIdx];
    sView += String.fromCodePoint(
      nPart > 251 && nPart < 254 && nIdx + 5 < nLen /* six bytes */
        ? /* (nPart - 252 << 30) may be not so safe in ECMAScript! So…: */
          (nPart - 252) * 1073741824 +
            ((aBytes[++nIdx] - 128) << 24) +
            ((aBytes[++nIdx] - 128) << 18) +
            ((aBytes[++nIdx] - 128) << 12) +
            ((aBytes[++nIdx] - 128) << 6) +
            aBytes[++nIdx] -
            128
        : nPart > 247 && nPart < 252 && nIdx + 4 < nLen /* five bytes */
          ? ((nPart - 248) << 24) +
            ((aBytes[++nIdx] - 128) << 18) +
            ((aBytes[++nIdx] - 128) << 12) +
            ((aBytes[++nIdx] - 128) << 6) +
            aBytes[++nIdx] -
            128
          : nPart > 239 && nPart < 248 && nIdx + 3 < nLen /* four bytes */
            ? ((nPart - 240) << 18) +
              ((aBytes[++nIdx] - 128) << 12) +
              ((aBytes[++nIdx] - 128) << 6) +
              aBytes[++nIdx] -
              128
            : nPart > 223 && nPart < 240 && nIdx + 2 < nLen /* three bytes */
              ? ((nPart - 224) << 12) +
                ((aBytes[++nIdx] - 128) << 6) +
                aBytes[++nIdx] -
                128
              : nPart > 191 && nPart < 224 && nIdx + 1 < nLen /* two bytes */
                ? ((nPart - 192) << 6) + aBytes[++nIdx] - 128
                : /* nPart < 127 ? */ /* one byte */
                  nPart,
    );
  }
  return sView;
}

function strToUTF8Arr(sDOMStr) {
  let aBytes;
  let nChr;
  const nStrLen = sDOMStr.length;
  let nArrLen = 0;

  /* mapping… */
  for (let nMapIdx = 0; nMapIdx < nStrLen; nMapIdx++) {
    nChr = sDOMStr.codePointAt(nMapIdx);

    if (nChr >= 0x10000) {
      nMapIdx++;
    }

    nArrLen +=
      nChr < 0x80
        ? 1
        : nChr < 0x800
          ? 2
          : nChr < 0x10000
            ? 3
            : nChr < 0x200000
              ? 4
              : nChr < 0x4000000
                ? 5
                : 6;
  }

  aBytes = new Uint8Array(nArrLen);

  /* transcription… */
  let nIdx = 0;
  let nChrIdx = 0;
  while (nIdx < nArrLen) {
    nChr = sDOMStr.codePointAt(nChrIdx);
    if (nChr < 128) {
      /* one byte */
      aBytes[nIdx++] = nChr;
    } else if (nChr < 0x800) {
      /* two bytes */
      aBytes[nIdx++] = 192 + (nChr >>> 6);
      aBytes[nIdx++] = 128 + (nChr & 63);
    } else if (nChr < 0x10000) {
      /* three bytes */
      aBytes[nIdx++] = 224 + (nChr >>> 12);
      aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63);
      aBytes[nIdx++] = 128 + (nChr & 63);
    } else if (nChr < 0x200000) {
      /* four bytes */
      aBytes[nIdx++] = 240 + (nChr >>> 18);
      aBytes[nIdx++] = 128 + ((nChr >>> 12) & 63);
      aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63);
      aBytes[nIdx++] = 128 + (nChr & 63);
      nChrIdx++;
    } else if (nChr < 0x4000000) {
      /* five bytes */
      aBytes[nIdx++] = 248 + (nChr >>> 24);
      aBytes[nIdx++] = 128 + ((nChr >>> 18) & 63);
      aBytes[nIdx++] = 128 + ((nChr >>> 12) & 63);
      aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63);
      aBytes[nIdx++] = 128 + (nChr & 63);
      nChrIdx++;
    } /* if (nChr <= 0x7fffffff) */ else {
      /* six bytes */
      aBytes[nIdx++] = 252 + (nChr >>> 30);
      aBytes[nIdx++] = 128 + ((nChr >>> 24) & 63);
      aBytes[nIdx++] = 128 + ((nChr >>> 18) & 63);
      aBytes[nIdx++] = 128 + ((nChr >>> 12) & 63);
      aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63);
      aBytes[nIdx++] = 128 + (nChr & 63);
      nChrIdx++;
    }
    nChrIdx++;
  }

  return aBytes;
}

测试

/* Tests */

const sMyInput = "Base 64 \u2014 Mozilla Developer Network";

const aMyUTF8Input = strToUTF8Arr(sMyInput);

const sMyBase64 = base64EncArr(aMyUTF8Input);

alert(sMyBase64);

const aMyUTF8Output = base64DecToArr(sMyBase64);

const sMyOutput = UTF8ArrToStr(aMyUTF8Output);

alert(sMyOutput);

Blob URL 和 Data URL

Blob URL 和 Data URL 是两种不同的 URL 方案,用于在浏览器中表示和使用数据。

Blob URL(或称为 Object URL)是一种特殊的 URL 格式,用于表示 Blob 对象的地址。它通过使用 URL.createObjectURL() 方法生成,该方法接受一个 Blob 或 File 对象作为参数,并返回一个唯一的 URL,该 URL 可以用于引用该 Blob 对象。Blob URL 的格式通常是以 "blob:" 开头,后面跟随一个唯一的标识符。

Data URL 是一种用于嵌入数据的 URL 格式,可以直接将数据嵌入到 URL 中。它的格式如下:

data:[<mediatype>][;base64],<data>

其中 <mediatype> 是数据的 MIME 类型,例如 text/plainimage/jpeg 等;;base64 是可选的,表示数据是否使用 Base64 编码;<data> 是实际的数据内容。

Blob URL 和 Data URL 的区别主要在于数据的来源和用途:

  • Blob URL 用于表示 Blob 对象的地址,通常用于在浏览器中处理和操作二进制数据,如文件下载、视频播放、图像显示等。它适用于大型数据或二进制数据,因为它仅提供了 Blob 对象的引用,而不需要将整个数据嵌入到 URL 中。
  • Data URL 则直接将数据嵌入到 URL 中,适用于小型数据或文本数据,如图像的 Base64 编码表示、内联脚本或样式表等。它可以简化资源的引用和传输,但对于较大的数据会增加 URL 的长度,可能导致性能下降。

在选择使用 Blob URL 还是 Data URL 时,需要根据具体的使用场景和数据大小来进行权衡。如果涉及到大型或二进制数据,Blob URL 通常更合适;而对于小型或文本数据,Data URL 可能更方便。

encodeURIComponent

encodeURIComponent() 函数通过将特定字符的每个实例替换成代表字符的 UTF-8 编码的一个、两个、三个或四个转义序列来编码 URI(只有由两个“代理”字符组成的字符会被编码为四个转义序列)。与 encodeURI() 相比,此函数会编码更多的字符,包括 URI 语法的一部分。

以下是 encodeURIComponent 的编码过程:

  • 将要编码的字符串按字符进行遍历。
  • 对于每个字符,判断是否属于以下字符集之一:

    • 字母(A-Z,a-z)
    • 数字(0-9)
    • 特殊字符(-,_,.,!,~,*,',(,))

    如果字符属于上述字符集之一,则保持不变。

  • 对于不属于上述字符集的字符:

    • 将字符转换为 UTF-8 编码的字节序列。
    • 将每个字节转换为两位十六进制数。
    • 在每个十六进制数前添加 "%"。
    • 将得到的编码后的字符串连接起来。
  • 返回编码后的字符串作为结果。

以下是一个示例,将一个字符串 "шеллы" 进行编码:

console.log(`?x=${encodeURIComponent('шеллы')}`);
// Expected output: "?x=%D1%88%D0%B5%D0%BB%D0%BB%D1%8B"

以"ш"字符为例,看其是如何被编码的:

// 获取字符"ш"的码点
"ш".charCodeAt() // 1096

// 转成十六进制
Number(1096).toString(16) // '448'

// 对照以下 Unicode 十六进制转化 UTF-8 编码方式表,448 介于 U+0080 到 U+07FF 之间

Unicode符号范围                                  UTF-8编码方式
(十六进制)                                      (二进制)
0000 0000-0000 007F(U+0000 到 U+007F)            0xxxxxxx
0000 0080-0000 07FF(U+0080 到 U+07FF)            110xxxxx 10xxxxxx
0000 0800-0000 FFFF(U+0800 到 U+FFFF)            1110xxxx 10xxxxxx 10xxxxxx
0001 0000-0010 FFFF(U+10000 到 U+10FFFF)         11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

// 将其转化成二进制,然后从 "ш" 的最后一个二进制位开始,依次从后向前填入格式中的x,多出的位补0

Number(1096).toString(2) // '10001001000'

// 填充后得到 UTF-8 编码方式
11010001 10001000

// 然后,转成十六进制,每个十六进制数对应四位二进制数
1101(D) 0001(1) 1000(8) 1000(8) -> %D1%88

参考

Base64-MDN
Base64-维基百科
JavaScript中"ArrayBuffer"对象与"Blob"对象到底有什么区别?
谈谈JS二进制:File、Blob、FileReader、ArrayBuffer、Base64
axios中responseType配置blob、arraybuffer、stream值有什么差异


记得要微笑
1.9k 声望4.5k 粉丝

知不足而奋进,望远山而前行,卯足劲,不减热爱。


引用和评论

0 条评论