likes
comments
collection
share

大文件上传实现-秒传续传功能(Vue + Express)

作者站长头像
站长
· 阅读数 13

前言

大文件上传实现-秒传续传功能(Vue + Express)

大文件上传实现-秒传续传功能(Vue + Express)

需求分析

先从思路上想想,秒传是什么?就是在服务端已经有完整文件的情况下,不用继续上传了,直接提示上传成功;续传是什么?就是服务器已经有部分文件了,但不全,不全包括两种情况:一种是切片数量不全,一种是切片本身不全;那只上传“未上传的切片” 或者 “某个切片还未上传的部分”。

秒传功能

秒传功能简单,后端多一个接口,查一下是否已经存在文件;前端查一下是否存在,如果存在,就不继续上传了。

后端验证接口

app.get("/verify/:filename", async (req, res) => {
  // 获取文件名
  const { filename } = req.params;
  // 拼接文件路径
  const filePath = path.resolve(UPLOADS_DIR, filename);
  // 判断文件是否存在
  const isExist = await fs.pathExists(filePath);
  if (isExist) {
    res.json({ success: true, needUpload: false });
  } else {
    res.json({ success: true, needUpload: true });
  }
});

上篇文章的代码可以上传文件了,先上传一个,然后用postman验证下:

大文件上传实现-秒传续传功能(Vue + Express)

文件名是:321f84eca184e49fbdaea3683e6e6d0e4a1bd69bee988e6c3232ab2930574e85.mp4

先看看不存在:

大文件上传实现-秒传续传功能(Vue + Express)

再看看已经存在的:

大文件上传实现-秒传续传功能(Vue + Express)

接口没问题。

前端验证秒传

const handleUpload = async () => {
  ...
  // 验证文件是否已经上传了
  const { needUpload } = await axiosInstance.get(`verify/${filename}`);
  if (!needUpload) {
    return ElMessage.success("文件秒传成功");
  }
  ...
 }

代码比较简单,不做啰嗦讲解了,看看效果:

大文件上传实现-秒传续传功能(Vue + Express)

续传功能

续传功能,减少不必要的重复上传;那么后端需要提供已经上传了的文件信息,并且上传接口能够在原来的基础上继续上传;前端则需要根据已经上传了的信息,对切片再一次进行切割,只上传还未上传的切面部分。

下面为了文章行篇的顺畅,先改造一下前后端,实现上传可取消,为后面的续传做铺垫。

后端取消写入文件

大文件上传实现-秒传续传功能(Vue + Express)

app.post("/upload/:filename", async (req, res) => {
  ...
  // 请求取消时停止写入
  req.on("aborted", () => {
    ws.close();
  });
  ...
});

前端取消上传请求

// 取消请求的 token 数组
const cancelTokens = [];
// 取消所有请求
const handleCancel = () => {
  cancelTokens.forEach((cancelToken) => {
    cancelToken.cancel();
  });
};

上传的时候带上取消的token:

大文件上传实现-秒传续传功能(Vue + Express)

const handleUpload = async () => {
  ...
  // 批量上传分片
  const requests = chunks.map((chunkInfo) => {
  
   const cancelToken = axios.CancelToken.source();
   cancelTokens.push(cancelToken);
   
    return axiosInstance.post(`/upload/${filename}`, chunkInfo.chunk, {
      ...
      cancelToken: cancelToken.token,
    });
  });
  try {
    ...
  } catch (error) {
    if (axios.isCancel(error)) {
      ElMessage.warning("上传已取消");
    } else {
      ElMessage.error("上传失败");
    }
  }
  ...
};

页面添加一个取消按钮:

 <el-button @click="handleCancel" type="danger">取消上传</el-button>

大文件上传实现-秒传续传功能(Vue + Express)

先删除后端 uploads 里面的文件,测试看看取消上传效果:

大文件上传实现-秒传续传功能(Vue + Express)

可以看到,前端没问题,是期待的取消效果,后端查看文件分片,也是上传了部分的文件分片(完整是100M),没问题。

大文件上传实现-秒传续传功能(Vue + Express)

后端验证接口返回已上传的切片信息

前面铺垫好了,当上传中途取消了,那么后端存有部分文件切片,如果点击开始上传,应该排除这些文件切片,验证接口需要返回已经上传的信息,来实现下:

app.get("/verify/:filename", async (req, res) => {
  // 获取文件名
  const { filename } = req.params;
  // 拼接文件路径
  const filePath = path.resolve(UPLOADS_DIR, filename);
  // 判断文件是否存在
  const isExist = await fs.pathExists(filePath);
  if (isExist) {
    res.json({ success: true, needUpload: false });
  } else {
    // 拼接切片文件夹路径
    const chunkDir = path.resolve(TEMP_DIR, filename);
    // 判断切片文件夹是否存在
    const hasChunks = await fs.pathExists(chunkDir);
    let uploadedChunks = [];
    if (hasChunks) {
      // 读取切片文件夹中的文件名
      const chunkFilenames = await fs.readdir(chunkDir);
      // 获取切片文件的大小
      uploadedChunks = await Promise.all(
        chunkFilenames.map(async (chunkFilename) => {
          const { size } = await fs.stat(path.resolve(chunkDir, chunkFilename));
          return { chunkFilename, size };
        })
      );
      res.json({ success: true, needUpload: true, uploadedChunks });
    } else {
      res.json({ success: true, needUpload: true, uploadedChunks });
    }
  }
});

那么这个接口就会根据已经上传的文件,返回对应的切片大小信息,用postman试下。

不存在的文件结果如下:

大文件上传实现-秒传续传功能(Vue + Express)

上文中提到上传取消之后的文件,结果如下:

大文件上传实现-秒传续传功能(Vue + Express)

后端上传接口续传

前面写的上传接口,都是直接“流入”文件中,但要能够续传,那么在调用 fs.createWriteStream(),得传个参数: 大文件上传实现-秒传续传功能(Vue + Express)

  // 创建可读流
  const ws = fs.createWriteStream(chunkPath, {
    // 追加写入
    flags: "a",
    start,
  });

有了以上的后端接口支持,我们能知道服务器上传的文件信息,然后上传接口也能指定写入文件的起始位置,那么实现续传的后端条件就满足了。下面开始实现前端的续传功能吧。

前端实现续传对接

我将思路讲解写在注释中,总的思路:根据拿到的验证信息,处理我们要上传的切片,同时维护起始位置start,再调用上传接口。

const handleUpload = async () => {
  if (!selectedFile.value.file) {
    return ElMessage.warning("请先选择文件");
  }
  const file = selectedFile.value.file;

  calculating.value = true;
  // 获取文件名
  const filename = await getFilename(file);
  calculating.value = false;

  // 验证文件是否已经上传了
  const { needUpload, uploadedChunks } = await axiosInstance.get(
    `verify/${filename}`
  );
  if (!needUpload) {
    return ElMessage.success("文件秒传成功");
  }

  const CHUNK_SIZE = 100 * 1024 * 1024;
  // 创建分片
  const chunks = createChunks(file, CHUNK_SIZE, filename);

  const requests = chunks.map((chunkInfo) => {
    const cancelToken = axios.CancelToken.source();
    cancelTokens.push(cancelToken);
    // 查找是否有上传过的切片
    const uploadedChunk = uploadedChunks.find(
      (item) => item.chunkFilename === chunkInfo.chunkFilename
    );

    // 初始化要上传的切片和位置
    let chunk = chunkInfo.chunk;
    let start = 0;
    // 总大小,用于计算进度
    const totalSize = chunk.size;
    if (uploadedChunk) {
      // 如果已经上传过了,切除掉已经上传的部分
      chunk = chunk.slice(uploadedChunk.size);
      // 从已经上传的位置开始
      start = uploadedChunk.size;
    }
    // 切除之后,如果剩下还有切片大小就继续上传,没有就说明整个都上传过了,不用再上传这个
    if (chunk.size > 0) {
      // 初始进度
      progressInfo.value[chunkInfo.chunkFilename] = Math.round(
        (start * 100) / totalSize
      );
      return axiosInstance.post(`/upload/${filename}`, chunk, {
        headers: {
          "Content-Type": "application/octet-stream",
        },
        params: {
          chunkFilename: chunkInfo.chunkFilename,
          start,
        },
        onUploadProgress(progressEvent) {
          // 注意进度的计算要结合start,即开始上传的位置
          progressInfo.value[chunkInfo.chunkFilename] = Math.round(
            ((progressEvent.loaded + start) * 100) / totalSize
          );
        },
        cancelToken: cancelToken.token,
      });
    } else {
      progressInfo.value[chunkInfo.chunkFilename] = 100;
      return Promise.resolve();
    }
  });

  try {
    await Promise.all(requests);
    ElMessage.success("上传分片完成");
    // 合并分片
    axiosInstance.get(`/merge/${filename}`).then(() => {
      ElMessage.success("合并分片完成");
    });
  } catch (error) {
    if (axios.isCancel(error)) {
      ElMessage.warning("上传已取消");
    } else {
      ElMessage.error("上传失败");
    }
  }
};

删除上传过的文件,走一遍流程看看效果:

大文件上传实现-秒传续传功能(Vue + Express)

上面先开始上传,然后暂停,查看已经上传了的切片,接着又开始上传,是从之前的基础上继续上传的。那么到这里,断点续传的功能也实现了。

总结

还望动动小手,点个赞或收藏,感谢支持~

转载自:https://juejin.cn/post/7328329691352940571
评论
请登录