☰
vxe-form-design自定义控件从注册到调试的完整指南
2026/10/9 4:03:32 网站建设 项目流程

做中后台项目的时候,可视化表单设计器基本是绕不开的话题。vxe-form-design 是 vxe 体系下的表单设计器,跟 vxe-table 配套,拖拽生成表单很快,但它真正拉开差距的地方在于自定义控件。我一开始也觉得内置控件够用,直到做了几个业务系统才发现,什么地区联动、评分、上传附件、部门选择,全是自定义场景。这篇文章就专门讲 vxe-form-design 里自定义控件的详细用法,从注册到属性面板到调试,把完整套路捋清楚。无论你是刚接触这个库,还是已经在项目中踩过几个坑,都能直接拿去参考。

1. 先搞清楚 vxe-form-design 的自定义控件机制

1.1 所有控件都逃不过三个核心字段

vxe-form-design 的控件本质上是配置驱动的。每个控件在画布里能显示、能配置、能生成表单,靠的是三样东西:type、widget、render。type是这个控件在项目里的唯一 ID,比如rating,后面所有注册、反序列化都靠它识别;widget是真正要渲染的那个组件,可能来自 vxe-table、Element Plus、Ant Design Vue,或者你自己写的 Vue 组件;render是设计态到组件之间的“翻译器”,它接收上下文,返回一个 VNode 或渲染描述。很多人自定义控件失败,就是只写了widget没写render,或者把render理解成表单里的模板,其实它更接近 Vue 的渲染函数。

这三个字段在 vxe-form-design 里大多数情况是配合出现的。type是入参,widget是底料,render是烹饪方式。比如你要把 Element Plus 的el-rate变成设计器里能拖拽的评分控件,就要给设计器一份这样的描述:我的名字叫rating,我背后的组件是el-rate,画布里怎么画我由render说了算。设计器本身并不关心你是不是第三方库的组件,只要这三件事说清楚,它就能把你整合进拖拽体系。

1.2 控件从拖拽到出值的完整生命周期

我实际用下来,一个自定义控件至少要跑四段生命周期。第一段是在左侧物料面板里,设计器通过图标和名称展示控件,供你拖拽;第二段是拖进画布之后,render函数开始工作,把控件画出来,此时它只是“设计态”,不跟真实表单数据绑定;第三段是右侧属性面板,你点中控件后,配置项会显示出来,你改的每一个属性都写回这份 widget 描述对象;第四段是导出或运行时,表单渲染器根据同一份 widget 描述重新渲染成真实表单项,并完成 v-model 双向绑定。理解这四个阶段,后面所有 API 都不会觉得奇怪。

有一点值得强调:设计态和运行态是两套渲染逻辑。设计态的画布可以只展示控件外观,不一定要有完整交互;运行态的渲染器则必须把字段值、事件、校验规则全都接起来。很多人自定义控件做到一半发现“画布里有,页面里没有”,多半是把这两套环境混为一谈了。所以后面我在讲注册的时候,会刻意区分设计器注册和渲染器注册。

2. 环境准备与控件注册流程

2.1 安装依赖和初始化项目

以一个 Vue 3 + Vite 的基础项目为例,设计器的安装很直接。执行:

npm install vxe-table vxe-form-design

然后在入口文件里引入并注册插件。注意 vxe-form-design 通常依赖 vxe-table 的样式和工具方法,两者一起引入比较稳。

import { createApp } from 'vue' import VxeUI from 'vxe-table' import 'vxe-table/lib/style.css' import VueVxeFormDesign from 'vxe-form-design' import 'vxe-form-design/lib/style.css' const app = createApp(App) app.use(VxeUI) app.use(VueVxeFormDesign) app.mount('#app')

这里有一个容易忽略的点:如果你项目里同时用了 Element Plus,建议把 Element Plus 的插件注册放在 VxeUI 之后,这样后面自定义控件里用第三方组件时,组件实例和全局配置都不会被覆盖。我遇到过几次样式冲突和组件找不到的情况,调整注册顺序后基本都能解决。

2.2 用 widgets.add 注册自定义控件

vxe-form-design 暴露了扩展点,一般是在入口文件或者一个独立的customWidgets.js模块里调用。以注册一个最简单的“纯文本”控件为例:

import VxeFormDesign from 'vxe-form-design' VxeFormDesign.widgets.add('plain-text', { name: '纯文本', icon: 'vxe-icon-text', category: '自定义', props: { content: '默认内容' }, setting: [ { label: '内容', prop: 'content', component: 'input' } ], render(h, { modelValue, widget }) { return h('div', widget.props.content || String(modelValue ?? '')) } })

这段代码里,name是左侧物料面板显示的名称,icon是物料面板里的图标,category用来分组,props是控件的默认配置,setting决定了右侧属性面板长什么样,render则是画布里怎么画它。这五个字段是我常用的最小集合。特别注意:props.conent这类用户配置必须放在props下面,很多自定义控件的属性在导出时丢失,就是因为把自定义字段挂在了顶层,设计器序列化时只认props区域。

2.3 设计器注册与渲染器注册要成对出现

很多场景下,你在后台管理项目里用vxe-form-design设计表单,在另一个 H5 或者用户端页面里用vxe-form-render渲染表单。设计器负责“编排”,渲染器负责“执行”。自定义控件如果只在设计器里注册,拖拽和配置都没问题,但用户端运行时会白屏;反之一样。

所以我的习惯是建一个customWidgets.js文件,把自定义控件的注册逻辑集中在一起,然后设计器入口和渲染器入口都引它。在这个文件里,我一般会放两个注册函数:

import VxeFormDesign from 'vxe-form-design' import VxeFormRender from 'vxe-form-render' export function registerCustomWidgets() { // 设计器注册 VxeFormDesign.widgets.add('plain-text', { name: '纯文本', icon: 'vxe-icon-text', category: '自定义', props: { content: '默认内容' }, setting: [ { label: '内容', prop: 'content', component: 'input' } ], render(h, { widget }) { return h('div', widget.props.content) } }) // 渲染器注册 VxeFormRender.widgets.set('plain-text', { component: 'div', props: ['content'] }) }

如果渲染器有完整的组件配置,我通常直接用一个 Vue 组件来承接。这样设计时能看到效果,运行时也能复用同一份逻辑,只是render的侧重点略有不同。

2.4 局部页面内注册的坑

如果你只在某个页面里临时用自定义控件,不要在这个页面的setup里反复调用VxeFormDesign.widgets.add。这个东西是全局注册的,一旦在页面里执行,其他页面也会生效;如果组件热更新或者页面重复挂载,还可能重复注册同名type,导致后一次覆盖前一次。最好的方式是把注册动作放进一个模块顶层执行,让它只跑一次。模块缓存能保证这一点。

我踩过的另一个坑是在注册后马上使用设计器,但设计器组件还没重新渲染物料面板。解决办法是在注册完成后再渲染vxe-form-design,或者用一个nextTick等物料面板刷新。不要硬编码等待时间,容易在慢设备上翻车。

3. 手写一个“评分选择”自定义控件

3.1 场景定义和控件结构

有一回我做一个讲师评估后台,需要在表单里放一个评分项。内置控件里没有星点评分,只有一个数值输入,体验很差。于是我把 Element Plus 的el-rate封装成自定义控件接进 vxe-form-design。当时的需求是:能设置字段名、标题、最大分值,还能控制是否允许半选。做成设计器控件后,运营人员可以在表单设计界面上直接拖一个进来,不用写代码。

控件结构我用一个对象来描述,type起名为rate-score。之所以不用太通用的rating,是怕跟团队其他扩展库里的控件 ID 撞车。设计器里type一旦冲突,后注册的会覆盖先注册的,而且不太好查。

3.2 控件描述对象完整实现

注册评分控件的完整实现大概是下面这样。我没有用 JSX,全部用h函数,这样在纯 JS 或者 TS 项目里都通用,不需要额外配置编译插件。

import { h } from 'vue' import { ElRate } from 'element-plus' import VxeFormDesign from 'vxe-form-design' VxeFormDesign.widgets.add('rate-score', { name: '评分', icon: 'vxe-icon-star', category: '自定义', props: { field: 'score', label: '综合评分', max: 5, allowHalf: false }, setting: [ { label: '字段名', prop: 'field', component: 'input' }, { label: '标题', prop: 'label', component: 'input' }, { label: '最大值', prop: 'max', component: 'number' }, { label: '允许半选', prop: 'allowHalf', component: 'switch' } ], render(h, { widget, modelValue }) { const { field, label, ...rest } = widget.props return h('div', { class: 'custom-rate-wrap' }, [ h('label', { class: 'custom-rate-label' }, label), h(ElRate, { modelValue: modelValue ?? 0, max: rest.max, allowHalf: rest.allowHalf, onChange: (val) => { // 在设计态把值写回 widget,方便预览和导出 if (widget.modelValueChanged) { widget.modelValueChanged(val) } } }) ]) } })

这里有几个细节值得展开讲。第一,render函数里拿到的modelValue是该控件当前的表单值,可能是undefined,所以渲染时需要兜底成0。第二,el-rate的文案和标签自己包一层div和label,设计器里看起来更完整。第三,onChange里调用widget.modelValueChanged是为了让设计器内部能感知到值的变化,从而在右侧属性面板或代码预览里同步。这个回调名字在不同版本里可能有差异,你可以打印一下widget对象,找到当前版本暴露的更新函数。

3.3 渲染器端如何回显和绑定数据

设计器里配置好的表单,最终会导出一份 JSON,里面有type: 'rate-score'、field: 'score'、props: { max: 5, allowHalf: false }。当另一端的vxe-form-render加载这份 JSON 时,会根据type找到渲染器端注册的那个组件,用它替换成真正的el-rate。

渲染器端注册同样要成对。我在渲染器端会写一个轻量封装:

import { h, defineComponent } from 'vue' import { ElRate } from 'element-plus' import VxeFormRender from 'vxe-form-render' const RateScoreRender = defineComponent({ name: 'RateScoreRender', props: { widget: Object, modelValue: [String, Number] }, emits: ['update:modelValue'], setup(props, { emit }) { return () => { const { max, allowHalf } = props.widget.props return h(ElRate, { modelValue: Number(props.modelValue ?? 0), max, allowHalf, onChange: (val) => { emit('update:modelValue', val) } }) } } }) VxeFormRender.widgets.set('rate-score', RateScoreRender)

这样做的好处是:设计器端负责给人看,渲染器端负责给表单用,两边共用props里的同一份配置。如果你不想分别写两个组件,也可以在设计器注册时把渲染组件也挂上,很多版本支持widget.component字段,我建议你看一眼当前版本的文档,优先用官方推荐的方式。

3.4 默认值和校验规则的接入

评分控件的默认值在配置里常见,比如默认给 5 分。我在props里增加一个defaultValue,注册时赋值为 0。渲染器端在拿不到外部绑定值时,直接用props.defaultValue:

const initialValue = props.widget.props.defaultValue ?? 0

校验规则也是类似的思路。设计器右侧面板一般有“必填”“校验”配置,但自定义控件未必能完全吃上内置校验。最简单的做法是在render阶段把required属性交给包一层表单校验逻辑,或者在渲染器端根据widget里的rules字段动态塞给外层组件。我习惯在setting里加一个required开关,导出 JSON 时自动带出,渲染器端再看这个开关决定是否显示红星和校验。

4. 给自定义控件配属性面板

4.1 属性面板是配置生成的

vxe-form-design 的属性面板不是每个控件单独写一套 Vue 组件,而是基于setting数组自动生成。这一点非常好用。你只需要声明“这个控件有哪些属性可改、每种属性用什么输入控件”,设计器右侧就会自动渲染出对应的表单。自定义控件要做的,就是把setting写得足够完整,把字段名、标题、默认值、选项、开关等配置全部暴露出来。

我见过一些开发者试图自己写属性面板,最后跟设计器的交互逻辑对不上,反而搞得很复杂。实际上你只需要在setting里堆配置项。比如要给评分控件加一个“是否显示标题”的开关,我加一行:

setting: [ { label: '是否显示标题', prop: 'showLabel', component: 'switch' } ]

设计器属性面板就会自动多一个开关控件,改完之后值会写入widget.props.showLabel。不需要额外注册组件,不需要改设计器源码。

4.2 常用 setting 配置项类型

我用得比较多的component类型有:input文本输入、number数字输入、switch开关、select下拉选择、radio单选。如果你的配置项之间还有联动,比如“最大值”改变了,“允许半选”才可配置,可以在配置项上加一个visible函数或字符串表达式。

举个实际的例子:

setup: [ { label: '最大值', prop: 'max', component: 'number' }, { label: '允许半选', prop: 'allowHalf', component: 'switch', visible: (widget) => widget.props.max > 1 } ]

这个visible函数会在属性面板渲染时被调用,参数是当前控件对象,返回true才显示对应配置。通过这个方式,我不用单独写任何面板组件,就能实现“属性跟着参数走”的效果。

4.3 属性联动:字段名变更时同步标题

有一次需求是:用户改了“字段名”,标题要自动带上字段名的前缀。这种联动不能只靠静态setting,需要在控件描述里挂一个事件监听。vxe-form-design 的控件在属性变更时会触发对应事件,你可以注册一个events或methods来完成联动。

我当时的实现思路是在控件描述里加一个onSettingChange回调,然后在回调里判断prop === 'field',再同步修改props.label:

onSettingChange({ prop, value, widget }) { if (prop === 'field') { widget.props.label = `${value} 综合评分` } }

这个回调不一定是你当前版本的 API,但我建议你在打印widget对象时重点找一下跟setting、change、update相关的方法。如果实在找不到统一入口,还可以在setting的某一项里加change配置,针对某项配置变化单独处理。属性联动这块没有太多银弹,别怕查文档和打断点,做一次就熟了。

4.4 扩展属性面板的自定义组件

当你需要特别复杂的属性编辑器,比如颜色选择器、图标选择器、自定义 JSON 编辑框,内置的component类型就不够用了。vxe-form-design 允许你注册自定义属性控件,原理跟注册表单控件类似,只不过它出现在属性面板区域。

我建议不要一开始就上这种方案。先看内置的input和select能不能用控制字段映射解决需求。实在不行了,再注册一个属性编辑器。属性编辑器的注册方式通常叫settings或panels,不同版本差异较大,而且是老手才会踩到的进阶点,这里先不展开。

5. 常见问题与排查技巧

5.1 控件拖进画布后不显示

这是自定义控件最常见的问题。我排查的顺序一般是这样的:

  • 第一步,看type是否在全局唯一。检查有没有覆盖同类型控件。
  • 第二步,看widget是不是真实组件。如果你写的widget对象不是组件,也没有注册成全局组件,设计器画不出来。
  • 第三步,看render返回的内容。render必须返回一个 VNode,如果返回undefined或者普通对象,画布就会空白。
  • 第四步,打开浏览器控制台,在render函数里console.log(widget),确认 props 有没有值,尤其是field、label这类字段是不是空的。

有一次我的控件怎么都不显示,最后发现是render里的h函数引错了来源。我用的是组件库内部的h,但应该用 Vue 导出的h。这种问题表面上看不出,控制台也很少报错,只能靠 console 定位。

5.2 设计器能显示,表单运行时空白

这种情况十有八九是渲染器端没有同步注册自定义控件。你在设计器里看得好好的,导出 JSON 后到渲染端一看,那个字段位空空如也,或者被渲染成一段报错信息。原因就是渲染器端不知道rate-score是什么。

排查方法很简单:切换到你项目里负责渲染的页面,看看有没有执行VxeFormRender.widgets.set('rate-score', ...)。如果没有,补齐即可。还有第二个可能性:你在渲染器端注册的组件接收的modelValue类型跟设计器导出值不一致。比如导出的字段值是字符串'5',el-rate期望 number,就会表现异常。我在渲染组件里做了一层Number()转换,这种坑就能避开。

5.3 与第三方 UI 库的样式冲突

自定义控件用了 Element Plus,设计器本身用的是 vxe-table 的样式体系,两者一起出现时偶尔会有全局样式互相干扰。最常见的表现是弹窗层级不对、按钮间距错乱、字体大小不一致。我的处理办法是:在引入设计器粮食的地方不要用全局样式重置,而是给设计器容器加一个独立 class,把样式作用域限定住。

另一个容易踩的坑是按需加载没处理好。Element Plus 的组件样式没有全量引入时,el-rate可能只有逻辑没有样式,看起来就是一组干巴巴的图标。最好在入口里引入element-plus/dist/index.css,或者在按需加载的配置里把用到的组件和样式都包含进去。这个坑在做自定义控件时特别常见,因为“逻辑没错,就是不好看”很容易被误判成 JS 问题。

5.4 自定义控件在复用/拷贝时属性丢失

设计器导出的 JSON 再导入回来,发现自定义控件的某些配置项变成默认值了。这种问题大多是属性没放在props下。设计器序列化时可能只识别props作为属性档,而你在setting里的prop如果指向了一个顶层字段,导来导去就丢了。

我的习惯是所有可配置数据全部挂在widget.props下,哪怕是临时用的备注字段也放进去。如果一定要在props外放数据,就检查设计器导出的 JSON 里有没有包含这个字段。没有就加映射,不要硬来。

5.5 自定义控件的数据校验失效

自定义控件想要纳入表单校验体系,必须在控件描述里把校验规则表达清楚。我通常在props下加一个required开关,并在设置面板里暴露出来。渲染器端读取这个开关,动态安装校验规则,并在validate阶段检查值是否为空。

如果你发现校验永远不生效,要么是渲染器端的校验规则没挂进去,要么是自定义控件没有正确写回value。评分控件要确保onChange时把数值 emit 出去,不是只改自己的内部 state。很多交互组件喜欢在内部缓存值,不往外抛,这时候要在外层包一层面板,显式用update:modelValue同步。

最后再说一点个人体会

自定义控件这个事,看着是技术问题,其实是设计问题。vxe-form-design 已经把拖拽、配置、导出这套底座搭好了,自定义控件要做的只是把业务组件“翻译”成它认识的描述对象。我最开始做的时候总想搞一套特别通用的“万能控件”,把各种配置抽象来抽象去,结果代码越来越难维护。后来发现,业务变化快,最靠谱的反而是把每个场景写成清楚、独立的控件描述,公共逻辑抽成工具函数,不要为了复合而复合。也建议你在项目里维护一份自定义控件清单,写上 type、用途、配置项、负责人,不然传统切换和同事维护时会很痛苦。按照这套思路去扩展,后面加控件基本就是复制、改配置、注册三步的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询