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 对象,切换编辑目标只需要一次赋值
相关推荐
小流苏生18 分钟前
这是一篇关于忘记的文章
前端·程序员·ai编程
2601_9637713722 分钟前
Scaling Podcast RSS Feeds and Audio Streaming on WordPress Servers
前端·windows·php
名字还没想好☜3 小时前
Next.js App Router 数据获取:fetch 缓存、revalidate 与 dynamic 一次讲清
前端
成都渲染101云渲染66667 小时前
如何在3ds Max中实现更快、更高质量的渲染
前端·javascript·人工智能
闲猫9 小时前
Spring AI 对接Deepseek ChatModel 聊天对话
java·前端·spring
paopaokaka_luck10 小时前
英语单词学习系统的设计与实现(基于艾宾浩斯遗忘曲线的智能复习机制、语音朗读、多题型测试与错题自动收录、Echarts图形化分析)
vue.js·spring boot·后端·echarts
自信的未来11 小时前
JSON 工具|Web Worker 工程化打包 + 语法自动修复 + 多语言代码生成实战
java·前端·json
SRET11 小时前
Mineradio 沙盒隔离安装完全指南 | 一键安全运行 Electron 应用!!!
javascript·经验分享·安全·electron·aigc·音视频·ai编程
全栈前端老曹11 小时前
【MongoDB】安全与权限管理 —— 用户认证、角色权限、SSL 加密
前端·javascript·数据库·安全·mongodb·nosql·ssl
zhangxingchao12 小时前
AI大模型核心八:从 Agent Skill、长文档 RAG 到知识库更新与训练策略
前端·人工智能·后端