Skip to content

前端文件传输实战

🎯 大文件分片上传、断点续传、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: 大文件上传如何实现断点续传?

  1. 文件计算 Hash 作为唯一标识
  2. 文件切分为固定大小的分片
  3. 服务端记录已上传的分片索引
  4. 上传前请求已上传的分片列表
  5. 只上传未完成的分片
  6. 全部上传完成后合并

Q2: 文件 Hash 计算太慢怎么办?

  1. 使用 Web Worker 避免阻塞 UI
  2. 采样计算:只取首尾和中间部分
  3. 使用增量 Hash 库(如 spark-md5)
  4. 结合文件大小、修改时间作为辅助标识

Q3: 如何实现秒传?

  1. 上传前计算文件 Hash
  2. 请求服务端检查该 Hash 是否已存在
  3. 如果存在,直接返回已有文件 URL
  4. 不存在则正常上传

Q4: OSS 直传的安全性如何保证?

  1. 使用 STS 临时凭证(有效期短)
  2. 限制上传路径(Policy)
  3. 限制文件类型和大小
  4. 服务端签名 URL(不暴露密钥)

前端面试知识库