前端:Vue3
效果:
HTML相关代码:
<template>
<!-- 大文件切片上传和断点续传 -->
<div class="item">
<h3>大文件切片上传和断点续传</h3>
<section class="upload_box" id="upload4">
<input type="file" class="upload_inp" ref="input" @change="changeInput" />
<div class="upload_button_box">
<button class="upload_button select" :class="{ loading: isLoading }" @click="changeFile">
上传文件
</button>
</div>
<div class="upload_progress" v-show="isShow">
<div class="progress" ref="progress"></div>
</div>
<span class="percent" ref="percent" v-show="isShow">0%</span>
</section>
</div>
</template>
scss样式相关代码:
<style lang="scss" scoped>
.item {
// 文件上传盒子
.upload_box {
position: relative;
padding: 10px;
box-sizing: border-box;
//width: 400px;
min-height: 100px;
border: 3px dashed rgb(218, 232, 233);
// input
.upload_inp {
display: none; // 隐藏原生的input,太丑了
}
// 选择文件盒子
.upload_button_box {
// 选择文件、上传文件到服务器
.upload_button {
position: relative;
margin-right: 10px;
min-width: 80px;
height: 30px;
padding: 0 10px;
line-height: 30px;
border: none;
cursor: pointer;
background-color: #ddd;
overflow: hidden;
/*将伪类隐藏出来*/
&::before,
&::after {
position: absolute;
left: 0;
z-index: 999;
transition: top 0.3s; // 过渡
width: 100%;
height: 100%;
padding-left: 25px;
box-sizing: border-box;
text-align: left;
}
&::before {
top: -30px;
content: "";
background: #eee;
color: #999;
}
&::after {
top: 30px;
content: "loading...";
background: #eee url("../assets/images/loading.gif") no-repeat 5px center;
color: #999;
}
}
//选择文件按钮
.select {
background: #409eff;
color: #fff;
}
//上传文件到服务器按钮
.upload {
background: #67c23a;
color: #fff;
}
// 动态类:loading
.loading {
cursor: inherit; // 文件上传中,鼠标恢复默认
&::before,
&::after {
top: 0;
}
}
// 动态类:disable
.disable {
background: #eee;
color: #999;
cursor: inherit;
}
}
// 进度条盒子
.upload_progress {
width: 90%;
display: inline-block;
position: relative;
margin-top: 10px;
height: 3px;
background: #777777;
border-radius: 10px;
.progress {
position: absolute;
top: 0;
left: 0;
z-index: 999;
font-size: 12px;
color: #fff;
height: 100%;
background: #67c23a;
text-align: right;
border-radius: 10px;
box-sizing: border-box;
transition: width 0.1s;
}
}
.percent {
display: inline-block;
font-size: 12px;
color: #67c23a;
margin-left: 5px;
}
}
}
</style>
javascript相关代码:
<script setup>
import { ref } from "vue";
import $axios from "../utils/axios";
import SparkMD5, { hash } from "spark-md5";
const input = ref(null); // 获取input元素
const progress = ref(null); // 获取进度条元素
const percent = ref(null) // 获取百分比
let _file = ref(null); // 文件源
let isShow = ref(false); // 隐藏提示
let isLoading = ref(false); // 禁用文件上传到服务器按钮
let index = ref(0)
let count = ref(0)
// 选择文件按钮
function changeFile() {
// 判断选择文件按钮 或者 文件上传到服务器按钮 是否为禁用
if (isLoading.value) return;
// 实际上是手动触发原生的input
input.value.click();
}
// 4.切片合并
async function mergeSlice(HASH, count) {
// 管控进度条
index.value++;
// 设置进度条的宽
progress.value.style.width = `${((index.value / count) * 100).toFixed(0)}%`;
// 设置进度条100%
percent.value.innerHTML = `${((index.value / count) * 100).toFixed(0)}%`;
console.log('!!!', index.value, HASH, count)
// 当所有切片都上传成功,再发送请求合并所有的切片
if (index.value == count) {
try {
let data = await $axios.post('/upload_merge', { HASH, count }, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
});
console.log(data)
if (+data.code === 0) {
alert(`恭喜您,文件上传成功,您可以基于 ${data.servicePath} 访问该文件~~`);
return;
}
throw data.codeText;
} catch (err) {
alert('切片合并失败,请您稍后再试~~');
}
};
}
// 3.把每一个切片都上传到服务器上
function uploadChunks(chunks, HASH) {
chunks.forEach(async (chunk) => {
let fm = new FormData;
fm.append('file', chunk.file);
fm.append('filename', chunk.filename);
// 发送请求上传所有切片
try {
const data = await $axios.post('/upload_chunk', fm)
if (+data.code === 0) {
console.log(data)
// 发送请求合并所有切片
mergeSlice(HASH, count.value);
}
// if(+data.code === 2) { // 文件已存在
// // 设置进度条的宽
// progress.value.style.width = `100%`;
// // 设置进度条100%
// percent.value.innerHTML = `100%`;
// console.log(data)
// }
} catch (error) {
// 错误处理
console.log(error)
} finally {
}
});
}
// 2.实现文件切片处理 「固定数量 & 固定大小」
function fileSlice(file, HASH, suffix, size) {
let max = 1024 * 10 * size // 切片大小
count.value = Math.ceil(file.size / size) // 切片数量
let chunks = []
let index = 0
// 如果切片数量 大于20
if (count.value > 20) {
max = file.size / 20; // 重新计算最大切片大小
count.value = 20; // 重新计算最大切片数量
}
while (index < count.value) {
chunks.push({
// 切片
file: file.slice(index * max, (index + 1) * max),
// 切片名
filename: `${HASH}_${index + 1}.${suffix}`
});
index++;
}
return { chunks }
}
// 1.将文件读取成ArrayBuffer
function changeBuffer(file) {
return new Promise((resolve, reject) => {
let fileReader = new FileReader()
// 将数据读取到:数组缓冲区
fileReader.readAsArrayBuffer(file)
// 取完成后通过 onload 事件处理函数来处理读取到的二进制数据
fileReader.onload = (ev) => {
// 拿到数组缓冲区数据
let buffer = ev.target.result
let spark = new SparkMD5.ArrayBuffer()
spark.append(buffer)
// 难道文件的hash值,相同内容的文件hash值是一样的
let HASH = spark.end();
// 文件后缀名 例如:mp4
let suffix = /\.([a-zA-Z0-9]+)$/.exec(file.name)[1];
resolve({
buffer, // 数组缓冲区数据
HASH, // hash值
suffix, // 文件后缀名
filename: `${HASH}.${suffix}` // 文件名
})
}
})
}
// 监听用户选择文件的操作(注意:文件改变触发)
async function changeInput(e) {
// 拿到文件对象,注意:是数组
_file.value = e.target.files[0];
if (!_file.value) return;
isShow.value = true
isLoading.value = true
// 1.获取文件的HASH
const { HASH, suffix } = await changeBuffer(_file.value);
console.log(HASH, suffix)
// 2.实现文件切片处理
const { chunks } = fileSlice(_file.value, HASH, suffix, 100)
console.log(chunks)
// 3.上传切片前,查看文件是否已经上传过
try {
const data = await $axios.get(`/has_uploaded?HASH=${HASH}`)
console.log(data)
if (+data.code === 2) {
// 设置进度条的宽
progress.value.style.width = `100%`;
// 设置进度条100%
percent.value.innerHTML = `100%`;
return
}
} catch (error) {
} finally{
// isShow.value = true
isLoading.value = false
}
// 4.把每一个切片都上传到服务器上
uploadChunks(chunks, HASH)
}
后端:node.js
const express = require('express'),
fs = require('fs'),
paths = require('path'),
bodyParser = require('body-parser'),
// 插件1:处理上传文件(解析form-data文件)
multiparty = require('multiparty'),
// 处理客户端文件hash名字
SparkMD5 = require('spark-md5')
const https = require('https')
const http = require('http')
// 创建app
const app = express(),
PORT = 8889, // 端口
HOST = 'http://127.0.0.1', // 协议
HOSTNAME = `${HOST}:${PORT}`; // 拼接协议、端口
// 静态资源
app.use('/upload',express.static('upload'))
// 监听
app.listen(PORT, () => {
console.log(`端口:${PORT},链接:${HOSTNAME}`)
})
// 创建文件并写入到指定的目录 & 返回客户端结果
// 参数:(响应头,要写入的路径,文件内容,文件名)
const writeFile_formdata = function writeFile(res, path, file, filename) {
return new Promise((resolve, reject) => {
// 文件流
try {
// 创建可读流,并读取其中的是数据,【file.path 是客服端的文件路径】
let readStream = fs.createReadStream(file.path),
// 创建可写流,【file.path 是需要写入到服务器的文件路径】
writeStream = fs.createWriteStream(path);
// 当有新的数据可供读取时,readStream会自动推入writeStream中
readStream.pipe(writeStream);
readStream.on('end', () => {
resolve();
// unlinkSync()函数同步删除文件
fs.unlinkSync(file.path);
res.send({
code: 0,
codeText: '上传成功',
originalFilename: filename,
servicePath: path.replace(__dirname, HOSTNAME)
});
});
} catch (err) {
reject(err);
res.send({
code: 1,
codeText: err
});
}
});
};
// 判断文件是否已经上传过
app.get('/has_uploaded',async (req,res) => {
const {HASH} = req.query
// 合并好的文件路径
let mergePath = `${uploadDir}/${HASH}.mp4`
// 查看文件是否存在
let isExists = await exists(mergePath);
console.log(mergePath,isExists)
if(isExists) {
res.send({
code: 2,
codeText: '文件已存在',
servicePath: mergePath.replace(__dirname, HOSTNAME)
});
return
}
res.send({
code: 1,
codeText: '文件不存在,开始上传切片',
});
})
// 大文件切片上传 & 合并切片
const merge = function merge(HASH, count) {
return new Promise(async (resolve, reject) => {
// 找到需要合并的切片目录
let path = `${uploadDir}/${HASH}`
let fileList = [] // 切片名称数组
let suffix // 后缀
let isExists // 是否存在
// 判断是否存在切片目录
isExists = await exists(path);
// 不存在目录
if (!isExists) {
reject('根据HASH值查找目录,找不到路径!');
return;
}
// 读取目录
fileList = fs.readdirSync(path);
// 目录是否为空
if (fileList.length < count) {
reject('切片还没有上传!');
return;
}
// 切片排序并遍历
fileList.sort((a, b) => {
let reg = /_(\d+)/;
return reg.exec(a)[1] - reg.exec(b)[1];
}).forEach(item => {
!suffix ? suffix = /\.([0-9a-zA-Z]+)$/.exec(item)[1] : null;
// 追加切片数据
fs.appendFileSync(`${uploadDir}/${HASH}.${suffix}`, fs.readFileSync(`${path}/${item}`));
// 删除切片
fs.unlinkSync(`${path}/${item}`);
});
// 删除切片目录
fs.rmdirSync(path);
resolve({
path: `${uploadDir}/${HASH}.${suffix}`,
filename: `${HASH}.${suffix}`
});
});
};
// 切片存储
app.post('/upload_chunk', async (req, res) => {
try {
let { fields, files } = await multiparty_upload(req);
// 文件对象
let file = (files.file && files.file[0]) || {}
// 文件名称
let filename = (fields.filename && fields.filename[0]) || ""
let path = ''
// 创建存放切片的临时目录(HASH命名)
let [, HASH] = /^([^_]+)_(\d+)/.exec(filename);
// 目录路径
path = `${uploadDir}/${HASH}`;
// 判断目录是否存在,不存在就创建此目录
!fs.existsSync(path) ? fs.mkdirSync(path) : null;
// 把切片存储到临时目录中
path = `${uploadDir}/${HASH}/${filename}`;
// 不存在就写入
writeFile_formdata(res, path, file, filename);
} catch (err) {
res.send({
code: 1,
codeText: err
});
}
});
// 当所有切片都上传成功,我们合并切片
app.post('/upload_merge', async (req, res) => {
let {HASH,count} = req.body;
try {
let {filename,path} = await merge(HASH, count);
res.send({
code: 0,
codeText: '合并成功',
originalFilename: filename,
servicePath: path.replace(__dirname, HOSTNAME)
});
} catch (err) {
res.send({
code: 1,
codeText: err
});
}
});