UniApp跨平台文件OSS直传最佳实践:解决H5与小程序文件损坏问题

在UniApp开发中,实现跨平台的文件上传功能是一个常见需求。本文将详细介绍如何实现支持前端直传和后端代理两种模式的文件上传方案,并解决H5与小程序环境下的兼容性问题。

前言

在现代Web应用开发中,文件上传是一个基础而重要的功能。使用UniApp开发跨平台应用时,我们需要面对不同平台(H5、小程序、App)之间的差异性。本文将通过一个完整的实战案例,展示如何构建一个健壮的文件上传解决方案。

项目背景

我们需要实现一个支持以下特性的文件上传功能:

  • 🚀 支持前端直传(S3兼容存储)和后端代理两种模式
  • 📱 兼容H5、微信小程序等多个平台
  • 🔒 支持预签名URL安全上传
  • 📊 完整的上传进度反馈
  • 🛠️ TypeScript类型安全

核心架构设计

上传模式枚举

export enum UPLOAD_TYPE {
  // 客户端直接上传(只支持S3服务)
  CLIENT = 'client',
  // 客户端发送到后端上传
  SERVER = 'server'
}

关键技术挑战

  1. 平台差异性处理:不同平台的文件读取API不同
  2. 二进制数据处理:确保文件在传输过程中不被损坏
  3. 预签名URL集成:安全地实现前端直传
  4. 错误处理机制:提供友好的错误反馈

完整实现方案

1. 核心接口定义

// 获取文件预签名地址
const getFilePresignedUrl = (name: string, directory?: string) => {
  return httpGet<any>('/infra/file/presigned-url', { name, directory })
}

// 创建文件记录
const createFile = (data: any) => {
  return httpPost('/infra/file/create', data)
}

// 后端上传接口
const updateFile = (data: any) => {
  return httpUpload({ tempFilePath: data.file, formData: data })
}

2. 跨平台文件读取核心函数

这是整个方案的核心,需要处理不同平台的文件读取差异:

async function getFileBinary(file: any): Promise<ArrayBuffer> {
  // #ifdef MP
  // 小程序环境:使用文件系统API
  const fs = uni.getFileSystemManager()
  return new Promise((resolve, reject) => {
    fs.readFile({
      filePath: file.path || file.tempFilePath,
      // 关键:不指定encoding,直接读取二进制数据
      success: (res) => {
        if (res.data instanceof ArrayBuffer) {
          resolve(res.data)
        } else {
          // 兼容处理:转换为ArrayBuffer
          const uint8Array = new Uint8Array(res.data)
          resolve(uint8Array.buffer)
        }
      },
      fail: reject
    })
  })
  // #endif

  // #ifdef H5
  // H5环境:支持多种文件读取方式
  return new Promise((resolve, reject) => {
    const filePath = file.tempFilePath || file.path
    if (filePath) {
      // 方案1:使用fetch读取blob URL
      fetch(filePath)
        .then(response => response.arrayBuffer())
        .then(resolve)
        .catch(reject)
    } else if (file && typeof file.arrayBuffer === 'function') {
      // 方案2:标准File对象直接调用
      file.arrayBuffer().then(resolve).catch(reject)
    } else if (file instanceof Blob || file instanceof File) {
      // 方案3:使用FileReader兜底
      const reader = new FileReader()
      reader.onload = () => resolve(reader.result as ArrayBuffer)
      reader.onerror = () => reject(new Error('Failed to read file'))
      reader.readAsArrayBuffer(file)
    } else {
      reject(new Error('Unsupported file type for H5 environment'))
    }
  })
  // #endif
}

3. 文件上传主要逻辑

const httpRequest = async (options: any) => {
  if (isClientUpload) {
    // 模式一:前端直传
    const fileName = await generateFileName(options.file)
    const { data } = await getFilePresignedUrl(fileName, directory)
    const arrayBuffer = await getFileBinary(options.file)
    
    return new Promise((resolve, reject) => {
      uni.request({
        url: data.uploadUrl,
        method: 'PUT',
        header: {
          isToken: false,
          'Content-Type': options.file.type || 'application/octet-stream'
        },
        data: arrayBuffer,
        dataType: 'text',
        responseType: 'text',
        success: (response) => {
          // 异步记录文件信息
          _createFile(data, options.file)
          resolve(data.url)
        },
        fail: reject
      })
    })
  } else {
    // 模式二:后端代理上传
    return updateFile({ file: options.file, directory })
  }
}

4. 平台兼容的文件选择

const run = (): Promise<string> => {
  return new Promise((resolve, reject) => {
    // #ifdef MP-WEIXIN
    // 微信小程序:使用chooseMedia
    uni.chooseMedia({
      count: 1,
      mediaType: ['image'],
      success: (res) => {
        const tempFile = res.tempFiles[0]
        const fileObj = {
          ...tempFile,
          tempFilePath: tempFile.tempFilePath,
          path: tempFile.tempFilePath,
          type: tempFile.fileType || getContentTypeByExtension(tempFile.tempFilePath),
          name: tempFile.name || generateTempFileName(tempFile.tempFilePath)
        }
        httpRequest({ file: fileObj }).then(resolve).catch(reject)
      },
      fail: reject
    })
    // #endif

    // #ifndef MP-WEIXIN
    // 其他平台:使用chooseImage
    uni.chooseImage({
      count: 1,
      success: (res) => {
        const tempFile = res.tempFiles[0]
        const tempFilePath = res.tempFilePaths[0]
        const fileObj = {
          ...tempFile,
          tempFilePath: tempFilePath,
          path: tempFilePath,
          type: tempFile.type || getContentTypeByExtension(tempFilePath),
          name: tempFile.name || generateTempFileName(tempFilePath),
          size: tempFile.size || 0
        }
        httpRequest({ file: fileObj }).then(resolve).catch(reject)
      },
      fail: reject
    })
    // #endif
  })
}

关键问题解决方案

问题1:小程序文件损坏

原因分析

  • 使用了错误的编码参数 encoding: 'binary'
  • 数据格式转换不当

解决方案

// ❌ 错误做法
fs.readFile({
  filePath: file.tempFilePath,
  encoding: 'binary', // 这会导致数据损坏
  success: (res) => resolve(res.data)
})

// ✅ 正确做法
fs.readFile({
  filePath: file.tempFilePath,
  // 不指定encoding,直接读取二进制
  success: (res) => {
    if (res.data instanceof ArrayBuffer) {
      resolve(res.data)
    } else {
      const uint8Array = new Uint8Array(res.data)
      resolve(uint8Array.buffer)
    }
  }
})

问题2:H5环境file.arrayBuffer不存在

原因分析

  • UniApp包装的文件对象不是标准File对象
  • 缺少arrayBuffer方法

解决方案

// 提供多种读取方式的兜底方案
if (filePath) {
  // 优先使用fetch读取
  fetch(filePath).then(response => response.arrayBuffer())
} else if (file && typeof file.arrayBuffer === 'function') {
  // 标准File对象
  file.arrayBuffer()
} else if (file instanceof Blob || file instanceof File) {
  // FileReader兜底
  const reader = new FileReader()
  reader.readAsArrayBuffer(file)
}

问题3:Content-Type识别

实现自动Content-Type识别:

function getContentTypeByExtension(filename: string): string {
  const ext = getFileExtension(filename).toLowerCase()
  const mimeTypes: Record<string, string> = {
    '.jpg': 'image/jpeg',
    '.jpeg': 'image/jpeg',
    '.png': 'image/png',
    '.gif': 'image/gif',
    '.webp': 'image/webp',
    '.pdf': 'application/pdf'
    // ... 更多类型
  }
  return mimeTypes[ext] || 'application/octet-stream'
}

使用方式

基础用法

// 在Vue组件中使用
import { useUploads } from '@/composables/useUploads'

const { run } = useUploads('images')

const handleUpload = async () => {
  try {
    const url = await run()
    console.log('上传成功:', url)
  } catch (error) {
    console.error('上传失败:', error)
  }
}

配置环境变量

# .env文件
VITE_UPLOAD_TYPE=client  # 或 server
VITE_BASE_UPLOAD_URL=https://your-api.com

最佳实践建议

1. 错误处理

try {
  const url = await run()
  // 成功处理
} catch (error) {
  if (error.message.includes('file size')) {
    uni.showToast({ title: '文件过大', icon: 'none' })
  } else {
    uni.showToast({ title: '上传失败', icon: 'none' })
  }
}

2. 进度提示

uni.showLoading({ title: '上传中...' })
try {
  const url = await run()
  uni.hideLoading()
  uni.showToast({ title: '上传成功', icon: 'success' })
} catch (error) {
  uni.hideLoading()
  uni.showToast({ title: '上传失败', icon: 'error' })
}

3. 文件大小限制

const validateFileSize = (file: any, maxSize: number = 5 * 1024 * 1024) => {
  if (file.size > maxSize) {
    throw new Error('文件大小不能超过5MB')
  }
}

性能优化

1. 图片压缩

// 在上传前压缩图片
const compressImage = (file: any) => {
  return new Promise((resolve) => {
    uni.compressImage({
      src: file.tempFilePath,
      quality: 80,
      success: resolve,
      fail: () => resolve(file) // 压缩失败时使用原文件
    })
  })
}

2. 并发上传限制

const uploadQueue = new Set()
const MAX_CONCURRENT = 3

const addToQueue = async (uploadTask: () => Promise<any>) => {
  while (uploadQueue.size >= MAX_CONCURRENT) {
    await new Promise(resolve => setTimeout(resolve, 100))
  }
  
  const promise = uploadTask()
  uploadQueue.add(promise)
  
  try {
    return await promise
  } finally {
    uploadQueue.delete(promise)
  }
}

总结

本文提供了一个完整的UniApp跨平台文件上传解决方案,主要特点包括:

  • 跨平台兼容:支持H5、小程序等多个平台
  • 双模式支持:前端直传与后端代理两种模式
  • 类型安全:完整的TypeScript类型定义
  • 错误处理:完善的异常处理机制
  • 性能优化:支持图片压缩和并发控制

通过本文的实践,你可以构建一个稳定、高效的文件上传功能,避免常见的文件损坏和兼容性问题。

参考资源


💡 提示:在实际项目中,建议根据具体需求调整配置参数,并进行充分的测试验证。如果遇到问题,可以通过增加日志输出来排查具体原因。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

爱宇阳

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值