实现分片上传

539 阅读5分钟

🎯 1. 概述

在大文件上传场景中,传统的上传方式存在诸多问题,如:

  • 文件过大,上传超时或失败:一次性上传大文件容易受到网络环境和带宽限制的影响,导致上传失败。
  • 上传中断需重新上传:如果上传过程中网络中断或其他原因导致上传失败,必须重新上传整个文件,浪费了大量的时间和带宽。
  • 无法复用已上传数据:对于重复上传的文件,服务器无法识别已经存在的数据,用户需重新上传,无法提高上传效率。

为了解决上述问题,通常使用以下几种技术手段:

  • 切片上传(Chunk Upload) :将大文件拆分为多个小块,逐块上传,可以有效应对大文件上传失败的问题,减少上传失败带来的影响。
  • 断点续传(Resumable Upload) :记录上传进度,上传失败后从中断处继续上传,避免重复上传已经成功的部分。
  • 秒传(Instant Upload) :利用文件的唯一标识(如 hash),检测文件是否已上传,若文件已存在,直接跳过上传,节省时间和带宽。
  • 暂停/恢复上传:在上传过程中可以随时暂停上传任务,并在恢复时继续上传,极大提升用户的操作体验。

这些技术手段相互结合,可以显著提升大文件上传的稳定性、效率和用户体验,特别适用于大文件、网络环境不稳定的场景。

📚 2. 实现思路

2.1 流程概述

大文件上传的核心流程通常包括以下几个步骤:

  1. 选择文件,计算文件 hash

    • 通过 File 对象读取文件并计算其唯一的 hash 值,作为文件的唯一标识。
  2. 切片处理,生成文件分片

    • 将文件按照固定的大小(如 5MB)切分为若干小块,方便并行上传,减轻服务器压力。
  3. 上传前检查(校验秒传与已上传切片)

    • 在上传文件前向服务器发送文件 hash,检查是否已有上传记录,若存在已上传分片则跳过,支持秒传和断点续传。
  4. 上传分片,更新进度,支持暂停与恢复

    • 依次上传切片,支持并行上传和暂停/恢复,实时更新上传进度,提升用户操作体验。
  5. 合并分片,完成上传

    • 所有切片上传完成后,通知服务器进行文件合并,生成完整文件,确保数据一致性。

通过上述流程,可以有效提高上传效率,避免重复上传,增强用户操作的灵活性。

📐 3. 关键技术与实现

3.1 文件切片

切片是实现大文件上传的基础,通过 File.prototype.slice 方法可以轻松将文件切割为若干小块,避免单次上传文件过大导致的失败。

以下代码实现了一个文件切片的函数,支持自定义切片大小(默认为 5MB)。

// 文件切片方法
function createFileChunks(file: File, chunkSize: number = 5 * 1024 * 1024) {
  const chunks = [];
  let cur = 0;
  // 循环遍历文件,按照指定的 chunkSize 进行切片
  while (cur < file.size) {
    chunks.push(file.slice(cur, cur + chunkSize)); // 通过 slice 方法切割文件
    cur += chunkSize; // 移动当前游标
  }
  return chunks; // 返回切片数组
}

3.2 生成文件 hash

生成 hash 是实现秒传和断点续传的关键,常用方法包括使用 Crypto API 或第三方库计算文件内容的 MD5/SHA-256 值。

以下代码使用 spark-md5 库对文件进行分片计算,生成文件的唯一 hash 值。

import SparkMD5 from 'spark-md5';

// 生成文件 hash
function calculateFileHash(chunks: Blob[]): Promise<string> {
  return new Promise((resolve) => {
    const spark = new SparkMD5.ArrayBuffer();
    let index = 0;

    // 递归读取每个分片并计算 hash
    const loadNext = () => {
      if (index < chunks.length) {
        const reader = new FileReader();
        reader.onload = (e) => {
          spark.append(e.target?.result as ArrayBuffer); // 追加每个分片的数据
          index++;
          loadNext(); // 继续处理下一个分片
        };
        reader.readAsArrayBuffer(chunks[index]); // 以 ArrayBuffer 形式读取分片数据
      } else {
        resolve(spark.end()); // 所有分片处理完成,返回最终 hash 值
      }
    };
    loadNext();
  });
}

3.3 上传前校验

上传前,前端应向服务器发送 hash 请求,服务器检查是否存在已上传的切片列表。

此操作可以实现秒传和断点续传,避免重复上传已存在的数据。

// 上传前校验文件是否已上传
async function checkFile(hash: string) {
  return await fetch('/api/upload/check', {
    method: 'POST',
    body: JSON.stringify({ hash }),
  }).then((res) => res.json()); // 返回服务器的校验结果
}

3.4 上传的主流程

// 上传主流程
async function handleUpload(file) {
  const chunks = createFileChunks(file);
  const hash = await calculateFileHash(chunks);
  const { uploadedChunks } = await checkFile(hash);

  for (let i = 0; i < chunks.length; i++) {
    if (!uploadedChunks.includes(i)) {
      await uploadChunk(hash, i, chunks[i]);
    }
  }

  await mergeChunks(hash, chunks.length);
  alert('上传完成');
}

3.4 Node.js 服务端实现

使用 Node.js 处理切片上传、断点续传、秒传和合并切片。

const fs = require('fs');
const path = require('path');

// 检查文件是否已上传
app.post('/api/upload/check', (req, res) => {
  const { hash } = req.body;
  const uploadedChunks = getUploadedChunks(hash);
  res.json({ uploadedChunks });
});

// 处理切片上传
app.post('/api/upload/chunk', (req, res) => {
  const { hash, index } = req.body;
  const chunkPath = path.join(__dirname, 'uploads', `${hash}-${index}`);

  req.pipe(fs.createWriteStream(chunkPath));
  req.on('end', () => res.status(200).send('ok'));
});

// 合并切片
app.post('/api/upload/merge', (req, res) => {
  const { hash, total } = req.body;
  const filePath = path.join(__dirname, 'uploads', hash);

  for (let i = 0; i < total; i++) {
    fs.appendFileSync(filePath, fs.readFileSync(`${filePath}-${i}`));
    fs.unlinkSync(`${filePath}-${i}`);
  }

  res.status(200).send('merge completed');
});

📊 4. 总结

通过切片上传、断点续传、秒传与暂停/恢复上传技术的结合,可以显著提升大文件上传的效率与可靠性。这种方案特别适用于网络环境复杂或大文件传输需求强烈的场景,既能提升用户体验,又能有效节省带宽和时间。