大文件上传
本文基于 2. 大文件上传.md 迁移整理,目标是:
- 统一知识结构,形成可直接落地的技术文档。
- 修复原笔记中的错误逻辑与代码问题。
- 提供完整、可复用、带注释的示例代码。
1. 核心概念
1.1 为什么大文件上传要“分片”
单请求直传大文件会有三个典型问题:
- 请求耗时长,失败概率高。
- 网络抖动后需要重传整个文件。
- 无法做稳定进度控制与断点恢复。
分片上传的本质是把一个大文件拆成多个小块,分别上传,最终由服务端合并。
1.2 三种常见请求体编码
application/x-www-form-urlencoded适合普通键值对,不适合直接传二进制文件。multipart/form-data文件上传标准方案,支持二进制和多文件。text/plain纯文本场景,文件上传基本不用。
结论:文件上传优先使用 multipart/form-data,图片 Base64 仅在特定场景使用。
2. 后端接口约定(建议)
为了让前端代码可复用,先明确接口协议:
POST /upload/single:单文件上传(multipart/form-data)。POST /upload/single-base64:Base64 上传(application/x-www-form-urlencoded)。POST /upload/chunk:上传切片。GET /upload/already?hash=xxx:查询已上传切片名列表。POST /upload/merge:通知服务端合并切片。
响应建议统一为:
{
"code": 0,
"message": "ok",
"data": {}
}3. 完整前端实现(TypeScript)
以下代码包含:
- 请求封装。
- Base64 与 Hash 工具函数。
- 单文件、多文件、拖拽上传。
- 切片上传与断点续传(含并发与重试)。
import axios, { type AxiosProgressEvent } from 'axios';
import qs from 'qs';
import SparkMD5 from 'spark-md5';
type ApiResult<T = unknown> = {
code: number;
message: string;
data: T;
};
type ChunkTask = {
index: number;
blob: Blob;
chunkName: string;
};
// -----------------------------
// 1) Axios 上传实例
// -----------------------------
const uploadClient = axios.create({
baseURL: 'http://localhost:3000',
timeout: 60_000,
});
uploadClient.interceptors.request.use((config) => {
// 仅对 x-www-form-urlencoded 做序列化
const contentType =
(config.headers?.['Content-Type'] as string | undefined) ??
(config.headers?.['content-type'] as string | undefined);
if (
contentType?.includes('application/x-www-form-urlencoded') &&
config.data &&
typeof config.data !== 'string'
) {
config.data = qs.stringify(config.data);
}
// 注意:multipart/form-data 不要手动写 boundary,让浏览器自动处理
return config;
});
uploadClient.interceptors.response.use(
(response) => response.data,
(error) => Promise.reject(error),
);
// -----------------------------
// 2) 文件工具函数
// -----------------------------
const readAsDataURL = (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error('读取文件失败'));
reader.readAsDataURL(file);
});
const getFileMeta = async (file: File) => {
// File 本身是 Blob,可直接转 ArrayBuffer
const buffer = await file.arrayBuffer();
const hash = SparkMD5.ArrayBuffer.hash(buffer);
const ext = file.name.includes('.') ? file.name.split('.').pop()! : 'bin';
return {
hash,
ext,
mergedFileName: `${hash}.${ext}`,
};
};
// -----------------------------
// 3) 单文件上传(FormData)
// -----------------------------
export const uploadSingleWithFormData = async (
file: File,
onProgress?: (percent: number, e: AxiosProgressEvent) => void,
) => {
const formData = new FormData();
formData.append('file', file);
formData.append('filename', file.name);
const res = (await uploadClient.post('/upload/single', formData, {
onUploadProgress: (e) => {
const percent = e.total ? Math.round((e.loaded / e.total) * 100) : 0;
onProgress?.(percent, e);
},
})) as ApiResult<{ url: string }>;
if (res.code !== 0) {
throw new Error(res.message || '单文件上传失败');
}
return res.data;
};
// -----------------------------
// 4) 单文件上传(Base64)
// -----------------------------
export const uploadSingleWithBase64 = async (file: File) => {
const base64 = await readAsDataURL(file);
const res = (await uploadClient.post(
'/upload/single-base64',
{
// 防止特殊字符在传输链路上出现兼容问题
file: encodeURIComponent(base64),
filename: file.name,
},
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
)) as ApiResult<{ url: string }>;
if (res.code !== 0) {
throw new Error(res.message || 'Base64 上传失败');
}
return res.data;
};
// -----------------------------
// 5) 多文件上传
// -----------------------------
export const uploadMultipleFiles = async (files: File[]) => {
const tasks = files.map((file) => uploadSingleWithFormData(file));
const results = await Promise.allSettled(tasks);
const success = results.filter((r) => r.status === 'fulfilled').length;
const failed = results.length - success;
return { success, failed, results };
};
// -----------------------------
// 6) 切片与断点续传
// -----------------------------
const MAX_CHUNKS = 100;
const DEFAULT_CHUNK_SIZE = 2 * 1024 * 1024; // 2MB
const MAX_RETRY = 3;
const CONCURRENCY = 4;
const createChunks = (file: File, hash: string, ext: string) => {
let chunkSize = DEFAULT_CHUNK_SIZE;
let total = Math.ceil(file.size / chunkSize);
// 限制切片总量,避免切片过多导致调度开销过高
if (total > MAX_CHUNKS) {
total = MAX_CHUNKS;
chunkSize = Math.ceil(file.size / total);
}
const tasks: ChunkTask[] = [];
for (let i = 0; i < total; i += 1) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const blob = file.slice(start, end);
tasks.push({
index: i,
blob,
chunkName: `${hash}_${i + 1}.${ext}`,
});
}
return tasks;
};
const getUploadedChunkNames = async (hash: string) => {
const res = (await uploadClient.get('/upload/already', {
params: { hash },
})) as ApiResult<{ fileList: string[] }>;
if (res.code !== 0) {
return [];
}
return res.data.fileList || [];
};
const uploadChunk = async (task: ChunkTask, hash: string) => {
const formData = new FormData();
formData.append('file', task.blob);
formData.append('hash', hash);
formData.append('index', String(task.index));
formData.append('filename', task.chunkName);
const res = (await uploadClient.post('/upload/chunk', formData)) as ApiResult;
if (res.code !== 0) {
throw new Error(res.message || `切片上传失败: ${task.chunkName}`);
}
};
const uploadChunkWithRetry = async (
task: ChunkTask,
hash: string,
retry = 0,
): Promise<void> => {
try {
await uploadChunk(task, hash);
} catch (error) {
if (retry >= MAX_RETRY) {
throw error;
}
await uploadChunkWithRetry(task, hash, retry + 1);
}
};
const runWithConcurrency = async <T>(
list: T[],
worker: (item: T) => Promise<void>,
limit: number,
) => {
let cursor = 0;
const runners = Array.from({ length: Math.min(limit, list.length) }).map(async () => {
while (cursor < list.length) {
const current = list[cursor];
cursor += 1;
await worker(current);
}
});
await Promise.all(runners);
};
export const uploadLargeFileWithResume = async (
file: File,
onProgress?: (finished: number, total: number) => void,
) => {
const { hash, ext, mergedFileName } = await getFileMeta(file);
const allChunks = createChunks(file, hash, ext);
const uploaded = new Set(await getUploadedChunkNames(hash));
const pendingChunks = allChunks.filter((task) => !uploaded.has(task.chunkName));
let finished = allChunks.length - pendingChunks.length;
onProgress?.(finished, allChunks.length);
await runWithConcurrency(
pendingChunks,
async (task) => {
await uploadChunkWithRetry(task, hash);
finished += 1;
onProgress?.(finished, allChunks.length);
},
CONCURRENCY,
);
// 所有分片上传完成后,通知服务端合并
const mergeRes = (await uploadClient.post(
'/upload/merge',
{
hash,
total: allChunks.length,
ext,
filename: mergedFileName,
originalName: file.name,
},
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
)) as ApiResult<{ url: string }>;
if (mergeRes.code !== 0) {
throw new Error(mergeRes.message || '切片合并失败');
}
return mergeRes.data;
};4. 页面事件示例(input + 拖拽)
const fileInput = document.querySelector<HTMLInputElement>('#fileInput')!;
const dropZone = document.querySelector<HTMLDivElement>('#dropZone')!;
const progressText = document.querySelector<HTMLSpanElement>('#progressText')!;
// input 选择文件
fileInput.addEventListener('change', async (e) => {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
try {
await uploadLargeFileWithResume(file, (finished, total) => {
progressText.textContent = `上传进度: ${finished}/${total}`;
});
alert('上传成功');
} catch (error) {
alert('上传失败,请重试');
} finally {
// 重置 input,便于重复选择同一文件
target.value = '';
}
});
// 拖拽区域:必须阻止默认行为,否则浏览器会直接打开文件
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
});
dropZone.addEventListener('drop', async (e) => {
e.preventDefault();
const file = e.dataTransfer?.files?.[0];
if (!file) return;
try {
await uploadSingleWithFormData(file);
alert('拖拽上传成功');
} catch {
alert('拖拽上传失败');
}
});5. 最佳实践
multipart/form-data不手动设置boundary,由浏览器自动处理。- 前端与后端统一切片命名规则,推荐
hash_index.ext。 - 切片上传使用“有限并发 + 有限重试”,避免请求风暴。
- 对文件做前置校验(大小、类型、扩展名、空文件)。
- Base64 仅用于小图或特殊场景,大文件不要走 Base64。
- 断点续传先查已上传切片,再上传剩余片段,最后再触发合并。
- 进度条分两层:分片上传进度与整体任务进度。
6. 常见错误与修复
错误:
Promise.reslove拼写错误。 修复:使用Promise.resolve。错误:
Array.form拼写错误。 修复:使用Array.from。错误:
formData/fromData变量名不一致。 修复:统一变量名,避免引用未定义变量。错误:
const files = ...后再次赋值。 修复:改为let,或不重赋值,直接创建新变量。错误:
chunk.name读取分片名称。 修复:Blob没有name,应由业务主动生成切片名。错误:
header字段写错。 修复:Axios 配置项应为headers。错误:递归重试不设上限。 修复:增加最大重试次数,避免无限递归。
错误:
drop回调中直接使用await但函数未声明async。 修复:将事件处理函数声明为async。错误:变量名混用(
sliceSize/chunkSize/sliceCount)。 修复:统一命名,按“总片数、单片大小、切片数组”拆分。
7. 迁移说明
本文件已完成从 2. 大文件上传.md 到 大文件上传.md 的内容迁移,并对以下方面做了统一:
- 结构化章节重排。
- 核心代码完整化与注释化。
- 原笔记中的逻辑错误、拼写错误、异步错误和命名错误修复。
