前言
在《大文件上传实现-基础上传功能》 这篇文章中,实现了前后端基础的上传文件功能:前端包括选择文件、拖拽文件、展示文件、上传文件显示上传进度等功能,后端则是接收文件功和存储文件功能。例子中采用流的传输方式,请求头的Content-Type
用 application/octet-stream
。
在《大文件上传实现-切片上传功能》这篇文章中,我们将简单的上传功能进行了升级,实现了切片并发上传功能:前端增加了文件"摘要"、切片等功能,后端除了文件上传,增加合并文件等功能。
这篇文章是大文件上传实现专栏的第三篇文章,将在上一篇文章实现的代码上,进行升级迭代,逐步实现文件秒传、续传功能,所以如果没有看前面两篇,可以花点时间先看看。
需求分析
先从思路上想想,秒传是什么?就是在服务端已经有完整文件的情况下,不用继续上传了,直接提示上传成功;续传是什么?就是服务器已经有部分文件了,但不全,不全包括两种情况:一种是切片数量不全,一种是切片本身不全;那只上传"未上传的切片" 或者 "某个切片还未上传的部分"。
秒传功能
秒传功能简单,后端多一个接口,查一下是否已经存在文件;前端查一下是否存在,如果存在,就不继续上传了。
后端验证接口
js
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验证下:
文件名是:321f84eca184e49fbdaea3683e6e6d0e4a1bd69bee988e6c3232ab2930574e85.mp4
。
先看看不存在:
再看看已经存在的:
接口没问题。
前端验证秒传
js
const handleUpload = async () => {
...
// 验证文件是否已经上传了
const { needUpload } = await axiosInstance.get(`verify/${filename}`);
if (!needUpload) {
return ElMessage.success("文件秒传成功");
}
...
}
代码比较简单,不做啰嗦讲解了,看看效果:
续传功能
续传功能,减少不必要的重复上传;那么后端需要提供已经上传了的文件信息,并且上传接口能够在原来的基础上继续上传;前端则需要根据已经上传了的信息,对切片再一次进行切割,只上传还未上传的切面部分。
下面为了文章行篇的顺畅,先改造一下前后端,实现上传可取消,为后面的续传做铺垫。
后端取消写入文件
js
app.post("/upload/:filename", async (req, res) => {
...
// 请求取消时停止写入
req.on("aborted", () => {
ws.close();
});
...
});
前端取消上传请求
js
// 取消请求的 token 数组
const cancelTokens = [];
// 取消所有请求
const handleCancel = () => {
cancelTokens.forEach((cancelToken) => {
cancelToken.cancel();
});
};
上传的时候带上取消的token:
js
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("上传失败");
}
}
...
};
页面添加一个取消按钮:
html
<el-button @click="handleCancel" type="danger">取消上传</el-button>
先删除后端 uploads 里面的文件,测试看看取消上传效果:
可以看到,前端没问题,是期待的取消效果,后端查看文件分片,也是上传了部分的文件分片(完整是100M),没问题。
后端验证接口返回已上传的切片信息
前面铺垫好了,当上传中途取消了,那么后端存有部分文件切片,如果点击开始上传,应该排除这些文件切片,验证接口需要返回已经上传的信息,来实现下:
js
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试下。
不存在的文件结果如下:
上文中提到上传取消之后的文件,结果如下:
后端上传接口续传
前面写的上传接口,都是直接"流入"文件中,但要能够续传,那么在调用 fs.createWriteStream()
,得传个参数:
js
// 创建可读流
const ws = fs.createWriteStream(chunkPath, {
// 追加写入
flags: "a",
start,
});
有了以上的后端接口支持,我们能知道服务器上传的文件信息,然后上传接口也能指定写入文件的起始位置,那么实现续传的后端条件就满足了。下面开始实现前端的续传功能吧。
前端实现续传对接
我将思路讲解写在注释中,总的思路:根据拿到的验证信息,处理我们要上传的切片,同时维护起始位置start
,再调用上传接口。
js
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("上传失败");
}
}
};
删除上传过的文件,走一遍流程看看效果:
上面先开始上传,然后暂停,查看已经上传了的切片,接着又开始上传,是从之前的基础上继续上传的。那么到这里,断点续传的功能也实现了。
总结
这篇文章带读者在《大文件上传实现-基础上传功能》、《大文件上传实现-切片上传功能》 这两篇文章实现的功能基础上,实现了秒传、暂停、续传功能。代码我放在gitee。
到这里,大文件上传实现专栏主线就完成啦。专栏其他文章就讲讲优化点如并发控制或者相关的主题如并发下载文件等,敬请期待。