vue3 + node大文件分片上传(前后端, 包括文件秒传、断点续传)

vue3 + node大文件分片上传(前后端, 包括文件秒传、断点续传)

前端html部分:


<template>
  <div>
    <input type="file" @change="handleUpload" multiple><br>
    <progress :value="progressNum" max="100"></progress>
    <div>{{ progressNum }}% {{ text }}</div>
  </div>
</template>

ts部分

<script lang="ts" setup>
import SparkMD5 from 'spark-md5'
import { ref } from 'vue'
 
// hash
let fileHash = ref<string>('')
// 文件名
let fileName = ref<string>('')
// 进度条
let progressNum = ref<number>(0)
// 文字
let text = ref<string>('')
 
const handleUpload = async (e: Event) => {
  const files = (e.target as HTMLInputElement).files
  if (!files) return
  // console.log(files[0]);
 
  // 文件名
  fileName.value = files[0].name // 上传需要的数据
 
  // 文件分片
  let chunks = createChunks(files[0]) // 数组
  // console.log(chunks);
 
  // 文件hash计算
  const hash = await calculateHash(chunks)
  // console.log(hash);
  fileHash.value = hash as string
 
  // 文件hash校验  (文件秒传 在上传文件前,就要把文件hash值告诉后端, 如果后端有该文件,直接返回上传成功)
  let shouldUpload = await verify()
  console.log(shouldUpload);
  
  if (shouldUpload.data.shouldUpload) { // true: 服务器没有该文件需要上传
    // 上传分片
    uploadChunks(chunks, shouldUpload.data?.existChunks)
  }
 
  // 上传分片
  // uploadChunks(chunks)
}
 
// 分片大小
const CHUNK_SIZE = 1024 * 1024 // 1m
 
// 文件分片
const createChunks = (file: File) => {
  let chunks = []
  for (let i = 0; i < file.size; i += CHUNK_SIZE) {
    chunks.push(file.slice(i, i + CHUNK_SIZE))
  }
  return chunks
}
 
// 计算文件的hash
const calculateHash = (chunks: Blob[]) => {
  return new Promise((resolve) => {
    // 1. 第一个和最后一个切片全部参与计算
    // 2. 中间的切片只计算前面两个字节、中间两个字节、最后两个字节
 
    const targets: Blob[] = [] // 存储所有参与计算的切片 blob数组
 
    // 循环产生新的分片数组
    chunks.forEach((chunk, index) => {
      if (index === 0 || index === chunks.length - 1) {
        // 1. 第一个和最后一个切片全部参与计算
        targets.push(chunk)
      } else {
        // 2. 中间的切片只计算前面两个字节、中间两个字节、最后两个字节
        // chunk也是一个blob对象, 大小是CHUNK_SIZE
        targets.push(chunk.slice(0, 2)) // 前面两个字节
        targets.push(chunk.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2)) // 中间两个字节
        targets.push(chunk.slice(CHUNK_SIZE - 2, CHUNK_SIZE)) // 最后两个字节
      }
    })
 
    const spark = new SparkMD5.ArrayBuffer()
    const fileReader = new FileReader()
 
    // new Blob(targets) 将新分片数据转为blob对象
    // 文档用法
    fileReader.readAsArrayBuffer(new Blob(targets))
    // onload方法是异步的
    fileReader.onload = (e) => {
      spark.append((e.target as FileReader).result)
      // 拿到计算出来的hash值
      // console.log(spark.end());
      resolve(spark.end())
    }
  })
}
 
// 上传分片
const uploadChunks = async (chunks: Blob[], existChunks: string[]) => {
  const dataObj = chunks.map((chunk, index) => {
    // chunk 每个blob对象
    // console.log(chunk, index);
    // 返回上传需要的数据
    return {
      fileHash: fileHash.value, // 文件的hash: 区分上传的是哪个文件
      chunkHash: fileHash.value + '-' + index, // 切片的hash
      chunk
    }
  })
 
  // console.log(dataObj); // 数组对象 [{fileHash: xxx, chunkHash: xxx, chunk}]
  
  // 每个切片都要有formData对象
  const formDatas = dataObj.filter((item) => !existChunks.includes(item.chunkHash)).map((item) => {
    // console.log(item); {fileHash: xxx, chunkHash: xxx, chunk}
    const formData = new FormData()
 
    formData.append('fileHash', item.fileHash)
    formData.append('chunkHash', item.chunkHash)
    formData.append('chunk', item.chunk)
    
    return formData
  })
  // console.log(formDatas);
  
  const max = 6 // 最大并发请求数
  const taskPool: any = [] // 请求池: 用来存放当前执行的请求 Promise数组
 
  let num = 0 // 当前上传成功数量
  text.value = '上传中...'
 
  for (let i = 0; i < formDatas.length; i++) {
    // i: 用来标识当前上传到第几个
    // task: Promise
    const task = fetch('http://127.0.0.1:3000/upload', {
      method: 'POST',
      body: formDatas[i]
    })
    
    // 请求完成从请求池移除
    task.then(() => {
      num++
      progressNum.value = Math.round((num / formDatas.length) * 100) / 100 * 100
      taskPool.splice(taskPool.findIndex((item: any) => item === task))
    })
 
    taskPool.push(task) // 将每个请求放入请求池数组中
 
    // 请求池已经达到最大请求数, 需要等待请求池中要有完成的请求(完成一个就行)
    if (taskPool.length === max) {
      await Promise.race(taskPool) // 一个完成 promise状态为成功
    }
  }
 
  // 为了保证请求池中的请求全部完成
  await Promise.all(taskPool)
 
  // 全部完成后, 通知服务器去合并分片
  mergeRequest()
}
 
// 合并分片
const mergeRequest = () => {
  fetch('http://127.0.0.1:3000/merge', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      fileHash: fileHash.value,
      fileName: fileName.value,
      size: CHUNK_SIZE
    })
  }).then(res => {
    console.log('合并成功');
    text.value = '上传成功'
  })
}
 
// 校验文件hash
const verify = () => {
  return fetch('http://127.0.0.1:3000/verify', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      fileHash: fileHash.value,
      fileName: fileName.value // 用来获取文件后缀的
    })
  })
  .then(res => res.json())
  .then(data => {
    return data
  })
}
</script>

node后端部分:

const express = require('express')
const path = require('path')
const multiparty = require('multiparty')
const fse = require('fs-extra')
const cors = require('cors')
const bodyParser = require('body-parser')
 
const app = express()
 
app.use(bodyParser.json())
app.use(cors())
 
// upload文件夹目录
const UPLOAD_DIR = path.resolve(__dirname, 'upload') // __dirname当前目录
 
// 提取文件后缀名
const extractExt = fileName => {
    return fileName.slice(fileName.lastIndexOf('.'), fileName.length)
}
 
app.post('/upload', (req, res) => {
    const form = new multiparty.Form()
 
    form.parse(req, async (err, fields, files) => {
        // fields 可获取普通数据信息。
        try {
            // files 主要是获取文件数据信息。
            if (err) {
                return res.json({
                    status: 401,
                    msg: '上传失败'
                })
            }
            // console.log('fields',fields);
            // console.log('files',files);
 
            // const fileHash = fields['fileHash'][0]
            // const chunkHash = fields['chunkHash'][0]
            const fileHash = fields.fileHash[0]
            const chunkHash = fields.chunkHash[0]
 
            // 临时存放目录
            const chunkPath = path.resolve(UPLOAD_DIR, fileHash)
            // console.log('chunkPath', chunkPath);
 
            if (!fse.existsSync(chunkPath)) {
                // fse.existsSync(chunkPath) 判断目录是否存在,不存在创建目录
                await fse.mkdir(chunkPath)
            }
 
            const oldPath = files.chunk[0].path // multiparty创建的临时目录
 
            // 将切片放入该文件夹中
            await fse.move(oldPath, path.resolve(chunkPath, chunkHash))
 
            res.json({
                status: 200,
                msg: '上传成功'
            })
        } catch (error) {
 
        }
    })
})
 
app.post('/merge', async (req, res) => {
    try {
        const { fileHash, fileName, size } = req.body
        // console.log(fileHash, fileName, size);
 
        // 完整文件路径
        const filePath = path.resolve(UPLOAD_DIR, fileHash + extractExt(fileName))
 
        // 如果已经存在该文件,就没必要合并
        if (fse.existsSync(filePath)) {
            return res.json({
                status: 200,
                msg: '合并成功'
            })
        }
 
        // 如果不存在该文件,就合并文件
        const chunkDir = path.resolve(UPLOAD_DIR, fileHash) // 切片总目录
        // 切片目录不存在,需要重新上传文件
        if (!fse.existsSync(chunkDir)) {
            res.json({
                status: 401,
                msg: '合并失败,请重新上传'
            })
        }
 
        // 开始合并操作
        const chunkPaths = await fse.readdir(chunkDir) // 所有的切片文件名(数组)
        // console.log(chunkPaths);
        // 将所有切片排序(根据末尾数字排序)
        chunkPaths.sort((a, b) => {
            return a.split('-')[1] - b.split('-')[1]
        })
 
        const list = chunkPaths.map((chunkName, index) => { // chunkName 每个切片文件名
            return new Promise((resolve) => {
                const chunkPath = path.resolve(chunkDir, chunkName) // 每个切片路径
                // 读取切片内容 读取流
                const readStream = fse.createReadStream(chunkPath)
                // 写入流
                const writeStream = fse.createWriteStream(filePath, { // filePath 完整文件路径
                    start: index * size,
                    end: (index + 1) * size
                })
                // 将读取流放到写入流中
                readStream.pipe(writeStream)
 
                // 读取完后, 将切片删除
                readStream.on('end', async () => {
                    await fse.unlink(chunkPath)
                    resolve()
                })
            })
        })
 
        // list中所有的文件都合并完后,将存放所有切片的目录删掉
        await Promise.all(list)
        await fse.remove(chunkDir)
 
        res.json({
            status: 200,
            msg: '合并成功'
        })
    } catch (error) {
 
    }
})
 
// 文件秒传 在上传文件前,就要把文件hash值告诉后端
app.post('/verify', async (req, res) => {
    try {
        const { fileHash, fileName } = req.body
 
        const filePath = path.resolve(UPLOAD_DIR, fileHash + extractExt(fileName)) // 文件路径
 
        if (fse.existsSync(filePath)) {
            // 文件存在,不需要重新上传
            res.json({
                status: 200,
                data: {
                    shouldUpload: false // 不需要重新上传
                }
            })
        } else {
            // 文件不存在,重新上传
            // 返回服务器已经上传成功的切片
            const chunkDir = path.join(UPLOAD_DIR, fileHash)
            let chunkPaths = []
            if (fse.existsSync(chunkDir)) {
                // 如果存在已经上传的部分切片, 只需要上传没有的切片(断点续传)
                chunkPaths = await fse.readdir(chunkDir)
            }
            res.json({
                status: 200,
                data: {
                    shouldUpload: true, // 重新上传
                    existChunks: chunkPaths
                }
            })
        }
    } catch (error) {
 
    }
})
 
app.listen(3000, () => {
    console.log(`Example app listening on port http://127.0.0.1:${3000}`)
})

欢迎各位前端大佬一起交流

  • 9
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Spring MVC + Vue.js 的大文件分片上传前后端MD5加密流程如下: 1. 前端通过 Vue.js 实现文件分片上传,同时计算每个分片的MD5值。 ```javascript <template> <div> <input type="file" @change="uploadFile"/> </div> </template> <script> import SparkMD5 from 'spark-md5' export default { name: 'FileUpload', methods: { async uploadFile(event) { const file = event.target.files[0] const chunkSize = 2097152 // 每个分片的大小为2MB const chunks = Math.ceil(file.size / chunkSize) // 文件分成的总块数 const fileReader = new FileReader() const spark = new SparkMD5.ArrayBuffer() const md5List = [] for (let i = 0; i < chunks; i++) { const start = i * chunkSize const end = start + chunkSize > file.size ? file.size : start + chunkSize const chunk = file.slice(start, end) const chunkReader = new FileReader() chunkReader.onload = e => { spark.append(e.target.result) md5List.push(spark.end()) } chunkReader.readAsArrayBuffer(chunk) } fileReader.onload = e => { const formData = new FormData() formData.append('file', file) formData.append('md5List', JSON.stringify(md5List)) // 发送文件分片和MD5值到后端 this.$axios.post('/uploadFile', formData) } fileReader.readAsArrayBuffer(file) } } } </script> ``` 2. 后端 Spring MVC 接收到文件分片和 MD5 值,对每个分片进行 MD5 计算,最后将所有分片的 MD5 值进行合并,得到整个文件的 MD5 值,并与前端过来的 MD5 值进行比较,如果相同,则说明文件输完整无误。 ```java import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import javax.servlet.http.HttpServletRequest; import java.io.File; import java.io.IOException; import java.io.RandomAccessFile; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.List; @RestController public class FileUploadController { @PostMapping("/uploadFile") public void uploadFile(@RequestParam("file") MultipartFile file, @RequestParam("md5List") String md5List, HttpServletRequest request) throws IOException, NoSuchAlgorithmException { // 将 MD5 值的 JSON 字符串转换成 List List<String> md5Array = JSON.parseArray(md5List, String.class); // 计算文件的 MD5 值 MessageDigest md5Digest = MessageDigest.getInstance("MD5"); byte[] buffer = new byte[1024 * 1024]; int len; try (RandomAccessFile randomAccessFile = new RandomAccessFile(file.getOriginalFilename(), "rw")) { for (int i = 0; i < md5Array.size(); i++) { randomAccessFile.seek(i * buffer.length); len = randomAccessFile.read(buffer, 0, buffer.length); md5Digest.update(buffer, 0, len); if (!md5Array.get(i).equals(getMD5(buffer, len))) { throw new IOException("文件输错误"); } } randomAccessFile.seek(0); while ((len = file.getInputStream().read(buffer)) != -1) { randomAccessFile.write(buffer, 0, len); } } // 保存文件 File newFile = new File("upload/" + file.getOriginalFilename()); file.transferTo(newFile); } private String getMD5(byte[] buffer, int len) throws NoSuchAlgorithmException { MessageDigest md5Digest = MessageDigest.getInstance("MD5"); md5Digest.update(buffer, 0, len); byte[] md5Bytes = md5Digest.digest(); StringBuilder stringBuilder = new StringBuilder(); for (byte md5Byte : md5Bytes) { int value = md5Byte & 0xFF; if (value < 16) { stringBuilder.append("0"); } stringBuilder.append(Integer.toHexString(value)); } return stringBuilder.toString(); } } ``` 以上代码仅供参考,实际应用中需要根据具体需求进行修改。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值