vue3 + node大文件分片上传(前后端, 包括文件秒传、断点续传)
vue3+typescript+node实现大文件切片上传,断点续传
·
vue3 + node大文件分片上传(前后端, 包括文件秒传、断点续传)
前端html部分:
<template>
<div>
<input type="file" @change="handleUpload" multiple><br>
<progress :value="progressNum" max="100"></progress>
<div>{{ progressNum }}% {{ text }}</div>
</div>
</template>
ts部分
<script lang="ts" setup>
import SparkMD5 from 'spark-md5'
import { ref } from 'vue'
// hash
let fileHash = ref<string>('')
// 文件名
let fileName = ref<string>('')
// 进度条
let progressNum = ref<number>(0)
// 文字
let text = ref<string>('')
const handleUpload = async (e: Event) => {
const files = (e.target as HTMLInputElement).files
if (!files) return
// console.log(files[0]);
// 文件名
fileName.value = files[0].name // 上传需要的数据
// 文件分片
let chunks = createChunks(files[0]) // 数组
// console.log(chunks);
// 文件hash计算
const hash = await calculateHash(chunks)
// console.log(hash);
fileHash.value = hash as string
// 文件hash校验 (文件秒传 在上传文件前,就要把文件hash值告诉后端, 如果后端有该文件,直接返回上传成功)
let shouldUpload = await verify()
console.log(shouldUpload);
if (shouldUpload.data.shouldUpload) { // true: 服务器没有该文件需要上传
// 上传分片
uploadChunks(chunks, shouldUpload.data?.existChunks)
}
// 上传分片
// uploadChunks(chunks)
}
// 分片大小
const CHUNK_SIZE = 1024 * 1024 // 1m
// 文件分片
const createChunks = (file: File) => {
let chunks = []
for (let i = 0; i < file.size; i += CHUNK_SIZE) {
chunks.push(file.slice(i, i + CHUNK_SIZE))
}
return chunks
}
// 计算文件的hash
const calculateHash = (chunks: Blob[]) => {
return new Promise((resolve) => {
// 1. 第一个和最后一个切片全部参与计算
// 2. 中间的切片只计算前面两个字节、中间两个字节、最后两个字节
const targets: Blob[] = [] // 存储所有参与计算的切片 blob数组
// 循环产生新的分片数组
chunks.forEach((chunk, index) => {
if (index === 0 || index === chunks.length - 1) {
// 1. 第一个和最后一个切片全部参与计算
targets.push(chunk)
} else {
// 2. 中间的切片只计算前面两个字节、中间两个字节、最后两个字节
// chunk也是一个blob对象, 大小是CHUNK_SIZE
targets.push(chunk.slice(0, 2)) // 前面两个字节
targets.push(chunk.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2)) // 中间两个字节
targets.push(chunk.slice(CHUNK_SIZE - 2, CHUNK_SIZE)) // 最后两个字节
}
})
const spark = new SparkMD5.ArrayBuffer()
const fileReader = new FileReader()
// new Blob(targets) 将新分片数据转为blob对象
// 文档用法
fileReader.readAsArrayBuffer(new Blob(targets))
// onload方法是异步的
fileReader.onload = (e) => {
spark.append((e.target as FileReader).result)
// 拿到计算出来的hash值
// console.log(spark.end());
resolve(spark.end())
}
})
}
// 上传分片
const uploadChunks = async (chunks: Blob[], existChunks: string[]) => {
const dataObj = chunks.map((chunk, index) => {
// chunk 每个blob对象
// console.log(chunk, index);
// 返回上传需要的数据
return {
fileHash: fileHash.value, // 文件的hash: 区分上传的是哪个文件
chunkHash: fileHash.value + '-' + index, // 切片的hash
chunk
}
})
// console.log(dataObj); // 数组对象 [{fileHash: xxx, chunkHash: xxx, chunk}]
// 每个切片都要有formData对象
const formDatas = dataObj.filter((item) => !existChunks.includes(item.chunkHash)).map((item) => {
// console.log(item); {fileHash: xxx, chunkHash: xxx, chunk}
const formData = new FormData()
formData.append('fileHash', item.fileHash)
formData.append('chunkHash', item.chunkHash)
formData.append('chunk', item.chunk)
return formData
})
// console.log(formDatas);
const max = 6 // 最大并发请求数
const taskPool: any = [] // 请求池: 用来存放当前执行的请求 Promise数组
let num = 0 // 当前上传成功数量
text.value = '上传中...'
for (let i = 0; i < formDatas.length; i++) {
// i: 用来标识当前上传到第几个
// task: Promise
const task = fetch('http://127.0.0.1:3000/upload', {
method: 'POST',
body: formDatas[i]
})
// 请求完成从请求池移除
task.then(() => {
num++
progressNum.value = Math.round((num / formDatas.length) * 100) / 100 * 100
taskPool.splice(taskPool.findIndex((item: any) => item === task))
})
taskPool.push(task) // 将每个请求放入请求池数组中
// 请求池已经达到最大请求数, 需要等待请求池中要有完成的请求(完成一个就行)
if (taskPool.length === max) {
await Promise.race(taskPool) // 一个完成 promise状态为成功
}
}
// 为了保证请求池中的请求全部完成
await Promise.all(taskPool)
// 全部完成后, 通知服务器去合并分片
mergeRequest()
}
// 合并分片
const mergeRequest = () => {
fetch('http://127.0.0.1:3000/merge', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
fileHash: fileHash.value,
fileName: fileName.value,
size: CHUNK_SIZE
})
}).then(res => {
console.log('合并成功');
text.value = '上传成功'
})
}
// 校验文件hash
const verify = () => {
return fetch('http://127.0.0.1:3000/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
fileHash: fileHash.value,
fileName: fileName.value // 用来获取文件后缀的
})
})
.then(res => res.json())
.then(data => {
return data
})
}
</script>
node后端部分:
const express = require('express')
const path = require('path')
const multiparty = require('multiparty')
const fse = require('fs-extra')
const cors = require('cors')
const bodyParser = require('body-parser')
const app = express()
app.use(bodyParser.json())
app.use(cors())
// upload文件夹目录
const UPLOAD_DIR = path.resolve(__dirname, 'upload') // __dirname当前目录
// 提取文件后缀名
const extractExt = fileName => {
return fileName.slice(fileName.lastIndexOf('.'), fileName.length)
}
app.post('/upload', (req, res) => {
const form = new multiparty.Form()
form.parse(req, async (err, fields, files) => {
// fields 可获取普通数据信息。
try {
// files 主要是获取文件数据信息。
if (err) {
return res.json({
status: 401,
msg: '上传失败'
})
}
// console.log('fields',fields);
// console.log('files',files);
// const fileHash = fields['fileHash'][0]
// const chunkHash = fields['chunkHash'][0]
const fileHash = fields.fileHash[0]
const chunkHash = fields.chunkHash[0]
// 临时存放目录
const chunkPath = path.resolve(UPLOAD_DIR, fileHash)
// console.log('chunkPath', chunkPath);
if (!fse.existsSync(chunkPath)) {
// fse.existsSync(chunkPath) 判断目录是否存在,不存在创建目录
await fse.mkdir(chunkPath)
}
const oldPath = files.chunk[0].path // multiparty创建的临时目录
// 将切片放入该文件夹中
await fse.move(oldPath, path.resolve(chunkPath, chunkHash))
res.json({
status: 200,
msg: '上传成功'
})
} catch (error) {
}
})
})
app.post('/merge', async (req, res) => {
try {
const { fileHash, fileName, size } = req.body
// console.log(fileHash, fileName, size);
// 完整文件路径
const filePath = path.resolve(UPLOAD_DIR, fileHash + extractExt(fileName))
// 如果已经存在该文件,就没必要合并
if (fse.existsSync(filePath)) {
return res.json({
status: 200,
msg: '合并成功'
})
}
// 如果不存在该文件,就合并文件
const chunkDir = path.resolve(UPLOAD_DIR, fileHash) // 切片总目录
// 切片目录不存在,需要重新上传文件
if (!fse.existsSync(chunkDir)) {
res.json({
status: 401,
msg: '合并失败,请重新上传'
})
}
// 开始合并操作
const chunkPaths = await fse.readdir(chunkDir) // 所有的切片文件名(数组)
// console.log(chunkPaths);
// 将所有切片排序(根据末尾数字排序)
chunkPaths.sort((a, b) => {
return a.split('-')[1] - b.split('-')[1]
})
const list = chunkPaths.map((chunkName, index) => { // chunkName 每个切片文件名
return new Promise((resolve) => {
const chunkPath = path.resolve(chunkDir, chunkName) // 每个切片路径
// 读取切片内容 读取流
const readStream = fse.createReadStream(chunkPath)
// 写入流
const writeStream = fse.createWriteStream(filePath, { // filePath 完整文件路径
start: index * size,
end: (index + 1) * size
})
// 将读取流放到写入流中
readStream.pipe(writeStream)
// 读取完后, 将切片删除
readStream.on('end', async () => {
await fse.unlink(chunkPath)
resolve()
})
})
})
// list中所有的文件都合并完后,将存放所有切片的目录删掉
await Promise.all(list)
await fse.remove(chunkDir)
res.json({
status: 200,
msg: '合并成功'
})
} catch (error) {
}
})
// 文件秒传 在上传文件前,就要把文件hash值告诉后端
app.post('/verify', async (req, res) => {
try {
const { fileHash, fileName } = req.body
const filePath = path.resolve(UPLOAD_DIR, fileHash + extractExt(fileName)) // 文件路径
if (fse.existsSync(filePath)) {
// 文件存在,不需要重新上传
res.json({
status: 200,
data: {
shouldUpload: false // 不需要重新上传
}
})
} else {
// 文件不存在,重新上传
// 返回服务器已经上传成功的切片
const chunkDir = path.join(UPLOAD_DIR, fileHash)
let chunkPaths = []
if (fse.existsSync(chunkDir)) {
// 如果存在已经上传的部分切片, 只需要上传没有的切片(断点续传)
chunkPaths = await fse.readdir(chunkDir)
}
res.json({
status: 200,
data: {
shouldUpload: true, // 重新上传
existChunks: chunkPaths
}
})
}
} catch (error) {
}
})
app.listen(3000, () => {
console.log(`Example app listening on port http://127.0.0.1:${3000}`)
})
欢迎各位前端大佬一起交流
更多推荐
已为社区贡献1条内容
所有评论(0)