手把手教你用nodejs + vue3 实现大文件上传、秒传、断点续传

大文件上传到主要流程

  1. 客户端上传文件
  2. 文件切片
  3. 调用服务器的校验接口,判断当前文件是否在服务器中存在
  4. 如果存在则进度条直接完成(秒传)
  5. 如果存在部分,服务器会返回存在的部分给前端,然后前端上传剩余的部分(断点续传)
  6. 切片上传
  7. 所有切片上传成功后调用服务端合并的接口,由后端对所有切片进行合并
  8. 上传完成
  • 先搭建前端框架,这里前端使用vue3 + elementui plus
  • 执行创建项目命令
js 复制代码
pnpm create vue@latest

我们这里只是做一个demo其他的配置项就不需要添加

js 复制代码
pnpm install element-plus
  • 大致写一下页面结构
vue 复制代码
<template>
  <div class="upload-container">
    <el-upload
      class="upload-demo"
      :auto-upload="false"
      :on-change="handleFileChange"
      :show-file-list="false"
    >
    <template #trigger>
      <el-button type="primary">select file</el-button>
    </template>

    </el-upload>

    <div class="progress-container" v-if="file1">
      <div class="file-info">
        <p>{{ file1.name }}</p>
        <p>{{ (file1.size / 1024 / 1024).toFixed(2) }} MB</p>
      </div>
      <el-progress :percentage="progressPercent" :status="progressStatus" />
      <el-button type="primary" @click="startUpload">开始上传</el-button>
    </div>
  </div>
</template>
  • 一开始页面长这样
  • js代码
js 复制代码
const handleFileChange = (uploadFile) => {
  file1.value = uploadFile.raw; // 获取上传的文件信息
  console.log('file1', file1.value)
  progressPercent.value = 0; // 进度条
};

上传文件以后页面长这样

  • 当点击开始上传按钮后,开始上传文件

众所周知大文件上传的方式是分片,将大文件分割成一个个小片,如下图所示

  • 切片的原理我这里就不赘述了,相信大家也看过很多资料了
  • 我这里直接实现对文件的切片

由于我们对切片生成hash值时用了一个spark-md5的插件,计算hash值是很耗时的,会阻塞浏览器,因此会使用到web Worker开启多线程计算hash值

index.js

js 复制代码
// 这是对大文件切块多主要代码
export const chunkSize = 10 * 1024 * 1024; // 10MB

const threadCount = navigator.hardwareConcurrency || 4; // 线程数
export const cutFile = async (file) => {
    return new Promise((resolve) => {
        const chunkCount =  Math.ceil(file.size / chunkSize); // 块数
        const threadChunkCount = Math.ceil(chunkCount / threadCount); // 每个线程处理的块数
        console.log('threadChunkCount', threadChunkCount, chunkCount)
        const result = []
        let finishCount = 0
        for (let i = 0; i < threadCount; i++) {
            const worker = new Worker(new URL('./worker.js', import.meta.url), {type: 'module'});
            worker.postMessage({ 
                file,
                start: i * threadChunkCount, // 每个线程处理的起始块数
                end: Math.min((i + 1) * threadChunkCount, chunkCount), // 每个线程处理的结束块数
                chunkSize
            })
            worker.onmessage = (e) => {
                worker.terminate() // 处理完就关闭线程
                result[i] = e.data // 将每个线程的结果保存到result数组中, 用下标时因为要保证顺序
                finishCount++
                if (finishCount === threadCount) {
                    resolve(result.flat()) // 将二维数组变为一维数组
                }
            }
        }
    })
}

worker.js

js 复制代码
import { createChunk } from "./creatChunk";
onmessage = async function (e) {
    const { file, start, end, chunkSize } = e.data;
    const result = []
    for (let i = start; i < end; i ++) {
        const chunk = await createChunk(file, i, chunkSize);
        result.push(chunk)
    }
    const chunks = await Promise.all(result)
    postMessage(chunks)
}

creatChunk.js

js 复制代码
import SparkMD5 from "spark-md5";
// 导出一个函数createChunk,用于创建文件块
export const createChunk = (file, index, size) => {
    // 返回一个Promise对象
    return new Promise((resolve) => {
        // 计算文件块的起始位置
        const start = index * size;
        // 计算文件块的结束位置
        const end = start + size
        // 创建一个SparkMD5对象,用于计算文件块的hash值
        const spark = new SparkMD5.ArrayBuffer();
        const fileReader = new FileReader();
        const blob = file.slice(start, end);
        fileReader.onload = (e) => {
            spark.append(e.target.result);
            resolve({
                start,
                end,
                index,
                hash: spark.end(),
                blob
            })
        }
        fileReader.readAsArrayBuffer(blob);
    })
}
  • 对文件分完块,并计算出每个切片的hash后,我们还需要得到整个文件的hash值,这个主要是给文件一个标识,保证文件名是唯一的
  • 那我们还是利用sparkms5这个插件计算hash值

fileHash1.js

js 复制代码
import SparkMD5 from "spark-md5";
export const fileHash1 = (chunk) => {
    return new Promise((resolve) => {
        const spark = new SparkMD5.ArrayBuffer()
        _read(0)
        function _read(i) {
            if (i>= chunk.length) {
                resolve(spark.end())
                return
            }
            const blob = chunk[i]
            spark.append(blob.hash) // 已经得到所有切片了,并且每个切片都有hash值,这里就不用再去获取文件数据,生成hash值,直接用每个切片的hash值进行增量计算,提高效率
                _read(i+1)
        }
    })
}

现在开始搭建node服务器,这里用到了express

文件目录很简单

index.js

js 复制代码
const express = require('express');
const multer = require('multer');
const fs = require('fs');
const path = require('path');
const cors = require('cors');

const app = express();
app.use(cors());
app.use(express.json());
const upload = multer();
app.use(express.urlencoded({ extended: true }));

// 文件上传临时目录
const UPLOAD_DIR = path.resolve(__dirname, 'uploads');


// 分块上传接口
app.post('/upload', upload.single('chunk'), (req, res) => {
  // 检查上传目录是否存在,如果不存在则创建
  if (!fs.existsSync(UPLOAD_DIR)) {
    fs.mkdirSync(UPLOAD_DIR);
  }
  
  // console.log('req.body', req.file)
  const { fileHash, chunkHash, chunkIndex } = req.body;
  const chunk = req.file;
  
  // 检查参数是否正确
  if (!chunk || !fileHash || !chunkHash || chunkIndex === undefined) {
    return res.status(400).send({ code: 1, message: '参数错误' });
  }
  
  // 创建分块目录
  const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
  if (!fs.existsSync(chunkDir)) {
    fs.mkdirSync(chunkDir);
  }
  
  // 保存分块文件
  const chunkPath = path.resolve(chunkDir, `${chunkIndex}-${chunkHash}`);
  
  fs.writeFileSync(chunkPath, chunk.buffer);
  res.send({ code: 0, message: '上传成功' });
});

// 合并文件接口
app.post('/merge', (req, res) => {
  const { fileHash, fileName, chunkCount } = req.body;
  // 检查参数是否正确
  if (!fileHash || !fileName || !chunkCount) {
    return res.status(400).send({ code: 1, message: '参数错误' });
  }
  
  // 创建分块目录
  const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
  const filePath = path.resolve(UPLOAD_DIR, fileName);
  
  // 检查所有分块是否已上传
  for (let i = 0; i < chunkCount; i++) {
    const chunkPath = path.resolve(chunkDir, `${i}-${req.body.chunks[i].hash}`);
    // console.log(chunkPath)
    if (!fs.existsSync(chunkPath)) {
      return res.status(400).send({ code: 1, message: `分块${i}缺失` });
    }
  }
  
  // 合并文件
  const writeStream = fs.createWriteStream(filePath);
  
  for (let i = 0; i < chunkCount; i++) {
    const chunkPath = path.resolve(chunkDir, `${i}-${req.body.chunks[i].hash}`);
    const chunk = fs.readFileSync(chunkPath);
    writeStream.write(chunk);
    fs.unlinkSync(chunkPath);
  }
  
  writeStream.end();
  fs.rmdirSync(chunkDir);
  
  res.send({ code: 0, message: '合并成功' });
});

// 校验文件接口
app.post('/verify', (req, res) => {
  const { fileHash, fileName } = req.body;
  
  // 检查参数是否正确
  if (!fileHash || !fileName) {
    return res.status(400).send({ code: 1, message: '参数错误' });
  }
  
  const filePath = path.resolve(UPLOAD_DIR, fileName);
  
  // 检查文件是否已存在
  if (fs.existsSync(filePath)) {
    return res.send({ 
      code: 0, 
      message: '文件已存在', 
      data: { shouldUpload: false }
    });
  }
  
  // 检查已上传的分块
  const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
  const uploadedChunks = [];
  
  if (fs.existsSync(chunkDir)) {
    uploadedChunks.push(...fs.readdirSync(chunkDir)); // 将文件夹中的chunk添加到数组中
  }
  
  res.send({ 
    code: 0, 
    message: '校验成功', 
    data: { 
      shouldUpload: true, 
      uploadedChunks: uploadedChunks.map(c => parseInt(c.split('-')[0])) // 返回已经上传过的chunks的索引
    }
  });
});


const PORT = 12306;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

客户端实现上传

js 复制代码
<script setup>
import { ref } from 'vue'
import { cutFile, chunkSize} from './utils'
import { fileHash1 } from './utils/fileHash'
import axios from 'axios'

// const upload = ref()
const file1 = ref() // 文件
const progressPercent = ref(0) // 进度百分比
const progressStatus = ref('') // 进度状态

const uploadedChunks = ref([]) // 已上传的块
const uploadCount = ref(0) // 已上传的块数量
let total = 0 // 总块数量
// 开始上传
const startUpload = async () => {
  const file = file1.value
  let chunks = await cutFile(file, chunkSize) // 切割文件
  const fileHash = await fileHash1(chunks) // 计算文件哈希值
  total = chunks.length

  // 检查文件是否已上传或部分上传
  // 发送post请求到http://localhost:12306/verify,请求体中包含fileHash和fileName
  const { data: verifyRes } = await axios.post('http://localhost:12306/verify', {
    fileHash,
    fileName: file1.value.name
  });
  
  // 如果verifyRes.data.shouldUpload为false,则设置进度条为100%和success状态,并返回
  if (!verifyRes.data.shouldUpload) {
    progressPercent.value = 100;
    progressStatus.value = 'success';
    return;
  }
  // 如果verifyRes.data.shouldUpload为true,且verifyRes.data.uploadedChunks不为空,则将uploadedChunks.value设置为verifyRes.data.uploadedChunks的排序后的数组,uploadCount.value设置为uploadedChunks.value的长度,并打印uploadedChunks.value
  let temp = []
  if (verifyRes.data.shouldUpload && verifyRes.data.uploadedChunks.length) {
    uploadedChunks.value = verifyRes.data.uploadedChunks.sort((a, b) => a - b);
    uploadCount.value = uploadedChunks.value.length
    console.log('uploadedChunks', uploadedChunks.value)
    // 将chunks中不包含uploadedChunks.value的元素过滤出来,并赋值给temp
    temp = chunks.filter((chunk) => !uploadedChunks.value.includes(chunk.index));
  } else {
    // 否则将chunks赋值给temp
    temp = chunks
  }
  console.log('chunks', uploadCount.value, temp)
  // return
  // 上传每个分片
  // 遍历temp数组
  for(let i = 0; i < temp.length; i++) {
    // 获取当前元素的hash值
    const chunkHash = temp[i].hash
    // 获取当前元素的blob值
    const blob = temp[i].blob
    // 创建一个FormData对象
    const formData = new FormData();
    // 向FormData对象中添加fileHash字段
    formData.append('fileHash', fileHash);
    // 向FormData对象中添加chunkHash字段
    formData.append('chunkHash', chunkHash);
    // 向FormData对象中添加chunkIndex字段
    formData.append('chunkIndex', temp[i].index);
    // 向FormData对象中添加chunk字段
    formData.append('chunk', blob);
    // 发送post请求
    try {
      await axios.post('http://localhost:12306/upload', formData);
      // 更新进度百分比
      progressPercent.value = Math.round(((i + 1) / temp.length) * 100);
      // 更新上传数量
      uploadCount.value ++
    } catch (err) {
      // 打印错误信息
      console.log('err', err)
      // 更新进度状态
      progressStatus.value = 'exception';
      // 返回
      return;
    }
  }
  console.log('chunks', temp.length, uploadCount.value, total)
  if (uploadCount.value === total) {
    // 合并文件
   try {
    await axios.post('http://localhost:12306/merge', {
      fileHash,
      fileName: file1.value.name,
      chunkCount: chunks.length,
      chunks: chunks,
    });
    progressStatus.value = 'success';
  } catch (error) {
    progressStatus.value = 'exception';
    console.error(error);
  }
  }
   
}


// 文件改变时触发
const handleFileChange = (uploadFile) => {
  file1.value = uploadFile.raw;
  console.log('file1', file1.value)
  progressPercent.value = 0;
};
</script>

效果

  • 当文件在后端不存在时

调用校验接口,接口返回

  • 如果需要上传则将切片一个个上传到服务器
  • 当所有切片的数量等于上传的数量时
  • 此时需要调用合并文件接口,告诉后端文件上传完了,可以合并文件了

此时服务器会多一个文件出来

  • 当文件只上传了一半(上传一半刷新页面或者断网)

此时的服务器存的是部分切片数据

  • 当我们再次上传时,调用校验接口

校验接口会返回已经上传了的分片的下坐标

此时前端会做处理,需要将还没上传的切片继续上传

全部上传完成后对文件进行合并

gitee地址

相关推荐
求知若渴,虚心若愚。2 小时前
Error reading config file (/home/ansible.cfg): ‘ACTION_WARNINGS(default) = True
linux·前端·ansible
GSDjisidi2 小时前
日本IT就职面试|仪容&礼仪篇分享建议
面试·职场和发展
LinDaiuuj2 小时前
最新的前端技术和趋势(2025)
前端
一只小风华~3 小时前
JavaScript 函数
开发语言·前端·javascript·ecmascript·web
程序猿阿伟4 小时前
《不只是接口:GraphQL与RESTful的本质差异》
前端·restful·graphql
若梦plus5 小时前
Nuxt.js基础与进阶
前端·vue.js
樱花开了几轉5 小时前
React中为甚么强调props的不可变性
前端·javascript·react.js
风清云淡_A5 小时前
【REACT18.x】CRA+TS+ANTD5.X实现useImperativeHandle让父组件修改子组件的数据
前端·react.js
小飞大王6665 小时前
React与Rudex的合奏
前端·react.js·前端框架
若梦plus6 小时前
React之react-dom中的dom-server与dom-client
前端·react.js