32 个 Vue 组件的设计取舍
文章目录
- [32 个 Vue 组件的设计取舍](#32 个 Vue 组件的设计取舍)
做 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。
原因很简单:
- 没有"切换 UI 库"的需求------你不会今天用 Element Plus,明天换 Ant Design
- adapter 的抽象成本太高------每个 UI 库的 API 差异大到 adapter 要么是最小公分母,要么全是 if/else
- 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 种覆盖了剩下的需求。省市区级联没有加------它太依赖具体业务的数据结构。
总结
组件设计的三条原则:
- 不要为了"可能的未来"做抽象------adapter 层就是为永远不发生的 UI 切换付出的成本
- 数据来源应该是组件的隐式依赖而非显式参数------字典中心比 props 更适合跨组件共享数据
- 绑定协议比 v-model 更适合低代码场景------父组件不需要管理 form 对象,切换编辑目标只需要一次赋值