前端通过 Web Workers 实现大文件断点续传是一个非常经典的优化场景,它能有效解决以下问题:
- UI 阻塞: 大文件读取(如计算 MD5)、切片等操作是计算密集型的,如果放在主线程会造成页面卡顿甚至无响应。Web Worker 可以将这些任务放到后台线程执行,保持 UI 流畅。
- 断点续传: 允许用户在上传中断后从上次中断的地方继续上传,提升用户体验。
- 分片上传: 将大文件分割成小块上传,提高上传的稳定性和成功率,也方便服务器管理。
核心概念
- File.prototype.slice(): 用于将文件(Blob)切割成指定大小的 Blob 片段。
- SparkMD5 (或其他哈希库): 用于计算文件的 MD5 值。MD5 值可以作为文件的唯一标识,用于秒传、校验文件完整性以及断点续传时识别文件。
- Web Worker: 在后台线程执行脚本,不阻塞主线程。通过
postMessage
和onmessage
进行主线程与 Worker 线程之间的通信。 - XMLHttpRequest / Fetch API: 用于发送分片数据到服务器。
- 服务器端配合: 服务器需要提供接口来接收分片、合并分片、查询已上传分片列表。
实现思路
-
用户选择文件: 在主线程中获取
File
对象。 -
创建 Worker: 主线程创建 Web Worker 实例。
-
Worker 任务分配:
- 文件哈希计算: 将
File
对象发送给 Worker,Worker 负责读取文件内容并计算 MD5 值。这个过程是耗时操作,放在 Worker 中避免阻塞 UI。 - 文件分片: Worker 根据文件大小和预设分片大小,计算出所有分片的起始和结束位置。
- 分片上传管理: Worker 负责管理分片的上传顺序、重试机制、以及与服务器通信获取已上传分片列表(断点续传)。
- 文件哈希计算: 将
-
主线程与 Worker 通信:
- 主线程 -> Worker: 发送
File
对象(或其关键信息)、分片大小、上传 URL 等。 - Worker -> 主线程: 发送哈希计算进度、哈希结果、当前分片上传进度、分片上传完成通知、上传失败通知、整体上传完成通知等。
- 主线程执行实际上传: 虽然 Worker 可以发送 XHR/Fetch 请求,但为了简化错误处理和利用浏览器原生网络栈的优势,通常会让 Worker 准备好分片数据(
ArrayBuffer
),然后通过postMessage
发送回主线程,由主线程负责实际的XMLHttpRequest
或fetch
请求。
- 主线程 -> Worker: 发送
详细代码讲解
分为三个文件:
index.html
:前端页面结构。main.js
:主线程逻辑,处理 UI 交互和 Worker 通信。worker.js
:Web Worker 逻辑,负责文件哈希计算和分片管理。- (概念性)
server.js
:模拟服务器端逻辑。
注意: 本示例的服务器端逻辑是简化的,实际生产环境需要更复杂的后端实现(如文件存储、并发处理、安全校验等)。
1. index.html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>大文件断点续传(Web Worker)</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
#upload-container { border: 1px solid #ccc; padding: 20px; border-radius: 8px; max-width: 600px; margin: 0 auto; }
input[type="file"] { margin-bottom: 10px; }
button { padding: 8px 15px; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; margin-right: 10px; }
button:disabled { background-color: #cccccc; cursor: not-allowed; }
progress { width: 100%; margin-top: 10px; }
#messages { margin-top: 20px; padding: 10px; border: 1px solid #eee; background-color: #f9f9f9; min-height: 50px; overflow-y: auto; }
</style>
</head>
<body>
<div id="upload-container">
<h1>大文件断点续传</h1>
<input type="file" id="fileInput">
<button id="uploadBtn" disabled>开始上传</button>
<button id="pauseBtn" disabled>暂停</button>
<button id="resumeBtn" disabled>继续</button>
<p>文件哈希计算进度: <span id="hashProgress">0%</span></p>
<progress id="overallProgress" value="0" max="100"></progress>
<p>总进度: <span id="overallProgressText">0%</span></p>
<div id="messages"></div>
</div>
<script src="main.js"></script>
</body>
</html>
2. main.js
(主线程逻辑)
js
// main.js
const fileInput = document.getElementById('fileInput');
const uploadBtn = document.getElementById('uploadBtn');
const pauseBtn = document.getElementById('pauseBtn');
const resumeBtn = document.getElementById('resumeBtn');
const hashProgressSpan = document.getElementById('hashProgress');
const overallProgressBar = document.getElementById('overallProgress');
const overallProgressText = document.getElementById('overallProgressText');
const messagesDiv = document.getElementById('messages');
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB per chunk
const UPLOAD_URL = 'http://localhost:3000/upload'; // 你的服务器上传接口
const CHECK_UPLOADED_URL = 'http://localhost:3000/check'; // 检查已上传分片接口
const MERGE_URL = 'http://localhost:3000/merge'; // 合并分片接口
let selectedFile = null;
let fileWorker = null;
let isUploading = false;
let fileHash = ''; // 文件的唯一哈希值
let uploadedChunks = new Set(); // 已上传的分片索引
function logMessage(msg) {
const p = document.createElement('p');
p.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
messagesDiv.prepend(p); // 新消息放在顶部
}
fileInput.addEventListener('change', (event) => {
selectedFile = event.target.files[0];
if (selectedFile) {
uploadBtn.disabled = false;
pauseBtn.disabled = true;
resumeBtn.disabled = true;
overallProgressBar.value = 0;
overallProgressText.textContent = '0%';
hashProgressSpan.textContent = '0%';
messagesDiv.innerHTML = ''; // 清空消息
logMessage(`文件已选择: ${selectedFile.name} (${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB)`);
} else {
uploadBtn.disabled = true;
}
});
uploadBtn.addEventListener('click', () => {
if (!selectedFile) {
logMessage('请先选择文件!');
return;
}
startUpload();
});
pauseBtn.addEventListener('click', () => {
if (fileWorker && isUploading) {
fileWorker.postMessage({ type: 'pause' });
isUploading = false;
pauseBtn.disabled = true;
resumeBtn.disabled = false;
logMessage('上传已暂停。');
}
});
resumeBtn.addEventListener('click', () => {
if (fileWorker && !isUploading) {
fileWorker.postMessage({ type: 'resume_upload' });
isUploading = true;
pauseBtn.disabled = false;
resumeBtn.disabled = true;
logMessage('上传已恢复。');
}
});
async function startUpload() {
uploadBtn.disabled = true;
fileInput.disabled = true;
pauseBtn.disabled = false;
isUploading = true;
logMessage('开始上传...');
// 1. 创建或重用 Worker
if (!fileWorker) {
fileWorker = new Worker('worker.js');
fileWorker.onmessage = handleWorkerMessage;
fileWorker.onerror = handleWorkerError;
}
// 2. 检查是否是断点续传
let resumePoint = 0; // 默认从头开始
if (localStorage.getItem(`upload_progress_${selectedFile.name}`)) {
const storedProgress = JSON.parse(localStorage.getItem(`upload_progress_${selectedFile.name}`));
fileHash = storedProgress.fileHash;
uploadedChunks = new Set(storedProgress.uploadedChunks);
resumePoint = storedProgress.resumePoint; // 这是一个粗略的断点,更精确的是根据已上传分片列表
logMessage(`检测到上次上传进度,将从断点续传。已上传 ${uploadedChunks.size} 个分片。`);
} else {
// 如果是新文件或没有历史记录,则清空已上传分片
uploadedChunks.clear();
}
// 3. 将文件信息发送给 Worker
fileWorker.postMessage({
type: 'start',
file: selectedFile,
chunkSize: CHUNK_SIZE,
fileHash: fileHash, // 如果是断点续传,传递已知的哈希
uploadedChunks: Array.from(uploadedChunks) // 传递已上传的分片列表
});
}
async function handleWorkerMessage(event) {
const data = event.data;
switch (data.type) {
case 'hash_progress':
hashProgressSpan.textContent = `${data.progress}%`;
break;
case 'hash_calculated':
fileHash = data.hash;
logMessage(`文件哈希计算完成: ${fileHash}`);
overallProgressBar.value = 0; // 重置进度条,因为哈希计算不计入上传进度
overallProgressText.textContent = '0%';
hashProgressSpan.textContent = '100%';
// 检查服务器上是否已存在该文件或部分分片
await checkFileStatus(fileHash);
break;
case 'chunk_upload_request':
if (isUploading) { // 只有在上传状态才发送请求
const { chunk, index, totalChunks } = data;
// chunk 是 ArrayBuffer,需要转换为 Blob 才能发送
const chunkBlob = new Blob([chunk], { type: 'application/octet-stream' });
uploadChunk(chunkBlob, index, totalChunks);
}
break;
case 'upload_progress':
const { uploadedCount, totalChunks: totalChunksProgress } = data;
const progress = (uploadedCount / totalChunksProgress) * 100;
overallProgressBar.value = progress;
overallProgressText.textContent = `${progress.toFixed(2)}%`;
break;
case 'upload_complete':
logMessage('所有分片上传完成,请求服务器合并文件...');
await mergeFile(fileHash, selectedFile.name, data.totalChunks);
break;
case 'upload_error':
isUploading = false;
pauseBtn.disabled = true;
resumeBtn.disabled = true;
logMessage(`上传失败: ${data.message}`);
break;
case 'upload_paused':
isUploading = false;
pauseBtn.disabled = true;
resumeBtn.disabled = false;
logMessage('上传已暂停。');
break;
}
}
function handleWorkerError(error) {
isUploading = false;
pauseBtn.disabled = true;
resumeBtn.disabled = true;
logMessage(`Worker 错误: ${error.message}`);
console.error('Worker error:', error);
}
async function checkFileStatus(hash) {
logMessage('检查服务器文件状态...');
try {
const response = await fetch(CHECK_UPLOADED_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileHash: hash, fileName: selectedFile.name })
});
const result = await response.json();
if (result.code === 0) {
if (result.data.isUploaded) {
logMessage('文件已存在于服务器,秒传成功!');
overallProgressBar.value = 100;
overallProgressText.textContent = '100%';
isUploading = false;
uploadBtn.disabled = false;
fileInput.disabled = false;
pauseBtn.disabled = true;
resumeBtn.disabled = true;
// 清除本地存储的进度
localStorage.removeItem(`upload_progress_${selectedFile.name}`);
fileWorker.postMessage({ type: 'upload_finished' }); // 通知worker停止
} else {
uploadedChunks = new Set(result.data.uploadedChunks);
logMessage(`服务器已存在 ${uploadedChunks.size} 个分片。`);
// 通知 Worker 更新已上传分片列表,并开始上传剩余分片
fileWorker.postMessage({
type: 'update_uploaded_chunks',
uploadedChunks: Array.from(uploadedChunks)
});
// 更新主线程进度条
const totalChunks = Math.ceil(selectedFile.size / CHUNK_SIZE);
overallProgressBar.value = (uploadedChunks.size / totalChunks) * 100;
overallProgressText.textContent = `${overallProgressBar.value.toFixed(2)}%`;
// 立即通知 Worker 开始上传(如果之前暂停了)
if (isUploading) {
fileWorker.postMessage({ type: 'start_upload' });
}
}
} else {
logMessage(`检查文件状态失败: ${result.message}`);
// 即使检查失败,也尝试开始上传,但可能无法断点续传
fileWorker.postMessage({ type: 'start_upload' });
}
} catch (error) {
logMessage(`检查文件状态网络错误: ${error.message}`);
console.error('Check file status error:', error);
// 网络错误,也尝试开始上传
fileWorker.postMessage({ type: 'start_upload' });
}
}
async function uploadChunk(chunkBlob, index, totalChunks) {
const formData = new FormData();
formData.append('fileHash', fileHash);
formData.append('fileName', selectedFile.name);
formData.append('chunk', chunkBlob);
formData.append('chunkIndex', index);
formData.append('totalChunks', totalChunks);
try {
const response = await fetch(UPLOAD_URL, {
method: 'POST',
body: formData,
});
const result = await response.json();
if (result.code === 0) {
// 通知 Worker 分片上传成功
fileWorker.postMessage({ type: 'chunk_uploaded_success', index: index });
uploadedChunks.add(index); // 更新主线程的已上传分片列表
// 保存进度到本地存储
localStorage.setItem(`upload_progress_${selectedFile.name}`, JSON.stringify({
fileHash: fileHash,
uploadedChunks: Array.from(uploadedChunks),
resumePoint: index // 记录最后一个成功上传的分片索引
}));
} else {
logMessage(`分片 ${index} 上传失败: ${result.message}`);
// 通知 Worker 分片上传失败,Worker 可以决定是否重试
fileWorker.postMessage({ type: 'chunk_uploaded_fail', index: index, message: result.message });
}
} catch (error) {
logMessage(`分片 ${index} 上传网络错误: ${error.message}`);
console.error(`Chunk ${index} upload error:`, error);
// 通知 Worker 分片上传失败
fileWorker.postMessage({ type: 'chunk_uploaded_fail', index: index, message: error.message });
}
}
async function mergeFile(hash, name, totalChunks) {
try {
const response = await fetch(MERGE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileHash: hash, fileName: name, totalChunks: totalChunks })
});
const result = await response.json();
if (result.code === 0) {
logMessage('文件合并成功!上传完成。');
overallProgressBar.value = 100;
overallProgressText.textContent = '100%';
isUploading = false;
uploadBtn.disabled = false;
fileInput.disabled = false;
pauseBtn.disabled = true;
resumeBtn.disabled = true;
// 清除本地存储的进度
localStorage.removeItem(`upload_progress_${selectedFile.name}`);
} else {
logMessage(`文件合并失败: ${result.message}`);
isUploading = false;
pauseBtn.disabled = true;
resumeBtn.disabled = true;
}
} catch (error) {
logMessage(`文件合并网络错误: ${error.message}`);
console.error('Merge file error:', error);
isUploading = false;
pauseBtn.disabled = true;
resumeBtn.disabled = true;
}
}
3. worker.js
(Web Worker 逻辑)
Web Worker 中不能直接访问 DOM 或 localStorage
。它需要通过 postMessage
与主线程通信。它也不能直接访问 File
对象的内容,需要通过 FileReader
。
为了计算 MD5,我们需要一个 MD5 库。这里使用 SparkMD5
。
js
// worker.js
// 导入 SparkMD5 库
// 在生产环境中,你可以使用 CDN 或其他方式引入
// 这里假设 SparkMD5.min.js 和 worker.js 在同一目录下
importScripts('https://cdn.jsdelivr.net/npm/[email protected]/spark-md5.min.js');
let file = null;
let chunkSize = 0;
let fileHash = '';
let totalChunks = 0;
let uploadedChunks = new Set(); // 存储已上传分片的索引
let chunksToUpload = []; // 待上传分片的索引
let isPaused = false;
let currentChunkIndex = -1; // 当前正在处理的分片索引
self.onmessage = async (event) => {
const data = event.data;
switch (data.type) {
case 'start':
file = data.file;
chunkSize = data.chunkSize;
fileHash = data.fileHash || ''; // 可能是断点续传带过来的哈希
uploadedChunks = new Set(data.uploadedChunks || []);
isPaused = false; // 重新开始时确保不是暂停状态
// 如果没有哈希,先计算哈希
if (!fileHash) {
await calculateFileHash();
} else {
// 如果有哈希,直接开始分片和上传流程(主线程会先检查服务器状态)
self.postMessage({ type: 'hash_calculated', hash: fileHash });
}
break;
case 'update_uploaded_chunks':
// 主线程通知 Worker 已上传的分片列表
uploadedChunks = new Set(data.uploadedChunks);
self.postMessage({ type: 'upload_progress', uploadedCount: uploadedChunks.size, totalChunks: totalChunks });
break;
case 'start_upload':
// 主线程通知 Worker 开始/恢复上传
isPaused = false;
startChunkUpload();
break;
case 'chunk_uploaded_success':
uploadedChunks.add(data.index);
self.postMessage({ type: 'upload_progress', uploadedCount: uploadedChunks.size, totalChunks: totalChunks });
// 继续上传下一个分片
startChunkUpload();
break;
case 'chunk_uploaded_fail':
// 失败重试逻辑 (这里简单地记录并继续,实际可以加入重试次数限制和延迟)
console.error(`分片 ${data.index} 上传失败,原因: ${data.message}`);
// 可以将失败的分片重新加入队列,或者等待用户手动重试
// 为了演示,这里直接尝试下一个分片
startChunkUpload();
break;
case 'pause':
isPaused = true;
self.postMessage({ type: 'upload_paused' });
break;
case 'resume_upload':
isPaused = false;
startChunkUpload();
break;
case 'upload_finished': // 文件秒传或合并完成,通知 Worker 停止
isPaused = true; // 停止所有上传活动
chunksToUpload = []; // 清空队列
currentChunkIndex = -1;
break;
}
};
async function calculateFileHash() {
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReader();
const fileChunks = [];
let currentChunk = 0;
const chunkSize = 2 * 1024 * 1024; // 2MB for hash calculation chunks
const totalFileChunks = Math.ceil(file.size / chunkSize);
return new Promise((resolve, reject) => {
reader.onload = (e) => {
spark.append(e.target.result); // Append array buffer
currentChunk++;
const progress = Math.min(100, Math.round((currentChunk / totalFileChunks) * 100));
self.postMessage({ type: 'hash_progress', progress: progress });
if (currentChunk < totalFileChunks) {
loadNextHashChunk();
} else {
fileHash = spark.end(); // Compute hash
self.postMessage({ type: 'hash_calculated', hash: fileHash });
resolve(fileHash);
}
};
reader.onerror = (e) => {
self.postMessage({ type: 'upload_error', message: '文件读取失败' });
reject(e);
};
function loadNextHashChunk() {
const start = currentChunk * chunkSize;
const end = Math.min(start + chunkSize, file.size);
reader.readAsArrayBuffer(file.slice(start, end));
}
loadNextHashChunk(); // Start reading
});
}
function createChunks() {
totalChunks = Math.ceil(file.size / chunkSize);
chunksToUpload = [];
for (let i = 0; i < totalChunks; i++) {
if (!uploadedChunks.has(i)) { // 只添加未上传的分片
chunksToUpload.push(i);
}
}
// 可以对 chunksToUpload 进行排序或打乱,以优化上传策略
// chunksToUpload.sort((a, b) => a - b); // 确保按顺序上传
console.log(`Worker: 需要上传 ${chunksToUpload.length} 个分片。`);
self.postMessage({ type: 'upload_progress', uploadedCount: uploadedChunks.size, totalChunks: totalChunks });
if (chunksToUpload.length === 0) {
self.postMessage({ type: 'upload_complete', totalChunks: totalChunks });
return false; // 没有需要上传的分片
}
return true; // 有需要上传的分片
}
async function startChunkUpload() {
if (isPaused) {
return;
}
if (chunksToUpload.length === 0) {
// 第一次进来或者更新了已上传分片列表后,重新创建分片列表
if (!createChunks()) { // 如果没有需要上传的分片,直接返回
return;
}
}
if (chunksToUpload.length > 0) {
currentChunkIndex = chunksToUpload.shift(); // 取出第一个待上传的分片索引
const start = currentChunkIndex * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunkBlob = file.slice(start, end);
// 使用 FileReader 读取 Blob 为 ArrayBuffer,然后发送给主线程
const reader = new FileReader();
reader.onload = (e) => {
if (!isPaused) { // 再次检查是否暂停
self.postMessage({
type: 'chunk_upload_request',
chunk: e.target.result, // ArrayBuffer
index: currentChunkIndex,
totalChunks: totalChunks
}, [e.target.result]); // 将 ArrayBuffer 作为可转移对象发送,提高性能
}
};
reader.onerror = (e) => {
self.postMessage({ type: 'upload_error', message: `读取分片 ${currentChunkIndex} 失败` });
};
reader.readAsArrayBuffer(chunkBlob);
} else {
// 所有分片都已处理完毕
self.postMessage({ type: 'upload_complete', totalChunks: totalChunks });
}
}
4. server.js
(模拟服务器端 Node.js)
这是一个非常简化的 Express.js 服务器,用于演示分片接收、检查和合并。生产环境需要更健壮的错误处理、文件存储、安全措施和并发控制。
js
// server.js
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const app = express();
const port = 3000;
// 存储上传文件的临时目录
const UPLOAD_DIR = path.join(__dirname, 'uploads');
const CHUNKS_DIR = path.join(UPLOAD_DIR, 'chunks');
// 确保目录存在
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR);
if (!fs.existsSync(CHUNKS_DIR)) fs.mkdirSync(CHUNKS_DIR);
// 配置 multer 用于处理文件上传
const storage = multer.diskStorage({
destination: (req, file, cb) => {
// 每个文件的分片存储在一个以文件哈希命名的子目录中
const fileHash = req.body.fileHash;
const chunkPath = path.join(CHUNKS_DIR, fileHash);
if (!fs.existsSync(chunkPath)) {
fs.mkdirSync(chunkPath);
}
cb(null, chunkPath);
},
filename: (req, file, cb) => {
// 分片文件名以索引命名
const chunkIndex = req.body.chunkIndex;
cb(null, `${chunkIndex}`);
}
});
const upload = multer({ storage: storage });
app.use(express.json()); // 用于解析 JSON 请求体
app.use(express.urlencoded({ extended: true })); // 用于解析 URL-encoded 请求体
// 允许跨域请求
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
return res.sendStatus(200);
}
next();
});
// 检查文件或分片状态接口
app.post('/check', (req, res) => {
const { fileHash, fileName } = req.body;
const filePath = path.join(UPLOAD_DIR, fileName);
const chunkPath = path.join(CHUNKS_DIR, fileHash);
// 1. 检查最终文件是否已存在 (秒传)
if (fs.existsSync(filePath)) {
return res.json({ code: 0, message: '文件已上传', data: { isUploaded: true } });
}
// 2. 检查已上传的分片
let uploadedChunks = [];
if (fs.existsSync(chunkPath)) {
uploadedChunks = fs.readdirSync(chunkPath)
.map(name => parseInt(name))
.filter(index => !isNaN(index)); // 确保是数字
}
res.json({ code: 0, message: '检查成功', data: { isUploaded: false, uploadedChunks: uploadedChunks } });
});
// 分片上传接口
app.post('/upload', upload.single('chunk'), (req, res) => {
// req.file 包含上传的分片信息
// req.body 包含 fileHash, fileName, chunkIndex, totalChunks
console.log(`Received chunk ${req.body.chunkIndex} for ${req.body.fileName} (Hash: ${req.body.fileHash})`);
res.json({ code: 0, message: '分片上传成功' });
});
// 合并分片接口
app.post('/merge', async (req, res) => {
const { fileHash, fileName, totalChunks } = req.body;
const finalFilePath = path.join(UPLOAD_DIR, fileName);
const chunkDirPath = path.join(CHUNKS_DIR, fileHash);
if (!fs.existsSync(chunkDirPath)) {
return res.json({ code: 1, message: '分片目录不存在' });
}
// 检查所有分片是否都已上传
const uploadedChunkFiles = fs.readdirSync(chunkDirPath);
if (uploadedChunkFiles.length !== totalChunks) {
return res.json({ code: 1, message: `分片数量不匹配,期望 ${totalChunks},实际 ${uploadedChunkFiles.length}` });
}
try {
// 创建写入流
const writeStream = fs.createWriteStream(finalFilePath);
// 按照分片索引顺序合并
for (let i = 0; i < totalChunks; i++) {
const chunkFilePath = path.join(chunkDirPath, String(i));
if (!fs.existsSync(chunkFilePath)) {
// 理论上这里不会发生,因为上面已经检查了数量
throw new Error(`分片 ${i} 不存在`);
}
const chunkBuffer = fs.readFileSync(chunkFilePath);
writeStream.write(chunkBuffer);
}
writeStream.end(); // 结束写入
writeStream.on('finish', () => {
// 合并完成后,可以删除临时分片目录
fs.rm(chunkDirPath, { recursive: true, force: true }, (err) => {
if (err) console.error('删除分片目录失败:', err);
});
res.json({ code: 0, message: '文件合并成功' });
});
writeStream.on('error', (err) => {
throw err;
});
} catch (error) {
console.error('合并文件失败:', error);
res.json({ code: 1, message: `文件合并失败: ${error.message}` });
}
});
app.listen(port, () => {
console.log(`Server listening at http://localhost:${port}`);
});
运行步骤
-
保存文件:
index.html
main.js
worker.js
server.js
将它们放在同一个目录下。
-
安装 Node.js 服务器依赖:
在
server.js
所在的目录下打开终端,运行:csharpnpm init -y npm install express multer
-
启动服务器:
在
server.js
所在的目录下运行:vbscriptnode server.js
你将看到
Server listening at http://localhost:3000
。 -
打开
index.html
:在浏览器中直接打开
index.html
文件。 -
测试:
- 选择一个大文件(例如几十 MB 或上百 MB)。
- 点击"开始上传"。
- 观察哈希计算进度和总上传进度。
- 在上传过程中可以尝试点击"暂停"和"继续"。
- 如果关闭浏览器或刷新页面,再次选择相同文件,它应该能从上次中断的地方继续上传(因为使用了
localStorage
存储进度)。 - 上传完成后,你会在
server.js
所在的uploads
目录下找到合并后的文件。
关键点和注意事项
- 错误处理和重试: 示例中的错误处理较为简单。实际应用中,分片上传失败后应有重试机制(带指数退避),以及更完善的错误日志记录。
- 并发上传: 为了提高上传速度,可以同时上传多个分片。这需要在 Worker 中维护一个并发队列,控制同时进行的上传请求数量。
- 文件完整性校验: 在服务器端合并文件后,最好也计算一次文件的 MD5 或 SHA1 值,并与客户端上传的哈希值进行比对,确保文件完整性。
- 安全性: 上传接口需要进行身份验证、文件类型校验、大小限制、恶意文件扫描等安全措施。
- 服务器存储: 示例中直接存储在本地文件系统,生产环境应考虑使用对象存储(如 AWS S3, Aliyun OSS)或分布式文件系统。
postMessage
的数据传输: 当通过postMessage
传递ArrayBuffer
等可转移对象时,它们的所有权会从发送线程转移到接收线程,发送线程将无法再访问这些数据。这是一种高效的传输方式,避免了数据拷贝。SparkMD5
的引入: 在 Worker 中使用importScripts
引入外部脚本。localStorage
用于断点续传: 客户端使用localStorage
记录已上传分片的索引和文件哈希,以便下次继续。这只是客户端的记录,服务器端也需要维护这些状态。- UI 反馈: 实时更新进度条和消息,给用户良好的体验。
- 暂停/恢复逻辑: 通过
isPaused
标志控制 Worker 和主线程的上传行为。
这个实现提供了一个完整的 Web Worker 大文件断点续传的框架,你可以根据实际需求进行扩展和优化。