【文件上传那些事儿】- 04 切片上传和网格进度条

2,240 阅读5分钟

前文链接

【文件上传那些事儿】- 01 简单的拖拽上传和进度条

【文件上传那些事儿】- 02 二进制级别的格式验证

【文件上传那些事儿】- 03 两种计算 hash 的方式

V1.4:大文件切片上传 - 切片上传与合并

在前面的部分,我们已经完成了文件的切片和 hash 的计算,接下来要做的就是把这些切片上传到后端了,这一部分并没有什么难度,按部就班的进行即可。

首先对 chunks 进行一些自定义:

const chunks = fileChunks.map((c, i) => {
  // 切片名,下标,内容,hash
  const name = `${fileHash}-${i}`;
  return {
    name,
    index: i,
    hash: fileHash,
    chunk: c.fileChunk,
  };
});

而上传部分的逻辑可以分为三部分:

  • 对每个切片进行包装
  • 使用 Promise.all 发送请求
  • 发起 merge 请求让后端进行切片的合并

那么整个方法的宏观架构如下:

const uploadChunks = async (chunks, hash) => {
  const requests = chunks.map(/* do something */)
  await Promise.all(requests);
  await mergeRequest(hash);
};

显然这里最关键的是如何对切片进行包装。

而这可以分为两步:

  • 将数据放进 formdata
  • 发起请求

综上,对 requests 做如下处理:

const requests = chunks
  .map(({ name, index, hash, chunk }) => {
    const formdata = new FormData();
    formdata.append("name", name);
    formdata.append("index", index);
    formdata.append("hash", hash);
    formdata.append("chunk", chunk);
    return formdata;
  })
  .map((form) => {
    return axios.post("/dev-api/upload", form);
  });

到这里,我们可以去后端完成切片的接收工作:

router.post("/api/v1/upload", async ctx => {
  const { chunk } = ctx.request.files;
  const { name, hash } = ctx.request.body;
  const chunkPath = path.resolve(uploadPath, hash);
  if (!fse.existsSync(chunkPath)) {
    await fse.mkdir(chunkPath);
  }
  const { path: cachePath } = chunk;
  await fse.move(cachePath, `${chunkPath}/${name}`);
  ctx.body = { msg: "revice chunks success!" };
});

测试如下:

img01

有了这些切片,我们再从前端发送一个 merge 请求,即可在后端完成切片的合并了。

思考一下,merge 需要哪些参数呢?

在切片的上传过程中,后端并不知道切片的大小和文件的后缀名,显然这二者在合并的过程中是需要用到的,另外由于 merge 是一个新的请求,所以 hash 也是需要从前端获取的,那么可以比较容易的得出需要的参数:size, hash, ext

const mergeRequest = async (hash) => {
  await axios.post("/dev-api/mergefile", {
    ext: getFileExtension(fileRef.value.name),
    size: CHUNK_SIZE,
    hash: hash
  });
};

在后端,我们要获取切片的地址以及目标文件的地址,随后进行切片的合并,所以大致代码应该如下:

router.post("/api/v1/mergefile", async ctx => {
  let chunks;
  const { ext, size, hash } = ctx.request.body;
  const filepath = path.resolve(uploadPath, `${hash}.${ext}`);
  const chunkpath = path.join(uploadPath, hash);
  chunks = await fse.readdir(chunkpath);
  chunks = chunks.map(c => path.resolve(chunkpath, c));
  await mergeChunks(chunks, filepath, size, chunkpath);
  ctx.body = {
    url: `upload/${hash}.${ext}`
  };
});

需要注意的是,这里一定要对切片按照 index 进行排序,否则最后合并好的文件就变得面目全非了,这里给大家看个例子:

img02

所以要对切片进行排序:

router.post("/api/v1/mergefile", async ctx => {
  let chunks;
  chunks = await fse.readdir(chunkpath);
+ chunks.sort((a, b) => a.split("-")[1] - b.split("-")[1]);
  chunks = chunks.map(c => path.resolve(chunkpath, c));
});

最后,真正的 merge 操作,我们通过流来实现,一边读,一边写,在所有操作都完成之后删除掉存放切片的目录即可:

const mergeChunks = async (chunks, dest, size, chunkpath) => {
  const pipStream = (filepath, writeStream) =>
    new Promise(resolve => {
      const readStream = fse.createReadStream(filepath);
      readStream.on("end", () => {
        resolve();
      });
      readStream.pipe(writeStream);
    });
  await Promise.all(
    chunks.map((chunk, index) => {
      pipStream(
        chunk,
        fse.createWriteStream(dest, {
          start: index * size,
          end: (index + 1) * size
        })
      );
    })
  );
  await fse.remove(chunkpath);
};

结果如下:

img03

V1.4:大文件切片上传 - 网格进度条

文件切片上传的整体流程已经跑通了,现在我们可以回到前端做一些体验上的优化,比如网格进度条。

思路其实并不复杂,关键点如下:

  • 网格的个数就是切片的个数
  • 单个网格的进度可以通过 onUploadProgress 获取
  • 整个网格拼接而成的图最好接近正方形,那么整体的宽度应该是计算而来

核心如上,我们还可以进一步分析:

  • 根据 progress 计算是否成功,从而改变网格的颜色来标识是否成功
  • 同样,根据 progress 计算网格高度从而实现网格渐渐被填满的效果

那么 html 结构如下:

<div class="cube-container" :style="{ width: cubeContainerWidth }">
  <div class="cube" v-for="chunk in chunks" :key="chunk.name">
    <div
      class="progress"
      :class="{
        success: chunk.progress === 100,
        uploading: chunk.progress > 0 && chunk.progress < 100,
        error: chunk.progress < 0
      }"
      :style="{ height: chunk.progress + '%' }"
    ></div>
  </div>
</div>

而整体容器的宽度,其实很好得到,因为要接近正方形,所以求根即可,但求根不一定是整数,所以向上取整即可:

const cubeContainerWidth = computed(() => {
  const count = chunks.value.length;
  return `${Math.ceil(Math.sqrt(count)) * 16}px`;
});

之后就是简单的样式设置:

.cube-container {
  .cube {
    width: 16px;
    height: 16px;
    border: 1px solid #000;
    float: left;

    > .success {
      background: #a7ff83;
    }
    > .uploading {
      background: #22d1ee;
    }
    > .error {
      background: #fc5185;
    }
  }
}

结果如下:

img04

细心的读者可能已经发现了:切片之后只能获取单个切片上传的进度了,那么整体的进度是怎么的来的呢?

事实上,我们知道单个切片的大小,单个切片上传的进度,以及拥有整个 chunks,计算出整体的进度也并不是特别困难的事情:

const uploadProgress = computed(() => {
  if (!fileRef.value || !chunks.value.length) {
    return 0;
  }
  const loaded = chunks.value
    .map((chunk: any) => chunk.chunk.size * chunk.progress)
    .reduce((acc: number, cur: number) => acc + cur, 0);
  return parseInt((loaded / fileRef.value.size).toFixed(2));
});

结束语

一个简易的大文件上传 demo 至此已经基本实现了,但这里还有一些坑,还有一些功能可以拓展,比如:

  • 如果上传到一半失败了,如何进行断点续传
  • 一股脑的把所有切片上传请求都发送出去,对性能肯定是有影响的,改如何进行并发控制
  • 对于每一个上传请求,又如何做错误重试

这些问题我们会在接下来的文章中一起探讨,解决,那么今天就到这里啦,期待下次再见~