32 个 Vue 组件的设计取舍

32 个 Vue 组件的设计取舍

文章目录

  • [32 个 Vue 组件的设计取舍](#32 个 Vue 组件的设计取舍)
    • [决策一:不用 adapter 层](#决策一:不用 adapter 层)
    • [决策二:字典通过 inject 中心化](#决策二:字典通过 inject 中心化)
    • [决策三:provide/inject 做绑定,不用 v-model](#决策三:provide/inject 做绑定,不用 v-model)
    • [32 个组件的其他决策](#32 个组件的其他决策)
    • 一些经验
    • 总结

做 low-code 平台,难的不是写组件,是做选择。

browise-vue 有 32 个组件,分布在 7 个分类里:

复制代码
form/       18 个:TextBox、NumberBox、ComboBox、DateBox......
grid/       1 个:MetaGrid
tree/       2 个:MetaTree、ComboBoxTree
container/  4 个:FieldSet、FlexContainer、TabContainer、Panel
feedback/   4 个:Button、Dialog、MessageBox、ContextMenu
business/   3 个:UserSelect、DepartmentSelect、LinkTable
editor/     2 个:Editor、MarkdownEditor

组件本身不复杂------多数是 Element Plus 的一层薄封装。复杂的不是实现,是决定怎么封装

这篇文章聊三个关键的设计决策。

决策一:不用 adapter 层

做 low-code 组件库,常见的设计是写一层 adapter(也叫桥接):

ts 复制代码
interface IUiAdapter {
  renderInput(props): VNode
  renderSelect(props): VNode
  // ... 几十个方法
}

class ElementPlusAdapter implements IUiAdapter { /* ... */ }
class AntDesignAdapter implements IUiAdapter { /* ... */ }

当初 VedioCall 就用了这模式------BaseComp.java + 128 个 adapter 子类,每个 UI 库一套。

这次我决定不用 adapter,直接依赖 Element Plus

原因很简单:

  1. 没有"切换 UI 库"的需求------你不会今天用 Element Plus,明天换 Ant Design
  2. adapter 的抽象成本太高------每个 UI 库的 API 差异大到 adapter 要么是最小公分母,要么全是 if/else
  3. adapter 层拦截了 TypeScript 类型 ------你在 adapter 里写 any,组件层的类型推断就断了

对比一下两种写法:

有 adapter

ts 复制代码
adapter.renderSelect({
  value: row[field],
  options: dict[field],
  onChange: (v) => { row[field] = v }
})

无 adapter

vue 复制代码
<el-select :model-value="value" @update:model-value="onChange">
  <el-option v-for="item in options" ... />
</el-select>

后者类型推断直接来自 Element Plus 的官方定义,编辑器体验好得多。

这个决策的代价是:如果以后要切 UI 库,32 个组件全部要改。但我认为这个代价不会发生。

决策二:字典通过 inject 中心化

表单组件最麻烦的事情不是渲染,是数据来源

一个 ComboBox 的下拉选项哪里来?

方案 A:父组件传 props

vue 复制代码
<b-combo-box :options="genderOptions" />

看起来直接,但跨组件共享选项时(grid 也要显示中文、表单也要选),你得写两份:

ts 复制代码
const genderOptions = [...]
const gridFormatters = { gender: v => map.get(v) }

方案 B:组件自感知 FieldMeta

FieldMeta 里带 options:

ts 复制代码
fields: [
  { field: 'gender', options: [{ value: '1', label: '男' }] }
]

组件通过 inject 拿到 store,查 field 同名 metadata:

ts 复制代码
const meta = inject(store).meta.fields.find(f => f.field === props.field)
const options = meta?.options

但我最终选了方案 C:独立字典中心

ts 复制代码
const dict = useDictProvider()
dict.registerAll({
  GENDER: [{ value: '1', label: '男' }, { value: '2', label: '女' }]
})

// 后端对接时
dict.setLoader(async (codesName) => {
  return httpClient.request({ url: `/ea/codes/${codesName}` })
})

组件不需要知道 options 从哪里来,也不需要传 props:

vue 复制代码
<b-combo-box field="gender" />

它只做一件事:读 FieldMeta 的 codes: 'GENDER',然后从 DictCenter 里查。

好处:

  • Grid 和 ComboBox 共享字典,同一个 codes 名输出一致
  • 后端切换(本地注册 → 远程加载)不需要改任何组件
  • 和 Dojo 时代的 decoder="{store:'AAC004'}" 一个思路

决策三:provide/inject 做绑定,不用 v-model

表单组件最核心的交互:读数据、写数据、标记脏数据

最常规的做法是 v-model:

vue 复制代码
<b-text-box v-model="form.name" label="姓名" />

问题:v-model 需要父组件维护 form 对象,并且没有脏标记能力。

browise 采用的是 provide/inject 隐式绑定:

vue 复制代码
<!-- 不需要 v-model,不需要传 form -->
<b-text-box field="name" label="姓名" />
<b-combo-box field="gender" label="性别" />
<b-date-box field="hireDate" label="入职日期" />

底层实现:

ts 复制代码
// 父组件在 setup 阶段提供绑定上下文
const currentRow = provideBinding(store)

// 点击表格行切换编辑目标
currentRow.value = clickedRow

// 所有组件自动切换

每个组件内部通过 inject 拿到当前行,通过 computed 双向绑定:

ts 复制代码
const ctx = inject(BINDING_KEY)!
const value = computed({
  get: () => ctx.currentRow.value?.getItemValue(props.field) ?? '',
  set: (v) => ctx.currentRow.value?.setItemValue(props.field, v)
})

好处:

  • 不需要每一层传递 form 对象
  • 表格行切换时,所有表单组件自动更新
  • 脏标记自动追踪(setItemValue 内部处理了)

坏处:

  • 一个页面上只有一套绑定上下文(一个 form editor)
  • 需要 MultiBinding 模式来处理多个编辑区域

32 个组件的其他决策

组件 关键决策 原因
MetaGrid 显示用 toRaw(),事件用 $rowIndex 反查 Row vxe-table 不认嵌套对象
ComboBox onMounteddict.ensure(codes) 懒加载 减少首屏请求
Range 两个 field 字段(startField / endField) 日期范围和数字范围复用同一组件
Switch '1'/'0' 字符串 兼容数据库 CHAR(1) 类型
FileUpload 逗号分隔文件列表 单字段存多文件,不建关联表
Editor wangEditor 5 轻量级富文本,不需要 Quill/TinyMCE
Popup displayField 显示文本,field 存值 单选弹出选择器通用模式
ContextMenu Teleport 挂载到 body 避免 overflow: hidden 截断
FlexContainer CSS flexbox,不依赖任何 UI 库 布局组件应该最轻量
FieldSet 原生 HTML fieldset + legend 不需要 Element Plus 的组件包装

一些经验

Q:什么时候该封装一个组件?

我的标准:当业务逻辑和渲染逻辑不能干净分开时,封装。比如 ComboBox------"从字典取选项、存值到 Row、标记脏数据"是业务逻辑,"渲染输入框"是渲染逻辑。这两者一旦耦合在业务代码里,就会在每个页面里重复。

Q:什么时候不该封装?

按钮不需要封装------<el-button> 本身已经够用了。但我还是封装了,为了统一控制样式和权限。这是妥协。

Q:23 种字段类型够用吗?

够。从政务系统的实际经验看,Text、Number、Combo、Date 四类占了 80% 的字段。其余 19 种覆盖了剩下的需求。省市区级联没有加------它太依赖具体业务的数据结构。

总结

组件设计的三条原则:

  1. 不要为了"可能的未来"做抽象------adapter 层就是为永远不发生的 UI 切换付出的成本
  2. 数据来源应该是组件的隐式依赖而非显式参数------字典中心比 props 更适合跨组件共享数据
  3. 绑定协议比 v-model 更适合低代码场景------父组件不需要管理 form 对象,切换编辑目标只需要一次赋值
相关推荐
dfdvervdv3 小时前
Vue3 + Element Plus 表单校验踩坑:为什么我写的规则不生效?
前端
Rhi6373 小时前
第 5 篇:用React-Leaflet 做充电桩地图监控,实现实时状态
前端
洞窝技术3 小时前
低成本高可用:洞窝团队如何搭建 AI 协同开发环境
前端·ai编程
Asize3 小时前
JavaScript 对象通关指南:从字面量到原型链,一篇文章踩遍所有坑
前端·javascript
yingyima3 小时前
Docker 容器内定时任务秘诀全解
前端
moMo3 小时前
前后端模块化分离,web盒子布局思维
前端·后端
前端繁华如梦3 小时前
不写模型文件,用代码「捏」出 3D 世界:Vue3 + Three.js 程序化资产生成实战
前端·vue.js
灰子学技术3 小时前
Envoy OAuth2 过滤器功能实现分析
运维·服务器·前端·网络
LCG元3 小时前
MySQL慢查询分析与索引调优:从故障诊断到性能翻倍的进阶之路
android·前端·mysql