实现一个大文件切片上传+断点续传
文件上传的操作方法, 跟 DOM 和 JavaScript 表单 介绍的方法, 是一样的.
文件上传一般是基于两种方式,FormData 以及 Base64
一个上传组件,需要具备的功能:
- 需要校验文件格式
- 可以上传任何文件,包括超大的视频文件(切片)
- 上传期间断网后,再次联网可以继续上传(断点续传)
- 要有进度条提示
- 已经上传过同一个文件后,直接上传完成(秒传)
前后端分工:
- 前端:
- 文件格式校验
- 文件切片、md5计算
- 发起检查请求,把当前文件的hash发送给服务端,检查是否有相同hash的文件
- 上传进度计算
- 上传完成后通知后端合并切片
- 后端:
- 检查接收到的hash是否有相同的文件,并通知前端当前hash是否有未完成的上传
- 接收切片
- 合并所有切片
格式校验
对于上传的文件,一般来说,我们要校验其格式,仅需要获取文件的后缀(扩展名),即可判断其是否符合我们的上传限制:
<inputaccept=".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项目为例:
<inputaccept=".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后,打印结果为trueconsole.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);// 把文件传递给服务器:FormDatalet 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");// 获取Base64BASE64 = 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");}});
