说起文件上传,我们往往停留在type=”file”和 var formData = new FormData()的层次,然而这些还不够,想深入优化,想做更好的安全策略。我们必须更加深入一点,理解我们给后端传递图片到底做了什么。

首先要明确一点,现代浏览器的普遍做法是,我们给后端传递一个二进制的文件流,当然,你也可以用一个对象包裹文件流,并传递其他参数等等。

*文件上传的难点:

在文件上传中,我们往往面临如下问题:

  • 文件过大怎么办-切片?

    • 断点续传怎么实现

      • 断点续传如何判断文件的唯一性(MD5\hash值)
      • 断点续传判断文件唯一性的时候,计算md5如何不卡顿(控制并发数量)

        • 控制并发数量时候如何根据网速动态控制包的大小

          1. **上面可简单总结为:并发数量 + 包控制**
    • 切片上传时候出错怎么重试,怎么终止

  • 如何限制文件上传格式-二进制流头文件信息判断

**
下面我们层层递进,按照深度由浅入深来了解一下:

1、判断文件格式

最常用的方法input的accept属性可以限制,以及获取到的file.type可以获取文件格式。其实这两种判断是不准确的,这种判断方式是通过split方法获取的文件后缀名,包括你图片转为base64获取到的字符串头信息也是文件后缀名判断的。
我们可以做一个实验,将一个jpg文件改为png后来获取文件type信息,我们会发现type信息变为png了。然而实际上,文件依然是jpg格式的。因为文件是以二进制形式存储以及给后端传递的。
我们判断文件格式,最正确的方式是先将图片转为二进制流,然后获取文件的二进制头信息来判断,而且转为二进制流的文件是无法进行修改的。具有可靠的安全性。

常见图片的二进制流头信息判断如下:

1)所有png图片的二进制流前八位头信息是一样的-
文件头标识 (8 bytes) 89 50 4E 47 0D 0A 1A 0A

2)jpg的文件头标识 (2 bytes):
开头标识:FF, D8 (SOI) (JPEG 文件标识)

  1. - 文件结束标识 (2 bytes): FF, D9 (EOI)
  2. 3)GIF - 文件头标识 (6 bytes) 47 49 46 38 39(37) 61

4)BMP文件头标识 (2 bytes) 42 4D

将一个文件转为二进制流

**

1)文件转为base64字符串

下面列举几种方法:

  1. var fileInput = document.getElementById("image_upload");
  2. //选择文件
  3. fileInput.addEventListener('change', function () {
  4. //如果未传入文件则中断
  5. if (fileInput.files[0] == undefined) {
  6. return
  7. }
  8. var file = fileInput.files[0];
  9. var reader = new FileReader();
  10. reader.readAsDataURL(file);
  11. reader.onload = function () {
  12. var base64Str = this.result;
  13. return base64Str
  14. }
  15. })

重绘canvas转为base64

  1. function getImgToBase64(url,callback){//将图片转换为Base64
  2. var canvas = document.createElement('canvas'),
  3. ctx = canvas.getContext('2d'),
  4. img = new Image;
  5. img.crossOrigin = 'Anonymous';
  6. img.onload = function(){
  7. canvas.height = img.height;
  8. canvas.width = img.width;
  9. ctx.drawImage(img,0,0);
  10. var dataURL = canvas.toDataURL('image/png');
  11. callback(dataURL);
  12. canvas = null;
  13. };
  14. img.src = url;
  15. }

具体实现方法很多,自行问度娘。

2)图片转二进制

转二进制对象

  1. // 将base64转为二进制流
  2. // 该方法接受一个base64字符串,进行处理
  3. function dataURLtoBlob(base64) {
  4. var arr = base64.split(','),
  5. mime = arr[0].match(/:(.*?);/)[1],
  6. bstr = atob(arr[1]),
  7. n = bstr.length,
  8. u8arr = new Uint8Array(n);
  9. while (n--) {
  10. u8arr[n] = bstr.charCodeAt(n);
  11. }
  12. return new Blob([u8arr], {
  13. type: mime
  14. });
  15. }

上面方法得到的是binary对象。

转二进制string
我们需要的是文件转为二进制字符串,方法如下:

  1. // file转二进制string
  2. blobToString = function (blob) {
  3. return new Promise(resolve => {
  4. const reader = new FileReader()
  5. reader.onload = function () {
  6. const ret = reader.result.split('')
  7. .map(v => v.charCodeAt())
  8. .map(v => v.toString(16).toUpperCase())
  9. .map(v => v.padStart(2, '0'))
  10. .join(' ')
  11. resolve(ret)
  12. }
  13. reader.readAsBinaryString(blob)
  14. })
  15. }
  16. /*
  17. *判断方法如下jpg格式的
  18. *我们的文件是blob对象,可以直接使用blob.slice方法
  19. */
  20. const isJpg = async function (file) {
  21. const len = file.size
  22. const start = await blobToString(file.slice(0, 2))
  23. const tail = await blobToString(file.slice(-2, len))
  24. const isjpg = start === 'FF D8' && tail === 'FF D9'
  25. return isjpg
  26. }
  27. // png格式的
  28. const isPng = async function (file) {
  29. const ret = await this.blobToString(file.slice(0, 8))
  30. const ispng = ret === '89 50 4E 47 0D 0A 1A 0A'
  31. return ispng
  32. }
  33. // gif格式的
  34. const isGif = async function (file) {
  35. const ret = await this.blobToString(file.slice(0, 6))
  36. const isgif = (ret === '47 49 46 38 39 61') || (ret === '47 49 46 38 37 61')
  37. return isgif
  38. // 文件头16进制 47 49 46 38 39 61 或者47 49 46 38 37 61
  39. // 分别仕89年和87年的规范
  40. // const tmp = '47 49 46 38 39 61'.split(' ')
  41. // .map(v=>parseInt(v,16))
  42. // .map(v=>String.fromCharCode(v))
  43. // console.log('gif头信息',tmp)
  44. // // 或者把字符串转为16进制 两个方法用那个都行
  45. // const tmp1 = 'GIF89a'.split('')
  46. // .map(v=>v.charCodeAt())
  47. // .map(v=>v.toString(16))
  48. // console.log('gif头信息',tmp1)
  49. // return ret ==='GIF89a' || ret==='GIF87a'
  50. // 文件头标识 (6 bytes) 47 49 46 38 39(37) 61
  51. }

有了上面的判断方法就无惧某些坏小子更改文件后缀绕过校验了。

2、文件切片

对于文件过大的时候,我相信很多人都知道要进行文件切片,我们通过blob.slice方法将切片的数据放入一个数组中(习惯命名为chunks)。然后进行分段上传。
画一个简易的流程图吧:
未命名文件.jpg

上面流程中,我们设置的文件并发数量为1,也就是每次只进行一个文件的上传。其实,当网速优越,我们是可以一次性上传多个文件的。比如我们设置并发数量limit=4。
那么,问题来了,我们如何控大量并发请求的并发数量?下面会讲到。

1、切片

下面先来看一下简单的实现逻辑,度娘上有很多切片的方法,blob.slice是比较好的。

  1. // 定义每片大小为100k
  2. const CHUNK_SIZE = 0.1 * 1024 * 1024
  3. // 按照定义大小进行切片
  4. function createFileChunk (file, size = CHUNK_SIZE) {
  5. // 生成文件块 Blob.slice语法
  6. const chunks = [];
  7. let cur = 0;
  8. while (cur < file.size) {
  9. chunks.push({ index: cur, file: file.slice(cur, cur + size) });
  10. cur += size;
  11. }
  12. return chunks;
  13. }

对文件切片后可以得到如下结构的数组:
image.png

我们得到了一个blob对象的数组,下一步最简单的做法,便利数组,将blob塞入formdata然后传给后端。但是这种做法是存在隐患的。因为我们无法知道文件是否上传完成,以及每次上传成功或者失败后该做些什么。而hash值就是用来判断文件是否上传完成的,除此之外,我们还需要在formdata放入标识来方便后端进行文件合并。

  1. this.chunks = chunks.map((chunk, index) => {
  2. // 每一个切片的名字
  3. const chunkName = this.hash + '-' + index
  4. return {
  5. hash: this.hash,
  6. chunk: chunk.file,
  7. name: chunkName,
  8. index,
  9. // 设置进度条
  10. progress: uploadedList.indexOf(chunkName) > -1 ? 100 : 0,
  11. }
  12. })

2、计算文件的hash值

hash计算有三种方式:

web-worker

通过 Worker对象,配合spark-md5.js插件来实现
**

时间切片(fiber原理)

  1. 浏览器的每一帧都有空闲时间,流畅的网页60fps116.6ms<br /> |渲染绘制16.6ms|更新UI16.6ms|动画16.6ms|16.6ms|<br /> 同步任务,比如计算md5远大于16.6<br /> 我们可以利用空闲时间来计算,一旦有优先级更高的同步任务,返回浏览器控制权,等待下一次空闲

抽样hash

MD5的运算量是很大的,一般采用抽样hash。

算完hash值后,和后端交互,通过hash值作为唯一标识,来看文件是否已经上传完毕。

3、并发量控制

参考文档:https://github.com/shengxinjing/file-upload