Vue3 + Express 实现大文件分片上传、断点续传、秒传

在日常的网页开发中,文件上传是一项常见操作。通过文件上传技术,用户可以将本地文件方便地传输到Web服务器上。这种功能在许多场景下都是必不可少的,比如上传文件到网盘或上传用户头像等。

然而,当需要上传大型文件时,可能会遇到以下问题:

  1. 长时间上传:由于文件大小较大,上传过程可能会耗费较长时间。

  2. 上传中断重新上传:如果在上传过程中出现意外情况导致上传中断,用户需要重新开始整个上传过程,这会增加用户的不便。

  3. 服务端限制:通常,服务端会对上传的文件大小进行限制,这可能导致无法上传大型文件。

为了解决这些问题,可以采用分片上传的方式:

分片上传即将大文件分割成小块,然后分块上传到服务器。通过分片上传,可以实现以下优势:

  • 快速上传:由于每个小块的大小相对较小,上传时间大大缩短。

  • 断点续传:如果上传过程中出现中断,只需重新上传中断的部分,而不需要重新上传整个文件,提高了用户体验。

  • 避免大小限制:分片上传可以避免由于文件大小限制而无法上传大文件的问题。

通过采用分片上传技术,可以提升用户体验,加快大文件上传速度,并确保上传过程的稳定性和可靠性。

原理:

  • 分片上传的概念类似于将一个大文件分割成多个小块,然后分别上传这些小块到服务器上。
  • 首先,将待上传的大文件划分为固定大小的小块,比如每块大小为1MB。然后逐个上传这些小块到服务器。在上传过程中,可以同时处理多个小块的上传,也可以按顺序逐一上传小块。每个小块上传完成后,服务器会妥善保存这些小块,并记录它们的顺序和位置信息。
  • 当所有小块都上传完成后,服务器会按照预先记录的顺序和位置信息,将这些小块组合成完整的大文件。最终,整个大文件就成功地被分片上传并合并完成了。这种分片上传的方式能够有效地提升大文件上传的效率和稳定性,确保文件上传过程更加可靠和高效。

 实现:

1.项目搭建
  1. 要实现大文件上传,还需要后端的支持,所以我们就用nodejs来写后端代码。

  2. 前端:vue3 + vite

  3. 后端:express 框架,用到的工具包:nodemon 、cors 、connect-multiparty 、fs-extra 、body-parser

2.获取文件

 通过监听 input 标签的 change 事件,当选择本地文件后,可以在回调函数中获取对应的文件。(也可以使用第三方UI库的上传组件获取到对应的文件)

<script>
// 绑定上传事件
const handleUpload = async (e: Event) => {
  const file = (e.target as HTMLInputElement).files?.[0];
  if (!file) return;
};
</script>

<template>
  <h1>大文件上传</h1>
  <input type="file" @change="handleUpload" />
</template>
3.文件分片

文件分片的核心是用Blob对象的slice方法,我们在上一步获取到选择的文件是一个File对象,它是继承于Blob,所以我们就可以用slice方法对文件进行分片。

// 绑定上传事件
const handleUpload = async (e: Event) => {
  ...
+  // 创建文件分片
+  const chunks = createChunks(file);
};


const CHUNK_SIZE = 1024 * 1024;  // 1MB
// 创建文件分片
const createChunks = (file: File) => {
  let start = 0;
  const chunks = [];
  while (start < file.size) {
    chunks.push(file.slice(start, start + CHUNK_SIZE));
    start += CHUNK_SIZE;
  }
  return chunks;
};
 4.hash计算
  • 在向服务器上传文件时,需要有效地区分不同的文件,而仅仅依靠文件名是不够可靠的。因为文件名可以随意修改,无法作为唯一标识来区分不同的文件。相比之下,通过文件内容生成唯一的哈希值是一种更可靠的方法。

  • 哈希值是根据文件内容计算得出的固定长度的唯一标识。当文件内容发生改变时,对应的哈希值也会发生变化。因此,可以利用文件内容生成的哈希值来区分不同的文件,并实现秒传功能。

  • 具体实现方式是,当用户上传文件时,服务器首先计算文件内容对应的哈希值。然后,服务器会检查记录中是否已经存在该哈希值对应的文件。如果存在,则表示相同内容的文件已经上传过,可以直接跳过文件上传的过程,实现秒传的效果。

  • 通过哈希值的方式,不仅可以准确区分不同的文件,还能够提高文件上传的效率,避免重复上传相同内容的文件,为用户提供更便捷的文件上传体验。这种基于哈希值的文件管理方式在实际应用中具有很高的实用性和可靠性。

  • 可以通过一个工具:spark-md5,所以我们得先安装它。

const fileHash = ref(""); // 文件hash

// 绑定上传事件
const handleUpload = async (e: Event) => {
  ...
  // 创建文件分片
  const chunks = createChunks(file);
+  // 计算文件内容hash值
+  fileHash.value = await calculateHash(file);
};


// 计算文件内容hash值
const calculateHash = (file: File): Promise<string> => {
  return new Promise((resolve) => {
    const fileReader = new FileReader();
    fileReader.readAsArrayBuffer(file);
    fileReader.onload = function (e) {
      const spark = new SparkMD5.ArrayBuffer();
      spark.append((e.target as FileReader).result as ArrayBuffer);
      resolve(spark.end());
    };
  });
};
 5.文件上传
前端实现
  • 在上传大文件时,将文件分片上传是一种常见的策略。如果以1GB文件为例,每个分片大小为1MB,总共会有1024个分片。同时发送这么多分片会给浏览器带来过大的负担,因为浏览器默认并发请求数量有限(比如Chrome默认为6个),过多请求不会提升上传速度,反而可能导致性能下降。
  • 为了解决这个问题,可以通过限制前端请求个数来控制分片上传的并发量。比如设定最大并发数为6,即同一时刻允许浏览器最多发送6个请求。当其中一个请求完成并返回结果后,再发送下一个请求,以此类推,直到所有请求都发送完毕。
  • 通过控制并发请求数量,可以有效减轻浏览器的负担,保证上传过程的稳定性和效率。这种方式可以更好地管理大文件的分片上传,避免造成不必要的性能问题,确保上传操作顺利进行。

上传文件时一般还要用到FormData对象,需要将我们要传递的文件还有额外信息放到这个FormData对象里面。

// 绑定上传事件
const handleUpload = async (e: Event) => {
  ...
+  // 上传文件分片
+  uploadChunks(chunks);
};

// 上传文件分片
const uploadChunks = async (
  chunks: Array<Blob>,
) => {
  const formDatas = chunks
    .map((chunk, index) => ({
      fileHash: fileHash.value,
      chunkHash: fileHash.value + "-" + index,
      chunk,
    }))
    .map((item) => {
      const formData = new FormData();
      formData.append("fileHash", item.fileHash);
      formData.append("chunkHash", item.chunkHash);
      formData.append("chunk", item.chunk);
      return formData;
    });

  const taskPool = formDatas.map(
    (formData) => () =>
      fetch("http://localhost:3000/upload", {
        method: "POST",
        body: formData,
      })
  );

  // 控制请求并发
  await concurRequest(taskPool, 6);
};


// 控制请求并发
const concurRequest = (
  taskPool: Array<() => Promise<Response>>,
  max: number
): Promise<Array<Response | unknown>> => {
  return new Promise((resolve) => {
    if (taskPool.length === 0) {
      resolve([]);
      return;
    }

    const results: Array<Response | unknown> = [];
    let index = 0;
    let count = 0;

    const request = async () => {
      if (index === taskPool.length) return;
      const i = index;
      const task = taskPool[index];
      index++;
      try {
        results[i] = await task();
      } catch (err) {
        results[i] = err;
      } finally {
        count++;
        if (count === taskPool.length) {
          resolve(results);
        }
        request();
      }
    };

    const times = Math.min(max, taskPool.length);
    for (let i = 0; i < times; i++) {
      request();
    }
  });
};
后端实现
  • 在后端处理文件时,通常需要使用到connect-multiparty这样的工具来实现文件上传功能。在处理每个上传的文件分片时,首先需要将这些分片临时存储到服务器的某个位置,以便在合并文件时进行读取操作。为了区分不同文件的分片,可以利用文件对应的哈希值作为文件夹的名称,在这个文件夹中存放该文件的所有分片。

  • 这种做法可以有效地管理上传的文件分片,保证文件的完整性和正确性。通过以哈希值作为文件夹名称的方式,可以确保不同文件的分片被正确地归类和存储,为后续的文件合并操作提供便利。这样的文件管理策略有助于提高文件处理的效率和可靠性,确保文件上传过程顺利进行。

const multipart = require("connect-multiparty");
const multipartMiddleware = multipart();

// 所有上传的文件存放在该目录下
const UPLOADS_DIR = path.resolve("uploads");

/**
 * 上传
 */
app.post("/upload", multipartMiddleware, (req, res) => {
  const { fileHash, chunkHash } = req.body;

  // 如果临时文件夹(用于保存分片)不存在,则创建
  const chunkDir = path.resolve(UPLOADS_DIR, fileHash);
  if (!fse.existsSync(chunkDir)) {
    fse.mkdirSync(chunkDir);
  }

  // 如果临时文件夹里不存在该分片,则将用户上传的分片移到临时文件夹里
  const chunkPath = path.resolve(chunkDir, chunkHash);
  if (!fse.existsSync(chunkPath)) {
    fse.moveSync(req.files.chunk.path, chunkPath);
  }

  res.send({
    success: true,
    msg: "上传成功",
  });
});
  • 写完前后端代码后就可以来试下看看文件能不能实切片的上传,如果没有错误的话,服务器的uploads文件夹下应该就会多一个文件夹,这个文件夹里面就是存储的所有文件的分片了。
6.文件合并 

在完成所有文件分片的上传后,接下来需要将这些分片按顺序合并成一个完整的文件。

前端实现
+  const fileName = ref(""); // 文件名称
  // 绑定上传事件
  const handleUpload = async (e: Event) => {
    const file = (e.target as HTMLInputElement).files?.[0];
    if (!file) return;
+    fileName.value = file.name;
    ...
  };

// 上传文件分片
const uploadChunks = async (
  chunks: Array<Blob>,
) => {
  ...
+  // 合并分片请求
+  mergeRequest();
};

// 合并分片请求
const mergeRequest = () => {
  fetch("http://localhost:3000/merge", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      fileHash: fileHash.value,
      fileName: fileName.value,
    }),
  });
};
后端实现
  • 在之前的步骤中,我们已经成功将所有文件切片上传到服务器并保存在相应的目录中。现在,在进行合并操作时,我们需要从相应的文件夹中获取所有的文件切片,并利用文件读写操作来实现文件的合并。通过逐一读取每个文件切片的内容,并按正确的顺序将它们写入到一个新文件中,从而完成文件的合并过程。

  • 合并完成后,我们可以将生成的文件以其哈希值命名,并将其存储到相应的位置。这样就能够清晰地标识和管理合并后的完整文件,确保文件的完整性和可靠性。这个过程可以有效地整合所有文件切片,生成完整的文件,并将其妥善保存在服务器上的指定位置。

/**
 * 合并
 */
app.post("/merge", async (req, res) => {
  const { fileHash, fileName } = req.body;

  // 最终合并的文件路径
  const filePath = path.resolve(UPLOADS_DIR, fileHash + path.extname(fileName));
  // 临时文件夹路径
  const chunkDir = path.resolve(UPLOADS_DIR, fileHash);

  // 读取临时文件夹,获取该文件夹下“所有文件(分片)名称”的数组对象
  const chunkPaths = fse.readdirSync(chunkDir);

  // 读取临时文件夹获得的文件(分片)名称数组可能乱序,需要重新排序
  chunkPaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]);

  // 遍历文件(分片)数组,将分片追加到文件中
  const pool = chunkPaths.map(
    (chunkName) =>
      new Promise((resolve) => {
        const chunkPath = path.resolve(chunkDir, chunkName);
        // 将分片追加到文件中
        fse.appendFileSync(filePath, fse.readFileSync(chunkPath));
        // 删除分片
        fse.unlinkSync(chunkPath);
        resolve();
      })
  );
  await Promise.all(pool);
  // 等待所有分片追加到文件后,删除临时文件夹
  fse.removeSync(chunkDir);

  res.send({
    success: true,
    msg: "合并成功",
  });
});
  • 到目前为止,我们已经成功实现了大文件的分片上传功能。然而,我们还需要考虑到以下两个问题:首先是如果用户尝试上传相同的文件,其次是在上传过程中网络断开导致需要重新上传所有分片的情况。
  • 问题
    1. 针对第一个问题,我们需要实现文件的重复检测功能,以确保不会重复上传相同的文件。
    2. 第二个问题,我们可以引入断点续传的机制,即使在网络中断的情况下,也能够从断点处继续上传,而不需要重新上传所有的分片。

下面,我们就来解决下这两个问题。

7.秒传&断点续传
前端实现

前端在上传之前,需要将对应文件的hash值告诉服务器,看看服务器上有没有对应的这个文件,如果有,就直接返回,不执行上传分片的操作了。

// 校验文件、文件分片是否存在
const verify = (fileHash: string, fileName: string) => {
  return fetch("http://localhost:3000/verify", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      fileHash,
      fileName,
    }),
  }).then((res) => res.json());
};


// 绑定上传事件
const handleUpload = async (e: Event) => {
  ...
  // 计算文件内容hash值
  fileHash.value = await calculateHash(file);
+  // 校验文件、文件分片是否存在
+  const verifyRes = await verify(fileHash.value, fileName.value);
+  const { existFile, existChunks } = verifyRes.data;
+  if (existFile) return;
  ...
};
后端实现

因为我们在合并文件时,文件名是根据该文件的hash值命名的,所以只需要看看服务器上有没有对应的这个hash值的文件就可以判断了。

/**
 * 校验
 */
app.post("/verify", (req, res) => {
  const { fileHash, fileName } = req.body;

  // 判断服务器上是否存在该hash值的文件
  const filePath = path.resolve(UPLOADS_DIR, fileHash + path.extname(fileName));
  const existFile = fse.existsSync(filePath);

  res.send({
    success: true,
    msg: "校验文件",
    data: {
      existFile,
    },
  });
});
  • 解决了重复上传文件的问题之后,我们发现即使更改文件名,再次上传相同内容的文件时,服务器会提示秒传成功,因为服务器已经存在对应的文件。尽管我们已解决了重复上传的问题,但仍需解决网络中断需要重新上传的情况。

  • 在面对网络中断的情况时,我们可以通过检测已上传的文件分片,只上传尚未成功上传的分片,避免重复上传已经传输完成的部分。通过这种方式,我们可以避免重复上传整个文件,而只需上传未成功传输的部分分片,从而提高了大文件上传的效率。

  • 通过这一改进,我们能够更好地处理网络不稳定导致的上传中断情况,减少重复传输已完成部分的分片,从而提高了大文件上传的可靠性和效率。

前端实现

我们还是在那个/verify的接口中去获取已经上传成功的分片,然后在上传分片前进行一个过滤。

// 绑定上传事件
const handleUpload = async (e: Event) => {
  ...
  // 上传文件分片
-  uploadChunks(chunks);
+  uploadChunks(chunks, existChunks);
};


// 上传文件分片
const uploadChunks = async (
  ...
+  existChunks: Array<string>
) => {
  const formDatas = chunks
    .map((chunk, index) => ({
      fileHash: fileHash.value,
      chunkHash: fileHash.value + "-" + index,
      chunk,
    }))
+    .filter((item) => !existChunks.includes(item.chunkHash))
    .map((item) => {
      const formData = new FormData();
      formData.append("fileHash", item.fileHash);
      formData.append("chunkHash", item.chunkHash);
      formData.append("chunk", item.chunk);
      return formData;
    });
  ...
};
后端实现 

只需要在/verify这个接口中加上已经上传成功的所有切片的名称就可以,因为所有的切片都存放在以文件的hash值命名的那个文件夹,所以需要读取这个文件夹中所有的切片的名称就可以。

/**
 * 校验
 */
app.post("/verify", (req, res) => {
  const { fileHash, fileName } = req.body;

  // 判断服务器上是否存在该hash值的文件
  const filePath = path.resolve(UPLOADS_DIR, fileHash + path.extname(fileName));
  const existFile = fse.existsSync(filePath);
  
+  // 获取已经上传到服务器的文件分片
+  const chunkDir = path.resolve(UPLOADS_DIR, fileHash);
+  const existChunks = [];
+  if (fse.existsSync(chunkDir)) {
+    existChunks.push(...fse.readdirSync(chunkDir));
+  }
  res.send({
    success: true,
    msg: "校验文件",
    data: {
      existFile,
+      existChunks,
    },
  });
});
8.完整代码
前端代码
<script setup lang="ts">
import { ref } from "vue";
import SparkMD5 from "spark-md5";

const CHUNK_SIZE = 1024 * 1024; // 1MB
const fileName = ref(""); // 文件名称
const fileHash = ref(""); // 文件hash

// 创建文件分片
const createChunks = (file: File) => {
  let start = 0;
  const chunks = [];
  while (start < file.size) {
    chunks.push(file.slice(start, start + CHUNK_SIZE));
    start += CHUNK_SIZE;
  }
  return chunks;
};

// 计算文件内容hash值
const calculateHash = (file: File): Promise<string> => {
  return new Promise((resolve) => {
    const fileReader = new FileReader();
    fileReader.readAsArrayBuffer(file);
    fileReader.onload = function (e) {
      const spark = new SparkMD5.ArrayBuffer();
      spark.append((e.target as FileReader).result as ArrayBuffer);
      resolve(spark.end());
    };
  });
};

// 控制请求并发
const concurRequest = (
  taskPool: Array<() => Promise<Response>>,
  max: number
): Promise<Array<Response | unknown>> => {
  return new Promise((resolve) => {
    if (taskPool.length === 0) {
      resolve([]);
      return;
    }

    const results: Array<Response | unknown> = [];
    let index = 0;
    let count = 0;

    const request = async () => {
      if (index === taskPool.length) return;
      const i = index;
      const task = taskPool[index];
      index++;
      try {
        results[i] = await task();
      } catch (err) {
        results[i] = err;
      } finally {
        count++;
        if (count === taskPool.length) {
          resolve(results);
        }
        request();
      }
    };

    const times = Math.min(max, taskPool.length);
    for (let i = 0; i < times; i++) {
      request();
    }
  });
};

// 合并分片请求
const mergeRequest = () => {
  fetch("http://localhost:3000/merge", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      fileHash: fileHash.value,
      fileName: fileName.value,
    }),
  });
};

// 上传文件分片
const uploadChunks = async (
  chunks: Array<Blob>,
  existChunks: Array<string>
) => {
  const formDatas = chunks
    .map((chunk, index) => ({
      fileHash: fileHash.value,
      chunkHash: fileHash.value + "-" + index,
      chunk,
    }))
    .filter((item) => !existChunks.includes(item.chunkHash))
    .map((item) => {
      const formData = new FormData();
      formData.append("fileHash", item.fileHash);
      formData.append("chunkHash", item.chunkHash);
      formData.append("chunk", item.chunk);
      return formData;
    });

  const taskPool = formDatas.map(
    (formData) => () =>
      fetch("http://localhost:3000/upload", {
        method: "POST",
        body: formData,
      })
  );

  // 控制请求并发
  await concurRequest(taskPool, 6);

  // 合并分片请求
  mergeRequest();
};

// 校验文件、文件分片是否存在
const verify = (fileHash: string, fileName: string) => {
  return fetch("http://localhost:3000/verify", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      fileHash,
      fileName,
    }),
  }).then((res) => res.json());
};

// 绑定上传事件
const handleUpload = async (e: Event) => {
  const file = (e.target as HTMLInputElement).files?.[0];
  if (!file) return;

  fileName.value = file.name;

  // 创建文件分片
  const chunks = createChunks(file);

  // 计算文件内容hash值
  fileHash.value = await calculateHash(file);

  // 校验文件、文件分片是否存在
  const verifyRes = await verify(fileHash.value, fileName.value);
  const { existFile, existChunks } = verifyRes.data;
  if (existFile) return;

  // 上传文件分片
  uploadChunks(chunks, existChunks);
};
</script>

<template>
  <h1>大文件上传</h1>
  <input type="file" @change="handleUpload" />
</template>
后端代码
const express = require("express");
const cors = require("cors");
const bodyParser = require("body-parser");
const fse = require("fs-extra");
const path = require("path");
const multipart = require("connect-multiparty");
const multipartMiddleware = multipart();

const app = express();

app.use(cors());
app.use(bodyParser.json());

// 所有上传的文件存放在该目录下
const UPLOADS_DIR = path.resolve("uploads");

/**
 * 上传
 */
app.post("/upload", multipartMiddleware, (req, res) => {
  const { fileHash, chunkHash } = req.body;

  // 如果临时文件夹(用于保存分片)不存在,则创建
  const chunkDir = path.resolve(UPLOADS_DIR, fileHash);
  if (!fse.existsSync(chunkDir)) {
    fse.mkdirSync(chunkDir);
  }

  // 如果临时文件夹里不存在该分片,则将用户上传的分片移到临时文件夹里
  const chunkPath = path.resolve(chunkDir, chunkHash);
  if (!fse.existsSync(chunkPath)) {
    fse.moveSync(req.files.chunk.path, chunkPath);
  }

  res.send({
    success: true,
    msg: "上传成功",
  });
});

/**
 * 合并
 */
app.post("/merge", async (req, res) => {
  const { fileHash, fileName } = req.body;

  // 最终合并的文件路径
  const filePath = path.resolve(UPLOADS_DIR, fileHash + path.extname(fileName));
  // 临时文件夹路径
  const chunkDir = path.resolve(UPLOADS_DIR, fileHash);

  // 读取临时文件夹,获取该文件夹下“所有文件(分片)名称”的数组对象
  const chunkPaths = fse.readdirSync(chunkDir);

  // 读取临时文件夹获得的文件(分片)名称数组可能乱序,需要重新排序
  chunkPaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]);

  // 遍历文件(分片)数组,将分片追加到文件中
  const pool = chunkPaths.map(
    (chunkName) =>
      new Promise((resolve) => {
        const chunkPath = path.resolve(chunkDir, chunkName);
        // 将分片追加到文件中
        fse.appendFileSync(filePath, fse.readFileSync(chunkPath));
        // 删除分片
        fse.unlinkSync(chunkPath);
        resolve();
      })
  );
  await Promise.all(pool);
  // 等待所有分片追加到文件后,删除临时文件夹
  fse.removeSync(chunkDir);

  res.send({
    success: true,
    msg: "合并成功",
  });
});

/**
 * 校验
 */
app.post("/verify", (req, res) => {
  const { fileHash, fileName } = req.body;

  // 判断服务器上是否存在该hash值的文件
  const filePath = path.resolve(UPLOADS_DIR, fileHash + path.extname(fileName));
  const existFile = fse.existsSync(filePath);

  // 获取已经上传到服务器的文件分片
  const chunkDir = path.resolve(UPLOADS_DIR, fileHash);
  const existChunks = [];
  if (fse.existsSync(chunkDir)) {
    existChunks.push(...fse.readdirSync(chunkDir));
  }

  res.send({
    success: true,
    msg: "校验文件",
    data: {
      existFile,
      existChunks,
    },
  });
});

const server = app.listen(3000, () => {
  console.log(`Example app listening on port ${server.address().port}`);
});

  • 23
    点赞
  • 41
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值