实现一个大文件切片上传+断点续传
文件上传的操作方法, 跟 DOM 和 JavaScript 表单 介绍的方法, 是一样的.
文件上传一般是基于两种方式,FormData 以及 Base64
一个上传组件,需要具备的功能:
- 需要校验文件格式
- 可以上传任何文件,包括超大的视频文件(切片)
- 上传期间断网后,再次联网可以继续上传(断点续传)
- 要有进度条提示
- 已经上传过同一个文件后,直接上传完成(秒传)
前后端分工:
- 前端:
- 文件格式校验
- 文件切片、md5计算
- 发起检查请求,把当前文件的hash发送给服务端,检查是否有相同hash的文件
- 上传进度计算
- 上传完成后通知后端合并切片
- 后端:
- 检查接收到的hash是否有相同的文件,并通知前端当前hash是否有未完成的上传
- 接收切片
- 合并所有切片
格式校验
对于上传的文件,一般来说,我们要校验其格式,仅需要获取文件的后缀(扩展名),即可判断其是否符合我们的上传限制:
<input
accept=".csv"
type="file"
:multiple="true"
:class="`${prefixCls}__select_file_input`"
@change="handleFileChange"
/>
<script>
const handleFileChange = async (e) => {
for (let key in e.target.files) {
let file = e.target.files[key]
if (file instanceof File) {
//获取最后一个.的位置
let index= file.name.lastIndexOf('.')
//获取后缀
let extName = file.name.substring(index+1)
//输出结果
console.log(extName);
const isAllowedFile = ["csv","png","jpeg"].includes(extName);
}
}
}
</script>
但是,这种方式有个弊端,那就是我们可以随便篡改文件的后缀名,比如:test.mp4 ,我们可以通过修改其后缀名:test.mp4 -> test.png ,这样即可绕过限制进行上传。那有没有更严格的限制方式呢?当然是有的。
那就是通过查看文件的二进制数据来识别其真实的文件类型,因为计算机识别文件类型时,并不是真的通过文件的后缀名来识别的,而是通过 “魔数”(Magic Number)来区分,对于某一些类型的文件,起始的几个字节内容都是固定的,根据这几个字节的内容就可以判断文件的类型。借助十六进制编辑器,可以查看一下图片的二进制数据,我们还是以test.png为例:
由上图可知,PNG 类型的图片前 8 个字节是 0x89 50 4E 47 0D 0A 1A 0A。基于这个结果,我们可以据此来做文件的格式校验,以vue项目为例:
<input
accept=".csv"
ref="selectFiles"
type="file"
:multiple="true"
:class="`${prefixCls}__select_file_input`"
οnclick="f.outerHTML=f.outerHTML"
@change="handleFileChange"
/>
<script>
const handleFileChange = async (e) => {
for (let key in e.target.files) {
let file = e.target.files[key]
if (file instanceof File) {
// 以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))
}
}
}
function 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));
});
}
function check(headers) {
return (buffers, options = { offset: 0 }) =>
headers.every(
(header, index) => header === buffers[options.offset + index]
);
}
1.JPEG/JPG - 文件头标识 (2 bytes): ff, d8 文件结束标识 (2 bytes): ff, d9
2.TGA - 未压缩的前 5 字节 00 00 02 00 00 - RLE 压缩的前 5 字节 00 00 10 00 00
3.PNG - 文件头标识 (8 bytes) 89 50 4E 47 0D 0A 1A 0A
4.GIF - 文件头标识 (6 bytes) 47 49 46 38 39(37) 61
5.BMP - 文件头标识 (2 bytes) 42 4D B M
6.PCX - 文件头标识 (1 bytes) 0A
7.TIFF - 文件头标识 (2 bytes) 4D 4D 或 49 49
8.ICO - 文件头标识 (8 bytes) 00 00 01 00 01 00 20 20
9.CUR - 文件头标识 (8 bytes) 00 00 02 00 01 00 20 20
10.IFF - 文件头标识 (4 bytes) 46 4F 52 4D
11.ANI - 文件头标识 (4 bytes) 52 49 46 46
文件切片
基于js管理大文件上传以及断点续传
//Axios的简单封装
let instance = axios.create();
instance.defaults.baseURL = 'http://127.0.0.1:8888';
instance.defaults.headers['Content-Type'] = 'multipart/form-data';
instance.defaults.transformRequest = (data, headers) => {
const contentType = headers['Content-Type'];
if (contentType === "application/x-www-form-urlencoded") return Qs.stringify(data);
return data;
};
instance.interceptors.response.use(response => {
return response.data;
});
FormData
// 主要展示基于ForData实现上传的核心代码
upload_button_upload.addEventListener('click', function () {
if (upload_button_upload.classList.contains('disable') || upload_button_upload.classList.contains('loading')) return;
if (!_file) {
alert('请您先选择要上传的文件~~');
return;
}
changeDisable(true);
// 把文件传递给服务器:FormData
let formData = new FormData();
// 根据后台需要提供的字段进行添加
formData.append('file', _file);
formData.append('filename', _file.name);
instance.post('/upload_single', formData).then(data => {
if (+data.code === 0) {
alert(`文件已经上传成功~~,您可以基于 ${data.servicePath} 访问这个资源~~`);
return;
}
return Promise.reject(data.codeText);
}).catch(reason => {
alert('文件上传失败,请您稍后再试~~');
}).finally(() => {
clearHandle();
changeDisable(false);
});
});
<form action="?" onsubmit='return f()' method="post" enctype="multipart/form-data">
<input type="file">
</form>
<script type="text/javascript">
function f() {
var file = document.querySelector('input[type=file]')
var filename = file.value // 文件路径
if (!filename && !(filename.endsWith('.jpg'))) { // 验证文件
// ...
return false
}
</script>
Base64
把文件流转为BASE64,这里可以封装一个方法
export changeBASE64(file) => {
return new Promise(resolve => {
let fileReader = new FileReader();
fileReader.readAsDataURL(file);
fileReader.onload = ev => {
resolve(ev.target.result);
};
});
};
具体实现
upload_inp.addEventListener("change", async function () {
let file = upload_inp.files[0],
BASE64,
data;
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
alert("上传的文件不能超过2MB~~");
return;
}
upload_button_select.classList.add("loading");
// 获取Base64
BASE64 = await changeBASE64(file);
try {
data = await instance.post(
"/upload_single_base64",
{
// encodeURIComponent(BASE64) 防止传输过程中特殊字符乱码,同时后端需要用decodeURIComponent进行解码
file: encodeURIComponent(BASE64),
filename: file.name,
},
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
}
);
if (+data.code === 0) {
alert(
`恭喜您,文件上传成功,您可以基于 ${data.servicePath} 地址去访问~~`
);
return;
}
throw data.codeText;
} catch (err) {
alert("很遗憾,文件上传失败,请您稍后再试~~");
} finally {
upload_button_select.classList.remove("loading");
}
});