前言
产品经理拿着新需求走过来:"这个页面也要上传图片,复用之前那个组件就行。"
我说:"行,那个组件大家都在用。"
然后真的出事了。
同事小张接了个需求------给上传组件的拍照功能加个水印。他改了公共组件里的一行代码,自测通过,提测通过,上线。
上线后半小时,客服群炸了。
另一个页面的上传功能完全失效,用户点任何按钮都没反应。那个页面依赖同一个组件,但它的业务流程里,加水印的那个环节会触发一个隐藏的条件判断------小张不知道,测试不知道,代码里没有任何注释提醒。
回滚,排查,修Bug,重新发版。从炸锅到恢复,两个小时。
事故复盘会上,产品经理问:"一个上传功能,怎么改一行代码就能让整个主流程挂掉?"
会议室没人说话。
因为答案很简单:这个"公共组件"从一开始就不是组件,只是一段被十几个页面复制粘贴后"各自长歪"的代码,后来被人硬捏成了一个共享模块------表面统一,内部耦合得像一团乱麻。
改的人以为自己在改一个独立功能,实际上他在拆一颗不知道连着什么引线的炸弹。
会后我们决定,把这颗炸弹拆了。
下面是我们交出的答案------不追求表面复用,只追求改了不炸。分享我们团队在重构移动端文件上传组件时的一些思考,不谈太多高大上的概念,而是聊聊如何用适度的设计解决实际问题。
一、背景:为什么需要重构?
原来的实现存在这些问题:
- 逻辑散落 - 上传逻辑分散在各个业务页面,复制粘贴到处都是
- 扩展困难 - 新增一种上传方式(比如视频)需要改多个文件
- 维护困难 - 改一处逻辑要同时改多个页面
- 样式不统一 - 每个页面都有自己的上传按钮样式
于是我们决定抽取公共能力,但关键问题是:怎么抽?抽到什么程度?
二、设计目标
我们定了三个小目标:
- 统一入口 - 所有文件上传都走同一套组件
- 可配置 - 不同业务场景可以灵活定制
- 可扩展 - 新增能力时不破坏现有代码
三、整体架构
先上一张架构图:
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 已经够用
- 不用过度抽象,因为当前复杂度完全可控
好的架构,不是用最"高级"的技术,而是用最"合适"的方式解决问题。
我们见过太多"过度设计"的案例------引入一堆框架和模式,最后发现业务根本不需要。适度设计,是在复杂性和可维护性之间找到的那个平衡点。
如果你有更好的方案,欢迎交流。