前端在处理文件上传时,通常一次性发送到server端,如果遇到大文件的时候,xhr请求会处理很长时间,这就大大增加了失败的概率,通常我们会将大文件切片然后发送。本文将循序渐进地聊聊,如何进行分片文件上传、文件分片的原理、如何解放js主进程。
1. 文件分片
通过input标签得到一个FileList(设置了multiple)或者File,其中每一个item(可以通过FileList.item(index)或者FileList[index])是一个File,而File继承于Blob,那么就可以使用Blob的方法来处理文件了。item的内容如下:
看看文件各个字段含义:
使用slice方法可以将文件切成若干相等的块儿(最后一块儿可能小于其他块的大小,如:s3对分片要求最小的分片是5M,如果最后一块儿小于5M时需要合并到倒数第二块儿中),slice来自于file.__proto__。分片代码如下:
const FILE_PER_PICE_SIZE = 1024 * 1024 * 5;
const splitFile = (file, pieceSize) => {
let start = 0;
let end;
let index = 0;
const { size = 0 } = file || {};
if (pieceSize < FILE_PER_PICE_SIZE) {
pieceSize = FILE_PER_PICE_SIZE;
}
const totalPieces = Math.floor(size / pieceSize);
const chucks = [];
while (start < size) {
end = start + pieceSize;
if (end > size) {
end = size;
}
if (index === totalPieces - 1) {
chucks.push({ chuck: file.slice(start), index: index + 1 });
break;
} else {
chucks.push({ chuck: file.slice(start, end), index: index + 1 });
start = end;
index++;
}
}
return {
total: chucks.length,
chucks: chucks.length === 1 ? [file] : chucks,
};
};
2. 分片上传
文件被分成若干块后,需要确保每一块儿都上传成功,也就是若干请求都成功,首先想到了Promise.all。
const upload = (fileObj) => {
const { total, chucks, name } = fileObj;
if (total) {
setLoading(true);
const type = (name && name.split(".").pop()) || "";
const reqList = [];
getMultiKey(type || "video", name.replace(/\s/g, ""))
.then((res) => {
const { id, key } = res || {};
Promise.all(
chucks.map((item, i) => {
const { chuck, index } = item || {};
const formData = new FormData();
formData.append("Body", chuck);
formData.append("PartNumber", index);
formData.append("Key", key);
formData.append("UploadId", id);
return upload(formData, i, reqList);
})
)
.then((list) => {
checkUploadStatus({
parts: list,
id,
key,
}).then((url) => {
saveVideoInfo(url);
setLoading(false);
});
})
.catch(() => {
reqList.forEach((req) => {
req._xhr.abort();
});
message.destroy();
message.error("上传失败,请重试!");
cancelUpload({ Key: key, UploadId: id });
setLoading(false);
});
})
.catch(() => {
setLoading(false);
});
}
};
请求过程中network面板如下:
一次将所有的分片发出去,由于浏览器对同一个域名连接数量有限制(如:chrome是6个连接),这导致大量请求处于pending状态(也就是排队,hold在了浏览器,没有发出去),后面的请求可能因为排队而超时(超时的请求浏览会自动cancel了),只能将请求的超时时间设置的长一些(但是这个时间不好确定);而且还会阻塞了同域下的别的请求,这可能导致页面不能响应UI交互了。
基于上述问题,不能一次将请求全部发出去,那么需要确定什么时候发请求并且需要知道文件什么时候能全部上传完毕。可以使用发布订阅模式,一次发出一定数量的分片,当收到响应后,再逐一发送剩余的分片。发布订阅模式有很多实际应用,这里不再赘述。
我将使用Proxy来实现这一功能,代码如下(不涉及取消和重试的过程):
const createUploaderProxy = (cb) => {
const tmp = Object.create(null);
tmp.failed = []; // 用来存储失败的分片,重试的时候使用
tmp.done = []; // 用来存储成功的分片标识
tmp.pieceList = []; // 用来存储待发送的分片
tmp.multiConfig = Object.create(null);
tmp.total = 0;
return new Proxy(
{ ...tmp },
{
set(target, prop, value, receiver) {
if (prop === "done" || prop === "failed") {
if (Array.isArray(value) && !value.length) {
target[prop] = value;
return true;
}
target[prop].push(value);
if (target.pieceList.length) {
const next = target.pieceList.shift();
uploader.singlePiece(next, target.multiConfig, receiver);
return true;
}
if (target.failed.length + target.done.length === target.total) {
const fName = target.name;
if (target.done.length === target.total) {
checkUploadStatus(target.done, target.multiConfig)
.then((url) => {
cb(url, true); // 所有分片上传成功
uploader.files[fName] = url;
})
.catch(() => {
cb(fName, false); // 分片上传成功,但是获取文件在服务器上的地址失败
});
} else {
cb(fName, false); // 有分片上传失败
}
}
return true;
}
target[prop] = value;
return true;
},
}
);
};
const uploader = Object.create(null);
uploader.files = Object.create(null);
uploader.load = (
file,
config = {
accepts: ["video/mp4", "video/ogg", "video/webm", "video/quicktime"],
pieceSize: 1024 * 1024 * 5,
waterFlow: 6,
},
cb = () => {}
) => {
let { name } = file;
const fileUploader = createUploaderProxy(cb);
uploader.files[name] = {
config,
cb,
uploader: fileUploader,
};
};
uploader.singlePiece = (piece, fConfig, fileUploader) => {
upload(piece, fConfig)
.then((ret) => {
fileUploader.done = ret;
})
.catch(() => {
fileUploader.failed = piece;
});
}
由上图可知,处于pending状态的请求数量一直是6个,不仅避免了排队超时的情况,同时还是释放浏览器资源。
我们知道文件上传的过程中,不涉及页面的UI交互的,那么是不是可以在另一个单独的进程中处理呢?而web worker能够独立于主进程运行。
3. web worker
切记web worker 不能访问dom和window,但是可以访问location,还有同源的限制。
web worker的兼容性还不错,接下来将使用web worker来实现上述Proxy版本:
/**
* worker.js
*/
const createUploaderProxy = () => {
const tmp = Object.create(null);
tmp.failed = [];
tmp.done = [];
tmp.pieceList = [];
tmp.multiConfig = Object.create(null);
tmp.total = 0;
return new Proxy(
{ ...tmp },
{
set(target, prop, value, receiver) {
if (prop === "done" || prop === "failed") {
if (Array.isArray(value) && !value.length) {
target[prop] = value;
return true;
}
target[prop].push(value);
if (target.pieceList.length) {
const next = target.pieceList.shift();
uploader.singlePiece(next, target.multiConfig, receiver);
return true;
}
if (target.failed.length + target.done.length === target.total) {
const fName = target.name;
if (target.done.length === target.total) {
checkUploadStatus(target.done, target.multiConfig)
.then((url) => {
// 成功的时候用postMessage通知主进程
postMessage({
url,
success: true,
});
uploader.files[fName] = url;
})
.catch(() => {
// 失败的时候用postMessage通知主进程
postMessage({
name: fName,
success: false,
});
});
} else {
// 失败的时候用postMessage通知主进程
postMessage({
name: fName,
success: false,
});
}
}
return true;
}
target[prop] = value;
return true;
},
}
);
};
const uploader = Object.create(null);
uploader.files = Object.create(null);
uploader.load = (
file,
config = {
accepts: ["video/mp4", "video/ogg", "video/webm", "video/quicktime"],
pieceSize: 1024 * 1024 * 5,
waterFlow: 6,
}
) => {
let { name } = file;
const fileUploader = createUploaderProxy();
uploader.files[name] = {
config,
cb,
uploader: fileUploader,
};
};
uploader.singlePiece = (piece, fConfig, fileUploader) => {
upload(piece, fConfig)
.then((ret) => {
fileUploader.done = ret;
})
.catch(() => {
fileUploader.failed = piece;
});
};
onmessage = (event) => {
const { data: { isFile, file } = {} } = event;
uploader.load(file);
};
/**
* index.js
*/
if (window.Worker) {
// 创建worker
window.uploadWorker = new Worker("./worker.js");
// 监听worker响应结果
window.uploadWorker.onmessage = (event) => {
console.log(event.data);
};
}
// input的onchange事件
const onChange = (event) => {
window.uploadWorker.postMessage({ file, isFile: true });
};
使用worker后network面板如下:
由上图可知,请求都是从worker中发出的,不占用主进程。
由于worker有同源的限制,而我们为提高页面载入速度,一般会将静态资源放到cdn上,这样页面路径和静态资源路径就不同源了,所以需要将worker打包生成独立文件,并copy到服务器上。如果使用webpack打包,经过babel转换后带有浏览器的一些信息,这样就不能作为worker了,需要作为worker打包,推荐使用worker-plugin。
// 需要在index文件中增加如下代码
/**
* index.js
*/
import "worker-plugin/loader?name=upload!./uploadWorker.js";
使用worker-plugin后,打包产物会增加:
如果项目中含有nodejs,本地开发的时候需要能够访问到server中的worker,通过worker是放在client端,为方便调试,可以将client中的worker link到 server 端:
ln client/src/pages/videoUpload/uploadWorker.js /server/src/static/worker.js
4. 总结
由于大文件上传存在很多分片,这些分片如果一次性发出,会存在请求队列长时间排队的情况。如果网络环境不佳,队列中排队的请求有很大的概率因排队时间过长而超时。我们使用proxy的方式控制请求时机,从而避免这种超时情况,同时给予同域其他请求及时发出的时机。我们知道js是单线程,在该线程上要执行js、交互响应处理、异步请求、页面渲染等等工作,如果某一环节长期占用线程的话,就不能及时响应页面交互了,影响了用户体验。通过将文件分片、发送文件分片、处理响应、失败重发或取消这些操作放入web worker中执行,就能极大程度上减轻主线程的task量。
5. 参考
File
https://developer.mozilla.org/en-US/docs/Web/API/File
Blob
https://developer.mozilla.org/en-US/docs/Web/API/Blob
Promise
https://es6.ruanyifeng.com/#docs/promise
proxy
https://es6.ruanyifeng.com/#docs/proxy
web worker
http://www.ruanyifeng.com/blog/2018/07/web-worker.html
Off The Main Thread
https://css-tricks.com/off-the-main-thread/
worker-plugin
https://github.com/GoogleChromeLabs/worker-plugin