01. 背景
最近專案需求裡有個檔案上傳功能,而客戶需求裡的檔案基本上是比較大的,基本上得有 1 GiB 以上的大小,而上傳大檔案尤其是讀大檔案,可能會造成卡 UI 或者說點不動的問題。而用後臺的 Worker 去實現是一個比較不錯的解決辦法。
02. 原理講解
02.01. Shared Worker
Shared Worker 的好處是可以從幾個瀏覽上下文中訪問,例如幾個視窗、iframe 或其他 worker。這樣我們可以保證全域性的頁面上傳任務都在我們的控制之下,甚至可以防止重複提交等功能。
02.02. 組合式函式
組合式函式的好處是在 Vue 3 是可以在任何 *.vue
檔案中使用,並且是響應式方法,可以偵聽 pinia 內 token 等的變化,傳遞給 Worker
02.03 簡單流程設計
flowchart TB
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);