前端大文件上传和断点续传

一、为什么需要大文件上传和断点续传?

传统的单文件上传方式,当文件体积较大时,会面临以下挑战:

  • 请求超时:HTTP请求通常有超时限制,大文件上传时间过长容易导致请求超时。
  • 网络不稳定:网络波动、中断可能导致上传失败,用户体验差。
  • 服务器压力:单个大文件上传会长时间占用服务器资源。
  • 用户体验:上传失败后需要重新上传整个文件,耗时且 frustrates 用户。

**分片上传(Chunked Upload)断点续传(Resumable Upload)**是解决这些问题的核心方案。

  • 分片上传:将大文件分割成若干个小文件片(chunk),然后逐个上传这些文件片。即使某个文件片上传失败,也只需要重新上传该文件片,而不是整个文件。所有文件片上传完成后,再由服务器将它们合并成完整的文件。
  • 断点续传:在分片上传的基础上,记录每个文件片的上传状态。当上传中断后,下次上传时可以查询服务器已上传的文件片列表,然后只上传未上传的文件片,从而实现从中断处继续上传。

二、核心概念

  1. 文件分片 (File Chunking) :

    • 利用 File 对象的 slice 方法将文件切割成固定大小的块。这是实现分片上传的基础。
    • 例如:file.slice(start, end)
  2. 文件唯一标识 (File Hashing/Fingerprinting) :

    • 为了实现断点续传和秒传(如果服务器已存在相同文件则直接完成上传),需要为每个文件生成一个唯一的标识符。通常使用文件的哈希值(如 MD5, SHA-256)作为标识。
    • 计算哈希值通常需要读取整个文件内容,对于大文件,这个过程可能耗时且阻塞主线程。可以考虑使用 Web Worker 在后台计算,或者采用抽样哈希(只计算文件的一部分,如开头、中间、结尾的几个块)来提高效率,但抽样哈希的唯一性不如全量哈希可靠。 [1][2]
  3. 断点续传原理:

    • 客户端 :在上传前,向服务器查询该文件(通过文件哈希)已上传的分片列表。 [1][3]
    • 服务器:维护每个文件(通过文件哈希)已接收到的分片信息。
    • 上传逻辑 :客户端只上传服务器尚未接收到的分片。 [1][4]
    • 合并 :所有分片上传完成后,客户端通知服务器进行文件合并。 [1][5]
  4. 并发上传 (Concurrent Uploads) :

    • 为了提高上传速度,可以同时上传多个文件分片。
    • 需要控制并发数量,避免同时发出过多请求导致浏览器或服务器压力过大。 [4][6]

三、前端实现步骤(详细代码讲解)

我们将通过一个简单的 HTML 页面和 JavaScript 代码来演示大文件上传和断点续传的实现。

HTML 结构 (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>大文件上传与断点续传</title>
    <style>
        body { font-family: Arial, sans-serif; margin: 20px; }
        .container { max-width: 800px; margin: 0 auto; padding: 20px; border: 1px solid #ccc; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }
        input[type="file"] { margin-bottom: 10px; }
        button { padding: 10px 15px; background-color: #007bff; color: white; border: none; border-radius: 5px; cursor: pointer; margin-right: 10px; }
        button:hover { background-color: #0056b3; }
        button:disabled { background-color: #cccccc; cursor: not-allowed; }
        .progress-bar-container { width: 100%; background-color: #f3f3f3; border-radius: 5px; margin-top: 10px; }
        .progress-bar { width: 0%; height: 20px; background-color: #4CAF50; border-radius: 5px; text-align: center; line-height: 20px; color: white; }
        .file-info { margin-top: 20px; border-top: 1px solid #eee; padding-top: 15px; }
        .chunk-status { margin-top: 10px; font-size: 0.9em; color: #555; }
        .chunk-item { display: inline-block; width: 10px; height: 10px; border: 1px solid #ccc; margin: 1px; }
        .chunk-item.uploaded { background-color: #4CAF50; }
        .chunk-item.pending { background-color: #f0ad4e; }
        .chunk-item.error { background-color: #d9534f; }
    </style>
</head>
<body>
    <div class="container">
        <h1>大文件上传与断点续传</h1>
        <input type="file" id="fileInput">
        <button id="uploadBtn" disabled>上传</button>
        <button id="pauseBtn" disabled>暂停</button>
        <button id="resumeBtn" disabled>继续</button>

        <div class="file-info" style="display: none;">
            <p>文件名: <span id="fileName"></span></p>
            <p>文件大小: <span id="fileSize"></span></p>
            <p>文件哈希: <span id="fileHash"></span></p>
            <p>上传进度: <span id="uploadProgressText">0%</span></p>
            <div class="progress-bar-container">
                <div class="progress-bar" id="uploadProgressBar"></div>
            </div>
            <p>分片状态:</p>
            <div id="chunkStatusContainer" class="chunk-status"></div>
        </div>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/[email protected]/spark-md5.min.js"></script>
    <script src="main.js"></script>
</body>
</html>

JavaScript 逻辑 (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 fileNameSpan = document.getElementById('fileName');
const fileSizeSpan = document.getElementById('fileSize');
const fileHashSpan = document.getElementById('fileHash');
const uploadProgressText = document.getElementById('uploadProgressText');
const uploadProgressBar = document.getElementById('uploadProgressBar');
const fileInfoDiv = document.querySelector('.file-info');
const chunkStatusContainer = document.getElementById('chunkStatusContainer');

// 配置参数
const CHUNK_SIZE = 1 * 1024 * 1024; // 1MB 每个分片的大小
const CONCURRENCY_LIMIT = 3; // 并发上传数
const API_BASE_URL = 'http://localhost:3000'; // 后端API地址,请替换为你的后端地址

let selectedFile = null;
let fileHash = '';
let chunks = [];
let uploadedChunks = new Set(); // 记录已上传的分片索引
let isUploading = false;
let isPaused = false;
let controller = null; // AbortController 用于取消 Fetch 请求

fileInput.addEventListener('change', handleFileChange);
uploadBtn.addEventListener('click', startUpload);
pauseBtn.addEventListener('click', pauseUpload);
resumeBtn.addEventListener('click', resumeUpload);

// 1. 文件选择与信息展示
function handleFileChange(event) {
    selectedFile = event.target.files[0];
    if (!selectedFile) {
        resetUI();
        return;
    }

    fileNameSpan.textContent = selectedFile.name;
    fileSizeSpan.textContent = formatBytes(selectedFile.size);
    fileInfoDiv.style.display = 'block';
    uploadBtn.disabled = true; // 先禁用上传,等待哈希计算
    pauseBtn.disabled = true;
    resumeBtn.disabled = true;
    uploadProgressText.textContent = '0%';
    uploadProgressBar.style.width = '0%';
    chunkStatusContainer.innerHTML = '';

    // 计算文件哈希
    calculateFileHash(selectedFile).then(hash => {
        fileHash = hash;
        fileHashSpan.textContent = hash;
        uploadBtn.disabled = false; // 哈希计算完成后启用上传按钮
        console.log('文件哈希:', fileHash);
    }).catch(error => {
        console.error('文件哈希计算失败:', error);
        alert('文件哈希计算失败!');
        resetUI();
    });
}

// 辅助函数:格式化文件大小
function formatBytes(bytes, decimals = 2) {
    if (bytes === 0) return '0 Bytes';
    const k = 1024;
    const dm = decimals < 0 ? 0 : decimals;
    const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}

// 2. 计算文件哈希 (使用 spark-md5 和 Web Worker 模拟异步)
function calculateFileHash(file) {
    return new Promise((resolve, reject) => {
        const spark = new SparkMD5.ArrayBuffer();
        const fileReader = new FileReader();
        const chunkSize = 2 * 1024 * 1024; // 每次读取2MB进行哈希计算
        let offset = 0;

        fileReader.onload = function (e) {
            spark.append(e.target.result); // Append array buffer
            offset += e.target.result.byteLength;

            if (offset < file.size) {
                readNextChunk();
            } else {
                resolve(spark.end()); // Compute hash
            }
        };

        fileReader.onerror = function () {
            reject('文件读取失败');
        };

        function readNextChunk() {
            const slice = file.slice(offset, offset + chunkSize);
            fileReader.readAsArrayBuffer(slice);
        }

        readNextChunk();
    });
}

// 3. 文件分片
function createFileChunks(file, hash) {
    const fileChunks = [];
    let current = 0;
    let index = 0;
    while (current < file.size) {
        const chunk = file.slice(current, current + CHUNK_SIZE);
        fileChunks.push({
            fileHash: hash,
            chunk: chunk,
            index: index,
            size: chunk.size,
            fileName: file.name,
            totalChunks: Math.ceil(file.size / CHUNK_SIZE),
            status: 'pending' // pending, uploading, uploaded, error
        });
        current += CHUNK_SIZE;
        index++;
    }
    chunks = fileChunks; // 保存到全局变量
    renderChunkStatus();
    return fileChunks;
}

// 渲染分片状态小方块
function renderChunkStatus() {
    chunkStatusContainer.innerHTML = '';
    chunks.forEach(chunk => {
        const div = document.createElement('div');
        div.classList.add('chunk-item');
        div.classList.add(chunk.status);
        div.title = `分片 ${chunk.index + 1}: ${chunk.status}`;
        chunkStatusContainer.appendChild(div);
    });
}

function updateChunkStatusUI(index, status) {
    if (chunks[index]) {
        chunks[index].status = status;
        const chunkItem = chunkStatusContainer.children[index];
        if (chunkItem) {
            chunkItem.className = 'chunk-item ' + status;
        }
    }
}

// 4. 开始上传
async function startUpload() {
    if (!selectedFile || !fileHash) {
        alert('请先选择文件并等待哈希计算完成!');
        return;
    }

    uploadBtn.disabled = true;
    pauseBtn.disabled = false;
    resumeBtn.disabled = true;
    isUploading = true;
    isPaused = false;
    controller = new AbortController(); // 初始化 AbortController

    chunks = createFileChunks(selectedFile, fileHash);
    uploadedChunks.clear(); // 清空已上传列表

    // 检查文件是否已存在或已部分上传
    try {
        const response = await fetch(`${API_BASE_URL}/upload/verify`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                fileHash: fileHash,
                fileName: selectedFile.name,
                fileSize: selectedFile.size,
                totalChunks: chunks.length
            })
        });
        const data = await response.json();

        if (data.uploaded) {
            alert('文件已秒传成功!');
            updateProgressBar(100);
            chunks.forEach((_, i) => updateChunkStatusUI(i, 'uploaded'));
            resetUI();
            return;
        }

        if (data.uploadedChunks && data.uploadedChunks.length > 0) {
            data.uploadedChunks.forEach(index => {
                uploadedChunks.add(index);
                updateChunkStatusUI(index, 'uploaded');
            });
            console.log('已上传分片:', uploadedChunks);
            updateProgressBar(uploadedChunks.size / chunks.length * 100);
        }

        // 开始上传未完成的分片
        await uploadChunksSequentially();

    } catch (error) {
        console.error('文件验证或上传失败:', error);
        alert('上传失败,请重试!');
        resetUI();
    }
}

// 5. 上传分片(使用Promise池控制并发)
async function uploadChunksSequentially() {
    const pendingChunks = chunks.filter(chunk => !uploadedChunks.has(chunk.index));
    let currentConcurrency = 0;
    let uploadQueue = [];

    const uploadNext = async () => {
        if (!isUploading || isPaused) {
            console.log('上传暂停或停止。');
            return;
        }

        if (pendingChunks.length === 0 && uploadQueue.length === 0) {
            // 所有分片都已处理
            if (uploadedChunks.size === chunks.length) {
                console.log('所有分片上传完成,通知服务器合并。');
                await mergeChunks();
            }
            return;
        }

        while (currentConcurrency < CONCURRENCY_LIMIT && pendingChunks.length > 0) {
            const chunkToUpload = pendingChunks.shift();
            if (!chunkToUpload) continue; // 队列可能为空

            currentConcurrency++;
            uploadQueue.push(
                uploadChunk(chunkToUpload)
                    .finally(() => {
                        currentConcurrency--;
                        uploadNext(); // 递归调用,尝试上传下一个
                    })
            );
        }
    };

    // 启动上传
    uploadNext();
    await Promise.allSettled(uploadQueue); // 等待所有当前并发的请求完成
}


async function uploadChunk(chunkInfo) {
    const formData = new FormData();
    formData.append('fileHash', chunkInfo.fileHash);
    formData.append('chunk', chunkInfo.chunk);
    formData.append('index', chunkInfo.index);
    formData.append('totalChunks', chunkInfo.totalChunks);
    formData.append('fileName', chunkInfo.fileName);

    updateChunkStatusUI(chunkInfo.index, 'uploading');

    try {
        const response = await fetch(`${API_BASE_URL}/upload/chunk`, {
            method: 'POST',
            body: formData,
            signal: controller.signal // 关联 AbortController
        });

        if (response.ok) {
            uploadedChunks.add(chunkInfo.index);
            updateChunkStatusUI(chunkInfo.index, 'uploaded');
            updateProgressBar(uploadedChunks.size / chunks.length * 100);
            console.log(`分片 ${chunkInfo.index} 上传成功`);
        } else {
            throw new Error(`分片 ${chunkInfo.index} 上传失败: ${response.statusText}`);
        }
    } catch (error) {
        if (error.name === 'AbortError') {
            console.log(`分片 ${chunkInfo.index} 上传已取消`);
            updateChunkStatusUI(chunkInfo.index, 'pending'); // 状态回到待上传
        } else {
            console.error(`分片 ${chunkInfo.index} 上传失败:`, error);
            updateChunkStatusUI(chunkInfo.index, 'error'); // 标记为错误
            // 可以在这里实现重试逻辑
        }
        throw error; // 抛出错误以便 Promise.allSettled 捕获
    }
}

// 6. 合并文件
async function mergeChunks() {
    try {
        const response = await fetch(`${API_BASE_URL}/upload/merge`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                fileHash: fileHash,
                fileName: selectedFile.name,
                totalChunks: chunks.length
            })
        });

        if (response.ok) {
            alert('文件上传并合并成功!');
            resetUI();
        } else {
            throw new Error('文件合并失败');
        }
    } catch (error) {
        console.error('文件合并请求失败:', error);
        alert('文件合并失败,请联系管理员!');
        resetUI();
    }
}

// 7. 进度显示
function updateProgressBar(percentage) {
    uploadProgressBar.style.width = percentage.toFixed(2) + '%';
    uploadProgressText.textContent = percentage.toFixed(2) + '%';
}

// 8. 暂停/继续上传
function pauseUpload() {
    isPaused = true;
    isUploading = false; // 停止上传循环
    controller.abort(); // 取消所有进行中的 fetch 请求
    pauseBtn.disabled = true;
    resumeBtn.disabled = false;
    uploadBtn.disabled = true; // 暂停后上传按钮仍然禁用
    console.log('上传已暂停。');
}

async function resumeUpload() {
    if (!selectedFile || !fileHash) {
        alert('没有文件或哈希值,无法继续上传。');
        return;
    }
    if (uploadedChunks.size === chunks.length) {
        alert('所有分片已上传,无需继续。');
        resetUI();
        return;
    }

    isPaused = false;
    isUploading = true;
    controller = new AbortController(); // 重新初始化 AbortController
    pauseBtn.disabled = false;
    resumeBtn.disabled = true;
    uploadBtn.disabled = true;

    console.log('上传已继续。');
    await uploadChunksSequentially();
}

// 重置UI状态
function resetUI() {
    selectedFile = null;
    fileHash = '';
    chunks = [];
    uploadedChunks.clear();
    isUploading = false;
    isPaused = false;
    if (controller) {
        controller.abort(); // 确保所有请求都被取消
        controller = null;
    }

    fileInput.value = '';
    uploadBtn.disabled = true;
    pauseBtn.disabled = true;
    resumeBtn.disabled = true;
    fileInfoDiv.style.display = 'none';
    fileNameSpan.textContent = '';
    fileSizeSpan.textContent = '';
    fileHashSpan.textContent = '';
    uploadProgressText.textContent = '0%';
    uploadProgressBar.style.width = '0%';
    chunkStatusContainer.innerHTML = '';
}

// 初始禁用上传按钮
uploadBtn.disabled = true;
pauseBtn.disabled = true;
resumeBtn.disabled = true;

代码讲解:

  1. HTML 结构:

    • fileInput: 文件选择输入框。
    • uploadBtn, pauseBtn, resumeBtn: 上传、暂停、继续按钮。
    • file-info 区域: 用于显示文件名、大小、哈希、总进度和分片状态。
    • spark-md5.min.js: 引入第三方库 spark-md5 用于快速计算文件 MD5 哈希值。 [1][5]
  2. 全局变量和配置:

    • CHUNK_SIZE: 定义每个文件分片的大小,这里设置为 1MB。实际应用中可以根据网络情况和服务器能力调整。
    • CONCURRENCY_LIMIT: 定义同时上传的分片数量,控制并发。
    • API_BASE_URL: 后端 API 的基础 URL。
    • selectedFile, fileHash, chunks, uploadedChunks, isUploading, isPaused, controller: 用于存储文件信息、上传状态和控制上传流程。
  3. 文件选择 (handleFileChange) :

    • 当用户选择文件后,获取 selectedFile 对象。
    • 显示文件的基本信息。
    • 计算文件哈希 : 调用 calculateFileHash 函数。这是一个耗时操作,因此在计算完成前禁用上传按钮。
    • calculateFileHash 使用 FileReader 异步读取文件内容,并通过 spark-md5 库逐步计算 MD5 哈希。为了避免阻塞主线程,这里模拟了分块读取和计算,实际生产中可以考虑使用 Web Worker 来完全避免主线程阻塞。 [7][8]
  4. 文件分片 (createFileChunks) :

    • 根据 CHUNK_SIZEselectedFile 使用 file.slice() 方法分割成多个 Blob 对象,每个 Blob 代表一个文件分片。 [5][9]
    • 每个分片对象包含 fileHash (用于关联整个文件)、chunk (分片数据)、index (分片序号)、fileNametotalChunks (总分片数) 和 status (当前状态)。
    • renderChunkStatus 函数负责在页面上可视化每个分片的状态(小方块)。
  5. 开始上传 (startUpload) :

    • 禁用上传按钮,启用暂停按钮。

    • 文件验证/断点续传查询 : 首先向后端发送一个 /upload/verify 请求,携带文件哈希、文件名、文件大小等信息。

      • 后端会检查该文件是否已存在(实现秒传)。 [1][5]
      • 如果文件已存在,则直接提示秒传成功,并重置 UI。
      • 如果文件未完全上传,后端会返回已上传的分片索引列表 (uploadedChunks)。 [1][3]
      • 前端根据返回的 uploadedChunks 更新 uploadedChunks Set,并更新分片状态 UI 和进度条。
    • 调用 uploadChunksSequentially 开始上传未完成的分片。

  6. 上传分片 (uploadChunksSequentially, uploadChunk) :

    • uploadChunksSequentially 实现了并发控制的逻辑。它维护一个 pendingChunks 队列和 currentConcurrency 计数器。

    • uploadNext 函数会检查当前并发数是否小于 CONCURRENCY_LIMITpendingChunks 队列中还有待上传的分片,如果是,则取出分片并调用 uploadChunk

    • uploadChunk 函数负责发送单个分片的 fetch 请求。

      • 使用 FormData 封装分片数据和其他元信息。
      • fetch 请求中传入 signal: controller.signal,用于实现暂停时取消请求。
      • 成功上传后,将分片索引添加到 uploadedChunks Set,并更新 UI 进度条和分片状态。
      • 失败时,标记分片状态为 error,并可以根据需要实现重试逻辑。
  7. 合并文件 (mergeChunks) :

    • 当所有分片都成功上传后(即 uploadedChunks.size === chunks.length),前端会向后端发送一个 /upload/merge 请求,通知后端将所有分片合并成完整的文件。 [1][5]
    • 后端接收到合并请求后,会根据 fileHash 找到所有分片,按顺序合并,并清理临时分片文件。
  8. 进度显示 (updateProgressBar) :

    • 根据已上传分片的数量占总分片数的比例,更新进度条的宽度和文本。
  9. 暂停/继续 (pauseUpload, resumeUpload) :

    • pauseUpload:

      • 设置 isPaused = trueisUploading = false,阻止 uploadChunksSequentially 继续上传。
      • 最关键的是调用 controller.abort(),这会取消所有当前正在进行的 fetch 请求,使它们抛出 AbortError
      • 更新按钮状态。
    • resumeUpload:

      • 重新初始化 AbortController
      • 设置 isPaused = falseisUploading = true
      • 再次调用 uploadChunksSequentially,它会从 uploadedChunks 中已记录的进度开始,继续上传剩余的分片。
  10. UI 重置 (resetUI) :

    • 在上传完成、失败或用户取消后,重置所有状态变量和 UI 元素到初始状态。

四、后端考虑(简要说明)

虽然本示例主要关注前端,但大文件上传和断点续传离不开后端的支持。后端需要实现以下功能:

  1. 文件验证接口 (/upload/verify) :

    • 接收前端发送的文件哈希、文件名、大小等信息。
    • 查询数据库或存储系统,判断该文件是否已存在(秒传)。
    • 如果文件已存在,直接返回成功。
    • 如果文件未完全上传,返回已接收到的分片列表(例如,已上传分片的索引数组)。
    • 为新文件或未完成的文件创建一个临时目录来存放分片。
  2. 分片上传接口 (/upload/chunk) :

    • 接收前端发送的分片数据(通常是 multipart/form-data 格式)、文件哈希、分片索引等。
    • 将接收到的分片保存到对应的临时目录中,并以分片索引命名(例如 fileHash/chunk_0, fileHash/chunk_1)。
    • 记录该分片已成功接收。
  3. 文件合并接口 (/upload/merge) :

    • 接收前端发送的文件哈希、文件名、总分片数等信息。
    • 根据文件哈希找到所有已上传的分片。
    • 按照分片索引的顺序读取所有分片,并将其内容写入一个最终的文件。
    • 合并完成后,清理临时分片文件和目录。

后端技术选型 :

后端可以使用 Node.js (如 Express, Koa)、Python (如 Flask, Django)、Java (如 Spring Boot)、Go 等任何支持文件上传和处理的语言和框架来实现。关键在于正确处理文件流、存储分片、以及合并逻辑。

五、潜在优化和高级话题

  • Web Workers 计算哈希 : 对于非常大的文件,哈希计算可能耗时。将哈希计算放在 Web Worker 中,可以完全不阻塞主线程,提升用户体验。 [2][7]
  • 客户端状态持久化 : 在某些场景下,为了在浏览器关闭后也能恢复上传,可以将 uploadedChunks 等状态信息存储到 localStorageIndexedDB 中。
  • 分片重试机制 : 在 uploadChunk 失败时,可以实现指数退避等重试策略,增加上传的健壮性。
  • 文件完整性校验: 在文件合并后,后端可以再次计算合并后文件的哈希值,与前端提供的哈希值进行比对,确保文件在传输过程中没有损坏。
  • 文件切片策略: 除了固定大小切片,还可以根据文件内容(如视频关键帧)进行智能切片,但这会增加复杂性。
  • 上传队列管理 : 对于大量分片,可以使用更复杂的队列管理系统,例如限制并发的 Promise.allSettled 结合递归调用的方式,或者使用专门的库。 [4][6]
  • CDN/OSS 直传 : 对于生产环境,通常会结合 CDN 或对象存储服务(如阿里云 OSS、AWS S3)的直传功能,将文件直接上传到存储服务,减轻后端服务器压力。这些服务通常也内置了分片上传和断点续传的能力。 [10]

通过上述详细的讲解和代码示例,你应该对前端如何实现大文件上传和断点续传有了全面的理解。记住,前端和后端紧密协作是实现这一功能的关键。


推荐好文:

  1. 一文吃透 大文件分片上传、断点续传、秒传 - 稀土掘金
  2. 大文件处理(上传,下载)思考 - 阿里云开发者社区
  3. 面试官:大文件上传如何做断点续传? | web前端面试 - Vue3
  4. 如何实现大文件上传、断点续传、切片上传- Xproer-松鼠- 博客园
  5. 大文件上传以及断点续传(项目) - DragonPeng的博客
  6. 前端大文件切片上传,断点续传,并发控制实现原创 - CSDN博客
  7. 前端如何获取图片的hash值 - PingCode 智库
  8. 在线计算文件Hash值- 拉米工具
  9. 大文件上传优化,断点续传,分片上传- javascript - SegmentFault 思否
  10. 使用分片上传的方式上传大文件_对象存储(OSS) - 阿里云文档
相关推荐
打小就很皮...2 小时前
简单实现Ajax基础应用
前端·javascript·ajax
wanhengidc3 小时前
服务器租用:高防CDN和加速CDN的区别
运维·服务器·前端
哆啦刘小洋4 小时前
HTML Day04
前端·html
再学一点就睡4 小时前
JSON Schema:禁锢的枷锁还是突破的阶梯?
前端·json
从零开始学习人工智能5 小时前
FastMCP:构建 MCP 服务器和客户端的高效 Python 框架
服务器·前端·网络
烛阴6 小时前
自动化测试、前后端mock数据量产利器:Chance.js深度教程
前端·javascript·后端
好好学习O(∩_∩)O6 小时前
QT6引入QMediaPlaylist类
前端·c++·ffmpeg·前端框架
敲代码的小吉米6 小时前
前端HTML contenteditable 属性使用指南
前端·html
testleaf6 小时前
React知识点梳理
前端·react.js·typescript
站在风口的猪11086 小时前
《前端面试题:HTML5、CSS3、ES6新特性》
前端·css3·html5