大文件分片上传
转载https://mp.weixin.qq.com/s/HAvngRjJAbakRX40GHIx7g
前言
最近我们公司的项目中多了一个需求,因为我们的管理系统需要管理背景音乐
的存储,那就肯定涉及到前端的上传音乐功能
了,可能是由于我们公司的编辑们所制作的BGM
质量比较高,所以每一个BGM
文件都会比较大,每一个都在20M
以上,所以我使用了大文件的分片上传
,并做了暂停上传
,续传
功能,接下来就通过一个小demo,给大家演示一下吧!!!
BGM切片上传
1.大致流程
分为以下几步:
- 1.前端接收BGM并进行
切片
- 2.将每份
切片
都进行上传
- 3.后端接收到所有
切片
,创建一个文件夹
存储这些切片
- 4.后端将此
文件夹
里的所有切片合并为完整的BGM文件 - 5.删除
文件夹
,因为切片
不是我们最终想要的,可删除
- 6.当服务器已存在某一个文件时,再上传需要实现
“秒传”
图片
2.前端实现切片
简单来说就是,咱们上传文件时,选中文件后,浏览器会把这个文件转成一个Blob对象
,而这个对象的原型上上有一个slice
方法,这个方法是大文件能够切片的原理,可以利用这个方法来给打文件切片
<input type="file" @change="handleFileChange" /><el-button @click="handleUpload"> 上传 </el-button>data() { return { fileObj: { file: null } }; }, methods: { handleFileChange(e) { const [file] = e.target.files if (!file) return this.fileObj.file = file }, handleUpload () { const fileObj = this.fileObj if (!fileObj.file) return const chunkList = this.createChunk(fileObj.file) console.log(chunkList) // 看看chunkList长什么样子 }, createChunk(file, size = 5 * 1024 * 1024) { const chunkList = [] let cur = 0 while(cur < file.size) { // 使用slice方法切片 chunkList.push({ file: file.slice(cur, cur + size) }) cur += size } return chunkList }
图片例子我就用我最近很喜欢听得一首歌
嘉宾-张远
,他的大小是32M
<figcaption style="margin: 5px 0px 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; text-align: center; color: rgb(136, 136, 136); font-size: 14px;">截屏2021-07-08 下午8.06.22.png</figcaption>
图片点击上传,看看
chunkList
长什么样子吧:
<figcaption style="margin: 5px 0px 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; text-align: center; color: rgb(136, 136, 136); font-size: 14px;">image.png</figcaption>
证明我们切片成功了!!!分成了7个切片
3.上传切片并展示进度条
我们先封装一个请求方法,使用的是axios
:
import axios from "axios";axiosRequest({ url, method = "post", data, headers = {}, onUploadProgress = (e) => e, // 进度回调 }) { return new Promise((resolve, reject) => { axios[method](url, data, { headers, onUploadProgress, // 传入监听进度回调 }) .then((res) => { resolve(res); }) .catch((err) => { reject(err); }); }); }
接着上一步,我们获得了所有切片
,接下来要把这些切片保存起来,并逐一去上传
handleUpload() { const fileObj = this.fileObj; if (!fileObj.file) return; const chunkList = this.createChunk(fileObj.file);+ this.fileObj.chunkList = chunkList.map(({ file }, index) => ({+ file,+ size: file.size,+ percent: 0,+ chunkName: `${fileObj.file.name}-${index}`,+ fileName: fileObj.file.name,+ index,+ }));+ this.uploadChunks(); // 执行上传切片的操作 },
uploadChunks
就是执行上传所有切片的函数
+ async uploadChunks() {+ const requestList = this.fileObj.chunkList+ .map(({ file, fileName, index, chunkName }) => {+ const formData = new FormData();+ formData.append("file", file);+ formData.append("fileName", fileName);+ formData.append("chunkName", chunkName);+ return { formData, index };+ })+ .map(({ formData, index }) =>+ this.axiosRequest({+ url: "http://localhost:3000/upload",+ data: formData,+ onUploadProgress: this.createProgressHandler(+ this.fileObj.chunkList[index]+ ), // 传入监听上传进度回调+ })+ );+ await Promise.all(requestList); // 使用Promise.all进行请求+ },+ createProgressHandler(item) {+ return (e) => {+ // 设置每一个切片的进度百分比+ item.percent = parseInt(String((e.loaded / e.total) * 100));+ };+ },
我不知道他们后端Java
是怎么做的,我这里使用Nodejs
模拟一下
const http = require("http");const path = require("path");const fse = require("fs-extra");const multiparty = require("multiparty");const server = http.createServer();const UPLOAD_DIR = path.resolve(__dirname, ".", `qiepian`); // 切片存储目录server.on("request", async (req, res) => { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Headers", "*"); if (req.method === "OPTIONS") { res.status = 200; res.end(); return; } console.log(req.url) if (req.url === '/upload') { const multipart = new multiparty.Form(); multipart.parse(req, async (err, fields, files) => { if (err) { console.log('errrrr', err) return; } const [file] = files.file; const [fileName] = fields.fileName; const [chunkName] = fields.chunkName; // 保存切片的文件夹的路径,比如 张远-嘉宾.flac-chunks const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`); // // 切片目录不存在,创建切片目录 if (!fse.existsSync(chunkDir)) { await fse.mkdirs(chunkDir); } // 把切片移动到切片文件夹 await fse.move(file.path, `${chunkDir}/${chunkName}`); res.end( JSON.stringify({ code: 0, message: "切片上传成功" })); }); }})server.listen(3000, () => console.log("正在监听 3000 端口"));
接下来就是页面上进度条的显示了,其实很简单,我们想要展示总进度条
,和各个切片的进度条
,各个切片的进度条我们都有了,我们只需要算出总进度
就行,怎么算呢?这么算:各个切片百分比 * 各个切片的大小 / 文件总大小
+ <div style="width: 300px">+ 总进度:+ <el-progress :percentage="totalPercent"></el-progress>+ 切片进度:+ <div v-for="item in fileObj.chunkList" :key="item">+ <span>{{ item.chunkName }}:</span>+ <el-progress :percentage="item.percent"></el-progress>+ </div>+</div>+ computed: {+ totalPercent() {+ const fileObj = this.fileObj;+ if (fileObj.chunkList.length === 0) return 0;+ const loaded = fileObj.chunkList+ .map(({ size, percent }) => size * percent)+ .reduce((pre, next) => pre + next);+ return parseInt((loaded / fileObj.file.size).toFixed(2));+ },+ },
我们再次上传音乐,查看效果:
图片<figcaption style="margin: 5px 0px 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; text-align: center; color: rgb(136, 136, 136); font-size: 14px;">截屏2021-07-08 下午10.33.51.png</figcaption>
后端也成功保存了
图片<figcaption style="margin: 5px 0px 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; text-align: center; color: rgb(136, 136, 136); font-size: 14px;">截屏2021-07-08 下午10.34.28.png</figcaption>
4.合并切片为BGM
好了,咱们已经保存好所有切片,接下来就要开始合并切片
了,我们会发一个/merge
请求,叫后端合并这些切片,前端代码添加合并的方法:
async uploadChunks() { const requestList = this.fileObj.chunkList .map(({ file, fileName, index, chunkName }) => { const formData = new FormData(); formData.append("file", file); formData.append("fileName", fileName); formData.append("chunkName", chunkName); return { formData, index }; }) .map(({ formData, index }) => this.axiosRequest({ url: "http://localhost:3000/upload", data: formData, onUploadProgress: this.createProgressHandler( this.fileObj.chunkList[index] ), }) ); await Promise.all(requestList); // 使用Promise.all进行请求+ this.mergeChunks() },+ mergeChunks(size = 5 * 1024 * 1024) {+ this.axiosRequest({+ url: "http://localhost:3000/merge",+ headers: {+ "content-type": "application/json",+ },+ data: JSON.stringify({ + size,+ fileName: this.fileObj.file.name+ }),+ });+ }
后端增加/merge
接口:
// 接收请求的参数const resolvePost = req => new Promise(res => { let chunk = '' req.on('data', data => { chunk += data }) req.on('end', () => { res(JSON.parse(chunk)) }) })const pipeStream = (path, writeStream) => { console.log('path', path) return new Promise(resolve => { const readStream = fse.createReadStream(path); readStream.on("end", () => { fse.unlinkSync(path); resolve(); }); readStream.pipe(writeStream); });}// 合并切片const mergeFileChunk = async (filePath, fileName, size) => { // filePath:你将切片合并到哪里,的路径 const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`); let chunkPaths = null // 获取切片文件夹里所有切片,返回一个数组 chunkPaths = await fse.readdir(chunkDir); // 根据切片下标进行排序 // 否则直接读取目录的获得的顺序可能会错乱 chunkPaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]); const arr = chunkPaths.map((chunkPath, index) => { return pipeStream( path.resolve(chunkDir, chunkPath), // 指定位置创建可写流 fse.createWriteStream(filePath, { start: index * size, end: (index + 1) * size }) ) }) await Promise.all(arr)};if (req.url === '/merge') { const data = await resolvePost(req); const { fileName, size } = data; const filePath = path.resolve(UPLOAD_DIR, fileName); await mergeFileChunk(filePath, fileName, size); res.end( JSON.stringify({ code: 0, message: "文件合并成功" }) ); }
现在我们重新上传音乐,发现切片上传成功了,也合并成功了:
图片<figcaption style="margin: 5px 0px 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; text-align: center; color: rgb(136, 136, 136); font-size: 14px;">截屏2021-07-09 下午1.44.29.png</figcaption>
5.删除切片
上一步我们已经完成了切片合并
这个功能了,那之前那些存在后端的切片就没用了,不然会浪费服务器的内存,所以我们在确保合并成功后,可以将他们删除
// 合并切片const mergeFileChunk = async (filePath, fileName, size) => { // filePath:你将切片合并到哪里,的路径 const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`); let chunkPaths = null // 获取切片文件夹里所有切片,返回一个数组 chunkPaths = await fse.readdir(chunkDir); // 根据切片下标进行排序 // 否则直接读取目录的获得的顺序可能会错乱 chunkPaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]); const arr = chunkPaths.map((chunkPath, index) => { return pipeStream( path.resolve(chunkDir, chunkPath), // 指定位置创建可写流 fse.createWriteStream(filePath, { start: index * size, end: (index + 1) * size }) ) }) await Promise.all(arr) fse.rmdirSync(chunkDir); // 合并后删除保存切片的目录};
我们再次上传,再看看,那个储存此音乐的切片文件夹被我们删了
:
<figcaption style="margin: 5px 0px 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; text-align: center; color: rgb(136, 136, 136); font-size: 14px;">截屏2021-07-09 下午1.46.59.png</figcaption>
6.秒传功能
所谓的秒传功能
,其实没那么高大上,通俗点说就是,当你上传一个文件时,后端会判断服务器上有无这个文件,有的话就不执行上传,并返回给你“上传成功”
,想要执行此功能,后端需要重新写一个接口/verify
if (req.url === "/verify") { const data = await resolvePost(req); const { fileName } = data; const filePath = path.resolve(UPLOAD_DIR, fileName); console.log(filePath) if (fse.existsSync(filePath)) { res.end( JSON.stringify({ shouldUpload: false }) ); } else { res.end( JSON.stringify({ shouldUpload: true }) ); }
前端在上传文件
步骤也要做拦截:
async handleUpload() { const fileObj = this.fileObj; if (!fileObj.file) return;+ const { shouldUpload } = await this.verifyUpload(+ fileObj.file.name,+ );+ if (!shouldUpload) {+ alert("秒传:上传成功");+ return;+ } const chunkList = this.createChunk(fileObj.file); this.fileObj.chunkList = chunkList.map(({ file }, index) => ({ file, size: file.size, percent: 0, chunkName: `${fileObj.file.name}-${index}`, fileName: fileObj.file.name, index, })); this.uploadChunks(); },+ async verifyUpload (fileName) {+ const { data } = await this.axiosRequest({+ url: "http://localhost:3000/verify",+ headers: {+ "content-type": "application/json",+ },+ data: JSON.stringify({+ fileName,+ }),+ });+ return data+ }
现在我们重新上传音乐,因为服务器上已经存在了张远-嘉宾
这首歌了,所以,直接alert出秒传:上传成功
<figcaption style="margin: 5px 0px 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; text-align: center; color: rgb(136, 136, 136); font-size: 14px;">截屏2021-07-09 下午2.17.02.png</figcaption>
暂停续传
1.大致流程
暂停续传其实很简单,比如一个文件被切成10片
,当你上传成功5片
后,突然暂停,那么下次点击续传
时,只需要过滤掉之前已经上传成功的那5片
就行,怎么实现呢?其实很简单,只需要点击续传
时,请求/verity
接口,返回切片文件夹里
现在已成功上传的切片列表,然后前端过滤后再把还未上传的切片
的继续上传就行了,后端的/verify
接口需要做一些修改
if (req.url === "/verify") { // 返回已经上传切片名列表 const createUploadedList = async fileName =>+ fse.existsSync(path.resolve(UPLOAD_DIR, fileName))+ ? await fse.readdir(path.resolve(UPLOAD_DIR, fileName))+ : []; const data = await resolvePost(req); const { fileName } = data; const filePath = path.resolve(UPLOAD_DIR, fileName); console.log(filePath) if (fse.existsSync(filePath)) { res.end( JSON.stringify({ shouldUpload: false }) ); } else { res.end( JSON.stringify({ shouldUpload: true,+ uploadedList: await createUploadedList(`${fileName}-chunks`) }) ); } }
2.暂停上传
前端增加一个暂停按钮
和pauseUpload
事件
+ <el-button @click="pauseUpload"> 暂停 </el-button>+ const CancelToken = axios.CancelToken;+ const source = CancelToken.source();axiosRequest({ url, method = "post", data, headers = {}, onUploadProgress = (e) => e, }) { return new Promise((resolve, reject) => { axios[method](url, data, { headers, onUploadProgress,+ cancelToken: source.token }) .then((res) => { resolve(res); }) .catch((err) => { reject(err); }); }); },+ pauseUpload() {+ source.cancel("中断上传!");+ source = CancelToken.source(); // 重置source,确保能续传+ }
3.续传
增加一个续传
按钮,并增加一个keepUpload
事件
+ <el-button @click="keepUpload"> 续传 </el-button>+ async keepUpload() {+ const { uploadedList } = await this.verifyUpload(+ this.fileObj.file.name+ );+ this.uploadChunks(uploadedList);+ }
4.优化进度条
续传
中,由于那些没有上传的切片会从零开始
传,所以会导致总进度条
出现倒退现象
,所以我们要对总进度条
做一下优化,确保他不会倒退
,做法就是维护一个变量,这个变量只有在总进度大于他
时他才会更新成总进度
总进度:+ <el-progress :percentage="tempPercent"></el-progress>+ watch: {+ totalPercent (newVal) {+ if (newVal > this.tempPercent) this.tempPercent = newVal+ }+ },