Fork me on GitHub

uni-app + unibest + Vue3 + Vite 开发微信小程序避坑指南(实战总结)

这不是一篇入门教程,而是基于真实项目踩坑后的工程化经验总结,重点解决:

  • 文件上传 / 选择
  • 图片压缩
  • 文件预览(Blob → 小程序)
  • 接口兼容问题
  • 常见运行时报错

适用于:

  • unibest + Vue3 + Vite 技术栈

  • 微信小程序端


一、环境与基础认知

1. unibest + Vite 的特点

  • 构建快 

  • TS 友好 

  • 但部分 uni API 类型不稳定 

常见问题:

Property 'forEach' does not exist on type ...

解决:

const tempFiles = Array.isArray(res.tempFiles)
  ? res.tempFiles
  : [res.tempFiles]

二、文件选择(核心坑)

坑1:uni.chooseFile 在小程序不可用

你遇到的报错:

TypeError: chooseFile is not a function

正确方案(微信小程序专用)

wx.chooseMessageFile({
  count: 5,
  type: 'file',
  extension: ['pdf', 'doc', 'docx', 'xls', 'xlsx'],
})

关键点:

  • uni.chooseFile ❌(很多版本不可用)

  • 必须用 wx.chooseMessageFile 


推荐封装(一步完成:选择 + 校验 + 上传)

核心思路:

选择 → 校验 → 并发上传 → 返回结果

三、上传文件核心问题

坑2:uploadFile 报错

你遇到的错误:

e.url.startsWith is not a function

原因

你传了错误参数:

uploadSingle(file) //  file 是对象

而正确应该是:

uploadSingle(file.path) 

小程序文件结构:

{
  path: "wxfile://xxx",
  name: "xxx.pdf",
  size: 12345
}

正确上传写法

uni.uploadFile({
  url,
  filePath: file.path,
  name: 'file',
})

四、图片压缩(必须做)

问题

  • 图片动不动 5MB+

  • 上传慢 / 超时


推荐策略

const shouldCompress = (size) => size / 1024 / 1024 > 1

if (shouldCompress(file.size)) {
  await uni.compressImage({
    src: filePath,
    quality: 75
  })
}

注意

  • 只压缩图片

  • PDF / Word 不可压缩


五、文件预览(最容易踩坑)

坑3:PC 用 Blob,小程序不支持

PC代码:

responseType: 'blob'

 小程序  不支持 Blob


小程序正确方案

流程必须这样:

请求 arraybuffer → 写入本地 → 打开文件

标准实现

// 1. 请求文件流
uni.request({
  url,
  responseType: 'arraybuffer'
})

// 2. 写文件
wx.getFileSystemManager().writeFile()

// 3. 打开
uni.openDocument()

坑4:接口返回 JSON(错误)而不是文件流

必须判断:

if (contentType.includes('application/json')) {
  // 说明是错误返回
}

六、文件预览封装建议

推荐工具方法

previewFileById({
  fileId,
  fileUpType
})

必须处理的异常

  • HTTP 状态码 ≠ 200

  • content-type 是 JSON

  • 文件写入失败

  • openDocument 失败


七、图片加载失败处理

坑5:image error 无限触发

你的写法:

item.url = defaultImg

如果 defaultImg 也加载失败 → 死循环 


正确写法

function imageError(e, item, index) {
  if (item.url === defaultImg)
    return

  item.url = defaultImg
}

八、复制文本体验优化

问题

user-select

需要长按 ,不友好


推荐方案

uni.setClipboardData({
  data: value
})

UI 加一个复制图标即可


九、附件URL获取(并发问题)

问题

Promise.allSettled
  • 写法复杂

  • 不好维护


推荐简化

const tasks = files.map(async file => {
  try {
    const res = await getAttachmentUrl()
    return success
  } catch {
    return default
  }
})

await Promise.all(tasks)

十、性能与稳定性建议

1. 并发控制(重要)

const concurrency = 3

避免:

  • 接口打爆

  • 小程序限制


2. 日志体系

建议统一:

const log = (...args) => console.log('[upload]', ...args)
const logError = (...args) => console.error('[upload.error]', ...args)

3. 全链路兜底

每个步骤必须考虑失败:

  • 选择失败

  • 校验失败

  • 上传失败

  • 解析失败


十一、总结(核心经验)

常见错误

  • 用 uni.chooseFile ❌

  • 上传传 file 对象 ❌

  • 直接用 Blob ❌

  • 不判断 content-type ❌


正确心智模型

文件上传

选择 → 校验 →(图片压缩)→ 上传 → 结果汇总

文件预览

请求流 → 写文件 → 打开

十二、建议封装(强烈推荐)

建议抽成3个核心模块:

1、useUploadFile(图片上传)

  • 支持压缩

  • 支持并发

2、useChooseAnyFile(文件上传)

  • 支持文件类型限制

  • 支持批量上传

3、previewFileById(文件预览)

  • 统一处理流

  • 自动识别类型

 

例如:

封装的useUploadFile.ts

import { ref } from 'vue'
import { getEnvBaseUrl } from '@/utils'

const baseUrl = getEnvBaseUrl()
// const baseUrl = 'http://10.xxx:9101'

/* ================== 日志 ================== */
const LOG_ENABLE = true
const log = (...args: any[]) => LOG_ENABLE && console.log('[upload]', ...args)
const logError = (...args: any[]) => LOG_ENABLE && console.error('[upload.error]', ...args)

/* ================== URL ================== */
export const uploadFileUrl = {
  CERT: `${baseUrl}/api/xxx/file/upLoad`,
  AI_CERT: `${baseUrl}/api/xxx/rapidVinRecognition`,
  PREVIEW_CERT: `${baseUrl}/api/xxx/file/preview-url`,
}

/* ================== 类型定义 ================== */

export interface UploadOptions {
  count?: number
  sizeType?: Array<'original' | 'compressed'>
  sourceType?: Array<'album' | 'camera'>
  maxSize?: number
  onProgress?: (progress: number) => void
  onSuccess?: (res: Record<string, any>) => void
  onError?: (err: any) => void
  onComplete?: (summary: {
    data: any[]
    sizeLimitCount: number
    uploadFailCount: number
  }) => void
  onLoading?: (loading: boolean) => void
}

/* ================== 核心上传 ================== */

export function useUploadFile<T = any>(
  url: string,
  formData: Record<string, any> = {},
  options: UploadOptions = {},
  directFilePath?: string,
) {
  const loading = ref(false)
  const error = ref(false)
  const data = ref<T>()
  const progress = ref(0)

  const {
    count = 4,
    sizeType = ['original', 'compressed'],
    sourceType = ['album', 'camera'],
    maxSize = 10,
    onProgress,
    onSuccess,
    onError,
    onComplete,
    onLoading,
  } = options

  const setLoading = (val: boolean) => {
    loading.value = val
    onLoading?.(val)
    log('loading:', val)
  }

  const checkFileSize = (size: number) => {
    const fileSize = size / 1024 / 1024
    log('checkFileSize, maxSize=', fileSize, maxSize, 'MB')
    const ok = fileSize <= maxSize
    log('checkFileSize:', fileSize, '=>', ok)
    return ok
  }

  /* ================== 压缩 ================== */

  const compressImage = (filePath: string, quality = 65) => {
    return new Promise<string>((resolve) => {
      uni.compressImage({
        src: filePath,
        quality,
        success: (res) => {
          log('[已压缩], res:', res)
          return resolve(res.tempFilePath)
        },
        fail: () => resolve(filePath), // 兜底
      })
    })
  }

  const shouldCompress = (file?: any) => {
    // if (url === uploadFileUrl.AI_CERT)
    //   return false
    if (!file)
      return true
    return file.size / 1024 / 1024 > 1
  }

  /* ================== 上传 ================== */

  const uploadSingle = (filePath: string) => {
    log('开始上传单文件:', filePath, formData)
    return new Promise<{ ok: boolean, data?: any }>((resolve) => {
      const task = uni.uploadFile({
        url,
        filePath,
        name: 'file',
        formData,
        header: { 'Content-Type': 'multipart/form-data' },
        success: (res) => {
          log('upload success raw:', res)
          try {
            const r = JSON.parse(res.data)
            log('upload parsed:', r)
            if (r?.result === '1') {
              const response = {
                ...r.row,
                url: filePath,
                fileId: r.row?.docFileInfoDTO?.attachmentId,
                fileUpType: r.row?.docFileInfoDTO?.storageSystem,
              }
              onSuccess?.(response)
              resolve({ ok: true, data: response })
            }
            else {
              logError('业务失败:', r)
              onError?.(r)
              resolve({ ok: false })
            }
          }
          catch (e) {
            logError('解析失败:', e)
            onError?.(e)
            resolve({ ok: false })
          }
        },

        fail: (err) => {
          logError('upload fail:', err)
          onError?.(err)
          resolve({ ok: false })
        },
      })

      task.onProgressUpdate((res) => {
        progress.value = res.progress
        onProgress?.(res.progress)
      })
    })
  }

  /* ================== 主流程 ================== */

  const run = async () => {
    log('run start', { directFilePath })

    if (directFilePath) {
      setLoading(true)

      let finalPath = directFilePath
      if (shouldCompress()) {
        finalPath = await compressImage(directFilePath)
      }

      const r = await uploadSingle(finalPath)

      data.value = r.ok ? r.data : undefined

      onComplete?.({
        data: r.ok && r.data ? [r.data] : [],
        sizeLimitCount: 0,
        uploadFailCount: r.ok ? 0 : 1,
      })

      setLoading(false)
      return
    }

    uni.chooseMedia({
      count,
      mediaType: ['image'],
      sourceType,
      sizeType,
      success: async (res) => {
        log('chooseMedia success:', res)

        const valid: string[] = []
        let sizeLimitCount = 0

        res.tempFiles.forEach((f) => {
          if (checkFileSize(f.size))
            valid.push(f.tempFilePath)
          else sizeLimitCount++
        })

        log('valid files:', valid)

        if (!valid.length) {
          onComplete?.({ data: [], sizeLimitCount, uploadFailCount: 0 })
          return
        }

        setLoading(true)

        const results = await Promise.all(
          valid.map(async (filePath, index) => {
            const raw = res.tempFiles[index]
            let finalPath = filePath

            if (shouldCompress(raw)) {
              finalPath = await compressImage(filePath)
            }

            return uploadSingle(finalPath)
          }),
        )

        log('upload results:', results)

        const successList = results.filter(r => r.ok).map(r => r.data)
        const failCount = results.filter(r => !r.ok).length

        data.value = successList as any

        onComplete?.({
          data: successList,
          sizeLimitCount,
          uploadFailCount: failCount,
        })

        setLoading(false)
      },

      fail: (err) => {
        logError('chooseMedia fail:', err)
        error.value = true
        onError?.(err)
      },
    })
  }

  return { loading, error, data, progress, run }
}

/* ================== 选择文件 ================== */

export function useChooseFile(options: UploadOptions = {}) {
  const {
    count = 4,
    sizeType = ['original', 'compressed'],
    sourceType = ['album', 'camera'],
    maxSize = 10,
  } = options

  const files = ref<string[]>([])
  const rawFiles = ref<any[]>([])

  const run = () => {
    log('chooseFile start')
    return new Promise((resolve, reject) => {
      uni.chooseMedia({
        count,
        mediaType: ['image'],
        sizeType,
        sourceType,
        success: (res) => {
          log('chooseFile success:', res)
          const valid: string[] = []

          res.tempFiles.forEach((f) => {
            if (f.size / 1024 / 1024 <= maxSize)
              valid.push(f.tempFilePath)
          })

          files.value = valid
          rawFiles.value = res.tempFiles

          log('chooseFile valid:', valid)

          resolve({ files: valid, rawFiles: res.tempFiles })
        },

        fail: (err) => {
          logError('chooseFile fail:', err)
          reject(err)
        },
      })
    })
  }

  return { files, rawFiles, run }
}

/* ================== AI上传 ================== */

export function useUploadAIFile<T = any>(options?: {
  url?: string
  formData?: Record<string, any>
}) {
  const loading = ref(false)
  const data = ref<T | null>(null)
  const error = ref<any>(null)

  const finalUrl = options?.url ?? uploadFileUrl.AI_CERT
  const formData = options?.formData || {}

  const run = (filePath: string) => {
    log('AI upload start:', finalUrl, formData, filePath)

    loading.value = true

    return new Promise<{ ok: boolean, data?: T }>((resolve) => {
      const task = uni.uploadFile({
        url: finalUrl,
        filePath,
        name: 'file',
        formData,
        header: { 'Content-Type': 'multipart/form-data' },
        success: (res) => {
          try {
            const r = JSON.parse(res.data)
            log('AI upload 结果:', r)

            if (r?.result === '1') {
              data.value = r
              resolve({ ok: true, data: r.row })
            }
            else {
              error.value = r
              resolve({ ok: false })
            }
          }
          catch (e) {
            error.value = e
            resolve({ ok: false })
          }
        },

        fail: (err) => {
          error.value = err
          resolve({ ok: false })
        },
      })

      setTimeout(() => {
        if (loading.value) {
          task.abort()
          error.value = new Error('AI识别超时')
          resolve({ ok: false })
        }
      }, 30000)
    }).finally(() => {
      loading.value = false
    })
  }

  return { loading, data, error, run }
}

/* ================== 批量上传 ================== */

export async function uploadBatchFile<T = any>(
  url: string,
  filePaths: string[],
  formData: Record<string, any> = {},
) {
  log('batch upload start:', filePaths)

  const successList: T[] = []
  let failCount = 0

  for (const path of filePaths) {
    log('batch uploading:', path)
    const { data, run } = useUploadFile<T>(url, formData, {}, path)
    await run()

    if (data.value) {
      successList.push(data.value)
    }
    else {
      failCount++
      logError('batch fail:', path)
    }
  }

  log('batch result:', { successList, failCount })

  return { successList, failCount }
}

  

 


最后一句

小程序开发不是“能跑就行”,而是:

接口、文件、设备能力三者兼容的工程问题

把上传 + 预览这套打通,你的项目稳定性会提升一个量级。

posted @ 2026-05-06 15:34  极度恐慌_JG  阅读(106)  评论(0)    收藏  举报