项目场景:
基于上篇文章的埋点上报文章中,哈喽开提同学针对监控到的不同的数据类型,做了全面可靠的上报方案。
近日,做了一个LLM大模型组件对话框的一个项目,即为用户实现便捷直观的AI交互效果。此时哈喽开提同学联想到了之前做的上报策略,发现上传也可以采取上报的某些策略思想完善 。细想,如果只是调用API做会话效果,再做个对话框页面对于整个项目来说还是远远不够的。侧重于如何给用户带来最好的体验,本次哈喽开提同学针对组件对话的输入 做了大文件分片上传的性能优化。
在LLM对话框的输入能力的设计中,针对用户的多种形式输入,如文本,图片,PDF等文件,在上传过程中,可能会带来以下问题:
-
网络不稳定导致的上传失败
-
服务器对文件大小有限制
-
上传失败后可能还需要上传整个文件,文件过大带来的加载时间太慢等
-
分片上传的话如果分片过多,一片一片上传岂不是还是无法优化性能
根据以上LLM输入中带来的上传问题,大文件的分片上传是前端需要将大文件分割成多个小片段,将分片固定一个大小,每个分片单独上传到服务器,可能按顺序上传,也可能并行上传,有效解决了大文件上传的稳定性,效率和服务器限制问题。
通过本文章你会学到什么?
- 分片上传的设计结构
- 上报和上传的思想重叠性
- 分片上传的代码逻辑实现
实现思路:
- 分片上传的核心流程:
- 核心思想:
哈喽开提同学定义了一个 FileUploader 的组件,引入多个自定义钩子如 useUploader 和 useFileInfo,实现了大文件分片上传的核心流程。其中,代码结构大致分为状态管理,文件处理,worker 的处理,上传逻辑和 UI 渲染几部分。其中涉及到了用加密算法返回文件等复杂的计算,为了不阻塞主线程的工作,哈喽开提同学使用 Web Worker,即开辟一个独立于所有线程的协程处理这种复杂运算,处理完之后将结果返回给主线程。
那我们如何用 Web Worker 这个 API 实现这个分片上传的,这其中涉及到了多方面,包括:
-
文件分片
-
计算哈希(用作后端返回的 ID 标识)
-
使用 Web Worker
-
断点续传
-
进度跟踪
-
错误处理
- 项目结构:
展开实现:
- 前端选择文件,后端返回一个id:
scss
const fileInfo = useFileInfo(file);
... ...
const handleStartUpload = async () => {
if (!file) {
setError("请选择要上传的文件");
return;
}
try {
setUploading(true); //上传状态
setError(null); //错误信息
resetProgress(); //上传进度
// 测试服务器连接
await testBackendConnection();
// 计算文件哈希
setHashingProgress(0);
const fileHash = await calculateMD5();
// 处理文件名
const sanitizedFileName = sanitizeFileName(file.name);
// 检查文件是否已存在
const fileExistsResult = await checkFileExists(fileHash);
if (fileExistsResult.exists && fileExistsResult.fileUrl) {
setUploadedFileUrl(fileExistsResult.fileUrl);
setUploading(false);
return;
}
// 使用文件哈希作为文件ID的一部分,确保断点续传能找到之前的上传记录
let fileId = `${fileHash.substring(0, 10)}-${Date.now()}`;
... ...
} catch (error: unknown) {
handleUploadError(error);
} finally {
setUploading(false);
}
};
- 分片与哈希计算:
typescript
import { UploadConfig } from "../types/upload";
//分片配置
export const uploadConfig: UploadConfig = {
CHUNK_SIZE: 2 * 1024 * 1024, // 2MB chunks for better performance
HASH_CHUNK_SIZE: 5 * 1024 * 1024, // 5MB chunks for hashing
API_BASE_URL: "http://localhost:8080", // 直接连接到后端服务
};
const calculateMD5 = async (): Promise<string> => {
return new Promise((resolve, reject) => {
if (!file) {
reject(new Error("No file selected"));
return;
}
const worker = getHashWorker(); //懒加载创建进行计算处理的 worker 对象
if (!worker) {
reject(new Error("Hash worker not available"));
return;
}
worker.onmessage = (e) => {
const message = e.data as WorkerMessage;
//文件哈希计算流程
switch (message.type) {
case "progress": //进度更新
if ("progress" in message) {
setHashingProgress(message.progress);
}
break;
case "complete": //结果
setHashingProgress(100);
if ("hash" in message && message.hash) {
resolve(message.hash);
} else {
reject(
new Error("Hash calculation completed but no hash returned")
);
}
break;
case "error":
reject(new Error(message.error));
break;
}
};
worker.onerror = () => {
reject(new Error("Hash calculation failed"));
};
//分片上传控制
worker.postMessage({
file,
chunkSize: uploadConfig.HASH_CHUNK_SIZE,
});
});
};
- 分片上传到后端:
typescript
// 将上传逻辑抽离为单独的函数,提高可读性
const uploadFile = (
fileId: string,
fileName: string,
fileHash: string,
uploadedChunks: number[]
): Promise<void> => {
return new Promise((resolve, reject) => {
if (!file) {
reject(new Error("No file selected"));
return;
}
const worker = getUploadWorker(); //创建做上传处理的 worker 对象
if (!worker) {
reject(new Error("Upload worker not available"));
return;
}
worker.onmessage = (e) => {
const message = e.data as WorkerMessage;
switch (message.type) {
case "progress": //进度更新
if (
"bytesUploaded" in message &&
"totalBytes" in message &&
message.bytesUploaded !== undefined &&
message.totalBytes !== undefined
) {
updateProgress(message.bytesUploaded, message.totalBytes);
}
break;
case "complete": //结果
if ("fileUrl" in message && message.fileUrl) {
setUploadedFileUrl(message.fileUrl);
}
resolve();
break;
case "error":
reject(new Error(message.error));
break;
}
};
worker.onerror = () => {
reject(new Error("上传失败:worker错误"));
};
worker.postMessage({ //上传Worker消息处理
file,
fileId,
fileName,
fileHash,
chunkSize: uploadConfig.CHUNK_SIZE,
uploadedChunks,
});
});
};
- 合并:与后端联调,交给后端处理
如何实现的断点续传:
断点续传,是一种在文件传输过程中因意外中断(如网络断开,系统崩溃等)后,能够从中断电继续传输的技术。对于大文件上传尤为重要,可显著提升上传效率和用户体验。以下是核心实现机制及技术细节:
-
分片上传: 使用
File.slice()
将文件切割为多个Blob
对象,每个分片携带序号(chunkIndex
)。iniconst chunkSize = 5 * 1024 * 1024; // 5MB const chunks = []; for (let i = 0; i < totalChunks; i++) { const start = i * chunkSize; const end = Math.min(start + chunkSize, file.size); chunks.push(file.slice(start, end)); }
-
唯一标识与状态追踪:
- 客户端: 通过文件内容哈希(MD5) 生成唯一标识,确保同一文件的多次上传可被识别。利用 localStorage 存储文件哈希等元数据
- 服务端: 根据已上传的分片序号,服务端为每个上传任务(uploadId)维护已上传分片列表
typescriptconst saveUploadStateToStorage = (fileHash: string, state: UploadState) => { localStorage.setItem(`upload_${fileHash}`, JSON.stringify(state)); };
-
断点 恢复机制:
从本地存储获取上传状态,恢复上传
typescript
// 保存上传状态到本地存储
const saveUploadStateToStorage = (
fileHash: string,
state: {
fileId: string; //与服务器记录的分片序号有关
fileName: string;
fileSize: number;
startTime: number;
}
) => {
if (typeof window !== "undefined") {
try {
//通过本地保存上传状态
localStorage.setItem(`upload_${fileHash}`, JSON.stringify(state));
} catch (e) {
console.error("无法保存上传状态到本地存储:", e);
}
}
};
// 从本地存储获取上传状态
const getUploadStateFromStorage = (fileHash: string) => {
if (typeof window !== "undefined") {
try {
//恢复上传
const state = localStorage.getItem(`upload_${fileHash}`);
return state ? JSON.parse(state) : null;
} catch (e) {
console.error("无法从本地存储读取上传状态:", e);
return null;
}
}
return null;
};
和之前的上报策略有什么思想重叠性?
核心思想 | 大文件的分片上传 | 埋点上报 |
---|---|---|
数据分块 | 遇到大文件时,做分片处理 | 对于高频事件做批量上报 |
容错恢复 | 遇到网络中断,服务器大小限制等问题采取分片上传 | 遇到网络中断,页面突然关闭等问题的重新上报机制 |
上传策略 | sendBeacon优先,sendBeacon,xhr,Img 三种不同策略的选择 | 根据进度跟踪做策略选择,三种状态:上传中,上传完毕,上传失败(由跟踪进度的状态维护) |
存储方案 | 本地持久化,再重新上报 | 本地持久化,将没传完的分片继续上传 |
总结:
大文件的分片上传在 LLM 大模型输入中起到了至关重要的作用,通过Web Worker 的管理减少了内存占用,保证了主线程工作的稳定性。通过分片上传的思想,确保遇到意外事件时,再次上传不必上传整个文件,对于上传的传输速率和用户体验带来绝佳的效果。