前端大文件上传解决方案 --- 分片上传

3,913 阅读5分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第2天,点击查看活动详情

俗话(一个俗人说的)说的好:白面馒头是大的好, 上传文件是小的好!可是在前端总要遇到那么几次上传大文件的时候,这可怎么办?我来给你解决方案!

图片

文件上传功能

我们先看一个antd的文件上传的案例

import React from 'react';
import 'antd/dist/antd.css';
import './index.css';
import { UploadOutlined } from '@ant-design/icons';
import { Button, message, Upload } from 'antd';

const props = {
 name'file',
 action'https://www.mocky.io/v2/5cc8019d300000980a055e76',
 headers: {
   authorization'authorization-text',
},

 onChange(info) {
   if (info.file.status !== 'uploading') {
     console.log(info.file, info.fileList);
  }

   if (info.file.status === 'done') {
     message.success(`${info.file.name} file uploaded successfully`);
  } else if (info.file.status === 'error') {
     message.error(`${info.file.name} file upload failed.`);
  }
 },
};

const App = () => (
 <Upload {...props}>
   <Button icon={<UploadOutlined />}>Click to Upload</Button>
 </Upload>
);

export default App;

图片

图片

我们可以发现,文件的上传只进行了一次请求。 

前端在处理文件上传时,通常也是一次性发送到server端,如果遇到大文件的时候,xhr请求会处理很长时间,这就大大增加了失败的概率,通常我们会将大文件切片然后发送,也就是说将一个大文件的上传问题转化为多个小文件上传的问题。   


文件类型

先上一段简单的单文件上传和两个类型定义

/** A file-like object of immutable, raw data.Blobs represent data that isn't necessarily in a JavaScript-native format. The File interface is based on Blob, inheriting blob functionality and expanding it to support files on the user's system. */
interface Blob {
   readonly sizenumber;
   readonly typestring;
   arrayBuffer(): Promise<ArrayBuffer>; // 字节数组
   slice(start?: number, end?: number, contentType?: string): Blob// 切片的核心
   stream(): ReadableStream// 返回ReadableStream对象(包含getReader())Blob 的内容
   text(): Promise<string>; // promise中返回 USVString 基本为 UTF-8 的blob字符串数据接近FileReader 的 readAsText() 
}

/** Provides information about files and allows JavaScript in a web page to access their content. */
interface File extends Blob {
   readonly lastModifiednumber;
   readonly namestring;
}  
const upload = async (file: File) => {
     if (!file) {
       return;
    }
     const formData = new FormData();
     formData.append('file', file);
     formData.append('hash''true');
  const res = await fileUpload(formData);
}

通过定义我们知道, Blob是一个不可变、存储文件原数据的一个类文件,但其并非是JS的原生数据,而 File继承于 Blob,使得 Blob信息扩展为用户操作系统可支持的文件,并使得页面里可以使用 Javascript访问其文件信息。

分片上传

切片

下面我们来对文件进行切片操作

/**
  * 文件切片
  * @param {File} file 切片文件
  * @param {number} pieceSize 切片大小
  * @param {string} fileKey 文件唯一标识
  */
 const getSliceFile = async (file: File, pieceSizes = 50, fileKey: string) => {
   const piece = 1024 * 1024 * pieceSizes;
   // 文件总大小
   const totalSize = file.size;
   const fileName = file.name;
   // 每次上传的开始字节
   let start = 0;
   let index = 1;
   // 每次上传的结尾字节
   let end = start + piece;
   const chunks = [];
   while (start < totalSize) {
     const current = Math.min(end, totalSize);
     // 根据长度截取每次需要上传的数据
     // File对象继承自Blob对象,因此包含slice方法
     const blob = file.slice(start, current);
     const hash = (await getHash(blob)) as string;

     // 特殊处理,对接阿里云大文件上传
     chunks.push({
       file: blob,
       size: totalSize,
       index,
       fileSizeInByte: totalSize,
       name: fileName,
       fileName,
       hash,
       sliceSizeInByte: blob.size,
       fileKey,
    });
     start = current;
     end = start + piece;
     index += 1;
  }
   return chunks;
};

Promise.all

文件被分成若干块后,需要确保每一块儿都上传成功,也就是若干请求都成功,首先想到了Promise.all。

// 获取promise数组  
const getTasks = (
   files: FileInfo[],
   uploadId: string,
   fileKey: string,
   finish: number[],
): Promise<CommonResponse_LargeFileUploadResponse_>[] => {
   const tasks: Promise<CommonResponse_LargeFileUploadResponse_>[] = [];
   const currentTaskIndex: number[] = [];
   files.forEach((chunk: FileInfo) => {
     if (finish.includes(chunk.index)) {
       return;
    }
     currentTaskIndex.push(chunk.index);
     const formData = new FormData();
     formData.append('file', chunk.file);
     // @ts-ignore
     formData.append('sliceIndex', chunk.index);
     formData.append('hash', chunk.hash);
     formData.append('uploadId', uploadId);
     // @ts-ignore
     formData.append('fileSizeInByte', chunk.sliceSizeInByte);
     tasks.push(sliceUpload(formData));
  });
   return tasks;
};

浏览器连接数瓶颈

但是这样操作我们就会看到如下图这种结果,先说说下图的起因一次将所有的分片发出去,由于浏览器对同一个域名连接数量有限制(如:chrome是6个连接,各个浏览器版本和HTTP协议版本的连接数有些许差距,但是大部分都在6个左右,原则上是不超过10个连接数),这导致大量请求处于pending状态(也就是排队,hold在了浏览器,没有发出去),后面的请求可能因为排队而超时(超时的请求浏览会自动cancel了),只能将请求的超时时间设置的长一些(但是这个时间不好确定);而且还会阻塞了同域下的别的请求,这可能导致页面不能响应UI交互了。

基于上述问题,不能一次将请求全部发出去,那么需要确定什么时候发请求并且需要知道文件什么时候能全部上传完毕。可以通过执行栈或者队列的方式,一次往栈或者队列加入不超过3个的执行单元,再通过状态请求接口轮询或者Promise.allSettled()获取对应promise异步请求的结果来更新栈或者队列,具体操作这里不再赘述。

看效果

图片

性能优化 Web Workers

大家都知道js是单线程,但是如果有多线程的方案那么对性能的影响将是巨大的,而不阻塞主线程之后体验上也会有极大改善。一个 workers 是使用一个构造函数创建的一个对象(e.g. Worker()) 运行一个命名的 JavaScript 文件 - 这个文件包含将在工作线程中运行的代码; workers 运行在另一个全局上下文中,不同于当前的window. 因此,在 Worker 内通过 window 获取全局作用域 和 DOM 将返回错误。所以你可以理解为 worker 就是新建了一个私有的有别于 window 主线程的工作线程。因为 workers 实体是js脚本,想使用的同学可以去了解下worker-plugin,可以防止babel转换附带进额外信息导致 workers 脚本失效。

// 创建Workers
const myWorker = new Worker('worker.js');
// 界面发送消息给myWorker
input1.onchange = function() {
 myWorker.postMessage([input1.value,input2.value]);
 console.log('Message posted to worker');
}
input2.onchange = function() {
 myWorker.postMessage([input1.value,input2.value]);
 console.log('Message posted to worker');
}
// 相应workers的返回
myWorker.onmessage = function(e) {
 result.textContent = e.data;
 console.log('Message received from worker');
}



// worker.js 获取两个参数并且求和返回
onmessage = function(e) {
 console.log('Message received from main script');
 var workerResult = 'Result: ' + (e.data[0] * e.data[1]);
 console.log('Posting message back to main script');
 postMessage(workerResult);
}

通过上面例子其实我们可以看出workers的本质是运行脚本js,既然是资源那么是在client端的,所以这里就要注意同源跨域的问题,同时意味着web Workers是可以被多个window等共享使用的,也存在 SharedWorker 但是共享workers的端口配置等就会复杂不少。官方现在好像还没有 <script> 脚本的形式引入web workers的js, 但是可以通过 <script type="text/js-worker"> 嵌入而这种方式其实就是比较常见的数据块的形式,而对于数据块可以像下面一样将函数数据化,而返回的带hash的url, 前端的不通过后端下载指定数据都可以通过下面类似的方式获取下载的url。

function fn2workerURL(fn) {
 var blob = new Blob(['('+fn.toString()+')()'], {type'application/javascript'})
 return URL.createObjectURL(blob)
}

想尝试的同学可以尝试本地谷歌打开这个HTML会收到 Received: Hello World!

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>MDN Example - Embedded worker</title>
<script type="text/js-worker">
// 该脚本不会被 JS 引擎解析,因为它的 mime-type 是 text/js-worker。
var myVar = "Hello World!";
// 剩下的 worker 代码写到这里。
</script>
<script type="text/javascript">
 // 该脚本会被 JS 引擎解析,因为它的 mime-type 是 text/javascript。
 function pageLog (sMsg) {
   // 使用 fragment:这样浏览器只会进行一次渲染/重排。
   var oFragm = document.createDocumentFragment();
   oFragm.appendChild(document.createTextNode(sMsg));
   oFragm.appendChild(document.createElement("br"));
   document.querySelector("#logDisplay").appendChild(oFragm);
}
</script>
<script type="text/js-worker">
// 该脚本不会被 JS 引擎解析,因为它的 mime-type 是 text/js-worker。
onmessage = function (oEvent) {
  postMessage(myVar);
};
// 剩下的 worker 代码写到这里。
</script>
<script type="text/javascript">
 // 该脚本会被 JS 引擎解析,因为它的 mime-type 是 text/javascript。

 // 在过去...:
 // 我们使用 blob builder
 // ...但是现在我们使用 Blob...:
 var blob = new Blob(Array.prototype.map.call(document.querySelectorAll("script[type="text/js-worker"]"), function (oScript) { return oScript.textContent; }),{type"text/javascript"});

 // 创建一个新的 document.worker 属性,包含所有 "text/js-worker" 脚本。
 document.worker = new Worker(window.URL.createObjectURL(blob));

 document.worker.onmessage = function (oEvent) {
   pageLog("Received: " + oEvent.data);
};

 // 启动 worker.
 window.onload = function() { document.worker.postMessage(""); };
</script>
</head>
<body><div id="logDisplay"></div></body>
</html>

小伙伴们你们知道怎么办了吗?赶紧Get下来,自己试一试吧!

- End -