欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 文旅 > 美景 > nuxt3 + vue3 分片上传组件全解析(大文件分片上传)

nuxt3 + vue3 分片上传组件全解析(大文件分片上传)

2025/6/24 2:48:22 来源:https://blog.csdn.net/weixin_43073383/article/details/148807545  浏览:    关键词:nuxt3 + vue3 分片上传组件全解析(大文件分片上传)

本文将详细介绍一个基于 Vue.js 的分片上传组件的设计与实现,该组件支持大文件分片上传进度显示等功能。

组件概述

这个上传组件主要包含以下功能:

  1. 支持大文件分片上传(默认5MB一个分片)
  2. 支持文件哈希计算,用于文件唯一标识
  3. 显示上传进度(整体和单个文件)
  4. 支持自定义UI样式
  5. 提供完整的文件管理功能(添加、删除)
  6. 后端支持分片合并和临时存储

组件结构

组件由三个主要文件组成:

  1. Uploader.vue - 主组件
  2. fileChunk.ts - 文件分片和哈希计算工具
  3. uploader.post.ts - 后端API处理
  4. uploaderImg.vue - 调用示例

核心功能实现

1. 文件分片处理

fileChunk.ts 中,我们实现了文件分片功能:

import SparkMD5 from 'spark-md5';
/*** 创建文件分片* @param file 文件对象* @param chunkSize 每个分片的大小 (字节)*/
export const createFileChunk = (file: File, chunkSize: number) => {const chunks = []let current = 0while (current < file.size) {const end = Math.min(current + chunkSize, file.size)const chunk = file.slice(current, end)chunks.push({file: chunk,index: chunks.length,start: current,end: end,size: end - current,})current = end}return chunks
}/*** 计算文件hash (使用SparkMD5)* @param file 文件对象*/
export const calculateHash = (file: File): Promise<string> => {return new Promise((resolve) => {const spark = new SparkMD5.ArrayBuffer()const reader = new FileReader()const chunkSize = 2 * 1024 * 1024 // 2MBconst chunks = Math.ceil(file.size / chunkSize)let currentChunk = 0reader.onload = (e) => {spark.append(e.target?.result as ArrayBuffer)currentChunk++if (currentChunk < chunks) {loadNext()} else {resolve(spark.end())}}const loadNext = () => {const start = currentChunk * chunkSizeconst end = Math.min(start + chunkSize, file.size)const chunk = file.slice(start, end)reader.readAsArrayBuffer(chunk)}loadNext()})
}

这个方法将大文件分割成指定大小的多个小分片,便于上传和管理。

2. 文件哈希计算

使用 SparkMD5 库计算文件哈希,用于唯一标识文件:

export const calculateHash = (file: File): Promise<string> => {return new Promise((resolve) => {const spark = new SparkMD5.ArrayBuffer()const reader = new FileReader()const chunkSize = 2 * 1024 * 1024 // 2MBconst chunks = Math.ceil(file.size / chunkSize)let currentChunk = 0reader.onload = (e) => {spark.append(e.target?.result as ArrayBuffer)currentChunk++if (currentChunk < chunks) {loadNext()} else {resolve(spark.end())}}const loadNext = () => {const start = currentChunk * chunkSizeconst end = Math.min(start + chunkSize, file.size)const chunk = file.slice(start, end)reader.readAsArrayBuffer(chunk)}loadNext()})
}

3. 上传组件实现

Uploader.vue 组件提供了完整的上传功能:

<template><div class="upload-container"><!-- 默认触发区域 slot --><div v-if="$slots.trigger" class="upload-trigger" @click="handleTriggerClick"><input ref="fileInput" type="file" multiple @change="handleFileChange" :disabled="uploading" class="file-input" /><slot name="trigger"></slot></div><div v-else class="default-trigger" @click="handleTriggerClick"><input ref="fileInput" type="file" multiple @change="handleFileChange" :disabled="uploading" class="file-input" /><button class="upload-button"><slot name="icon"><span class="default-icon">+</span></slot><slot name="text"><span class="default-text">选择文件</span></slot></button></div><!-- 文件列表 slot --><div v-if="files.length > 0" class="upload-file-list"><slot name="fileList" :files="files" :removeFile="removeFile"><div v-for="(fileInfo, index) in files" :key="index" class="file-item"><div class="file-info"><span class="file-name">{{ fileInfo.file.name }}</span><span class="file-size">{{ (fileInfo.file.size / 1024 / 1024).toFixed(2) }} MB</span><span v-if="fileInfo.progress < 100" class="file-progress">{{ Math.round(fileInfo.progress) }}%</span><span v-else class="file-complete">✓</span><button class="remove-button" @click.stop="removeFile(index)">×</button></div><div v-if="fileInfo.progress > 0" class="file-progress-bar"><div class="progress" :style="{ width: `${fileInfo.progress}%` }"></div></div></div></slot></div><!-- 操作按钮 slot --><div class="upload-actions"><slot name="actions" :uploadFiles="uploadFiles" :files="files" :uploading="uploading"><button @click="uploadFiles" :disabled="files.length === 0 || uploading" class="upload-action-button">{{ uploading ? '上传中...' : '开始上传' }}</button></slot></div><!-- 进度条 slot --><div v-if="uploading" class="upload-progress"><slot name="progress" :progress="totalProgress"><div class="progress-container"><div class="progress-bar" :style="{ width: `${totalProgress}%` }"></div><span>{{ Math.round(totalProgress) }}%</span></div></slot></div></div>
</template><script setup lang="ts">
import { ref, computed } from 'vue'
import { createFileChunk, calculateHash } from '../utils/fileChunk'const fileInput = ref<HTMLInputElement | null>(null)
const files = ref<Array<{file: Fileprogress: numberhash?: stringchunks?: Array<any>
}>>([])
const uploading = ref(false)
const chunkSize = ref(5 * 1024 * 1024) // 5MB 每个分片大小const totalProgress = computed(() => {if (files.value.length === 0) return 0const total = files.value.reduce((sum, fileInfo) => sum + fileInfo.progress, 0)return total / files.value.length
})const handleTriggerClick = () => {if (fileInput.value && !uploading.value) {fileInput.value.value = '' // 清除之前的选择,允许重复选择同一文件fileInput.value.click()}
}const handleFileChange = (e: Event) => {const target = e.target as HTMLInputElementif (target.files && target.files.length > 0) {// 添加新文件到列表,过滤掉重复文件const newFiles = Array.from(target.files).filter(file =>!files.value.some(f => f.file.name === file.name && f.file.size === file.size))files.value = [...files.value,...newFiles.map(file => ({file,progress: 0,hash: undefined,chunks: undefined}))]}
}const removeFile = (index: number) => {if (!uploading.value) {files.value.splice(index, 1)}
}const uploadFiles = async () => {if (files.value.length === 0) returnuploading.value = true// 重置所有文件进度files.value = files.value.map(fileInfo => ({...fileInfo,progress: 0}))try {// 为每个文件计算hash并创建分片await Promise.all(files.value.map(async (fileInfo) => {fileInfo.hash = await calculateHash(fileInfo.file)fileInfo.chunks = createFileChunk(fileInfo.file, chunkSize.value)}))// 上传所有文件的分片await Promise.all(files.value.filter(fileInfo => fileInfo.hash !== undefined && fileInfo.chunks !== undefined).map(fileInfo =>uploadFile(fileInfo as {file: Fileprogress: numberhash: stringchunks: Array<any>})))// 上传成功回调if (props.onSuccess) {props.onSuccess(files.value.map(f => f.file))}} catch (error) {console.error('上传失败:', error)// 上传失败回调if (props.onError) {props.onError(error)}} finally {uploading.value = false}
}const uploadFile = async (fileInfo: {file: Fileprogress: numberhash: stringchunks: Array<any>
}) => {if (!fileInfo.hash || !fileInfo.chunks) {throw new Error('File info is incomplete')}const { hash, chunks } = fileInfo// 上传每个分片const requests = chunks.map((chunk, index) => {const formData = new FormData()formData.append('file', chunk.file)formData.append('hash', `${hash}-${index}`)formData.append('filename', fileInfo.file.name)formData.append('fileHash', hash)formData.append('chunkIndex', index.toString())formData.append('chunkCount', chunks.length.toString())return uploadChunk(formData, (e: ProgressEvent) => {// 更新单个分片的上传进度const loaded = e.loadedconst total = e.totalconst chunkProgress = (loaded / total) * 100// 更新文件进度fileInfo.progress = ((index / chunks.length) * 100) + (chunkProgress / chunks.length)})})await Promise.all(requests)// 通知服务器合并分片await mergeChunks(hash, fileInfo.file.name, chunks.length)// 标记文件为100%完成fileInfo.progress = 100
}const uploadChunk = async (formData: FormData, onProgress: (e: ProgressEvent) => void) => {return new Promise((resolve, reject) => {const xhr = new XMLHttpRequest()xhr.open('POST', '/api/uploader', true)xhr.upload.onprogress = onProgressxhr.onload = () => {if (xhr.status >= 200 && xhr.status < 300) {resolve(xhr.response)} else {reject(xhr.statusText)}}xhr.onerror = () => reject('上传出错')xhr.send(formData)})
}const mergeChunks = async (fileHash: string, filename: string, chunkCount: number) => {const response = await fetch('/api/uploader', {method: 'POST',headers: {'Content-Type': 'application/json',},body: JSON.stringify({action: 'merge',fileHash,filename,chunkCount,}),})if (!response.ok) {throw new Error('合并分片失败')}return response.json()
}// 定义组件props
const props = defineProps({onSuccess: {type: Function,default: null},onError: {type: Function,default: null},// customizeTrigger: {//   type: Boolean,//   default: false// }
})
</script><style scoped>
.upload-container {max-width: 600px;margin: 0 auto;font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}.file-input {display: none;
}.default-trigger {display: inline-block;cursor: pointer;
}.upload-button {padding: 10px 16px;background-color: #409eff;color: white;border: none;border-radius: 4px;cursor: pointer;font-size: 14px;transition: all 0.3s;
}.upload-button:hover {background-color: #66b1ff;
}.upload-button:disabled {background-color: #a0cfff;cursor: not-allowed;
}.default-icon {margin-right: 8px;font-size: 16px;
}.upload-file-list {margin-top: 15px;border: 1px solid #ebeef5;border-radius: 4px;padding: 10px;
}.file-item {margin-bottom: 10px;padding: 8px;border-radius: 4px;transition: background-color 0.3s;
}.file-item:hover {background-color: #f5f7fa;
}.file-info {display: flex;align-items: center;margin-bottom: 5px;
}.file-name {flex: 1;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;font-size: 14px;color: #606266;
}.file-size {margin: 0 10px;color: #909399;font-size: 12px;
}.file-progress,
.file-complete {margin: 0 10px;font-size: 12px;
}.file-progress {color: #409eff;font-weight: bold;
}.file-complete {color: #67c23a;
}.remove-button {background: none;border: none;color: #f56c6c;cursor: pointer;font-size: 16px;padding: 0 5px;
}.remove-button:hover {color: #f78989;
}.file-progress-bar {height: 6px;background-color: #ebeef5;border-radius: 3px;overflow: hidden;
}.file-progress-bar .progress {height: 100%;background-color: #409eff;transition: width 0.3s;
}.upload-actions {margin-top: 15px;text-align: right;
}.upload-action-button {padding: 9px 15px;background-color: #409eff;color: white;border: none;border-radius: 4px;cursor: pointer;font-size: 14px;
}.upload-action-button:hover {background-color: #66b1ff;
}.upload-action-button:disabled {background-color: #a0cfff;cursor: not-allowed;
}.progress-container {margin: 10px 0;height: 20px;background-color: #ebeef5;border-radius: 4px;position: relative;
}.progress-bar {height: 100%;background-color: #409eff;border-radius: 4px;transition: width 0.3s;
}.progress-container span {position: absolute;left: 50%;top: 50%;transform: translate(-50%, -50%);color: white;font-size: 12px;
}
</style>

4. 后端API实现

uploader.post.ts 处理分片上传和合并:

import fs from 'fs'
import path from 'path'
import { promisify } from 'util'
import { H3Event } from 'h3'
import { randomUUID } from 'crypto'const mkdir = promisify(fs.mkdir)
const writeFile = promisify(fs.writeFile)
const readdir = promisify(fs.readdir)
const unlink = promisify(fs.unlink)
const stat = promisify(fs.stat)
const rename = promisify(fs.rename)const UPLOAD_DIR = path.resolve(process.cwd(), 'uploads')
const CHUNK_DIR = path.resolve(UPLOAD_DIR, 'chunks')// 确保上传目录存在
if (!fs.existsSync(UPLOAD_DIR)) {fs.mkdirSync(UPLOAD_DIR, { recursive: true })
}
if (!fs.existsSync(CHUNK_DIR)) {fs.mkdirSync(CHUNK_DIR, { recursive: true })
}export default defineEventHandler(async (event: H3Event) => {const { req, res } = event.nodeif (req.method !== 'POST') {res.statusCode = 405return { error: 'Method not allowed' }}try {const contentType = req.headers['content-type'] || ''if (contentType.includes('multipart/form-data')) {// 处理分片上传return await handleChunkUpload(event)} else if (contentType.includes('application/json')) {// 处理合并请求const body = await readBody(event)if (body.action === 'merge') {return await mergeChunks(body)}}return { error: 'Invalid request' }} catch (error) {console.error('Upload error:', error)res.statusCode = 500return { error: 'Internal server error' }}
})async function handleChunkUpload(event: H3Event) {const formData = await readMultipartFormData(event)if (!formData) {throw new Error('Invalid form data')}const fileData = formData.find(item => item.name === 'file')const hash = formData.find(item => item.name === 'hash')?.data.toString()const filename = formData.find(item => item.name === 'filename')?.data.toString()if (!fileData || !hash || !filename) {throw new Error('Missing required fields')}// 保存分片到临时目录const chunkPath = path.resolve(CHUNK_DIR, hash)await writeFile(chunkPath, fileData.data)return { success: true, hash }
}async function mergeChunks(body: any) {const { fileHash, filename, chunkCount } = body// 验证所有分片是否已上传const chunkFiles = await readdir(CHUNK_DIR)const uploadedChunks = chunkFiles.filter(name => name.startsWith(fileHash))if (uploadedChunks.length !== Number(chunkCount)) {throw new Error('Not all chunks have been uploaded')}// 按分片索引排序uploadedChunks.sort((a, b) => {const aIndex = parseInt(a.split('-').pop() || '0')const bIndex = parseInt(b.split('-').pop() || '0')return aIndex - bIndex})// 创建最终文件const filePath = path.resolve(UPLOAD_DIR, filename)const writeStream = fs.createWriteStream(filePath)// 合并所有分片for (const chunkName of uploadedChunks) {const chunkPath = path.resolve(CHUNK_DIR, chunkName)const chunkData = await fs.promises.readFile(chunkPath)writeStream.write(chunkData)await unlink(chunkPath) // 删除已合并的分片}writeStream.end()return new Promise((resolve, reject) => {writeStream.on('finish', () => {resolve({ success: true, path: filePath })})writeStream.on('error', (error) => {reject(error)})})
}

组件使用示例

<template><h1 class="borderh1">默认调用</h1><br><br><br><Uploader /><br><br><br><h1 class="borderh1">自定义调用</h1><br><br><br><Uploader @success="handleSuccess" @error="handleError"><!-- 自定义触发区域 --><template #trigger><div class="custom-trigger"><svg class="upload-icon" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" /></svg><span>选择文件</span></div></template><!-- 自定义文件列表 --><template #fileList="{ files, removeFile }"><ul class="custom-file-list"><li v-for="(fileInfo, index) in files" :key="index" class="file-item"><span class="file-name">{{ fileInfo.file.name }}</span><div v-if="fileInfo.progress > 0" class="progress-container"><div class="progress-bar" :style="{ width: `${fileInfo.progress}%` }"></div><span class="progress-text">{{ Math.round(fileInfo.progress) }}%</span></div><button v-if="fileInfo.progress < 100" @click="removeFile(index)" class="remove-button">删除</button><span v-else class="complete-icon">✓</span></li></ul></template><!-- 自定义操作按钮 --><template #actions="{ uploadFiles, files, uploading }"><button @click="uploadFiles" :disabled="files.length === 0 || uploading" class="upload-button">{{ uploading ? '上传中...' : '开始上传' }}</button></template></Uploader>
</template><script setup>
const handleSuccess = (files) => {console.log('上传成功:', files);alert(`成功上传 ${files.length} 个文件`);
};const handleError = (error) => {console.error('上传失败:', error);alert('上传失败: ' + error.message);
};
</script><style scoped>
/* 自定义触发区域样式 */
.custom-trigger {border: 2px dashed #ccc;border-radius: 6px;padding: 20px;text-align: center;cursor: pointer;transition: all 0.3s;margin-bottom: 16px;background-color: #f8fafc;
}.custom-trigger:hover {border-color: #409eff;background-color: #f0f7ff;
}.upload-icon {width: 24px;height: 24px;fill: #409eff;margin-bottom: 8px;
}/* 自定义文件列表样式 */
.custom-file-list {list-style: none;padding: 0;margin: 0 0 16px 0;border: 1px solid #e5e7eb;border-radius: 6px;
}.file-item {display: flex;align-items: center;padding: 12px;border-bottom: 1px solid #e5e7eb;transition: background-color 0.3s;
}.file-item:hover {background-color: #f9fafb;
}.file-item:last-child {border-bottom: none;
}.file-name {flex: 1;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;margin-right: 16px;font-size: 14px;color: #333;
}.progress-container {width: 120px;height: 20px;background-color: #f3f3f3;border-radius: 4px;position: relative;margin-right: 16px;overflow: hidden;
}.progress-bar {height: 100%;background-color: #409eff;transition: width 0.3s;
}.progress-text {position: absolute;left: 50%;top: 50%;transform: translate(-50%, -50%);font-size: 12px;color: white;
}.remove-button {background: none;border: 1px solid #f56c6c;color: #f56c6c;border-radius: 4px;padding: 4px 8px;cursor: pointer;transition: all 0.3s;font-size: 13px;
}.remove-button:hover {background-color: #f56c6c;color: white;
}.complete-icon {color: #67c23a;font-weight: bold;margin-left: 16px;font-size: 16px;
}/* 自定义操作按钮样式 */
.upload-button {background-color: #409eff;color: white;border: none;border-radius: 4px;padding: 10px 20px;cursor: pointer;transition: all 0.3s;font-size: 14px;font-weight: 500;
}.upload-button:hover {background-color: #66b1ff;
}.upload-button:disabled {background-color: #a0cfff;cursor: not-allowed;
}/* 标题样式 */
.borderh1 {border-bottom: 2px solid #409eff;border-top: 2px solid #409eff;padding: 10px 0;margin-bottom: 20px;font-size: 24px;color: #333;text-align: center;background-color: #f0f7ff;
}
</style>

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com

热搜词