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 对象,切换编辑目标只需要一次赋值
相关推荐
mldong1 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排2 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60612 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角3 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!3 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰4 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi5 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen6 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马7 小时前
AI源码分析:拆解模型底层实现逻辑
前端
BillKu7 小时前
全局样式变量:CSS 自定义属性(--border-color)和SCSS 变量($border-color)的说明
javascript·css·scss