最近看了一个关于大文件切片上传,就想自己实现一下包含普通文件上传、切片上传、切片上传后合并、断点续传等功能
首先做一个checkList
,按照checkList
逐个实现
- 严格验证文件格式和大小
- 实现上传百分比
- 上传相同文件处理
- 文件预览
- 切片上传,合并上传文件
- 断点续传
项目搭建
搭建客户端
创建vite
项目,我使用的是vue3+ts
npm create vite@latest
安装依赖
npm run install
运行项目
npm run dev
引入element+plus
npm install element-plus --save
//main.ts
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
const app = createApp(App)
app.use(ElementPlus)
app.mount('#app')
引入axios
npm install axios --save
const app = createApp(App)
app.config.globalProperties.$axios = axios
封装请求
在src
目录下新建index.ts
import axios from "axios"
//声明请求参数类型
export interface RequestParams {
url: string
method: string
params: any
isFile?: boolean
}
//axios配置
axios.defaults.baseURL = "http://localhost:3000"
axios.defaults.headers.common["Access-Control-Allow-Origin"] = "*"
//设置代理的时候会用到
axios.defaults.baseURL = "/api"
//封装请求方法
export function request(config: RequestParams) {
const { url, method, params, isFile } = config
return new Promise((resolve, reject) => {
axios({
method: method,
url: url,
params: params,
headers: {
//设置的默认请求头内容类型,也可以自己传参进行覆盖
"Content-Type": "multipart/form-data",
},
})
.then((res) => {
resolve(res)
})
.catch((error) => {
reject(error)
})
})
}
对接口进行封装后,这样可以通过在vue
文件中使用uploadFileApi().then()
的方式来调用接口,比较方便,或者可以直接使用axios().then()
直接调用的方式也可以
//api.ts
import { request } from "./index"
export function uploadFileApi(data: any) {
return request({
url: "/upload",
method: "post",
params: data,
})
}
创建upload
组件
<template>
<el-upload
v-model:file-list="fileList"
class="upload-demo"
multiple
:on-preview="handlePreview"
:on-remove="handleRemove"
:before-remove="beforeRemove"
:limit="3"
:on-exceed="handleExceed"
:before-upload="beforeUpload"
:http-request="httpRequest"
>
<el-button type="primary">点击上传</el-button>
<template #tip>
<div class="el-upload__tip">
jpg/png files with a size less than 500KB.
</div>
</template>
</el-upload>
</template>
<script lang="ts" setup>
//文件预览
const handlePreview: UploadProps["onPreview"] = (uploadFile) => {
console.log(uploadFile)
}
//文件移除事件
const handleRemove: UploadProps["onRemove"] = (file, uploadFiles) => {
console.log(file, uploadFiles)
}
//文件移除前事件
const beforeRemove: UploadProps["beforeRemove"] = (uploadFile, uploadFiles) => {
return ElMessageBox.confirm(
`Cancel the transfert of ${uploadFile.name} ?`
).then(
() => true,
() => false
)
}
//文件大小超出限制事件
const handleExceed: UploadProps["onExceed"] = (files, uploadFiles) => {
ElMessage.warning(
`The limit is 3, you selected ${files.length} files this time, add up to ${
files.length + uploadFiles.length
} totally`
)
}
//上传前钩子钩子函数
const beforeUpload: UploadProps["beforeUpload"] = async (uploadFile) => {}
//自定义上传方法
const httpRequest = async function (options: UploadRequestOptions) {}
</script>
在App.vue
中引入组件
搭建服务端
在src
同级目录下新建server
目录,新建index.cjs
,使用koa
创建服务端,使用koa-body
处理参数信息,使用koa-router
创建服务端路由,使用koa-cors
解决服务端跨域问题
//index.cjs
const Koa = require("koa")
const { koaBody } = require("koa-body")
const cors = require("koa-cors")
const router = require("./routes.cjs")
//读取流
const app = new Koa()
app
.use(
koaBody({
multipart: true,
formidable: {
maxFileSize: 200 * 1024 * 1024, // 设置上传文件大小最大限制,默认2M
},
})
)
.use(router.routes())
.use(cors())
.listen(3000)
路由信息配置
const Router = require("koa-router")
//引入路由
const router = new Router()
module.exports = router
package.json
中配置server
快速启动命令。
{
"serve": "node ./server/index.cjs",
}
严格验证文件格式和大小
通常我们会用后缀名来验证文件格式,但是这样是不准确的,其实每种类型的文件读取为二进制文件时都有特定的标头,参考
代码实现,在上传前钩子里面进行校验
//util.js
type UtilsTypes = {
readBuffer(file: File, start: number, end: number): Promise<any>
//string和String有区别,String被认为是一个类
getFileSuffix(unit8Array: Uint8Array): string
}
//截取文件流的后缀
const Utils: UtilsTypes = {
readBuffer(file, start = 0, end = 2) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
resolve(reader.result)
}
reader.onerror = () => {
reject()
}
reader.readAsArrayBuffer(file.slice(start, end))
})
},
//根据截取的文件流内容获取文件后缀
getFileSuffix(unit8Array) {
let suffix = ""
switch (unit8Array.join(" ")) {
case "137 80 78 71 13 10 26 10":
suffix = ".png"
break
case "47 49 46 38 39(37) 61":
suffix = "gif"
break
case "ff d8 ff":
suffix = "jpeg"
break
case "ff d9 ff":
suffix = ".jpg"
break
case "ff d9 ff":
suffix = ".jpg"
break
default:
break
}
return suffix
},
}
export default Utils
文件大小的判断,根据文件里面的属性size
,可以读取到这个属性,然后和规定的文件大小做判断就可以了
import { ref, toRaw } from "vue"
import {
ElMessage,
ElMessageBox,
ProgressProps,
UploadFile,
UploadRequestOptions,
} from "element-plus"
import Utils from "../util"
//可接受文件类型
const accepts = [".png"]
//单位M
const size = 10
const beforeUpload: UploadProps["beforeUpload"] = async (uploadFile) => {
// console.log(Utils.readBuffer(uploadFile, 0, 8))
//读取文件验证文件后缀,防止通过修改文件名更新文件格式
const fileBufferPrefix = await Utils.readBuffer(uploadFile, 0, 8)
const unit8Array = new Uint8Array(fileBufferPrefix)
const fileSuffix = Utils.getFileSuffix(unit8Array)
//文件后缀判断
if (!fileSuffix || !accepts.includes(fileSuffix)) {
ElMessage(`请选择支持的文件格式${accepts.join("、")}`)
return false
}
//文件大小判断
const isLt10M = uploadFile.size / 1024 / 1024 < size
if (!isLt10M) {
ElMessage(`请选择小于${size}M的文件进行上传!`)
return false
}
}
实现上传百分比
axios
里面提供的有一个方法onUploadProgress
,上传处理进度事件,可以监听到上传进度
代码实现
vue部分实现,需要给上传文件加一个进度条
<!--文件上传百分比-->
<el-progress
v-if="showProgress"
:percentage="uploadPercentage"
status="success"
/>
js部分实现
//进度条
let uploadPercentage = ref<Number>(0)
//控制是否展示进度条
let showProgress = ref<Boolean>(false)
const httpRequest = function (options: UploadRequestOptions) {
let formData = new FormData()
formData.append("uploadFile", options.file)
axios({
url: "/api/upload",
method: "post",
data: formData,
// `onUploadProgress` 允许为上传处理进度事件
onUploadProgress: function (progressEvent) {
const { loaded, total = 0 } = progressEvent
uploadPercentage.value = (loaded / total) * 100
showProgress.value = true
},
}).then(async (res) => {
if (res.status) {
ElMessage(`上传成功!`)
//上传成功后将进度条置为0,隐藏进度条
showProgress.value = false
uploadPercentage.value = 0
}
})
}
上传相同文件处理
使用spark-md5
根据文件内容生成唯一hash值,代码实现,在httpRequest
里面,处理上传文件名
const httpRequest = function (options: UploadRequestOptions) {
let formData = new FormData()
formData.append("uploadFile", options.file)
console.log(options.file)
const name = options.file.name
const suffix = name.split(".")[1]
const buffer = getBuffer(options.file)
//读取到文件对象,将文件名替换为hash值的文件名
const spark = new sparkMd5.ArrayBuffer()
spark.append(buffer)
console.log(spark.end())
const fileName = spark.end()
// options.file.name = fileName
formData.append("fileName", `${fileName}.${suffix}`)
//文件上传axios请求内容
}
后端upload接口实现
//request.cjs
module.exports = {
//上传文件
upload: "/upload",
//上传切片
uploadChunks: "/uploadChunks",
//合并文件
mergeFiles: "/mergeFiles",
//新-切片上传
uploadChunksNew: "/uploadChunksNew",
//合并文件
mergeFilesNew: "/mergeFilesNew",
//检查合并后得文件
checkUploaded: "/checkUploaded",
}
通过koabody
接收上传的文件信息,不做重命名的时候koa-body
里面会自己做处理的,所以我们需要自己根据文件内容生成一个hash值来处理上传相同文件这样的场景
//routes.cjs
const path = require("path")
//获取文件名
const sparkMd5 = require("spark-md5")
const requestApi = require("./request.cjs")
const { koaBody } = require("koa-body")
router.post(
requestApi.upload,
koaBody({
multipart: true, //支持多文件上传
formidable: {
uploadDir: path.join(__dirname, "./upload"),
onFileBegin: (name, file) => {
//如果不会自动做上传内容重复处理的,可以自己进行判断
//判断文件是否存在
fs.stat(
path.join(__dirname, `./upload/${fileName}.${suffix}`),
function (err, stats) {
if (!stats) {
//文件处理操作
//会自动进行去重处理
const spark = new sparkMd5.ArrayBuffer()
spark.append(file)
const fileName = spark.end()
// console.log(fileName)
const suffix = file.originalFilename.split(".")[1]
const newFileName = file.filepath.slice(
file.filepath.indexOf("upload") + 7
)
file.filepath = file.filepath.replace(
newFileName,
`${fileName}.${suffix}`
)
} else {
return false
}
}
)
},
onError: (error) => {
//上传失败处理
},
},
}),
async (ctx) => {
const body = ctx.request.body
const files = ctx.request.files.uploadFile
//读取文件并且保存文件
ctx.response.body = {
status: true,
message: "操作成功",
result: {
fileName: files.newFilename,
},
}
}
)
文件预览
上传成功后,预览文件,实际情况可能返回一个url链接,直接给el-image
组件赋值就可以了,我自己模拟的没有用这个,就直接读取文件然后赋值了
//图片预览地址
let imgUrl = ref<String>("")
//文件预览地址
let previewList = ref<String[]>([])
//上传成功后的处理里面
const imageUrl = await previewImage(options.file)
imgUrl.value = imageUrl
previewList.value = [imageUrl]
//文件预览
function previewImage(file: File): Promise<string> {
return new Promise((resolve) => {
let fileReader = new FileReader()
fileReader.readAsDataURL(file)
fileReader.onload = (ev) => {
resolve(ev.target!.result as string)
}
})
}
切片上传
读取文件,将文件按照大小进行切片,然后对每个块进行标识,然后上传,然后再进行合并文件,我将读取到的切块的文件放在temp
目录,然后合并完成后将所有的片再删除
// 单位kb
const is10Kb = 20 * 1024
const fileList = ref<UploadUserFile[]>([])
let sliceFiles = ref<BlobFile[]>([])
interface BlobFile {
chunk: Blob
fileName: string
}
//切片上传
const httpRequest = async function (options: UploadRequestOptions) {
let originFileName = options.file.name
const fileArr = sliceFiles.value
let filename = ""
fileArr.forEach(async (item: BlobFile, index: number) => {
const { chunk, fileName } = item
filename = fileName
const formData = new FormData()
formData.append("chunk", chunk)
formData.append("fileName", fileName)
console.log(chunk)
axios({
url: "/api/uploadChunksNew",
method: "post",
data: formData,
headers: {},
}).then((res) => {
//合并文件
mergeFiles(filename, originFileName)
})
// })
})
}
//合并文件
function mergeFiles(fileName: string, originFileName: string) {
axios({
url: "/api/mergeFilesNew",
method: "post",
data: {
fileName: fileName,
originFileName,
},
}).then((res) => {
ElMessage(`合并文件成功!`)
})
}
切片上传后端实现
const TEMP_DIR = path.resolve(__dirname, "temp")
//切片上传
router.post(requestApi.uploadChunksNew, async (ctx) => {
const file = ctx.request.files.chunk
const fileName = ctx.request.body.fileName
const chunkPath = path.join(TEMP_DIR, `${fileName}`)
await fs.promises.copyFile(file.path || file.filepath, chunkPath)
await fs.promises.unlink(file.path || file.filepath)
ctx.body = {
success: true,
}
})
合并文件后端实现
//合并文件
router.post(requestApi.mergeFilesNew, async (ctx, next) => {
const fileName = ctx.request.body.fileName.split("-")[0]
const fileArr = Number(ctx.request.body.fileName.split("-")[1])
//原始文件名称
const originFileName = ctx.request.body.originFileName
const suffix = originFileName.split(".")[1]
//读取改文件夹下得文件
const filePath = path.join(__dirname, `./temp`)
const outputPath = path.join(__dirname, "./upload", fileName)
fs.readdir(filePath, async function (err, files) {
if (err) throw err
if (files.length !== fileArr) {
ctx.response.body = {
message: "文件长度不一致",
status: false,
}
}
files.sort().forEach((filename, index) => {
const fullFilePath = path.join(filePath, `${filename}`)
fs.stat(fullFilePath, (err, stats) => {
if (err) throw err
const isFile = stats.isFile()
if (isFile) {
fs.appendFileSync(
path.join(__dirname, `./upload/${fileName}.${suffix}`),
fs.readFileSync(path.join(__dirname, `./temp/${filename}`))
)
//合并完之后删除temp下得数据
fs.unlinkSync(path.join(__dirname, `./temp/${filename}`))
}
})
})
ctx.response.body = {
status: true,
message: "合并完成",
result: null,
}
})
})
断点续传
写一个检查该切片是否已经上传的方法,如果判断已经上传就跳过,没有上传就执行uploadchunk
方法
//切片上传
const httpRequest = async function (options: UploadRequestOptions) {
let originFileName = options.file.name
const fileArr = sliceFiles.value
let filename = ""
fileArr.forEach(async (item: BlobFile, sliceFilesindex: number) => {
const { chunk, fileName } = item
filename = fileName
const isExist = await checkUploadedChunks(fileName)
//文件名
if (!isExist) {
const formData = new FormData()
formData.append("chunk", chunk)
formData.append("fileName", fileName)
console.log(chunk)
debugger
axios({
url: "/api/uploadChunksNew",
method: "post",
data: formData,
headers: {},
}).then((res) => {
//合并文件
if (index === fileArr.length - 1 && res.status) {
mergeFiles(filename, originFileName)
}
})
// })
}
})
}
//断点续传,检查上传项
function checkUploadedChunks(chunkName: string): Promise<boolean> {
return new Promise((resolve) => {
axios({
url: "/api/checkUploaded",
method: "post",
data: {
chunkName,
},
}).then((res) => {
if (res.data.status) {
resolve(res.data.result as boolean)
}
})
})
}
后端检查上传方法
//断点续传
router.post(requestApi.checkUploaded, async (ctx) => {
const originalChunkName = ctx.request.body.chunkName
const chunkPath = path.join(__dirname, `./temp/${originalChunkName}`)
try {
await fs.promises.stat(chunkPath, (err, stats) => {
if (err) throw err
ctx.response.body = {
status: true,
result: true,
}
})
} catch (error) {
ctx.response.body = {
status: true,
result: false,
}
}
})
完整代码地址gitee