查看原文
其他

【第2652期】实现一个大文件切片上传+断点续传

大转转FE 前端早读课 2022-07-31

前言

通过案例分享。今日前端早读课文章由转转 @郭洋分享,公号:大转转 FE 授权。

正文从这开始~~

PM:喂,那个切图仔,我这里有个 100G 的视频要上传,你帮我做一个上传后台,下班前给我哦,辛苦了。

我:。。。

相信每个切图工程师,都接触过文件上传的需求,一般的小文件,我们直接使用 input file,然后构造一个 new FormData () 对象,扔给后端就可以了。如果使用了 Ant design 或者 element ui 之类的 ui 库,那更简单,直接调用一下 api 即可。当然了,复杂一些的,市面上也有不少优秀的第三方插件,比如 WebUploader。但是作为一个有追求的工程师,怎么能仅仅满足于使用插件呢,今天我们就来自己实现一个。

首先我们来分析一下需求

一个上传组件,需要具备的功能
  • 需要校验文件格式

  • 可以上传任何文件,包括超大的视频文件(切片)

  • 上传期间断网后,再次联网可以继续上传(断点续传)

  • 要有进度条提示

  • 已经上传过同一个文件后,直接上传完成(秒传)

前后端分工
前端
  • 文件格式校验

  • 文件切片、md5 计算

  • 发起检查请求,把当前文件的 hash 发送给服务端,检查是否有相同 hash 的文件

  • 上传进度计算

  • 上传完成后通知后端合并切片

后端
  • 检查接收到的 hash 是否有相同的文件,并通知前端当前 hash 是否有未完成的上传

  • 接收切片

  • 合并所有切片

架构图如下

接下来开始具体实现

一、 格式校验

对于上传的文件,一般来说,我们要校验其格式,仅需要获取文件的后缀(扩展名),即可判断其是否符合我们的上传限制:

//文件路径
var filePath = "file://upload/test.png";
//获取最后一个.的位置
var index= filePath.lastIndexOf(".");
//获取后缀
var ext = filePath.substr(index+1);
//输出结果
console.log(ext);
// 输出:png

但是,这种方式有个弊端,那就是我们可以随便篡改文件的后缀名,比如:test.mp4 ,我们可以通过修改其后缀名:test.mp4 -> test.png ,这样即可绕过限制进行上传。那有没有更严格的限制方式呢?当然是有的。

那就是通过查看文件的二进制数据来识别其真实的文件类型,因为计算机识别文件类型时,并不是真的通过文件的后缀名来识别的,而是通过 “魔数”(Magic Number)来区分,对于某一些类型的文件,起始的几个字节内容都是固定的,根据这几个字节的内容就可以判断文件的类型。借助十六进制编辑器,可以查看一下图片的二进制数据,我们还是以 test.png 为例:

由上图可知,PNG 类型的图片前 8 个字节是 0x89 50 4E 47 0D 0A 1A 0A。基于这个结果,我们可以据此来做文件的格式校验,以 vue 项目为例:

<template>
<div>
<input
type="file"
id="inputFile"
@change="handleChange"
/>
</div>
</template>

<script>
export default {
name: "HelloWorld",
methods: {
check(headers) {
return (buffers, options = { offset: 0 }) =>
headers.every(
(header, index) => header === buffers[options.offset + index]
);
},
async handleChange(event) {
const file = event.target.files[0];
// 以PNG为例,只需要获取前8个字节,即可识别其类型
const buffers = await this.readBuffer(file, 0, 8);
const uint8Array = new Uint8Array(buffers);
const isPNG = this.check([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
// 上传test.png后,打印结果为true
console.log(isPNG(uint8Array))
},
readBuffer(file, start = 0, end = 2) {
// 获取文件的二进制数据,因为我们只需要校验前几个字节即可,所以并不需要获取整个文件的数据
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = reject;
reader.readAsArrayBuffer(file.slice(start, end));
});
}
}
};
</script>

以上为校验文件类型的方法,对于其他类型的文件,比如 mp4,xsl 等,大家感兴趣的话,也可以通过工具查看其二进制数据,以此来做格式校验。

以下为汇总的一些文件的二进制标识:

  • JPEG/JPG - 文件头标识 (2 bytes): ff, d8 文件结束标识 (2 bytes): ff, d9

  • TGA - 未压缩的前 5 字节 00 00 02 00 00 - RLE 压缩的前 5 字节 00 00 10 00 00

  • PNG - 文件头标识 (8 bytes) 89 50 4E 47 0D 0A 1A 0A

  • GIF - 文件头标识 (6 bytes) 47 49 46 38 39 (37) 61

  • BMP - 文件头标识 (2 bytes) 42 4D B M

  • PCX - 文件头标识 (1 bytes) 0A

  • TIFF - 文件头标识 (2 bytes) 4D 4D 或 49 49

  • ICO - 文件头标识 (8 bytes) 00 00 01 00 01 00 20 20

  • CUR - 文件头标识 (8 bytes) 00 00 02 00 01 00 20 20

  • IFF - 文件头标识 (4 bytes) 46 4F 52 4D

  • ANI - 文件头标识 (4 bytes) 52 49 46 46

二、 文件切片

假设我们要把一个 1G 的视频,分割为每块 1MB 的切片,可定义 DefualtChunkSize = 1 * 1024 * 1024,通过 spark-md5 来计算文件内容的 hash 值。那如何分割文件呢,使用文件对象 File 的方法 File.prototype.slice 即可。

需要注意的是,切割一个较大的文件,比如 10G,那分割为 1Mb 大小的话,将会生成一万个切片,众所周知,js 是单线程模型,如果这个计算过程在主线程中的话,那我们的页面必然会直接崩溃,这时,就该我们的 Web Worker 来上场了。

Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。具体的作用,不了解的同学可以自行去学些一下。这里就不展开讲了。

以下为部分关键代码:

// upload.js

// 创建一个worker对象
const worker = new worker('worker.js')
// 向子线程发送消息,并传入文件对象和切片大小,开始计算分割切片
worker.postMessage(file, DefualtChunkSize)

// 子线程计算完成后,会将切片返回主线程
worker.onmessage = (chunks) => {
...
}

子线程代码:

// worker.js

// 接收文件对象及切片大小
onmessage (file, DefualtChunkSize) => {
let blobSlice = File.prototype.slice || File.prototype.mozSlice ||File.prototype.webkitSlice,
chunks = Math.ceil(file.size / DefualtChunkSize),
currentChunk = 0,
spark = new SparkMD5.ArrayBuffer(),
fileReader = new FileReader();

fileReader.onload = function (e) {
console.log('read chunk nr', currentChunk + 1, 'of');
const chunk = e.target.result;
spark.append(chunk);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
} else {
let fileHash = spark.end();
console.info('finished computed hash', fileHash);
// 此处为重点,计算完成后,仍然通过postMessage通知主线程
postMessage({ fileHash, fileReader })
}
};

fileReader.onerror = function () {
console.warn('oops, something went wrong.');
};

function loadNext() {
let start = currentChunk * DefualtChunkSize,
end = ((start + DefualtChunkSize) >= file.size) ? file.size : start + DefualtChunkSize;
let chunk = blobSlice.call(file, start, end);
fileReader.readAsArrayBuffer(chunk);
}
loadNext();
}

以上利用 worker 线程,我们即可得到计算后的切片,以及 md5 值。

三、 断点续传 + 秒传 + 上传进度计算

在拿到切片和 md5 后,我们首先去服务器查询一下,是否已经存在当前文件。

  • 如果已存在,并且已经是上传成功的文件,则直接返回前端上传成功,即可实现 "秒传"。

  • 如果已存在,并且有一部分切片上传失败,则返回给前端已经上传成功的切片 name,前端拿到后,根据返回的切片,计算出未上传成功的剩余切片,然后把剩余的切片继续上传,即可实现 "断点续传"。

  • 如果不存在,则开始上传,这里需要注意的是,在并发上传切片时,需要控制并发量,避免一次性上传过多切片,导致崩溃。

// 检查是否已存在相同文件
async function checkAndUploadChunk(chunkList, fileMd5Value) {
const requestList = []
// 如果不存在,则上传
for (let i = 0; i < chunkList; i++) {
requestList.push(upload({ chunkList[i], fileMd5Value, i }))
}
// 并发上传
if (requestList?.length) {
await Promise.all(requestList)
}
}

// 上传chunk
function upload({ chunkList, chunk, fileMd5Value, i }) {
current = 0
let form = new FormData()
form.append("data", chunk) //切片流
form.append("total", chunkList.length) //总片数
form.append("index", i) //当前是第几片
form.append("fileMd5Value", fileMd5Value)
return axios({
method: 'post',
url: BaseUrl + "/upload",
data: form }).then(({ data }) => {
if (data.stat) {
current = current + 1
// 获取到上传的进度
const uploadPercent = Math.ceil((current / chunkList.length) * 100)
}
})
}

在以上代码中,我们在上传切片的同时,也会告诉后端当前上传切片的 index,后端接收后,记录该 index 以便在合并时知道切片的顺序。

当所有切片上传完成后,再向后端发送一个上传完成的请求,即通知后端把所有切片进行合并,最终完成整个上传流程。

大功告成!由于篇幅有限,本文主要讲了前端的实现思路,最终落地成完整的项目,还是需要大家根据真实的项目需求来实现。

关于本文
作者:@郭洋
原文:https://mp.weixin.qq.com/s/SyP0zUsRHsTDyEQoab4fXg

关于【上传】相关阅读,欢迎读者们自荐投稿,前端早读课等你。

【第2019期】JS纯前端实现audio音频剪裁剪切复制播放与上传

【第1829期】复制黏贴上传图片和跨浏览器自动化测试

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存