大家好,我是全栈小卷。AI 时代下的前端开发,表单乃至页面的构建将基于 AI 生成的json schema来完成,实现了对业务人员友好的无代码表单/页面构建。当然这里离不开底层动态表单渲染引擎的支撑,市面上比较有代表性的基于 Vue3的表单引擎,这里我们将以 FormCreate 作为对比,与咱们自研中的 JuanForm 来实现一次联动配置方面的 PK。一起来看看实现相同的表单联动需求,这两款表单引擎的配置能力,来瞧一瞧究竟谁更对齐 vue 的原生机制,也让用户在 vue 技术体系的低代码表单引擎选型时有更合适的选择。
示例 pk
下面小卷给大家分别介绍下针对各种表单内联动需求,这两款表单引擎如何来配置实现。依赖引入与项目整合咱们略过,直接看页面配置以及渲染出来的 Element Plus 的 UI 的页面效果吧。
字段显隐联动
单开关控制单字段
需求:通过一个开关来隐藏一个字段,且数据模型对隐藏的字段也要做相应的清理。
要实现的效果

实现效果上两个引擎都能做到,但机制是否干净还要具体看代码配置,先看咱的 JuanForm:
html
<script setup lang="ts">
import JuanForm from '@/form/JuanForm.vue'
import type { FormSchema } from '@/form/types.ts'
const schema: FormSchema = {
children: [
{
items: [
{
prop: 'hideName',
label: '隐藏姓名',
type: 'Switch',
col: 8,
defaultValue: false,
},
{
prop: 'name',
label: '姓名',
type: 'Input',
col: 8,
props: { placeholder: '请输入姓名' },
hidden: (scope) => scope.hideName === true,
},
],
},
],
}
</script>
<template>
<JuanForm :schema="schema" />
</template>
咱们的配置很简单易懂,children 代表这一层有几行,items 代表这一行有几列,也就是说,咱们的配置,在数据链路上对齐了层的作用域概念,在视图上对齐了非固定总列数的栅格系统,这里主要看 hidden 配置:需要对哪个字段控制隐藏逻辑,就直接在目标字段上设置一个箭头函数,基于作用域中的源头控制的字段值判断即可。
再来看 FormCreate,它的字段隐藏支持两种配置方式,我们逐一来看,首先是 control配置方式:
html
<script setup lang="ts">
import { computed, ref } from 'vue'
const fcOption = { submitBtn: false, resetBtn: false }
const fcData = ref({ hideName: false, name: '' })
const json = computed(() => JSON.stringify(fcData.value, null, 2))
const fcRule = [
{
type: 'switch',
field: 'hideName',
title: '隐藏姓名',
value: false,
col: { span: 12 },
control: [
{
handle(val: boolean) {
return val !== true
},
method: 'if',
rule: ['name'],
},
],
},
{
type: 'input',
field: 'name',
title: '姓名',
col: { span: 12 },
props: { placeholder: '请输入姓名' },
},
]
</script>
<template>
<form-create v-model="fcData" :rule="fcRule" :option="fcOption" />
<pre class="form-json">{{ json }}</pre>
</template>
这里主要看 fcRule 配置,它对应了要渲染的规则定义,所以首先从定义的组件选项上明显看出来我们更干净,因为我们的目标是页面级渲染引擎,而 FormCreate 的产品定义局限在表单级,因此对应的表单数据模型它必须提供,且按钮的渲染它都是内置的,也需要都配置定义,而我们对按钮、弹框、数据表格等等都将支持页面级的配置。
这里的配置 control 代表用源字段控制目标字段,而 hidden 方式更像是 IoC 控制反转、依赖倒置的理念,源头控制如果源头只有一个字段还说的过去,但对多个源字段要一起作用的话(后续会有配置示例)就显得配置复杂,可读性更差更难维护了。对于控件的隐藏,不同于普通的 html 元素,一般都是 v-if 的控制,而非样式级别的隐藏,这里通过 method 来配置 if,也显得不那么干净,如果用户配置为 display 则,对控件来说意义不大。
当前的配置存在的问题:当视图隐藏了字段,数据模型中并没有把隐藏的字段对应剔除,如果要在当前 control 方式的配置中解决这个问题,需要做如下调整:
ts
// 全局配置中增加忽略隐藏字段设置
const fcOption = { ..., ignoreHiddenFields: true }
...
// 整个规则配置对象要用 ref 包裹为响应式
const fcRule = ref(...)
显然 ref() 这种对整个配置表单都应用响应式,随着表单配置数据量的增加,对监听性能的影响是越发显著的,FormCreate 如果要支持数据量更大的复杂联动响应的动态表单,那么 ref() 这种配置绝对是性能上的硬伤!
除了 control 外,FormCreate 也支持 hidden 的计算属性配置:
ts
const fcOption = { ..., ignoreHiddenFields: true }
...
const fcRule = ref([
{
type: 'switch',
field: 'hideName',
title: '隐藏姓名',
value: false,
col: { span: 12 },
},
{
type: 'input',
field: 'name',
title: '姓名',
col: { span: 12 },
props: { placeholder: '请输入姓名' },
computed: {
hidden: (form: { hideName: boolean }) => form.hideName === true,
},
},
])
这种方式和咱们 JuanForm 一样都是面向目标字段的 IoC 配置方式,但它的硬伤还是 ref(),只要使用了 computed 计算属性配置方式,要让它生效,则整个配置规则数组必须包一层响应式,尽管这对小表单性能影响不大。但在渲染链路机制干净程度上,无疑咱们 JuanForm 非响应式的 schema 配置方式是完胜的。
单开关控制多字段
先看 CreateForm 的支持,对于这种情况,如果是 control 方式,则在 rule 数组中维护下;如果是 hidden 计算属性配置,则直接在目标字段上加。那很显然,control 方式看似调整最简单,只是刚好适应这里单字段控制多字段,对其他情况语义上更拧;hidden方式,对多个目标字段应用一样的隐藏逻辑则叠了多个一样的计算属性配置。
再回到 vue 模板配置的源头来想一想,这其实就是在不改变模板元素结构的情况下,对多个相同条件的元素外面套一个 <template> 元素,通过 v-if 就统一实现了一处显隐控制。注意这里外层包的 <template> 元素,而不是 <div>元素,因为后者直接改变了布局结构,这不是我们的初衷。
回到咱们的 JuanForm,这里我们针对这个场景提供了这样的配置形式,本质上原生对齐了 vue 模板的 <template> 标签特性:
ts
const schema: FormSchema = {
children: [
{
items: [
{
prop: 'showDetail',
label: '显示详情',
type: 'Switch',
col: 4,
defaultValue: true,
},
{
type: 'Html',
el: 'template',
// el: 'div',
// col: 'auto',
if: (scope) => scope.showDetail === true,
children: [
{
items: [
{
prop: 'phone',
label: '手机',
type: 'Input',
col: 6,
props: { placeholder: '请输入手机', class: 'min-md' },
},
{
prop: 'email',
label: '邮箱',
type: 'Input',
col: 6,
props: { placeholder: '请输入邮箱', class: 'min-md' },
},
],
},
],
},
],
},
],
}
注意,这里虽然在 schema 配置结构上进行了分层,实际在渲染时 template 元素这一层中的 children 元素会按照条件渲染到 showDetail 字段同一层,实际的渲染结构如下:

如果把 template 元素换成 div,那么则手机和邮箱字段在 html 结构中会位于一个单独的容器布局中:

因此,通过对比配置实现,咱们 JuanForm 在 vue 原生机制上再一次胜出。
双开关显隐
需求:两个开关控制一个字段的显隐,且同步到数据模型
要实现的效果

先看咱们 JuanForm 的实现,直接对要隐藏的目标字段应用 hidden 配置,关联多个作用域变量的判断:
ts
const schema: FormSchema = {
children: [
{
items: [
{
prop: 'showA',
label: '条件甲',
type: 'Switch',
col: 8,
defaultValue: true,
},
{
prop: 'showB',
label: '条件乙',
type: 'Switch',
col: 8,
defaultValue: true,
},
{
prop: 'name',
label: '姓名',
type: 'Input',
col: 8,
props: { placeholder: '请输入姓名' },
hidden: (scope) => scope.showA !== true || scope.showB !== true,
},
],
},
],
}
对这种场景,FormCreate 也同样可以在目标 姓名 字段上用 hidden 计算属性来同时监听多个字段的变化来执行隐藏逻辑:
ts
computed: {
hidden: (form: { showA: boolean; showB: boolean }) =>
form.showA !== true || form.showB !== true,
},
但必须要注意,这里的 hidden 计算属性要能工作,必须对整个规则配置应用 ref() 包裹。
除了 hidden 计算属性之外,多字段一起联动一个字段的显隐,也可以用 control 对一个源字段上结合 link 配置再去追加另一个源字段的监听,确保 handle 能触发多次,这种配置形式略拧,没有在目标字段上配置 hidden 那么干净。
ts
const fcRule = [
{
type: 'switch',
field: 'showA',
title: '条件甲',
value: true,
col: { span: 12 },
link: ['showB'],
control: [
{
handle(val: boolean, api: { getValue: (field: string) => unknown }) {
return val === true && api.getValue('showB') === true
},
method: 'if',
rule: ['name'],
},
],
},
{
type: 'switch',
field: 'showB',
title: '条件乙',
value: true,
col: { span: 12 },
},
{
type: 'input',
field: 'name',
title: '姓名',
col: { span: 12 },
props: { placeholder: '请输入姓名' },
},
]
表达式求和
需求:两个输入框,接收类型为 number,输入后会自动求和显示到 结果输入框 ,同时 结果输入框 也可以自己输入。
要实现的效果

这种涉及到某个字段的值可自行编辑,且可以基于其他字段值进行计算赋值的场景,我们 JuanForm 提供了 expression 配置:
ts
const schema: FormSchema = {
children: [
{
cols: 24,
items: [
{
prop: 'a',
label: '加数甲',
type: 'Input',
col: 8,
defaultValue: 1,
props: { type: 'number', placeholder: '请输入' },
},
{
prop: 'b',
label: '加数乙',
type: 'Input',
col: 8,
defaultValue: 2,
props: { type: 'number', placeholder: '请输入' },
},
{
prop: 'total',
label: '合计',
type: 'Input',
col: 8,
props: { type: 'number', placeholder: '甲加乙' },
expression: (scope) => Number(scope.a || 0) + Number(scope.b || 0),
},
],
},
],
}
依赖的字段发生变化,就会自动触发计算,把表达式的结果求出来赋值给目标字段,同时目标字段本身也支持自行输入。
同样的,FormCreate 引擎也提供了字段级对应的更新 value 的计算属性,用法如下:
ts
const fcRule = ref([
{
type: 'input',
field: 'a',
title: '加数甲',
value: 1,
col: { span: 8 },
props: { type: 'number', placeholder: '请输入' },
},
{
type: 'input',
field: 'b',
title: '加数乙',
value: 2,
col: { span: 8 },
props: { type: 'number', placeholder: '请输入' },
},
{
type: 'input',
field: 'total',
title: '合计',
col: { span: 8 },
props: { type: 'number', placeholder: '甲加乙' },
computed: {
value: (form: { a: number | string; b: number | string }) =>
Number(form.a || 0) + Number(form.b || 0),
},
},
])
不谈内部实现机制是否干净,至少需求是满足了,但这里不得不提得是:value 计算属性要生效,也必须对整个 rule 数组配置外面用 ref() 套一层,让整体变为响应式的。
跨层写入
需求:这里包含嵌套的模型,要在外层字段上监听,把结果同步到内层模型的字段上。
要实现的效果

针对这种需求,我们从两个配置方向来分别实现:源字段监听、目标字段更新。
源字段监听
咱们的 JuanForm 引擎提供了强大的 dependsOn 依赖监听配置能力,可灵活应对大多数定制化的联动需求。监听可以做到层级别、字段级别以及后续涉及到的列表级别等等,这里我们只要在根这一层上配置即可:
ts
const schema: FormSchema = {
dependsOn: {
inject: ['node'],
handle: (scope, node: NodeApi) => {
const deep = node.at('crossOuter.crossInner.crossLeaf') as FieldNode
deep.value = `来自源头:${scope.crossSource ?? ''}`
},
},
children: [
{
items: [
{
prop: 'crossSource',
label: '跨层源头',
type: 'Input',
col: 8,
props: { placeholder: '改这里,看深层节点' },
defaultValue: '验证',
},
{
prop: 'crossOuter',
label: '最后外层',
type: 'Group',
children: [
{
items: [
{
prop: 'crossInner',
label: '嵌套内层',
type: 'Group',
children: [
{
items: [
{
prop: 'crossLeaf',
label: '深层节点',
type: 'Input',
col: 'auto',
props: { placeholder: '由源头写入' },
defaultValue: '',
},
],
},
],
},
],
},
],
},
],
},
],
}
后续很多需求咱们都会用 dependsOn 来配置实现,这里的定义非常干净,因为我们引入的 handle 参数的依赖注入机制,需要什么工具模块,只要在 inject 的列表中配置即可,这样,在 handle 函数的第二个参数开始,会按照 inject 列表自动对后续的参数列表一一注入需要的模块类型。 这里咱们注入了节点 API 模块,使用 node.at(...) api 来按照路径查找到目标节点对象,并基于作用域 scope 访问的变量来更新目标节点的值。这里通过 scope 访问的变量来做到源字段的监听,只要涉及到的字段值发生变化,dependsOn 配置中的 handle 就会重新执行。
再回到 FormCreate 引擎,要实现同样的需求,只要在源字段值变化的 update 配置实现相关逻辑,利用提供的 api 等参数获取子表单来进行目标字段值更新。
ts
const fcRule = ref([
{
type: 'input',
field: 'crossSource',
title: '跨层源头',
col: { span: 12 },
props: { placeholder: '改这里,看深层节点' },
update(val: string, _rule: unknown, api: FcApi) {
api
.getSubForm('crossOuter')
.getSubForm('crossInner')
.setValue('crossLeaf', `来自源头:${val || ''}`)
},
},
{
type: 'object',
field: 'crossOuter',
title: '最后外层',
props: {
rule: [
{
type: 'object',
field: 'crossInner',
title: '嵌套内层',
props: {
rule: [
{
type: 'input',
field: 'crossLeaf',
title: '深层节点',
props: { placeholder: '由源头写入' },
},
],
},
},
],
},
},
])
通过对比,发现节点的获取,FormCreate 对于内层子表单,需要通过 getSubForm 方法来获取子表单的 api 对象,再以此类推,获取更内层的 api 对象,也就是 api 做了嵌套。也就是说 FormCreate 引擎对外的配置层,没有 schema 对应的 节点 node 的概念,按照节点的查找机制,如我们这里的用法:node.at('crossOuter.crossInner.crossLeaf'),则很容易就找到内层的目标节点来操作。
这还不是主要问题,主要问题是内部渲染机制由于第一次内层子表单初始化还没完成,父表就已经触发了源头字段的 update。看如下配置:
ts
update(val: string, _rule: unknown, api: FcApi) {
api
.getSubForm('crossOuter')
.getSubForm('crossInner')
.setValue('crossLeaf', `来自源头:${val || ''}`)
},
页面一刷新就报错:

这时 getSubForm 拿不到内层 api,配置里必须自己判断 origin === 'init',再套一层 nextTick 才能写入。这里的 api.nextTick 参考 FormCreate 源码,发现其实并不是 vue 的 nextTick 机制,而是耍小聪明的做了 setTimeout 延时处理。 update 需要调整如下:
ts
update(val: string, _rule: unknown, api: FcApi, { origin }: { origin: string }) {
const write = () => {
api
.getSubForm('crossOuter')
.getSubForm('crossInner')
.setValue('crossLeaf', `来自源头:${val || ''}`)
}
if (origin === 'init') api.nextTick(write)
else write()
},
因此,引擎内部链路处理不干净的代价就是用户配置层需要接锅,api 用法更叠更拧些。
目标字段更新
相比源字段监听这种更灵活的配置,这里直接改目标字段的值会更简单,只需要在目标字段上设置更新值的计算属性即可。
JuanForm 的配置方式:
ts
{
prop: 'crossLeaf',
label: '深层节点',
type: 'Input',
col: 'auto',
props: { placeholder: '由源头写入' },
expression: (scope) => `来自源头:${scope.$root.crossSource ?? ''}`,
}
FormCreate 的配置方式:
ts
{
type: 'input',
field: 'crossLeaf',
title: '深层节点',
props: { placeholder: '由源头写入' },
computed: {
value: (
_form: unknown,
api: { top: { form: { crossSource?: string } } },
) => `来自源头:${api.top.form.crossSource || ''}`,
},
}
省市联动
这种联动需求最常见,咱们这里不采用后台 API 接口的交互方式,而是动态更新本地数据。先看下要实现的效果:

首先看咱们 JuanForm 引擎的配置实现,这里我们只要在被联动的目标字段上配置字段级的 dependsOn ,这里通过 scope.province 只监听省的变化,通过注入的 node API,node.current() 来获取当前的节点,更新选项。最后通过 scope 作用域来更新 city 初始选中为选项列表的第一项,配置非常的清晰。
ts
const schema: FormSchema = {
children: [
{
items: [
{
prop: 'province',
label: '省份',
type: 'Select',
col: 8,
props: {
placeholder: '请选择省份',
clearable: true,
options: [
{ label: '江苏', value: '01' },
{ label: '安徽', value: '02' },
],
},
},
{
prop: 'city',
label: '城市',
type: 'Select',
col: 8,
props: {
placeholder: '请选择城市',
clearable: true,
options: [],
},
dependsOn: {
inject: ['node'],
handle: (scope, node: NodeApi) => {
let options: { label: string; value: string }[] = []
if (scope.province === '01') {
options = [
{ label: '南京', value: '0101' },
{ label: '苏州', value: '0102' },
]
} else if (scope.province === '02') {
options = [{ label: '合肥', value: '0201' }]
}
node.current().props.options = options
scope.city = options[0] ? options[0].value : undefined
},
},
},
],
},
],
}
而对于 FormCreate 引擎,这里没有像 JuanForm 这样比较通用的 dependsOn 依赖监听配置,只能往监听源字段或目标字段变化的 update 方法上去靠,比较源头的就是在源字段 province 上听它的值的改变来做相关逻辑:
ts
const fcRule = ref([
{
type: 'select',
field: 'province',
title: '省份',
col: { span: 12 },
style: { width: '100%' },
props: { placeholder: '请选择省份', clearable: true },
options: [
{ label: '江苏', value: '01' },
{ label: '安徽', value: '02' },
],
update(
val: string,
_rule: unknown,
api: {
getRule: (field: string) => { options: { label: string; value: string }[] }
setValue: (field: string, value: string | undefined) => void
},
) {
let options: { label: string; value: string }[] = []
if (val === '01') {
options = [
{ label: '南京', value: '0101' },
{ label: '苏州', value: '0102' },
]
} else if (val === '02') {
options = [{ label: '合肥', value: '0201' }]
}
api.getRule('city').options = options
api.setValue('city', options[0] ? options[0].value : undefined)
},
},
{
type: 'select',
field: 'city',
title: '城市',
col: { span: 12 },
style: { width: '100%' },
props: { placeholder: '请选择城市', clearable: true },
options: [],
},
])
这种配置方式跟源头字段的 control 配置风格差不多,都属于源字段干预方式。最符合配置思考习惯的,是对目标字段按依赖倒置来配置,咱们的 dependsOn 就符合这一原则。FormCreate 也可以把逻辑写到目标字段 city 上:用 link 声明监听 province,在 update 里改选项并写回选中值。fcRule 同样要用 ref。
ts
{
field: 'city',
...
link: ['province'],
update(_val, rule, api) {
const province = api.getValue('province')
...
rule.options = options
api.setValue('city', options[0] ? options[0].value : undefined)
},
}
还可以在 city 上写 computed.options 和 computed.value,直接读取 form.province。写法更短,同样要求 fcRule 使用 ref。
ts
{
field: 'city',
...
computed: {
options: (form) => cities(form.province),
value: (form) => cities(form.province)[0]?.value,
},
}
这两种都写在目标字段附近,表面上接近咱们在 city 上配置 dependsOn、通过 scope.province 监听。link 仍要声明监听再改规则、写回值;computed 读取的是整个表单数据,不是字段级的依赖声明,也没有当前节点。FormCreate 没有专门给目标字段用的稳定依赖配置,只能在源字段 update、目标字段 link、computed 之间切换,并且都要配合 ref 才能刷新选项。
列表示例
需求:以保险受益人列表为例,支持动态新增与删除行。每一行填写姓名、关系、份额;新增时向数组追加一条记录,删除时按当前行从数组中移除,视图与数据模型保持同步。
暂且忽略样式,先看关键功能的实现效果:
JuanForm 的实现效果

FormCreate 的实现效果

首先看咱们 JuanForm。列表在数据模型中对应一个数组。List 写在页面第一行的 items 中,行模板是它的 children;删除按钮与姓名、关系、份额写在同一行。添加按钮不在 List 内部,而与 List 同级,写在下一行的 items 中。二者都是普通 Button,分别通过行作用域的 $parent、$index 以及父层数组完成增删。
ts
{
children: [
{
items: [
{
prop: 'beneficiaries',
type: 'List',
children: [{
items: [
{ prop: 'name', type: 'Input', ... },
{ prop: 'relation', type: 'Select', ... },
{ prop: 'share', type: 'Input', ... },
{
label: '删除',
type: 'Button',
props: {
onClick: {
handle: (scope) => {
scope.$parent.beneficiaries.splice(scope.$index, 1)
},
},
},
},
],
}],
},
],
},
{
items: [
{
label: '添加受益人',
type: 'Button',
props: {
onClick: {
handle: (scope) => {
scope.beneficiaries.push({})
},
},
},
},
],
},
],
}
扩展能力体现在:增删是页面字段,而不是列表控件的内置行为。按钮的位置、文案、显隐以及点击时是否需要确认,都可以通过字段配置调整;也可以 inject 列表工具,或在行上继续使用 dependsOn。后文的列表长度、限制添加,仍然沿用这条作用域链路,无需再引入单独的列表接口。
再来看 FormCreate。它在规则数组的顶层放置一条 group,行内字段写在该规则的 props.rule 中,增减由组件内置的 button 提供,不能像咱们那样把添加按钮写到 group 外面。
ts
{
type: 'group',
field: 'beneficiaries',
props: {
rule: [
{ type: 'input', field: 'name', ... },
{ type: 'select', field: 'relation', ... },
{ type: 'input', field: 'share', ... },
],
button: true,
sortBtn: false,
},
}
这种封装是封闭的。添加与删除的位置、交互由 group 内部决定,配置层难以将添加按钮放到列表外部,也难以将删除替换为自定义的 handle。每一行并不是独立的作用域节点,外层如果要操作某一行,只能再次通过 group 的 api 获取子表单,这与前文跨层 getSubForm 属于同一类嵌套。
因此,同样是受益人列表,咱们对齐的是层、行、作用域与字段配置;FormCreate 对齐的是内置的 group 组件。一旦定制超出 button、sortBtn 所提供的能力,配置就只能迁就组件内部的实现。
行列动态样式
需求:在受益人列表中,根据关系为整行设置背景色;当份额大于 3 时,份额所在列的背景变为红色。
先看各自的实现效果:
JuanForm的列表行列动态样式

FormCreate的列表行列动态样式

首先看咱们 JuanForm。行样式与列样式都写在 List 字段 beneficiaries 上,位置是该字段的 dependsOn,而不是某一列的配置。这段配置随每一行执行:node.current() 为当前行节点,用于写入行背景;node.at('share') 取到该行的份额字段,用于写入列背景。监听的仍是行作用域中的 relation 与 share。
ts
{
prop: 'beneficiaries',
type: 'List',
dependsOn: [
{
inject: ['node'],
handle: (scope, node) => {
...
node.current().props.style = bg ? { background: bg } : undefined
},
},
{
inject: ['node'],
handle: (scope, node) => {
const share = node.at('share')
share.props.style = Number(scope.share) > 3
? { background: 'var(--el-color-danger-light-9)' }
: undefined
},
},
],
children: [ ... ],
}
扩展能力体现在:行节点与列节点都可以作为样式的写入目标。如果还要按其它字段变色、增加 class,或改变整行显隐,仍然使用 inject: ['node'] 与作用域判断,不必为样式再增加布局字段。
再来看 FormCreate。group 没有行节点。整行着色与份额列变色,都写在 group 的 props.rule 内部:先增加一层原生 row,用 computed.style 读取 form.relation;再在份额所在的 col 上书写 computed.style。
ts
{
type: 'group',
field: 'beneficiaries',
props: {
rule: [
{
type: 'row',
native: true,
computed: {
style: (form) => { ... form.relation ... },
},
children: [
{ type: 'col', children: [{ field: 'name', ... }] },
{ type: 'col', children: [{ field: 'relation', ... }] },
{
type: 'col',
computed: {
style: (form) =>
Number(form.share) > 3 ? { background: '...' } : undefined,
},
children: [{ field: 'share', ... }],
},
],
},
],
button: true,
...
},
}
样式只能写在布局节点上,而不能写在字段节点上。为了得到可计算的 style,列表行被改写成 row / col 嵌套。这与咱们在行节点、列节点上直接写入 props.style 不同:FormCreate 需要为样式叠加一层栅格结构,配置会随视觉需求继续加深。
因此,同样是行、列着色,咱们对齐的是作用域与节点;FormCreate 对齐的是为样式而增加的布局封装。group 一旦缺少行节点,定制就只能迁就它内部能够放置 computed 的位置。
列表长度文字提示
需求:受益人人数超过 3 人时,在列表下方显示红色提示「受益人超过 3 人」;人数回到 3 人以内,提示不再显示。
同一需求,JuanForm 可以采用两条配置路径:一条在运行时动态创建、删除提示节点;另一条将提示预先写在布局中,用 if 控制是否渲染。FormCreate 没有运行时创建节点的能力,只能预先写出提示,再用 hidden 控制显示。
先看第一种:运行时动态创建节点。布局的 children 中没有这条提示。与上一节行样式使用的 dependsOn 不同,本节监听的是列表整体的长度,配置键是 listDependsOn,写在 List 字段 beneficiaries 上。超过 3 人时,node.add 按 { below: list } 在列表下方创建一条 Html 节点;回到 3 人以内时,node.delete 删除该节点。添加按钮与 List 写在同一行的 items 中。
ts
{
children: [
{
items: [
{
prop: 'beneficiaries',
type: 'List',
listDependsOn: {
inject: ['node'],
handle: (scope, node) => {
const tip = {
id: 'beneficiaries-length-tip',
type: 'Html',
text: '受益人超过 3 人',
...
}
const list = node.at('beneficiaries')
if (scope.beneficiaries.length > 3) node.add(tip, { below: list })
else node.delete(tip)
},
},
children: [ ... ],
},
{ label: '添加受益人', type: 'Button', ... },
],
},
],
}
这就是运行时动态创建节点:提示不是 schema 中预先声明的字段,而是按长度条件创建与删除。插入位置、文案与样式仍是普通字段配置。
再看第二种:不使用动态创建,将 Html 预先写在下一行的 items 中,用 if 读取 length。条件不成立时不渲染。List 与添加按钮仍在上一行。
ts
{
children: [
{
items: [
{ prop: 'beneficiaries', type: 'List', children: [ ... ] },
{ label: '添加受益人', type: 'Button', ... },
],
},
{
items: [
{
type: 'Html',
text: '受益人超过 3 人',
if: (scope) => scope.beneficiaries.length > 3,
},
],
},
],
}
两条路径效果相同,可以按场景选择:需要按条件插入布局中原本没有的内容时,使用 node.add;提示位置已经确定时,预先放置并用 if 控制。同一需求不必固定为一种写法。
再来看 FormCreate。官方没有在运行时创建或删除规则节点的接口。提示必须预先写在规则数组中,作为 group 的下一项:一条原生 div,用 computed.hidden 读取 length。fcRule 需要使用 ref。
ts
[
{
type: 'group',
field: 'beneficiaries',
props: {
rule: [ ... ],
button: true,
...
},
},
{
type: 'div',
native: true,
children: ['受益人超过 3 人'],
computed: {
hidden: (form) => form.beneficiaries.length <= 3,
},
},
]
因此,同样是按人数显示提示,咱们既有运行时动态创建节点的能力,也可以预先放置后用 if 控制;多种实现可以按场景选用。FormCreate 只能预先放置,再用 hidden 控制显示,并且依赖整个规则数组的响应式。
限制添加长度
需求:受益人最多 3 人。已经有 3 人时,再次点击添加,不再向数组追加记录。
首先看咱们 JuanForm。人数上限写在添加按钮的 onClick.handle 中。该按钮与 List 同级,位于同一行的 items 中。handle 读取数组 length,达到 3 就返回,否则 push 一条空记录。
ts
{
children: [
{
items: [
{ prop: 'beneficiaries', type: 'List', children: [ ... ] },
{
label: '添加受益人',
type: 'Button',
props: {
onClick: {
handle: (scope) => {
const list = scope.beneficiaries
if (list.length >= 3) return
list.push({})
},
},
},
},
],
},
],
}
灵活性在于:上限约束的是普通按钮字段的点击处理,而不是列表控件的内置人数限制。拦截添加、隐藏按钮、更换文案或加入确认,都仍然在该字段上配置,不必使用单独的列表接口。
再来看 FormCreate。增加与删除由 group 内置的 button 负责。限制只能写在 group 的 props 上,与 button: true 同层,使用官方的 onBeforeAdd:长度为 3 时返回 false。添加按钮仍然显示,点击后不再追加。配置无法改写按钮位置,也无法改成字段上的点击处理。
ts
{
type: 'group',
field: 'beneficiaries',
props: {
rule: [ ... ],
button: true,
onBeforeAdd: (list) => {
if (list.length >= 3) return false
},
},
}
因此,同样是限制人数,咱们写在添加按钮的点击处理上,与增加、删除共用同一条作用域链路,显隐、文案与确认仍然写在该按钮字段上;FormCreate 写在 group 组件的内置钩子上,按钮的显隐与位置仍由组件内部决定。
对比总结
前面各节用同一组联动需求做了对比。从机制上看,两边的差别如下。
| 方面 | JuanForm | FormCreate |
|---|---|---|
| 依赖位置 | 写在被联动的目标字段上。hidden、expression、dependsOn 读取作用域中的字段,从而建立依赖。 |
多在源字段上用 control 指定目标。写到目标字段时,还要在 link、update、computed 中选择一种。 |
| 运行时结构 | 层、作用域和节点。跨层用 node.at 查找,列表中的每一行也是节点,显隐接近 Vue 的条件渲染。 |
规则树和实例 api。跨层用 getSubForm,列表对应封闭的 group 组件。 |
| 实现是否可替换 | 同一组配置可以用于跨层、列表,也可以在运行时创建节点。提示既可以用 node.add,也可以先写在布局中,再用 if 控制是否渲染。 |
超出 control、computed、group 之后,只能使用组件提供的钩子和布局。 |
| 扩展性 | 新的联动仍写在 hidden、expression、dependsOn 上,需要节点时用 inject 注入。列表中的按钮、提示和人数限制都是字段,不必另做一套列表接口。 |
新的联动通常要改用其他接口:control、link、update、computed,或 group 的 onBeforeAdd。组件能力不够时,需要改写布局,或调用实例 API。 |
| 性能 | schema 是普通对象。联动只监听作用域中实际读到的字段,不必将整个配置做成响应式。 | computed 要生效,实践中需要将整个 rule 包进 ref。字段增多、联动变多之后,配置对象上的监听会成为负担。 |
| 使用场景 | 更适合复杂联动、字段配置体量更大的场景,也可以做到页面级。 跨层、列表行内、按条件创建节点、改写按钮,都可以继续写在同一套字段配置上。 | 更适合字段不多、由源字段控制若干目标、官方 group 自带增减就可以满足需要的标准表单,范围主要在表单级。 |
配置上的具体差别如下。
| 对比项 | JuanForm | FormCreate |
|---|---|---|
| 显隐 | 在目标字段上配置 hidden。函数读到作用域中的字段后,引擎按这些值创建或销毁节点,并同步清理数据模型。多个字段共用同一条件时,用 Html 的 el: 'template' 配合 if,对应 Vue 的 <template v-if>。 |
用源字段的 control 指定目标,method 可选 if 或 display。也可以在目标上写 computed.hidden,但整个 rule 需要用 ref 包裹。字段隐藏后默认仍留在表单数据中,需要另外开启 ignoreHiddenFields。 |
| 求值 | 在目标字段上配置 expression。 |
使用 computed.value,同样需要 ref。 |
| 通用联动 | dependsOn 读取 scope 来建立依赖,并用 inject 注入 node 等工具。层、字段和列表都是这一种写法。 |
没有对应的目标字段依赖声明,需要在 update、link、control、computed 中选择。 |
| 跨层写入 | 用 node.at('a.b.c') 按路径取得节点并写入值。 |
需要连续调用 getSubForm。子表单还没有初始化时,得到的是 undefined。第一次写入还要判断 origin === 'init',再调用 api.nextTick。源码中这个 nextTick 不是 Vue 的 nextTick,而是由 bindNextTick 用 setTimeout(..., 10) 触发的。 |
| 列表内联动 | 行和列都是节点。行样式写在 node.current() 上,列样式写在 node.at('share') 上。添加、删除和人数上限都写在普通按钮的点击处理中。 |
group 没有行节点。行列样式要在 props.rule 中增加原生 row 与 col。添加、删除和人数上限使用内置的 button 与 onBeforeAdd。 |
| 按条件出现的节点 | 配置中可以直接调用 node.add / node.delete,按相对位置创建或删除节点;也可以先写在布局中,用 if 决定是否渲染。两种写法都能满足同一需求。 |
实例 API 提供 append 与 removeRule,用来直接改规则树,并不能当作字段 handle 中的节点工具。联动时通常仍是先写好规则,再用 hidden 或 computed 控制显示。 |
| 联动如何生效 | schema 是普通对象,引擎监听作用域中实际读到的字段。 | computed 要生效,整个 rule 必须是响应式的。联动变多以后,对配置对象的监听会成为负担。 |
为什么选择 JuanForm
JuanForm 更适合复杂联动、字段配置体量更大的场景,也可以做到页面级。 FormCreate 更适合结构稳定、规模不大的标准表单。
联动写在目标字段上,常用的是 hidden、expression 和 dependsOn。跨层查找节点,列表中修改样式、增加和删除行,以及按条件加入提示,都使用同一条作用域链路。同一需求既可以用运行时创建节点,也可以用 if,不必在多种联动接口之间切换,也不必将整个规则数组做成响应式。字段增多、联动变复杂以后,配置还可以写在这几个入口上。