大文件断点续传笔记

前端通过 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/[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}`);
});

运行步骤

  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 大文件断点续传的框架,你可以根据实际需求进行扩展和优化。

相关推荐
10年前端老司机5 分钟前
Vue3项目中使用vue-draggable-plus实现拖拽需求简直不要太丝滑
前端·javascript·vue.js
&白帝&5 小时前
前端实现截图的几种方法
前端
动能小子ohhh5 小时前
html实现登录与注册功能案例(不写死且只使用js)
开发语言·前端·javascript·python·html
Jimmy6 小时前
理解 React Context API: 实用指南
前端·javascript·react.js
保持学习ing6 小时前
SpringBoot电脑商城项目--显示勾选+确认订单页收货地址
java·前端·spring boot·后端·交互·jquery
李明一.6 小时前
Java 全栈开发学习:从后端基石到前端灵动的成长之路
java·前端·学习
观默7 小时前
我用AI造了个“懂我家娃”的育儿助手
前端·人工智能·产品
crary,记忆7 小时前
微前端MFE:(React 与 Angular)框架之间的通信方式
前端·javascript·学习·react.js·angular
星空寻流年7 小时前
javaScirpt学习第七章(数组)-第一部分
前端·javascript·学习