谈谈前端大文件分片上传

10,893 阅读7分钟

前端上传文件方式

form表单 + iframe

原理:通过form表单提交文件,需要把 form 标签的enctype设置为multipart/form-data,同时method必须为post方法。

enctype属性

  • application/x-www-form-urlencoded 在发送前编码所有字符(默认)
  • multipart/form-data 不对字符编码,在文件上传时必须使用该值
  • text/plain 空格转换为 "+" 加号,但不对特殊字符编码。
 <form action="/formUpload" enctype="multipart/form-data" method="post" target="upload">
   <input type="file" name="file" />
   <input type="submit" value="上传文件">
</form>
<iframe name='upload' id='uploader' style="display: none;"></iframe>

Ajax + formData

通过formData来携带文件内容,ajax异步上传文件。

 const formData = new FormData()
 formData.append("file", file);
  $.ajax({
    url:'/formUpload',
    type:'POST',
    data:formData,
    processData:false,   //  告诉jquery不要处理发送的数据
    contentType:false,   // 告诉jquery不要设置content-Type请求头
    succuss:() =>{},
    error:() =>{}
  }) 

Blob

Blob ****对象表示一个不可变、原始数据的类文件对象。它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream 来用于数据操作。

File接口基于Blob,继承了 blob 的功能并将其扩展使其支持用户系统上的文件。

Blob.size

Blob对象中所包含数据的大小(字节)。

Blob.type

一个字符串,表明该 Blob 对象所包含数据的 MIME 类型(image/png、text/html等等)。如果类型未知,则该值为空字符串。

Blob.slice()

返回一个新的 Blob 对象,包含了源 Blob 对象中指定范围内的数据。和数组的slice方法类似,截取数组的内容,大文件切片就是基于此方法来切分。

详情见 Blob

Worker

Worker 接口是 Web Workers API的一部分,指的是一种可由脚本创建的后台任务,任务执行中可以向其创建者收发信息。要创建一个 Worker 只须调用 Worker(URL) 构造函数,函数参数 URL 为指定的脚本,主要用来处理一些非常耗时的任务。

除了worker还有其他的,比如Shared Workers、Service Workers 、音频Workers等等。

Worker()

创建一个专用Web worker,它只执行URL指定的脚本。使用 Blob URL 作为参数亦可。

const w = new Worker('./test-worker.js')

Worker.onmessage()

接收来自最近的外层对象的数据。

w.onmessage = event => {
  console.log(event.data)
}

Worker.postMessage()

发送一条消息到最近的外层对象,消息可由任何 JavaScript 对象组成。

self.postMessage('666')

self.importScripts()

其他线程导入js

详细见Worker

阮一峰教程web-worker

文件切片

  • 为什么要切片?

一个文件过大,上传会非常的慢,而且还可能会中途失败导致前功尽弃。要重新上传文件,非常影响用户体验。文件切片以后,可以并发上传,速度比之前更快。而且如果中途失败可以做到不用上传已经上传过的。

  • 对文件如何切片?

通过Blob.prototype.slice方法即可对文件进行切分,分片尽量不要太大,一般最大50M即可。

  • 相关实现如下
const maxChunkSize = 52428800  // 最大容量块
const chunkSum = Math.ceil(file?.size / maxChunkSize)
                       
export const createFileChunks = async ({file, chunkSum, setProgress}) => {
  const fileChunkList = [];
  const chunkSize = Math.ceil(file?.size / chunkSum);
  let start = 0;
  for (let i = 0; i < chunkSum; i++) {
    const end = start + chunkSize;
    fileChunkList.push({
      index: i,
      filename: file?.name,
      file: file.slice(start, end)
    });
    start = end;
  }
  const result = await getFileHash({chunks: fileChunkList, setProgress});
  fileChunkList.map((item, index) => {
    item.key = result;
  });
  return fileChunkList;
};

计算文件hash

  • 为什么要计算文件的hash?

通过对文件的内容进行一些算法加密运算得出文件的hash,文件的内容和hash是一一对应的。当我们修改文件内容时,hash就会变化。我们通过hash来判断是否是同一个文件。通过判别文件hash,可以知道哪些文件或者文件切片的已经上传完成。

  • 如何计算文件的hash?

首先,我们上传的是一个大文件,所以计算文件的hash是非常的耗时的。我们都知道JS是单线程的,如果用来计算这么耗时的任务肯定是不妥的。解决方法有两个,如下

1、 通过Worker模拟JS多线程来处理耗时任务。

self.importScripts("https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.0/spark-md5.min.js");
self.onmessage = event => {
  console.log('worker接收的数据:',event.data)
  const chunks = event.data
  const spark = new self.SparkMD5.ArrayBuffer();
  const appendToSpark = async file => new Promise(resolve => {
      const reader = new FileReader();
      reader.readAsArrayBuffer(file);
      reader.onload = e => {
        console.log(e.target.result)
        spark.append(e.target.result);
        resolve();
      };
    });
  let count = 0;
  const workLoop = async () => {
    while (count < chunks.length) {
      await appendToSpark(chunks[count].file);
      count++;
      if (count >= chunks.length){
        self.postMessage(spark.end())
      } 
    }
  };
  workLoop()
}

2、 在浏览器的空闲时间去计算文件的hash。

  • 具体实现如下
export const getFileHash = async ({chunks, setProgress}) => new Promise(resolve => {
    const spark = new sparkMD5.ArrayBuffer();
    const appendToSpark = async file => new Promise(resolve => {
        const reader = new FileReader();
        reader.readAsArrayBuffer(file);
        reader.onload = e => {
          spark.append(e.target.result);
          resolve();
        };
      });
    let count = 0;
    const workLoop = async deadline => {
       // 块数没有计算完,并且当前帧还没结束
      while (count < chunks.length && deadline.timeRemaining() > 1) {
        await appendToSpark(chunks[count].file);
        count++;
        setProgress(Number(((100 * count) / chunks.length).toFixed(2)));
        if (count >= chunks.length) resolve(spark.end());
      }
      window.requestIdleCallback(workLoop);
    };
    window.requestIdleCallback(workLoop);
  });
  • 如何快速计算文件hash?

计算文件md5值的作用,无非就是为了判定文件是否存在,我们可以考虑设计一个抽样的hash,牺牲一些命中率的同时,提升效率,设计思路如下

  1. 文件切成2M的切片
  2. 第一个和最后一个切片全部内容,其他切片的取 首中尾三个地方各2个字节
  1. 合并后的内容,计算md5,称之为影分身Hash
  2. 这个hash的结果,有小概率误判。如果被抽样的样本都相同,而未被抽样的内容不同则会造成误判。

 function createFileChunks({ file,setProgress }){
      const fileChunkList = [];
      const chunkSum = Math.ceil(file?.size / maxChunkSize)
      const chunkSize = Math.ceil(file?.size / chunkSum);
      let start = 0;
      for (let i = 0; i < chunkSum; i++) {
        const end = start + chunkSize;
        if(i == 0 || i == chunkSum - 1) {
          fileChunkList.push({
            index: i,
            filename: file?.name,
            file: file.slice(start, end),
          });
        } else {
          fileChunkList.push({
            index: i,
            filename: file?.name,
            file:sample(file,start,end),
          });
        }
        start = end;
      }
      return fileChunkList;
    };
		// 抽样函数
    function sample(file,start,end) {
      const pre = file.slice(start, start + 1024 * 2)
      const after = file.slice(end - 1024 * 2, end)
      const merge = new Blob([pre, after])
      return merge
    }

requestIdleCallback

window.requestIdleCallback()方法插入一个函数,这个函数将在浏览器空闲时期被调用。

页面是一帧一帧绘制出来的,当每秒绘制的帧数(FPS)达到 60 时,页面是流畅的,小于这个值时,用户会感觉到卡顿。1s 60帧,所以每一帧分到的时间是 1000/60 ≈ 16 ms。

  • 浏览器一帧都做了啥?
  1. 处理用户的交互
  2. JS 解析执行
  3. 帧开始。窗口尺寸变更,页面滚去等的处理
  4. requestAnimationFrame(rAF)
  5. 布局
  6. 绘制


就是说requestIdleCallback()会在一帧16ms中的最后执行,等到页面绘制完成以后看是否有空闲时间执行。如果有就执行,否则不执行。因为DOM已经绘制完成,所以不要在requestIdleCallback里面去操作DOM,这样容易引起会导致重新计算布局和视图的绘制,操作DOM建议在requestAnimationFrame中。

var handle = window.requestIdleCallback(callback[, options])
const cb = ({didTimeout, timeRemaining()}) => {}
const op = {timeout: 3000}
  • didTimeout 任务是否超时
  • timeRemaining() 当前帧剩余的时间
  • timeout 超时强制执行

详细见requestIdleCallback

requestAnimationFrame

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。

requestAnimationFrame 方法不同与 setTimeout 或 setInterval,它是由系统来决定回调函数的执行时机的,会请求浏览器在下一次重新渲染之前执行回调函数。无论设备的刷新率是多少,requestAnimationFrame 的时间间隔都会紧跟屏幕刷新一次所需要的时间;

setTimeout 做动画可能会卡顿,因为js是单线程的,如果前面的任务阻塞了,那么setTimeout 就会等之前的任务执行完成在执行,造成卡顿。

详细见requestAnimationFrame

并发上传

通过Promise.all()可以并发上传所有的切片,并发上传所有切片可能会导致网络请求被全部占用,而且这样上传的成功率也会大打折扣。控制切片上传的并发数来解决此问题。

1、通过mapLimit来控制并发数

详情见链接

npm install --save async
import mapLimit from 'async/mapLimit';
export const uploadChunks = ({
  chunks,
  url,
  chunkSum,
  limit,
  setProgress
}) => new Promise((resolve, reject) => {
    mapLimit(
      chunks,
      limit,
      ({index, key, filename, file}, cb) => {
        const formData = new FormData();
        formData.append('slice', index);
        formData.append('guid', key);
        formData.append('slices', chunkSum);
        formData.append('filename', filename);
        formData.append('file', file);
        request({
          url,
          data: formData,
          onProgress: e => {
            setProgress(parseInt(String((e.loaded / e.total) * 100)), index);
          },
          cb
        });
      },
      (err, result) => {
        if (err) {
          Message.error('文件上传出错,请检查以后重新上传')
          reject(err)
          return
        }
        resolve(result)
      }
    )
  });

切片上传进度

切片的上传进度是通过XMLHttpRequest的upload.onprogress事件去监听。

export const request = ({
  url,
  method = 'post',
  data,
  headers = {},
  cb = e => e,
  onProgress = e => e
}) => {
    const xhr = new XMLHttpRequest();
    xhr.withCredentials = true
    xhr.timeout = 1000 * 60 * 60
    xhr.upload.onprogress = onProgress;
    xhr.open(method, url);
    headers = Object.assign({}, headers, {'EDR-Token': Cookie.get('auth')})
    Object.keys(headers).forEach(key => xhr.setRequestHeader(key, headers[key])
    );
    xhr.send(data);
    xhr.onload = e => {
      cb(null, {
        data: JSON.parse(e.target.response)
      });
    };
    xhr.onerror = error => {
      cb(error)
    }
  }

切片合并与断点续传

当所有的切片都上传成功之后,我们就会向后端发起合并切片的请求。

如果切片上传到一半就发生了故障或者无网络了怎么办?要重新上传所有的切片吗?

不用,我们只要上传我们还没上传的切片就好。通过localStorage记录切片上传的信息或者每次在上传切片前向后台询问该切片是否已经上传。具体流程如下

总结

  1. 计算文件的hash是为了判断文件的唯一性,通过hash也可以判断文件是否已经上传成功。
  2. 文件的切片是通过Blob.prototype.slice去切分文件,切片是否上传成功的判断可以在服务的处理也可以在前端保留已上传切片的信息。
  1. 计算文件的hash是一个非常耗时的任务,可以通过worker模拟多线程去处理,或者在浏览器的空闲时间requestIdleCallback去处理这个耗时的任务。为了快速的计算出文件的hash,也可以通过抽样的方式。
  2. 文件的实时上传进度可以通过XMLHttpRequest的upload.onprogress去处理,也可以通过websocket去实时监听。
  1. 所有切片都上传成功时,发起合并请求,让所有的切片合并成一个大文件。