前端手动实现大文件分片上传调度层:分片计算、并发上传与断点续传

文章目录

  • 前端手动实现大文件分片上传调度层:分片计算、并发上传与断点续传
    • [1.初始化上传会话:获取 uploadId](#1.初始化上传会话:获取 uploadId)
    • [2. 前端分片计算:File -> Blob/File chunks](#2. 前端分片计算:File -> Blob/File chunks)
      • [2.1 计算分片数量和分片大小](#2.1 计算分片数量和分片大小)
      • [2.2 生成分片数组](#2.2 生成分片数组)
    • 3.分片指纹
    • 4.并发上传分片
      • [4.1 默认配置](#4.1 默认配置)
      • [4.2 并发池核心逻辑](#4.2 并发池核心逻辑)
      • [4.3 单个分片上传逻辑](#4.3 单个分片上传逻辑)
    • 5.断点续传:暂停与恢复
      • [5.1 pause:取消进行中的请求,保留已成功分片](#5.1 pause:取消进行中的请求,保留已成功分片)
      • [5.2 resume:筛出未上传分片,继续并发上传](#5.2 resume:筛出未上传分片,继续并发上传)
    • [6. 总结](#6. 总结)

前端手动实现大文件分片上传调度层:分片计算、并发上传与断点续传

这个方案没有直接用 MinIO 的预签名 URL 直传,主要不是因为 MinIO 做不了,而是因为需要更强的业务控制。同时减少适配 s3 协议的复杂度.这套方案的重点不是"前端直接操作 MinIO",而是:前端实现上传调度层,后端实现上传协议和存储落盘,MinIO 作为最终对象存储底座

普通整文件上传太简单,大文件一断就得重传,不适合 20GB 这种场景;简单的预签名 PUT URL 也更适合中小文件,S3 单次 PUT 最大通常是 5GB;预签名 URL 配合 multipart upload 虽然也能做大文件直传,但前端还要维护每个 part 的 URL、ETag、过期重签,后端也要处理 complete multipart,整体复杂度更高。前端既要控制上传流程,又要适配 S3/MinIO 那套对象存储协议

所以我们选择在业务层自己实现一套 init / upload / merge 协议。前端负责切片、MD5、并发、重试、暂停恢复和断点续传状态;后端负责 uploadId、分片状态、缺片检查、merge 和最终落 MinIO。这样虽然文件流量会经过业务后端,但整个上传过程对业务系统更可控。

1.初始化上传会话:获取 uploadId

upload.ts

typescript 复制代码
async initUpload(params: InitUploadParams): Promise<InitUploadResponse> {
  const { data } = await request<InitUploadResponse>({
    url: `${BASE_URL}/init`,
    method: 'POST',
    data: params,
  })
  return data
},

initUpload 不做业务编排,只做 API 封装。关键是把文件元信息发给 /init,拿回 uploadId 和后续分片策略参数。

2. 前端分片计算:File -> Blob/File chunks

useChunkUpload.ts

上传前置流程

typescript 复制代码
async function initializeUpload(file: File): Promise<string> {
  validateFileSize(file.size)

  // 计算分片信息
  const chunkInfo = calculateChunkInfo(file, config.chunkSize)
  chunks.value = chunkInfo.chunks
  state.totalChunks = chunkInfo.chunkCount

  // 初始化分片上传
  const response = await initChunkUpload(UploadService, {
    fileName: file.name,
    fileSize: String(file.size),
    partSize: chunkInfo.chunkSize,
    partCount: chunkInfo.chunkCount,
  })

  return response.data.uploadId
}

init 阶段前端先本地切片,拿到 partCount/partSize,再调用 /init。后端返回 uploadId,它是整个上传会话的唯一标识。之后每个分片请求都带 uploadId + partNumber,服务端就能知道哪些分片已到达,从而支持中断后继续上传。"

2.1 计算分片数量和分片大小

在上传前置流程的开始,需要计算分片信息,分片信息相关封装在了一个工具函数中utils/chunkCalculator.ts

javascript 复制代码
// 计算分片信息
const chunkInfo = calculateChunkInfo(file, config.chunkSize)
chunks.value = chunkInfo.chunks
state.totalChunks = chunkInfo.chunkCount
  • 得到分片信息,传原始 file 文件和对应的业务要求每个分片分多大的业务要求

calculateChunkInfo 如下:

javascript 复制代码
/**
 * 完整的分片计算(包含生成分片信息)
 * @param file 文件对象
 * @param chunkSize 分片大小(字节)
 * @returns 完整的分片计算结果
 */
export function calculateChunkInfo(
  file: File,
  chunkSize: number = CHUNK_SIZE,
): ChunkCalculationResult {
  const { chunkSize: actualChunkSize, chunkCount } = calculateChunks(file.size, chunkSize)

  const chunks = generateChunks(file, actualChunkSize)

  return {
    chunkSize: actualChunkSize,
    chunkCount,
    chunks,
  }
}

核心逻辑:

通过封装好的方法,最终得到了每一个分片的大小,分片的数量,和包含有各种信息的完整的分好的分片数组

2.2 生成分片数组

calculateChunks 如下:

typescript 复制代码
/**
 * 计算分片信息
 * @param fileSize 文件大小(字节)
 * @param chunkSize 分片大小(字节),默认 5MB
 * @returns 分片计算结果
 */
export function calculateChunks(
  fileSize: number,
  chunkSize: number = CHUNK_SIZE,
): { chunkSize: number; chunkCount: number } {
  validateFileSize(fileSize)

  // 如果文件小于分片大小,分片大小设为 5MB,数量为 1
  if (fileSize < CHUNK_SIZE) {
    return {
      chunkSize: CHUNK_SIZE,
      chunkCount: 1,
    }
  }

  // 确保分片大小不小于 5MB
  const actualChunkSize = Math.max(chunkSize, CHUNK_SIZE)
  const chunkCount = Math.ceil(fileSize / actualChunkSize)

  return {
    chunkSize: actualChunkSize,
    chunkCount,
  }
}

核心逻辑:

得到分片大小和分片数量,校验分片大小,上取整得到分片数量

generateChunks 如下:

typescript 复制代码
/**
 * 生成分片信息数组
 * @param file 文件对象
 * @param chunkSize 分片大小(字节)
 * @returns 分片信息数组
 */
export function generateChunks(file: File, chunkSize: number = CHUNK_SIZE): ChunkInfo[] {
  validateFileSize(file.size)

  const { chunkSize: actualChunkSize, chunkCount } = calculateChunks(file.size, chunkSize)

  const chunks: ChunkInfo[] = []

  for (let i = 0; i < chunkCount; i++) {
    const start = i * actualChunkSize
    const end = Math.min(start + actualChunkSize, file.size)
    const size = end - start

    // 将 Blob 转换为 File 对象
    // 功能:file.slice() 返回的是 Blob,但服务端需要 File 对象(MultipartFile)
    // 使用 File 构造函数将 Blob 转换为 File
    const blob = file.slice(start, end)
    const chunkFile = new File([blob], `${file.name}.part${i + 1}`, {
      type: file.type || 'application/octet-stream',
      lastModified: file.lastModified,
    })

    chunks.push({
      partNumber: i + 1, // 分片序号从 1 开始
      start,
      end,
      size,
      blob: chunkFile, // 现在 blob 字段存储的是 File 对象
    })
  }

  return chunks
}

核心逻辑:

start 代表每一片分片的开始,end 代表每一片分片的结尾,end-start 代表每一片的真实大小

Blob:slice()方法:官方文档,通过官方文档中可知,这是一个左闭右开的区间,返回一个 blob

因为后端的需求是拿到 file,所以应该使用 file 构造函数,得到 file,官方文档,type 字段稍微解释一下,表示这个数据是什么格式的,例如图片还是文本

注意 chunks 数组中 blob 字段存储的是 file对象

拓展:

file 和 blob 的区别?

Blob 是"二进制数据块";File 是"带文件信息的 Blob"。File 是一种更具体的 Blob,它通常来自:,File 除了有 Blob 的能力,还多了几个文件相关信息,比如

javascript 复制代码
file.name          // 文件名,例如 "demo.mp4"
file.lastModified  // 最后修改时间
file.webkitRelativePath // 上传文件夹时可能用到

3.分片指纹

在单个文件上传的过程中,后端需要,每一个小分片的上传过程中上传一个 md5 摘要,md5 是在上传前由前端计算生成的

计算 md5

typescript 复制代码
async function calculateChunkMD5(chunk: ChunkInfo): Promise<string> {
  if (chunk.md5) {
    return chunk.md5
  }
  const md5 = await calculateMD5(chunk.blob)
  chunk.md5 = md5
  return md5
}

具体md5 工具函数:

具体的实现不用过多纠结,只需要知道,我们在上传每个分片前,会先对当前分片 Blob 计算 MD5。前端通过 FileReader 把 Blob 读取成 ArrayBuffer,再转成 CryptoJS 可以处理的 WordArray,最后调用 CryptoJS.MD5 得到该分片的哈希值。这个 MD5 主要用于分片完整性校验、断点续传或后端判断分片是否已上传。

typescript 复制代码
/**
 * MD5 计算工具
 */

import CryptoJS from 'crypto-js'

/**
 * 计算 Blob 的 MD5 值
 * @param blob 要计算 MD5 的 Blob 对象
 * @returns Promise<string> MD5 哈希值
 */
export function calculateMD5(blob: Blob): Promise<string> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader()

    reader.onload = (event) => {
      try {
        if (!event.target?.result) {
          reject(new Error('读取文件失败'))
          return
        }

        // 将 ArrayBuffer 转换为 WordArray
        const wordArray = CryptoJS.lib.WordArray.create(event.target.result as ArrayBuffer)

        // 计算 MD5
        const hash = CryptoJS.MD5(wordArray)

        // 转换为字符串
        resolve(hash.toString())
      } catch (error) {
        reject(error instanceof Error ? error : new Error('MD5 计算失败'))
      }
    }

    reader.onerror = () => {
      reject(new Error('文件读取错误'))
    }

    // 读取为 ArrayBuffer
    reader.readAsArrayBuffer(blob)
  })
}

/**
 * 批量计算多个 Blob 的 MD5 值
 * @param blobs Blob 数组
 * @returns Promise<string[]> MD5 哈希值数组
 */
export async function calculateMD5Batch(blobs: Blob[]): Promise<string[]> {
  const promises = blobs.map((blob) => calculateMD5(blob))
  return Promise.all(promises)
}

/**
 * 使用 Web Worker 异步计算 MD5(可选优化)
 * 注意:需要单独创建 worker 文件
 * @param blob 要计算 MD5 的 Blob 对象
 * @returns Promise<string> MD5 哈希值
 */
export function calculateMD5WithWorker(blob: Blob): Promise<string> {
  // 如果浏览器不支持 Worker,回退到同步计算
  if (typeof Worker === 'undefined') {
    return calculateMD5(blob)
  }

  return new Promise((resolve, reject) => {
    // 这里可以创建一个 Web Worker 来处理大文件的 MD5 计算
    // 为了简化,这里先使用同步方式
    // 后续可以优化为 Worker 实现
    calculateMD5(blob).then(resolve).catch(reject)
  })
}

4.并发上传分片

4.1 默认配置

typescript 复制代码
// 默认配置
const DEFAULT_OPTIONS: Required<
  Pick<
    ChunkUploadOptions,
    'chunkSize' | 'maxFileSize' | 'maxConcurrent' | 'maxRetries' | 'retryDelay'
  >
> = {
  chunkSize: CHUNK_SIZE,
  maxFileSize: MAX_FILE_SIZE,
  maxConcurrent: 3,
  maxRetries: 3,
  retryDelay: 1000,
}
  • 规定了并发路数,最大重试数,重试秒数

4.2 并发池核心逻辑

并发上传多个分片:

javascript 复制代码
/**
 * 并发上传分片
 */
async function uploadChunksConcurrently(partNumbers: number[]) {
  const queue = [...partNumbers]
  const workers: Promise<void>[] = []

  while (queue.length > 0 || workers.length > 0) {
    // 如果已取消或暂停,停止上传
    if (state.isCancelled || state.isPaused) {
      // 取消所有进行中的上传
      workers.forEach(() => {
        // 这里可以添加取消逻辑
      })
      break
    }

    // 填充工作队列
    while (workers.length < config.maxConcurrent && queue.length > 0) {
      const partNumber = queue.shift()!
      state.pendingChunks.add(partNumber)

      const worker = uploadSingleChunk(partNumber)
        .catch((error) => {
          // 错误已在 uploadSingleChunk 中处理
          console.error(`分片 ${partNumber} 上传失败:`, error)
        })
        .finally(() => {
          const index = workers.indexOf(worker)
          if (index > -1) {
            workers.splice(index, 1)
          }
        })

      workers.push(worker)
    }

    // 等待至少一个任务完成
    if (workers.length > 0) {
      await Promise.race(workers)
    }
  }
}

核心逻辑:

传入切好的分片数组中的数字编号,比如分了 10 片就是 1-10,把这些编号丢进并发队列queue ,workers作为正在执行的 Promise 任务池让 worker 按编号逐个上传对应分片.workers 里面放的就是一个个 Promise 任务,uploadSingleChunk 是单个上传分片的逻辑

如果错误,打印第几个分片错误了,无论正确还是错误,都要把这个分片,从工作队列中移除

如果有一个任务完成(无论失败还是成功),进入到下一轮的循环

状态管理:

typescript 复制代码
// 状态管理
const state = reactive<ChunkUploadState>({
  file: null,
  fileName: '',
  fileSize: 0,
  status: 'idle',
  progress: 0,
  uploadId: null,
  totalChunks: 0,
  uploadedChunks: new Set<number>(),
  failedChunks: new Set<number>(),
  pendingChunks: new Set<number>(),
  isPaused: false,
  isCancelled: false,
  error: null,
  result: null,
})

核心逻辑:

很明显的看到了 3 个 set

  • uploadedChunks:已上传成功的分片编号

  • failedChunks:上传失败的分片编号

  • pendingChunks:当前待处理中的分片编号

4.3 单个分片上传逻辑

javascript 复制代码
/**
 * 上传单个分片
 */
async function uploadSingleChunk(partNumber: number, retryCount = 0): Promise<void> {
  if (state.isCancelled || state.isPaused) {
    return
  }

  const chunk = chunks.value.find((c) => c.partNumber === partNumber)
  if (!chunk) {
    throw new Error(`分片 ${partNumber} 不存在`)
  }

  // 如果已经上传成功,跳过
  if (state.uploadedChunks.has(partNumber)) {
    return
  }

  // 创建 CancelToken
  const source = axios.CancelToken.source()
  cancelTokens.set(partNumber, source)

  try {
    // 计算 MD5
    const md5 = await calculateChunkMD5(chunk)

    // 上传分片
    // 功能:调用阶段二的 uploadChunk API 上传单个分片
    // chunk.blob 现在已经是 File 对象(在 chunkCalculator 中已转换)
    await uploadChunk(
      UploadService,
      {
        fileName: state.fileName,
        uploadId: state.uploadId!,
        partNumber,
        partSize: chunk.size,
        file: chunk.blob as File, // 确保是 File 对象
        md5,
      },
      source.token,
    )

    // 标记为已上传
    state.uploadedChunks.add(partNumber)
    state.failedChunks.delete(partNumber)
    state.pendingChunks.delete(partNumber)
    cancelTokens.delete(partNumber)

    // 更新进度
    updateProgress()

    // 调用回调
    if (config.onChunkComplete) {
      config.onChunkComplete(partNumber)
    }
  } catch (error) {
    cancelTokens.delete(partNumber)

    // 如果是取消或暂停,不处理错误
    if (state.isCancelled || state.isPaused) {
      // 从 pendingChunks 中删除,因为已经被取消/暂停
      state.pendingChunks.delete(partNumber)
      return
    }

    // 检查是否是取消错误
    if (
      error &&
      typeof error === 'object' &&
      'message' in error &&
      typeof error.message === 'string' &&
      (error.message === '上传已取消' || error.message.includes('cancel'))
    ) {
      return
    }

    // 重试逻辑
    if (retryCount < config.maxRetries) {
      await new Promise((resolve) => setTimeout(resolve, config.retryDelay))
      return uploadSingleChunk(partNumber, retryCount + 1)
    }

    // 标记为失败
    state.failedChunks.add(partNumber)
    state.pendingChunks.delete(partNumber)

    const err = error instanceof Error ? error : new Error('分片上传失败')
    state.error = err

    // 调用错误回调
    if (config.onChunkError) {
      config.onChunkError(partNumber, err)
    }

    throw err
  }
}

核心逻辑:

javascript 复制代码
// 创建 CancelToken
const source = axios.CancelToken.source()
cancelTokens.set(partNumber, source)

给当前这个分片请求创建一个"取消令牌",并且按分片号存起来,后面暂停/取消时能找到它并中断这个分片的 axios 请求。Axios 官方文档里也是这样用的:通过 CancelToken.source() 创建 source,请求配置里传 cancelToken: source.token,然后调用 source.cancel() 来取消请求。这个 API 现在已被官方标记为 deprecated,但旧项目里仍然能看懂和维护。

这个 source,跟对应的分片号对应存在一个 map 中在调用对应的 axios 接口的时候,就把对应cancelToken 传入.

在单个分片上传中,包含了计算 md5,更新进度,重试

更新进度是,就是整个已经上传成功的分片数和分片的总数做比

重试就是setTimeout 这个 promise,根据配置的重传次数和重传时间

调用并发上传的逻辑:

javascript 复制代码
// 1. 初始化上传
state.uploadId = await initializeUpload(file)

// 2. 准备所有分片序号
const allPartNumbers = chunks.value.map((c) => c.partNumber)
allPartNumbers.forEach((num) => state.pendingChunks.add(num))

// 3. 并发上传所有分片
await uploadChunksConcurrently(allPartNumbers)

核心逻辑:

chunks.value 这个分片数组里,只取每个分片的 partNumber,生成一个新的数组。map() 的作用就是对数组每个元素执行一次回调,并返回一个新数组。

讲这个新数组的每一项加入到pendingChunks,这个 set 中

5.断点续传:暂停与恢复

5.1 pause:取消进行中的请求,保留已成功分片

断点续传主要就看暂停和恢复这两部分

javascript 复制代码
/**
 * 暂停上传
 */
function pause() {
  // 如果已经暂停,直接返回(幂等性)
  if (state.status === 'paused' || state.isPaused) {
    return
  }

  // 只有在 uploading 状态时才能暂停
  if (state.status !== 'uploading') {
    return
  }

  state.isPaused = true
  state.status = 'paused'

  // 取消所有进行中的上传
  cancelTokens.forEach((source) => {
    source.cancel('上传已暂停')
  })
  cancelTokens.clear()

  // 清理 pendingChunks,因为暂停时这些分片会被取消,恢复时需要重新上传
  // 只保留已上传成功的分片,其他都清理掉,这样恢复时才能正确识别需要继续上传的分片
  state.pendingChunks.clear()
}

核心逻辑:

cancelTokens 里面就是正在上传的分片序号和对应取消 source 的映射,因为在上传成功后cancelTokens.delete(partNumber)会对应的删除

所以把正在上传的请求取消,再把对应还没有上传也就是pendingChunks 中的序号清空

5.2 resume:筛出未上传分片,继续并发上传

javascript 复制代码
/**
 * 继续上传
 */
async function resume() {
  if (state.status !== 'paused') {
    return
  }

  if (!state.uploadId || !state.file) {
    throw new Error('无法继续:缺少必要信息')
  }

  state.isPaused = false
  state.status = 'uploading'
  isUploading.value = true

  try {
    // 找出未上传的分片
    const remainingPartNumbers = chunks.value
      .map((c) => c.partNumber)
      .filter((num) => !state.uploadedChunks.has(num) && !state.pendingChunks.has(num))

    if (remainingPartNumbers.length === 0) {
      // 所有分片都已上传,直接合并
      await mergeUploadedChunks()
      return
    }

    // 继续上传剩余分片
    await uploadChunksConcurrently(remainingPartNumbers)

    // 检查是否在恢复过程中被暂停
    if (state.isPaused) {
      // 如果在恢复过程中被暂停,直接返回,不继续合并
      return
    }

    // 检查是否有失败的分片
    if (state.failedChunks.size > 0) {
      throw new Error(`${state.failedChunks.size} 个分片上传失败,请重试`)
    }

    // 合并分片
    await mergeUploadedChunks()
  } catch (error) {
    // 如果是暂停导致的错误,不处理为错误状态
    if (state.isPaused) {
      return
    }

    state.status = 'error'
    state.error = error instanceof Error ? error : new Error('继续上传失败')
    isUploading.value = false

    if (config.onError) {
      config.onError(state.error)
    }

    throw state.error
  } finally {
    // 只有在没有被暂停的情况下才重置 isUploading
    if (!state.isPaused) {
      isUploading.value = false
    }
  }
}

核心逻辑:

用 map+filter 方法拿到还没有上传成功的序号数组,调用并发上传函数(这个序号数组),记得合并分片

小总结:为什么能断点续传?

断点指的是已经成功上传的分片集合,续传指的是恢复时只上传 uploadedChunks 之外的分片。uploadId + partNumber 让服务端能识别这次上传会话里的每个分片,前端通过 uploadedChunks 跳过已成功分片,最后所有分片上传完成后再触发 merge。

6. 总结

这个大文件上传方案本质上是一个 init -> upload part -> merge 的分片上传流程。

前端首先根据文件大小和配置的 chunkSize 计算分片数量,通过 file.slice(start, end) 按左闭右开的字节区间切出 Blob。由于后端接口接收 MultipartFile,所以项目里又用 new File([blob], filename, options) 把 Blob 包装成 File。

初始化阶段,前端把 fileName、fileSize、partSize、partCount 等元信息传给后端,后端返回 uploadId。后续每个分片上传时都会带上 uploadId + partNumber + md5 + file,这样后端就能识别当前分片属于哪一次上传任务、在原文件中的第几片。

并发上传阶段,前端维护一个 queue 和 workers。queue 保存待上传分片编号,workers 保存当前正在上传的 Promise 任务。通过 workers.length < maxConcurrent 控制最大并发数,通过 Promise.race(workers) 等待任意一个任务完成后释放并发名额,再补充新的分片任务。

断点续传的核心是保留 uploadedChunks。暂停时,前端通过 CancelToken 取消当前正在上传的请求,并清理 pendingChunks,但不会清空 uploadedChunks。恢复时,通过 chunks.map(...).filter(...) 筛出还没有上传成功的分片,只继续上传剩余分片。所有分片上传完成后,再调用 merge 接口完成合并。

这套方案里,uploadId 负责标识上传会话,partNumber 负责标识分片位置,md5 负责分片完整性校验,uploadedChunks / failedChunks / pendingChunks 负责维护前端任务状态。

相关推荐
胖纳特1 小时前
Nextcloud 文件预览困局与破局:集成 BaseMetas Fileview 实现全格式在线预览
前端·后端
一个心烑1 小时前
Layui结合springboot读取返回值,前端展示简单示例
前端·spring boot·layui
天天向上10241 小时前
openlayers 加载Shapefile文件
前端·javascript·html
亿元程序员1 小时前
手工拼豆有风险?手把手教你开发个电子版的
前端
hhhhhh_we1 小时前
再定义“皮肤人格”:从Baumann 16型分型到预颜美历的AI时序人格
前端·图像处理·人工智能·python·aigc
鹏程十八少1 小时前
10. 2026金三银四 Android 组件化 & ARouter 面试杀手锏:33 道高频题 + 答案 + 流程图 + 源码,资深工程师必刷
前端·后端·面试
yqcoder1 小时前
CSS 布局双雄:浮动 (Float) vs 绝对定位 (Absolute) 深度解析
前端·css
朝阳392 小时前
react【实战】首页 -- 白天/黑夜主题切换(含组件封装)
前端·react.js·前端框架
无心使然云中漫步2 小时前
Openlayers调用ArcGis地图服务之五 —— 要素识别(/identify)
前端·arcgis·vue·数据可视化