Vue3利用組合式函數(shù)和Shared Worker實(shí)現(xiàn)后臺(tái)分片上傳
1.背景
最近項(xiàng)目需求里有個(gè)文件上傳功能,而客戶(hù)需求里的文件基本上是比較大的,基本上得有 1 GiB 以上的大小,而上傳大文件尤其是讀大文件,可能會(huì)造成卡 UI 或者說(shuō)點(diǎn)不動(dòng)的問(wèn)題。而用后臺(tái)的 Worker 去實(shí)現(xiàn)是一個(gè)比較不錯(cuò)的解決辦法。
2.原理講解
2.1Shared Worker
Shared Worker 的好處是可以從幾個(gè)瀏覽上下文中訪問(wèn),例如幾個(gè)窗口、iframe 或其他 worker。這樣我們可以保證全局的頁(yè)面上傳任務(wù)都在我們的控制之下,甚至可以防止重復(fù)提交等功能。
2.2組合式函數(shù)
組合式函數(shù)的好處是在 Vue 3 是可以在任何 *.vue 文件中使用,并且是響應(yīng)式方法,可以偵聽(tīng) pinia 內(nèi) token 等的變化,傳遞給 Worker
2.3簡(jiǎn)單流程設(shè)計(jì)

3.代碼
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;
/**
* 消息參數(shù)
*/
interface MessageArg<T> {
/**
* 函數(shù)名
*/
func: string;
/**
* 參數(shù)
*/
arg: T;
}
/**
* 上傳任務(wù)信息
*/
interface UploadTaskInfo {
/**
* 文件名
*/
fileName: string;
/**
* 上傳路徑
*/
uploadPath: string;
/**
* 任務(wù) id
*/
id: string;
/**
* 文件大小
*/
size: number;
/**
* 上傳進(jìn)度
*/
progress: number;
/**
* 上傳速度
*/
speed?: number;
/**
* 任務(wù)狀態(tài)
*/
status: 'uploading' | 'paused' | 'canceled' | 'done' | 'error' | 'waiting';
/**
* 開(kāi)始時(shí)間
*/
startTime?: Date;
/**
* 結(jié)束時(shí)間
*/
endTime?: Date;
/**
* 錯(cuò)誤信息
*/
errorMessage?: string;
}
/**
* 上傳任務(wù)
*/
interface UploadTask extends UploadTaskInfo {
file: File;
pieces: Array<boolean>;
abort?: AbortController;
}
/**
* 任務(wù)/哈希值映射
*/
const hashs = new Map();
/**
* 上傳任務(wù)列表
*/
const uploadTasks: Array<UploadTask> = [];
/**
* 狀態(tài)接收器
*/
const statusReceivers = new Map<string, MessagePort>();
/**
* token 倉(cāng)庫(kù)
*/
const tokenStore = {
/**
* token
*/
BearerToken: '',
};
/**
* 返回上傳狀態(tài)
* @param task 上傳任務(wù)
*/
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);
});
};
/**
* 運(yùn)行上傳任務(wù)
* @param task 上傳任務(wù)
*/
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();
}
};
/**
* 運(yùn)行下一個(gè)上傳任務(wù)
*/
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);
}
};
/**
* 排隊(duì)上傳
* @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();
};
/**
* 注冊(cè)狀態(tài)接收器
* @param e 消息事件
* @param sender 發(fā)送者
*/
const registerStatusReceiver = (
e: MessageEvent<MessageArg<string>>,
sender?: MessagePort
) => {
if (sender) statusReceivers.set(e.data.arg, sender);
};
/**
* 注銷(xiāo)狀態(tài)接收器
* @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 任務(wù) 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],
]);
// 監(jiān)聽(tīng)連接
_self.onconnect = (e) => {
const port = e.ports[0];
port.onmessage = async (e) => {
// 調(diào)用函數(shù)
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';
/**
* 上傳任務(wù)信息
*/
interface UploadTaskInfo {
/**
* 文件名
*/
fileName: string;
/**
* 上傳路徑
*/
uploadPath: string;
/**
* 任務(wù) id
*/
id: string;
/**
* 文件大小
*/
size: number;
/**
* 上傳進(jìn)度
*/
progress: number;
/**
* 上傳速度
*/
speed?: number;
/**
* 任務(wù)狀態(tài)
*/
status: 'uploading' | 'paused' | 'canceled' | 'done' | 'error' | 'waiting';
/**
* 開(kāi)始時(shí)間
*/
startTime?: Date;
/**
* 結(jié)束時(shí)間
*/
endTime?: Date;
/**
* 錯(cuò)誤信息
*/
errorMessage?: string;
}
/**
* 上傳服務(wù)
*/
export const useUploadService = () => {
const store = storeToRefs(useAuthStore());
// 創(chuàng)建共享 worker
const worker = new UploadWorker();
/**
* 上傳任務(wù)列表
*/
const uploadTasks = ref<Map<string, UploadTaskInfo>>(
new Map<string, UploadTaskInfo>()
);
// 是否已注冊(cè)狀態(tài)接收器
const isRegistered = ref(false);
// 服務(wù) id
const serviceId = crypto.randomUUID();
// 監(jiān)聽(tīng)上傳任務(wù)列表變化(只有在注冊(cè)狀態(tài)接收器后才會(huì)收到消息)
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,
});
});
/**
* 排隊(duì)上傳
* @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 任務(wù) id
*/
const pauseUpload = (id: string) => {
worker.port.postMessage({
func: 'pauseUpload',
arg: id,
});
};
/**
* 取消上傳
* @param id 任務(wù) id
*/
const cancelUpload = (id: string) => {
worker.port.postMessage({
func: 'cancelUpload',
arg: id,
});
};
/**
* 注冊(cè)狀態(tài)接收器
*/
const registerStatusReceiver = () => {
worker.port.postMessage({
func: 'registerStatusReceiver',
arg: serviceId,
});
isRegistered.value = true;
};
/**
* 注銷(xiāo)狀態(tài)接收器
*/
const unregisterStatusReceiver = () => {
worker.port.postMessage({
func: 'unregisterStatusReceiver',
arg: serviceId,
});
isRegistered.value = false;
};
onUnmounted(() => {
unregisterStatusReceiver();
worker.port.close();
});
return {
uploadTasks,
queueUpload,
pauseUpload,
cancelUpload,
registerStatusReceiver,
unregisterStatusReceiver,
};
};
4.用法
// 引入組合式函數(shù)
const uploadService = useUploadService();
// 注冊(cè)狀態(tài)接收器
uploadService.registerStatusReceiver();
// 表單綁定上傳方法
const upload = (file: File, filePath: string) => {
uploadService.queueUpload(file, filePath);
}
// 監(jiān)聽(tīng)上傳進(jìn)度,當(dāng)然也可以直接展示在界面,畢竟是 Ref
watch(uploadService.uploadTasks, console.log);以上就是Vue3利用組合式函數(shù)和Shared Worker實(shí)現(xiàn)后臺(tái)分片上傳的詳細(xì)內(nèi)容,更多關(guān)于Vue3分片上傳的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
- Vue2.0結(jié)合webuploader實(shí)現(xiàn)文件分片上傳功能
- Vue.Js及Java實(shí)現(xiàn)文件分片上傳代碼實(shí)例
- vue實(shí)現(xiàn)大文件分片上傳與斷點(diǎn)續(xù)傳到七牛云
- 使用Vue3+ElementPlus前端實(shí)現(xiàn)分片上傳的全過(guò)程
- 利用Vue3+Element-plus實(shí)現(xiàn)大文件分片上傳組件
- vue大文件分片上傳之simple-uploader.js的使用
- Java實(shí)現(xiàn)大文件的分片上傳與下載(springboot+vue3)
- vue3+element 分片上傳與分片下載功能實(shí)現(xiàn)方法詳解
- vue分片上傳視頻并轉(zhuǎn)換為m3u8文件播放的實(shí)現(xiàn)示例
相關(guān)文章
vue keep-alive實(shí)現(xiàn)多組件嵌套中個(gè)別組件存活不銷(xiāo)毀的操作
這篇文章主要介紹了vue keep-alive實(shí)現(xiàn)多組件嵌套中個(gè)別組件存活不銷(xiāo)毀的操作,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2020-10-10
基于vue.js實(shí)現(xiàn)側(cè)邊菜單欄
這篇文章主要為大家詳細(xì)介紹了基于vue.js實(shí)現(xiàn)側(cè)邊菜單欄的相關(guān)資料,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-03-03
vue后臺(tái)管理如何配置動(dòng)態(tài)路由菜單
這篇文章主要介紹了vue后臺(tái)管理如何配置動(dòng)態(tài)路由菜單,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-04-04
在Vue使用$attrs實(shí)現(xiàn)構(gòu)建高級(jí)組件
本文我們主要來(lái)看下Vue3中的$attrs屬性。首先,我們會(huì)介紹它的用途以及它的實(shí)現(xiàn)與Vue2有哪些不兩同點(diǎn),并通過(guò)事例來(lái)加深對(duì)它的理解2022-09-09
vue-cli 自定義指令directive 添加驗(yàn)證滑塊示例
本篇文章主要介紹了vue-cli 自定義指令directive 添加驗(yàn)證滑塊示例,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-10-10
vue項(xiàng)目配置element-ui容易遇到的坑及解決
這篇文章主要介紹了vue項(xiàng)目配置element-ui容易遇到的坑及解決方案,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-07-07

