大文件上传实现指南 秒传 暂停上传+续传 vue3+node

大文件上传实现指南

在 Web 应用程序中,处理大文件上传是一个常见的挑战。本指南将介绍如何使用 Node.js 和 Vue.js 实现大文件上传,包括前端和后端的代码示例。

前端实现

在前端,我们使用 Vue.js 来构建用户界面,并使用axios库来处理 HTTP 请求。以下是关键步骤和代码示例:

首先

  1. 用户界面:提供一个文件选择框和几个按钮来控制上传、播放、暂停和继续操作。
  2. 文件选择 :使用<input type="file" @input="fileChange" />来选择文件,并在fileChange方法中处理文件。
  3. 初始化上传 :在fileChange方法中,创建一个UploadController实例并调用init方法来初始化上传。
  4. 播放文件 :点击 "播放" 按钮时,调用getFile方法,通过axios获取文件的 Blob 数据,并创建一个 URL 来播放视频。
  5. 暂停和继续 :使用stopupdate方法来控制上传的暂停和继续。
js 复制代码
<template>
  <div class="app">
    <div class="bo1x">
      <input type="file" @input="fileChange" />
    </div>
    <button @click="getFile">播放</button>
    <button @click="stop">暂停</button>
    <button @click="update">继续</button>
    <video autoplay v-if="url" controls width="578" height="150">
      <source :src="url" type="video/mp4" />
    </video>
    <div style="width: 500px;">
      <div class="process"></div>
    </div>
  </div>
</template>

<script setup>
  import { UploadController, RequestStrategy } from '../api/chunks.js'
  import { ref, onMounted, watchEffect } from 'vue'
  import axios from 'axios'
  const url = ref('')
  const videoRef = ref(null)
  let token = ref("")
  let abc = ref({})
  let uploadController = ref({})

  // 选择上传文件 
  const fileChange = async (e) => {
    let requestStrategy = new RequestStrategy(e.target.files[0].size)
    let result = new UploadController(e.target.files[0], requestStrategy)
    uploadController.value = result
    await result.init()
    token.value = result.token
  }


//  播放上传的文件 
  const getFile = async () => {
    const response = await axios({
      url: `/api/files/${token.value}`,
      method: 'GET',
      responseType: 'blob' // 关键配置:设置响应类型为 blob
    });
    const blob = new Blob([response.data], { type: 'video/mp4' });
    // 创建一个url地址 
    url.value = URL.createObjectURL(blob);

  }

  const stop = () => {
    uploadController.value.stop()
  }

  const update = () => {
    uploadController.value.update()
  }


  // 下载 
  // function openDownloadDialog (urlOrBlob, saveName) {
  //   let url;
  //   if (typeof urlOrBlob === "object" && urlOrBlob instanceof Blob) {
  //     url = URL.createObjectURL(urlOrBlob); // 创建blob地址
  //   } else {
  //     url = urlOrBlob;
  //   }
  //   const aLink = document.createElement("a");
  //   aLink.href = url;
  //   aLink.download = saveName || ""; // HTML5新增的属性,指定保存文件名,可以不要后缀,注意,file:///模式下不会生效
  //   let event;
  //   if (window.MouseEvent) {
  //     event = new MouseEvent("click");
  //   } else {
  //     event = document.createEvent("MouseEvents");
  //     event.initMouseEvent(
  //       "click",
  //       true,
  //       false,
  //       window,
  //       0,
  //       0,
  //       0,
  //       0,
  //       0,
  //       false,
  //       false,
  //       false,
  //       false,
  //       0,
  //       null
  //     );
  //   }
  //   aLink.dispatchEvent(event);
  // }
</script>

接着创建一个chunks.js 文件,主要实现了文件分片上传的功能,以下是对文件内容的逐段解释:

ChunkSplitor 类

  • 用途:用于分割文件为多个块。

  • 构造函数参数

    • file:要分割的文件对象。
    • chunkSize:每个块的大小,默认是5MB。
  • 方法

    • createChunks():根据设定的块大小创建文件块。
    • calcHash():使用Web Worker异步计算每个文件块的哈希值。

UploadController 类

  • 用途:控制文件上传过程。

  • 构造函数参数

    • file:待上传的文件对象。
    • requestStrategy:定义了如何与服务器交互的策略。
  • 方法

    • init():初始化上传流程,获取上传令牌并开始计算哈希值。
    • stop():取消当前的上传任务。
    • update():更新上传状态,可以用来重新启动暂停的上传。
    • uploadChunks():遍历所有文件块,检查是否需要上传,并执行上传操作。
    • mergeFile():当所有块上传完成后,向服务器发送请求以合并这些块为原始文件。

RequestStrategy 类

  • 用途:封装了与服务器通信的具体逻辑。

  • 构造函数参数

    • size:整个文件的大小。
  • 方法

    • createFile():向服务器发起创建新文件的请求,并获取上传所需的令牌。
    • patchHash(token, hash, type):通知服务器特定块的哈希值,检查该块是否已经存在于服务器上。
    • uploadChunk(token, chunk):上传单个文件块到服务器。
  1. 导入依赖

    javascript 复制代码
    import axios from 'axios';
    let cancelSource = null;

导入了 axios 库,用于发送 HTTP 请求,并定义了一个 cancelSource 变量,用于取消请求。cancelSource是生成一个取消请求的token令牌,暂停上传的时候会需要用到。

  1. 定义 ChunkSplitor

    ini 复制代码
    export class ChunkSplitor {
      constructor(file, chunkSize = 1024 * 1024 * 5) {
        this.file = file;
        this.fileHash = "";
        this.chunkSize = chunkSize;
        this.chunks = [];
        this.createChunks();
      }
    
      createChunks() {
        for (let i = 0; i < Math.ceil(this.file.size / this.chunkSize); i++) {
          const start = i * this.chunkSize;
          const end = Math.min((i + 1) * this.chunkSize, this.file.size);
          this.chunks.push({
            blob: this.file.slice(start, end),
            end,
            hash: '',
            index: i
          });
        }
      }
    
      async calcHash() {
        let that = this;
        const workerPath = new URL('./worker.js', import.meta.url).href;
        const worker = new Worker(workerPath, { type: 'module' });
        worker.postMessage(that.chunks);
        return new Promise((resolve, reject) => {
          worker.onmessage = function(e) {
            that.chunks = e.data;
            resolve(e.data);
            worker.terminate();
          };
        });
      }
    }

    这个类用于将文件分割成多个块(chunks),并计算每个块的哈希值。构造函数接收一个文件对象和一个可选的块大小参数,默认值为 5MB。createChunks 方法根据文件大小和块大小计算出需要分割的块数,并将每个块的信息存储在 chunks 数组中。calcHash 方法使用 Web Worker 来并行计算每个块的哈希值,并返回一个 Promise,当所有哈希值计算完成后 resolve。

注意:分片数量需根据实际业务场景来定

  1. 对于大量小分片,频繁的哈希计算可能会占用大量的系统资源,尤其是在客户端(如浏览器)中进行时,过多的分片会导致每次上传前都要等待哈希计算完成,从而增加了总的上传时间。

  2. 大量小分片过多会导致大量的HTTP请求,这不仅增加了网络负载,还可能导致浏览器或服务器端设置的最大并发连接数被迅速耗尽。

  3. 大量的小分片最终合并时的复杂度越高,尤其是在确保所有分片按正确顺序排列方面。任何丢失或损坏的分片都可能导致合并失败。

  4. 必须用Promise包一下等切片hash计算完成调用resolve方法, 在切片hash计算完成后再进行上传切片工作 否则会造成上传时切片hash信息计算不完成导致请求失败

  5. 哈希计算(尤其是对大文件或大量数据)属于计算密集型任务,它可能会占用大量的CPU资源和时间。在Web开发环境中,直接在主线程中执行这样的任务会导致页面响应变慢甚至无响应,严重影响用户体验。因此,使用 new Worker 来处理哈希计算是非常重要的。

定义 UploadController

kotlin 复制代码
```
export class UploadController {
  constructor(file, requestStrategy) {
    this.file = file;
    this.requestStrategy = requestStrategy;
    this.splitStrategy = new ChunkSplitor(file);
  }

  async init() {
    this.token = await this.requestStrategy.createFile();
    let result = await this.splitStrategy.calcHash();
    this.uploadChunks();
  }

  stop() {
    //暂停上传  
    cancelSource.cancel('Upload paused by user');
  }

  update() {
  //继续上传 
    this.uploadChunks();
  }

  uploadChunks() {
    cancelSource = axios.CancelToken.source(); // 确保每次都重新请求一个令牌
    this.splitStrategy.chunks.forEach(async (chunk) => {
      try {
       //每次上传前都会比对hash 避免切片不会被重复上传 
        const resp = await this.requestStrategy.patchHash(this.token, chunk.hash, 'chunk');
        if (!resp.hasFile) {
          await this.requestStrategy.uploadChunk(this.token, chunk);
        }
      } catch (error) {
        cancelSource.cancel('Upload paused by user');
        console.error('Error uploading chunk:', error);
      }
    });
    // 合并文件
    // this.mergeFile();
  }

  async mergeFile() {
    try {
      const url = await this.requestStrategy.mergeFile(this.token);
      console.log('File uploaded successfully:', url);
    } catch (error) {
      console.error('Error merging file:', error);
    }
  }
}
```

  

负责管理文件上传的整个过程。构造函数接收一个文件对象和一个请求策略对象。init 方法首先调用请求策略的 createFile 方法获取上传令牌,然后调用 splitStrategycalcHash 方法计算每个块的哈希值,最后调用 uploadChunks 方法开始上传。stop 方法用于取消上传。update 方法用于更新上传状态。uploadChunks 方法遍历每个块,调用请求策略的 patchHash 方法检查块是否已上传,如果未上传则调用 uploadChunk 方法上传块。mergeFile 方法用于合并上传的块。

  1. 定义 RequestStrategy

    ini 复制代码
    export class RequestStrategy {
      constructor(size) {
        this.uploadTotal = 0;
        this.size = size;
        this.processDom = document.querySelector('.process');
        this.uploadChunkArr = [];
      }
    
      createFile() {
        // 发送 GET 请求并处理响应
        return axios.post('/api/upload/create')
         .then(function(response) {
            return response.data.token;
          })
         .catch(function(error) {
            console.error(error);
          });
      }
       // 比对 hash 并计算上传字节数 
      patchHash(token, hash, type) {
        let that = this;
        // 发送 POST 请求
        return axios.post(`/api/upload/hash/${token}`, {
          hash,
          type
        })
         .then(function(response) {
            if (response.data.hasFile && that.uploadChunkArr.indexOf(hash) < 0) {
              that.uploadTotal += response.data.size;
              let process = Math.round((that.uploadTotal) * 100 / that.size);
              that.processDom.style.width = process + '%';
              that.processDom.innerHTML = process + '%';
              that.uploadChunkArr.push(hash);
              if (that.uploadTotal === that.size) {
                that.uploadChunkArr = [];
              }
            }
            return response.data;
          })
         .catch(function(error) {
            console.error(error);
          });
      }
      // 上传切片并计算字节数
      uploadChunk(token, chunk) {
        let that = this;
        const formData = new FormData();
        formData.append('chunk', chunk.blob);
        formData.append('hash', chunk.hash);
        formData.append('index', chunk.index);
        formData.append('end', chunk.end);
    
        let config = {
          cancelToken: cancelSource.token,
        };
        return axios.post(`/api/upload/chunk/${token}`, formData, config)
         .then(function(response) {
            that.uploadTotal += response.data.size;
            let process = Math.round((that.uploadTotal) * 100 / that.size);
            that.processDom.style.width = process + '%';
            that.processDom.innerHTML = process + '%';
            that.uploadChunkArr.push(chunk.hash);
    
            if (that.uploadTotal === that.size) {
              that.uploadChunkArr = [];
            }
          })
         .catch(function(error) {
            console.error(error);
          });
      }
    
      async mergeFile(token) {
        try {
          const url = await axios.post(`/api/upload/merge/${token}`);
          console.log('File uploaded successfully:', url);
          return url;
        } catch (error) {
          console.error('Error merging file:', error);
          throw error;
        }
      }
    }

    这个类定义了上传文件的具体策略,包括创建文件、上传块和合并文件等操作。构造函数接收文件大小,并初始化一些上传相关的状态和 DOM 元素。createFile 方法用于创建一个新的上传文件,并返回一个上传令牌。patchHash 方法用于检查块的哈希值是否已上传,并更新上传进度。uploadChunk 方法用于上传单个块,并更新上传进度。mergeFile 方法用于合并上传的块,并返回合并后的文件 URL。

注意:

  1. 每次暂停需要重新生成cancelToken,否则会报错

  2. 计算上传进度需要服务端返回真实已上传切片字节数,利用 progress 方法监听会导致加入一些hash 或者其他参数的字节数,和文件的总字节数比对计算导致上传进度计算不准确。最终导致上传切片文件的字节数超过实际上传的字节数

在这段代码中,我们定义了一个 calcChunkHash 函数,它使用 SparkMD5 库来计算给定 blob 的哈希值。然后,我们在 self.onmessage 事件处理函数中使用这个函数来处理从主线程接收到的数据,并将处理后的数据发送回主线程。

javascript

javascript 复制代码
import { default as SparkMD5 } from 'spark-md5';

function calcChunkHash(blob) {
  return new Promise(resolve => {
    const spark = new SparkMD5.ArrayBuffer();
    const fileReader = new FileReader();
    fileReader.onload = (e) => {
      spark.append(e.target.result);
      resolve(spark.end());
    };
    fileReader.readAsArrayBuffer(blob);
  });
}

self.onmessage = async function (event) { // 子线程接收主线程传递数据触发的方法
  const data = event.data;
  await Promise.all(data.map(async (chunk) => {
    chunk.hash = await calcChunkHash(chunk.blob);
  }));

  this.postMessage(data); // 向主线程提交请求完成后或处理后的数据 
};

在这段代码中,我们定义了一个 calcChunkHash 函数,它使用 SparkMD5 库来计算给定 blob 的哈希值。然后,我们在 self.onmessage 事件处理函数中使用这个函数来处理从主线程接收到的数据,并将处理后的数据发送回主线程。

总结:

chunks.js 文件实现了一个文件分片上传的功能,通过 ChunkSplitor 类将文件分割成多个块,并计算每个块的哈希值,然后通过 UploadController 类管理上传过程,使用 RequestStrategy 类定义具体的上传策略。

后端实现

在后端,我们使用 node.js和express 创建了一个简单的文件上传和合并服务

导入模块

  • express:用于创建Web服务器。
  • fsfs.promises:用于文件系统操作。
  • crypto:用于生成唯一的标识符。
  • multer:用于处理multipart/form-data,主要用于上传文件。

配置Multer

  • 使用内存存储引擎来保存上传的文件块,以便直接访问原始的Buffer数据,而不是将其写入磁盘。

模拟数据库

  • filesDb:模拟的数据库,用于存储文件信息。
  • chunksDb:数组,用来跟踪已经接收的文件块哈希值。

API路由

  1. 创建文件

    • 路径POST /api/upload/create
    • 功能 :为新文件创建一个唯一标识符(token),并初始化文件信息到filesDb中。
  2. Hash校验

    • 路径POST /api/upload/hash/:token
    • 功能 :检查给定哈希值的文件块是否已存在于服务器上。如果是整个文件的hash,则总是返回hasFile: false;如果是分块且已存在,则返回该分块的信息。
  3. 分片上传

    • 路径POST /api/upload/chunk/:token
    • 功能 :接收客户端发送的文件块,并根据哈希值保存至本地文件系统。同时更新filesDb中的文件块列表。
  4. 分片合并

    • 路径POST /api/upload/merge/:token
    • 功能:模拟合并所有接收到的文件块,实际上只更新状态和生成下载链接。真实环境中应该实现具体的合并逻辑。
  5. 获取文件

    • 路径GET /api/files/:token
    • 功能:通过读取流的方式提供文件下载服务,并设置正确的响应头以支持浏览器播放或下载。
  6. 辅助函数

    • mergeFilesWithStreamspipeStream:帮助函数,分别负责使用流式API合并文件块和管理流之间的管道连接。
ini 复制代码
// 引入 Express 框架
const express = require('express');
// 引入文件系统模块
const fs = require('fs');
// 引入加密模块
const crypto = require('crypto');
// 配置 Multer 存储引擎为内存存储,以便我们可以访问原始的 Buffer 数据
const multer = require('multer');
const storage = multer.memoryStorage();
const upload = multer({ storage: storage });
// 引入路径处理模块
const path = require('path');

// 创建 Express 应用实例
const app = express();
// 解析 JSON 请求体
app.use(express.json());

// 模拟数据库存储分片信息
const filesDb = {};
const chunksDb = [];

// 创建文件
app.post('/api/upload/create', async (req, res) => {
  // 生成一个随机的 UUID 作为文件的 token
  const token = crypto.randomUUID();
  // 在模拟数据库中创建一个新的文件记录,初始状态为 chunks 为空数组,size 为请求体中的 size
  filesDb[token] = { chunks: [], size: req.body.size };
  // 返回 token 给客户端
  res.send({ token });
});

// Hash校验
app.post('/api/upload/hash/:token', async (req, res) => {
  const { token } = req.params;
  const { hash, type } = req.body;
  let response;
  // 根据 type 判断是文件还是分片
  if (type === 'file') {
    // 如果是文件,检查文件是否存在
    response = { hasFile: false };
  } else if (type === 'chunk' && chunksDb.indexOf(hash) >= 0) {
    // 如果是分片,检查分片是否存在,并返回分片的大小
    const stats = await fs.promises.stat(`./chunks/${hash}`);
    response = { hasFile: true, size: stats.size };
  } else {
    // 如果既不是文件也不是分片,返回不存在
    response = { hasFile: false };
  }
  // 返回响应
  res.send(response);
});

// 分片上传
app.post('/api/upload/chunk/:token', upload.single('chunk'), async (req, res) => {
  const { token } = req.params;
  const { hash, index } = req.body;
  // 如果分片不存在,则将分片数据写入文件,并记录分片的 hash
  if (chunksDb.indexOf(hash) < 0) {
    const chunkData = req.file.buffer;
    await fs.promises.writeFile(`./chunks/${hash}`, chunkData);
    const stats = await fs.promises.stat(`./chunks/${hash}`);
    if (stats.size) {
      chunksDb.push(hash)
    }
  }
  // 将分片的 hash 记录到文件的 chunks 数组中
  filesDb[token].chunks[index] = hash;
  // 返回上传成功的消息和分片的大小
  res.send({ msg: 'success', size: req.file.size });
});

// 分片合并
app.post('/api/upload/merge/:token', async (req, res) => {
  const { token } = req.params;
  const { chunks } = filesDb[token];
  // 这里不执行真正的合并,仅更新状态和生成URL
  // 真实环境应实现合并逻辑或流式读取逻辑
  filesDb[token].url = `/files/${token}`;
  // 返回合并后的文件 URL
  res.send({ url: filesDb[token].url });
});

// 获取文件
app.get('/api/files/:token', async (req, res) => {
  const { token } = req.params;
  const { chunks } = filesDb[token];
  let filePath = path.join(__dirname, 'file', token);

  await mergeFilesWithStreams(chunks, filePath)
  const stats = await fs.promises.stat(filePath);
  const head = {
    'Content-Length': stats.size,
    'Content-Type': 'video/mp4' // 根据实际视频格式调整
  };
  res.writeHead(200, head);
  const readStream = fs.createReadStream(filePath);
  readStream.on('end', () => {
    // 手动结束响应
    res.end();
    readStream.destroy();
  });

  readStream.pipe(res);
  // 监听读取流的结束事件
  // 错误处理
  readStream.on('error', (err) => {
    res.status(500).send(err.message);
  });

});

async function mergeFilesWithStreams (filePaths, outputPath) {
  const writeStream = fs.createWriteStream(outputPath);
  for (const file of filePaths) {
    let filePath = path.join(__dirname, 'chunks', file);
    let readStream = fs.createReadStream(filePath);
    await pipeStream(readStream, writeStream);
  }

}

function pipeStream (readStream, writeStream) {
  return new Promise((resolve, reject) => {
    readStream.pipe(writeStream, { end: false });
    readStream.on('end', resolve);
    readStream.on('error', reject);
  });
}

// 启动服务器,监听 3000 端口
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));

实现了一个简单的文件上传和合并服务,其中文件被分成多个分片上传,然后在服务器端合并成一个完整的文件。这个服务可以用于上传大文件,因为它可以避免一次性上传整个文件,而是将文件分成小块上传,这样可以减少内存的使用和提高上传的效率。

最终效果

相关推荐
OpenTiny社区5 分钟前
Node.js技术原理分析系列——Node.js的perf_hooks模块作用和用法
前端·node.js
菲力蒲LY8 分钟前
输入搜索、分组展示选项、下拉选取,全局跳转页,el-select 实现 —— 后端数据处理代码,抛砖引玉展思路
java·前端·mybatis
MickeyCV1 小时前
Nginx学习笔记:常用命令&端口占用报错解决&Nginx核心配置文件解读
前端·nginx
祈澈菇凉2 小时前
webpack和grunt以及gulp有什么不同?
前端·webpack·gulp
zy0101012 小时前
HTML列表,表格和表单
前端·html
初辰ge2 小时前
【p-camera-h5】 一款开箱即用的H5相机插件,支持拍照、录像、动态水印与样式高度定制化。
前端·相机
HugeYLH2 小时前
解决npm问题:错误的代理设置
前端·npm·node.js
六个点3 小时前
DNS与获取页面白屏时间
前端·面试·dns
道不尽世间的沧桑3 小时前
第9篇:插槽(Slots)的使用
前端·javascript·vue.js
bin91533 小时前
DeepSeek 助力 Vue 开发:打造丝滑的滑块(Slider)
前端·javascript·vue.js·前端框架·ecmascript·deepseek