概述
前端上传大视频文件时,单次上传容易失败、超时,重试只能从头再来。本文实现一套完整的分片上传系统:前端用 SparkMD5 计算文件指纹实现秒传,将文件切成 5MB 分片并发上传,刷新页面后可断点续传,最终合并成完整文件。附带统一 API 接口规范和生产级 TypeScript 代码。
1. 系统架构
┌─────────────────────────────────────────────────────────┐
│ 前端 │
│ 1. 选择文件 → SparkMD5 计算指纹 │
│ 2. POST /upload/prepare 申请上传 │
│ ├── needUpload=false → 秒传(同 MD5 已存在) │
│ └── needUpload=true → 返回 uploadId + 已上传分片 │
│ 3. 并发上传分片 POST /upload/chunk (每个5MB) │
│ 4. 全部分片完成 → POST /upload/merge │
│ 5. 返回 fileId + url │
└─────────────────────────────────────────────────────────┘
2. API 接口规范
通用返回格式
{
"code": 200,
"message": "success",
"data": {},
"requestId": "uuid"
}
接口列表
| 接口 | 说明 |
|---|---|
POST /upload/prepare |
申请上传,返回 uploadId + 秒传判断 |
POST /upload/chunk |
上传单个分片 |
POST /upload/merge |
合并所有分片 |
POST /upload/cancel |
取消上传 |
请求参数
// /upload/prepare
{ fileName: string; fileSize: number; fileType: string; md5: string }
// 响应: { uploadId, chunks: number[], needUpload: boolean }
// /upload/chunk (FormData)
uploadId: string; chunkIndex: number; chunkSize: number; file: Blob
// /upload/merge
{ uploadId: string; fileName: string }
// 响应: { fileId, url, duration? }
3. 前端完整实现
// ===== FileChunkedUpload.tsx =====
import React, { useState } from 'react';
import SparkMD5 from 'spark-md5';
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
const MAX_CONCURRENT_CHUNKS = 3; // 分片并发数
interface UploadState {
status: 'idle' | 'hashing' | 'preparing' | 'uploading' | 'merging' | 'done' | 'error';
progress: number; // 总体 0-100
message: string;
uploadId?: string;
uploadedChunks: number[];
}
async function getFileMD5(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReader();
let offset = 0;
reader.onload = (e) => {
spark.append(e.target!.result as ArrayBuffer);
offset += CHUNK_SIZE;
if (offset < file.size) {
// 继续读下一块
readNext();
} else {
resolve(spark.end());
}
};
reader.onerror = reject;
function readNext() {
const blob = file.slice(offset, offset + CHUNK_SIZE);
reader.readAsArrayBuffer(blob);
}
readNext();
});
}
export default function FileChunkedUpload() {
const [state, setState] = useState<UploadState>({
status: 'idle',
progress: 0,
message: '',
uploadedChunks: [],
});
const [resultUrl, setResultUrl] = useState('');
const uploadFile = async (file: File) => {
try {
// === 步骤 1: 计算 MD5 ===
setState((s) => ({ ...s, status: 'hashing', message: '计算文件指纹...', progress: 0 }));
const md5 = await getFileMD5(file);
// === 步骤 2: 申请上传 ===
setState((s) => ({ ...s, status: 'preparing', message: '准备上传...' }));
const prepareRes = await fetch('/api/upload/prepare', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: file.name,
fileSize: file.size,
fileType: file.type || 'application/octet-stream',
md5,
}),
});
const prepare = await prepareRes.json();
// 秒传判断
if (!prepare.data.needUpload) {
setState((s) => ({ ...s, status: 'done', progress: 100, message: '秒传成功!' }));
setResultUrl(prepare.data.url);
return;
}
const { uploadId, chunks: skippedChunks = [] } = prepare.data;
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
// === 步骤 3: 找出需要上传的分片(跳过已上传的) ===
const pendingChunks: number[] = [];
for (let i = 0; i < totalChunks; i++) {
if (!skippedChunks.includes(i)) {
pendingChunks.push(i);
}
}
setState((s) => ({
...s,
status: 'uploading',
message: `上传中... (${skippedChunks.length}/${totalChunks} 已跳过)`,
uploadId,
uploadedChunks: [...skippedChunks],
}));
// === 步骤 4: 并发上传分片 ===
await uploadChunksWithConcurrency(file, uploadId, pendingChunks, totalChunks, (done, total, chunkIndex) => {
setState((s) => ({
...s,
progress: Math.round((done / total) * 90),
message: `上传中 ${done}/${total} 分片`,
uploadedChunks: [...s.uploadedChunks, chunkIndex],
}));
});
// === 步骤 5: 合并 ===
setState((s) => ({ ...s, status: 'merging', message: '合并文件...' }));
const mergeRes = await fetch('/api/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadId, fileName: file.name }),
});
const merge = await mergeRes.json();
setState((s) => ({ ...s, status: 'done', progress: 100, message: '上传完成!' }));
setResultUrl(merge.data.url);
} catch (err: any) {
setState((s) => ({ ...s, status: 'error', message: `失败:${err.message}` }));
}
};
return (
<div className="file-uploader">
<input
type="file"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) uploadFile(file);
}}
disabled={state.status === 'uploading' || state.status === 'merging'}
/>
{state.status !== 'idle' && (
<div className="upload-progress">
<p>{state.message}</p>
<div className="progress-bar-bg">
<div
className="progress-bar-fill"
style={{ width: `${state.progress}%`, transition: 'width 0.3s' }}
/>
</div>
<p>{state.progress}%</p>
</div>
)}
{resultUrl && <p>结果: <a href={resultUrl} target="_blank">{resultUrl}</a></p>}
</div>
);
}
// 分片并发上传器
async function uploadChunksWithConcurrency(
file: File,
uploadId: string,
chunkIndices: number[],
totalChunks: number,
onProgress: (done: number, total: number, chunkIndex: number) => void,
maxConcurrent = MAX_CONCURRENT_CHUNKS
): Promise<void> {
let completed = 0;
const total = chunkIndices.length + (totalChunks - chunkIndices.length);
let currentIndex = 0;
async function uploadChunk(index: number): Promise<void> {
const start = index * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const blob = file.slice(start, end);
const formData = new FormData();
formData.append('uploadId', uploadId);
formData.append('chunkIndex', String(index));
formData.append('chunkSize', String(end - start));
formData.append('file', blob);
await fetch('/api/upload/chunk', {
method: 'POST',
body: formData,
});
completed++;
onProgress(completed, total, index);
}
async function worker() {
while (currentIndex < chunkIndices.length) {
const idx = currentIndex++;
await uploadChunk(chunkIndices[idx]);
}
}
// 启动并发 workers
const workers: Promise<void>[] = [];
for (let i = 0; i < Math.min(maxConcurrent, chunkIndices.length); i++) {
workers.push(worker());
}
await Promise.all(workers);
}
4. 断点续传原理
断点续传无需前端额外代码。核心在 /upload/prepare 的响应:
{
"uploadId": "abc123",
"chunks": [0, 1, 3, 5], // 已上传的分片索引
"needUpload": true
}
前端根据 chunks 跳过已上传的分片,只上传缺失的分片。即使关闭浏览器再打开,只要 uploadId 还在(存在 localStorage 或 URL 参数),就能续传。
5. localStorage 持久化上传状态
interface UploadRecord {
uploadId: string;
fileName: string;
fileSize: number;
md5: string;
completedChunks: number[];
totalChunks: number;
createdAt: number;
}
const STORAGE_KEY = 'file-upload-records';
function saveUploadRecord(record: UploadRecord) {
const records = loadUploadRecords();
const idx = records.findIndex((r) => r.uploadId === record.uploadId);
if (idx >= 0) {
records[idx] = record;
} else {
records.push(record);
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(records));
}
function loadUploadRecords(): UploadRecord[] {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
} catch { return []; }
}
function findUploadRecordByMD5(md5: string): UploadRecord | undefined {
return loadUploadRecords().find((r) => r.md5 === md5);
}
6. 面试核心要点
为什么能秒传?
- 前端用 SparkMD5 对文件内容计算唯一哈希
/upload/prepare传递 MD5,后端查库看是否已存在- 若存在,直接返回已有的 fileId 和 URL,跳过上传
断点续传怎么实现的?
前端在 /upload/prepare 拿到已上传的分片列表,只上传缺失分片。刷新页面后用 MD5 或 uploadId 恢复上传状态。
多个分片同时上传,怎么保证顺序?
每个分片带 index 编号,/chunk 时指定 chunkIndex。合并阶段后端按 index 顺序拼接,前端无需关心上传顺序。
总结
- SparkMD5 分块计算指纹,大文件不卡顿
- 秒传 的核心是 MD5 去重 +
/prepare接口的needUpload判断 - 断点续传 的关键是
/prepare返回已上传分片列表 - 并发控制 用 worker 模式,
MAX_CONCURRENT_CHUNKS=3平衡速度与稳定性 - 面试规范:上传用
POST + FormData,下载用GET + Stream,实时用WebSocket/SSE
Comments 留言讨论
还没有评论,来抢个沙发,聊聊你的看法~