vue3+node解决大文件上传,体验嘎嘎好🤨

1,053 阅读6分钟

来分享一篇技术文章,花费一天的时间,一下午的代码 一上午的文章,大文件上传,看看我的技术方案是否符合你当下的需求。不知道各位的解决方案都是怎么样的。

我这里使用的是 vue3 + express 用什么都无所谓。

源代码地址 帮忙去点下star🌟 感谢!

大文件上传的场景

如果遇到需要上传电影、视频或者特别大的数据之类的需求,那么上传的文件是非常大的,这个时候我们不能说用一个请求就直接将所有的文件传输过去,因为这个大文件上传时间相比较来说是比较长的,存在很多的弊端,假如用户刷新了页面之类的情况,这时候上传又需要重头开始上传,这对用户以及服务器都是不妥的。

首先想几个问题:

  1. 怎么判断文件是否上传过了,再上传就重复了?
    • 有人会说 我通过判断文件名不可以吗?No
    • 那我判断文件名和最后修改时间?No 有个别情况会产生重复
    • 针对文件生成 hash 值(唯一标识),这种首先可以判断是否是重复的文件,又可以解决分片上传时,每一个chunk最后的归属。
  2. 如果一个文件已经上传过了,下次上传如何处理?
    • 当然是直接返回一样文件的链接了。(秒传)
  3. 如果用户不小心点了刷新,再上传时如果处理?(断点续传)
    • 刷新之前的chunk已经存在了,没有必要再进行重新上传,所以可以返回最后一个上传的index值。
  4. 上传到最后,需要将所有上传过的chunk进行合并成文件。
    1. 可以看一下这篇博客

需要解决的基本问题

  • 对大文件进行分片上传
  • 对文件进行hash处理
  • 上传的实时进度
  • 上传中断后再次上传跳过已经上传的部分(断点续传) 这些都比较简单,难得是如何优化上传体验,以及上传效率。

前端主要代码

spark-md5-npm链接

<template>
  <input type="file" @change="fileChange" />
  <button @click="uploadBtn">{{ loading ? "正在解析" : "开始上传" }}</button>
  <!-- 上传进度 -->
  <input type="range" name="" id="" :value="percentage" />
  {{ percentage }}%
</template>
<script setup>
  import SparkMD5 from "spark-md5";
  ...
  const chunkSize = 1024 * 1024; 
  // 可以拿到文件对象,将文件存储起来
  const fileChange = () => {...}
  // 点击开始上传按钮,将文件进行切片处理、解析文件(生成hash)、开始上传
  const uploadBtn = () => {
    loading.value = true;
    let _fileList = [];
    // 切片
    for (let i = 0; i < file.value.size; i += chunkSize) {
      _fileList.push(file.value.slice(i, i + chunkSize));
    }
    fileList.value = _fileList;
    const hash = new SparkMD5.ArrayBuffer();
    const fileReader = new FileReader();
    fileReader.onload = (e) => {
      hash.append(e.target.result);
      fileMd5.value = hash.end();
      upload(0);
    };
    fileReader.readAsArrayBuffer(file.value);
  };
  
  const upload = async (index) => {
    if (index === fileList.value.length) {
      mergeUpload();
      return;
    }
    const formData = new FormData();
    formData.append("chunk", fileList.value[index]);
    formData.append("index", index);
    /**
     * 这里的名字特别约定一下
     * 为什么不使用 fileMd5.value + "@" + index?
     *   如果是断点续传的时候,需要拿到最后一次上传的index。
     *   上边这种方式还需要进行排序,这种的话直接取到最后一个就可以了
     */
    formData.append("name", index + "@" + fileMd5.value);
    formData.append("filename", fileMd5.value);
    formData.append("extname", "png"); // 测试
    let { data } = await axios.post("http://localhost:3000/upload", formData, {
      header: {
        "Content-Type": "multipart/form-data",
      },
    });
  
    if (data.code === 300) {
      // 证明已经存在部分文件
      percentage.value = ((data.index / fileList.value.length) * 100).toFixed(2);
      upload(data.index + 1);
    } else if (data.code === 200) {
      percentage.value = (((index + 1) / fileList.value.length) * 100).toFixed(2);
      upload(data.index + 1);
    } else if (data.code === 201) {
      console.log(
        "%c [  ]-60",
        "font-size:13px; background:pink; color:#bf2c9f;",
        data
      );
    } else {
      upload(index);
    }
  };
  
  // 合并请求
  const mergeUpload = async () => {
    let { data } = await axios.post("http://localhost:3000/mergeFile", {
      filename: fileMd5.value,
      extname: "png",
    });
  };
  ...
</script>

后端主要代码

// 上传
app.post("/upload", (req, res, next) => {
  const form = new multiparty.Form();
  form.parse(req, (err, fields, files) => {
    console.log(fields, files);
    if (err) {
      next(err);
      return;
    }
    let pa = path.join(
      __dirname,
      "./public/upload/chunk/" + fields["filename"][0]
    );
    let filePa = path.join(
      __dirname,
      "./assets/",
      `${fields.filename}.${fields.extname}`
    );
    // 判断是否是断点续传
    if (fs.existsSync(pa) && parseInt(fields.index[0]) === 0) {
      // 存在该目录
      // 返回最大的索引
      let maxIndex = 0;
      let arr = fs.readdirSync(pa);
      /**
       * 如果前端传递的文件名称为 index + @ + name 的话
       * 就不需要这种方式进行过滤出最后一次上传时的index了
       */
      // for (let i = 0; i < arr.length; i++) {
      //   let str = parseInt(arr[i].split("@")[0]);
      //   if (str > maxIndex) {
      //     maxIndex = str;
      //   }
      // }
      res.send({
        code: 300,
        msg: "存在该目录,请继续上传",
        index: arr.at(-1).split("@")[0],
      });
    }
    // 判断文件是否已经存在
    else if (fs.existsSync(filePa)) {
      res.send({
        code: 201,
        data: "/" + fields.filename + "." + fields.extname,
      });
    } else {
      // 将每一次上传的数据进行统一的存储
      const oldName = files.chunk[0].path;

      const newName = path.join(
        __dirname,
        "./public/upload/chunk/" +
          fields["filename"][0] +
          "/" +
          fields["name"][0]
      );

      // 创建临时存储目录
      fs.mkdirSync("./public/upload/chunk/" + fields["filename"][0], {
        recursive: true,
      });
      fs.copyFile(oldName, newName, (err) => {
        if (err) {
          console.error(err);
        } else {
          fs.unlink(oldName, (err) => {
            if (err) {
              console.error(err);
            } else {
              console.log("文件复制和删除成功");
            }
          });
        }
      });
      res.send({
        code: 200,
        msg: "分片上传成功",
        index: parseInt(fields["index"][0]),
      });
    }
  });
});
// 合并请求
app.post("/mergeFile", (req, res, next) => {
  const fields = req.body;
  thunkStreamMerge(
    path.join(__dirname, "./public/upload/chunk/", fields.filename),
    path.join(__dirname, "./assets/", fields.filename + "." + fields.extname)
  );
  res.send({
    code: 200,
    data: "/" + fields.filename + "." + fields.extname,
  });
});

const thunkStreamMerge = (sourceFiles, targetFile) => {
  const list = fs.readdirSync(sourceFiles);
  console.log(list);
  const chunkFilePathList = list.map((name) => ({
    name,
    filePath: path.resolve(sourceFiles, name),
  }));
  const fileWriteStream = fs.createWriteStream(targetFile);
  thunkStreamMergeProgress(chunkFilePathList, fileWriteStream, sourceFiles);
};

const thunkStreamMergeProgress = (fileList, fileWriteStream, sourceFiles) => {
  if (!fileList.length) {
    fileWriteStream.end("完成了");
    if (sourceFiles)
      fs.rm(sourceFiles, { recursive: true, force: true }, (error) => {
        console.error(error);
      });
    return;
  }
  const data = fileList.shift();
  const { filePath: chunkFilePath } = data;

  const currentReadStream = fs.createReadStream(chunkFilePath);

  currentReadStream.pipe(fileWriteStream, { end: false });
  currentReadStream.on("end", () => {
    thunkStreamMergeProgress(fileList, fileWriteStream, sourceFiles);
  });
};

这样就已经实现了 断点续传、分片上传、秒传功能,但是呢!有问题,如果你不方便执行代码,直接看我这里的效果。

有问题

a7aac5fe780eb3bb30e7143d081e678c.gif 我的文件大小是 cc969873d090ade686a332f378ca2b5a.png

时间长短问题这个避免不了,但是有没有注意到 我在疯狂的按键盘,就是显示不出来,当解析完成之后全部都显示出来了。我的页面是卡住的状态。

查看控制台打印的执行时间:

...
console.time("文件加载使用的时间");
fileReader.onload = (e) => {
  console.timeEnd("文件加载使用的时间");
  console.time("文件生成hash使用的时间");
  hash.append(e.target.result);
  fileMd5.value = hash.end();
  console.timeEnd("文件生成hash使用的时间");
  // upload(0);
};
...

添加 console.time 查看时间消耗,就是生成hash阻塞了。时间还是非常的长。

所以要进行优化

根据上边提供的 npm spark-md5 的链接,查看官方案例可以进行追加内容,生成最后的 hash 值。其实这种又是分片,如果是分片那么我们就可以提升一下用户体验,添加一个实时解析的进度。上代码。

...
fileReader.onload = (e) => {
  hash.append(e.target.result);
  ii++;
  if (ii < fileList.value.length) {
    readerChunk();
  } else {
    ii = 0;
    fileMd5.value = hash.end();
    loading.value = false;
    upload(0);
  }
};
function readerChunk() {
  // 解析的进度
  parsePercentage.value = (((ii + 1) / fileList.value.length) * 100).toFixed(
    2
  );
  fileReader.readAsArrayBuffer(fileList.value[ii]);
}
readerChunk();
...

这样做 页面也可以正常的操作,不会出现卡死的情况,并且还会反馈用户解析的实时进度。老规矩如果不方便执行代码,看我的效果。

66958e59a87c828c8f2217339c94122e.gif

目前的效果就先这样吧,后边才追加新的功能。

其实还有好几个点可以做优化:

  1. 前端可以并发的去发请求。
  2. 添加暂停功能
  3. 如果第一次上传没有上传完,中断了,那么间隔多长时间必须来进行下面的上传,如果不上传则清除之前的chunk。
  4. 美化一下样式
  5. 管理所有上传完成的文件