前端大文件上传(切片并发/断点续传/秒传/WebWorker 计算Hash) 含完整代码

大文件上传时前端开发常见需求之一,尤其在处理较大的Excel,高清图片,视频等文件时,优化上传链路不仅能提升用户体验,也能显著降低服务端压力,本文围绕一套工业界常用的方案展开,包含:

  • 文件切片与并发上传(限流)
  • 断点续传
  • 秒传(基于文件hash)
  • WebWorker计算hash(避免阻塞主线程)
  • 服务端分片接收与流式合并
  • 安全性与体验优化建议

1. 背景问题

直接整文件上传通常会遇到:

  • 网络不稳定: 中途断开导致失败,必须重传
  • 耗时过长: 长时间等待,用户容易放弃
  • 服务端压力大: 单连接长时间占用,吞吐下降
  • 容错差: 失败无法复用已上传部分

因此, 大文件上传通常采用切片上传 + 状态查询+ 合并的方式实现可靠传输

2. 方案总览(推荐落地)

整体流程建议这样设计:

  1. 前端选择文件==> 生成fileId (hash 或 hash+size)

  2. 前端向服务器查询上传状态: /upload/status?fileId=...

  3. 前端切片后跳过已上传切片,并发上传未完成切片

  4. 全部分片上传完成后调用合并接口/upload/merge

  5. 合并完成返回最终文件URL

如果查询状态返回"已存在完整文件", 则直接返回结果-> 秒传

3. 文件切片与并发上传

3.1 切片原理

浏览器的File继承自Blob, 可通过slice(start,end)拿到某段二进制片段,切片上传本质是吧大文件拆分成多个小请求

分片大小建议: 2MB-10MB

  • 大小: 请求数量多,开销大
  • 太大: 失败重传成本高,易超时 工程上常用: 5MB

3.2 并发上传为什么要限流

并发越大不一定越快:

  • 浏览器单域并发连接有限制
  • 服务器瞬时QPS(每秒查询率) / IO 压力上升
  • 反而导致失败率增高

建议并发数: 3 - 6 (移动端更低)

4.断点续传与秒传

1.断点续传

核心是服务器记录 / 判断哪些切片已经上传成功,前端重试或刷新页面后重新查询即可继续上传缺失分片

2.秒传

如果服务端发现该fileId 对应的完整文件已存在,直接返回文件信息即可,不必重新上传

前提是 fileId 必须基于内容(hash),而不是仅文件名

5. WebWorker: 计算文件Hash (避免卡UI) Web Worker 使用教程

计算大文件hash 属于CPU密集型操作,在主线程执行会明显卡顿,因此建议用WebWorker,"多线程"技术,可以让我们在页面主运行的js线程中,加载运行另外单独的一个或者多个js线程

6.代码实现

1. 前端部分(基础版)

前端拿到超大文件,需要把文件进行起个分成固定大小的切片,再通过http请求把所有切片传给后端,后端拿到切片后,处理每一个切片并返回是否处理成功给前端,等把所有的切片都上传完成后,后端再把所有的切片合并成一个完整的文件,代表大文件上传完成

怎么知道每一个文件的唯一标识? 我们可以用speak-md5去计算文件hash值,这样如果就算文件同名那它的唯一标识也会不同,又或者文件内容更改后得到的hash值也会不同

2.上传进度条

通过(上传成功的切片数 / 该文件的总切片数) * 100 去计算单个文件的上传进度条, 也可以通过

最新进度 = 旧进度 + 100 / 总切片数 去计算

3. vue2 前端代码实现

切片核心是利用Blob.prototype.slice 方法,和数组的 slice 方法相似,文件的 slice 方法可以返回原文件的某个切片 ,同时利用Web Worker 结合spark-md5去计算文件hash值。

因为要用到 spark-md5 ,但是Worker内部需要用 importScripts() 去加载其他脚本,所以需要自行下载spark-md5源码单独作为js文件方便在Worker内部引入。(在github源代码中也有)

在Worker内部引入脚本方式

其实 worker 在内部不仅仅可以使用importScripts()引入脚本,也可以使用ES6的引入方式

将 Worker 脚本作为静态资源:

在public中分别新建 hash-worker.js 跟 spark-md5.min.js 文件,至于为什么要放在在public中,worker需要与主线程的脚本文件同源的网络资源,它是相对于你vue项目中的index.html位置去引入的。

在.vue文件中

const worker = new Worker('./hash-worker.js')

hash-worker.js中,因为spark-md5跟worker是同目录下,就不需要使用相对路径引入了。

importScripts('spark-md5.min.js')

使用ES6方式引入

在 src 下新建文件夹 worker 再分别新建 hash-worker.jsspark-md5.min.js 文件

其实我们查看 webpack5 (vue-cli5内置webpack5)官方文档就可以看到推荐使用引入方式代替了 worker-loader

但是在使用importScripts()引入脚本时就不能像第一种用法那样了,不然就会

// .vue文件 const worker = new Worker( new URL('@/worker/hash-worker.js', import.meta.url) )


// hash-worker.js

// 第一种写法

importScripts('spark-md5.min.js')

// 第二种写法

importScripts('./spark-md5.min.js')

// 第三种:把spark-md5.min.js放在 public 文件夹下使用以上两种写法

// 第四种:把spark-md5.min.js放在 public 文件夹下

importScripts('/public/spark-md5.min.js')

以上四种写法都会导致发生同一种错误如下,可以看到在worker同源策略下时浏览器读不到spark-md5.min.js文件,但是如果这两个文件都放在public下,再使用import.meta.url是正常读取到的。但这不是我们想要的

// .vue文件

const worker = new Worker(

new URL('@/worker/hash-worker.js', import.meta.url),

{

type: 'module',

}

)

// hash-worker.js

import SparkMD5 from './spark-md5.min.js'

所以后续的代码都会使用第二种引入方法,如遇到问题可直接使用第一种。

代码实战
javascript 复制代码
<template>
  <div>
    <div class="upload-drag" @click="handleClick">
      <input
        type="file"
        ref="fileInput"
        @change="hanldeUploadFile"
        accept=""
        multiple="false"
        style="display: none" />
      <div>
        <i class="el-icon-upload" style="font-size: 50px; color: #c0c4cc"></i>
      </div>
    </div>
    <div
      style="display: flex; margin-top: 10px"
      v-for="item in uploadFileList"
      :key="item.fileHash">
      <div style="width: 300px">
        <el-progress
          type="line"
          :text-inside="true"
          :stroke-width="26"
          :percentage="item.percentage"></el-progress>
        <div>
          <div style="margin-left: 4px">
            <div
              v-if="item.state === 0"
              style="height: 24px; width: 100%"></div>
            <p v-else-if="item.state === 1">正在解析中...</p>
            <p v-else-if="item.state === 2">正在上传中...</p>
            <p v-else-if="item.state === 3">上传完成</p>
            <p v-else-if="item.state === 4">上传失败</p>
          </div>
        </div>
      </div>
      <!-- 在处理文件中时不能取消 -->
      <el-button
        v-if="![0, 1].includes(item.state)"
        style="margin-left: 10px; height: 40px"
        type="danger"
        @click="cancelUpload(item)"
        >取消</el-button
      >
    </div>
  </div>
</template>

<style>

.upload-drag {

width: 150px;

height: 80px;

display: flex;

justify-content: center;

align-items: center;

background: #fff;

border: 1px dashed #d8d8d8;

border-radius: 4px;

cursor: pointer;

overflow: hidden;

transition: border-color 0.2s ease;

}

.upload-drag:hover {

border: 1px dashed #4595eb;

}

</style>

首先我们要定义几个变量,考虑到用户需要多选上传,就直接定义上传列表为数组,以及请求最大并发数

javascript 复制代码
data() {
    return {
      // 1kb = 1024b   1kb * 1024 = 1M
      // 切片大小 1 * 1024 * 1024 刚好1M
      chunkSize: 1 * 1024 * 1024,
      // 上传文件列表:
      uploadFileList: [],
      // 请求最大并发数
      maxRequest:6
    }
  },

在点击触发文件上传事件后

javascript 复制代码
  methods: {
    handleClick() {
      // 点击触发选取文件
      this.$refs.fileInput.dispatchEvent(new MouseEvent('click'))
    },
    async hanldeUploadFile() {
      const fileEle = this.$refs.fileInput

      // 如果没有文件内容
      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 = {
          id: new Date() + i, // 因为forEach是同步,所以需要用指定id作为唯一标识
          state: 0, // 0不做任何处理,1是计算hash中,2是正在上传中,3是上传完成,4是上传失败,5是上传取消
          fileHash: '',
          fileName: file.name,
          fileSize: file.size,
          allChunkList: [], // 所有请求的数据
          whileRequests: [], // 正在请求中的请求个数,目前是要永远都保存请求个数为6
          finishNumber: 0, //请求完成的个数
          errNumber: 0, // 报错的个数,默认是0个,超多3个就是直接上传中断
          percentage: 0, // 单个文件上传进度条
        }
        this.uploadFileList.push(inTaskArrItem)

        // 开始处理解析文件
        inTaskArrItem.state = 1

        if (file.size === 0) {
          // 文件大小为0直接取消该文件上传
          this.uploadFileList.splice(i, 1)
          // 继续执行for循环
        }
        // 计算文件hash
        const { fileHash, fileChunkList } = await this.useWorker(file)

        console.log(fileHash, '文件hash计算完成')

        // 解析完成开始上传文件
        let baseName = ''
        // 查找'.'在fileName中最后出现的位置
        const lastIndex = file.name.lastIndexOf('.')
        // 如果'.'不存在,则返回整个文件名
        if (lastIndex === -1) {
          baseName = file.name
        }
        // 否则,返回从fileName开始到'.'前一个字符的子串作为文件名(不包含'.')
        baseName = file.name.slice(0, lastIndex)

        // 这里要注意!可能同一个文件,是复制出来的,出现文件名不同但是内容相同,导致获取到的hash值也是相同的
        // 所以文件hash要特殊处理
        inTaskArrItem.fileHash = `${fileHash}${baseName}`
        inTaskArrItem.state = 2
        // 初始化需要上传所有切片列表
        inTaskArrItem.allChunkList = fileChunkList.map((item, index) => {
          return {
            // 总文件hash
            fileHash: `${fileHash}${baseName}`,
            // 总文件size
            fileSize: file.size,
            // 总文件name
            fileName: file.name,
            index: index,
            // 切片文件本身
            chunkFile: item.chunkFile,
            // 单个切片hash,以 - 连接
            chunkHash: `${fileHash}-${index}`,
            // 切片文件大小
            chunkSize: this.chunkSize,
            // 切片个数
            chunkNumber: fileChunkList.length,
            // 切片是否已经完成
            finish: false,
          }
        })
        //  逐步对单个文件进行切片上传
        this.uploadSignleFile(inTaskArrItem)
      })
      console.log(this.uploadFileList, 'uploadFileList')
    },

    // 生成文件 hash(web-worker)
    useWorker(file) {
      return new Promise((resolve) => {
        const worker = new Worker(
          new URL('@/worker/hash-worker.js', import.meta.url),
          {
            type: 'module',
          }
        )
        worker.postMessage({ file, chunkSize: this.chunkSize })
        worker.onmessage = (e) => {
          const { fileHash, fileChunkList } = e.data
          if (fileHash) {
            resolve({
              fileHash,
              fileChunkList,
            })
          }
        }
      })
    },
  },

注意: 计算文件hash是需要时间的,所以我们的文件上传状态也需要改变,从而对每个文件对象定义了state属性,关于allChunkList是用于记录所有需要上传的切片数据(指还未上传的),whileRequests是正在http请求中的个数,finishNumber是记录后端处理完的切片个数,也是方便计算文件上传进度的;errNumber是记录在所有的上传切片列表中,切片上传失败的个数(后面会详细讲)

forEach循环,一开始的想法是用for循环 + await,但是发现会出现一个现象:就是只能等一个文件解析完才能进行解析下一个,但是我想要的是,在多文件上传中,直接全部一起解析,哪个文件解析完就开始调上传接口即可 ,所以这里改用成了forEach

接下来的重点是对Worker的代码详细说明:

javascript 复制代码
import SparkMD5 from './spark-md5.min.js'
// 创建文件切片
function createFileChunk(file, chunkSize) {
  return new Promise((resolve, reject) => {
    let fileChunkList = []
    let cur = 0
    while (cur < file.size) {
      // Blob 接口的 slice() 方法创建并返回一个新的 Blob 对象,该对象包含调用它的 blob 的子集中的数据。
      fileChunkList.push({ chunkFile: file.slice(cur, cur + chunkSize) })
      cur += chunkSize
    }
    // 返回全部文件切片
    resolve(fileChunkList)
  })
}

// 加载并计算文件切片的MD5
async function calculateChunksHash(fileChunkList) {
  // 初始化脚本
  const spark = new SparkMD5.ArrayBuffer()

  // 计算切片进度(拓展功能,可自行添加)
  let percentage = 0
  // 计算切片次数
  let count = 0

  // 递归函数,用于处理文件切片
  async function loadNext(index) {
    if (index >= fileChunkList.length) {
      // 所有切片都已处理完毕
      return spark.end() // 返回最终的MD5值
    }

    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 = (err) => {
        reject(err) // 如果读取错误,则拒绝Promise
      }
    })
  }

  try {
    // 开始计算切片
    const fileHash = await loadNext(0) // 等待所有切片处理完毕
    self.postMessage({ percentage: 100, fileHash, fileChunkList }) // 发送最终结果到主线程
    self.close() // 关闭Worker
  } catch (err) {
    self.postMessage({ name: 'error', data: err }) // 发送错误到主线程
    self.close() // 关闭Worker
  }
}

// 监听消息
self.addEventListener(
  'message',
  async (e) => {
    try {
      const { file, chunkSize } = e.data
      const fileChunkList = await createFileChunk(file, chunkSize) // 创建文件切片
      await calculateChunksHash(fileChunkList) // 等待计算完成
    } catch (err) {
      // 这里实际上不会捕获到calculateChunksHash中的错误,因为错误已经在Worker内部处理了
      // 但如果未来有其他的异步操作,这里可以捕获到它们
      console.error('worker监听发生错误:', err)
    }
  },
  false
)

// 主线程可以监听 Worker 是否发生错误。如果发生错误,Worker 会触发主线程的error事件。
self.addEventListener('error', function (event) {
  console.log('Worker触发主线程的error事件:', event)
  self.close() // 关闭Worker
})

在worker中我们可以拿到file信息以及需要切片的大小chunkSize,这时候就需要对每个文件进行切片,我们利用Blob.slice进行切割相同等分,并返回全部切片fileChunkList

在拿到fileChunkList我们需要用到spark-md5以增量方式对文件进行哈希处理(在spark-md5文档有说明),最终可以拿到整个文件的hash值,并发送消息回给调用worker的主线程。但是在处理文件hash值赋值的时候要注意,不同文件名但内容相同的文件哈希值是一样的,因为spark-md5是根据文件内容去获取文件哈希,从而导致可能出现同一个哈希值,所以我们在上面处理文件的时候就要特殊处理!

inTaskArrItem.fileHash = `{fileHash}{baseName}`

下面两个压缩包处理后的hash值是一样的

在调用接口上传文件之前我们先对axios进行简单的二次封装,以及封装接口信息

javascript 复制代码
// src/utils/request.js
import axios from 'axios'

// 设置请求头
const myBaseURL = 'http://localhost:3000'

// 创建axios实例
const service = axios.create({
  baseURL: myBaseURL, // 请求头
  timeout: 6 * 1000, // 请求超时时间
})

// 响应拦截
service.interceptors.response.use(
  (response) => {
    return response.data
  },
  (error) => {
    return Promise.reject(error)
  }
)

export default service
javascript 复制代码
// src/api/index.js
import service from '@/utils/request'

/**
 * [uploadFile] - 上传切片参数
 * @param fileHash 文件hash,String
 * @param fileSize 文件大小,Number
 * @param fileName 文件名称,String
 * @param index 多文件上传中的所在index,number
 * @param chunkFile 切片文件本身,File || Blob || void
 * @param chunkHash 切片文件hash,String
 * @param chunkSize 分片大小,Number
 * @param chunkNumber 切片总数量,Number
 * @param finish 是否上传完成,可选参数,Boolean
 * @returns 返回值描述(如果有的话)
 */

// 上传单个切片
export function uploadFile(data) {
  // 封装 axios 请求或 HTTP 客户端请求
  return service({
    url: '/upload',
    method: 'post',
    data,
  })
}

/**
 * [mergeChunk] - 合并切片
 * @param chunkSize 分片大小,Number
 * @param fileName 文件名称,String
 * @param fileSize 文件大小,Number
 */

// 合并所有切片
export function mergeChunk(data) {
  return service({
    url: '/merge',
    method: 'post',
    data,
  })
}

览器同域名同一时间请求的最大并发数

在处理完上面的代码后,我们就可以开始上传了,但是上传切片之前要普及一个知识点,在Chrome浏览器中同域名同一时间请求的最大并发数为6个,一开始是打算使用for循环或者Promise.all()并发请求几百个接口,感觉这样效率高一些,后来发现文件小没事,大了请求直接不响应了,例如15个切片15个请求浏览器会感觉有点卡顿, 几百上千个切片请求瞬间浏览器卡死或者接口超时自动取消!

所以我们刚才定义的maxRequest就派上用场了,同时要注意的是要使用for循环搭配await去使用(forEach是没有异步效果以及中断的)

javascript 复制代码
    // 单个文件上传
    uploadSignleFile(taskArrItem) {
      // 如果没有需要上传的切片 / 正在上传的切片还没传完,就不做处理
      if (  
        taskArrItem.allChunkList.length === 0 ||
        taskArrItem.whileRequests.length > 0
      ) {
        return false
      }
      // 找到文件处于处理中/上传中的 文件列表(是文件而不是切片)
      const isTaskArrIng = this.uploadFileList.filter(
        (itemB) => itemB.state === 1 || itemB.state === 2
      )

      // 实时动态获取并发请求数,每次调请求前都获取一次最大并发数
      // 浏览器同域名同一时间请求的最大并发数限制为6
      // 例如如果有3个文件同时上传/处理中,则每个文件切片接口最多调 6 / 3 == 2个相同的接口
      this.maxRequest = Math.ceil(6 / isTaskArrIng.length)

      // 从数组的末尾开始提取 maxRequest 个元素。
      let whileRequest = taskArrItem.allChunkList.slice(-this.maxRequest)

      // 设置正在请求中的个数
      taskArrItem.whileRequests.push(...whileRequest)
      //  如果总请求数大于并发数
      if (taskArrItem.allChunkList.length > this.maxRequest) {
        // 则去掉即将要请求的列表
        taskArrItem.allChunkList.splice(-this.maxRequest)
      } 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).catch(() => {})
        // 先判断是不是取消状态,就什么都不要再做了,及时停止
        if (taskArrItem.state === 5) {
          return false
        }

        // 请求异常,或者请求成功服务端返回报错都按单片上传失败逻辑处理,.then.catch的.catch是只能捕捉请求异常的
        if (!res || res.code === -1) {
          // 切片上传失败+1
          taskArrItem.errNumber++
          // 超过3次之后直接上传中断
          if (taskArrItem.errNumber > 3) {
            console.log('切片上传失败超过三次了')
            // 标识文件上传失败
            taskArrItem.state = 4
          } else {
            console.log('切片上传失败还没超过3次')
            uploadChunk(needObj) // 失败了一片,继续当前分片请求
          }
        } else if (res.code === 0) {
          // 单个文件上传失败次数大于0则要减少一个
          taskArrItem.errNumber > 0 ? taskArrItem.errNumber-- : 0
          // 单个文件切片上传成功数+1
          taskArrItem.finishNumber++
          // 单个切片上传完成
          needObj.finish = true
          
          // 单个文件上传成功后就要更新文件进度条
          this.signleFileProgress(needObj, taskArrItem) // 更新进度条
          // 上传成功了就删掉请求中数组中的那一片请求
          taskArrItem.whileRequests = taskArrItem.whileRequests.filter(
            (item) => item.chunkFile !== needObj.chunkFile
          )

          // 如果单个文件最终成功数等于切片个数
          if (taskArrItem.finishNumber === chunkNumber) {
            // 全部上传完切片后就开始合并切片
            await this.handleMerge(taskArrItem)
          } else {
            // 如果还没完全上传完,则继续上传
            this.uploadSignleFile(taskArrItem)
          }
        }
      }
      // 开始上传单个切片
      for (const item of whileRequest) {
        uploadChunk(item)
      }
    },

代码解析:在上传前要判断如果还未上传列表为空 || 部分正在上传的切片请求还在请求,则不做处理(或等待正在上传的切片上传完)。

同时要动态设置最大并发数,例如如果有3个文件同时上传/处理中,则每个文件切片接口最多调 6 / 3 == 2个相同的接口。

设置正在上传的请求列表,并把在未上传切片列表中的相对应数据删除,如果把没请求的全部放进请求列表了,则要把未上传切片列表置空。

开始循环调上传接口,但是如果中途取消了某个文件上传,取消状态则不要做任何处理,及时停止

假设在某个切片在上传过程中,突然被网管拔了网线又或者请求超时等,可能会导致某个上传失败/丢失。那怎么办?我们可以选择继续上传该切片,如果连续三次失败则判定该文件上传失败。

所以我们就得以后端是否处理成功并返回 为标准,防止出现切片丢失等问题,并把该切片标识为上传完成,文件切片上传成功数+1,并动态设置文件上传进度条

那我们可以根据 文件切片上传成功数 === 文件总切片数 判断文件是否已经把所有切片上传完成,然后调接口通知后端**,让后端去合并所有的切片并通知前端是否合并完成** ,这就代表了该文件完全上传成功!

javascript 复制代码
    // 调取合并接口处理所有切片
    async handleMerge(taskArrItem) {
      const { fileName, fileHash } = taskArrItem
      const res = await mergeChunk({
        chunkSize: this.chunkSize,
        fileName,
        fileHash,
      }).catch(() => {})
      //  如果合并成功则标识该文件已经上传完成
      if (res && res.code === 0) {
        // 设置文件上传状态
        this.finishTask(taskArrItem)
        console.log('文件合并成功!')
      } else {
        // 否则上传文件失败
        taskArrItem.state = 4
        console.log('文件合并失败!')
      }
      //  最后赋值文件切片上传完成个数为0
      taskArrItem.finishNumber = 0
    },

    // 更新单个文件进度条
    signleFileProgress(needObj, taskArrItem) {
      taskArrItem.percentage = Number(
        ((taskArrItem.finishNumber / needObj.chunkNumber) * 100).toFixed(2)
      )
    },
    // 设置单个文件上传已完成
    finishTask(item) {
      item.state = 3
      item.percentage = 100
    },

    cancelUpload(item) {
      item.state = 5
      // 取消上传后删除该文件
      this.uploadFileList = this.uploadFileList.filter(
        (itemB) => itemB.fileHash !== item.fileHash
      )
    },

以上代码就可以实现前端的大文件切片上传功能了!

4. 前端部分(Vue3 + vite完整版)

切片上传 + 断点上传 + 秒传 + 暂停上传 + 恢复上传等功能

秒传

关于秒传原理其实就是:在上传文件之前先询问这个文件服务器端是否已存在,若已存在,则不需要上传了,直接让前端显示"已上传成功!",这就是秒传(是不是很简单!)

所以我们只要在调上传接口之前调用一次检查文件是否存在接口即可!(代码会在断点上传一起放出)

断点上传

所谓断点上传 (又名恢复上传)就是某个文件已经把部分切片上传到后端且保存,但是在上传过程中出现了一些不可预知问题:例如网络中断、超时、不小心刷新了页面等等,会导致上传中断,但是我们又不想再一次把所有切片又上传一次,只要把未上传的切片上传给后端即可

那我们在上传文件之前就要调接口询问后端:我到底上传了哪些切片给你?赶紧给我返回过来,我要过滤掉这些已经传过的,留下没传过的。

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
    // 单个上传文件
    // 这里要注意vue2跟vue3不同,
    // 如果在循环 + await中,如果把一个普通对象push进一个响应式数组
    // 直接修改原对象可能不会触发vue的DOM视图更新(但最终值会改变)
    // 所以这里用了reactive做响应式代理
    let inTaskArrItem = reactive({
      id: new Date() + i, // 因为forEach是同步,所以需要用指定id作为唯一标识
      state: 0, // 0是什么都不做,1文件处理中,2是上传中,3是暂停,4是上传完成,5上传中断,6是上传失败
      fileHash: '',
      fileName: file.name,
      fileSize: file.size,
      allChunkList: [], // 所有请求的数据
      whileRequests: [], // 正在请求中的请求个数,目前是要永远都保存请求个数为6
      finishNumber: 0, //请求完成的个数
      errNumber: 0, // 报错的个数,默认是0个,超多3个就是直接上传中断
      percentage: 0, // 单个文件上传进度条
      cancel: null, // 用于取消切片上传接口
    })
    uploadFileList.value.push(inTaskArrItem)
    // 如果不使用reactive,就得使用以下两种方式
    // inTaskArrItem = uploadFileList.value[i]
    // uploadFileList.value[i].state = 1
    
    // 开始处理解析文件
    inTaskArrItem.state = 1

    if (file.size === 0) {
      // 文件大小为0直接上传失败
      inTaskArrItem.state = 6
      // 上传中断
      pauseUpload(inTaskArrItem, false)
    }
    console.log('文件开始解析')

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

    console.log(fileHash, '文件hash计算完成')

    // 解析完成开始上传文件
    let baseName = ''
    // 查找'.'在fileName中最后出现的位置
    const lastIndex = file.name.lastIndexOf('.')
    // 如果'.'不存在,则返回整个文件名
    if (lastIndex === -1) {
      baseName = file.name
    }
    // 否则,返回从fileName开始到'.'前一个字符的子串作为文件名(不包含'.')
    baseName = file.name.slice(0, lastIndex)

    // 这里要注意!可能同一个文件,是复制出来的,出现文件名不同但是内容相同,导致获取到的hash值也是相同的
    // 所以文件hash要特殊处理
    inTaskArrItem.fileHash = `${fileHash}${baseName}`
    inTaskArrItem.state = 2
    console.log(uploadFileList.value, 'uploadFileList.value')
    // 上传之前要检查服务器是否存在该文件
    try {
      const res = await checkFile({
        fileHash: `${fileHash}${baseName}`,
        fileName: file.name,
      })

      if (res.code === 0) {
        const { shouldUpload, uploadedList } = res.data


        if (!shouldUpload) {
          finishTask(inTaskArrItem)
          console.log('文件已存在,实现秒传')
          return false
        }

        inTaskArrItem.allChunkList = fileChunkList.map((item, index) => {
          return {
            // 总文件hash
            fileHash: `${fileHash}${baseName}`,
            // 总文件size
            fileSize: file.size,
            // 总文件name
            fileName: file.name,
            index: index,
            // 切片文件本身
            chunkFile: item.chunkFile,
            // 单个切片hash,以 - 连接
            chunkHash: `${fileHash}-${index}`,
            // 切片文件大小
            chunkSize: chunkSize,
            // 切片个数
            chunkNumber: fileChunkList.length,
            // 切片是否已经完成
            finish: false,
          }
        })

        // 如果已存在部分文件切片,则要过滤调已经上传的切片
        if (uploadedList.length > 0) {
          // 过滤掉已经上传过的切片
          inTaskArrItem.allChunkList = inTaskArrItem.allChunkList.filter(
            (item) => !uploadedList.includes(item.chunkHash)
          )

          // 如果存在需要上传的,但是又为空,可能是因为还没合并,
          if (!inTaskArrItem.allChunkList.length) {
            // 所以需要调用合并接口
            await handleMerge(inTaskArrItem)
            return false
          } else {
            // 同时要注意处理切片数量
            inTaskArrItem.allChunkList = inTaskArrItem.allChunkList.map(
              (item) => {
                return {
                  ...item,
                  chunkNumber: inTaskArrItem.allChunkList.length,
                }
              }
            )
          }
        }

        // 逐步对单个文件进行切片上传
        uploadSignleFile(inTaskArrItem)
      }
    } catch (err) {}
  })
}

!这里要注意的是 inTaskArrItem 用了reactive做响应式代理push到uploadFileList,这是因为在 forEach循环 + await中,如果在 await 后面直接修改inTaskArrItem某个属性,Vue3并不能实时更新DOM视图,就会导致uploadFileList在视图中不会变化,就算是用for + await也是类似(最后一个inTaskArrItem不会触发DOM更新),所以这里必须要么使用reactive,要么直接uploadFileList.value[i].state = 2修改, 又或者使用赋值重新引用响应式inTaskArrItem = uploadFileList.value[i]等其中一种方法去修改。

在上面代码中,我们通过checkFile接口去获取该文件是否需要再次上传,以及拿到已上传切片列表,如果不需要二次上传,则直接让其显示上传完成并设置上传状态,如果是已上传部分切片,则需要过滤掉已经上传过的切片,同时要注意,可能存在已经上传完所有切片但未合并的情况,这时候就得调一次合并接口即可。同时要注意处理切片数量,因为这在判断是否已经上传完全部切片的时候用到。

暂停上传

其实就是把还在请求中的接口直接中断。这时候我们就可以用到axiosAbortController方法去取消接口请求,(原生XMLHttpRequest 使用 abort 方法)

所以我们的上传接口要重新封装一下:

javascript 复制代码
// src/api/index.js
// ...其他代码
// 上传单个切片
export function uploadFile(data, onCancel) {
  const controller = new AbortController()
  const signal = controller.signal // 获取 signal 对象
  // 封装 axios 请求或 HTTP 客户端请求
  const request = service({
    url: '/upload',
    method: 'post',
    data,
    signal, // 将 signal 传递给服务函数
  })
  // 如果提供了 onCancel 回调,则传递取消函数
  if (typeof onCancel === 'function') {
    // 如果是一个函数,则直接调用传一个取消方法给 这个方法
    // 所以只要传进来是方法,就会直接传一个参数并直接触发这个函数
    // 那传过来的这个方法就会接收到一个参数(就是取消函数() => controller.abort())
    // 在调用uploadFile就可以拿到这个参数
    onCancel(() => controller.abort()) // 调用 onCancel 时传入取消函数
  }
  return request
}
javascript 复制代码
// App.vue
  // 单个分片请求
  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++
      // 超过3次之后直接上传中断
      if (taskArrItem.errNumber > 3) {
        console.log('切片上传失败超过三次了')
        pauseUpload(taskArrItem, false) // 上传中断
      } else {
        console.log('切片上传失败还没超过3次')
        uploadChunk(needObj) // 失败了一片,继续当前分片请求
      }
    } else if (res.code === 0) {
      // 单个文件上传失败次数大于0则要减少一个
      taskArrItem.errNumber > 0 ? taskArrItem.errNumber-- : 0
      // 单个文件切片上传成功数+1
      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)
      }
    }
  }

在这段代码中

// 在调用接口的同时,相当于同时调用了传入的这个函数,又能同时拿到返回的取消方法去赋值

const res = await uploadFile(fd, (onCancelFunc) => {

needObj.cancel = onCancelFunc

}).catch(() => {})
解析:这里我们在调uploadFile接口的同时,把一个箭头函数A传递作为第二个形参传递给了uploadFile,而在封装的uploadFile内部,获取到的第二个形参也就是onCancel判断如果为function,则立即执行onCancel这个函数A,在执行的同时把箭头函数() => controller.abort()作为传参传给函数A,这样在函数A就可以拿到() => controller.abort()并赋值给当前切片的cancel字段。

这样就可以在主动取消的时候,调用当前切片内部属性cancel(),相当于调用该切片接口的controller.abort()进行取消请求

这样我们就可以定义一个暂停事件:

javascript 复制代码
// 暂停上传(是暂停剩下未上传的)
const pauseUpload = (taskArrItem, elsePause = true) => {
  // elsePause为true就是主动暂停,为false就是请求中断
  // 4是成功 6是失败  如果不是成功或者失败状态,
  if (![4, 6].includes(taskArrItem.state)) {
    // 3是暂停,5是中断
    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() : ''
    }
  }
}

在暂停事件中,先判断该文件的上传成功失败状态,如果都不属于这两者,则需要判断是否为主动暂停还是被动中断(某切片连续上传失败3次),同时调用controller.abort()取消还在请求中的所有接口。

恢复上传

既然我们都有了暂停上传了,就得有恢复上传吧,其实原理也很简单:只要把刚才暂停的正在上传中所有切片(whileRequests)放到待上传切片列表中(allChunkList ,再去调用单个文件上传方法(uploadSignleFile)就可以啦。

javascript 复制代码
// 继续上传
const resumeUpload = (taskArrItem) => {
  // 2为上传中
  taskArrItem.state = 2
  // 把刚才暂停的正在上传中所有切片放到待上传切片列表中
  taskArrItem.allChunkList.push(...taskArrItem.whileRequests)
  taskArrItem.whileRequests = []
  uploadSignleFile(taskArrItem)
}

不足之处

用户刷新页面/打开页面后,没有实现当前页面自动恢复文件列表的上传(该功能因为是存储文件信息,需要用到浏览器的本地缓存indexDB功能,但该功能还是不够完美,所以没去实现,可自行了解indexDB)一文读懂IndexedDB: 前端本地数据库入门与实践-CSDN博客

可优化部分

对于文件哈希值的计算,对于大文件就算使用增量读取,但仍很耗时,可以对文件进行抽样hash读取,但注意要保证准确度。

对前端文件合并处理,不一定要调接口,可以把这部分放到后端去处理,可以节省前端工作量

源代码

若觉得对你有所帮助,希望可以多多 Star⭐ 支持一下~

github:v2v3-large-file-upload

gitee:v2v3-large-file-upload

相关推荐
AKA__老方丈2 小时前
vue-cropper图片裁剪、旋转、缩放、实时预览
前端·vue.js
梦6503 小时前
Vue 单页面应用 (SPA) 与 多页面应用 (MPA) 对比
前端·javascript·vue.js
清铎3 小时前
大模型训练_week3_day15_Llama概念_《穷途末路》
前端·javascript·人工智能·深度学习·自然语言处理·easyui
岛泪3 小时前
把 el-cascader 的 options 平铺为一维数组(只要叶子节点)
前端·javascript·vue.js
Kiyra4 小时前
阅读 Netty 源码关于 NioEventLoop 和 Channel 初始化部分的思考
运维·服务器·前端
冰暮流星4 小时前
javascript的switch语句介绍
java·前端·javascript
做科研的周师兄5 小时前
【MATLAB 实战】|多波段栅格数据提取部分波段均值——批量处理(NoData 修正 + 地理信息保真)_后附完整代码
前端·算法·机器学习·matlab·均值算法·分类·数据挖掘
da_vinci_x5 小时前
图标量产:从“手绘地狱”到“风格克隆”?Style Reference 的工业化实战
前端·游戏·ui·prompt·aigc·设计师·游戏美术
利刃大大5 小时前
【ES6】变量与常量 && 模板字符串 && 对象 && 解构赋值 && 箭头函数 && 数组 && 扩展运算符 && Promise/Await/Async
开发语言·前端·javascript·es6