因为之前在做h5音乐播放器的时候需要上传MP3文件,就想到之前面试有问过大文件的上传,所以就着手实现了一个,演示地址:https://www.zsp.cool/ls
gitee仓库:https://gitee.com/zhangshengpengBXH/vue-musec
手机扫码:
一、前后端大致工作:
1.1 前端部分:
- 首先前端计算出读取到的大文件的md5,然后对文件进行切片,使用md5+切片序号来命名,然后将切片上传至后端,由于大文件计算md5相当耗时,在主线程操作可能会造成页面假死,所以将md5的计算放到WebWorker线程;
- 而断点续传的实现依赖于切片的上传进度,如果上传在中途中断,则已上传成功的切片无需再次上传,所以需在每次切片上传前,检查后端是否已存在该切片,若存在则跳过;
- 当所有切片上传完毕后,通知后端合并切片;
1.2 后端部分:
- 需要实现根据切片路径及名称查找该切片是否存在的接口;
- 根据md5动态生成文件夹,将该文件的所有切片保存在该目录下;
- 收到切片全部上传完毕的通知后,读取切生成原文件,删除切片及文件夹;
下面开始具体实现,先说说一下前后端用到的相关工具吧
前端:vue+axios,spark-md5
后端:node+express,multer
二、具体实现
2.1前端部分:
关于webWorker的创建,暂时没找到什么好方法,这里我直接将初始化webWorker的方法挂载到了window上,html模板文件里:
<script>
window.worker = null
window.createWorker = () => { window.worker = new Worker(‘./js/webWorker.js‘) }
</script>
把spark-md5.min.js拷贝到了public/js目录下,并在该目录创建webWorker.js,其中data为传入的文件切片数组,循环调用getMd5直至遍历完所有切片,向主线程发送计算得出的hash:
self.importScripts(‘./spark-md5.min.js‘)
self.addEventListener(‘message‘, ({ data }) => {
const spark = new self.SparkMD5.ArrayBuffer()
const getMd5 = files => {
const reader = new FileReader()
reader.readAsArrayBuffer(files.shift().file)
reader.onload = e => {
spark.append(e.target.result)
if (files.length) {
getMd5(files)
} else {
self.postMessage({ hash: spark.end() })
}
}
}
getMd5(data)
})
界面部分,先获取文件,对文件进行切片,将切片传递给webWorker,切片利用file.slice实现:
<input id="file" value="file" type="file" @change="handlefiles" v-show="false" accept="audio/mp3" multiple/>
data () {
return {
file: null,
fileList: null,
chunckList: []
}
},
methods: {
createChunkList (file) {
const fileChunkList = []
const chunkSize = 1024 * 1024 * 40 // 切片大小为40MB,大家随意
let cur = 0
while (cur < file.size) {
fileChunkList.push({ file: file.slice(cur, cur + chunkSize), percent: 0 })
cur += chunkSize
}
return fileChunkList
}
handlefiles (e) {
window.createWorker()
this.fileList = Array.from(e.target.files)
this.file = this.fileList[0]
this.chunckList = this.createChunkList(this.fileList.shift())
window.worker.postMessage(this.chunckList)
}
}
handleFiles中还需要在接收到webWorler线程的md5之后,验证后端是否已有该切片,以及将未发送的切片和md5信息上传至后端:
window.worker.onmessage = ({ data }) => {
const promiseList = []
this.chunckList.forEach((item, index) => {
promiseList.push(new Promise((resolve, reject) => {
this.$axios.post(‘/chunckAlready‘, { name: data.hash, hash: `${data.hash}-${index}` }).then(res => {
if (res.data.already === false) { // 后端不存在该切片,上传切片
const formData = new FormData()
formData.append(‘hash‘, `${data.hash}-${index}`)
formData.append(‘name‘, `${data.hash}`)
formData.append(this.fileName, item.file)
this.$upload.post(this.baseUrl, formData, {
onUploadProgress: progressEvent => {
this.chunckList[index].percent = (progressEvent.loaded / progressEvent.total * 100 | 0)
}
}).then(res => { resolve(res) })
} else {
this.chunckList[index].percent = 100
resolve()
}
})
}))
})
Promise.all(promiseList).then(() => { // 全部切片上传完毕,通知后端合并切片
this.$axios.post(‘/merge‘, { hash: data.hash, fileName: `${data.hash}-${this.file.name}` }).then((res) => {
this.$emit(‘finish‘, res.data)
if (this.fileList.length) { // 如果选中多个文件,重复切片、上传操作
this.file = this.fileList[0]
this.chunckList = this.createChunkList(this.fileList.shift())
window.worker.postMessage(this.chunckList)
} else {
this.showPercent = false
this.$nextTick(() => {
this.chunckList = []
window.worker.terminate()
})
}
})
})
}
2.2 后端部分
后端处理上传文件使用的multer插件,插件的使用不再介绍,下面是我的app.js,multer相关配置:
var Router = require(‘./route‘)
var multer = require(‘multer‘)
var storage = multer.diskStorage({
destination: function (req, file, cb) {
if (req.url === ‘/upload-chunck‘) {
fs.stat(`./public/music/chunck/${req.body.name}`, (err, stats) => {
if(err) { // 文件夹不曾存在,创建文件夹
fs.mkdir(`./public/music/chunck/${req.body.name}/`, () => { cb(null, `./public/music/chunck/${req.body.name}`) })
} else if (stats.isDirectory()) { // 存在,直接使用
cb(null, `./public/music/chunck/${req.body.name}`)
}
})
}
},
filename: function (req, file, cb) {
if (req.url === ‘/upload-chunck‘) {
cb(null, req.body.hash)
}
}
})
var upload = multer({ storage: storage });
app.post(‘/upload-chunck‘, upload.single(‘music‘), Router.uploadFile) // 上传切片
检测切片是否已存在:
app.js:
app.post(‘/chunckAlready‘, Router.cheackChunck)
Router.js
exports.cheackChunck = (req, res) => {
fs.stat(`./public/music/chunck/${req.body.name}/${req.body.hash}`, (err, stats) => {
if(err) {
console.log(‘查找失败‘,err)
res.send({ already: false })
} else if (stats.isFile()) {
res.send({ already: true })
}
})
}
通知合并:
app.js
app.post(‘/merge‘, Router.mergeChunck) // 合并切片
Router..js
function del(path) {
// 第一步读取文件内部的文件
let arr = fs.readdirSync(path)
// 遍历数组
for (let i = 0; i < arr.length; i++) {
// 获取文件的状态
let stat = fs.statSync(path + ‘/‘ + arr[i]);
// 判断是文件还是文件夹
if (stat.isDirectory()) {
// 说明是文件夹 递归调用
del(path + ‘/‘ + arr[i]);
} else {
// 说明是文件
fs.unlinkSync(path + ‘/‘ + arr[i]);
}
}
}
exports.mergeChunck = (req, res) => {
fs.readdir( `./public/music/chunck/${req.body.hash}/`,async function(err, files) {
files.sort((a, b) => { // 对切片排序
a = a.split(‘-‘)[1]
b = b.split(‘-‘)[1]
return Number(a) - Number(b)
})
let w = fs.createWriteStream(`./public/music/${req.body.fileName}`) // 创建用于写入的管道流
w.on(‘close‘, () => { // 关闭时删除切片文件及文件夹
del(`./public/music/chunck/${req.body.hash}`)
fs.rmdir(`./public/music/chunck/${req.body.hash}`, () => {})
})
function merge(chunck, finish) {
return new Promise((resolve, rej) => {
let r = fs.createReadStream(`./public/music/chunck/${req.body.hash}/${chunck}`)
r.pipe(w, { end: finish })
r.on(‘end‘, resolve)
})
}
for (let i = 0; i < files.length; i++) {
await merge(files[i], i === files.length -1)
}
res.send({
name: req.body.fileName,
src: `https://www.zsp.cool/music/${req.body.fileName}`
})
})
}
参考文章: https://mp.weixin.qq.com/s/xabsRAsBDoPfbRytAPikGA
欢迎联系交流: 1612977540@qq.com