上一篇文章中我们已经了解到了前端的文件切片上传以及后端的切片合并的思路及原理,这一次我们来聊聊文件的秒传以及断点续传。过程只有核心代码,完整代码看文末哈。
秒传是如何实现的?
每个文件能通过算法来计算hash值
,且相同的文件的hash
是一样的。这样我们就能在上传文件之前,调用接口查询数据库是否有相同的hash
的文件,如果有则直接返回上传成功。
前端计算hash
,一般使用第三方库spark-md5
。安装一下
css
npm i spark-md5
核心代码
vue
<template>
<div v-loading.fullscreen="loading" class="container">
<input type="file" @change="onFileChange" />
<el-button type="primary" @click="upload" :loading="loading">上传</el-button>
</div>
</template>
上一个文章是选项式API的写法,为了熟悉组合式API以及setup
语法糖,所以这里更改了写法。在input
标签监听change
事件,然后在事件会调用计算hash
值。FileReader
是一个文件读取类,能够以多种方式读取文件,如readAsArrayBuffer readAsDataURL readAsText
等等,读取是异步的,读取完成会执行onload
回调。
vue
import axios from 'axios';
import SparkMD5 from 'spark-md5'
import { ref } from 'vue';
import { ElMessage } from 'element-plus'
const file_hash = ref('')
const file = ref(null)
const filename = ref("")
const onFileChange = (event) => {
file.value = event.target.files[0]; //获取文件对象
filename.value = file.value.name
const reader = new FileReader();
const sparkk = new SparkMD5.ArrayBuffer()
reader.readAsArrayBuffer(file.value);
reader.onload = (e) => {
sparkk.append(e.target.result);//直接添加整个文件
file_hash.value =sparkk.end(); //调用end方法,返回hash
}
}
这时候我们拿到了文件的hash,文件名和文件对象。这时候我们写一个调接口的方法checkHaveFile
,check
接口是为了上传之前查看服务器有无该文件或该文件hash
。
vue
const checkHaveFile = async () => {
const res = await axios.get('http://127.0.0.1:3000/check', {
params: {
filename: filename.value,
file_hash: file_hash.value
}
});
console.log('res',res);
return res.data
}
从上面template
中可知在点击上传按钮的时候,调用upload
vue
const upload = async() => {
let continueChunk = 0
if (!file.value) {
ElMessage({
message: '请选择文件',
type: 'error',
})
return;
}
const haveFileRes = await checkHaveFile();
if (haveFileRes.code === 201) {
ElMessage({
message: '秒传成功',
type: 'success',
})
return
} else if (haveFileRes.code === 202) {
// ElMessage('断点续传');
continueChunk = haveFileRes.data
}
// 剩下的逻辑...
}
上一篇文章说过用nodejs来写接口,因为实现相对简单(其实是只会这个)。
先看一下我的node服务端的目录结构:
写这个check
接口。现在写一下后端的check
接口。可以看到引入了hashFns
。
js
const hashFns = require('./utils/hashFns')
app.get('/check', async(req, res) => {
const { filename, file_hash } = req.query;
const hasFile = await hashFns.checkHash(file_hash) //查看服务器有没有该文件
const hasChunk = await hashFns.hasChunk(file_hash) //查看是否存在切片,断点续传要用
console.log('hasChunk',hasChunk);
if(hasFile){
res.send({
code: 201,
msg: '秒传成功',
});
}else if(hasChunk!==-1){
res.send({
code:202,
msg:'断点续传',
data:hasChunk
})
}else{
res.send({
code:200,
msg:'文件不存在,继续上传',
})
}
})
js
//./utils/hashFns.js
//读取json文件是否存在该hash
const checkHash = (hash, filePath = path.resolve(__dirname, 'db.json')) => {
return new Promise((resolve, reject) => {
// 读取JSON文件
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error('Error reading file:', err);
reject()
return;
}
// 解析JSON数据
let jsonData = JSON.parse(data);
console.log(Object.keys(jsonData).includes(hash))
if (jsonData[hash]) {
console.log('true')
resolve(true)
} else {
console.log('false')
resolve(false)
}
});
})
}
鉴于主要提供思路及快速实现,我用一个db.json
文件模拟数据库
json
//db.json
{
"1da8e8fde4d9ab8e4047e8ca5493d420": "d:\\server\\uploadTest\\acceptFiles\\22.jpg",
"868f746a5ebbc15aab4a6185a0b1803b": "d:\\server\\uploadTest\\acceptFiles\\33.jpg"
}
可以看到以上checkHash
如果返回true
,则说明db.json
中找到了与前端上传的hash相同的文件,check
接口会返回{code:201,msg: '秒传成功'}
尝试一下,选择一个文件上传
服务器已经接收到
再次上传相同文件
断点续传是如何实现的?
其实和秒传的原理差不多,都是通过hash
来进行操作的。假如我要上传学习资料.mp4
文件,想要断点续传,那就先切片。假如分了10个切片,在上传了 5 个切片时,网络断了,服务器只收到了 5 个切片。这时候我要想重新上传剩下的切片,服务器需要一个这个文件hash
作为标识,就比如我直接用这个hash
当作目录名称了,那是不是我直接查找这个目录名为该hash
的目录内有多少个切片并且返回浏览器,前端就知道我们要从哪里开始上传了。 同样是先通过check
接口先查询,还记得node
接口check
是不是还调用了hashFns.hasChunk(file_hash)
,这个就是查询temp
目录有无相同hash的目录名存在的。
javascript
//./utils/hashFns.js
const hasChunk = (hash) => {
return new Promise((resolve, reject) => {
//查看是否存在 temp 文件夹
try {
// 检查目录是否存在
fs.accessSync(path.resolve(__dirname, "../temp"), fs.constants.F_OK);
console.log('Directory exists.');
} catch (err) {
if (err.code === 'ENOENT') {
console.log('Directory does not exist.');
fs.mkdirSync(path.resolve(__dirname, '../temp'), { recursive: true });
} else {
// 其他错误,如目录不可读取
console.error('An error occurred:', err);
}
}
//查看temp目录下是否有目录名与hash相同的目录
fs.readdir(path.resolve(__dirname, "../temp"), (err, files) => {
if (err) {
reject()
}
console.log("__files", files)
// resolve(files.indexOf(hash))
if (files.indexOf(hash) === -1) {
resolve(-1)
} else {
//如果有与该hash相同的文件,则读取该文件夹下的文件数,也就是切片数量
resolve(new Promise((resolveChunk, rejectChunk) => {
fs.readdir(path.resolve(__dirname, "../temp", hash), (err, chunkfiles) => {
if (err) {
rejectChunk()
}
resolveChunk(chunkfiles.length)
})
}))
}
})
})
}
如果有则会返回切片数量,check
接口会返回{ code:202, msg:'断点续传',data:hasChunk}
,data
就是服务器已存在的切片数量。前端进行切片操作的时候,根据这个数量来设置开始切片的index
就行了,当上传完成会调用/upload/complete
接口,以下分别是上传和上传完成的接口。
js
// 很显然,这里使用了个中间件,是存储文件的
app.post('/upload',upload.single('file'),(req, res) => {
res.send({
code: 200,
msg: '上传成功',
});
});
//当前端所有切片上传完成之后,会调用这个接口进行合并
app.post('/upload/complete', (req, res) => {
const file_hash = req.body.file_hash;
const filename = req.body.filename;
console.log('file_hash',file_hash);
try {
combineChunks(ACCEPTDIR,path.resolve(__dirname, 'temp',file_hash),filename)
.then(() => {
hashFns.updatedHash(file_hash,path.resolve(ACCEPTDIR, filename) )
res.json('文件上传完成');
})
}catch (error) {
res.json('文件上传失败', err);
fs.rm(path.resolve(__dirname,'temp', file_hash), { recursive: true }, (err) => {
if (err) {
// 处理错误
console.error('临时文件夹删除失败',err);
} else {
// 删除成功
console.log('临时文件夹删除成功.');
}
} );
console.log('合并文件时发生错误', error);
}
});
对于upload
接口的中间件以及合并切片操作可以参考上篇文章或者下载完整demo代码(文末)。
我们来试一下,选择文件
点击上传然后快速按 F5 模拟上传中断。可以看到temp文件夹存在了几个切片且目录名是hash
。
再次选择相同文件,点击上传,check
接口返回数据data:6
,根据这个数来再次分片上传。
可以看到服务器已经上传成功。这个时候有个问题,上一次上传的切片是删不掉的(因为我已经改了,图片没有展示出来),因为上次上传中断了,mutler
这个第三方包并没有关闭文件流,这个时候我们要监听一下aborted
事件,在这个事件触发的时候关闭文件流。这样就大功告成了!
js
const storage = multer.diskStorage({
// 设置文件存储的位置,其中回调函数的第一个参数表示错误信息,第二个参数表示文件的存储路径
destination: function (req, file, cb) {
// 创建临时文件夹
try {
fs.mkdirSync(path.resolve(__dirname, 'temp',req.body.fileHash), { recursive: true });
} catch (error) {
console.log(' 创建临时文件夹失败',error);
}
cb(null, path.resolve(__dirname, 'temp',req.body.fileHash));
},
// 设置文件名,同上,第一个参数表示错误信息,第二个参数表示文件名
filename:async function (req, file, cb) {
req.on('aborted',(e)=>{
file.stream.emit('end') //手动关闭流
})
await cb(null, file.fieldname + '_' + req.body.chunkIndex);
}
});
完整demo代码gitee.com/cweile/uplo...