跳到主要内容

设计大文件上传系统

需求分析

功能需求

需求描述
分片上传将大文件切割为固定大小的分片,逐片上传
断点续传上传中断后可从上次断点处继续,无需重传已完成分片
秒传上传前通过文件 Hash 检测服务端是否已存在相同文件,若存在则直接返回成功
并发控制限制同时上传的分片数量,避免浏览器连接数耗尽或服务器过载
进度显示实时展示整体上传进度和各分片上传状态
暂停/恢复允许用户手动暂停和恢复上传过程

非功能需求

需求描述
大文件支持支持 GB 级别文件上传,不触发浏览器内存限制
弱网容错网络抖动时自动重试,指数退避策略防止请求风暴
跨浏览器兼容兼容主流浏览器(Chrome、Firefox、Safari、Edge)
安全性分片校验、文件类型验证、防止恶意上传
可扩展性架构支持横向扩展,适配不同存储后端(本地磁盘、OSS、S3)

整体架构

模块架构


核心模块设计

1. 文件分片策略

文件分片是整个上传系统的基础,决定了上传粒度和性能表现。

固定大小分片 vs 动态分片

对比项固定大小分片动态分片
实现复杂度
分片大小固定(如 5MB)根据网速、文件大小动态调整
适用场景通用场景对上传体验要求极高的场景
重传成本固定弱网时分片更小,重传成本更低
主流方案绝大多数采用少数大厂定制方案
最佳实践

主流方案采用 固定大小分片,推荐分片大小 2MB - 10MB

  • 太小(< 1MB):HTTP 请求开销占比过高,合并分片耗时长
  • 太大(> 20MB):单个分片失败重传代价大,弱网体验差
  • 推荐默认 5MB,可根据文件大小动态微调

Blob.slice 实现

浏览器原生 Blob.slice() 方法是分片的核心 API,它不会将数据拷贝到内存,而是创建一个引用原始数据的新 Blob,因此即使是 GB 级文件,分片操作本身也几乎不占用额外内存。

chunk-slicer.ts
interface FileChunk {
/** 分片数据 */
blob: Blob;
/** 分片索引(从 0 开始) */
index: number;
/** 分片大小(字节) */
size: number;
/** 分片在原文件中的起始位置 */
start: number;
/** 分片在原文件中的结束位置 */
end: number;
}

const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024; // 5MB

function createFileChunks(
file: File,
chunkSize: number = DEFAULT_CHUNK_SIZE
): FileChunk[] {
const chunks: FileChunk[] = [];
let start = 0;
let index = 0;

while (start < file.size) {
const end = Math.min(start + chunkSize, file.size);
chunks.push({
blob: file.slice(start, end), // 零拷贝,不占用额外内存
index,
size: end - start,
start,
end,
});
start = end;
index++;
}

return chunks;
}
关于 Blob.slice

Blob.slice() 类似于数组的 Array.slice(),返回原始 Blob 指定范围的子集。它不会将数据拷贝到内存中,而是创建一个「视图」指向原始文件在磁盘上的对应区间。只有在真正读取(如调用 arrayBuffer() 或通过 FormData 发送)时,浏览器才会从磁盘加载对应的数据段。


2. 文件 Hash 计算

Hash 是秒传和断点续传的关键——通过文件内容的唯一标识来判断文件是否已存在、分片是否完整。

MD5 vs SHA-256

对比项MD5SHA-256
输出长度128 位(32 字符)256 位(64 字符)
计算速度较慢(约慢 30-50%)
碰撞安全已被破解,存在碰撞目前安全
适用场景文件去重(碰撞概率极低,实际可接受)安全性要求高的场景
主流选择大多数文件上传系统金融、医疗等安全敏感场景
注意

MD5 虽然在密码学上已不安全,但在文件去重场景下碰撞概率极低(约 21282^{-128}),主流方案仍使用 MD5。如需更高安全性,可采用 MD5 + 文件大小 双重校验或使用 SHA-256。

Web Worker 并行计算

GB 级别文件的 Hash 计算非常耗时,必须放到 Web Worker 中执行,避免阻塞主线程导致页面卡顿。

hash-worker.ts
// Web Worker 文件
import SparkMD5 from 'spark-md5';

interface HashWorkerMessage {
chunks: Blob[];
}

interface HashWorkerResponse {
type: 'progress' | 'complete';
percentage?: number;
hash?: string;
}

self.onmessage = async (e: MessageEvent<HashWorkerMessage>) => {
const { chunks } = e.data;
const spark = new SparkMD5.ArrayBuffer();

for (let i = 0; i < chunks.length; i++) {
// 逐片读取并追加到 SparkMD5,实现增量计算
const buffer = await chunks[i].arrayBuffer();
spark.append(buffer);

const response: HashWorkerResponse = {
type: 'progress',
percentage: Math.floor(((i + 1) / chunks.length) * 100),
};
self.postMessage(response);
}

const response: HashWorkerResponse = {
type: 'complete',
hash: spark.end(),
};
self.postMessage(response);
};
hash-calculator.ts
interface HashResult {
hash: string;
}

function calculateHash(
chunks: Blob[],
onProgress?: (percentage: number) => void
): Promise<HashResult> {
return new Promise((resolve, reject) => {
const worker = new Worker(
new URL('./hash-worker.ts', import.meta.url) // Vite/Webpack 5 支持
);

worker.onmessage = (e: MessageEvent) => {
const { type, percentage, hash } = e.data;

if (type === 'progress') {
onProgress?.(percentage);
}

if (type === 'complete' && hash) {
resolve({ hash });
worker.terminate();
}
};

worker.onerror = (err) => {
reject(err);
worker.terminate();
};

worker.postMessage({ chunks });
});
}

抽样 Hash 加速

对于超大文件(如几 GB),全量计算 Hash 耗时过长。可以采用抽样策略:只取文件的部分数据计算 Hash,在速度和准确性之间取得平衡。

sampling-hash.ts
/**
* 抽样 Hash 策略:
* - 取文件头部 2MB
* - 取文件尾部 2MB
* - 取文件中间每隔 N 个分片取 2 字节
* - 拼接后计算 MD5
*
* 优点:速度极快(几百 ms),大幅提升用户体验
* 缺点:极小概率产生碰撞(不同文件得到相同 Hash)
*/
async function calculateSamplingHash(file: File): Promise<string> {
const spark = new SparkMD5.ArrayBuffer();
const SAMPLE_SIZE = 2 * 1024 * 1024; // 2MB
const SAMPLE_OFFSET = 2; // 中间每片取 2 字节

// 1. 取头部
const head = await file.slice(0, SAMPLE_SIZE).arrayBuffer();
spark.append(head);

// 2. 取中间抽样
const chunkCount = Math.ceil(file.size / SAMPLE_SIZE);
for (let i = 1; i < chunkCount - 1; i++) {
const offset = i * SAMPLE_SIZE;
const sample = await file.slice(offset, offset + SAMPLE_OFFSET).arrayBuffer();
spark.append(sample);
}

// 3. 取尾部
if (file.size > SAMPLE_SIZE) {
const tail = await file
.slice(file.size - SAMPLE_SIZE, file.size)
.arrayBuffer();
spark.append(tail);
}

// 4. 追加文件大小作为额外区分因子
spark.append(new ArrayBuffer(8));
const view = new DataView(new ArrayBuffer(8));
view.setFloat64(0, file.size);
spark.append(view.buffer);

return spark.end();
}
实际方案选择
  • 小文件(< 100MB):全量 Hash,精确度高
  • 大文件(100MB - 1GB):全量 Hash + Web Worker + 进度显示
  • 超大文件(> 1GB):先抽样 Hash 快速检测秒传,上传完成后再做全量校验

3. 分片上传与并发控制

请求池设计

并发控制的核心是实现一个请求池:维护一个固定大小的执行窗口,当某个请求完成时立即补充新请求,保持并发数恒定。

request-pool.ts
type Task<T> = () => Promise<T>;

interface PoolOptions {
concurrency: number;
onTaskComplete?: (result: unknown, index: number) => void;
onTaskError?: (error: Error, index: number) => void;
}

class RequestPool {
private concurrency: number;
private running = 0;
private queue: Array<{ task: Task<unknown>; resolve: Function; reject: Function }> = [];

constructor(options: PoolOptions) {
this.concurrency = options.concurrency;
}

async add<T>(task: Task<T>): Promise<T> {
// 当并发数已满时,将任务放入队列等待
if (this.running >= this.concurrency) {
await new Promise<void>((resolve) => {
this.queue.push({ task: task as Task<unknown>, resolve, reject: () => {} });
});
}

return this.run(task);
}

private async run<T>(task: Task<T>): Promise<T> {
this.running++;

try {
const result = await task();
return result;
} finally {
this.running--;
// 从队列中取出下一个任务执行
if (this.queue.length > 0) {
const next = this.queue.shift()!;
next.resolve();
}
}
}
}

更实用的并发控制可以通过经典的 asyncPool 模式实现:

async-pool.ts
async function asyncPool<T, R>(
concurrency: number,
items: T[],
iteratorFn: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const results: R[] = [];
const executing = new Set<Promise<void>>();

for (let i = 0; i < items.length; i++) {
const p = Promise.resolve()
.then(() => iteratorFn(items[i], i))
.then((result) => {
results[i] = result; // 保持结果顺序
});

const e: Promise<void> = p.then(() => {
executing.delete(e);
});
executing.add(e);

// 当并发数达到上限时,等待最快完成的一个
if (executing.size >= concurrency) {
await Promise.race(executing);
}
}

await Promise.all(executing);
return results;
}

失败重试与指数退避

retry-with-backoff.ts
interface RetryOptions {
/** 最大重试次数 */
maxRetries: number;
/** 基础延迟时间(ms) */
baseDelay: number;
/** 最大延迟时间(ms) */
maxDelay: number;
/** 是否使用抖动(jitter) */
useJitter: boolean;
}

const DEFAULT_RETRY_OPTIONS: RetryOptions = {
maxRetries: 3,
baseDelay: 1000,
maxDelay: 30000,
useJitter: true,
};

async function retryWithBackoff<T>(
fn: () => Promise<T>,
options: Partial<RetryOptions> = {}
): Promise<T> {
const { maxRetries, baseDelay, maxDelay, useJitter } = {
...DEFAULT_RETRY_OPTIONS,
...options,
};

let lastError: Error;

for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;

if (attempt === maxRetries) break;

// 指数退避:delay = baseDelay * 2^attempt
let delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);

// 添加随机抖动,避免多个请求同时重试导致「重试风暴」
if (useJitter) {
delay = delay * (0.5 + Math.random() * 0.5);
}

console.warn(
`Attempt ${attempt + 1} failed, retrying in ${Math.round(delay)}ms...`
);

await new Promise((resolve) => setTimeout(resolve, delay));
}
}

throw lastError!;
}
为什么需要抖动(Jitter)?

当网络恢复时,所有暂停的分片可能同时开始重试,造成重试风暴(Thundering Herd)。添加随机抖动让各分片的重试时机分散开来,避免瞬间压力过大。


4. 断点续传

断点续传的核心是记录已上传的分片列表,上传恢复时跳过这些分片。需要前端后端双重记录。

前端持久化记录

upload-state-manager.ts
interface UploadState {
/** 文件 Hash(唯一标识) */
fileHash: string;
/** 文件名 */
fileName: string;
/** 文件总大小 */
fileSize: number;
/** 分片大小 */
chunkSize: number;
/** 分片总数 */
totalChunks: number;
/** 已上传的分片索引列表 */
uploadedChunks: number[];
/** 上传开始时间 */
startTime: number;
/** 最后更新时间 */
lastUpdateTime: number;
}

class UploadStateManager {
private storageKey: string;

constructor(prefix = 'file_upload') {
this.storageKey = prefix;
}

private getKey(fileHash: string): string {
return `${this.storageKey}_${fileHash}`;
}

/** 保存上传状态 */
save(state: UploadState): void {
const key = this.getKey(state.fileHash);
localStorage.setItem(key, JSON.stringify(state));
}

/** 获取上传状态 */
get(fileHash: string): UploadState | null {
const key = this.getKey(fileHash);
const data = localStorage.getItem(key);
return data ? JSON.parse(data) : null;
}

/** 更新已上传的分片 */
addUploadedChunk(fileHash: string, chunkIndex: number): void {
const state = this.get(fileHash);
if (state) {
state.uploadedChunks.push(chunkIndex);
state.lastUpdateTime = Date.now();
this.save(state);
}
}

/** 清除已完成的上传记录 */
remove(fileHash: string): void {
const key = this.getKey(fileHash);
localStorage.removeItem(key);
}

/** 清除超时的上传记录(默认 7 天) */
cleanup(maxAge: number = 7 * 24 * 60 * 60 * 1000): void {
const now = Date.now();
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(this.storageKey)) {
const state: UploadState = JSON.parse(localStorage.getItem(key)!);
if (now - state.lastUpdateTime > maxAge) {
localStorage.removeItem(key);
}
}
}
}
}

服务端记录(API 设计)

check-api.ts
/** 请求服务端获取已上传分片列表 */
async function checkUploadedChunks(
fileHash: string,
fileName: string
): Promise<{
/** 是否需要上传(false 表示秒传) */
shouldUpload: boolean;
/** 已上传的分片索引列表 */
uploadedChunks: number[];
}> {
const response = await fetch('/api/upload/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileHash, fileName }),
});
return response.json();
}
注意

前端 localStorage 记录是辅助手段,以服务端记录为准。localStorage 可能被用户清除,而服务端记录可以保证跨设备、跨浏览器的断点续传能力。前端记录的主要价值是在同一浏览器中快速恢复上传状态,避免每次都请求服务端校验。


5. 秒传机制

秒传本质上是服务端文件去重:上传前先把文件 Hash 发给服务端,如果服务端已存在相同 Hash 的文件,直接返回成功,跳过实际上传。

秒传的本质

秒传并不是真的「瞬间上传」,而是跳过了上传环节。文件实际上已经存在于服务端,客户端只需要建立一个「引用」即可。这对于用户来说体验非常好——选择文件后瞬间完成。


关键技术实现

完整上传器类

将所有模块整合到一个完整的上传器类中:

file-uploader.ts
import SparkMD5 from 'spark-md5';

interface UploaderOptions {
/** 分片大小,默认 5MB */
chunkSize?: number;
/** 并发数,默认 3 */
concurrency?: number;
/** 最大重试次数,默认 3 */
maxRetries?: number;
/** 上传接口基础路径 */
baseURL?: string;
}

interface UploadProgress {
/** 当前阶段 */
phase: 'hash' | 'upload' | 'merge';
/** 整体进度 0-100 */
percentage: number;
/** 已上传分片数 */
uploadedChunks: number;
/** 总分片数 */
totalChunks: number;
/** 上传速度(字节/秒) */
speed: number;
/** 预计剩余时间(秒) */
remainingTime: number;
}

type ProgressCallback = (progress: UploadProgress) => void;

interface UploadResult {
success: boolean;
url?: string;
fileHash?: string;
error?: string;
}

class FileUploader {
private options: Required<UploaderOptions>;
private stateManager: UploadStateManager;
private abortController: AbortController | null = null;
private isPaused = false;
private pausePromise: Promise<void> | null = null;
private pauseResolve: (() => void) | null = null;

constructor(options: UploaderOptions = {}) {
this.options = {
chunkSize: options.chunkSize ?? 5 * 1024 * 1024,
concurrency: options.concurrency ?? 3,
maxRetries: options.maxRetries ?? 3,
baseURL: options.baseURL ?? '/api',
};
this.stateManager = new UploadStateManager();
}

/**
* 上传文件
*/
async upload(file: File, onProgress?: ProgressCallback): Promise<UploadResult> {
this.abortController = new AbortController();

try {
// ---- 阶段 1:文件分片 ----
const chunks = createFileChunks(file, this.options.chunkSize);
const totalChunks = chunks.length;

// ---- 阶段 2:计算 Hash ----
const { hash: fileHash } = await calculateHash(
chunks.map((c) => c.blob),
(percentage) => {
onProgress?.({
phase: 'hash',
percentage: Math.floor(percentage * 0.3), // Hash 阶段占总进度 30%
uploadedChunks: 0,
totalChunks,
speed: 0,
remainingTime: 0,
});
}
);

// ---- 阶段 3:秒传检测 ----
const checkResult = await checkUploadedChunks(fileHash, file.name);

if (!checkResult.shouldUpload) {
onProgress?.({
phase: 'upload',
percentage: 100,
uploadedChunks: totalChunks,
totalChunks,
speed: 0,
remainingTime: 0,
});
return { success: true, url: `/files/${fileHash}`, fileHash };
}

// ---- 阶段 4:上传分片 ----
const uploadedSet = new Set(checkResult.uploadedChunks);
const pendingChunks = chunks.filter((c) => !uploadedSet.has(c.index));

let completedCount = checkResult.uploadedChunks.length;
const startTime = Date.now();
let uploadedBytes = checkResult.uploadedChunks.length * this.options.chunkSize;

const uploadTask = async (chunk: FileChunk): Promise<void> => {
// 检查是否暂停
if (this.isPaused) {
await this.pausePromise;
}

const formData = new FormData();
formData.append('file', chunk.blob);
formData.append('fileHash', fileHash);
formData.append('index', String(chunk.index));
formData.append('fileName', file.name);

await retryWithBackoff(
() =>
fetch(`${this.options.baseURL}/upload/chunk`, {
method: 'POST',
body: formData,
signal: this.abortController!.signal,
}).then((res) => {
if (!res.ok) throw new Error(`Upload chunk ${chunk.index} failed`);
return res;
}),
{ maxRetries: this.options.maxRetries }
);

completedCount++;
uploadedBytes += chunk.size;

// 更新本地状态
this.stateManager.addUploadedChunk(fileHash, chunk.index);

// 计算速度和剩余时间
const elapsed = (Date.now() - startTime) / 1000;
const speed = uploadedBytes / elapsed;
const remainingBytes = (totalChunks - completedCount) * this.options.chunkSize;
const remainingTime = remainingBytes / speed;

onProgress?.({
phase: 'upload',
percentage: 30 + Math.floor((completedCount / totalChunks) * 60),
uploadedChunks: completedCount,
totalChunks,
speed,
remainingTime,
});
};

await asyncPool(this.options.concurrency, pendingChunks, uploadTask);

// ---- 阶段 5:合并分片 ----
onProgress?.({
phase: 'merge',
percentage: 90,
uploadedChunks: totalChunks,
totalChunks,
speed: 0,
remainingTime: 0,
});

const mergeResult = await fetch(`${this.options.baseURL}/upload/merge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileHash,
fileName: file.name,
chunkCount: totalChunks,
}),
}).then((res) => res.json());

// 清除本地状态
this.stateManager.remove(fileHash);

onProgress?.({
phase: 'merge',
percentage: 100,
uploadedChunks: totalChunks,
totalChunks,
speed: 0,
remainingTime: 0,
});

return { success: true, url: mergeResult.url, fileHash };
} catch (error) {
if ((error as Error).name === 'AbortError') {
return { success: false, error: '上传已取消' };
}
return { success: false, error: (error as Error).message };
}
}

/** 暂停上传 */
pause(): void {
this.isPaused = true;
this.pausePromise = new Promise((resolve) => {
this.pauseResolve = resolve;
});
}

/** 恢复上传 */
resume(): void {
this.isPaused = false;
this.pauseResolve?.();
this.pausePromise = null;
this.pauseResolve = null;
}

/** 取消上传 */
cancel(): void {
this.abortController?.abort();
}
}

性能优化

1. Hash 计算优化

抽样策略

对于超大文件,可以先做抽样 Hash 进行秒传检测,通过后再全量校验:

hybrid-hash-strategy.ts
async function smartHash(
file: File,
chunks: Blob[],
onProgress?: (percentage: number) => void
): Promise<string> {
const THRESHOLD = 100 * 1024 * 1024; // 100MB 阈值

if (file.size <= THRESHOLD) {
// 小文件:直接全量计算
const { hash } = await calculateHash(chunks, onProgress);
return hash;
}

// 大文件:先抽样 Hash 检测秒传
const samplingHash = await calculateSamplingHash(file);
const checkResult = await checkUploadedChunks(samplingHash, file.name);

if (!checkResult.shouldUpload) {
// 秒传命中,无需全量计算
return samplingHash;
}

// 秒传未命中,使用全量 Hash
const { hash } = await calculateHash(chunks, onProgress);
return hash;
}

增量计算(SparkMD5)

SparkMD5 支持增量追加数据并最终得到 Hash 值,非常适合分片场景——无需将整个文件一次性读入内存:

incremental-hash.ts
import SparkMD5 from 'spark-md5';

// 逐片读取,边读边算,内存占用恒定
async function incrementalHash(file: File, chunkSize: number): Promise<string> {
const spark = new SparkMD5.ArrayBuffer();
let offset = 0;

while (offset < file.size) {
const end = Math.min(offset + chunkSize, file.size);
const buffer = await file.slice(offset, end).arrayBuffer(); // 每次只读一片
spark.append(buffer);
offset = end;
}

return spark.end();
}

2. 上传速度优化

并发数动态调优

根据网络状况动态调整并发数,弱网时降低并发避免频繁超时,强网时提高并发充分利用带宽:

adaptive-concurrency.ts
class AdaptiveConcurrency {
private concurrency: number;
private minConcurrency: number;
private maxConcurrency: number;
private recentSpeeds: number[] = [];

constructor(min = 1, max = 6, initial = 3) {
this.concurrency = initial;
this.minConcurrency = min;
this.maxConcurrency = max;
}

/** 根据最近的上传速度调整并发数 */
adjust(chunkSize: number, elapsed: number): number {
const speed = chunkSize / elapsed; // bytes/ms
this.recentSpeeds.push(speed);

if (this.recentSpeeds.length > 5) {
this.recentSpeeds.shift();
}

const avgSpeed =
this.recentSpeeds.reduce((a, b) => a + b, 0) / this.recentSpeeds.length;

if (avgSpeed > 1024 * 1024) {
// > 1MB/s,提高并发
this.concurrency = Math.min(this.concurrency + 1, this.maxConcurrency);
} else if (avgSpeed < 256 * 1024) {
// < 256KB/s,降低并发
this.concurrency = Math.max(this.concurrency - 1, this.minConcurrency);
}

return this.concurrency;
}

get current(): number {
return this.concurrency;
}
}

分片压缩

对于文本类文件(如日志、CSV),上传前压缩分片可以显著减少传输数据量:

chunk-compression.ts
async function compressChunk(blob: Blob): Promise<Blob> {
// 使用浏览器原生 CompressionStream API
const cs = new CompressionStream('gzip');
const stream = blob.stream().pipeThrough(cs);
return new Response(stream).blob();
}

3. 内存优化

流式读取

避免一次性将整个文件读入内存,始终使用 Blob.slice() 按需读取:

memory-safe-upload.ts
// 错误做法:一次性读取整个文件到内存
async function badExample(file: File): Promise<void> {
const buffer = await file.arrayBuffer(); // 1GB 文件 = 1GB 内存占用!
// ...
}

// 正确做法:通过 Blob.slice 按需读取
async function goodExample(file: File, chunkSize: number): Promise<void> {
let offset = 0;
while (offset < file.size) {
const chunk = file.slice(offset, offset + chunkSize); // 零拷贝
// 上传 chunk 后,chunk 引用被 GC 回收
await uploadSingleChunk(chunk);
offset += chunkSize;
}
}
内存陷阱

不要把所有分片的 ArrayBuffer 同时保存在数组中!如果文件 1GB,分片 5MB,这意味着同时持有 200 个 ArrayBuffer,占用 1GB 内存。正确做法是上传一片释放一片,或只保持并发窗口大小的 ArrayBuffer 在内存中。

优化点方法效果
Hash 计算抽样 Hash + 增量计算大文件 Hash 时间减少 90%+
并发数动态调优弱网少超时,强网高吞吐
分片压缩CompressionStream API文本类文件传输量减少 60-80%
内存控制Blob.slice 零拷贝 + 按需读取内存占用恒定,不随文件大小增长

扩展设计

1. 拖拽上传

通过 Drag and Drop API 实现拖拽上传:

drag-drop-upload.ts
function setupDragDrop(
dropZone: HTMLElement,
onFiles: (files: File[]) => void
): () => void {
const handleDragOver = (e: DragEvent): void => {
e.preventDefault();
dropZone.classList.add('drag-over');
};

const handleDragLeave = (): void => {
dropZone.classList.remove('drag-over');
};

const handleDrop = (e: DragEvent): void => {
e.preventDefault();
dropZone.classList.remove('drag-over');

const files = Array.from(e.dataTransfer?.files ?? []);
if (files.length > 0) {
onFiles(files);
}
};

dropZone.addEventListener('dragover', handleDragOver);
dropZone.addEventListener('dragleave', handleDragLeave);
dropZone.addEventListener('drop', handleDrop);

// 返回清理函数
return () => {
dropZone.removeEventListener('dragover', handleDragOver);
dropZone.removeEventListener('dragleave', handleDragLeave);
dropZone.removeEventListener('drop', handleDrop);
};
}

2. 文件夹上传

通过 webkitdirectory 属性或 File System Access API 实现文件夹上传:

folder-upload.ts
// 方式一:webkitdirectory
function setupFolderInput(input: HTMLInputElement): void {
input.setAttribute('webkitdirectory', '');
input.setAttribute('directory', '');

input.addEventListener('change', () => {
const files = Array.from(input.files ?? []);
// files 包含文件夹中的所有文件
// 每个 file 的 webkitRelativePath 包含相对路径
files.forEach((file) => {
console.log(file.name, (file as any).webkitRelativePath);
});
});
}

// 方式二:File System Access API(更现代)
async function pickFolder(): Promise<File[]> {
const dirHandle = await (window as any).showDirectoryPicker();
const files: File[] = [];

async function* getFiles(
dirHandle: FileSystemDirectoryHandle,
path = ''
): AsyncGenerator<{ file: File; path: string }> {
for await (const entry of (dirHandle as any).values()) {
if (entry.kind === 'file') {
const file = await entry.getFile();
yield { file, path: `${path}/${file.name}` };
} else if (entry.kind === 'directory') {
yield* getFiles(entry, `${path}/${entry.name}`);
}
}
}

for await (const { file } of getFiles(dirHandle)) {
files.push(file);
}

return files;
}

3. 图片压缩预处理

上传前对图片进行压缩,减少传输数据量:

image-compress.ts
interface CompressOptions {
maxWidth?: number;
maxHeight?: number;
quality?: number;
mimeType?: string;
}

async function compressImage(
file: File,
options: CompressOptions = {}
): Promise<File> {
const { maxWidth = 1920, maxHeight = 1080, quality = 0.8, mimeType = 'image/jpeg' } = options;

return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
// 计算等比缩放尺寸
let { width, height } = img;
if (width > maxWidth || height > maxHeight) {
const ratio = Math.min(maxWidth / width, maxHeight / height);
width = Math.round(width * ratio);
height = Math.round(height * ratio);
}

const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0, width, height);

canvas.toBlob(
(blob) => {
if (blob) {
resolve(new File([blob], file.name, { type: mimeType }));
} else {
reject(new Error('Compression failed'));
}
},
mimeType,
quality
);
};

img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}

4. 上传进度持久化

使用 IndexedDB 存储大容量上传状态,比 localStorage 更适合存储二进制数据和大量结构化数据:

indexed-db-state.ts
class IndexedDBStateManager {
private dbName = 'FileUploadDB';
private storeName = 'uploadStates';

private async openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);

request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: 'fileHash' });
}
};

request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}

async save(state: UploadState): Promise<void> {
const db = await this.openDB();
const tx = db.transaction(this.storeName, 'readwrite');
tx.objectStore(this.storeName).put(state);
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}

async get(fileHash: string): Promise<UploadState | undefined> {
const db = await this.openDB();
const tx = db.transaction(this.storeName, 'readonly');
const request = tx.objectStore(this.storeName).get(fileHash);
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}

async remove(fileHash: string): Promise<void> {
const db = await this.openDB();
const tx = db.transaction(this.storeName, 'readwrite');
tx.objectStore(this.storeName).delete(fileHash);
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
}

常见面试问题

Q1: 大文件上传为什么要分片?不分片直接上传有什么问题?

答案

不分片直接上传存在几个严重问题:

  1. 浏览器内存限制:一次性读取 GB 级文件到内存可能导致页面崩溃
  2. 网络中断代价高:上传到 90% 时断网,需要从头重新上传
  3. 服务器连接占用:单个大请求长时间占用连接,影响其他请求
  4. 无法显示精确进度:只能知道「正在上传」,无法显示百分比
  5. 超时风险:大文件上传时间长,容易触发 Nginx、网关等的超时限制

分片上传通过将文件切割为小块(通常 2-10MB),可以:

问题分片方案
内存限制Blob.slice 零拷贝,不额外占用内存
网络中断只需重传失败的分片,已上传分片保留
连接占用单个分片请求很快完成,释放连接
进度显示每完成一个分片更新进度
超时限制单片请求小,不易超时

Q2: 文件 Hash 计算为什么要放到 Web Worker 中?

答案

JavaScript 是单线程的,Hash 计算是 CPU 密集型任务。一个 1GB 文件的 MD5 计算可能需要 10-30 秒,如果在主线程执行,会导致:

  • 页面完全卡死,无法响应任何用户操作
  • 动画停止,滚动失灵
  • 浏览器可能弹出「页面无响应」提示

Web Worker 在独立线程中运行,不影响主线程:

// 主线程:发送任务给 Worker,不阻塞 UI
const worker = new Worker(new URL('./hash-worker.ts', import.meta.url));
worker.postMessage({ chunks });

// Worker 线程:执行 CPU 密集计算
worker.onmessage = (e) => {
if (e.data.type === 'progress') {
updateProgressBar(e.data.percentage); // 主线程仍能响应
}
};
延伸

如果需要更快地计算 Hash,还可以使用 多个 Worker 并行计算 不同分片的 Hash,然后在主线程合并结果。不过要注意 Worker 数量不宜超过 navigator.hardwareConcurrency(CPU 核心数)。

Q3: 如何实现秒传?秒传安全吗?

答案

实现原理:客户端计算文件内容的 Hash(通常为 MD5),上传前先发送 Hash 到服务端校验。如果服务端已存储相同 Hash 的文件,直接返回成功,无需实际传输文件内容。

安全性分析

方面风险解决方案
Hash 碰撞不同文件产生相同 MD5(概率极低 ~21282^{-128}Hash + 文件大小双重校验
隐私泄露用户可以通过 Hash 探测服务端是否存在某文件限制校验接口访问频率 + 登录态校验
数据篡改中间人替换 Hash使用 HTTPS 传输

Q4: 并发数设置多少合适?如何动态调整?

答案

推荐默认并发数 3-6,原因:

  • 浏览器限制:同一域名下并发连接数限制通常为 6(HTTP/1.1),设置过高会排队
  • HTTP/2 多路复用:理论上不受连接数限制,但服务端通常仍有并发处理限制
  • 带宽利用:并发太少浪费带宽,太多导致竞争反而变慢

动态调整策略

// 监测每个分片的上传耗时
// 如果平均速度 > 1MB/s → 增加并发(最多 6)
// 如果平均速度 < 256KB/s → 减少并发(最少 1)
// 如果出现超时或失败 → 立即减少并发

Q5: 断点续传的分片记录应该存在哪里?

答案

需要前端和后端双重记录:

存储位置优点缺点适用场景
localStorage读写快、实现简单容量限制(5MB)、可被清除同一浏览器恢复
IndexedDB容量大、支持二进制API 复杂需存储大量分片元数据
服务端数据库跨设备、可靠需网络请求跨设备续传

最佳实践:以服务端记录为准,前端记录为辅助加速。上传恢复时:

  1. 先检查前端本地缓存(localStorage/IndexedDB),快速恢复 UI 状态
  2. 再请求服务端获取实际已接收的分片列表
  3. 以服务端返回结果为准,过滤出待上传分片

Q6: 如何保证分片上传的可靠性?

答案

可靠性保障需要多层机制:

  1. 分片校验:每个分片附带自身的 MD5 Hash,服务端接收后校验完整性
  2. 失败重试:使用指数退避策略自动重试失败分片,避免重试风暴
  3. 合并校验:所有分片上传完成后,服务端合并文件并计算整体 Hash,与客户端传来的 Hash 对比
  4. 幂等设计:同一分片重复上传不会导致数据错误(服务端以 fileHash + index 为唯一键,覆盖写入)
// 分片级校验
const chunkHash = await calculateChunkHash(chunk.blob);
formData.append('chunkHash', chunkHash);

// 服务端合并后校验
// POST /api/upload/merge
// 服务端:合并所有分片 → 计算合并文件 Hash → 与客户端 fileHash 对比

Q7: 如何处理上传过程中用户关闭/刷新页面?

答案

// 1. 监听页面离开事件,提示用户
window.addEventListener('beforeunload', (e: BeforeUnloadEvent) => {
if (isUploading) {
e.preventDefault();
// 现代浏览器会显示默认确认对话框
}
});

// 2. 在每个分片上传成功后持久化状态
// 参见 UploadStateManager 的 addUploadedChunk 方法

// 3. 页面重新加载后恢复上传
async function resumeUpload(file: File): Promise<void> {
const chunks = createFileChunks(file);
const { hash } = await calculateHash(chunks.map((c) => c.blob));

// 优先从本地缓存恢复
const localState = stateManager.get(hash);
// 再从服务端确认
const serverState = await checkUploadedChunks(hash, file.name);

// 以服务端为准
const uploadedSet = new Set(serverState.uploadedChunks);
const pendingChunks = chunks.filter((c) => !uploadedSet.has(c.index));

// 继续上传
await asyncPool(3, pendingChunks, uploadTask);
}

Q8: 抽样 Hash 和全量 Hash 如何取舍?

答案

维度全量 Hash抽样 Hash
准确性100% 准确极小概率碰撞
速度(1GB 文件)10-30 秒100-500 毫秒
CPU 占用
适用场景最终文件校验秒传预检

推荐混合策略

  1. 上传前:先用抽样 Hash 快速检测秒传(用户几乎无感知)
  2. 秒传命中:直接成功,无需全量计算
  3. 秒传未命中:在 Web Worker 中进行全量 Hash 计算,作为文件唯一标识
  4. 上传完成后:服务端对合并后的完整文件做全量 Hash 校验

这种策略兼顾了用户体验和数据准确性。


相关链接