前言
相信每个前端开发者,都接触过文件上传的需求,当遇到几十上百兆甚至几个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 卡顿
- 新建 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();
};
- 主线程调用 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阻塞问题。