Appearance
需求分析
一个上传组件需要具备哪些功能:
- 文件格式校验
- 上传任意资源,包括超大的视频文件(分片)
- 上传期间若出现断网,再次联网可以继续上传(断点续传)
- 进度条提示
- 已上传的同一资源,直接完成(秒传)
要实现以上能力,需要前后端配合,下面我们简单实现一个上传组件。
原理概述
上传文件时,可通过 blob 分成多个块上传,所有分块上传完成之后,将这些块合并成一个文件。在这过程中,如果文件上传停止,下次重新上传时,拿到上次上传文件的块索引,在这个索引叠加上传即可。

前端:
- 文件格式校验
- 文件切片、md5 计算
- 发起检查请求,把当前文件的 hash 发送给服务端,检查是否有相同 hash 的文件
- 上传进度计算
- 上传完成后通知后端合并切片
后端:
- 检查接收到的 hash 是否有相同的文件,并通知前端当前 hash 是否有未完成的上传
- 接收切片
- 合并所有切片
代码实现
格式校验
对于上传的文件,一般来说,我们要校验其格式,仅需要获取文件的后缀(扩展名),即可判断其是否符合我们的上传限制:
js
// 文件路径
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 项目为例:
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 线程在后台运行,两者互不干扰。
以下为部分关键代码:
js
// upload.js
// 创建一个 worker 对象
const worker = new worker('worker.js');
// 向子线程发送消息,并传入文件对象和切片大小,开始计算分割切片
worker.postMessage(file, DefualtChunkSize);
// 子线程计算完成后,会将切片返回主线程
worker.onmessage = (chunks) => {
// ...
}子线程代码:
js
// 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,前端拿到后,根据返回的切片,计算出未上传成功的剩余切片,然后把剩余的切片继续上传,即可实现「断点续传」。 - 如果不存在,则开始上传,这里需要注意的是,在并发上传切片时,需要控制并发量,避免一次性上传过多切片,导致崩溃。
js
// 检查是否已存在相同文件
async function checkAndUploadChunk(chunkList, fileMd5Value) {
const requestList = [];
// 如果不存在,则上传
for (let i = 0; i < chunkList; i++) {
const chunk = chunkList[i];
requestList.push(upload({ chunkList, chunk, 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 以便在合并时知道切片的顺序。
当所有切片上传完成后,再向后端发送一个上传完成的请求,即通知后端把所有切片进行合并,最终完成整个上传流程。
总结
大文件上传:
- 前端上传大文件时使用
Blob.prototype.slice将文件切片,并发上传多个切片,最后发送一个合并的请求通知服务端合并切片 - 服务端接收切片并存储,收到合并请求后使用流将切片合并到最终文件
- 原生
XMLHttpRequest的upload.onprogress对切片上传进度的监听 - 使用 Vue 计算属性根据每个切片的进度算出整个文件的上传进度
断点续传:
- 使用
spark-md5根据文件内容算出文件 hash - 通过 hash 可以判断服务端是否已经上传该文件,从而直接提示用户上传成功(秒传)
- 通过
XMLHttpRequest的abort方法暂停切片的上传 - 上传前服务端返回已经上传的切片名,前端跳过这些切片的上传
进一步优化
拖拽上传
- 定义一个允许拖放文件的区域
div.drop-box - 取消
drop事件的默认行为e.preventDefault(),不然浏览器会直接打开文件 - 为拖拽区域绑定事件,鼠标在拖拽区域上
dragover,鼠标离开拖拽区域dragleave,在拖拽区域上释放文件drop drop事件内获得文件信息e.dataTransfer.files
js
var box = document.getElementById('drop-box');
// 禁用浏览器的拖放默认行为
document.addEventListener('drop',function (e) {
console.log('document drog');
e.preventDefault();
});
// 设置拖拽事件
function openDropEvent() {
box.addEventListener('dragover',function (e) {
console.log('elemenet dragover');
box.classList.add('over');
e.preventDefault();
});
box.addEventListener('dragleave', function (e) {
console.log('elemenet dragleave');
box.classList.remove('over');
e.preventDefault();
});
box.addEventListener('drop', function (e) {
e.preventDefault(); // 取消浏览器默认拖拽效果
var fileList = e.dataTransfer.files; // 获取拖拽中的文件对象
var len = fileList.length; // 用来获取文件的长度(其实是获得文件数量)
// 检测是否是拖拽文件到页面的操作
if (!len) {
box.classList.remove('over');
return;
}
box.classList.add('over');
submitUpload(fileList);
}, false);
}
openDropEvent();
function submitUpload(fileList) {
// upload...
}剪贴板上传
- 页面内增加一个可编辑的编辑区域
div.editor-box,开启contenteditable - 为
div.editor-box绑定paste事件 - 处理
paste事件,从event.clipboardData || window.clipboardData获得数据 - 将数据转换为文件
items[i].getAsFile() - 实现在编辑区域的光标处插入内容
insertNodeToEditor方法
HTML:
html
<div class="editor-box" id="editor-box" contenteditable="true" >
可以直接粘贴图片到这里直接上传
</div>JS:
js
// 光标处插入 dom 节点
function insertNodeToEditor(editor,ele) {
// 插入 dom 节点
var range; // 记录光标位置对象
var node = window.getSelection().anchorNode;
// 这里判断是做是否有光标判断,因为弹出框默认是没有的
if (node != null) {
range = window.getSelection().getRangeAt(0); // 获取光标起始位置
range.insertNode(ele); // 在光标位置插入该对象
} else {
editor.append(ele);
}
}
var box = document.getElementById('editor-box');
// 绑定 paste 事件
box.addEventListener('paste',function (event) {
var data = (event.clipboardData || window.clipboardData);
var items = data.items;
var fileList = []; // 存储文件数据
if (items && items.length) {
// 检索剪切板items
for (var i = 0; i < items.length; i++) {
console.log(items[i].getAsFile());
fileList.push(items[i].getAsFile());
}
}
event.preventDefault(); // 阻止默认行为
submitUpload(fileList);
});
function submitUpload(fileList) {
// upload...
}相关知识
文件类型
JS 提供了一些 API 来处理文件或原始文件数据,例如:File、Blob、FileReader、Base64、ArrayBuffer、Canvas 等。
类型说明
Blob
Blob 对象表示一个不可变、原始数据的类文件对象。Blob 表示的不一定是 JavaScript 原生格式的数据。File 接口基于 Blob,继承了 blob 的功能并将其扩展以支持用户系统上的文件。
Blob 对象是包含有只读原始数据的类文件对象。简单来说,Blob 对象就是一个不可修改的二进制文件。
- 创建 blob
ts
const blob = new Blob(array, options);有两个参数:
array: 是一个由ArrayBuffer,ArrayBufferView,Blob,DOMString等对象构成的 Array,或者其他类似对象的混合体;options: 可选的 BlobPropertyBag 字典,它可能会指定如下两个属性:type: 默认值为空字符串,表示将会被放入到 blob 中的数组内容的 MIME 类型;endings: 默认值为transparent,用于指定包含行结束符\n的字符串如何被写入,不常用;
常用的 MIME 类型如下:
| 类型 | 描述 |
|---|---|
| text/plain | 纯文本 |
| text/html | HTML 格式 |
| text/plain | 纯文本文档 |
| text/htmlHTML | 文档 |
| text/javascript | JavaScript文件 |
| text/css | CSS文件 |
| application/json | JSON 文件 |
| application/pdf | PDF 文件 |
| application/xml | XML 文件 |
| image/jpeg | JPEG 图像 |
| image/png | PNG 图像 |
| image/gif | GI F图像 |
| image/svg+xml | SVG 图像 |
| audio | MP3文件 |
| video | MP4文件 |
示例:
ts
// 创建一个 blob
const blob = new Blob(['Hello World'], { type: 'text/plain' })
console.log(blob.size) // 11
console.log(blob.type) // "text/plain"
// 使用示例:转化为 ULR,并在 iframe 中加载
const iframe = document.getElementsById('iframe');
iframe.src = URL.createObjectURL(blob);- blob 分片
ts
const blob = instanceOfBlob.slice([start [, end [, contentType]]]);有三个参数:
start: 可选,切片起点,默认值为0;end: 可选,切片结束位置,默认值为blob.size;contentType: 可选,设置新 blob 的 MIME 类型。如果省略 type,则默认为 blob 的原始值;
示例:
ts
// 切片示例:页面上会显示:Hello Wo
const iframe = document.getElementsById('iframe');
const blob = new Blob(['Hello World'], { type: 'text/plain' });
const subBlob = blob.slice(0, 8);
iframe.src = URL.createObjectURL(subBlob);File
文件(File)接口提供有关文件的信息,并允许网页中的 JavaScript 访问其内容。
File 对象是特殊类型的 Blob,且可以用在任意的 Blob 类型的 context 中。比如说,FileReader, URL.createObjectURL(), createImageBitmap()(en-US), 及 XMLHttpRequest.send() 都能处理 Blob 和 File。
以下为 FileReader 的对象方法:
| 方法 | 描述 |
|---|---|
FileReader.abort() | 中止读取操作,在返回时 readyState 属性为 DONE |
FileReader.readAsArrayBuffer() | 开始读取指定的 Blob 中的内容。完成后,result 属性中保存的将是被读取文件的 ArrayBuffer 数据对象 |
FileReader.readAsBinaryString() | 开始读取指定的 Blob 中的内容。完成后,result 属性中将包含所读取文件的原始二进制数据 |
FileReader.readAsDataURL() | 开始读取指定的 Blob 中的内容。完成后,result 属性中将包含一个 data: URL 格式的 Base64 字符串以表示所读取文件的内容 |
FileReader.readAsText() | 开始读取指定的 Blob 中的内容。一旦完成,result 属性中将包含一个字符串以表示所读取的文件内容 |
- Base64
Base64 是一组相似的二进制到文本(binary-to-text)的编码规则。一个常见应用是对二进制数据进行编码,以便将其纳入 dataURL 中。
在 JavaScript 中,有两个函数被分别用来处理解码和编码 Base64 字符串:
atob(): 解码通过 Base-64 编码的字符串数据(「atob」 应读作「ASCII to binary」);btoa(): 从二进制数据「字符串」创建一个 Base-64 编码的 ASCII 字符串(「btoa」应读作「binary to ASCII」);
html
<!-- 读取文件,用 Bse64 展示图片 -->
<!DOCTYPE html>
<html lang="en">
<body>
<input type="file" id="input" />
<img id="img" />
<script>
const input = document.getElementById('input');
const img = document.getElementById('img');
input.onchange = (e) => {
const reader = new FileReader();
reader.readAsDataURL(e.target.files[0]);
reader.onload = (e) => {
img.src = e.target.result;
console.log(e.target.result);
};
};
</script>
</body>
</html>- Object URL
Object URL(MDN 定义名称)又称 Blob URL(W3C 定义名称),是 HTML5 中的新标准。它是一个用来表示 File Object 或 Blob Object 的 URL。
Blob URL/Object URL 是一种伪协议,允许将 Blob 和 File 对象用作图像、二进制数据下载链接等的 URL 源。
html
<!-- 通过 Object URL 展示和下载图片 -->
<!DOCTYPE html>
<html lang="en">
<body>
<input type="file" id="input" />
<img id="img" />
<script>
const input = document.getElementById('input');
const img = document.getElementById('img');
input.onchange = (e) => {
const url = URL.createObjectURL(e.target.files[0]);
img.src = url;
// 实现下载
const a = document.createElement('a');
a.href = url;
a.download = 'img';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
</script>
</body>
</html>- ArrayBuffer
ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区。(只读)
类型转换
Blob、File → Base64
js
function fileToDataURL(file) {
let reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = function (e) {
return reader.result;
};
}Base64 → Blob、File
js
// Base64 转为 Blob
function dataURLToBlob(fileDataURL) {
let arr = fileDataURL.split(','),
mime = arr[0].match(/:(.*?);/)[1],
bStr = atob(arr[1]),
n = bStr.length,
u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bStr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
}
// Base64 转为 File
function dataURLToBlob(fileDataURL, filename) {
let arr = fileDataURL.split(','),
mime = arr[0].match(/:(.*?);/)[1],
bStr = atob(arr[1]),
n = bStr.length,
u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bStr.charCodeAt(n);
}
return new File([u8arr], filename, { type: mime });
}Blob、File → Object URL
js
// object:用于创建 URL 的 File 对象、Blob 对象
const objectUrl = URL.createObjectURL(object);ArrayBuffer → Blob
js
const blob = new Blob([new Uint8Array(buffer, byteOffset, length)]);ArrayBuffer → Base64
js
const base64 = btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)));Blob → ArrayBuffer
js
function blobToArrayBuffer (blob) {
const reader = new FileReader();
reader.readAsArrayBuffer(blob);
reader.onload = () => {
return reader.result;
};
}Blob、File 文件数据绘制到 Canvas
js
// 思路:Blob、File ——> dataURL(Base64) ——> canvas
function fileAndBlobToCanvas(fileDataURL) {
let img = new Image();
let canvas = document.createElement('canvas');
img.src = fileDataURL;
if (!canvas.getContext) {
alert('浏览器不支持 Canvas');
return;
}
let ctx = canvas.getContext('2d');
document.getElementById('container').appendChild(canvas);
img.onload = function () {
ctx.drawImage(img, 0, 0, img.width, img.height);
}
}从 Canvas 中获取文件 Base64
js
function canvasToDataURL() {
let canvas = document.createElement('canvas');
let canvasDataURL = canvas.toDataURL('image/png', 1.0);
return canvasDataURL;
}