保姆级 | 实现大文件切片上传、断点续传与秒传(Vue3+React+Node全覆盖)

前言

相信每个前端开发者,都接触过文件上传的需求,当遇到几十上百兆甚至几个G的大文件,常规上传就彻底歇菜------上传耗时久、网络中断就前功尽弃......

今天笔者给大家带来这篇教程,带你学习大文件上传 的核心方案:切片上传+断点续传+秒传,额外补充暂停/恢复上传功能,覆盖 Vue3+Vite、React、Node 三种技术栈!

核心原理

核心流程时序图

切片上传

将大文件按固定大小(如1MB)分割为多个小切片,每个切片携带唯一标识(文件 Hash+切片索引),并行上传到服务器;所有切片上传完成后,前端通知后端合并切片,还原为完整文件。

关键: 切片大小需合理。太小会增加请求数,太大仍会超时,通常选择 1-5MB;切片唯一标识用于后端区分不同文件的切片,避免混淆。

秒传

通过 spark-md5 计算文件的唯一 Hash 值,上传前先将 Hash 值发送给后端;后端查询该 Hash 值对应的文件是否存在,若存在则直接返回"上传成功",无需上传任何切片,实现秒传

补充:若文件内容相同但文件名不同,Hash 值一致,需额外处理(如Hash+文件名前缀),避免误判。

断点续传

上传前,前端将文件 Hash 值发送给后端,后端返回该文件已上传的切片列表;前端过滤掉已上传的切片,仅上传未完成的切片,从而实现"断点续传"。

关键:后端需保存每个文件的切片上传记录,如按 Hash 值创建文件夹,存储已接收的切片,确保刷新页面、断网后,能恢复上传进度。

Web Worker作用

JavaScript 是单线程模型,若直接在主线程中计算大文件 Hash、分割切片,会导致 UI 阻塞,页面卡顿、无法操作。

Web Worker 可创建独立的后台线程,专门处理这些耗时操作,主线程负责 UI 交互,两者互不干扰。

Web Worker 无法访问 DOM、window 对象,若需更新上传进度,需通过 postMessage 向主线程发送消息,主线程接收后更新 UI。

前端核心实现

Step 1:前端基础准备

安装依赖

bash 复制代码
# 安装axios(请求)、spark-md5(Hash计算)
npm install axios spark-md5 --save
# 下载 spark-md5 到 public 目录
cp node_modules/spark-md5/spark-md5.min.js public/

Step 2:计算文件 MD5

计算 MD5,使用 Web Worker 防止 UI 卡顿

  1. 新建 Web Worker 文件(hash-worker.js)
ini 复制代码
// public/hash-worker.js
self.onmessage = (e) => {
  const { file } = e.data;
  const chunkSize = Math.ceil(file.size / 10); // 分10块计算
  const spark = new self.SparkMD5.ArrayBuffer();
  const reader = new FileReader();
  let current = 0;
  
  reader.onload = (e) => {
    spark.append(e.target.result);
    if (++current < 10) loadNext();
    else self.postMessage({ type: 'complete', hash: spark.end() });
  };
  
  const loadNext = () => {
    const start = current * chunkSize;
    const end = Math.min(start + chunkSize, file.size);
    reader.readAsArrayBuffer(file.slice(start, end));
  };
  
  loadNext();
};
  1. 主线程调用 Web Worker,封装 calculateHash 方法
ini 复制代码
// 封装计算Hash的方法,调用Web Worker
// utils/calculateHash.js
export const calculateHash = (file) => {
  return new Promise((resolve, reject) => {
    const worker = new Worker('/hash-worker.js');
    worker.postMessage({ file });
    worker.onmessage = (e) => {
      if (e.data.type === 'complete') {
        resolve(e.data.hash);
        worker.terminate();
      }
    };
    worker.onerror = (err) => reject(err);
  });
};

Step 3:Vue 3 实现

使用 Composition API

ini 复制代码
<!-- Vue3Upload.vue -->
<template>
  <div>
    <input type="file" @change="handleFileChange" />
    <div class="btn-group">
      <button @click="startUpload" :disabled="status === 'uploading'">开始</button>
      <button @click="pauseUpload" :disabled="status !== 'uploading'">暂停</button>
      <button @click="resumeUpload" :disabled="status !== 'paused'">恢复</button>
    </div>
    <div v-if="status !== 'idle'">
      <div class="progress-bar">
        <div class="progress" :style="{ width: percent + '%' }"></div>
      </div>
      <div>{{ percent }}% - {{ statusText }}</div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import axios from 'axios';
import { calculateHash } from './utils/calculateHash';

const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB
const MAX_CONCURRENT = 6;

const file = ref(null);
const status = ref('idle'); // idle, uploading, paused, success, error
const percent = ref(0);
let cancelTokens = [];
let currentChunks = [];

const statusTextMap = { idle: '待上传', uploading: '上传中', paused: '已暂停', success: '成功', error: '失败' };
const statusText = computed(() => statusTextMap[status.value]);

const handleFileChange = (e) => {
  file.value = e.target.files[0];
  status.value = 'idle';
  percent.value = 0;
};

const pauseUpload = () => {
  if (status.value !== 'uploading') return;
  cancelTokens.forEach(cancel => cancel());
  cancelTokens = [];
  status.value = 'paused';
};

const resumeUpload = () => {
  if (status.value !== 'paused') return;
  startUpload();
};

const uploadChunk = (chunkInfo, fileHash, total) => {
  const source = axios.CancelToken.source();
  cancelTokens.push(source.cancel);
  
  const formData = new FormData();
  formData.append('chunk', chunkInfo.chunk);
  formData.append('hash', chunkInfo.hash);
  formData.append('fileHash', fileHash);
  
  return axios.post('/api/upload', formData, { cancelToken: source.token })
    .then(() => {
      percent.value = Math.ceil((++window._completed) / total * 100);
      if (window._completed === total) {
        return axios.post('/api/merge', { hash: fileHash, ext: file.value.name.split('.').pop() });
      }
    });
};

const startUpload = async () => {
  if (!file.value) return;
  status.value = 'uploading';
  cancelTokens = [];
  window._completed = 0;
  
  const hash = await calculateHash(file.value);
  const ext = file.value.name.split('.').pop();
  
  // 检查断点续传
  const { data } = await axios.post('/api/check', { hash, ext });
  if (!data.shouldUpload) {
    status.value = 'success';
    percent.value = 100;
    return alert('秒传成功');
  }
  
  // 分割切片
  const chunks = [];
  for (let i = 0, cur = 0; cur < file.value.size; i++, cur += CHUNK_SIZE) {
    chunks.push({
      chunk: file.value.slice(cur, cur + CHUNK_SIZE),
      hash: `${hash}-${i}`,
      index: i
    });
  }
  
  // 过滤已上传
  currentChunks = chunks.filter(c => !data.uploadedList?.includes(c.hash));
  if (!currentChunks.length) {
    await axios.post('/api/merge', { hash, ext });
    status.value = 'success';
    return;
  }
  
  // 并发上传
  const pool = [];
  let idx = 0;
  const total = currentChunks.length;
  
  const run = async () => {
    while (idx < total && status.value === 'uploading') {
      if (pool.length >= MAX_CONCURRENT) await Promise.race(pool);
      const promise = uploadChunk(currentChunks[idx++], hash, total);
      pool.push(promise);
      promise.finally(() => pool.splice(pool.indexOf(promise), 1));
    }
    await Promise.all(pool);
    if (status.value === 'uploading') status.value = 'success';
  };
  
  await run();
};
</script>

<style scoped>
.progress-bar { width: 100%; height: 30px; background: #f0f0f0; border-radius: 15px; overflow: hidden; }
.progress { height: 100%; background: #409eff; transition: width 0.3s; }
.btn-group { margin: 10px 0; display: flex; gap: 10px; }
</style>

Step 4:React 实现

使用 hooks,jsx 语法

ini 复制代码
// ReactUpload.jsx
import React, { useState, useRef, useCallback } from 'react';
import axios from 'axios';
import { calculateHash } from './utils/calculateHash';

const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB
const MAX_CONCURRENT = 6;

const ReactUpload = () => {
  const [file, setFile] = useState(null);
  const [status, setStatus] = useState('idle'); // idle, uploading, paused, success, error
  const [percent, setPercent] = useState(0);
  
  const cancelTokensRef = useRef([]);
  const statusRef = useRef(status);
  const completedRef = useRef(0);

  // 同步状态
  React.useEffect(() => { statusRef.current = status; }, [status]);

  const handleFileChange = (e) => {
    setFile(e.target.files[0]);
    setStatus('idle');
    setPercent(0);
  };

  const pauseUpload = () => {
    if (status !== 'uploading') return;
    cancelTokensRef.current.forEach(cancel => cancel());
    cancelTokensRef.current = [];
    setStatus('paused');
  };

  const uploadChunk = useCallback(async (chunkInfo, fileHash, total) => {
    const source = axios.CancelToken.source();
    cancelTokensRef.current.push(source.cancel);
    
    const formData = new FormData();
    formData.append('chunk', chunkInfo.chunk);
    formData.append('hash', chunkInfo.hash);
    formData.append('fileHash', fileHash);
    
    await axios.post('/api/upload', formData, { cancelToken: source.token });
    
    completedRef.current++;
    setPercent(Math.ceil(completedRef.current / total * 100));
    
    if (completedRef.current === total) {
      const ext = file.name.split('.').pop();
      await axios.post('/api/merge', { hash: fileHash, ext });
      setStatus('success');
    }
  }, [file]);

  const startUpload = async () => {
    if (!file || status === 'uploading') return;
    
    setStatus('uploading');
    setPercent(0);
    cancelTokensRef.current = [];
    completedRef.current = 0;
    
    const hash = await calculateHash(file);
    const ext = file.name.split('.').pop();
    
    // 检查断点续传
    const { data } = await axios.post('/api/check', { hash, ext });
    if (!data.shouldUpload) {
      setStatus('success');
      setPercent(100);
      return alert('秒传成功');
    }
    
    // 分割切片
    const chunks = [];
    for (let i = 0, cur = 0; cur < file.size; i++, cur += CHUNK_SIZE) {
      chunks.push({
        chunk: file.slice(cur, cur + CHUNK_SIZE),
        hash: `${hash}-${i}`,
      });
    }
    
    // 过滤已上传
    const needUpload = chunks.filter(c => !data.uploadedList?.includes(c.hash));
    if (!needUpload.length) {
      await axios.post('/api/merge', { hash, ext });
      setStatus('success');
      return;
    }
    
    // 并发上传
    const pool = [];
    let idx = 0;
    const total = needUpload.length;
    
    while (idx < total && statusRef.current === 'uploading') {
      if (pool.length >= MAX_CONCURRENT) await Promise.race(pool);
      const promise = uploadChunk(needUpload[idx++], hash, total);
      pool.push(promise);
      promise.finally(() => pool.splice(pool.indexOf(promise), 1));
    }
    
    await Promise.all(pool);
    if (statusRef.current === 'uploading') setStatus('success');
  };

  const statusTextMap = { idle: '待上传', uploading: '上传中', paused: '已暂停', success: '成功', error: '失败' };

  return (
    <div>
      <input type="file" onChange={handleFileChange} />
      <div style={{ display: 'flex', gap: 10, margin: '10px 0' }}>
        <button onClick={startUpload} disabled={status === 'uploading'}>开始</button>
        <button onClick={pauseUpload} disabled={status !== 'uploading'}>暂停</button>
        <button onClick={() => status === 'paused' && startUpload()} disabled={status !== 'paused'}>恢复</button>
      </div>
      {status !== 'idle' && (
        <div>
          <div style={{ width: '100%', height: 30, background: '#f0f0f0', borderRadius: 15, overflow: 'hidden' }}>
            <div style={{ width: `${percent}%`, height: '100%', background: '#409eff', transition: 'width 0.3s' }} />
          </div>
          <div>{percent}% - {statusTextMap[status]}</div>
        </div>
      )}
    </div>
  );
};

export default ReactUpload;

后端实现

技术栈 Node.js + Express,后端核心职责是接收前端切片、存储切片、检查文件是否存在、合并切片。

安装依赖

lua 复制代码
npm install express formidable fs-extra path --save

后端完整代码(server.js)

ini 复制代码
// server.js 核心部分
const express = require('express');
const multiparty = require('multiparty');
const fs = require('fs-extra');
const path = require('path');

const app = express();
const UPLOAD_DIR = path.resolve(__dirname, 'uploads');

// 检查接口
app.post('/api/check', async (req, res) => {
  const { hash, ext } = req.body;
  const filePath = path.resolve(UPLOAD_DIR, `${hash}.${ext}`);
  
  if (fs.existsSync(filePath)) {
    return res.json({ shouldUpload: false });
  }
  
  const chunkDir = path.resolve(UPLOAD_DIR, hash);
  const uploadedList = fs.existsSync(chunkDir) ? await fs.readdir(chunkDir) : [];
  res.json({ shouldUpload: true, uploadedList });
});

// 上传接口
app.post('/api/upload', (req, res) => {
  const form = new multiparty.Form();
  form.parse(req, async (err, fields, files) => {
    if (err) return res.status(500).send(err);
    const [chunk] = files.chunk;
    const [hash] = fields.hash;
    const [fileHash] = fields.fileHash;
    const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
    await fs.ensureDir(chunkDir);
    await fs.move(chunk.path, path.resolve(chunkDir, hash), { overwrite: true });
    res.json({ code: 0 });
  });
});

// 合并接口
app.post('/api/merge', async (req, res) => {
  const { hash, ext } = req.body;
  const chunkDir = path.resolve(UPLOAD_DIR, hash);
  const chunks = await fs.readdir(chunkDir);
  chunks.sort((a, b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1]));
  
  const writeStream = fs.createWriteStream(path.resolve(UPLOAD_DIR, `${hash}.${ext}`));
  for (const chunk of chunks) {
    await new Promise((resolve) => {
      const readStream = fs.createReadStream(path.resolve(chunkDir, chunk));
      readStream.pipe(writeStream, { end: false });
      readStream.on('end', resolve);
    });
  }
  writeStream.end();
  await fs.remove(chunkDir);
  res.json({ code: 0 });
});

总结

大文件上传 的核心逻辑的是"切片+Hash+并发控制":

  • 切片:将大文件分割为小切片,降低单次请求压力,避免超时。

  • Hash:作为文件唯一标识,实现秒传和断点续传的核心。

  • 并发控制:限制同时上传的切片数量,避免服务器崩溃和UI阻塞。

  • Web Worker:解决大文件Hash计算、切片分割导致的UI阻塞问题。

相关推荐
用户游民2 小时前
Android xml设置fitsSystemWindows与ImmersionBar设置fitsSystemWindows的区别及影响
前端
空中海2 小时前
第三章: Vue 3组合式 API(Composition API)
前端·javascript·vue.js
Wect2 小时前
HTML5 原生拖拽 API 基础原理与核心机制
前端·面试·html
用户游民2 小时前
Android 的 FragmentTransaction 中,hide() 和 add() 方法的执行顺序
前端
前端技术2 小时前
华为余承东:鸿蒙终端设备数突破5500万
java·前端·javascript·人工智能·python·华为·harmonyos
深海鱼在掘金2 小时前
Next.js从入门到实战保姆级教程(第五章):数据获取与缓存策略
前端·typescript·next.js
深海鱼在掘金2 小时前
Next.js从入门到实战保姆级教程(第四章):路由系统详解
前端·typescript·next.js
leafyyuki2 小时前
从零到一落地「智能助手」:一次基于 OpenSpec 的流式对话前端实践
前端·vue.js·人工智能
踩着两条虫2 小时前
VTJ:架构设计模式
前端·架构·ai编程