问题背景

在小程序开发中,文件上传是常见功能。但当用户上传较大的文件(如会议决议文档、合同PDF等)时,往往会遇到体验问题:

实际场景: 用户上传一个 40MB 的决议文件,上传耗时超过 1 分钟。

问题现象:

  1. 页面没有任何上传进度反馈,用户不知道是否在上传
  2. 上传期间用户可以点击其他按钮、甚至退出页面
  3. 误操作导致上传中断,用户需要重新上传
  4. 体验像"卡住了但还能乱点"
用户视角:
┌─────────────────────────────────────┐
│ 上传决议文件                         │
│                                     │
│  [选择文件] 会议决议.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 管理流
  • 合理设置超时时间
  • 支持分片上传(大文件)

总结

  1. 用户体验是核心:文件上传耗时不可控,必须给用户明确的反馈和保护

  2. 进度回调是关键uni.uploadFile 返回的 uploadTask 支持进度监听,一定要用起来

  3. 防误操作是必须:遮罩层 + 返回拦截,双重保障

  4. 组件化便于复用:封装通用的上传遮罩组件和 useUpload 组合式函数

  5. 前后端协作优化:前端体验 + 后端性能,缺一不可


本文源于实际项目中的性能优化实践,解决了文件上传体验差的问题。希望能帮助其他开发者构建更好的文件上传体验。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐