实现一个大文件切片上传+断点续传

文件上传的操作方法, 跟 DOMJavaScript 表单 介绍的方法, 是一样的.
文件上传一般是基于两种方式,FormData 以及 Base64

一个上传组件,需要具备的功能:

  1. 需要校验文件格式
  2. 可以上传任何文件,包括超大的视频文件(切片)
  3. 上传期间断网后,再次联网可以继续上传(断点续传)
  4. 要有进度条提示
  5. 已经上传过同一个文件后,直接上传完成(秒传)

前后端分工:

  • 前端:
  1. 文件格式校验
  2. 文件切片、md5计算
  3. 发起检查请求,把当前文件的hash发送给服务端,检查是否有相同hash的文件
  4. 上传进度计算
  5. 上传完成后通知后端合并切片
  • 后端:
  1. 检查接收到的hash是否有相同的文件,并通知前端当前hash是否有未完成的上传
  2. 接收切片
  3. 合并所有切片

格式校验

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

  1. <input
  2. accept=".csv"
  3. type="file"
  4. :multiple="true"
  5. :class="`${prefixCls}__select_file_input`"
  6. @change="handleFileChange"
  7. />
  8. <script>
  9. const handleFileChange = async (e) => {
  10. for (let key in e.target.files) {
  11. let file = e.target.files[key]
  12. if (file instanceof File) {
  13. //获取最后一个.的位置
  14. let index= file.name.lastIndexOf('.')
  15. //获取后缀
  16. let extName = file.name.substring(index+1)
  17. //输出结果
  18. console.log(extName);
  19. const isAllowedFile = ["csv","png","jpeg"].includes(extName);
  20. }
  21. }
  22. }
  23. </script>

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

那就是通过查看文件的二进制数据来识别其真实的文件类型,因为计算机识别文件类型时,并不是真的通过文件的后缀名来识别的,而是通过 “魔数”(Magic Number)来区分,对于某一些类型的文件,起始的几个字节内容都是固定的,根据这几个字节的内容就可以判断文件的类型。借助十六进制编辑器,可以查看一下图片的二进制数据,我们还是以test.png为例:
文件上传 - 图1
由上图可知,PNG 类型的图片前 8 个字节是 0x89 50 4E 47 0D 0A 1A 0A。基于这个结果,我们可以据此来做文件的格式校验,以vue项目为例:

  1. <input
  2. accept=".csv"
  3. ref="selectFiles"
  4. type="file"
  5. :multiple="true"
  6. :class="`${prefixCls}__select_file_input`"
  7. οnclick="f.outerHTML=f.outerHTML"
  8. @change="handleFileChange"
  9. />
  10. <script>
  11. const handleFileChange = async (e) => {
  12. for (let key in e.target.files) {
  13. let file = e.target.files[key]
  14. if (file instanceof File) {
  15. // 以PNG为例,只需要获取前8个字节,即可识别其类型
  16. const buffers = await this.readBuffer(file, 0, 8);
  17. const uint8Array = new Uint8Array(buffers);
  18. const isPNG = this.check([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
  19. // 上传test.png后,打印结果为true
  20. console.log(isPNG(uint8Array))
  21. }
  22. }
  23. }
  24. function readBuffer(file, start = 0, end = 2) {
  25. // 获取文件的二进制数据,因为我们只需要校验前几个字节即可,所以并不需要获取整个文件的数据
  26. return new Promise((resolve, reject) => {
  27. const reader = new FileReader();
  28. reader.onload = () => {
  29. resolve(reader.result);
  30. };
  31. reader.onerror = reject;
  32. reader.readAsArrayBuffer(file.slice(start, end));
  33. });
  34. }
  35. function check(headers) {
  36. return (buffers, options = { offset: 0 }) =>
  37. headers.every(
  38. (header, index) => header === buffers[options.offset + index]
  39. );
  40. }

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管理大文件上传以及断点续传

  1. //Axios的简单封装
  2. let instance = axios.create();
  3. instance.defaults.baseURL = 'http://127.0.0.1:8888';
  4. instance.defaults.headers['Content-Type'] = 'multipart/form-data';
  5. instance.defaults.transformRequest = (data, headers) => {
  6. const contentType = headers['Content-Type'];
  7. if (contentType === "application/x-www-form-urlencoded") return Qs.stringify(data);
  8. return data;
  9. };
  10. instance.interceptors.response.use(response => {
  11. return response.data;
  12. });

FormData

  1. // 主要展示基于ForData实现上传的核心代码
  2. upload_button_upload.addEventListener('click', function () {
  3. if (upload_button_upload.classList.contains('disable') || upload_button_upload.classList.contains('loading')) return;
  4. if (!_file) {
  5. alert('请您先选择要上传的文件~~');
  6. return;
  7. }
  8. changeDisable(true);
  9. // 把文件传递给服务器:FormData
  10. let formData = new FormData();
  11. // 根据后台需要提供的字段进行添加
  12. formData.append('file', _file);
  13. formData.append('filename', _file.name);
  14. instance.post('/upload_single', formData).then(data => {
  15. if (+data.code === 0) {
  16. alert(`文件已经上传成功~~,您可以基于 ${data.servicePath} 访问这个资源~~`);
  17. return;
  18. }
  19. return Promise.reject(data.codeText);
  20. }).catch(reason => {
  21. alert('文件上传失败,请您稍后再试~~');
  22. }).finally(() => {
  23. clearHandle();
  24. changeDisable(false);
  25. });
  26. });
  1. <form action="?" onsubmit='return f()' method="post" enctype="multipart/form-data">
  2. <input type="file">
  3. </form>
  4. <script type="text/javascript">
  5. function f() {
  6. var file = document.querySelector('input[type=file]')
  7. var filename = file.value // 文件路径
  8. if (!filename && !(filename.endsWith('.jpg'))) { // 验证文件
  9. // ...
  10. return false
  11. }
  12. </script>

Base64

把文件流转为BASE64,这里可以封装一个方法

  1. export changeBASE64(file) => {
  2. return new Promise(resolve => {
  3. let fileReader = new FileReader();
  4. fileReader.readAsDataURL(file);
  5. fileReader.onload = ev => {
  6. resolve(ev.target.result);
  7. };
  8. });
  9. };

具体实现

  1. upload_inp.addEventListener("change", async function () {
  2. let file = upload_inp.files[0],
  3. BASE64,
  4. data;
  5. if (!file) return;
  6. if (file.size > 2 * 1024 * 1024) {
  7. alert("上传的文件不能超过2MB~~");
  8. return;
  9. }
  10. upload_button_select.classList.add("loading");
  11. // 获取Base64
  12. BASE64 = await changeBASE64(file);
  13. try {
  14. data = await instance.post(
  15. "/upload_single_base64",
  16. {
  17. // encodeURIComponent(BASE64) 防止传输过程中特殊字符乱码,同时后端需要用decodeURIComponent进行解码
  18. file: encodeURIComponent(BASE64),
  19. filename: file.name,
  20. },
  21. {
  22. headers: {
  23. "Content-Type": "application/x-www-form-urlencoded",
  24. },
  25. }
  26. );
  27. if (+data.code === 0) {
  28. alert(
  29. `恭喜您,文件上传成功,您可以基于 ${data.servicePath} 地址去访问~~`
  30. );
  31. return;
  32. }
  33. throw data.codeText;
  34. } catch (err) {
  35. alert("很遗憾,文件上传失败,请您稍后再试~~");
  36. } finally {
  37. upload_button_select.classList.remove("loading");
  38. }
  39. });