大文件分片上传

转载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+       }+   },
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 200,841评论 5 472
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,415评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 147,904评论 0 333
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,051评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,055评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,255评论 1 278
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,729评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,377评论 0 255
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,517评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,420评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,467评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,144评论 3 317
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,735评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,812评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,029评论 1 256
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,528评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,126评论 2 341

推荐阅读更多精彩内容