Quasar框架文件上传组件全解析:QFile与QUploader企业级实践指南
Quasar框架文件上传组件全解析:QFile与QUploader企业级实践指南
目录
组件核心差异对比
基础能力对比表
评估维度 |
QFile组件 |
QUploader组件 |
核心定位 |
轻量级文件选择器 |
全功能上传解决方案 |
上传机制 |
需手动实现上传逻辑 |
内置XHR上传能力 |
UI组件 |
基础按钮+文件名显示 |
拖拽区域+文件列表+进度指示 |
拦截器支持 |
✅ 通过Axios支持 |
❌ 原生XHR不支持 |
配置复杂度 |
中等(需手动配置Axios) |
低(内置完整功能) |
自定义程度 |
高(完全控制上传逻辑) |
中(组件内置逻辑) |
工作原理架构图
┌─────────────── QFile工作流 ───────────────┐
│ │
│ 选择文件 → 获取File对象 → 构建FormData → │
│ │
│ Axios发送请求 → 处理响应 │
│ │
│ (拦截器自动处理认证、错误、进度监控) │
│ │
└───────────────────────────────────────────┘
┌─────────────── QUploader工作流 ─────────────┐
│ │
│ 选择/拖拽文件 → 组件内置XHR → 直接上传 │
│ │
│ (需手动配置认证、独立处理错误和进度) │
│ │
└────────────────────────────────────────────┘
QUploader与Axios关系深度解析
核心结论
QUploader确实绕过了Axios及其拦截器,因为它使用原生XMLHttpRequest实现上传功能,而非基于Axios封装。这导致:
1. 拦截器失效:Axios的请求/响应拦截器不会作用于QUploader的上传请求
2. 认证需手动处理:需通过工厂函数手动添加Authorization头
3. 独立错误处理:需通过组件@error事件单独处理上传错误
4. 状态管理差异:QUploader内置上传状态管理,与Axios无关
技术原理对比
特性 |
QUploader实现 |
Axios实现 |
底层技术 |
原生XMLHttpRequest |
XMLHttpRequest/Fetch API封装 |
拦截器支持 |
❌ 不支持 |
✅ 请求/响应拦截器 |
认证处理 |
工厂函数手动添加header |
请求拦截器自动注入token |
错误处理 |
@error事件回调 |
try/catch或响应拦截器 |
进度监控 |
内置@upload-progress事件 |
onUploadProgress配置项 |
取消请求 |
abort()方法 |
CancelToken或AbortController |
QUploader添加认证头示例
<template>
<q-uploader
:factory="createUploadConfig"
label="带认证的文件上传"
/>
</template>
<script setup>
import { useAuthStore } from 'stores/auth';
const authStore = useAuthStore();
// 工厂函数手动添加认证头
const createUploadConfig = () => {
return {
url: 'https://api.example.com/upload',
method: 'POST',
headers: [
{ name: 'Authorization', value: `Bearer ${authStore.token}` },
{ name: 'X-App-Version', value: '2.3.1' }
]
};
};
</script>
QFile+Axios多图上传完整实现
企业级完整代码实现
<template>
<div class="upload-container">
<h2>多图上传控制台</h2>
<!-- 文件选择区域 -->
<div class="upload-area">
<q-file
v-model="selectedFiles"
label="选择多张图片"
multiple
accept=".jpg, .jpeg, .png, .webp"
@update:model-value="handleFileSelection"
class="file-input"
>
<template v-slot:prepend>
<q-icon name="cloud_upload" size="2rem" color="primary" />
</template>
</q-file>
<p class="upload-hint">或拖放图片到此处(最多10张)</p>
<p class="file-format-hint">支持格式: JPG, PNG, WebP</p>
</div>
<!-- 预览区域 -->
<div class="preview-section">
<h3>图片预览 ({{ previewImages.length }})</h3>
<div class="preview-grid">
<div v-for="(image, index) in previewImages" :key="index" class="preview-item">
<img :src="image.url" alt="预览图" class="preview-img">
<div class="preview-overlay">
<button class="remove-btn" @click="removeImage(index)">
<q-icon name="close" size="1.2rem" />
</button>
<div class="file-info">
<span class="file-name">{{ image.name }}</span>
<span class="file-size">{{ formatSize(image.size) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 控制按钮 -->
<div class="control-buttons">
<q-btn
label="开始上传"
color="primary"
@click="uploadAllImages"
:disabled="isUploading || previewImages.length === 0"
:loading="isUploading"
class="upload-btn"
/>
<q-btn
label="清空列表"
color="negative"
@click="clearAllImages"
:disabled="isUploading"
class="clear-btn"
/>
</div>
<!-- 上传状态 -->
<div v-if="isUploading" class="upload-status">
<h3>上传进度</h3>
<q-linear-progress :value="uploadProgress" stripe color="primary" />
<div class="progress-details">
<span>进度: {{ Math.round(uploadProgress * 100) }}%</span>
<span>已上传: {{ uploadedCount }}/{{ totalCount }}</span>
</div>
</div>
<!-- 上传结果 -->
<div v-if="uploadResults.length > 0" class="upload-results">
<h3>上传结果</h3>
<q-list bordered>
<q-item v-for="(result, index) in uploadResults" :key="index" :color="result.success ? 'positive' : 'negative'">
<q-item-section avatar>
<q-icon :name="result.success ? 'check_circle' : 'error'" />
</q-item-section>
<q-item-section>
<q-item-label>{{ result.name }}</q-item-label>
<q-item-label caption>{{ result.message }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import axios from 'axios';
import { useQuasar } from 'quasar';
const $q = useQuasar();
// 状态管理
const selectedFiles = ref([]);
const previewImages = ref([]);
const uploadResults = ref([]);
const isUploading = ref(false);
const uploadProgress = ref(0);
const uploadedCount = ref(0);
const totalCount = ref(0);
// 格式化文件大小
const formatSize = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// 处理文件选择
const handleFileSelection = (files) => {
if (!files || files.length === 0) return;
// 限制最多10张图片
const maxFiles = 10;
const newFiles = Array.from(files);
const filesToAdd = newFiles.slice(0, maxFiles - previewImages.value.length);
filesToAdd.forEach(file => {
// 验证文件类型
if (!file.type.match('image.*')) {
$q.notify({
type: 'negative',
message: `文件 ${file.name} 不是图片类型`
});
return;
}
// 验证文件大小(最大5MB)
if (file.size > 5 * 1024 * 1024) {
$q.notify({
type: 'negative',
message: `文件 ${file.name} 超过5MB限制`
});
return;
}
// 创建预览
const reader = new FileReader();
reader.onload = (e) => {
previewImages.value.push({
file,
url: e.target.result,
name: file.name,
size: file.size
});
};
reader.readAsDataURL(file);
});
};
// 移除图片
const removeImage = (index) => {
previewImages.value.splice(index, 1);
};
// 清空所有图片
const clearAllImages = () => {
previewImages.value = [];
uploadResults.value = [];
};
// 上传所有图片
const uploadAllImages = async () => {
// 重置状态
isUploading.value = true;
uploadProgress.value = 0;
uploadedCount.value = 0;
totalCount.value = previewImages.value.length;
uploadResults.value = [];
// 创建FormData
const formData = new FormData();
// 添加图片文件
previewImages.value.forEach((item, index) => {
formData.append(`images[${index}]`, item.file, item.name);
});
// 添加额外元数据
formData.append('uploadTimestamp', new Date().toISOString());
formData.append('uploadSource', 'admin-panel');
try {
// 发送上传请求
const response = await axios.post('https://api.example.com/upload/batch', formData, {
headers: {
'Content-Type': 'multipart/form-data'
// Axios拦截器会自动添加Authorization头
},
onUploadProgress: (progressEvent) => {
// 更新上传进度
uploadProgress.value = progressEvent.loaded / progressEvent.total;
}
});
// 处理成功响应
if (response.data.success) {
response.data.results.forEach(result => {
uploadResults.value.push({
name: result.fileName,
success: true,
message: `上传成功,文件ID: ${result.fileId}`
});
});
$q.notify({
type: 'positive',
message: `成功上传 ${previewImages.value.length} 张图片`
});
}
} catch (error) {
// 处理错误
uploadResults.value.push({
name: '上传失败',
success: false,
message: error.response?.data?.message || error.message
});
$q.notify({
type: 'negative',
message: '上传失败,请重试'
});
} finally {
isUploading.value = false;
}
};
</script>
<style scoped>
.upload-container {
max-width: 1000px;
margin: 0 auto;
padding: 20px;
}
.upload-area {
border-radius: 12px;
padding: 30px;
margin-bottom: 30px;
border: 2px dashed #dee2e6;
}
.preview-section {
margin-bottom: 30px;
}
.preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 15px;
margin-top: 15px;
}
.preview-item {
position: relative;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
aspect-ratio: 1/1;
}
.preview-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
color: white;
padding: 10px;
}
.file-name {
font-weight: bold;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size {
font-size: 0.8rem;
opacity: 0.9;
}
.remove-btn {
position: absolute;
top: 5px;
right: 5px;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.2s;
}
.remove-btn:hover {
}
.control-buttons {
display: flex;
gap: 15px;
margin-bottom: 30px;
}
.upload-btn, .clear-btn {
flex: 1;
}
.upload-status {
margin-bottom: 30px;
padding: 20px;
border-radius: 8px;
}
.progress-details {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 0.9rem;
}
.upload-results {
margin-top: 20px;
}
</style>
企业级优化方案
1. 分片上传实现
// 分片上传核心代码
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB分片
async function uploadLargeFile(file) {
// 1. 初始化上传
const { fileId } = await axios.post('/api/upload/init', {
fileName: file.name,
fileSize: file.size,
mimeType: file.type,
chunkSize: CHUNK_SIZE
});
// 2. 计算总分片数
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const uploadPromises = [];
// 3. 生成所有分片上传任务
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
// 创建FormData
const formData = new FormData();
formData.append('fileId', fileId);
formData.append('chunkIndex', i);
formData.append('chunk', chunk);
// 添加到上传队列
uploadPromises.push(
axios.post('/api/upload/chunk', formData, {
onUploadProgress: (e) => {
// 计算该片进度
const chunkProgress = e.loaded / e.total;
// 更新整体进度
updateOverallProgress(i, chunkProgress, totalChunks);
}
})
);
}
// 4. 并发上传所有分片(控制并发数)
const results = await Promise.allSettled(uploadPromises);
// 5. 验证所有分片上传成功
const allSuccess = results.every(r => r.status === 'fulfilled');
if (allSuccess) {
// 6. 通知服务器合并分片
return axios.post('/api/upload/complete', { fileId });
} else {
throw new Error('部分分片上传失败');
}
}