88. 文件上传与下载
概述
文件上传与下载是现代Web应用中常见的功能需求,如头像上传、文档下载、图片分享等。本集将深入探讨Vue 3项目中的文件上传与下载实现,包括单文件上传、多文件上传、大文件分片上传、断点续传、文件下载等核心功能。我们将学习如何使用HTML5 File API、Axios处理文件请求,以及如何结合Vue 3的响应式系统实现流畅的文件处理体验。
核心知识点
1. 文件上传基础
1.1 HTML5 File API
HTML5提供了强大的File API,允许网页访问用户设备上的文件系统:
- File:表示单个文件,包含文件名、大小、类型等信息
- FileList:文件列表,通常来自
<input type="file">元素 - FileReader:用于读取文件内容
- Blob:表示二进制数据块
- FormData:用于构造表单数据,支持文件上传
1.2 单文件上传
<!-- src/components/SingleFileUpload.vue -->
<template>
<div class="file-upload">
<h2>单文件上传</h2>
<div class="upload-area" @click="triggerFileInput" @dragover.prevent @drop.prevent="handleDrop">
<input
ref="fileInput"
type="file"
accept=".jpg,.png,.gif"
@change="handleFileChange"
style="display: none"
/>
<div v-if="!selectedFile" class="upload-placeholder">
<span class="icon">📁</span>
<p>点击或拖拽文件到此处上传</p>
<p class="hint">支持JPG、PNG、GIF格式,大小不超过5MB</p>
</div>
<div v-else class="file-preview">
<img v-if="isImageFile(selectedFile)" :src="previewUrl" alt="预览" class="image-preview" />
<div v-else class="file-info">
<span class="file-icon">📄</span>
<div>
<div class="file-name">{{ selectedFile.name }}</div>
<div class="file-size">{{ formatFileSize(selectedFile.size) }}</div>
</div>
</div>
<button @click.stop="removeFile" class="remove-btn">×</button>
</div>
</div>
<div class="upload-progress" v-if="uploading">
<div class="progress-bar">
<div class="progress" :style="{ width: progress + '%' }"></div>
</div>
<div class="progress-text">{{ progress }}%</div>
</div>
<div class="upload-actions">
<button
@click="uploadFile"
:disabled="!selectedFile || uploading"
class="upload-btn"
>
{{ uploading ? '上传中...' : '上传文件' }}
</button>
<button
@click="removeFile"
:disabled="!selectedFile || uploading"
class="cancel-btn"
>
取消
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import axios from 'axios'
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const previewUrl = ref('')
const uploading = ref(false)
const progress = ref(0)
// 触发文件选择
const triggerFileInput = () => {
fileInput.value?.click()
}
// 处理文件选择
const handleFileChange = (event: Event) => {
const input = event.target as HTMLInputElement
if (input.files && input.files.length > 0) {
const file = input.files[0]
handleFile(file)
}
}
// 处理拖拽文件
const handleDrop = (event: DragEvent) => {
const files = event.dataTransfer?.files
if (files && files.length > 0) {
const file = files[0]
handleFile(file)
}
}
// 处理文件
const handleFile = (file: File) => {
// 检查文件大小
if (file.size > 5 * 1024 * 1024) {
alert('文件大小不能超过5MB')
return
}
selectedFile.value = file
// 生成预览
if (isImageFile(file)) {
const reader = new FileReader()
reader.onload = (e) => {
previewUrl.value = e.target?.result as string
}
reader.readAsDataURL(file)
}
}
// 检查是否为图片文件
const isImageFile = (file: File): boolean => {
return file.type.startsWith('image/')
}
// 格式化文件大小
const formatFileSize = (size: number): string => {
if (size < 1024) {
return `${size} B`
} else if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`
} else {
return `${(size / (1024 * 1024)).toFixed(1)} MB`
}
}
// 移除文件
const removeFile = () => {
selectedFile.value = null
previewUrl.value = ''
progress.value = 0
if (fileInput.value) {
fileInput.value.value = ''
}
}
// 上传文件
const uploadFile = async () => {
if (!selectedFile.value) return
try {
uploading.value = true
progress.value = 0
const formData = new FormData()
formData.append('file', selectedFile.value)
await axios.post('/api/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
progress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total)
}
}
})
alert('文件上传成功')
removeFile()
} catch (error) {
console.error('文件上传失败:', error)
alert('文件上传失败')
} finally {
uploading.value = false
}
}
</script>
<style scoped>
.file-upload {
width: 400px;
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
background-color: #f9f9f9;
}
.upload-area {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 30px;
text-align: center;
cursor: pointer;
background-color: white;
transition: all 0.3s ease;
position: relative;
}
.upload-area:hover {
border-color: #409eff;
}
.upload-placeholder {
color: #666;
}
.upload-placeholder .icon {
font-size: 48px;
margin-bottom: 10px;
display: block;
}
.hint {
font-size: 12px;
color: #999;
margin-top: 5px;
}
.file-preview {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
background-color: #f0f9ff;
border: 1px solid #e0f2fe;
border-radius: 4px;
}
.image-preview {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 4px;
}
.file-info {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
margin-left: 10px;
}
.file-icon {
font-size: 24px;
}
.file-name {
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.file-size {
font-size: 12px;
color: #666;
}
.remove-btn {
background-color: #fef2f2;
color: #f56c6c;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
font-size: 16px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
.upload-progress {
margin: 15px 0;
}
.progress-bar {
height: 8px;
background-color: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.progress {
height: 100%;
background-color: #409eff;
transition: width 0.3s ease;
}
.progress-text {
text-align: right;
font-size: 12px;
color: #666;
margin-top: 5px;
}
.upload-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 15px;
}
.upload-btn {
padding: 8px 16px;
background-color: #409eff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.upload-btn:disabled {
background-color: #a0cfff;
cursor: not-allowed;
}
.cancel-btn {
padding: 8px 16px;
background-color: #f5f7fa;
color: #606266;
border: 1px solid #dcdfe6;
border-radius: 4px;
cursor: pointer;
}
.cancel-btn:disabled {
background-color: #f5f7fa;
color: #c0c4cc;
border-color: #ebeef5;
cursor: not-allowed;
}
</style>1.3 多文件上传
<!-- src/components/MultipleFileUpload.vue -->
<template>
<div class="file-upload">
<h2>多文件上传</h2>
<div class="upload-area" @click="triggerFileInput" @dragover.prevent @drop.prevent="handleDrop">
<input
ref="fileInput"
type="file"
multiple
accept=".jpg,.png,.pdf,.doc,.docx"
@change="handleFileChange"
style="display: none"
/>
<div v-if="selectedFiles.length === 0" class="upload-placeholder">
<span class="icon">📁</span>
<p>点击或拖拽文件到此处上传</p>
<p class="hint">支持JPG、PNG、PDF、DOC格式,单文件不超过10MB</p>
</div>
<div v-else class="file-list">
<div
v-for="(file, index) in selectedFiles"
:key="index"
class="file-item"
>
<img v-if="isImageFile(file)" :src="getPreviewUrl(file)" alt="预览" class="file-preview" />
<div v-else class="file-icon">{{ getFileIcon(file) }}</div>
<div class="file-details">
<div class="file-name">{{ file.name }}</div>
<div class="file-size">{{ formatFileSize(file.size) }}</div>
</div>
<button @click="removeFile(index)" class="remove-btn">×</button>
</div>
</div>
</div>
<div class="upload-stats" v-if="selectedFiles.length > 0">
已选择 {{ selectedFiles.length }} 个文件,总大小 {{ formatFileSize(totalFileSize) }}
</div>
<div class="upload-progress" v-if="uploading">
<div class="progress-bar">
<div class="progress" :style="{ width: progress + '%' }"></div>
</div>
<div class="progress-text">{{ progress }}%</div>
</div>
<div class="upload-actions">
<button
@click="uploadFiles"
:disabled="selectedFiles.length === 0 || uploading"
class="upload-btn"
>
{{ uploading ? '上传中...' : `上传 ${selectedFiles.length} 个文件` }}
</button>
<button
@click="clearFiles"
:disabled="selectedFiles.length === 0 || uploading"
class="clear-btn"
>
清空
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import axios from 'axios'
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFiles = ref<File[]>([])
const previews = ref<Map<File, string>>(new Map())
const uploading = ref(false)
const progress = ref(0)
// 计算总文件大小
const totalFileSize = computed(() => {
return selectedFiles.value.reduce((total, file) => total + file.size, 0)
})
// 触发文件选择
const triggerFileInput = () => {
fileInput.value?.click()
}
// 处理文件选择
const handleFileChange = (event: Event) => {
const input = event.target as HTMLInputElement
if (input.files && input.files.length > 0) {
const files = Array.from(input.files)
files.forEach(file => handleFile(file))
}
}
// 处理拖拽文件
const handleDrop = (event: DragEvent) => {
const files = Array.from(event.dataTransfer?.files || [])
files.forEach(file => handleFile(file))
}
// 处理单个文件
const handleFile = (file: File) => {
// 检查文件大小
if (file.size > 10 * 1024 * 1024) {
alert(`${file.name} 文件大小超过10MB,无法上传`)
return
}
// 检查是否已存在
if (!selectedFiles.value.some(f => f.name === file.name && f.size === file.size)) {
selectedFiles.value.push(file)
// 生成预览
if (isImageFile(file)) {
const reader = new FileReader()
reader.onload = (e) => {
previews.value.set(file, e.target?.result as string)
}
reader.readAsDataURL(file)
}
}
}
// 检查是否为图片文件
const isImageFile = (file: File): boolean => {
return file.type.startsWith('image/')
}
// 获取文件图标
const getFileIcon = (file: File): string => {
const ext = file.name.split('.').pop()?.toLowerCase()
switch (ext) {
case 'pdf': return '📄'
case 'doc':
case 'docx': return '📝'
case 'xls':
case 'xlsx': return '📊'
case 'zip':
case 'rar': return '🗜️'
default: return '📑'
}
}
// 获取预览URL
const getPreviewUrl = (file: File): string => {
return previews.value.get(file) || ''
}
// 格式化文件大小
const formatFileSize = (size: number): string => {
if (size < 1024) {
return `${size} B`
} else if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`
} else {
return `${(size / (1024 * 1024)).toFixed(1)} MB`
}
}
// 移除文件
const removeFile = (index: number) => {
const file = selectedFiles.value[index]
previews.value.delete(file)
selectedFiles.value.splice(index, 1)
if (fileInput.value) {
fileInput.value.value = ''
}
}
// 清空所有文件
const clearFiles = () => {
selectedFiles.value = []
previews.value.clear()
if (fileInput.value) {
fileInput.value.value = ''
}
}
// 上传文件
const uploadFiles = async () => {
if (selectedFiles.value.length === 0) return
try {
uploading.value = true
progress.value = 0
const formData = new FormData()
selectedFiles.value.forEach(file => {
formData.append('files[]', file)
})
await axios.post('/api/upload/multiple', formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
progress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total)
}
}
})
alert(`${selectedFiles.length} 个文件上传成功`)
clearFiles()
} catch (error) {
console.error('文件上传失败:', error)
alert('文件上传失败')
} finally {
uploading.value = false
progress.value = 0
}
}
</script>
<style scoped>
/* 样式与单文件上传类似,调整file-list和file-item样式 */
.file-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.file-item {
display: flex;
align-items: center;
padding: 10px;
background-color: #f0f9ff;
border: 1px solid #e0f2fe;
border-radius: 4px;
}
.file-preview {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
}
.file-details {
flex: 1;
margin: 0 10px;
}
/* 其他样式参考单文件上传组件 */
</style>2. 高级文件上传功能
2.1 大文件分片上传
大文件分片上传是将大文件分割成多个小块,分别上传,最后在服务器端合并的技术。
<!-- src/components/ChunkUpload.vue -->
<template>
<div class="chunk-upload">
<h2>大文件分片上传</h2>
<div class="upload-area" @click="triggerFileInput" @dragover.prevent @drop.prevent="handleDrop">
<input
ref="fileInput"
type="file"
@change="handleFileChange"
style="display: none"
/>
<div v-if="!selectedFile" class="upload-placeholder">
<span class="icon">📁</span>
<p>点击或拖拽大文件到此处上传</p>
<p class="hint">支持各种格式,自动分片上传,断点续传</p>
</div>
<div v-else class="file-info">
<div class="file-name">{{ selectedFile.name }}</div>
<div class="file-size">{{ formatFileSize(selectedFile.size) }}</div>
</div>
</div>
<div class="upload-progress" v-if="uploading">
<div class="progress-bar">
<div class="progress" :style="{ width: progress + '%' }"></div>
</div>
<div class="progress-text">
{{ progress }}% ({{ uploadedChunks }}/{{ totalChunks }} 块)
</div>
</div>
<div class="upload-actions">
<button
@click="uploadFile"
:disabled="!selectedFile || uploading"
class="upload-btn"
>
{{ uploading ? '上传中...' : '开始上传' }}
</button>
<button
@click="pauseUpload"
:disabled="!uploading"
class="pause-btn"
>
暂停
</button>
<button
@click="resumeUpload"
:disabled="!selectedFile || uploading || uploadedChunks === 0"
class="resume-btn"
>
继续
</button>
<button
@click="cancelUpload"
:disabled="!selectedFile"
class="cancel-btn"
>
取消
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import axios from 'axios'
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const uploading = ref(false)
const paused = ref(false)
const progress = ref(0)
const chunkSize = 5 * 1024 * 1024 // 5MB per chunk
const totalChunks = ref(0)
const uploadedChunks = ref(0)
let currentChunk = 0
let fileHash = ''
// 生成文件哈希
const generateFileHash = async (file: File): Promise<string> => {
// 简化实现,实际项目中应使用更可靠的哈希算法
return `${file.name}-${file.size}-${file.lastModified}`
}
// 触发文件选择
const triggerFileInput = () => {
fileInput.value?.click()
}
// 处理文件选择
const handleFileChange = async (event: Event) => {
const input = event.target as HTMLInputElement
if (input.files && input.files.length > 0) {
selectedFile.value = input.files[0]
fileHash = await generateFileHash(selectedFile.value)
totalChunks.value = Math.ceil(selectedFile.value.size / chunkSize)
uploadedChunks.value = 0
currentChunk = 0
progress.value = 0
}
}
// 处理拖拽文件
const handleDrop = async (event: DragEvent) => {
const files = event.dataTransfer?.files
if (files && files.length > 0) {
selectedFile.value = files[0]
fileHash = await generateFileHash(selectedFile.value)
totalChunks.value = Math.ceil(selectedFile.value.size / chunkSize)
uploadedChunks.value = 0
currentChunk = 0
progress.value = 0
}
}
// 格式化文件大小
const formatFileSize = (size: number): string => {
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`
} else if (size < 1024 * 1024 * 1024) {
return `${(size / (1024 * 1024)).toFixed(1)} MB`
} else {
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`
}
}
// 获取文件分片
const getChunk = (file: File, chunkIndex: number): Blob => {
const start = chunkIndex * chunkSize
const end = Math.min(start + chunkSize, file.size)
return file.slice(start, end)
}
// 上传单个分片
const uploadChunk = async (chunkIndex: number): Promise<boolean> => {
if (!selectedFile.value) return false
try {
const chunk = getChunk(selectedFile.value, chunkIndex)
const formData = new FormData()
formData.append('file', chunk)
formData.append('filename', selectedFile.value.name)
formData.append('filehash', fileHash)
formData.append('chunkindex', chunkIndex.toString())
formData.append('totalchunks', totalChunks.value.toString())
formData.append('chunksize', chunkSize.toString())
await axios.post('/api/upload/chunk', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
return true
} catch (error) {
console.error(`上传分片 ${chunkIndex} 失败:`, error)
return false
}
}
// 合并分片
const mergeChunks = async (): Promise<boolean> => {
if (!selectedFile.value) return false
try {
await axios.post('/api/upload/merge', {
filename: selectedFile.value.name,
filehash: fileHash,
totalchunks: totalChunks.value
})
return true
} catch (error) {
console.error('合并分片失败:', error)
return false
}
}
// 上传文件
const uploadFile = async () => {
if (!selectedFile.value) return
try {
uploading.value = true
paused.value = false
// 检查是否已经上传过部分分片
const response = await axios.get('/api/upload/check', {
params: {
filehash: fileHash,
filename: selectedFile.value.name
}
})
if (response.data.uploaded) {
// 文件已完全上传
alert('文件已上传完成')
resetUpload()
return
}
// 获取已上传的分片索引
const uploadedChunkIndices = response.data.uploadedChunks || []
uploadedChunks.value = uploadedChunkIndices.length
currentChunk = uploadedChunkIndices.length
// 开始上传剩余分片
while (currentChunk < totalChunks.value && !paused.value) {
const success = await uploadChunk(currentChunk)
if (success) {
uploadedChunks.value++
currentChunk++
progress.value = Math.round((uploadedChunks.value / totalChunks.value) * 100)
} else {
// 分片上传失败,重试一次
await new Promise(resolve => setTimeout(resolve, 1000))
}
}
if (!paused.value && uploadedChunks.value === totalChunks.value) {
// 所有分片上传完成,合并文件
const mergeSuccess = await mergeChunks()
if (mergeSuccess) {
alert('文件上传成功')
resetUpload()
} else {
alert('文件合并失败')
}
}
} catch (error) {
console.error('文件上传失败:', error)
alert('文件上传失败')
} finally {
if (!paused.value) {
uploading.value = false
}
}
}
// 暂停上传
const pauseUpload = () => {
paused.value = true
uploading.value = false
}
// 继续上传
const resumeUpload = () => {
uploadFile()
}
// 取消上传
const cancelUpload = () => {
resetUpload()
}
// 重置上传状态
const resetUpload = () => {
selectedFile.value = null
uploading.value = false
paused.value = false
progress.value = 0
uploadedChunks.value = 0
totalChunks.value = 0
currentChunk = 0
fileHash = ''
if (fileInput.value) {
fileInput.value.value = ''
}
}
</script>
<style scoped>
/* 样式参考单文件上传组件,添加暂停、继续按钮样式 */
.pause-btn {
background-color: #f59e0b;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.resume-btn {
background-color: #10b981;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
/* 其他样式参考单文件上传组件 */
</style>3. 文件下载功能
3.1 基本文件下载
<!-- src/components/FileDownload.vue -->
<template>
<div class="file-download">
<h2>文件下载</h2>
<div class="file-list">
<div
v-for="file in files"
:key="file.id"
class="file-item"
>
<div class="file-icon">{{ getFileIcon(file) }}</div>
<div class="file-info">
<div class="file-name">{{ file.name }}</div>
<div class="file-meta">
<span>{{ formatFileSize(file.size) }}</span>
<span>{{ formatDate(file.uploadedAt) }}</span>
</div>
</div>
<div class="download-actions">
<button
@click="downloadFile(file)"
class="download-btn"
:disabled="downloadingFiles.includes(file.id)"
>
<span v-if="downloadingFiles.includes(file.id)">⏳</span>
<span v-else>📥</span>
{{ downloadingFiles.includes(file.id) ? '下载中...' : '下载' }}
</button>
<button
@click="previewFile(file)"
class="preview-btn"
:disabled="!canPreview(file)"
>
预览
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import axios from 'axios'
interface FileItem {
id: string
name: string
size: number
type: string
url: string
uploadedAt: string
}
const files = ref<FileItem[]>([
{
id: '1',
name: '文档.pdf',
size: 2048576,
type: 'application/pdf',
url: '/api/files/1',
uploadedAt: '2023-01-15T10:30:00Z'
},
{
id: '2',
name: '图片.jpg',
size: 5242880,
type: 'image/jpeg',
url: '/api/files/2',
uploadedAt: '2023-01-16T14:45:00Z'
},
{
id: '3',
name: '表格.xlsx',
size: 10485760,
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
url: '/api/files/3',
uploadedAt: '2023-01-17T09:15:00Z'
}
])
const downloadingFiles = ref<string[]>([])
// 获取文件图标
const getFileIcon = (file: FileItem): string => {
if (file.type.startsWith('image/')) return '🖼️'
if (file.type === 'application/pdf') return '📄'
if (file.type.includes('word')) return '📝'
if (file.type.includes('excel') || file.type.includes('spreadsheet')) return '📊'
if (file.type.includes('zip') || file.type.includes('rar')) return '🗜️'
return '📑'
}
// 格式化文件大小
const formatFileSize = (size: number): string => {
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`
}
// 格式化日期
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleString('zh-CN')
}
// 检查是否可以预览
const canPreview = (file: FileItem): boolean => {
return file.type.startsWith('image/') || file.type === 'application/pdf'
}
// 预览文件
const previewFile = (file: FileItem) => {
if (file.type.startsWith('image/')) {
// 图片预览
window.open(file.url, '_blank')
} else if (file.type === 'application/pdf') {
// PDF预览
window.open(file.url, '_blank')
}
}
// 下载文件
const downloadFile = async (file: FileItem) => {
try {
downloadingFiles.value.push(file.id)
// 使用axios下载文件
const response = await axios.get(file.url, {
responseType: 'blob' // 重要:设置响应类型为blob
})
// 创建下载链接
const url = window.URL.createObjectURL(new Blob([response.data]))
const link = document.createElement('a')
link.href = url
link.setAttribute('download', file.name)
document.body.appendChild(link)
link.click()
// 清理
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch (error) {
console.error('文件下载失败:', error)
alert('文件下载失败')
} finally {
downloadingFiles.value = downloadingFiles.value.filter(id => id !== file.id)
}
}
</script>
<style scoped>
.file-download {
max-width: 800px;
padding: 20px;
}
.file-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 20px;
}
.file-item {
display: flex;
align-items: center;
padding: 15px;
background-color: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.file-icon {
font-size: 24px;
margin-right: 15px;
}
.file-info {
flex: 1;
}
.file-name {
font-weight: bold;
margin-bottom: 5px;
}
.file-meta {
display: flex;
gap: 15px;
font-size: 12px;
color: #666;
}
.download-actions {
display: flex;
gap: 10px;
}
.download-btn,
.preview-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.download-btn {
background-color: #409eff;
color: white;
}
.preview-btn {
background-color: #f5f7fa;
color: #606266;
border: 1px solid #dcdfe6;
}
.preview-btn:disabled {
background-color: #f5f7fa;
color: #c0c4cc;
border-color: #ebeef5;
cursor: not-allowed;
}
</style>3.2 带进度条的文件下载
<!-- src/components/DownloadWithProgress.vue -->
<template>
<div class="download-progress">
<h2>带进度条的文件下载</h2>
<div class="file-item">
<div class="file-info">
<div class="file-name">大型文件.zip</div>
<div class="file-size">500 MB</div>
</div>
<button
@click="startDownload"
:disabled="downloading"
class="download-btn"
>
{{ downloading ? '下载中...' : '开始下载' }}
</button>
</div>
<div class="progress-container" v-if="downloading">
<div class="progress-bar">
<div class="progress" :style="{ width: progress + '%' }"></div>
</div>
<div class="progress-info">
<span>{{ progress }}%</span>
<span>{{ formatFileSize(downloadedSize) }} / 500 MB</span>
<span>{{ downloadSpeed }} KB/s</span>
</div>
<div class="download-actions">
<button @click="pauseDownload" class="pause-btn">暂停</button>
<button @click="cancelDownload" class="cancel-btn">取消</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import axios from 'axios'
const downloading = ref(false)
const progress = ref(0)
const downloadedSize = ref(0)
const downloadSpeed = ref(0)
let startTime = 0
let lastTime = 0
let lastSize = 0
let controller: AbortController | null = null
// 开始下载
const startDownload = async () => {
try {
downloading.value = true
progress.value = 0
downloadedSize.value = 0
downloadSpeed.value = 0
startTime = Date.now()
lastTime = startTime
lastSize = 0
// 创建AbortController用于取消下载
controller = new AbortController()
const response = await axios.get('/api/files/large', {
responseType: 'blob',
signal: controller.signal,
onDownloadProgress: (progressEvent) => {
if (progressEvent.total) {
downloadedSize.value = progressEvent.loaded
progress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total)
// 计算下载速度
const now = Date.now()
const timeDiff = now - lastTime
const sizeDiff = progressEvent.loaded - lastSize
if (timeDiff > 1000) { // 每秒计算一次
downloadSpeed.value = Math.round((sizeDiff / 1024) / (timeDiff / 1000))
lastTime = now
lastSize = progressEvent.loaded
}
}
}
})
// 下载完成,保存文件
const url = window.URL.createObjectURL(new Blob([response.data]))
const link = document.createElement('a')
link.href = url
link.setAttribute('download', '大型文件.zip')
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
alert('文件下载完成')
} catch (error: any) {
if (error.name !== 'CanceledError') {
console.error('文件下载失败:', error)
alert('文件下载失败')
}
} finally {
downloading.value = false
controller = null
}
}
// 暂停下载
const pauseDownload = () => {
// 注意:axios的取消下载会导致请求失败,实际项目中需要实现断点续传
if (controller) {
controller.abort()
downloading.value = false
}
}
// 取消下载
const cancelDownload = () => {
if (controller) {
controller.abort()
downloading.value = false
progress.value = 0
downloadedSize.value = 0
downloadSpeed.value = 0
}
}
// 格式化文件大小
const formatFileSize = (size: number): string => {
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`
}
</script>
<style scoped>
.download-progress {
max-width: 600px;
padding: 20px;
background-color: #f9f9f9;
border-radius: 8px;
border: 1px solid #ccc;
}
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.file-info {
display: flex;
flex-direction: column;
gap: 5px;
}
.file-name {
font-weight: bold;
}
.file-size {
color: #666;
font-size: 14px;
}
.progress-container {
background-color: white;
padding: 20px;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.progress-bar {
height: 10px;
background-color: #e5e7eb;
border-radius: 5px;
overflow: hidden;
margin-bottom: 10px;
}
.progress {
height: 100%;
background-color: #409eff;
transition: width 0.3s ease;
}
.progress-info {
display: flex;
justify-content: space-between;
font-size: 14px;
margin-bottom: 15px;
color: #666;
}
.download-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.download-btn,
.pause-btn,
.cancel-btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.download-btn {
background-color: #409eff;
color: white;
}
.download-btn:disabled {
background-color: #a0cfff;
cursor: not-allowed;
}
.pause-btn {
background-color: #f59e0b;
color: white;
}
.cancel-btn {
background-color: #f56c6c;
color: white;
}
</style>3. 文件上传下载的最佳实践
3.1 前端最佳实践
- 文件类型验证:使用accept属性限制文件类型,在客户端进行验证
- 文件大小限制:在客户端检查文件大小,避免过大文件上传
- 进度反馈:提供清晰的上传/下载进度反馈
- 拖拽支持:实现拖拽上传功能,提升用户体验
- 断点续传:大文件支持断点续传,提高上传成功率
- 并发控制:限制并发上传的文件数量或分片数量
- 错误处理:妥善处理上传/下载过程中的错误
- 取消/暂停功能:允许用户取消或暂停上传/下载
- 预览功能:对支持预览的文件提供预览功能
- 响应式设计:适配不同屏幕尺寸
3.2 后端最佳实践
- 文件存储:使用对象存储服务(如S3、OSS)存储文件,避免直接存储在服务器
- 文件命名:生成唯一文件名,避免文件名冲突
- 分块处理:支持大文件分片上传和合并
- 断点续传:记录已上传的分片信息,支持续传
- 文件元数据:存储文件的元数据,如大小、类型、上传时间等
- 访问控制:实现文件的访问权限控制
- CDN加速:使用CDN加速文件下载
- 限流策略:对上传/下载请求进行限流,防止服务器过载
- 病毒扫描:对上传的文件进行病毒扫描
- 日志记录:记录文件上传/下载日志,便于审计和排查问题
常见问题与解决方案
1. 问题:文件上传大小限制
解决方案:
- 前端:在客户端检查文件大小,给出友好提示
- 后端:调整服务器配置,如Nginx的
client_max_body_size,Node.js的body-parser限制 - 采用分片上传:将大文件分割成小块上传
2. 问题:跨域文件上传
解决方案:
- 后端设置正确的CORS头,允许跨域请求
- 使用代理服务器转发请求
- 在同域下部署文件上传服务
3. 问题:文件上传进度不准确
解决方案:
- 确保服务器正确返回
Content-Length头 - 使用
onUploadProgress和onDownloadProgress事件监听进度 - 对于分片上传,需要在服务器端实现进度计算
4. 问题:文件下载时浏览器直接打开而不是下载
解决方案:
- 后端设置正确的
Content-Disposition头:attachment; filename="filename.ext" - 前端使用
blob类型接收响应,创建下载链接
5. 问题:断点续传实现复杂
解决方案:
- 使用成熟的文件上传库,如
resumable.js、uppy等 - 后端需要实现分片管理、合并逻辑
- 使用文件哈希标识文件,避免重复上传
进一步学习资源
- HTML5 File API文档
- Axios文件上传下载文档
- 大文件分片上传实现
- 断点续传原理与实现
- Uppy - 现代文件上传库
- Resumable.js - 断点续传库
- AWS S3文件上传
- 阿里云OSS文件上传
课后练习
基础练习:
- 实现单文件上传功能,支持图片预览
- 实现文件下载功能,支持多种文件类型
- 添加文件上传进度显示
进阶练习:
- 实现多文件上传功能
- 实现大文件分片上传
- 添加断点续传功能
- 实现带进度条的文件下载
挑战练习:
- 实现文件上传队列管理
- 添加文件上传前的病毒扫描
- 实现文件共享功能
- 实现文件版本控制
- 集成云存储服务(如AWS S3、阿里云OSS)
通过本集的学习,你应该能够掌握Vue 3项目中文件上传与下载的核心实现,包括单文件上传、多文件上传、大文件分片上传、断点续传以及文件下载等功能。合理的文件处理机制能够显著提升用户体验,同时也需要考虑服务器端的性能和安全性。在实际项目中,建议根据业务需求选择合适的文件处理方案,必要时可以使用成熟的第三方库来简化开发。