uni-app——uni-app 小程序大文件上传的体验优化实践
# uni-app——uni-app 小程序大文件上传的体验优化实践
·
问题背景
在小程序开发中,文件上传是常见功能。但当用户上传较大的文件(如会议决议文档、合同PDF等)时,往往会遇到体验问题:
实际场景: 用户上传一个 40MB 的决议文件,上传耗时超过 1 分钟。
问题现象:
- 页面没有任何上传进度反馈,用户不知道是否在上传
- 上传期间用户可以点击其他按钮、甚至退出页面
- 误操作导致上传中断,用户需要重新上传
- 体验像"卡住了但还能乱点"
用户视角:
┌─────────────────────────────────────┐
│ 上传决议文件 │
│ │
│ [选择文件] 会议决议.pdf │
│ │
│ [提交] ← 点了没反应?再点一下? │
│ │
│ ← 返回 ← 算了不传了,退出吧 │
└─────────────────────────────────────┘
实际情况:文件正在上传中,但用户完全不知道!
问题分析
1. 前端:缺少上传状态管理
// 问题代码
const uploadFile = async () => {
// 直接调用上传,没有任何状态管理
const res = await api.upload(file)
// 用户在等待期间可以随意操作页面
}
2. 前端:未暴露上传进度
// 问题代码:通用上传函数没有进度回调
export const uploadFile = (options) => {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: options.url,
filePath: options.filePath,
name: 'file',
success: resolve,
fail: reject
// 缺少进度监听!
})
})
}
3. 后端:资源初始化重复
// 问题代码:每次上传都重新初始化 Tika
public String detectFileType(InputStream input) {
TikaConfig config = new TikaConfig(); // 每次都 new,耗时!
TikaInputStream stream = TikaInputStream.get(input);
// 没有关闭 stream,资源泄漏!
return detector.detect(stream, metadata).toString();
}
解决方案
方案一:上传进度监听
改造通用上传函数,暴露进度回调:
// utils/file.js
/**
* 文件上传(支持进度回调)
* @param {Object} options
* @param {string} options.url - 上传地址
* @param {string} options.filePath - 文件临时路径
* @param {string} options.name - 文件字段名
* @param {Object} options.formData - 额外表单数据
* @param {Function} options.onBeforeUpload - 上传前回调
* @param {Function} options.onProgress - 进度回调 (progress: 0-100)
* @returns {Promise}
*/
export const uploadFile = (options) => {
return new Promise((resolve, reject) => {
// 上传前回调
options.onBeforeUpload?.()
// 创建上传任务
const uploadTask = uni.uploadFile({
url: options.url,
filePath: options.filePath,
name: options.name || 'file',
formData: options.formData || {},
header: {
'Authorization': `Bearer ${getToken()}`
},
success: (res) => {
if (res.statusCode === 200) {
try {
const data = JSON.parse(res.data)
resolve(data)
} catch (e) {
resolve(res.data)
}
} else {
reject(new Error(`上传失败: ${res.statusCode}`))
}
},
fail: (err) => {
reject(err)
}
})
// 监听上传进度
uploadTask.onProgressUpdate((res) => {
// res.progress: 上传进度百分比 (0-100)
// res.totalBytesSent: 已上传的数据长度
// res.totalBytesExpectedToSend: 预期需要上传的数据总长度
options.onProgress?.(res.progress, res)
})
})
}
方案二:上传状态管理
在页面中管理上传状态,禁止用户误操作:
<template>
<view class="upload-page">
<!-- 上传遮罩层 -->
<view v-if="isUploading" class="upload-overlay">
<view class="upload-modal">
<view class="upload-icon">
<text class="iconfont icon-upload"></text>
</view>
<text class="upload-text">文件上传中...</text>
<view class="upload-progress-bar">
<view
class="upload-progress-inner"
:style="{ width: uploadProgress + '%' }"
></view>
</view>
<text class="upload-percent">{{ uploadProgress }}%</text>
<text class="upload-tip">请勿离开当前页面</text>
</view>
</view>
<!-- 页面内容 -->
<view class="content">
<view class="file-section">
<text class="label">决议文件</text>
<view class="file-picker" @tap="chooseFile">
<text v-if="!selectedFile">点击选择文件</text>
<text v-else>{{ selectedFile.name }}</text>
</view>
</view>
<button
class="submit-btn"
:disabled="isUploading"
@tap="handleSubmit"
>
{{ isUploading ? '上传中...' : '提交' }}
</button>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onBackPress } from '@dcloudio/uni-app'
import { uploadFile } from '@/utils/file'
// 上传状态
const isUploading = ref(false)
const uploadProgress = ref(0)
const selectedFile = ref(null)
// 选择文件
const chooseFile = () => {
if (isUploading.value) return
uni.chooseMessageFile({
count: 1,
type: 'file',
success: (res) => {
selectedFile.value = res.tempFiles[0]
}
})
}
// 提交上传
const handleSubmit = async () => {
if (!selectedFile.value) {
uni.showToast({ title: '请选择文件', icon: 'none' })
return
}
if (isUploading.value) return
try {
isUploading.value = true
uploadProgress.value = 0
const result = await uploadFile({
url: '/api/file/upload',
filePath: selectedFile.value.path,
name: 'file',
formData: {
meetingId: meetingId.value
},
onBeforeUpload: () => {
console.log('开始上传')
},
onProgress: (progress) => {
uploadProgress.value = progress
}
})
uni.showToast({ title: '上传成功', icon: 'success' })
// 处理上传成功后的逻辑
} catch (error) {
uni.showToast({ title: '上传失败,请重试', icon: 'none' })
} finally {
isUploading.value = false
uploadProgress.value = 0
}
}
// 拦截页面返回
onBackPress(() => {
if (isUploading.value) {
uni.showModal({
title: '提示',
content: '文件正在上传中,离开将中断上传,确定要离开吗?',
success: (res) => {
if (res.confirm) {
// 用户确认离开,可以取消上传任务
isUploading.value = false
uni.navigateBack()
}
}
})
return true // 阻止默认返回行为
}
return false // 允许返回
})
</script>
<style scoped>
.upload-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.upload-modal {
width: 500rpx;
padding: 60rpx 40rpx;
background: #fff;
border-radius: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.upload-icon {
width: 120rpx;
height: 120rpx;
background: #e8f5e9;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 32rpx;
}
.upload-icon .iconfont {
font-size: 60rpx;
color: #4caf50;
}
.upload-text {
font-size: 32rpx;
color: #333;
font-weight: 500;
margin-bottom: 32rpx;
}
.upload-progress-bar {
width: 100%;
height: 16rpx;
background: #e0e0e0;
border-radius: 8rpx;
overflow: hidden;
margin-bottom: 16rpx;
}
.upload-progress-inner {
height: 100%;
background: linear-gradient(90deg, #4caf50, #8bc34a);
border-radius: 8rpx;
transition: width 0.3s ease;
}
.upload-percent {
font-size: 28rpx;
color: #4caf50;
font-weight: 500;
margin-bottom: 24rpx;
}
.upload-tip {
font-size: 24rpx;
color: #999;
}
.submit-btn {
margin-top: 40rpx;
background: #4caf50;
color: #fff;
border-radius: 44rpx;
}
.submit-btn[disabled] {
background: #ccc;
}
</style>
方案三:封装上传组件
将上传逻辑封装为可复用的组合式函数:
// composables/useUpload.js
import { ref } from 'vue'
import { onBackPress } from '@dcloudio/uni-app'
import { uploadFile } from '@/utils/file'
/**
* 文件上传组合式函数
* @param {Object} options
* @param {string} options.url - 上传地址
* @param {boolean} options.blockBack - 是否阻止返回,默认 true
*/
export const useUpload = (options = {}) => {
const { url, blockBack = true } = options
const isUploading = ref(false)
const progress = ref(0)
const error = ref(null)
// 执行上传
const upload = async (file, formData = {}) => {
if (isUploading.value) {
console.warn('上传进行中,请勿重复调用')
return null
}
isUploading.value = true
progress.value = 0
error.value = null
try {
const result = await uploadFile({
url,
filePath: file.path || file,
name: 'file',
formData,
onProgress: (p) => {
progress.value = p
}
})
return result
} catch (err) {
error.value = err
throw err
} finally {
isUploading.value = false
}
}
// 拦截返回
if (blockBack) {
onBackPress(() => {
if (isUploading.value) {
uni.showToast({
title: '文件上传中,请稍候',
icon: 'none'
})
return true
}
return false
})
}
return {
isUploading,
progress,
error,
upload
}
}
使用方式:
<script setup>
import { useUpload } from '@/composables/useUpload'
const { isUploading, progress, upload } = useUpload({
url: '/api/upload'
})
const handleUpload = async (file) => {
try {
const result = await upload(file, { type: 'resolution' })
console.log('上传成功', result)
} catch (err) {
console.error('上传失败', err)
}
}
</script>
<template>
<upload-overlay v-if="isUploading" :progress="progress" />
</template>
方案四:后端性能优化
优化文件类型检测,避免重复初始化:
// SecurityFileService.java
@Service
public class SecurityFileService {
// 静态复用 TikaConfig,避免重复初始化
private static final TikaConfig TIKA_CONFIG;
private static final Detector DETECTOR;
static {
try {
TIKA_CONFIG = new TikaConfig();
DETECTOR = TIKA_CONFIG.getDetector();
} catch (Exception e) {
throw new RuntimeException("Failed to initialize Tika", e);
}
}
/**
* 检测文件类型
* @param input 文件输入流
* @param fileName 文件名
* @return MIME 类型
*/
public String detectFileType(InputStream input, String fileName) {
// 使用 try-with-resources 确保流关闭
try (TikaInputStream tikaStream = TikaInputStream.get(input)) {
Metadata metadata = new Metadata();
metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, fileName);
MediaType mediaType = DETECTOR.detect(tikaStream, metadata);
return mediaType.toString();
} catch (Exception e) {
log.warn("Failed to detect file type, fallback to octet-stream", e);
return "application/octet-stream";
}
}
}
完整的上传遮罩组件
封装一个通用的上传遮罩组件:
<!-- components/upload-overlay.vue -->
<template>
<view v-if="visible" class="upload-overlay" @tap.stop>
<view class="upload-modal">
<!-- 上传动画 -->
<view class="upload-animation">
<view class="upload-arrow"></view>
<view class="upload-cloud"></view>
</view>
<!-- 进度信息 -->
<text class="upload-title">{{ title }}</text>
<view class="progress-container">
<view class="progress-bar">
<view
class="progress-inner"
:style="{ width: progress + '%' }"
></view>
</view>
<text class="progress-text">{{ progress }}%</text>
</view>
<!-- 文件信息 -->
<view v-if="fileName" class="file-info">
<text class="file-name">{{ fileName }}</text>
<text class="file-size">{{ formatSize(fileSize) }}</text>
</view>
<!-- 提示 -->
<text class="upload-tip">{{ tip }}</text>
<!-- 取消按钮(可选) -->
<button
v-if="cancelable"
class="cancel-btn"
@tap="$emit('cancel')"
>
取消上传
</button>
</view>
</view>
</template>
<script setup>
defineProps({
visible: {
type: Boolean,
default: false
},
progress: {
type: Number,
default: 0
},
title: {
type: String,
default: '文件上传中...'
},
tip: {
type: String,
default: '请勿离开当前页面'
},
fileName: {
type: String,
default: ''
},
fileSize: {
type: Number,
default: 0
},
cancelable: {
type: Boolean,
default: false
}
})
defineEmits(['cancel'])
// 格式化文件大小
const formatSize = (bytes) => {
if (!bytes) return ''
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
}
</script>
<style scoped>
.upload-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.upload-modal {
width: 560rpx;
padding: 60rpx 48rpx;
background: #fff;
border-radius: 32rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.upload-animation {
width: 160rpx;
height: 160rpx;
position: relative;
margin-bottom: 40rpx;
}
.upload-cloud {
width: 100%;
height: 100%;
background: #e3f2fd;
border-radius: 50%;
}
.upload-arrow {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 0;
height: 0;
border-left: 24rpx solid transparent;
border-right: 24rpx solid transparent;
border-bottom: 40rpx solid #2196f3;
animation: upload-bounce 1s ease-in-out infinite;
}
@keyframes upload-bounce {
0%, 100% { transform: translate(-50%, -50%); }
50% { transform: translate(-50%, -70%); }
}
.upload-title {
font-size: 36rpx;
font-weight: 600;
color: #333;
margin-bottom: 32rpx;
}
.progress-container {
width: 100%;
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 24rpx;
}
.progress-bar {
flex: 1;
height: 20rpx;
background: #e0e0e0;
border-radius: 10rpx;
overflow: hidden;
}
.progress-inner {
height: 100%;
background: linear-gradient(90deg, #2196f3, #03a9f4);
border-radius: 10rpx;
transition: width 0.3s ease;
}
.progress-text {
font-size: 28rpx;
font-weight: 600;
color: #2196f3;
min-width: 80rpx;
text-align: right;
}
.file-info {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 24rpx;
}
.file-name {
font-size: 26rpx;
color: #666;
max-width: 300rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: 24rpx;
color: #999;
}
.upload-tip {
font-size: 26rpx;
color: #999;
margin-top: 16rpx;
}
.cancel-btn {
margin-top: 32rpx;
padding: 16rpx 48rpx;
background: #f5f5f5;
color: #666;
font-size: 28rpx;
border-radius: 32rpx;
}
</style>
最佳实践总结
1. 上传状态三要素
| 要素 | 说明 | 实现方式 |
|---|---|---|
| 进度反馈 | 让用户知道上传在进行中 | onProgressUpdate 回调 |
| 禁止操作 | 防止用户误操作中断上传 | 遮罩层 + 按钮禁用 |
| 返回拦截 | 防止用户退出页面 | onBackPress 钩子 |
2. 上传函数设计原则
// ✅ 好的设计:暴露完整的生命周期钩子
uploadFile({
url,
filePath,
onBeforeUpload, // 上传前
onProgress, // 进度更新
onSuccess, // 成功回调
onError, // 失败回调
onComplete // 完成回调(无论成功失败)
})
3. 用户体验优化清单
- 显示上传进度百分比
- 显示文件名和大小
- 遮罩层阻止误操作
- 拦截页面返回
- 提供取消上传选项(可选)
- 上传失败给出明确提示
- 支持断点续传(高级)
4. 后端配合优化
- 资源复用(如 TikaConfig)
- 使用 try-with-resources 管理流
- 合理设置超时时间
- 支持分片上传(大文件)
总结
-
用户体验是核心:文件上传耗时不可控,必须给用户明确的反馈和保护
-
进度回调是关键:
uni.uploadFile返回的uploadTask支持进度监听,一定要用起来 -
防误操作是必须:遮罩层 + 返回拦截,双重保障
-
组件化便于复用:封装通用的上传遮罩组件和
useUpload组合式函数 -
前后端协作优化:前端体验 + 后端性能,缺一不可
本文源于实际项目中的性能优化实践,解决了文件上传体验差的问题。希望能帮助其他开发者构建更好的文件上传体验。
更多推荐
所有评论(0)