Vue 3 中用组合式函数和 Shared Worker 实现后台分片上传(带哈希计算)
01. 背景
最近项目需求里有个文件上传功能,而客户需求里的文件基本上是比较大的,基本上得有 1 GiB 以上的大小,而上传大文件尤其是读大文件,可能会造成卡 UI 或者说点不动的问题。而用后台的 Worker 去实现是一个比较不错的解决办法。
02. 原理讲解
02.01. Shared Worker
Shared Worker 的好处是可以从几个浏览上下文中访问,例如几个窗口、iframe 或其他 worker。这样我们可以保证全局的页面上传任务都在我们的控制之下,甚至可以防止重复提交等功能。
02.02. 组合式函数
组合式函数的好处是在 Vue 3 是可以在任何 *.vue
文件中使用,并且是响应式方法,可以侦听 pinia 内 token 等的变化,传递给 Worker
02.03 简单流程设计
id1[用户选择文件] --> id2[创建上传任务]
id2 --> id3[任务推送到 Worker]
id3 --> id4[上传到服务器]
id4 --> id5[Worker 返回任务状态]
id5 --> id6[组合式函数拦截状态放到 Map 里]
03. 代码
upload-worker.ts
代码
import { sha256 } from '@noble/hashes/sha256';
import { bytesToHex as toHex } from '@noble/hashes/utils';
interface SharedWorkerGlobalScope {
onconnect: (event: MessageEvent<any>) => void;
}
const _self: SharedWorkerGlobalScope = self as any;
/**
* 分片大小
*/
const pieceSize = 1024 * 1024;
/**
* 消息参数
*/
interface MessageArg<T> {
/**
* 函数名
*/
func: string;
/**
* 参数
*/
arg: T;
}
/**
* 上传任务信息
*/
interface UploadTaskInfo {
/**
* 文件名
*/
fileName: string;
/**
* 上传路径
*/
uploadPath: string;
/**
* 任务 id
*/
id: string;
/**
* 文件大小
*/
size: number;
/**
* 上传进度
*/
progress: number;
/**
* 上传速度
*/
speed?: number;
/**
* 任务状态
*/
status: 'uploading' | 'paused' | 'canceled' | 'done' | 'error' | 'waiting';
/**
* 开始时间
*/
startTime?: Date;
/**
* 结束时间
*/
endTime?: Date;
/**
* 错误信息
*/
errorMessage?: string;
}
/**
* 上传任务
*/
interface UploadTask extends UploadTaskInfo {
file: File;
pieces: Array<boolean>;
abort?: AbortController;
}
/**
* 任务/哈希值映射
*/
const hashs = new Map();
/**
* 上传任务列表
*/
const uploadTasks: Array<UploadTask> = [];
/**
* 状态接收器
*/
const statusReceivers = new Map<string, MessagePort>();
/**
* token 仓库
*/
const tokenStore = {
/**
* token
*/
BearerToken: '',
};
/**
* 返回上传状态
* @param task 上传任务
*/
const updateStatus = (task: UploadTaskInfo) => {
const taskInfo: UploadTaskInfo = {
fileName: task.fileName,
uploadPath: task.uploadPath,
id: task.id,
size: task.size,
progress: task.progress,
speed: task.speed,
status: task.status,
startTime: task.startTime,
endTime: task.endTime,
errorMessage: task.errorMessage,
};
statusReceivers.forEach((item) => {
item.postMessage(taskInfo);
});
};
/**
* 运行上传任务
* @param task 上传任务
*/
const runUpload = async (task: UploadTask) => {
task.status = 'uploading';
const hash = hashs.get(task.id) || sha256.create();
hashs.set(task.id, hash);
let retryCount = 0;
const abort = new AbortController();
task.abort = abort;
while (task.status === 'uploading') {
const startTime = Date.now();
const index = task.pieces.findIndex((item) => !item);
if (index === -1) {
try {
const response: { code: number; message: string } = await fetch(
'/api/File/Upload',
{
method: 'PUT',
headers: {
Authorization: tokenStore.BearerToken,
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: task.id,
fileHash: toHex(hash.digest()),
filePath: task.uploadPath,
}),
}
).then((res) => res.json());
if (response.code !== 200) {
throw new Error(response.message);
}
task.status = 'done';
task.endTime = new Date();
updateStatus(task);
} catch (e: any) {
task.status = 'error';
task.errorMessage = e.toString();
task.endTime = new Date();
deleteUpload(task.id);
updateStatus(task);
}
break;
}
const start = index * pieceSize;
const end = start + pieceSize >= task.size ? task.size : start + pieceSize;
const buffer = task.file.slice(index * pieceSize, end);
hash.update(new Uint8Array(await buffer.arrayBuffer()));
const form = new FormData();
form.append('file', buffer);
let isTimeout = false;
try {
const timer = setTimeout(() => {
isTimeout = true;
abort.abort();
}, 8000);
const response: { code: number; message: string } = await fetch(
`/api/File/Upload?id=${task.id}&offset=${start}`,
{
method: 'POST',
body: form,
headers: {
Authorization: tokenStore.BearerToken,
},
signal: abort.signal,
}
).then((res) => res.json());
clearTimeout(timer);
if (response.code !== 200) {
throw new Error(response.message);
}
task.pieces[index] = true;
task.progress =
task.pieces.filter((item) => item).length / task.pieces.length;
task.speed = (pieceSize / (Date.now() - startTime)) * 1000;
updateStatus(task);
} catch (e: any) {
retryCount++;
if (retryCount > 3) {
task.status = 'error';
if (isTimeout) {
task.errorMessage = 'UploadTimeout';
} else {
task.errorMessage = e.toString();
}
task.endTime = new Date();
deleteUpload(task.id);
updateStatus(task);
}
}
runNextUpload();
}
};
/**
* 运行下一个上传任务
*/
const runNextUpload = async () => {
if (uploadTasks.filter((item) => item.status === 'uploading').length > 3) {
return;
}
const task = uploadTasks.find((item) => item.status === 'waiting');
if (task) {
await runUpload(task);
}
};
/**
* 排队上传
* @param e 消息事件
*/
const queueUpload = async (
e: MessageEvent<
MessageArg<{
id: string;
file: File;
uploadPath: string;
}>
>
) => {
uploadTasks.push({
file: e.data.arg.file,
fileName: e.data.arg.file.name,
id: e.data.arg.id,
uploadPath: e.data.arg.uploadPath,
size: e.data.arg.file.size,
progress: 0,
speed: 0,
status: 'waiting',
pieces: new Array(Math.ceil(e.data.arg.file.size / pieceSize)).fill(false),
errorMessage: undefined,
});
updateStatus(uploadTasks[uploadTasks.length - 1]);
await runNextUpload();
};
/**
* 注册状态接收器
* @param e 消息事件
* @param sender 发送者
*/
const registerStatusReceiver = (
e: MessageEvent<MessageArg<string>>,
sender?: MessagePort
) => {
if (sender) statusReceivers.set(e.data.arg, sender);
};
/**
* 注销状态接收器
* @param e 消息事件
*/
const unregisterStatusReceiver = (e: MessageEvent<MessageArg<string>>) => {
statusReceivers.delete(e.data.arg);
};
/**
* 更新 token
* @param e 消息事件
*/
const updateToken = (e: MessageEvent<MessageArg<string>>) => {
tokenStore.BearerToken = 'Bearer ' + e.data.arg;
};
/**
* 暂停上传
* @param e 消息事件
*/
const pauseUpload = (e: MessageEvent<MessageArg<string>>) => {
const task = uploadTasks.find((item) => item.id === e.data.arg);
if (task) {
task.status = 'paused';
if (task.abort) {
task.abort.abort();
}
updateStatus(task);
}
};
/**
* 取消上传
* @param e 消息事件
*/
const cancelUpload = (e: MessageEvent<MessageArg<string>>) => {
const task = uploadTasks.find((item) => item.id === e.data.arg);
if (task) {
task.status = 'canceled';
if (task.abort) {
task.abort.abort();
}
deleteUpload(task.id);
updateStatus(task);
}
};
/**
* 删除上传
* @param id 任务 id
*/
const deleteUpload = async (id: string) => {
uploadTasks.splice(
uploadTasks.findIndex((item) => item.id === id),
1
);
hashs.delete(id);
await fetch(`/api/File/Upload?id=${id}`, {
method: 'DELETE',
headers: {
Authorization: tokenStore.BearerToken,
},
}).then((res) => res.json());
};
/**
* 消息路由
*/
const messageRoute = new Map<
string,
(e: MessageEvent<MessageArg<any>>, sender?: MessagePort) => void
>([
['queueUpload', queueUpload],
['registerStatusReceiver', registerStatusReceiver],
['updateToken', updateToken],
['pauseUpload', pauseUpload],
['cancelUpload', cancelUpload],
['unregisterStatusReceiver', unregisterStatusReceiver],
]);
// 监听连接
_self.onconnect = (e) => {
const port = e.ports[0];
port.onmessage = async (e) => {
// 调用函数
const func = messageRoute.get(e.data.func);
if (func) {
func(e, port);
}
};
port.start();
};
upload-service.ts
代码
import UploadWorker from './upload-worker?sharedworker';
import { onUnmounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useAuthStore } from 'src/stores/auth';
/**
* 上传任务信息
*/
interface UploadTaskInfo {
/**
* 文件名
*/
fileName: string;
/**
* 上传路径
*/
uploadPath: string;
/**
* 任务 id
*/
id: string;
/**
* 文件大小
*/
size: number;
/**
* 上传进度
*/
progress: number;
/**
* 上传速度
*/
speed?: number;
/**
* 任务状态
*/
status: 'uploading' | 'paused' | 'canceled' | 'done' | 'error' | 'waiting';
/**
* 开始时间
*/
startTime?: Date;
/**
* 结束时间
*/
endTime?: Date;
/**
* 错误信息
*/
errorMessage?: string;
}
/**
* 上传服务
*/
export const useUploadService = () => {
const store = storeToRefs(useAuthStore());
// 创建共享 worker
const worker = new UploadWorker();
/**
* 上传任务列表
*/
const uploadTasks = ref<Map<string, UploadTaskInfo>>(
new Map<string, UploadTaskInfo>()
);
// 是否已注册状态接收器
const isRegistered = ref(false);
// 服务 id
const serviceId = crypto.randomUUID();
// 监听上传任务列表变化(只有在注册状态接收器后才会收到消息)
worker.port.onmessage = (e: MessageEvent<UploadTaskInfo>) => {
uploadTasks.value.set(e.data.id, e.data);
};
// 更新 token
worker.port.postMessage({
func: 'updateToken',
arg: store.token.value,
});
watch(store.token, (token) => {
worker.port.postMessage({
func: 'updateToken',
arg: token,
});
});
/**
* 排队上传
* @param file 文件
* @param uploadPath 上传路径
*/
const queueUpload = (file: File, uploadPath: string) => {
worker.port.postMessage({
func: 'queueUpload',
arg: {
id: crypto.randomUUID(),
file: file,
uploadPath: uploadPath,
},
});
};
/**
* 暂停上传
* @param id 任务 id
*/
const pauseUpload = (id: string) => {
worker.port.postMessage({
func: 'pauseUpload',
arg: id,
});
};
/**
* 取消上传
* @param id 任务 id
*/
const cancelUpload = (id: string) => {
worker.port.postMessage({
func: 'cancelUpload',
arg: id,
});
};
/**
* 注册状态接收器
*/
const registerStatusReceiver = () => {
worker.port.postMessage({
func: 'registerStatusReceiver',
arg: serviceId,
});
isRegistered.value = true;
};
/**
* 注销状态接收器
*/
const unregisterStatusReceiver = () => {
worker.port.postMessage({
func: 'unregisterStatusReceiver',
arg: serviceId,
});
isRegistered.value = false;
};
onUnmounted(() => {
unregisterStatusReceiver();
worker.port.close();
});
return {
uploadTasks,
queueUpload,
pauseUpload,
cancelUpload,
registerStatusReceiver,
unregisterStatusReceiver,
};
};
04. 用法
// 引入组合式函数
const uploadService = useUploadService();
// 注册状态接收器
uploadService.registerStatusReceiver();
// 表单绑定上传方法
const upload = (file: File, filePath: string) => {
uploadService.queueUpload(file, filePath);
}
// 监听上传进度,当然也可以直接展示在界面,毕竟是 Ref
watch(uploadService.uploadTasks, console.log);
Vue 3 中用组合式函数和 Shared Worker 实现后台分片上传(带哈希计算)的更多相关文章
- vue用阿里云oss上传图片使用分片上传只能上传100kb以内的解决办法
首先,vue和阿里云oss上传图片结合参考了 这位朋友的 https://www.jianshu.com/p/645f63745abd 文章,成功的解决了我用阿里云oss上传图片前的一头雾水. 该大神 ...
- vue+大文件分片上传
最近公司在使用vue做工程项目,实现大文件分片上传. 网上找了一天,发现网上很多代码都存在很多问题,最后终于找到了一个符合要求的项目. 工程如下: 对项目的大文件上传功能做出分析,怎么实现大文件分片上 ...
- 关于vue+element对ie9的兼容el-upload不支持在IE9上传
关于vue+element对ie9的兼容el-upload不支持在IE9上传 https://lian-yue.github.io/vue-upload-component/#/zh-cn/ 解决方案 ...
- vue+element+oss实现前端分片上传和断点续传
纯前端实现: 切片上传 断点续传 .断点续传需要在切上上传的基础上实现 前端之前上传OSS,无需后端提供接口.先上完整代码,直接复制,将new OSS里的参数修改成自己公司OSS相关信息后可用,如遇问 ...
- 聚是一团火散作满天星,前端Vue.js+elementUI结合后端FastAPI实现大文件分片上传
原文转载自「刘悦的技术博客」https://v3u.cn/a_id_175 分片上传并不是什么新概念,尤其是大文件传输的处理中经常会被使用,在之前的一篇文章里:python花式读取大文件(10g/50 ...
- 以寡治众各个击破,超大文件分片上传之构建基于Vue.js3.0+Ant-desgin+Tornado6纯异步IO高效写入服务
原文转载自「刘悦的技术博客」https://v3u.cn/a_id_218 分治算法是一种很古老但很务实的方法.本意即使将一个较大的整体打碎分成小的局部,这样每个小的局部都不足以对抗大的整体.战国时期 ...
- 前端vue的JsPDF html2canvas 生成pdf并以文件流形式上传到后端(转载)
原文地址 1.首先在文件内引入htmlToPdf.js这里代码引入了html2canvas和jspdf//需要 npm i html2Canvas 和 npm i jspdf 在这里将getPdf 这 ...
- JAVA SpringMVC + FormDate + Vue + file表单 ( 实现 js 单文件和多文件上传 )
JS 部分 <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <tit ...
- Vue 使用axios分片上传
Vue的界面 <input type="file"/> 上传方法 fileUpload: function () { var num = 1 var file = do ...
- vue富文本编辑,编辑自动预览,单个图片上传不能预览的问题解决:
//预览<div class="htmlViewBox"> <p v-html="activity_html_defaultMsg" v-sh ...
随机推荐
- Spring相关API
ApplicationContext的继承体系 applicationContext applicationContext:接口类型,代表应用上下文,可以通过其实例获得Spring容器中的Bean A ...
- vulnhub Necromancer wp
flag1 nmap -sU -T4 192.168.220.130 有666端口 nc -nvu 192.168.220.130 666 监听回显消息 tcpdump host 192.168.22 ...
- Mysql高级4-索引的使用规则
一.最左前缀法则 如果索引了多列(联合索引),要遵守最左前缀法则.最左前缀法则指的是查询从索引的最左列开始,并且不跳过索引中的列,如果跳跃某一列,索引将部分失效(后面的字段索引失效) 示例1:acco ...
- linux-服务操作和运行级别和关机重启
服务操作: service network [] systemctl [ disable(禁用) enable(启用)] network [] 中为操作命令 : 1.statu ...
- Vue报错: Property or method "changeLoginType" is not defined on the instance but referenced during render
原因 我这里是因为我代码中的方法不存在,我漏写了,后补充上就好了 解决方案 在methods中添加如下代码: // 修改登录状态 changeLoginType(bool){ this.loginTy ...
- JavaWeb和MVC三层架构
JavaWeb 概述 网站发布和部署一定要依托技术语言吗: 不一定,一个网站可以直接发布和部署,因为因为浏览器能够识别网页只需要两样东西,网络和静态页面,还有一个装在他们的容器,比如 nginx. 静 ...
- 代码随想录算法训练营第八天| LeetCode 344.反转字符串 541. 反转字符串II 151.翻转字符串里的单词
344.反转字符串 卡哥建议: 本题是字符串基础题目,就是考察 reverse 函数的实现,同时也明确一下 平时刷题什么时候用 库函数,什么时候 不用库函数 题目链接/文章讲解/视频讲解:https: ...
- 高效构建 vivo 企业级网络流量分析系统
作者:vivo 互联网服务器团队- Ming Yujia 随着网络规模的快速发展,网络状况的良好与否已经直接关系到了企业的日常收益,故障中的每一秒都会导致大量的用户流失与经济亏损.因此,如何快速发现网 ...
- 快速入门OpenCv(python版)
OpenCV是一个(开源)发行的跨平台计算机视觉库,可以运行在Linux.Windows和Mac OS操作系统上.它轻量级而且高效--由一系列 C 函数和少量 C++ 类构成,同时提供了Python. ...
- 如何选择最适合您的Excel处理库?
摘要:本文由葡萄城技术团队于博客园原创并首发.转载请注明出处:葡萄城官网,葡萄城为开发者提供专业的开发工具.解决方案和服务,赋能开发者. 引言 GcExcel和POI是两个应用于处理Excel文件的技 ...