掘金 后端 ( ) • 2024-04-16 11:33
title :vue3+element-plus 实现大文件上传(文件分片上传,断点续传)
author:NanYanBeiYu
desc:大文件上传-分片上传

大文件上传

文件分片处理

本文是借助element-ui搭建的页面。使用el-upload组件,因为我们要自定义上传的实现,所以需要将组件的自动上传关掉:auto-upload="false"。

<template>
    <el-upload
      class="upload-demo"
      drag
      action=""
      :auto-upload="false"
      :show-file-list="false"
      :on-change="handleChange"
      multiple
    >
      <el-icon class="el-icon--upload"><upload-filled /></el-icon>
      <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
      <template #tip>
        <div class="el-upload__tip">文件大小不超过4GB</div>
      </template>
    </el-upload>
</template>

当文件状态发生变化时触发:on-change是文件状态改变时的钩子,添加文件、上传成功和上传失败时都会被调用 handleChange。

获取到文件对象和切片处理

在el-upload组件中,:on-change的参数有file,但这里的file并不是File对象需要通过file.raw来获取。File由于继承了Blob中的slice方法,可以通过这个方法来将File进行切片。

获取文件对象:

const handleChange = (file) => {
  console.log(file.raw)
}

控制台打印信息:

01.png

对获取到的File对象进行切片处理,创建切片函数并调用:

const chunkSize = 1024 * 1024 * 2
​
/**
 * @description 当文件状态变化时触发
 * */
const handleChange = (file) => {
  console.log(file.raw)
  const fileChunks = createChunks(file.raw,chunkSize)
  console.log(fileChunks)
}
​
/**
 * @description 创建文件切片
 * @param file 文件
 * @param size 切片大小
 * @returns fileChunks 切片列表
 * */
const createChunks = (file,size) => {
  const fileChunks = []
  for(let i=0;i < file.size ;i += size){
    fileChunks.push(file.slice(i,i+size))
  }
  return fileChunks
}

观察控制台输出信息:

02.png

可以看到已经将这个File对象分片成了1885份。

根据文件内容生成SHA3-256

这里我使用的库是js-sha3 ,详情在https://github.com/emn178/js-sha3

const hash = async (chunks) => {
  const sha3 = sha3_256.create()
  let processedChunks = 0 // 记录已处理的chunks数量
  async function _read(i) {
    if (i >= chunks.length) {
      sha3256.value = sha3.hex()
      console.log('sha3256:', sha3256.value)
      return // 读取完毕
    }
    const blob = chunks[i]
    const reader = new FileReader()
    await new Promise((resolve, reject) => {
      reader.onload = (e) => {
        e.target.result // 读取到的字节数组
​
        sha3.update(e.target.result)
        resolve()
      }
      reader.onerror = reject
      reader.readAsArrayBuffer(blob)
    })
    await _read(i + 1)
  }
​
  await _read(0)
}

控制台输出信息:

03.png

将哈希值计算出来后,我们就可以使用这个哈希值来作为文件的唯一标识了。

上传分片信息:

拿到文件的哈希值后,就可以根据服务端的要求来上传分片。

要将分片进行上传,我们就需要对分片列表进行遍历,为每一个分片添加进formData中

/**
 * uploadFile函数负责将文件分片列表(`chunkList.value`)中的所有分片依次上传至服务器。该函数遵循以下步骤:
​
 1. **遍历分片**:
 - 使用一个`for`循环,从索引`0`开始,遍历至`chunkList.value.length - 1`。
 - 在每次迭代中,获取当前分片`chunk`及其在列表中的索引`index`。
​
 2. **构建FormData对象**:
 - 创建一个新的`FormData`实例,用于封装上传所需的各项数据。
 - 使用`append`方法向FormData对象添加以下字段:
 - `chunk`: 当前遍历到的分片数据。
 - `index`: 分片在列表中的索引。
 - `sha3256`: 文件的SHA-3256哈希值(存储在`sha3256.value`)。
 - `suffix`: 文件的后缀名(存储在`suffix.value`)。
 - `totalChunks`: 文件总分片数(存储在`totalChunks.value`)。
​
 3. **调用uploadChunkService进行上传**:
 - 使用封装好的FormData对象作为参数,调用`uploadChunkService(formData)`以发起异步请求,将当前分片上传至服务器。
​
 4. **响应处理**:
 - 对于上传成功的响应:
 - 在控制台输出一条成功消息,包含已成功上传的分片索引及响应数据。
 - 对于上传失败的响应:
 - 在控制台输出一条错误消息,包含失败的分片索引及具体的错误信息。
​
 **参数**:无
​
 **返回值**:无
​
 **依赖**:
 - `chunkList.value`: 存储文件分片数据的数组。
 - `sha3256.value`: 文件的SHA-3256哈希值。
 - `suffix.value`: 文件的后缀名。
 - `totalChunks.value`: 文件总分片数。
 - `uploadChunkService(formData)`: 异步服务函数,用于上传单个文件分片。
​
 **注意**:此函数仅负责上传分片,不涉及分片的生成或合并逻辑。实际执行时应确保相关依赖变量已正确初始化。
 */
const uploadFile = () => {
  // 遍历分片
  for (let i = 0; i < chunkList.value.length; i++) {
    const chunk = chunkList.value[i]
    const index = i
    // 创建FormData对象并添加分片和索引
    const formData = new FormData()
    formData.append('chunk', chunk) // 添加分片
    formData.append('index', index) // 添加索引
    formData.append('sha3256', sha3256.value) // 添加sha3256
    formData.append('suffix', suffix.value) // 添加后缀名
    formData.append('totalChunks', totalChunks.value) // 添加总分片数
    uploadChunkService(formData)
      .then((res) => {
        // 成功上传分片后的处理
        console.log(`Chunk ${index} uploaded successfully`, res)
      })
      .catch((error) => {
        // 上传失败后的处理
        console.error(`Chunk ${index} upload failed`, error)
      })
  }
}

file.js

// 分片上传
export const uploadChunkService = (params) =>
  request.post('/cloud/files/upload/chunk', params, {
    headers: {
      'Content-Type': 'multipart/form-data'
    }
  })

axios封装

import { useUserStore } from '@/stores/user.js'
import axios from 'axios'
import router from '@/router'
// import { ElMessage } from 'element-plus'
​
const baseURL = 'http://127.0.0.1:89/'
​
const instance = axios.create({
  baseURL,
  timeout: 100000
})
​
instance.interceptors.request.use(
  (config) => {
    const userStore = useUserStore()
    if (userStore.token) {
      config.headers.Authorization = userStore.token
    }
    return config
  },
  (err) => Promise.reject(err)
)
​
instance.interceptors.response.use(
  (res) => {
    if (res.status === 200) {
      // 添加检查HTTP状态码是否为200
      if (res.data.code === 200) {
        return res.data // 返回数据而不是整个响应对象
      }
    }
    ElMessage({ message: res.data.msg || '111', type: 'error' })
    return Promise.reject(res) // 当HTTP状态码不是200或者服务端返回错误代码时,才reject
  },
  (err) => {
    ElMessage({
      message: err.response.data.msg || '222',
      type: 'error'
    })
    console.log(err)
    if (err.response?.status === 401) {
      router.push('/login')
    }
    return Promise.reject(err)
  }
)
​
export default instance
export { baseURL }

后端实现:

这里后端使用的是Gin框架。路由这里就不放出来了,可以自行设计。

// UploadChunk 分片上传
func UploadChunk(c *gin.Context) {
    // 1. 解析请求参数
    fileHash := c.PostForm("sha3256") // 文件hash
    index := c.PostForm("index")      // 分片索引
​
    // 定义一个临时存储路径
    tempDir := "./upload/temp/" + fileHash + "/"
    filePath := filepath.Join(tempDir, fileHash+"_"+index+".part")
​
    // 使用goroutine处理分片
    //创建一个waitgroup,用于等待所有协程执行完毕
    var wg sync.WaitGroup
    wg.Add(1) // 增加一个协程,表示即将启动一个goroutine
​
    go func() {
        defer wg.Done() //确保在goroutine结束时,减少一个计数器
​
        // 确保 tempDir 存在
        if _, err := os.Stat(tempDir); os.IsNotExist(err) {
            os.MkdirAll(tempDir, os.ModePerm)
        }
​
        // 打开文件以追加数据
        file, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
        if err != nil {
            // 返回错误
            c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
                "code": 500,
                "msg":  "Failed to open file",
            })
            return
        }
        defer file.Close()
​
        // 读取切片数据
        part, err := c.FormFile("chunk")
        if err != nil {
            c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
                "code": 500,
                "msg":  "Failed to read part",
            })
            return
        }
        src, err := part.Open()
        if err != nil {
            c.AbortWithStatusJSON(500, gin.H{
                "code": 500,
                "msg":  "Failed to open part",
            })
            return
        }
        defer src.Close()
​
        // 将切片的内容写入文件
        _, err = io.Copy(file, src)
        if err != nil {
            c.JSON(500, gin.H{
                "code": 500,
                "msg":  "Failed to write part",
            })
            return
        }
        c.JSON(http.StatusOK, gin.H{
            "code": 200,
            "msg":  "切片上传成功",
        })
    }()
    wg.Wait() // 等待所有协程执行完毕
}

效果:

04.png 可以看到这里一个切片了31份,后端存储的切片信息:

05.png