大文件上传:分片、断点续传和秒传的实现方案

更新日期: 2025-12-03 阅读: 39 标签: 上传

处理大文件上传是个常见需求。直接上传大文件会遇到很多问题:网络不稳定、内存占用高、上传失败后又要从头开始。我们需要一套完整的解决方案。


核心思路

我们的做法是把大文件切成很多小片,分别上传这些小片,最后在服务器上把它们拼起来。这样做有几个好处:

  1. 分片上传:大文件变多个小文件,上传更稳定

  2. 断点续传:上传中断后,可以从断点继续

  3. 秒传:如果服务器已经有相同文件,可以瞬间完成


文件选择和队列管理

当用户选择一批文件时,不能同时上传所有文件。那样会拖慢浏览器,导致页面卡顿。

我们需要一个上传队列来控制同时上传的文件数量。

状态初始化和加入队列

// hooks/useFileUpload.ts
const addFiles = useCallback(async (fileList: FileList) => {
  // 把每个文件包装成对象
  const newFiles: FileItem[] = Array.from(fileList).map((file) => ({
    id: crypto.randomUUID(),  // 生成唯一ID
    file,                    // 原始文件对象
    status: "pending",       // 初始状态:等待中
    progress: 0,            // 进度从0开始
    name: file.name,
    size: file.size,
    // 其他属性...
  }));

  // 更新界面显示的文件列表
  setFiles((prev) => [...prev, ...newFiles]);
  
  // 加入上传队列
  queueRef.current.push(...newFiles);
  
  // 开始处理队列
  processQueue();
}, []);

文件级并发控制

我们用一个调度器来控制同时上传的文件数量:

// hooks/useFileUpload.ts
// 设置最大并发数
const MAX_CONCURRENT_FILES = 3;  // 同时最多上传3个文件

const processQueue = useCallback(async () => {
  // 循环条件:队列有文件 且 当前上传数没到上限
  while (
    queueRef.current.length > 0 &&
    uploadingCountRef.current < MAX_CONCURRENT_FILES
  ) {
    const fileItem = queueRef.current.shift();  // 从队列取一个文件
    if (fileItem) {
      uploadingCountRef.current++;  // 增加正在上传的计数

      // 开始上传这个文件
      uploadFile(fileItem).finally(() => {
        // 上传完成(无论成功失败)
        uploadingCountRef.current--;  // 减少计数
        processQueue();  // 继续处理队列中的下一个
      });
    }
  }
}, [uploadFile]);

这样无论用户选择多少文件,最多同时只上传3个,避免卡顿。


文件预处理:计算MD5

为了实现秒传和断点续传,我们需要给文件一个唯一标识。用文件名不行,因为用户可以改名字。我们采用MD5作为文件的“指纹”。

为什么不用整个文件计算MD5?

对于大文件(比如几个GB),一次性读入内存计算MD5会:

  1. 占用大量内存

  2. 卡住浏览器界面

  3. 可能导致浏览器崩溃

增量计算MD5

更好的做法是分块读取计算:

// libs/md5.ts
import SparkMD5 from 'spark-md5';

async function calculateMD5(file: File): Promise<string> {
  return new Promise((resolve, reject) => {
    const spark = new SparkMD5.ArrayBuffer();
    const fileReader = new FileReader();
    
    // 每次读取2MB
    const chunkSize = 2 * 1024 * 1024;
    let currentChunk = 0;
    const totalChunks = Math.ceil(file.size / chunkSize);

    fileReader.onload = (event) => {
      if (event.target?.result) {
        // 把这一块数据加入MD5计算
        spark.append(event.target.result as ArrayBuffer);
        currentChunk++;

        // 如果还有下一块,继续读取
        if (currentChunk < totalChunks) {
          loadNextChunk();
        } else {
          // 所有块都读完,得到最终MD5
          resolve(spark.end());
        }
      }
    };

    fileReader.onerror = () => {
      reject(new Error('文件读取失败'));
    };

    function loadNextChunk() {
      const start = currentChunk * chunkSize;
      const end = Math.min(start + chunkSize, file.size);
      const chunk = file.slice(start, end);
      fileReader.readAsArrayBuffer(chunk);
    }

    // 开始读取第一块
    loadNextChunk();
  });
}

使用Web Worker优化

MD5计算很耗时,如果在主线程计算,用户界面还是会卡。可以用Web Worker在后台线程计算:

// worker/md5.worker.ts
self.onmessage = async (event) => {
  const { file } = event.data;
  
  // 在worker里计算MD5
  const md5 = await calculateMD5(file);
  
  // 把结果发回主线程
  self.postMessage({ md5 });
};

// 在主线程中使用
const worker = new Worker('md5.worker.ts');
worker.postMessage({ file });
worker.onmessage = (event) => {
  const { md5 } = event.data;
  // 拿到MD5,继续下一步
};


预检查:秒传和断点续传

拿到文件的MD5后,先问服务器:“这个文件你见过吗?”

发送检查请求

// hooks/useFileUpload.ts
async function checkFile(md5: string, filename: string) {
  const response = await fetch('/api/upload/check', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ md5, filename })
  });
  
  return response.json();
}

服务器端检查逻辑

// app/api/upload/check/route.ts
export async function POST(request: Request) {
  const { md5, filename } = await request.json();
  
  // 1. 检查是否已有完整文件(秒传)
  const existingFile = await checkFileInIndex(md5);
  if (existingFile) {
    return Response.json({
      exists: true,          // 文件已存在
      path: existingFile.path,
      uploadedChunks: []     // 没有需要上传的分片
    });
  }
  
  // 2. 检查是否有部分上传的分片(断点续传)
  const tempDir = `./temp/${md5}`;
  let uploadedChunks: number[] = [];
  
  if (await fs.exists(tempDir)) {
    // 读取临时目录,找出已上传的分片
    const files = await fs.readdir(tempDir);
    uploadedChunks = files
      .filter(name => name.endsWith('.chunk'))
      .map(name => parseInt(name.split('.')[0]))
      .sort((a, b) => a - b);
  }
  
  return Response.json({
    exists: false,
    uploadedChunks  // 返回已上传的分片编号
  });
}


分片上传

如果文件不能秒传,就开始分片上传。

文件分片

// hooks/useFileUpload.ts
function createChunks(file: File, chunkSize: number = 5 * 1024 * 1024) {
  const chunks: Blob[] = [];
  let start = 0;
  
  while (start < file.size) {
    const end = Math.min(start + chunkSize, file.size);
    chunks.push(file.slice(start, end));
    start = end;
  }
  
  return chunks;
}

分片级并发控制

对于单个大文件,它的分片也不能同时上传,需要控制并发:

// hooks/useFileUpload.ts
async function uploadChunksWithConcurrency(
  file: File,
  md5: string,
  chunksToUpload: number[],
  uploadedChunks: number[] = []
) {
  const concurrency = 3;  // 同时上传3个分片
  let index = 0;         // 当前处理的索引
  
  // 上传单个分片
  const uploadChunk = async (chunkIndex: number) => {
    const chunkSize = 5 * 1024 * 1024;  // 5MB
    const start = chunkIndex * chunkSize;
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end);
    
    const formData = new FormData();
    formData.append('file', chunk);
    formData.append('md5', md5);
    formData.append('chunkIndex', chunkIndex.toString());
    formData.append('totalChunks', Math.ceil(file.size / chunkSize).toString());
    
    await fetch('/api/upload/chunk', {
      method: 'POST',
      body: formData
    });
    
    // 更新进度
    updateProgress(md5, chunkIndex, uploadedChunks.length);
  };
  
  // 并发上传
  const workers = Array.from({ length: concurrency }, async () => {
    while (index < chunksToUpload.length) {
      const chunkIndex = chunksToUpload[index];
      index++;
      await uploadChunk(chunkIndex);
    }
  });
  
  await Promise.all(workers);
}


分片合并

所有分片上传完成后,通知服务器合并文件。

客户端发送合并请求

// hooks/useFileUpload.ts
async function mergeFile(md5: string, filename: string, totalChunks: number) {
  const response = await fetch('/api/upload/merge', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      md5,
      filename,
      totalChunks
    })
  });
  
  return response.json();
}

服务器端流式合并

用Node.js的流(Stream)来合并,避免内存溢出:

// app/api/upload/merge/route.ts
import fs from 'fs/promises';
import { createReadStream, createWriteStream } from 'fs';
import path from 'path';

export async function POST(request: Request) {
  const { md5, filename, totalChunks } = await request.json();
  
  const tempDir = `./temp/${md5}`;
  const finalPath = `./uploads/${md5}_${filename}`;
  
  // 创建写入流
  const writeStream = createWriteStream(finalPath);
  
  try {
    // 按顺序合并所有分片
    for (let i = 0; i < totalChunks; i++) {
      const chunkPath = path.join(tempDir, `${i}.chunk`);
      
      await new Promise((resolve, reject) => {
        const readStream = createReadStream(chunkPath);
        
        // 把读流连接到写流,{ end: false } 让写流保持打开
        readStream.pipe(writeStream, { end: false });
        
        readStream.on('end', () => {
          // 这个分片读完,删除临时文件
          fs.unlink(chunkPath).then(resolve).catch(reject);
        });
        
        readStream.on('error', reject);
      });
    }
    
    // 所有分片都写完了,关闭写流
    writeStream.end();
    
    // 等待写流完全关闭
    await new Promise((resolve, reject) => {
      writeStream.on('finish', resolve);
      writeStream.on('error', reject);
    });
    
    // 删除临时目录
    await fs.rmdir(tempDir);
    
    // 更新文件索引
    await addToIndex(md5, finalPath);
    
    return Response.json({ success: true, path: finalPath });
    
  } catch (error) {
    writeStream.destroy();
    return Response.json({ success: false, error: String(error) }, { status: 500 });
  }
}

安全的文件索引更新

多个文件同时完成时,更新索引需要防止冲突:

// libs/file-index.ts
let indexWritePromise: Promise<void> = Promise.resolve();

export async function addToIndex(md5: string, filePath: string): Promise<void> {
  // 把新操作链接到Promise链末尾,确保顺序执行
  const newOperation = indexWritePromise
    .then(async () => {
      // 1. 读取现有索引
      const indexData = await fs.readFile('./index.json', 'utf-8');
      const index = JSON.parse(indexData || '{}');
      
      // 2. 添加新记录
      index[md5] = filePath;
      
      // 3. 写回文件
      await fs.writeFile('./index.json', JSON.stringify(index, null, 2));
    })
    .catch((error) => {
      console.error('更新索引失败:', error);
      throw error;
    });
  
  // 更新全局Promise链
  indexWritePromise = newOperation;
  
  // 返回新操作的Promise
  return newOperation;
}


完整的上传流程

1. 用户选择文件

// 在组件中
const handleFileSelect = (event: react.ChangeEvent<htmlInputElement>) => {
  if (event.target.files) {
    addFiles(event.target.files);
  }
};

2. 计算MD5和预检查

async function processFile(fileItem: FileItem) {
  try {
    // 1. 计算MD5
    const md5 = await calculateMD5(fileItem.file);
    
    // 2. 检查服务器状态
    const checkResult = await checkFile(md5, fileItem.file.name);
    
    if (checkResult.exists) {
      // 秒传成功
      fileItem.status = 'success';
      fileItem.progress = 100;
      return;
    }
    
    // 3. 准备上传分片
    const totalChunks = Math.ceil(fileItem.file.size / (5 * 1024 * 1024));
    const chunksToUpload = Array.from({ length: totalChunks }, (_, i) => i)
      .filter(i => !checkResult.uploadedChunks.includes(i));
    
    if (chunksToUpload.length === 0) {
      // 所有分片都已上传,直接合并
      await mergeFile(md5, fileItem.file.name, totalChunks);
      fileItem.status = 'success';
      fileItem.progress = 100;
      return;
    }
    
    // 4. 上传剩余分片
    fileItem.status = 'uploading';
    await uploadChunksWithConcurrency(
      fileItem.file,
      md5,
      chunksToUpload,
      checkResult.uploadedChunks
    );
    
    // 5. 合并分片
    await mergeFile(md5, fileItem.file.name, totalChunks);
    fileItem.status = 'success';
    fileItem.progress = 100;
    
  } catch (error) {
    fileItem.status = 'error';
    fileItem.error = String(error);
  }
}


界面显示和进度更新

用户需要看到上传进度:

// hooks/useFileUpload.ts
const updateProgress = useCallback((fileId: string, uploadedChunks: number, totalChunks: number) => {
  setFiles(prev => prev.map(file => {
    if (file.id === fileId) {
      const progress = Math.round((uploadedChunks / totalChunks) * 100);
      return { ...file, progress };
    }
    return file;
  }));
}, []);


错误处理和重试

网络可能不稳定,需要重试机制:

// hooks/useFileUpload.ts
async function uploadWithRetry(
  fn: () => Promise<any>,
  maxRetries: number = 3
): Promise<any> {
  let lastError: Error;
  
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error as Error;
      
      if (i < maxRetries - 1) {
        // 等待一段时间再重试(指数退避)
        const delay = Math.min(1000 * Math.pow(2, i), 10000);
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }
  
  throw lastError;
}


总结

实现大文件上传的关键点:

  1. 队列管理:控制同时上传的文件数,避免卡顿

  2. 分片处理:大文件切成小片,上传更稳定

  3. MD5计算:增量计算,避免内存问题

  4. 预检查:实现秒传和断点续传

  5. 并发控制:文件级和分片级都要控制

  6. 流式合并:服务器端用流避免内存溢出

  7. 错误处理:重试机制保证可靠性

这套方案能很好地处理各种大小的文件上传,用户体验好,服务器压力也小。实际项目中可以根据具体需求调整参数,比如分片大小、并发数等。

本文内容仅供个人学习、研究或参考使用,不构成任何形式的决策建议、专业指导或法律依据。未经授权,禁止任何单位或个人以商业售卖、虚假宣传、侵权传播等非学习研究目的使用本文内容。如需分享或转载,请保留原文来源信息,不得篡改、删减内容或侵犯相关权益。感谢您的理解与支持!

链接: https://fly63.com/article/detial/13281

input上传指定类型的文件

h5里提供了webkitdirectory 来实现上传文件夹的功能,但它仅支持Chrome。上传文件–限制类型 添加accept属性,使用*会导致谷歌浏览器响应延迟,对IE和火狐没有影响 。如需规定多个值,请使用逗号分隔。

Js先回显图片再上传

平时开发时可能会遇到上传图片问题,但如果是上传图片,多数是先进行上传然后才能回显,今天给大家介绍一个简单的上传前先对图片进行回显的方式,仅用一小部分js代码即可实现

input accept属性限制文件上传格式

上传文件的类型;具体做法如下所示:注意:accept属性可以限制上传格式,其有兼容性如下

理解下载速率(上行速率)和上传速率(下行速率)

下行速率一般是从网络上的主机获取数据的速率,各种网络软件的运用,都必须从网路上获取数据。上传速率(度)是指单位时间内,网络数据的上行流量。

谷歌chrome上传文件选择框会延迟很久才显示的解决方案

谷歌浏览器升级到chrome52.0.2743.80版本以上,会出现点击上传文件选择框会延迟几秒才会显示 反应很慢的情况。方案一:修改accept参数;方案二:浏览器修改设置

使用input的file进行上传进行预览

在使用file上传文件的时候,想到了图片预览的功能,然后查询了一些资料,一种是需要后端配合,将数据变成base64或者buff等数据传给后端然后调取接口进行显示,但是这种需要后端的配合和网络请求,感觉不如在纯前端操作方便的多

解决input type=file 同一个文件二次上传无效的问题

在做上传文件的时候,大家会引入input标签。但在实现的过程中,在上传一个文件后,第二次上传同一个文件时会无法触发上传的代码,问题其实这样解决。

设置nginx中文件上传的大小限制度

通过设置nginx的client_max_body_size解决nginx+php上传大文件的问题: 用nginx来做webserver的时,上传大文件时需要特别注意client_max_body_size这个参数,否则会中断在nginx的请求中,在php中是无法记录到访问的.

nodejs关于前后端图片上传的思路及实现代码

将图片发给后端 ajax:1.前端获取图片信息 文件域;2.将文件信息 存到formdata;3.调用后端写的api接口发送数据;b.接受返回的数据;前端页面显示图片

文件上传File格式转为Blob格式

最近项目开发中涉及到文件上传功能,使用的是七牛的服务。查看七牛文档发现文件上传格式为blob,而本地添加上传文件时获取到的是file格式,因此需要将file转换为blob,具体转换方法如下:

点击更多...

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!