前文链接
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!" };
});
测试如下:
有了这些切片,我们再从前端发送一个 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 进行排序,否则最后合并好的文件就变得面目全非了,这里给大家看个例子:
所以要对切片进行排序:
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);
};
结果如下:
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;
}
}
}
结果如下:
细心的读者可能已经发现了:切片之后只能获取单个切片上传的进度了,那么整体的进度是怎么的来的呢?
事实上,我们知道单个切片的大小,单个切片上传的进度,以及拥有整个 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 至此已经基本实现了,但这里还有一些坑,还有一些功能可以拓展,比如:
- 如果上传到一半失败了,如何进行断点续传
- 一股脑的把所有切片上传请求都发送出去,对性能肯定是有影响的,改如何进行并发控制
- 对于每一个上传请求,又如何做错误重试
这些问题我们会在接下来的文章中一起探讨,解决,那么今天就到这里啦,期待下次再见~