🚀UniApp + Vue3:我如何封装一个可复用的图片上传组件

47 阅读8分钟

UniApp + Vue3:实战封装一个通用图片上传模块(含业务页面完整示例)

技术栈:UniApp + Vue3 + Composition API
场景:微信小程序端「鉴酒订单」9 张图片上传 + 表单提交

最近在做一个鉴酒小程序项目,需要用户在下单前上传多张酒瓶图片(顶部、底部、防伪细节、正反面等),并配合表单一起提交。
这个模块一开始写得非常“糙”:每个页面都自己写 chooseImage + uploadFile,逻辑重复、难改难维护

后来干脆把上传逻辑彻底封装成一个 composable:

  • 页面只负责业务和 UI;
  • 上传、错误处理、接口调用全交给 composable 统一管理。

这篇文章就是对这个模块的一次完整复盘。


一、业务背景:为什么要抽离图片上传?

实际需求大致是这样:

  • 用户下单前必须上传 6 张必填图片 + 3 张选填图片
  • 图片上传完成后,需要拿到 OSS URL 再组装成后端需要的 cdImgs 数组;
  • 同时还要填写获取渠道、鉴定师、鉴定模式、生产日期等字段;
  • 最终统一调用「新增订单」接口。

如果不封装,页面里会充斥着大量这样的代码:

uni.chooseImage({
  count: 1,
  success(res) {
    const filePath = res.tempFilePaths[0]
    uni.uploadFile({
      url: 'xxx',
      filePath,
      name: 'file',
      header: { 'X-Token': token },
      success(uploadRes) { ... },
      fail(err) { ... }
    })
  }
})

这样的代码在多个页面反复出现,有几个明显问题:

  1. 逻辑重复,改一处要到处找;
  2. 错误处理风格不统一
  3. 容易出现某个页面漏传 token、漏处理 code 的情况;
  4. 页面代码看起来非常“厚重”,不利于阅读和维护。

所以我决定:把上传逻辑整体收走,做成一个通用图片上传模块。


二、统一上传接口:uploadFile API 封装

先在 api/user.js 中封装一个统一的上传接口。
因为是 UniApp 项目,小程序端必须使用 uni.uploadFile,不能用 axios:

export const uploadFile = (filePath) => {
  return new Promise((resolve, reject) => {
    uni.uploadFile({
      url: '这里填自己的请求路径',
      filePath,
      name: 'file',
      header: {
        "X-Token": uni.getStorageSync('token') || '',
      },
      success: (res) => {
        const data = JSON.parse(res.data)
        resolve(data)
      },
      fail: (err) => reject(err)
    })
  })
}

这里做了几件事:

  • 统一上传地址:以后如果改成别的 OSS,只需要改这一处;
  • 自动携带 token:从本地 uni.getStorageSync('token') 取;
  • 统一解析 JSONres.data 是字符串,统一 JSON.parse 一次;
  • 返回一个 Promise,方便在 async/await 里使用。

三、抽离核心上传逻辑:useImageUpload composable

然后在 composables/useImageUpload.js 中写一个通用的上传逻辑:

// -------------------------------------------
// 图片上传封装(通用版)
// -------------------------------------------

import { ref } from 'vue'
import { uploadFile } from '@/api/user.js'

export function useImageUpload() {

  // 后端返回的图片 URL
  const tupianUrl = ref('')

  /**
   * 上传单张图片文件,返回图片 URL
   * @param {string} wenjianLujing - 本地临时路径(chooseImage 返回)
   * @returns {string|null}
   */
  const shangchuanTupian = async (wenjianLujing) => {
    try {
      const res = await uploadFile(wenjianLujing)

      if (res.code === 200) {
        tupianUrl.value = res.data    // 保存到响应式变量
        return res.data               // 同时 return 给调用方使用
      } else {
        console.error('后端返回错误:', res)
        uni.showToast({ title: '图片上传失败', icon: 'none' })
        return null
      }

    } catch (err) {
      console.error('上传时发生异常:', err)
      uni.showToast({ title: '上传异常,请重试', icon: 'none' })
      return null
    }
  }

  return {
    tupianUrl,
    shangchuanTupian
  }
}

这里的设计思路

  • 返回 tupianUrl:可以在某些场景下做双向绑定展示;

  • 返回 shangchuanTupian 函数:页面只关心“给你一个临时路径,换回一个 URL”;

  • composable 内部统一处理:

    • 调用上传 API;
    • 处理 code === 200 的逻辑;
    • 错误弹窗提示。

四、业务页面:cateDetail.vue 中使用 composable

接下来就是最关键的一步:在实际业务页面中使用这个上传模块。

场景是「新增鉴酒订单」,需要上传 9 张图片,用 3×3 网格展示。

1. 定义图片位置信息配置

// 必填 + 选填图片位置信息
const imgTypes = [
  // 必填
  { key: 'top',          label: '顶部',         urlName: '顶部',         required: true },
  { key: 'bottom',       label: '底部',         urlName: '底部',         required: true },
  { key: 'head_teeth',   label: '头部齿',       urlName: '头部齿',       required: true },
  { key: 'tooth',        label: '头齿',         urlName: '头齿',         required: true },
  { key: 'detail_code1', label: '细节喷码(一)', urlName: '细节喷码',     required: true },
  { key: 'detail_code2', label: '细节喷码(二)', urlName: '细节喷码',     required: true },
  // 选填
  { key: 'anti_fake',    label: '防伪标签细节', urlName: '防伪标签细节', required: false },
  { key: 'front',        label: '正面',         urlName: '正面',         required: false },
  { key: 'back',         label: '背面',         urlName: '背面',         required: false }
]

每一项都包含三类信息:

  • key:在 form.cdImgs 中使用的字段
  • label:UI 上显示的文案
  • urlName:传给后端的固定描述
  • required:是否必填

这让后面的校验和组装参数非常简单。

2. 表单数据结构

const form = ref({
  cdImgs: {
    top: '',
    bottom: '',
    head_teeth: '',
    tooth: '',
    detail_code1: '',
    detail_code2: '',
    anti_fake: '',
    front: '',
    back: ''
  },
  dataDic: '',          // 获取渠道
  remark: '',           // 购买渠道 + dataDic
  windTime: '',         // YYYY-MM-DD
  expertId: '',         // 鉴定师 id
  expertName: '',       // 显示用
  expertMode: '',       // 1 单人,0 双人
  expertModeLabel: ''   // 显示用
})

表单里的 cdImgsimgTypes 一一对应。

3. 使用 composable 上传图片

<script setup> 里引入:

import { useImageUpload } from '@/composables/useImgeUpload.js'

const { shangchuanTupian } = useImageUpload()

然后封装选择 + 上传逻辑:

const chooseImage = (key) => {
  uni.chooseImage({
    count: 1,
    success: async (res) => {
      const filePath = res.tempFilePaths[0]

      uni.showLoading({ title: '上传中...' })

      const url = await shangchuanTupian(filePath)

      uni.hideLoading()

      if (url) {
        form.value.cdImgs[key] = url
      } else {
        uni.showToast({ title: '上传失败', icon: 'none' })
      }
    },
    fail: () => {
      uni.showToast({ title: '选择图片失败', icon: 'none' })
    }
  })
}

这个函数非常干净:

  1. 让用户选一张图片;
  2. 把临时路径丢给 composable 处理;
  3. 如果成功,填入 form.cdImgs[key]
  4. 如果失败,统一 toast 提示。

4. 模板中的 3×3 图片网格

<view class="img-group">
  <view class="img-item" v-for="item in imgTypes" :key="item.key">
    <view class="upload-btn" @click="chooseImage(item.key)">
      <text v-if="!form.cdImgs[item.key]">点击上传</text>
      <image
        v-else
        :src="form.cdImgs[item.key]"
        class="preview"
      ></image>
    </view>

    <view class="img-label">
      <text>{{ item.label }}</text>
      <text v-if="item.required" class="required">*</text>
    </view>
  </view>
</view>

配合简单的样式,就能实现一个规整的图片网格上传区:

.img-group {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 20rpx;
  margin-right: 15rpx;
}

.img-item {
  background: #fff;
  padding: 20rpx;
  border-radius: 12rpx;
  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
  display: flex;
  flex-direction: column;
  align-items: center;
}

.upload-btn {
  width: 200rpx;
  height: 200rpx;
  background: #f5f5f5;
  border-radius: 12rpx;
  display: flex;
  justify-content: center;
  align-items: center;
}

.preview {
  width: 200rpx;
  height: 200rpx;
  border-radius: 12rpx;
}

五、生成提交参数:把 9 张图片转换成后端需要的结构

后端要求的 cdImgs 字段格式:

[
  {
    "required": true,
    "url": "https://xxx.jpg",
    "urlName": "顶部"
  },
  ...
]

可以直接通过 imgTypes + form.cdImgs 合成:

const imgs = form.value.cdImgs

const cdImgsArr = imgTypes
  .filter(item => imgs[item.key]) // 只保留用户实际上传的项
  .map(item => ({
    required: item.required,
    url: imgs[item.key],
    urlName: item.urlName
  }))

这一块用配置驱动,避免写一堆 if/else。


六、提交订单:完整流程串起来

import { insertOrder, GetqueryrankingDeta } from '@/api/home.js'

const submitOrder = async () => {
  const imgs = form.value.cdImgs

  // 1. 校验必填图片
  for (let item of imgTypes) {
    if (item.required && !imgs[item.key]) {
      return uni.showToast({ title: `请上传:${item.label}`, icon: 'none' })
    }
  }

  // 2. 校验日期格式:YYYY-MM-DD
  const dateReg = /^\d{4}-\d{2}-\d{2}$/
  if (!dateReg.test(form.value.windTime)) {
    return uni.showToast({ title: '生产日期格式不正确:YYYY-MM-DD', icon: 'none' })
  }

  // 3. 渠道校验
  if (!channels.includes(form.value.dataDic)) {
    return uni.showToast({ title: '请选择正确的获取渠道', icon: 'none' })
  }

  // 4. 鉴定师 / 模式校验
  if (!form.value.expertId) {
    return uni.showToast({ title: '请选择鉴定师', icon: 'none' })
  }
  if (form.value.expertMode === '') {
    return uni.showToast({ title: '请选择鉴定模式', icon: 'none' })
  }

  // 5. 组装 cdImgs 数组
  const cdImgsArr = imgTypes
    .filter(item => imgs[item.key])
    .map(item => ({
      required: item.required,
      url: imgs[item.key],
      urlName: item.urlName
    }))

  // 从本地取“鉴定者 id”
  const identifyIdFromStorage = uni.getStorageSync('id') || '8297'

  // 6. 组装最终参数
  const params = {
    cId: form.value.cId,           // 分类 id(路由传入)
    cdImgs: cdImgsArr,
    cdPrice: '10',                 // 固定价格
    cdVideos: [],                  // 视频先传空
    dataDic: form.value.dataDic,
    expertId: form.value.expertId,
    expertMode: form.value.expertMode,   // 1 单人,0 双人
    identifyId: identifyIdFromStorage,
    remark: form.value.remark,
    windTime: form.value.windTime
  }

  uni.showLoading({ title: '提交中...' })

  try {
    const res = await insertOrder(params)
    uni.hideLoading()

    if (res.code === 200) {
      uni.showToast({ title: '提交成功', icon: 'success' })

      // 订单页是 tabBar 页面,需要 switchTab 跳转
      setTimeout(() => {
        uni.switchTab({
          url: '/pages/dingdan/dingdan'
        })
      }, 500)
    } else {
      uni.showToast({ title: `提交失败:${res.message || ''}`, icon: 'none' })
    }
  } catch (e) {
    uni.hideLoading()
    console.log(e)
    uni.showToast({ title: '请求异常', icon: 'none' })
  }
}

七、整体结构回顾

整个图片上传 + 下单页面,可以分成三层:

  1. API 层:uploadFileinsertOrder

    • 负责和后端交互,统一 URL / header / code 判断;
  2. 通用能力层:useImageUpload

    • 负责整个上传流程;
    • 页面只调用一个函数 shangchuanTupian(filePath)
  3. 业务页面层:cateDetail.vue

    • 负责表单字段、UI 布局;
    • 使用配置驱动 9 张图片与字段映射;
    • 处理各种业务校验与最终提交。

这种结构的好处是:各司其职,上传逻辑与业务逻辑完全解耦,后面如果项目新增别的需要上传图片的页面,复用的成本非常低。


八、项目复盘:踩过的坑与收获

做完这个模块之后,有几个印象比较深的点:

  1. 小程序端不能直接用 axios 上传文件

    • 微信小程序 环境不支持 axios 的 fetch/adapter,上传文件必须用 uni.uploadFile
    • 所以统一在 API 层封装 uploadFile,不要把小程序特性散落在业务页面。
  2. composable 非常适合做“跨页面通用能力”

    • 像图片上传、节流防抖、权限校验等,都可以往这方向抽一抽;
    • 页面代码会变得更清爽,阅读时只需要关注业务主线。
  3. 配置化可以减少大量重复代码

    • 9 张图片如果用 9 套模板 + 9 个字段手写,维护起来很痛苦;
    • imgTypes 做配置,一处改名、增减图片位置非常方便。
  4. 错误处理一定要统一

    • 上传失败 / 请求异常 / 校验不通过都要有明确的 toast 提示;
    • 这样用户体验会好很多,自己调试也更轻松。

如果你也在用 UniApp + Vue3 做小程序项目,可以按这个思路,把所有零散的上传逻辑逐步收拢到 composable 和 API 层里,让页面只负责“业务编排”和“交互呈现”。

整个过程改完之后,项目的可维护性和心情都会好很多。 如果我的文章帮助到了你,可以给我点一个小小的赞,不胜感激