前端文件传输实战
🎯 大文件分片上传、断点续传、OSS 直传完整方案
1. 文件上传方案对比
┌─────────────────────────────────────────────────────────────────────────┐
│ 文件上传方案对比 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 方案 │ 优点 │ 缺点 │
│ ─────────────────┼───────────────────────┼───────────────────────── │
│ FormData 上传 │ 简单,兼容性好 │ 无法断点续传 │
│ Base64 上传 │ 简单 │ 体积增大 33%,不适合大文件│
│ 分片上传 │ 支持大文件,可断点续传 │ 实现复杂 │
│ OSS 直传 │ 不经过服务器,带宽无压力 │ 需要云服务 │
│ │
│ 推荐: │
│ • 小文件 (<5MB): FormData 直传 │
│ • 大文件 (>5MB): 分片上传 或 OSS 直传 │
│ │
└─────────────────────────────────────────────────────────────────────────┘2. 基础文件上传
2.1 FormData 上传
typescript
// 单文件上传
async function uploadFile(file: File): Promise<string> {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
// 注意: 不要手动设置 Content-Type
// 浏览器会自动设置 multipart/form-data 和 boundary
});
const data = await response.json();
return data.url;
}
// 多文件上传
async function uploadFiles(files: FileList): Promise<string[]> {
const formData = new FormData();
Array.from(files).forEach((file, index) => {
formData.append(`files[${index}]`, file);
});
const response = await fetch('/api/upload/batch', {
method: 'POST',
body: formData,
});
const data = await response.json();
return data.urls;
}2.2 带进度的上传
typescript
function uploadWithProgress(
file: File,
onProgress: (percent: number) => void
): Promise<string> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('file', file);
// 上传进度
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100);
onProgress(percent);
}
};
xhr.onload = () => {
if (xhr.status === 200) {
const data = JSON.parse(xhr.responseText);
resolve(data.url);
} else {
reject(new Error(`Upload failed: ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.open('POST', '/api/upload');
xhr.send(formData);
});
}
// 使用
const url = await uploadWithProgress(file, (percent) => {
console.log(`上传进度: ${percent}%`);
progressBar.style.width = `${percent}%`;
});3. 大文件分片上传
3.1 分片上传流程
┌─────────────────────────────────────────────────────────────────────────┐
│ 分片上传流程 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Client Server │
│ │ │ │
│ │ 1. 计算文件 Hash (MD5/SHA) │ │
│ │ │ │
│ │─── POST /upload/check ───────────────────▶│ │
│ │ { hash, filename, size } │ │
│ │◀── { uploaded: false, uploadedChunks: [] } │ │
│ │ │ │
│ │ 2. 文件分片 (slice) │ │
│ │ │ │
│ │─── POST /upload/chunk ───────────────────▶│ 上传分片 1 │
│ │ { hash, chunk, index } │ │
│ │◀── { success: true } ──────────────────── │ │
│ │ │ │
│ │─── POST /upload/chunk ───────────────────▶│ 上传分片 2 │
│ │ ... │ │
│ │ │ │
│ │ 3. 所有分片上传完成 │ │
│ │ │ │
│ │─── POST /upload/merge ───────────────────▶│ 合并分片 │
│ │ { hash, filename, total } │ │
│ │◀── { url: "https://..." } ────────────────│ │
│ │
└─────────────────────────────────────────────────────────────────────────┘3.2 完整实现
typescript
// chunk-upload.ts
interface ChunkInfo {
chunk: Blob;
index: number;
hash: string;
}
interface UploadOptions {
chunkSize?: number; // 分片大小,默认 5MB
concurrent?: number; // 并发数,默认 3
onProgress?: (percent: number) => void;
}
const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
const DEFAULT_CONCURRENT = 3;
/**
* 计算文件 Hash (使用 Web Worker 避免阻塞)
*/
async function calculateHash(file: File): Promise<string> {
return new Promise((resolve) => {
const worker = new Worker('/hash-worker.js');
worker.postMessage(file);
worker.onmessage = (e) => {
resolve(e.data);
worker.terminate();
};
});
}
// hash-worker.js
// self.onmessage = async (e) => {
// const file = e.data;
// const buffer = await file.arrayBuffer();
// const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
// const hashArray = Array.from(new Uint8Array(hashBuffer));
// const hash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
// self.postMessage(hash);
// };
/**
* 简化版 Hash (使用抽样计算,速度快)
*/
async function calculateHashQuick(file: File): Promise<string> {
const spark = new SparkMD5.ArrayBuffer();
const chunkSize = 2 * 1024 * 1024; // 2MB 采样
// 取首尾和中间部分
const chunks = [
file.slice(0, chunkSize),
file.slice(file.size / 2 - chunkSize / 2, file.size / 2 + chunkSize / 2),
file.slice(-chunkSize),
];
for (const chunk of chunks) {
spark.append(await chunk.arrayBuffer());
}
// 加入文件大小
spark.append(new TextEncoder().encode(file.size.toString()));
return spark.end();
}
/**
* 分片
*/
function createChunks(file: File, chunkSize: number): Blob[] {
const chunks: Blob[] = [];
let start = 0;
while (start < file.size) {
chunks.push(file.slice(start, start + chunkSize));
start += chunkSize;
}
return chunks;
}
/**
* 上传单个分片
*/
async function uploadChunk(
chunk: Blob,
index: number,
hash: string,
filename: string
): Promise<void> {
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('index', index.toString());
formData.append('hash', hash);
formData.append('filename', filename);
const response = await fetch('/api/upload/chunk', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`Chunk ${index} upload failed`);
}
}
/**
* 并发控制
*/
async function concurrentUpload<T>(
tasks: (() => Promise<T>)[],
concurrent: number
): Promise<T[]> {
const results: T[] = [];
const executing: Promise<void>[] = [];
for (const [index, task] of tasks.entries()) {
const p = task().then((result) => {
results[index] = result;
});
executing.push(p);
if (executing.length >= concurrent) {
await Promise.race(executing);
// 移除已完成的
const completed = await Promise.race(executing.map((p, i) => p.then(() => i)));
executing.splice(completed, 1);
}
}
await Promise.all(executing);
return results;
}
/**
* 分片上传主函数
*/
export async function chunkUpload(
file: File,
options: UploadOptions = {}
): Promise<string> {
const {
chunkSize = DEFAULT_CHUNK_SIZE,
concurrent = DEFAULT_CONCURRENT,
onProgress,
} = options;
// 1. 计算 Hash
const hash = await calculateHashQuick(file);
// 2. 检查是否已上传 (秒传)
const checkResponse = await fetch('/api/upload/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hash,
filename: file.name,
size: file.size,
}),
});
const checkResult = await checkResponse.json();
// 已上传过,秒传
if (checkResult.uploaded) {
onProgress?.(100);
return checkResult.url;
}
// 3. 创建分片
const chunks = createChunks(file, chunkSize);
const uploadedIndexes = new Set(checkResult.uploadedChunks || []);
// 4. 过滤已上传的分片
const tasksToUpload = chunks
.map((chunk, index) => ({ chunk, index }))
.filter(({ index }) => !uploadedIndexes.has(index));
// 5. 上传分片
let uploadedCount = uploadedIndexes.size;
const uploadTasks = tasksToUpload.map(({ chunk, index }) => async () => {
await uploadChunk(chunk, index, hash, file.name);
uploadedCount++;
onProgress?.(Math.round((uploadedCount / chunks.length) * 100));
});
await concurrentUpload(uploadTasks, concurrent);
// 6. 合并分片
const mergeResponse = await fetch('/api/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hash,
filename: file.name,
total: chunks.length,
}),
});
const mergeResult = await mergeResponse.json();
return mergeResult.url;
}3.3 React Hook
typescript
// useChunkUpload.ts
import { useState, useCallback, useRef } from 'react';
import { chunkUpload } from './chunk-upload';
interface UseChunkUploadReturn {
upload: (file: File) => Promise<string>;
progress: number;
isUploading: boolean;
cancel: () => void;
}
export function useChunkUpload(): UseChunkUploadReturn {
const [progress, setProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const abortRef = useRef(false);
const upload = useCallback(async (file: File) => {
setIsUploading(true);
setProgress(0);
abortRef.current = false;
try {
const url = await chunkUpload(file, {
onProgress: (p) => {
if (!abortRef.current) {
setProgress(p);
}
},
});
return url;
} finally {
setIsUploading(false);
}
}, []);
const cancel = useCallback(() => {
abortRef.current = true;
setIsUploading(false);
}, []);
return { upload, progress, isUploading, cancel };
}
// 使用
function UploadComponent() {
const { upload, progress, isUploading } = useChunkUpload();
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const url = await upload(file);
console.log('Uploaded:', url);
}
};
return (
<div>
<input type="file" onChange={handleChange} disabled={isUploading} />
{isUploading && <progress value={progress} max="100" />}
</div>
);
}4. OSS 直传
4.1 OSS 直传流程
┌─────────────────────────────────────────────────────────────────────────┐
│ OSS 直传流程 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Client Server OSS │
│ │ │ │ │
│ │─ 获取上传凭证 ───▶│ │ │
│ │ │ │ │
│ │◀─ 返回凭证 ───────│ │ │
│ │ (STS Token / │ │ │
│ │ 签名URL) │ │ │
│ │ │ │ │
│ │───────────── 直接上传文件 ───────────▶│ │
│ │ │ │
│ │◀────────────── 上传成功 ──────────────│ │
│ │ │ │
│ │─ 上报文件信息 ───▶│ │ │
│ │ (可选) │ │ │
│ │
│ 优势: │
│ • 文件不经过业务服务器,节省带宽 │
│ • 利用 OSS 的全球加速 │
│ • 支持断点续传、分片上传 │
│ │
└─────────────────────────────────────────────────────────────────────────┘4.2 阿里云 OSS 直传
typescript
// oss-upload.ts
import OSS from 'ali-oss';
interface STSCredentials {
accessKeyId: string;
accessKeySecret: string;
securityToken: string;
expiration: string;
}
interface OSSConfig {
region: string;
bucket: string;
}
// 获取 STS 凭证
async function getSTSCredentials(): Promise<STSCredentials> {
const response = await fetch('/api/oss/sts');
return response.json();
}
// 创建 OSS Client
async function createOSSClient(config: OSSConfig): Promise<OSS> {
const credentials = await getSTSCredentials();
return new OSS({
region: config.region,
bucket: config.bucket,
accessKeyId: credentials.accessKeyId,
accessKeySecret: credentials.accessKeySecret,
stsToken: credentials.securityToken,
refreshSTSToken: async () => {
const newCredentials = await getSTSCredentials();
return {
accessKeyId: newCredentials.accessKeyId,
accessKeySecret: newCredentials.accessKeySecret,
stsToken: newCredentials.securityToken,
};
},
});
}
// 上传文件
export async function uploadToOSS(
file: File,
config: OSSConfig,
onProgress?: (percent: number) => void
): Promise<string> {
const client = await createOSSClient(config);
// 生成唯一文件名
const ext = file.name.split('.').pop();
const filename = `uploads/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
// 分片上传 (大文件)
if (file.size > 100 * 1024 * 1024) { // 100MB
const result = await client.multipartUpload(filename, file, {
progress: (p: number) => {
onProgress?.(Math.round(p * 100));
},
partSize: 5 * 1024 * 1024, // 5MB per part
});
return result.res.requestUrls[0].split('?')[0];
}
// 普通上传
const result = await client.put(filename, file);
return result.url;
}4.3 签名 URL 直传
typescript
// 后端生成签名 URL (Node.js)
/*
const OSS = require('ali-oss');
app.get('/api/oss/sign-url', async (req, res) => {
const { filename, contentType } = req.query;
const client = new OSS({
region: 'oss-cn-hangzhou',
accessKeyId: process.env.OSS_ACCESS_KEY_ID,
accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET,
bucket: 'my-bucket',
});
const key = `uploads/${Date.now()}-${filename}`;
// 生成签名 URL,有效期 1 小时
const signedUrl = client.signatureUrl(key, {
method: 'PUT',
expires: 3600,
'Content-Type': contentType,
});
res.json({
signedUrl,
key,
publicUrl: `https://my-bucket.oss-cn-hangzhou.aliyuncs.com/${key}`,
});
});
*/
// 前端使用签名 URL 上传
async function uploadWithSignedUrl(file: File): Promise<string> {
// 1. 获取签名 URL
const response = await fetch(
`/api/oss/sign-url?filename=${file.name}&contentType=${file.type}`
);
const { signedUrl, publicUrl } = await response.json();
// 2. 直接上传到 OSS
await fetch(signedUrl, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type,
},
});
return publicUrl;
}5. 文件下载
5.1 常规下载
typescript
// 方法 1: a 标签
function downloadByLink(url: string, filename: string): void {
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
}
// 方法 2: Blob 下载 (可处理需要认证的资源)
async function downloadByBlob(url: string, filename: string): Promise<void> {
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${getToken()}`,
},
});
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = filename;
link.click();
URL.revokeObjectURL(objectUrl);
}5.2 大文件流式下载
typescript
async function downloadLargeFile(
url: string,
filename: string,
onProgress?: (percent: number) => void
): Promise<void> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Download failed: ${response.status}`);
}
const contentLength = response.headers.get('Content-Length');
const total = contentLength ? parseInt(contentLength, 10) : 0;
const reader = response.body!.getReader();
const chunks: Uint8Array[] = [];
let loaded = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.length;
if (total) {
onProgress?.(Math.round((loaded / total) * 100));
}
}
// 合并 chunks
const blob = new Blob(chunks);
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = filename;
link.click();
URL.revokeObjectURL(objectUrl);
}6. 面试高频问题
Q1: 大文件上传如何实现断点续传?
- 文件计算 Hash 作为唯一标识
- 文件切分为固定大小的分片
- 服务端记录已上传的分片索引
- 上传前请求已上传的分片列表
- 只上传未完成的分片
- 全部上传完成后合并
Q2: 文件 Hash 计算太慢怎么办?
- 使用 Web Worker 避免阻塞 UI
- 采样计算:只取首尾和中间部分
- 使用增量 Hash 库(如 spark-md5)
- 结合文件大小、修改时间作为辅助标识
Q3: 如何实现秒传?
- 上传前计算文件 Hash
- 请求服务端检查该 Hash 是否已存在
- 如果存在,直接返回已有文件 URL
- 不存在则正常上传
Q4: OSS 直传的安全性如何保证?
- 使用 STS 临时凭证(有效期短)
- 限制上传路径(Policy)
- 限制文件类型和大小
- 服务端签名 URL(不暴露密钥)