手摸手带你实现大文件上传

2,733 阅读5分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第4天,点击查看活动详情

首先,我们为什么要实现大文件分片上传呢?直接上传不是更方便吗?对于小文件来说,比如图片这种,直接上传确实可以,但是如果是是几百兆,甚至几个g的文件用一个请求上传,那浏览器会直接卡死,用户不能操作,这种体验是非常不好的。

注意

注意

注意

本文代码已上传到GitHub,点击前往查看

欢迎各位大佬下载调试

分片的实现

其实分片的原理很简单,在File对象的原型上有个slice方法专门用来分片。 首先,我们先通过input的change事件获取上传的文件

<template>
  <div style="display: flex; justify-content: space-between">
    <Input id="file" type="file" @change="onChange" style="width: 300px" />
    <Button @click="handleUpload" type="primary">点击上传</Button>
  </div>
</template>

<script setup>
import { message, Button, Input } from "ant-design-vue";
import { ref } from "vue";
import axios from "axios";

// 切片大小
const SIZE = 10 * 1024 * 1024;
// 当前选中的源文件
const currentFile = ref();
// 文件切分之后的chunk数组
const fileChunkData = ref([]);

const onChange = (e) => {
  const [file] = e.target.files;
  // 获取当前上传的文件
  currentFile.value = file;
};
</script>

然后在点击上传之前,我们先把文件分片处理。这里分片操作主要在createFileChunk函数里,其实就是用到了slice方法。

// 点击上传按钮
const handleUpload = async () => {
  // 分片
  const fileChunkList = createFileChunk(currentFile.value, SIZE);
  fileChunkData.value = fileChunkList.map((fileChunk, index) => {
    const { file } = fileChunk;
    return {
      chunk: file,
      hash: hashRes.hash + "-" + index,
    };
  });
  // 分片上传
  uploadChunk(
    fileChunkData.value,
    hashRes.hash,
    currentFile.value.name,
    startIndex.value
  );
};

// 文件分片
const createFileChunk = (file, size) => {
  const fileChunkList = [];
  let current = 0;
  while (current < file.size) {
    fileChunkList.push({ file: file.slice(current, current + size) });
    current += size;
  }
  return fileChunkList;
};

// 上传文件
const uploadChunk = async (fileChunkList, hash, filename, startIndex = 0) => {
  let flag = true;
  for (let i = startIndex; i < fileChunkList.length; i++) {
    let { chunk, hash } = fileChunkList[i];
    // 发起请求
    flag = await uploadChunkRequest(chunk, hash, i);
  }
  if (flag) {
    // 全部分片上传完成,发送请求告诉服务器合并
    mergeChunk(hash, filename);
  }
};

这里我们我们使用formdata上传分片

// 上传文件请求
const uploadChunkRequest = async (chunk, hash, i) => {
  // 用formData上床文件
  const formData = new FormData();
  formData.append("chunk", chunk);
  formData.append("hash", hash);
  formData.append("filename", currentFile.value.name);
  let res = await axios.post("/api/upload", formData);
  if (res.data.code === 0) {
    message.error(`${res.data.hash} 上传失败`);
    return false;
  }
  return true;
};

好了,一个大文件上传的前端就实现了。我们看一下如何用express来简单搭建一个服务

Expresss服务搭建

为什么用express呢?因为express内置了很多模块,对于新手很友好,并且社区也很活跃。

const express = require('express')
const bodyParser = require('body-parser')
const multiparty = require('multiparty')
const path = require('path')
const fs = require('fs-extra')

const streamMerge = require('./utils/streamMerge')

const app = express()
const port = 3000

app.use(bodyParser())
app.use(express.static('./file'))

app.listen(port, () => {
  console.log(`服务在端口${port}起飞!!!`)
})

我们用nodemon来启动项目,因为nodemon可以实时监听文件的修改并自己重新启动服务,很方便。

image.png 看到服务在端口3000起飞就代表服务启动成功了。 好了,我们来写上传分片接口吧。

upload.png 这里我用到了multipart模块,可以快速的解析上传formdata数据请求,还可以指定文件的存储路径,当你上传文件时会自动保存到自定义路径,就很方便。

还有一个是fs-extra模块,这个模块实现了node自带fs文件系统没有的方法,比如rename方法,传入原始路径和目标路径就可以修改文件名,甚至还可以实现文件移动的功能。

分片合并

在很理想的情况下,我们在上传完全部的分片之后,其实是需要把所有的文件进行合并的。

在所有分片上传完成之后,发送merge请求

merge.png

后端接收到请求

mergeapi.png

注意: 这里有个文件流的传输,可以减少服务器的内存压力,不要让所有的文件一次性加入到内存中。我搜了很多实现方法,然后封装了一个工具函数streamMerge函数。

这里用到了Promise实现了同步分片合并。

文件流的合并其实就是从一个可读的流通过管道往可写入的流中进行传输,达到合并的目的。

streammerge.png

好了,至此一个简单的大文件前后端就实现了。

但是我们可以做很多的优化。比如,显示上传文件的进度。。。

文件上传进度

这里我们用到了progress组件来显示文件上传进度,但是我们怎么获取文件上传的进度呢?

我们通过翻阅axios的官网文档可以知道,在上传请求时,我们可以通过配置 onUploadProgress 方法来达到实时监听文件上传进度。axios监听文件上传进度,点击查看

这里我们截取一段代码 progress.png

秒传、断点续传

秒传其实就是在用户重复上传一个文件之后,我们不再上传分片,而是直接提示用户上传成功。这个要怎么实现呢? 还有如果用户在上传的时候,突然网络断开了,再次上传的时候可不可以从上次上传的位置开始上传呢?

**首先,我们怎么知道用户上传的是同一个文件呢? **

有个技术是获取文件的内容,然后用md5进行加密,最后得到一个唯一的hash值。已经有很多库实现了这个功能,我们这里选用spark-md5模块来实现。 点击前往spark-MD5文档

我们只需要在上传之前获取到文件的hash值,然后传给后端,让后端校验这个hash值是否已经上传过。

gethash.png

这里封装了一个getHash的方法,其中内部的实现也是复制spark-MD5官网的,只不过做了简单的修改。

  • 传入callback函数,用来获取文件hash值的进度
  • 用promise控制异步

sparkmd5.png

后端实现

checkfile.png

我们先获取上传的文件目录,然后遍历结果,看看是否是上传完整,还是只上传一部分。 如果是上传过,直接返回上传完成,如果是只上传一部分,则返回最后一个分片的索引。前端根据后端的索引就可以从下一个分片继续上传,大大减少了意外的发生。

暂停上传,继续上传

有时,用户想要暂停上传,然后等会再继续上传呢? 这个功能其实很简单,我们只需要获取到当前发送的请求,把当前的请求取消,然后取消后面的上传就可以了。继续上传的时候只需要从暂停的请求继续发就行了。

但是,我们怎么取消axios消请求呢?

我们再次翻阅axios官网,发现了AbortController这个对象。

使用起来很简单。 image.png

我们在代码里加一下。


 .......

 // 创建取消请求的controller
  const controller = new AbortController();
  currentController.value = {
    controller,
    hash,
  };
   let res = await axios.post("/api/upload", formData, {
    onUploadProgress: function () {},
    signal: controller.signal,
  });
  
  
  .......
  
  
  

stop.png

好了,完整的大文件上传就完成了。