被产品经理逼疯后,我们重构了移动端上传组件——2026最新成果复盘

前言

产品经理拿着新需求走过来:"这个页面也要上传图片,复用之前那个组件就行。"
我说:"行,那个组件大家都在用。"
然后真的出事了。
同事小张接了个需求------给上传组件的拍照功能加个水印。他改了公共组件里的一行代码,自测通过,提测通过,上线。
上线后半小时,客服群炸了。
另一个页面的上传功能完全失效,用户点任何按钮都没反应。那个页面依赖同一个组件,但它的业务流程里,加水印的那个环节会触发一个隐藏的条件判断------小张不知道,测试不知道,代码里没有任何注释提醒。
回滚,排查,修Bug,重新发版。从炸锅到恢复,两个小时。
事故复盘会上,产品经理问:"一个上传功能,怎么改一行代码就能让整个主流程挂掉?"
会议室没人说话。
因为答案很简单:这个"公共组件"从一开始就不是组件,只是一段被十几个页面复制粘贴后"各自长歪"的代码,后来被人硬捏成了一个共享模块------表面统一,内部耦合得像一团乱麻。
改的人以为自己在改一个独立功能,实际上他在拆一颗不知道连着什么引线的炸弹。
会后我们决定,把这颗炸弹拆了。

下面是我们交出的答案------不追求表面复用,只追求改了不炸。分享我们团队在重构移动端文件上传组件时的一些思考,不谈太多高大上的概念,而是聊聊如何用适度的设计解决实际问题


一、背景:为什么需要重构?

原来的实现存在这些问题:

  1. 逻辑散落 - 上传逻辑分散在各个业务页面,复制粘贴到处都是
  2. 扩展困难 - 新增一种上传方式(比如视频)需要改多个文件
  3. 维护困难 - 改一处逻辑要同时改多个页面
  4. 样式不统一 - 每个页面都有自己的上传按钮样式

于是我们决定抽取公共能力,但关键问题是:怎么抽?抽到什么程度?


二、设计目标

我们定了三个小目标:

  1. 统一入口 - 所有文件上传都走同一套组件
  2. 可配置 - 不同业务场景可以灵活定制
  3. 可扩展 - 新增能力时不破坏现有代码

三、整体架构

先上一张架构图:

scss 复制代码
┌─────────────────────────────────────────────────────┐
│                   业务页面                          │
│  (表单页面、详情页面、数据填报页面...)              │
└─────────────────────┬───────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────┐
│              useFileUploadActions (Hook)           │
│         组合业务逻辑,返回 action 数组              │
└─────────────────────┬───────────────────────────────┘
                      │
          ┌───────────┼───────────┐
          ▼           ▼           ▼
   ┌──────────┐ ┌──────────┐ ┌──────────┐
   │ 拍照用例 │ │ 上传用例 │ │ 视频用例 │
   └────┬─────┘ └────┬─────┘ └────┬─────┘
        │            │            │
        ▼            ▼            ▼
   ┌─────────────────────────────────────┐
   │         Adapter 层 (适配器)         │
   │   适配底层 native API (相机/文件)   │
   └─────────────────────────────────────┘
                      │
                      ▼
   ┌─────────────────────────────────────┐
   │         原生能力 (Native API)       │
   └─────────────────────────────────────┘

┌─────────────────────────────────────────────────────┐
│              UI 组件层                              │
│  ┌──────────────┐  ┌──────────────┐                 │
│  │ 简单上传组件 │  │ 标签上传组件 │                 │
│  │ (Plain模式)  │  │ (Labeling模式)│                │
│  └──────────────┘  └──────────────┘                 │
│           │                │                        │
│           └────────┬───────┘                        │
│                    ▼                                 │
│  ┌─────────────────────────────────────┐            │
│  │   FileItemDisplay (动态组件分发)    │            │
│  └─────────────────────────────────────┘            │
│                    │                                 │
│           ┌────────┼────────┐                       │
│           ▼        ▼        ▼                       │
│     ┌────────┐ ┌────────┐ ┌────────┐                │
│     │ 图片   │ │ 视频   │ │ 文件   │                │
│     │ 预览   │ │ 预览   │ │ 预览   │                │
│     └────────┘ └────────┘ └────────┘                │
└─────────────────────────────────────────────────────┘

四、核心设计

1. 统一的行为抽象:Action 对象

我们把每种上传方式抽象为一个统一的 action 对象

javascript 复制代码
const takePhotoAction = {
  name: '拍照',
  handlerFunction: async () => {
    // 调用拍照用例...
    return imageUrl
  }
}

const albumAction = {
  name: '相册',
  handlerFunction: async () => {
    // 调用相册用例...
    return imageUrl
  }
}

const recordVideoAction = {
  name: '拍摄',
  handlerFunction: async () => {
    // 调用视频用例...
    return videoUrl
  }
}

这样有什么好处?

  • 组件只管调用,不管实现 - 上传组件不需要知道"拍照"和"拍视频"有什么区别
  • 可自由组合 - 页面想要哪些能力,就传哪些 action
  • 新增能力不破坏现有代码 - 新增一个 action 即可

2. 依赖注入:让组件更"懒"

上传组件接收 actions,而不是自己创建:

vue 复制代码
<template>
  <section class="flex gap-2 flex-wrap">
    <FileItemDisplayRefact v-for="(url, index) in list" :key="index" :url="url" />
    <FileItemButtonRefact 
      v-if="list.length < max" 
      :actions="actions" 
      @onSuccess="handleSuccess" 
    />
  </section>
</template>

<script setup>
const props = defineProps({
  actions: {
    type: Array,
    default: () => []
  },
  list: {
    type: Array,
    default: () => []
  },
  max: {
    type: Number,
    default: 10
  }
})
</script>

这就是最简单的依赖注入思想:我不制造依赖,我只是依赖的搬运工。

3. 双模式:简单场景 vs 复杂场景

我们发现实际业务中有两种典型场景:

模式 组件 数据结构 场景
Plain PlainFileUploadRefact url[] 只关心文件
Labeling LabelingFileUploadRefact {url, label, required}[] 需要标注说明
javascript 复制代码
// Plain 模式:纯 URL 数组
['https://xxx/1.jpg', 'https://xxx/2.jpg']

// Labeling 模式:带标签的对象数组
[
  { imgUrl: 'https://xxx/1.jpg', imgExplain: '哈基米', isRequired: true },
  { imgUrl: 'https://xxx/2.jpg', imgExplain: '南北绿豆', isRequired: false }
]

两种模式对应不同的数据结构,但对外接口保持一致。

4. 预览组件的动态分发:策略模式

根据文件类型动态渲染不同的预览组件:

javascript 复制代码
function getComponentForUrl(url) {
  // 优先使用自定义映射
  if (props.customComponentMap?.length > 0) {
    const matched = props.customComponentMap.find(item => item.match(url))
    if (matched) return matched.component
  }
  return FilePreviewComponent  // 默认
}

预览组件的配置是这样的:

javascript 复制代码
export const imagePreviewConfig = {
  match: (url) => isImage(url),
  component: ImagePreviewComponent
}

export const videoPreviewConfig = {
  match: (url) => isVideo(url),
  component: VideoPreviewComponent
}

这就是配置驱动的设计:新增一种文件类型,只需要添加一行配置。

5. 领域用例:业务流程的封装

以拍照为例,完整的业务流程是:

scss 复制代码
调用相机 → 拍照 → 添加水印 → 上传 OSS → (可选)下载到本地

我们把这个流程封装成一个 UseCase:

javascript 复制代码
export const createTakePhotoUseCase = () => {
  const cameraService = createCameraAdapter()  // 适配器
  
  const takePhotoRefact = async (params, options = {}) => {
    // 1. 拍摄照片
    const blob = await cameraService.getPicture(params)
    
    // 2. 添加水印(如需要)
    const watermarkedBlob = options.enableWatermark 
      ? await addWatermark(blob, options) 
      : blob
    
    // 3. 上传到服务器
    const url = await uploadToServer(watermarkedBlob)
    
    // 4. 如果需要,下载到本地
    if (options.isDownloadImg) {
      await downloadToLocal(url)
    }
    
    return url
  }
  
  return { takePhotoRefact }
}

这样,业务逻辑被收敛到一个地方,后续改逻辑(比如水印样式)只需要改这一处。

6. 适配器模式:隔离底层差异

底层调用的是原生能力,但各平台的 API 不一致怎么办?用适配器:

javascript 复制代码
export const createCameraAdapter = () => {
  return {
    getPicture: async (params) => {
      return await native.getPicture(params)
    },
    saveImage: async (url) => {
      return await native.saveImage(url)
    }
  }
}

如果将来换了一个原生框架(比如 uniapp),只需要改适配器,业务用例不用动。


五、使用方式

业务页面使用非常简单:

vue 复制代码
<script setup>
import { useFileUploadActions } from './hooks/useFileUploadActions.hook.js'
import { imagePreviewConfig } from './components'

// 1. 获取 action(业务逻辑)
const { takePhotoAction, albumAction } = useFileUploadActions()

// 2. 按需组合
const actions = computed(() => {
  return [takePhotoAction, albumAction]
})

// 3. 传入组件
</script>

<template>
  <!-- 简单模式 -->
  <PlainFileUploadRefact 
    v-model="fileList" 
    :actions="actions" 
    :max="10"
    :customComponentMap="[imagePreviewConfig]"
  />
</template>

一行 v-model,搞定上传。


六、效果

重构后:

  • 代码复用 - 所有页面上传逻辑统一
  • 扩展方便 - 新增上传方式只需添加 action
  • 维护简单 - 改逻辑只改一处
  • 体验一致 - 所有页面上传交互统一

七、总结:适度设计的力量

回顾整个设计,我们用到了:

  • 适配器模式 - 隔离原生 API
  • 策略模式 - 动态选择上传方式
  • 依赖注入 - 组件接收而非创建
  • 配置驱动 - 动态渲染预览组件

但我想特别强调一点:我们不是为了用模式而用模式。

所有的设计选择,都是为了解决实际问题:

  • 不用复杂的状态管理,因为场景足够简单
  • 不用 IOC 容器,因为 Hook + Props 已经够用
  • 不用过度抽象,因为当前复杂度完全可控

好的架构,不是用最"高级"的技术,而是用最"合适"的方式解决问题。

我们见过太多"过度设计"的案例------引入一堆框架和模式,最后发现业务根本不需要。适度设计,是在复杂性和可维护性之间找到的那个平衡点。


如果你有更好的方案,欢迎交流。

相关推荐
cylgdzz1112 小时前
DSP技术架构深度拆解
后端·架构
菜鸟小码2 小时前
Hive数据模型、架构、表类型与优化策略
hive·hadoop·架构
张忠琳3 小时前
【vllm】(五)vLLM v1 Attention — 模块超深度分析之五
ai·架构·vllm
我母鸡啊3 小时前
软考架构师故事系列-数据库系统
后端·架构
张忠琳3 小时前
【vllm】(五)vLLM v1 Attention — 模块超深度分析之二
人工智能·深度学习·ai·架构·vllm
Yunzenn4 小时前
# 零基础复现Claude Code(二):地基篇——让模型开口说话
人工智能·架构
heimeiyingwang4 小时前
【架构实战】容器安全最佳实践
安全·架构
ximu_polaris4 小时前
设计模式(c++)-结构型模式-装饰器模式
c++·设计模式·装饰器模式
两年半的个人练习生^_^4 小时前
每日一学:设计模式之适配器模式
java·设计模式·适配器模式