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 对象,切换编辑目标只需要一次赋值
相关推荐
东方小月8 小时前
从零开发一个 Coding Agent(四):使用状态机校验大模型事件流
前端·人工智能·后端
想做后端的前端9 小时前
WebGL实现FPS游戏
vue.js·游戏·webgl
Csvn9 小时前
🧩 ESM vs CJS 混用的 7 个「天坑」——从 TypeScript 编译到 Node 与浏览器
前端
Csvn9 小时前
🎯 Web 性能 API 集合:Performance Observer 的 5 个冷门妙用
前端
Csvn9 小时前
深入 React 闭包陷阱:从根源上理解并根治 stale closure
前端
whyfail9 小时前
前端学 Spring Boot(8):接口为什么越用越慢?
前端·spring boot·后端
用户0595401744610 小时前
LangChain 记忆测试踩坑实录:这两个坑让我排查了 4 小时
前端·css
程序员黑豆10 小时前
鸿蒙应用开发:@Monitor 装饰器使用教程
前端·harmonyos
用户9385156350710 小时前
从零构建《天龙八部》知识库:EPUB 加载→文本分割→向量嵌入→Milvus 存储→RAG 问答,一条链路打通
javascript·人工智能·全栈
SamChan9011 小时前
在Web应用中集成PDF多语言翻译功能:PDFTranslator API实战指南
前端·python·ai·pdf·yapi·机器翻译