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 onMounted 时 dict.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 对象,切换编辑目标只需要一次赋值
相关推荐
卸任5 小时前
Vibe coding工具使用心得
前端·ai编程
brien96 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance6 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M6 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写6 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李6 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易6 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
清晨Coding6 小时前
删了 Cookie 还能自动登录?Google系网站登录态自动恢复的原理分析
javascript
风骏时光牛马7 小时前
前沿视界
前端
kyriewen7 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员