Skip to content

大文件分片上传技术详解

1. 引言

在Web应用开发中,大文件上传一直是一个具有挑战性的问题。传统的文件上传方式对于大文件来说存在诸多问题,比如上传时间长、网络不稳定导致上传失败需要重新上传、服务器内存占用高等。为了解决这些问题,分片上传技术应运而生。本文将详细介绍基于Vue 3实现的大文件分片上传解决方案,包括前端分片、并发控制、暂停/继续/取消等核心功能的实现原理和代码解析。

2. 大文件上传面临的挑战

在开始讲解技术实现之前,让我们先了解大文件上传面临的主要挑战:

  1. 上传时间长:大文件上传需要较长时间,用户体验较差
  2. 网络不稳定:上传过程中网络波动可能导致上传失败
  3. 服务器压力:大文件直接上传会占用服务器大量内存
  4. 上传控制:需要支持暂停、继续和取消上传的功能
  5. 上传进度:需要准确显示上传进度以提升用户体验
  6. 并发控制:需要合理控制并发请求数量以平衡速度和资源消耗

3. 核心技术原理

为解决上述问题,我们采用以下技术方案:

graph TD
    A[大文件] --> B[前端分片]
    B --> C[计算文件Hash]
    C --> D[并发上传分片]
    D --> E{所有分片上传完成?}
    E -->|是| F[合并分片]
    E -->|否| D
    D --> G[暂停/继续/取消]
    G --> D

3.1 分片上传基本流程

  1. 文件分片:将大文件切分为固定大小的小块
  2. 计算文件Hash:使用SparkMD5计算整个文件的唯一标识
  3. 上传分片:并发上传多个分片,提高上传效率
  4. 合并分片:所有分片上传完成后,服务器端合并分片
  5. 上传控制:支持暂停、继续和取消上传操作

4. 代码实现解析

4.1 文件分片与Hash计算

文件分片和Hash计算是比较耗时的操作,为避免阻塞主线程,我们使用Web Worker处理:

sequenceDiagram
    participant 主线程
    participant Web Worker
    主线程->>Web Worker: 发送文件和分片大小
    Web Worker->>Web Worker: 创建文件分片
    Web Worker->>Web Worker: 计算文件Hash
    Web Worker-->>主线程: 返回文件Hash和分片列表

Web Worker代码实现:

javascript
// 创建文件切片
function createFileChunk(file, chunkSize) {
  return new Promise((resolve, reject) => {
    let fileChunkList = []
    let cur = 0
    while (cur < file.size) {
      fileChunkList.push({ chunkFile: file.slice(cur, cur + chunkSize) })
      cur += chunkSize
    }
    resolve(fileChunkList)
  })
}

// 计算文件Hash
async function calculateChunksHash(fileChunkList) {
  const spark = new self.SparkMD5.ArrayBuffer();
  let percentage = 0
  let count = 0

  async function loadNext(index) {
    if (index >= fileChunkList.length) {
      return spark.end()
    }

    return new Promise((resolve, reject) => {
      const reader = new FileReader()
      reader.readAsArrayBuffer(fileChunkList[index].chunkFile)
      reader.onload = (e) => {
        count++
        spark.append(e.target.result)
        percentage += 100 / fileChunkList.length
        self.postMessage({ percentage })
        resolve(loadNext(index + 1))
      }
      reader.onerror = reject
    })
  }

  try {
    const fileHash = await loadNext(0)
    self.postMessage({ percentage: 100, fileHash, fileChunkList })
    self.close()
  } catch (err) {
    self.postMessage({ name: 'error', data: err })
    self.close()
  }
}

4.2 文件上传流程

以下是文件上传的完整流程:

flowchart TB
    A[选择文件] --> B[文件分片]
    B --> C[计算文件Hash]
    C --> D[创建上传任务]
    D --> E[并发上传分片]
    E --> F{所有分片上传完成?}
    F -->|是| G[请求合并分片]
    F -->|否| E
    G --> H[上传完成]

核心代码实现:

javascript
// 输入框change事件
const hanldeUploadFile = async (e) => {
  const fileEle = e.target
  if (!fileEle || !fileEle.files || fileEle.files.length === 0) {
    return false
  }
  const files = fileEle.files

  // 多文件
  Array.from(files).forEach(async (item, i) => {
    const file = item
    // 初始化上传任务
    let inTaskArrItem = reactive({
      id: new Date() + i,
      state: 0, // 0是什么都不做,1文件处理中,2是上传中,3是暂停,4是上传完成,5上传中断,6是上传失败
      fileHash: '',
      fileName: file.name,
      fileSize: file.size,
      allChunkList: [], // 所有请求的数据
      whileRequests: [], // 正在请求中的请求个数
      finishNumber: 0, //请求完成的个数
      errNumber: 0, // 报错的个数,超过3个就是直接上传中断
      percentage: 0, // 单个文件上传进度条
      cancel: null, // 用于取消切片上传接口
    })
    uploadFileList.value.push(inTaskArrItem)
    
    // 开始处理解析文件
    inTaskArrItem.state = 1

    if (file.size === 0) {
      inTaskArrItem.state = 6
      pauseUpload(inTaskArrItem, false)
    }

    // 计算文件hash
    const { fileHash, fileChunkList } = await useWorker(file)

    // 文件名处理
    let baseName = ''
    const lastIndex = file.name.lastIndexOf('.')
    if (lastIndex === -1) {
      baseName = file.name
    }
    baseName = file.name.slice(0, lastIndex)

    // 处理文件hash(文件名+hash确保唯一性)
    inTaskArrItem.fileHash = `${fileHash}${baseName}`
    inTaskArrItem.state = 2
    
    // 创建分片上传任务
    inTaskArrItem.allChunkList = fileChunkList.map((item, index) => {
      return {
        fileHash: `${fileHash}${baseName}`,
        fileSize: file.size,
        fileName: file.name,
        index: index,
        chunkFile: item.chunkFile,
        chunkHash: `${fileHash}-${index}`,
        chunkSize: chunkSize,
        chunkNumber: fileChunkList.length,
        finish: false,
      }
    })

    // 开始上传分片
    uploadSignleFile(inTaskArrItem)
  })
}

4.3 分片上传控制

为了提高上传效率同时又不过度占用资源,我们实现了动态并发控制:

graph TD
    A[开始上传] --> B[计算当前并发数]
    B --> C[取出部分分片进行上传]
    C --> D[上传成功?]
    D -->|是| E[更新进度]
    D -->|否,重试<3次| F[重试上传]
    D -->|否,重试>=3次| G[中断上传]
    E --> H{所有分片完成?}
    H -->|是| I[请求合并分片]
    H -->|否| J[继续上传]
    F --> C
    J --> C

核心实现代码:

javascript
// 单个文件上传
const uploadSignleFile = (taskArrItem) => {
  // 不需要上传或正在上传中,直接返回
  if (
    taskArrItem.allChunkList.length === 0 ||
    taskArrItem.whileRequests.length > 0
  ) {
    return false
  }
  
  // 动态计算并发数
  const isTaskArrIng = uploadFileList.value.filter(
    (itemB) => itemB.state === 1 || itemB.state === 2
  )
  maxRequest.value = Math.ceil(6 / isTaskArrIng.length)

  // 取出部分分片进行上传
  let whileRequest = taskArrItem.allChunkList.slice(-maxRequest.value)
  taskArrItem.whileRequests.push(...whileRequest)
  
  if (taskArrItem.allChunkList.length > maxRequest.value) {
    taskArrItem.allChunkList.splice(-maxRequest.value)
  } else {
    taskArrItem.allChunkList = []
  }

  // 单个分片请求
  const uploadChunk = async (needObj) => {
    const fd = new FormData()
    const {
      fileHash, fileSize, fileName, index,
      chunkFile, chunkHash, chunkSize, chunkNumber,
    } = needObj

    fd.append('fileHash', fileHash)
    fd.append('fileSize', String(fileSize))
    fd.append('fileName', fileName)
    fd.append('index', String(index))
    fd.append('chunkFile', chunkFile)
    fd.append('chunkHash', chunkHash)
    fd.append('chunkSize', String(chunkSize))
    fd.append('chunkNumber', String(chunkNumber))
    
    const res = await uploadFile(fd, (onCancelFunc) => {
      needObj.cancel = onCancelFunc
    }).catch(() => {})
    
    // 暂停/中断状态不继续处理
    if (taskArrItem.state === 3 || taskArrItem.state === 5) {
      return false
    }

    // 处理上传失败
    if (!res || res.code === -1) {
      taskArrItem.errNumber++
      if (taskArrItem.errNumber > 3) {
        pauseUpload(taskArrItem, false) // 上传中断
      } else {
        uploadChunk(needObj) // 继续重试
      }
    } else if (res.code === 0) {
      // 上传成功
      taskArrItem.errNumber > 0 ? taskArrItem.errNumber-- : 0
      taskArrItem.finishNumber++
      needObj.finish = true
      signleFileProgress(needObj, taskArrItem) // 更新进度
      
      // 从请求中数组移除该分片
      taskArrItem.whileRequests = taskArrItem.whileRequests.filter(
        (item) => item.chunkFile !== needObj.chunkFile
      )

      // 检查是否所有分片都已上传完成
      if (taskArrItem.finishNumber === chunkNumber) {
        handleMerge(taskArrItem)
      } else {
        uploadSignleFile(taskArrItem)
      }
    }
  }

  // 开始上传
  for (const item of whileRequest) {
    uploadChunk(item)
  }
}

4.4 暂停、继续与取消功能

为提升用户体验,我们实现了暂停、继续和取消功能:

sequenceDiagram
    participant 用户
    participant 前端
    participant 服务器
    
    用户->>前端: 点击暂停按钮
    前端->>前端: 修改文件状态为暂停
    前端->>服务器: 取消正在进行的请求
    
    用户->>前端: 点击继续按钮
    前端->>前端: 修改文件状态为上传中
    前端->>服务器: 继续上传未完成分片
    
    用户->>前端: 点击取消按钮
    前端->>前端: 取消上传并从列表移除
    前端->>服务器: 取消所有未完成请求

核心实现代码:

javascript
// 暂停上传
const pauseUpload = (taskArrItem, elsePause = true) => {
  if (![4, 6].includes(taskArrItem.state)) {
    if (elsePause) {
      taskArrItem.state = 3 // 暂停
    } else {
      taskArrItem.state = 5 // 中断
    }
  }
  taskArrItem.errNumber = 0

  // 取消正在请求的所有接口
  if (taskArrItem.whileRequests.length > 0) {
    for (const itemB of taskArrItem.whileRequests) {
      itemB.cancel ? itemB.cancel() : ''
    }
  }
}

// 继续上传
const resumeUpload = (taskArrItem) => {
  taskArrItem.state = 2 // 上传中
  taskArrItem.allChunkList.push(...taskArrItem.whileRequests)
  taskArrItem.whileRequests = []
  uploadSignleFile(taskArrItem)
}

// 取消单个
const cancelSingle = async (taskArrItem) => {
  pauseUpload(taskArrItem)
  uploadFileList.value = uploadFileList.value.filter(
    (itemB) => itemB.fileHash !== taskArrItem.fileHash
  )
}

// 全部取消
const cancelAll = () => {
  for (const item of uploadFileList.value) {
    pauseUpload(item)
  }
  uploadFileList.value = []
}

5. 关键技术难点与解决方案

5.1 如何计算文件分片

文件分片的计算是大文件上传的核心,需要考虑以下因素:

  1. 分片大小的选择

    • 分片太小:请求次数增多,服务器压力大
    • 分片太大:单个请求时间长,失败重试代价高
    • 本示例选择1MB作为平衡点:const chunkSize = 1 * 1024 * 1024
  2. 分片计算方式

    javascript
    function createFileChunk(file, chunkSize) {
      let fileChunkList = []
      let cur = 0
      while (cur < file.size) {
        fileChunkList.push({ chunkFile: file.slice(cur, cur + chunkSize) })
        cur += chunkSize
      }
      return fileChunkList
    }

5.2 如何高效计算文件Hash

文件Hash计算是分片上传的基础,但计算大文件Hash会阻塞主线程:

  1. 使用Web Worker:在单独线程中计算,不影响UI交互
  2. 增量计算:逐个处理分片并累加Hash,而不是一次性读取整个文件
  3. 进度反馈:通过postMessage向主线程报告计算进度

5.3 并发控制策略

上传并发数控制是优化性能的关键:

  1. 动态并发控制:根据当前上传文件数量动态调整每个文件的并发数

    javascript
    // 动态计算并发数
    const isTaskArrIng = uploadFileList.value.filter(
      (itemB) => itemB.state === 1 || itemB.state === 2
    )
    maxRequest.value = Math.ceil(6 / isTaskArrIng.length)
  2. 并发与性能平衡

    • 并发数太小:上传速度慢
    • 并发数太大:可能导致网络拥塞或服务器压力过大
    • 总并发控制在浏览器限制范围内(通常为6个同域名请求)

6. 常见问题与解答

Q1: 为什么需要使用Web Worker计算文件Hash?

A: 计算大文件的Hash是CPU密集型操作,在主线程中执行会导致UI冻结,影响用户体验。Web Worker可以在后台线程中执行这些操作,保持UI响应性。

Q2: 如何处理网络波动导致的上传失败?

A: 我们实现了重试机制和错误计数:

javascript
if (!res || res.code === -1) {
  taskArrItem.errNumber++
  if (taskArrItem.errNumber > 3) {
    pauseUpload(taskArrItem, false) // 超过三次失败则中断
  } else {
    uploadChunk(needObj) // 否则继续重试
  }
}

Q3: 分片大小如何确定?过大或过小会有什么影响?

A: 分片大小是权衡上传效率和稳定性的关键因素:

  • 分片过小:会产生大量HTTP请求,增加服务器负担和请求开销
  • 分片过大:单次请求时间长,失败时重试成本高
  • 本项目选择1MB作为分片大小,这是经验值,在大多数网络环境下都能取得较好的平衡

Q4: 文件上传状态管理是如何实现的?

A: 我们使用状态码管理文件上传的不同阶段:

javascript
// 0是什么都不做
// 1是文件处理中
// 2是上传中
// 3是暂停
// 4是上传完成
// 5是上传中断
// 6是上传失败

这种状态管理使得UI可以根据不同状态显示不同的界面元素,提升用户体验。

Q5: 如何处理最后一个分片可能小于设定大小的情况?

A: 我们的分片算法自动处理这种情况:

javascript
while (cur < file.size) {
  fileChunkList.push({ chunkFile: file.slice(cur, cur + chunkSize) })
  cur += chunkSize
}

当最后一个分片不足chunkSize大小时,file.slice会自动处理边界,返回实际大小的分片。

Q6: 如何实现取消正在进行的上传请求?

A: 我们使用AbortController API来取消正在进行的请求:

javascript
// 上传文件方法
export function uploadFile(data, onCancel) {
  const controller = new AbortController()
  const signal = controller.signal
  
  const request = service({
    url: '/upload',
    method: 'post',
    data,
    signal, // 将signal传递给请求
  })
  
  // 提供取消方法给调用者
  if (typeof onCancel === 'function') {
    onCancel(() => controller.abort())
  }
  
  return request
}

// 调用取消功能
pauseUpload() {
  if (taskArrItem.whileRequests.length > 0) {
    for (const itemB of taskArrItem.whileRequests) {
      itemB.cancel ? itemB.cancel() : ''
    }
  }
}

Q7: 如何计算和显示上传进度?

A: 我们通过记录已完成分片数与总分片数的比例来计算进度:

javascript
// 更新单个文件进度条
const signleFileProgress = (needObj, taskArrItem) => {
  taskArrItem.percentage = Number(
    ((taskArrItem.finishNumber / needObj.chunkNumber) * 100).toFixed(2)
  )
}

这种方式比监听XHR的upload.onprogress更稳定,因为它是基于成功上传的分片数量计算的,不会受到网络波动的影响。

7. 总结

本文详细介绍了基于Vue 3实现的大文件分片上传解决方案,包括前端分片、并发控制、暂停/继续/取消等核心功能的实现原理和代码解析。这种方案有效解决了大文件上传的诸多问题,提高了用户体验和系统稳定性。

未来可以进一步优化的方向包括:

  1. 断点续传功能:支持页面刷新或关闭后继续上传,需要持久化已上传分片信息
  2. 秒传功能:对于服务器已存在的文件实现秒传,避免重复上传
  3. 文件Hash计算优化:可以采用抽样Hash算法减少计算量
  4. 上传速度自适应:根据网络状况动态调整分片大小和并发数
  5. 前端压缩:上传前对文件进行压缩处理
  6. 加密传输:增加传输加密保证数据安全

大文件上传是Web应用中常见且具有挑战性的问题,通过合理的技术方案可以显著提升用户体验。希望本文对你实现高效、稳定的大文件上传功能有所帮助。