前端大文件上传

简介

一次性上传会存在的问题:比较慢,中途退出或者网络延迟,容易出现超时,上传失败等问题。所以一般会选择分片上传、断点续传来实现。

分片上传: 分片上传是将大文件分割成多个小块(分片),然后逐个上传这些小块。小块可以并行上传,从而提高上传速度。一旦所有分片上传完成,服务器可以将这些分片合并成完整的文件。

断点续传: 断点续传是一种允许在上传中断后继续上传,而无需从头开始。

秒传: 秒传实际上就是不传,允许用户在上传文件时,如果服务器已经存在完全相同的文件,就直接跳过上传过程,实现瞬间完成的效果。

依赖工具

前端实现大文件上传需要借助依赖spark-md5,用于生成每个文件对应的md5加密信息。既能在传送文件分片的时候起到防篡改的作用,也可以用于数据库中文件和分片数据进行md5加密对比,实现断点续传和秒传的功能!

javascript 复制代码
import SparkMD5 from 'spark-md5'

接口说明

  • findFile():提前告知后端文件信息、切片数量等等,以实现秒传和断点续传的功能。
  • breakpointContinue():向后端发送二进制切片数据。
  • breakpointContinueFinish():前端切片发送完毕,后端合成文件。
  • removeChunk():后端合成文件过后,删除缓存切片。

前端实现

首先,我们通过e.target.files[0].size读取文件的基本信息,包括文件大小、文件名等等,然后用于业务处理,比如是否限制文件大小等等。

1.分片上传

JavaScript中,我们可以通过FileReader()读取文件信息并生成对应的ArrayBuffer格式的数据。ArrayBufferJavaScript中用来表示二进制数据的对象,,通常用于处理文件、图像、音频等二进制数据。

dart 复制代码
const fileR = new FileReader() // 创建一个reader用来读取文件流
fileR.readAsArrayBuffer(fileChunk)
fileR.onload = async e => {
  ...
}  

同时我们根据文件信息e.target.result生成md5加密信息,这样即使文件改名了,也能够将文件信息和md5进行一一对应。

go 复制代码
const blob = e.target.result
const spark = new SparkMD5.ArrayBuffer() // 创建md5制造工具
spark.append(blob) // 文件流丢进工具
fileMd5.value = spark.end() // 工具结束 产生一个总文件的md5

浏览器对于超过2G的文件有限制要求(如下),因此对于超过2G的文件我们先要进行一次分片处理。

然后我们就要实现真正的分片功能,将转化好的二进制文件数据分成固定大小的切片(这个需要根据业务场景等因素,和后端进行商量),每个切片包括切片文件内容、当前是第几片等等。

2.断点续传和秒传

实现切片以后,我们要先发送一个findFile()请求,把文件名、md5、切片数量等信息提交给后端。后端根据md5和数据库里面文件的md5进行对比,和对应已经上传的文件切片也进行对比。

后端对比完成以后,后端告诉前端当前文件是否上传完毕,如果已经上传我们就可以实现秒传功能了。

如果后端返回对应文件已经上传的文件切片,那么对前端总切片数据进行过滤,我们只需要将未上传的切片提交给后端即可,从而实现断点续传

3.发送切片

前端实现切片以后,我们就要将每一个切片都通过请求发送给后端breakpointContinue(),有多少个切片就要发送多少个请求。

注意: 如果切片数量过大,发送的请求过多可能会导致部分请求发送失败!

4.后端合成切片,删除缓存切片

前端把所有切片都发送给后端以后,那么还需要发送breakpointContinueFinish()告诉后端切片上传完毕,同时后端开始合成切片生成文件。

合成文件以后,前端还要发送removeChunk(),告诉后端可以删除缓存切片了,以减少服务器的压力。

整体实现

xml 复制代码
<template>
  <div class="break-point">
    <div class="gva-table-box">
      <el-divider content-position="left">大文件上传</el-divider>
      <form id="fromCont" method="post">
        <div class="fileUpload" @click="inputChange">
          选择文件
          <input v-show="false" id="file" ref="FileInput" multiple="multiple" type="file" @change="choseFile">
        </div>
      </form>
      <el-button :disabled="limitFileSize" type="primary" size="small" class="uploadBtn" @click="getFile">上传文件</el-button>
      <div class="el-upload__tip">请上传不超过5MB的文件</div>
      <div class="list">
        <transition name="list" tag="p">
          <div v-if="file" class="list-item">
            <el-icon>
              <document />
            </el-icon>
            <span>{{ file.name }}</span>
            <span class="percentage">{{ percentage }}%</span>
            <el-progress :show-text="false" :text-inside="false" :stroke-width="2" :percentage="percentage" />
          </div>
        </transition>
      </div>
      <div class="tips">此版本为先行体验功能测试版,样式美化和性能优化正在进行中,上传切片文件和合成的完整文件分别再QMPlusserver目录的breakpointDir文件夹和fileDir文件夹</div>
    </div>
  </div>

</template>

<script setup>
import SparkMD5 from 'spark-md5'
import {
  findFile,
  breakpointContinueFinish,
  removeChunk,
  breakpointContinue
} from '@/api/breakpoint'
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { ElLoading } from 'element-plus'

const file = ref(null) // 读取的文件信息,可以读取文件大小、同时生成MD5标识等等。
const fileMd5 = ref('') // Md5加密标识
const formDataList = ref([]) // 分片存储的一个池子,{ key: sliceIndex.value, formData }
const waitUpLoad = ref([]) // 当是断点续传,剩下没有上传的切片
const waitNum = ref(NaN) // 当是断点续传,剩下没有上传的切片数量
const limitFileSize = ref(false)
const percentage = ref(0)
const percentageFlage = ref(true)
const FileSliceCap = 10 * 1024 * 1024 // 分片字节数,如果分的太细需要发送很多个分片请求。
const sliceStart = ref(0) // 定义分片开始切的地方
const sliceEnd = ref(0) // 每片结束切的地方a
const sliceIndex = ref(0) // 第几片
const allChunkSize = ref(0) // 所有大切片的累加体积
const isCreateMd5 = ref(true) // 是否生成Md5
const limitSize = 2000 * 1024 * 1024 // 浏览器对于上传的文件有限定大小,大于2G的文件无法上传。我们需要对大于2G的文件先切成大的chunk
const ChunkSizeCap = 2000 * 1024 * 1024 // 大的chunk容量
const maxSize = 10000 * 1024 * 1024 // 限制上传的文件大小
let moreThan_2G = false

const init = () => {
  percentage.value = 0
  allChunkSize.value = 0
  sliceStart.value = 0
  sliceEnd.value = 0
  sliceIndex.value = 0
  moreThan_2G = file.value.size > limitSize
  isCreateMd5.value = true
}

// 选中文件的函数。如果文件大于2G,先进行一次切片。
const choseFile = async(e) => {
  const fileInput = e.target.files[0] // 获取当前文件
  file.value = fileInput
  init()
  if (file.value.size < maxSize) {
    if (moreThan_2G) {
      formDataList.value = []
      let chunkStart = 0
      let chunkEnd = 0
      let chunkIndex = 0
      while (chunkEnd < file.value.size) {
        chunkStart = chunkIndex * ChunkSizeCap // 计算每片开始位置
        chunkEnd = (chunkIndex + 1) * ChunkSizeCap // 计算每片结束位置
        const fileChunk = file.value.slice(chunkStart, chunkEnd)
        sliceChunk(fileChunk)
        chunkIndex++
      }
    } else {
      sliceChunk(file.value)
    }
  } else {
    limitFileSize.value = true
    ElMessage('请上传小于5M文件')
  }
}

/**
 * 对文件进行切片,同时根据文件信息生成md5,md5用于后端识别文件。
 * 切片信息包括:
 *  -md5
 *  -当前切片文件
 *  -当前是第几片
 *  -文件名
 * @param fileChunk
 */
const sliceChunk = (fileChunk) => {
  const fileR = new FileReader() // 创建一个reader用来读取文件流
  percentage.value = 0
  fileR.readAsArrayBuffer(fileChunk) // 把文件读成ArrayBuffer  主要为了保持跟后端的流一致
  const loading = ElLoading.service({
    lock: true,
    text: 'Loading',
    background: 'rgba(0, 0, 0, 0.7)',
  })
  fileR.onload = async e => {
    allChunkSize.value += fileChunk.size
    if (isCreateMd5.value) {
      // 读成arrayBuffer的回调 e 为方法自带参数 相当于 dom的e 流存在e.target.result 中
      const blob = e.target.result
      const spark = new SparkMD5.ArrayBuffer() // 创建md5制造工具
      spark.append(blob) // 文件流丢进工具
      fileMd5.value = spark.end() // 工具结束 产生一个总文件的md5
      isCreateMd5.value = false
    }
    // 当结尾数字大于文件总size的时候 结束切片
    while (sliceEnd.value < file.value.size) {
      sliceStart.value = sliceIndex.value * FileSliceCap // 计算每片开始位置
      sliceEnd.value = (sliceIndex.value + 1) * FileSliceCap // 计算每片结束位置
      var fileSlice = fileChunk.slice(sliceStart.value, sliceEnd.value) // 开始切  file.slice 为 h5方法 对文件切片 参数为 起止字节数
      const formData = new window.FormData() // 创建FormData用于存储传给后端的信息
      formData.append('fileMd5', fileMd5.value) // 存储总文件的Md5 让后端知道自己是谁的切片
      formData.append('file', fileSlice) // 当前的切片
      formData.append('chunkNumber', sliceIndex.value) // 当前是第几片
      formData.append('fileName', file.value.name) // 当前文件的文件名 用于后端文件切片的命名  formData.appen 为 formData对象添加参数的方法
      formDataList.value.push({ key: sliceIndex.value, formData }) // 把当前切片信息 自己是第几片 存入我们方才准备好的池子
      sliceIndex.value++
    }
    if (allChunkSize.value === file.value.size) {
      loading.close()
      submitSlice()
    }
  }
  fileR.onerror = async e => {
    loading.close()
    ElMessage('文件读取失败')
  }
}

/**
 * 提交给后端要上传的文件信息,包括md5,切片数量等等。
 * 后端根据前端生成的md5与数据库文件的md5进行对比,如果一致那么就表示上传完成实现了秒传功能。
 * 如果数据库没有上传成功的文件,那么后端会返回对应文件已经上传的切片数据。
 * 前端formDataList和对应文件已经上传的切片数据进行对比,判断还需要上传哪些切片,从而实现断点上传。
 * @returns {Promise<void>}
 */
const submitSlice = async() => {
  const params = {
    fileName: file.value.name,
    fileMd5: fileMd5.value,
    chunkTotal: formDataList.value.length
  }
  const res = await findFile(params)
  // 全部切完以后 发一个请求给后端 拉当前文件后台存储的切片信息 用于检测有多少上传成功的切片
  const finishList = res.data.file.ExaFileChunk // 上传成功的切片
  const IsFinish = res.data.file.IsFinish // 是否是同文件不同命 (文件md5相同 文件名不同 则默认是同一个文件但是不同文件名 此时后台数据库只需要拷贝一下数据库文件即可 不需要上传文件 即秒传功能)
  if (!IsFinish) {
    // 当是断点续传时候
    waitUpLoad.value = formDataList.value.filter(all => {
      return !(finishList &&
          finishList.some(fi => fi.FileChunkNumber === all.key)
      )
    })
  } else {
    waitUpLoad.value = [] // 秒传则没有需要上传的切片
    ElMessage.success('文件已秒传')
  }
  waitNum.value = waitUpLoad.value.length // 记录长度用于百分比展示
}

const getFile = () => {
  // 确定按钮
  if (file.value === null) {
    ElMessage('请先上传文件')
    return
  }
  if (percentage.value === 100) {
    percentageFlage.value = false
  }
  sliceFile() // 上传切片
}

/**
 * 获取当前切片md5,主要用于后端验证切片完整性
 */
const sliceFile = () => {
  waitUpLoad.value &&
  waitUpLoad.value.forEach(item => {
    // 需要上传的切片
    item.formData.append('chunkTotal', formDataList.value.length) // 切片总数携带给后台 总有用的
    const fileR = new FileReader() // 功能同上
    const fileF = item.formData.get('file')
    fileR.readAsArrayBuffer(fileF)
    fileR.onload = e => {
      const spark = new SparkMD5.ArrayBuffer()
      spark.append(e.target.result)
      item.formData.append('chunkMd5', spark.end())
      upLoadFileSlice(item)
    }
  })
}

watch(() => waitNum.value, () => {
  percentage.value = Math.floor(((formDataList.value.length - waitNum.value) / formDataList.value.length) * 100)
})

/**
 * 上传切片信息,有多少个切片就发送多少个请求,因此切片大小和数量要根据实际情况去定。
 * 切片上传完毕以后,还要给后端再次发送一次请求,让后端合成文件并删除缓存切片。
 */
const upLoadFileSlice = async(item) => {
  // 切片上传
  const fileRe = await breakpointContinue(item.formData)
  if (fileRe.code !== 0) {
    return
  }
  waitNum.value-- // 百分数增加
  if (waitNum.value === 0) {
    // 切片传完以后 合成文件
    const params = {
      fileName: file.value.name,
      fileMd5: fileMd5.value
    }
    const res = await breakpointContinueFinish(params)
    if (res.code === 0) {
      // 合成文件过后 删除缓存切片
      const params = {
        fileName: file.value.name,
        fileMd5: fileMd5.value,
        filePath: res.data.filePath,
      }
      ElMessage.success('上传成功')
      await removeChunk(params)
    }
  }
}

const FileInput = ref(null)
const inputChange = () => {
  FileInput.value.dispatchEvent(new MouseEvent('click'))
}
</script>

<script>

export default {
  name: 'BreakPoint'
}
</script>

<style lang="scss" scoped>
h3 {
  margin: 40px 0 0;
}

ul {
  list-style-type: none;
  padding: 0;
}

li {
  display: inline-block;
  margin: 0 10px;
}

a {
  color: #42b983;
}

#fromCont {
  display: inline-block;
}

.fileUpload {
  padding: 3px 10px;
  font-size: 12px;
  height: 20px;
  line-height: 20px;
  position: relative;
  cursor: pointer;
  color: #000;
  border: 1px solid #c1c1c1;
  border-radius: 4px;
  overflow: hidden;
  display: inline-block;

  input {
    position: absolute;
    font-size: 100px;
    right: 0;
    top: 0;
    opacity: 0;
    cursor: pointer;
  }
}

.fileName {
  display: inline-block;
  vertical-align: top;
  margin: 6px 15px 0 15px;
}

.uploadBtn {
  position: relative;
  top: -10px;
  margin-left: 15px;
}

.tips {
  margin-top: 30px;
  font-size: 14px;
  font-weight: 400;
  color: #606266;
}

.el-divider {
  margin: 0 0 30px 0;
}

.list {
  margin-top: 15px;
}

.list-item {
  display: block;
  margin-right: 10px;
  color: #606266;
  line-height: 25px;
  margin-bottom: 5px;
  width: 40%;

  .percentage {
    float: right;
  }
}

.list-enter-active, .list-leave-active {
  transition: all 1s;
}

.list-enter, .list-leave-to
  /* .list-leave-active for below version 2.1.8 */
{
  opacity: 0;
  transform: translateY(-30px);
}
</style>
相关推荐
崔庆才丨静觅13 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606114 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了14 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅14 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅14 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅15 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment15 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅15 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊15 小时前
jwt介绍
前端
爱敲代码的小鱼15 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax