大文件断点续传笔记

前端通过 Web Workers 实现大文件断点续传是一个非常经典的优化场景,它能有效解决以下问题:

  1. UI 阻塞: 大文件读取(如计算 MD5)、切片等操作是计算密集型的,如果放在主线程会造成页面卡顿甚至无响应。Web Worker 可以将这些任务放到后台线程执行,保持 UI 流畅。
  2. 断点续传: 允许用户在上传中断后从上次中断的地方继续上传,提升用户体验。
  3. 分片上传: 将大文件分割成小块上传,提高上传的稳定性和成功率,也方便服务器管理。

核心概念

  1. File.prototype.slice(): 用于将文件(Blob)切割成指定大小的 Blob 片段。
  2. SparkMD5 (或其他哈希库): 用于计算文件的 MD5 值。MD5 值可以作为文件的唯一标识,用于秒传、校验文件完整性以及断点续传时识别文件。
  3. Web Worker: 在后台线程执行脚本,不阻塞主线程。通过 postMessageonmessage 进行主线程与 Worker 线程之间的通信。
  4. XMLHttpRequest / Fetch API: 用于发送分片数据到服务器。
  5. 服务器端配合: 服务器需要提供接口来接收分片、合并分片、查询已上传分片列表。

实现思路

  1. 用户选择文件: 在主线程中获取 File 对象。

  2. 创建 Worker: 主线程创建 Web Worker 实例。

  3. Worker 任务分配:

    • 文件哈希计算:File 对象发送给 Worker,Worker 负责读取文件内容并计算 MD5 值。这个过程是耗时操作,放在 Worker 中避免阻塞 UI。
    • 文件分片: Worker 根据文件大小和预设分片大小,计算出所有分片的起始和结束位置。
    • 分片上传管理: Worker 负责管理分片的上传顺序、重试机制、以及与服务器通信获取已上传分片列表(断点续传)。
  4. 主线程与 Worker 通信:

    • 主线程 -> Worker: 发送 File 对象(或其关键信息)、分片大小、上传 URL 等。
    • Worker -> 主线程: 发送哈希计算进度、哈希结果、当前分片上传进度、分片上传完成通知、上传失败通知、整体上传完成通知等。
    • 主线程执行实际上传: 虽然 Worker 可以发送 XHR/Fetch 请求,但为了简化错误处理和利用浏览器原生网络栈的优势,通常会让 Worker 准备好分片数据(ArrayBuffer),然后通过 postMessage 发送回主线程,由主线程负责实际的 XMLHttpRequestfetch 请求。

详细代码讲解

分为三个文件:

  1. index.html:前端页面结构。
  2. main.js:主线程逻辑,处理 UI 交互和 Worker 通信。
  3. worker.js:Web Worker 逻辑,负责文件哈希计算和分片管理。
  4. (概念性)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/spark-md5@3.0.2/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}`);
});

运行步骤

  1. 保存文件:

    • index.html
    • main.js
    • worker.js
    • server.js
      将它们放在同一个目录下。
  2. 安装 Node.js 服务器依赖:

    server.js 所在的目录下打开终端,运行:

    csharp 复制代码
    npm init -y
    npm install express multer
  3. 启动服务器:

    server.js 所在的目录下运行:

    vbscript 复制代码
    node server.js

    你将看到 Server listening at http://localhost:3000

  4. 打开 index.html

    在浏览器中直接打开 index.html 文件。

  5. 测试:

    • 选择一个大文件(例如几十 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 大文件断点续传的框架,你可以根据实际需求进行扩展和优化。

相关推荐
崔庆才丨静觅3 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60613 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了4 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅4 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅4 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅4 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment4 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅5 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊5 小时前
jwt介绍
前端
爱敲代码的小鱼5 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax