跪拜 Guibai
← Back to the summary

JuanForm vs FormCreate: A Vue 3 Form-Engine Linked-Config Shootout

JuanForm vs FormCreate! Vue3 form engine linked-config PK!

Hello everyone, I'm Full Stack Xiao Juan. In the AI era, frontend development — form and even page construction — will be built on AI-generated json schema, realizing no-code form/page building that is friendly to business people. Naturally, this depends on the support of the underlying dynamic form rendering engine. Among the more representative Vue3-based form engines on the market, we'll use FormCreate as the comparison baseline and run a linked-configuration PK against JuanForm, the one we're developing ourselves. Let's see how these two form engines handle the same form linking requirements with their configuration capabilities, and find out which one aligns more closely with Vue's native mechanisms, so users have a better basis when selecting a low-code form engine in the Vue technology stack.

Example PK

Below, Xiao Juan walks through how the two form engines are configured for each in-form linking requirement. We'll skip dependency imports and project integration; let's go straight to the page configuration and the rendered Element Plus UI effects.

Field show/hide linking

Single switch controls single field

Requirement: use one switch to hide a field, and the data model must also clean the hidden field up.

Effect to achieve

test1.gif

Both engines achieve the effect, but whether the mechanism is clean depends on the actual code configuration. First, our JuanForm:

<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: 'Hide name',
          type: 'Switch',
          col: 8,
          defaultValue: false,
        },
        {
          prop: 'name',
          label: 'Name',
          type: 'Input',
          col: 8,
          props: { placeholder: 'Enter name' },
          hidden: (scope) => scope.hideName === true,
        },
      ],
    },
  ],
}
</script>

<template>
  <JuanForm :schema="schema" />
</template>

Our config is simple and easy to understand: children represents how many rows this layer has, items how many columns this row has. In other words, our config aligns with the layer's scope concept on the data chain and with a non-fixed-total-column grid system on the view. The key thing is the hidden config: to control hiding for a field, set an arrow function directly on the target field and judge according to the value of the source-control field in the scope.

Now FormCreate. Its field hiding supports two configuration approaches; we'll look at them one by one. First, the control approach:

<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: 'Hide name',
    value: false,
    col: { span: 12 },
    control: [
      {
        handle(val: boolean) {
          return val !== true
        },
        method: 'if',
        rule: ['name'],
      },
    ],
  },
  {
    type: 'input',
    field: 'name',
    title: 'Name',
    col: { span: 12 },
    props: { placeholder: 'Enter name'     },
  },
]
</script>

<template>
  <form-create v-model="fcData" :rule="fcRule" :option="fcOption" />
  <pre class="form-json">{{ json }}</pre>
</template>

The key part is the fcRule config — the rule definition to render. So first, it's obvious from the component options that we're cleaner: our goal is a page-level rendering engine, while FormCreate's product definition is confined to the form level. It has to provide the form data model, and even button rendering is built in and must be configured. We will support page-level configuration for buttons, dialogs, data tables, and more.

Here control means a source field controls target fields, while the hidden style is more like IoC — inversion of control / dependency inversion. Source control is fine when there's only one source field, but when multiple source fields act together (there are config examples later), the config gets complicated, less readable, and harder to maintain. For control hiding — unlike ordinary html elements, this is generally v-if control rather than style-level hiding — configuring if via method is also not very clean; if a user configures display, it means little for a control.

The current config has a problem: when a field is hidden in the view, the hidden field is not removed from the data model. To solve it in the control approach, you need the following adjustments:

// add the ignore-hidden-fields setting to the global options
const fcOption = { ..., ignoreHiddenFields: true }
...
// the whole rules config object must be wrapped in a reactive ref
const fcRule = ref(...)

Obviously, applying ref() reactivity to the entire config form has an increasingly significant impact on listener performance as the config data grows. If FormCreate is to support larger, more complex linked dynamic forms, this ref() approach is a hard performance flaw.

Besides control, FormCreate also supports a computed hidden config:

const fcOption = { ..., ignoreHiddenFields: true }
...
const fcRule = ref([
  {
    type: 'switch',
    field: 'hideName',
    title: 'Hide name',
    value: false,
    col: { span: 12 },
  },
  {
    type: 'input',
    field: 'name',
    title: 'Name',
    col: { span: 12 },
    props: { placeholder: 'Enter name' },
    computed: {
      hidden: (form: { hideName: boolean }) => form.hideName === true,
    },
  },
])

This approach, like JuanForm's, is a target-field IoC configuration. But its hard flaw is still ref(): for the computed approach to take effect, the entire config rule array must be wrapped in reactivity. That has little impact on small forms, but in terms of a clean rendering chain, JuanForm's non-reactive schema approach clearly wins.

Single switch controls multiple fields

Let's look at FormCreate's support first. For this case, with control you maintain it in the rule array; with the hidden computed approach you add it directly on the target fields. Clearly, control looks like the simplest adjustment but only happens to fit this one case — single source controlling multiple fields — and gets semantically twistier elsewhere; the hidden approach stacks multiple identical computed configs across multiple target fields.

Going back to the source of Vue template config: this is really about, without changing the template element structure, wrapping multiple elements that share the same condition in a <template> element and using v-if to control visibility in one place. Note the outer wrap is <template>, not <div> — the latter directly changes the layout structure, which isn't our intention.

Back to JuanForm. For this scenario we provide this config form, which natively aligns with Vue's <template> tag behavior:

const schema: FormSchema = {
  children: [
    {
      items: [
        {
          prop: 'showDetail',
          label: 'Show details',
          type: 'Switch',
          col: 4,
          defaultValue: true,
        },
        {
          type: 'Html',
          el: 'template',
          // el: 'div',
          // col: 'auto',
          if: (scope) => scope.showDetail === true,
          children: [
            {
              items: [
                {
                  prop: 'phone',
                  label: 'Phone',
                  type: 'Input',
                  col: 6,
                  props: { placeholder: 'Enter phone', class: 'min-md' },
                },
                {
                  prop: 'email',
                  label: 'Email',
                  type: 'Input',
                  col: 6,
                  props: { placeholder: 'Enter email', class: 'min-md' },
                },
              ],
            },
          ],
        },
      ],
    },
  ],
}

Note that although the schema config is layered here, at render time the children under the template element render into the same layer as the showDetail field according to the condition. The actual render structure:

20261027164856.png

If you replace template with div, then phone and email sit in a separate container in the HTML structure:

20261027165311.png

So comparing the configs, JuanForm wins again in aligning with Vue's native mechanisms.

Double switch show/hide

Requirement: two switches control one field's visibility, synced to the data model.

Effect to achieve

test2.gif

JuanForm first. Apply hidden directly to the target field you want to hide, combining multiple scope variables in the judgment:

const schema: FormSchema = {
  children: [
    {
      items: [
        {
          prop: 'showA',
          label: 'Condition A',
          type: 'Switch',
          col: 8,
          defaultValue: true,
        },
        {
          prop: 'showB',
          label: 'Condition B',
          type: 'Switch',
          col: 8,
          defaultValue: true,
        },
        {
          prop: 'name',
          label: 'Name',
          type: 'Input',
          col: 8,
          props: { placeholder: 'Enter name' },
          hidden: (scope) => scope.showA !== true || scope.showB !== true,
        },
      ],
    },
  ],
}

For this scenario, FormCreate can likewise use the hidden computed on the target name field to listen to multiple fields at once:

computed: {
  hidden: (form: { showA: boolean; showB: boolean }) =>
	form.showA !== true || form.showB !== true,
},

But note: for this hidden computed to work, the entire rule config must be wrapped in ref().

Besides the hidden computed, for multiple fields together controlling one field's visibility you can also use control on a source field combined with a link config to append another source field's listener, ensuring handle fires multiple times. This config is a bit twisty, not as clean as hidden on the target field.

const fcRule = [
  {
    type: 'switch',
    field: 'showA',
    title: 'Condition A',
    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: 'Condition B',
    value: true,
    col: { span: 12 },
  },
  {
    type: 'input',
    field: 'name',
    title: 'Name',
    col: { span: 12 },
    props: { placeholder: 'Enter name' },
  },
]

Expression summation

Requirement: two input boxes accept number type; after input, the sum shows automatically in a result input box, and the result box can also be edited by hand.

Effect to achieve

test3.gif

This involves a field whose value can be edited on its own yet computed and assigned from other field values. JuanForm provides an expression config:

const schema: FormSchema = {
  children: [
    {
      cols: 24,
      items: [
        {
          prop: 'a',
          label: 'Addend A',
          type: 'Input',
          col: 8,
          defaultValue: 1,
          props: { type: 'number', placeholder: 'Enter' },
        },
        {
          prop: 'b',
          label: 'Addend B',
          type: 'Input',
          col: 8,
          defaultValue: 2,
          props: { type: 'number', placeholder: 'Enter' },
        },
        {
          prop: 'total',
          label: 'Total',
          type: 'Input',
          col: 8,
          props: { type: 'number', placeholder: 'A plus B' },
          expression: (scope) => Number(scope.a || 0) + Number(scope.b || 0),
        },
      ],
    },
  ],
}

When a depended-on field changes, the calculation fires automatically and assigns the result to the target field; the target also supports manual input.

FormCreate also provides a field-level computed for updating value, used as follows:

const fcRule = ref([
  {
    type: 'input',
    field: 'a',
    title: 'Addend A',
    value: 1,
    col: { span: 8 },
    props: { type: 'number', placeholder: 'Enter' },
  },
  {
    type: 'input',
    field: 'b',
    title: 'Addend B',
    value: 2,
    col: { span: 8 },
    props: { type: 'number', placeholder: 'Enter' },
  },
  {
    type: 'input',
    field: 'total',
    title: 'Total',
    col: { span: 8 },
    props: { type: 'number', placeholder: 'A plus B' },
    computed: {
      value: (form: { a: number | string; b: number | string }) =>
        Number(form.a || 0) + Number(form.b || 0),
    },
  },
])

Leaving aside whether the internal mechanism is clean, the requirement is at least met. But it must be said: for the value computed to work, the whole rule array config must also be wrapped in ref(), making the whole thing reactive.

Cross-layer write

Requirement: there's a nested model; listen on an outer field and sync the result down to a field in the inner model.

Effect to achieve

test4.gif

For this requirement, we implement from two config directions: source-field listening and target-field update.

Source-field listening

JuanForm provides a powerful dependsOn dependency-listener config that flexibly covers most custom linking needs. Listening works at layer level, field level, and later list level; here we just configure at the root layer:

const schema: FormSchema = {
  dependsOn: {
    inject: ['node'],
    handle: (scope, node: NodeApi) => {
      const deep = node.at('crossOuter.crossInner.crossLeaf') as FieldNode
      deep.value = `From source: ${scope.crossSource ?? ''}`
    },
  },
  children: [
    {
      items: [
        {
          prop: 'crossSource',
          label: 'Cross-layer source',
          type: 'Input',
          col: 8,
          props: { placeholder: 'Edit here, watch the deep node' },
          defaultValue: 'Verify',
        },
        {
          prop: 'crossOuter',
          label: 'Final outer',
          type: 'Group',
          children: [
            {
              items: [
                {
                  prop: 'crossInner',
                  label: 'Nested inner',
                  type: 'Group',
                  children: [
                    {
                      items: [
                        {
                          prop: 'crossLeaf',
                          label: 'Deep node',
                          type: 'Input',
                          col: 'auto',
                          props: { placeholder: 'Written from source' },
                          defaultValue: '',
                        },
                      ],
                    },
                  ],
                },
              ],
            },
          ],
        },
      ],
    },
  ],
}

Many later requirements will use dependsOn for configuration. The definition is very clean because of the dependency-injection mechanism we introduced for the handle parameters: whatever tool module you need, just list it in inject, and from the second handle parameter on, the required module types are injected one by one according to the inject list. Here we inject the node API module and use the node.at(...) api to find the target node by path, then update its value based on scope variables. Scope variables provide source-field listening: whenever any involved field changes, the handle in dependsOn re-executes.

Back to FormCreate. To achieve the same, implement the logic in the source field's update config, using the provided api parameters to get subforms and update the target value:

const fcRule = ref([
  {
    type: 'input',
    field: 'crossSource',
    title: 'Cross-layer source',
    col: { span: 12 },
    props: { placeholder: 'Edit here, watch the deep node' },
    update(val: string, _rule: unknown, api: FcApi) {
      api
        .getSubForm('crossOuter')
        .getSubForm('crossInner')
        .setValue('crossLeaf', `From source: ${val || ''}`)
    },
  },
  {
    type: 'object',
    field: 'crossOuter',
    title: 'Final outer',
    props: {
      rule: [
        {
          type: 'object',
          field: 'crossInner',
          title: 'Nested inner',
          props: {
            rule: [
              {
                type: 'input',
                field: 'crossLeaf',
                title: 'Deep node',
                props: { placeholder: 'Written from source' },
              },
            ],
          },
        },
      ],
    },
  },
])

From the comparison, for node access FormCreate must use getSubForm to fetch the subform's api object, then continue likewise for deeper layers — the api nests. In other words, FormCreate's external config layer has no node concept corresponding to a schema node. With a node-lookup mechanism like ours — node.at('crossOuter.crossInner.crossLeaf') — it's easy to reach the inner target node.

That's not the main problem. The main problem is the internal render order: because the inner subform hasn't finished its first initialization, the parent form already fires the source field's update. Look at this config:

update(val: string, _rule: unknown, api: FcApi) {
  api
	.getSubForm('crossOuter')
	.getSubForm('crossInner')
	.setValue('crossLeaf', `From source: ${val || ''}`)
},

A page refresh throws immediately:

20261028160635.png

At this point getSubForm can't get the inner api; the config must judge origin === 'init' itself and wrap in nextTick before writing. Checking FormCreate's source, this api.nextTick is actually not Vue's nextTick mechanism but a clever setTimeout delay. The update must be adjusted:

update(val: string, _rule: unknown, api: FcApi, { origin }: { origin: string }) {
  const write = () => {
	api
	  .getSubForm('crossOuter')
	  .getSubForm('crossInner')
	  .setValue('crossLeaf', `From source: ${val || ''}`)
  }
  if (origin === 'init') api.nextTick(write)
  else write()
},

So the cost of an unclean internal chain lands on the user's config layer: the api usage gets more stacked and twisty.

Target-field update

Compared with the more flexible source-field listening, directly changing the target field's value is simpler: just set an update computed on the target field.

JuanForm's config:

{
  prop: 'crossLeaf',
  label: 'Deep node',
  type: 'Input',
  col: 'auto',
  props: { placeholder: 'Written from source' },
  expression: (scope) => `From source: ${scope.$root.crossSource ?? ''}`,
}

FormCreate's config:

{
	type: 'input',
	field: 'crossLeaf',
	title: 'Deep node',
	props: { placeholder: 'Written from source' },
	computed: {
	  value: (
		_form: unknown,
		api: { top: { form: { crossSource?: string } } },
	  ) => `From source: ${api.top.form.crossSource || ''}`,
	},
  }

Province-city linkage

This is the most common kind of linkage. Here we don't use a backend API interaction; we dynamically update local data. First, the effect:

test5.gif

JuanForm first. We only need to configure field-level dependsOn on the linked target field. scope.province listens only to province changes; the injected node API uses node.current() to get the current node and update options; finally scope updates city's initial selection to the first item in the options list. Very clear config.

const schema: FormSchema = {
  children: [
    {
      items: [
        {
          prop: 'province',
          label: 'Province',
          type: 'Select',
          col: 8,
          props: {
            placeholder: 'Select province',
            clearable: true,
            options: [
              { label: 'Jiangsu', value: '01' },
              { label: 'Anhui', value: '02' },
            ],
          },
        },
        {
          prop: 'city',
          label: 'City',
          type: 'Select',
          col: 8,
          props: {
            placeholder: 'Select city',
            clearable: true,
            options: [],
          },
          dependsOn: {
            inject: ['node'],
            handle: (scope, node: NodeApi) => {
              let options: { label: string; value: string }[] = []
              if (scope.province === '01') {
                options = [
                  { label: 'Nanjing', value: '0101' },
                  { label: 'Suzhou', value: '0102' },
                ]
              } else if (scope.province === '02') {
                options = [{ label: 'Hefei', value: '0201' }]
              }
              node.current().props.options = options
              scope.city = options[0] ? options[0].value : undefined
            },
          },
        },
      ],
    },
  ],
}

For FormCreate, there's no generic dependsOn dependency-listener config like JuanForm's. You lean on update methods listening on source or target field changes. The most source-oriented is listening on the source province field's value change and doing the logic:

const fcRule = ref([
  {
    type: 'select',
    field: 'province',
    title: 'Province',
    col: { span: 12 },
    style: { width: '100%' },
    props: { placeholder: 'Select province', clearable: true },
    options: [
      { label: 'Jiangsu', value: '01' },
      { label: 'Anhui', 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: 'Nanjing', value: '0101' },
          { label: 'Suzhou', value: '0102' },
        ]
      } else if (val === '02') {
        options = [{ label: 'Hefei', value: '0201' }]
      }
      api.getRule('city').options = options
      api.setValue('city', options[0] ? options[0].value : undefined)
    },
  },
  {
    type: 'select',
    field: 'city',
    title: 'City',
    col: { span: 12 },
    style: { width: '100%' },
    props: { placeholder: 'Select city', clearable: true },
    options: [],
  },
])

This config style resembles source-field control: source-field intervention. What best matches configuration thinking habits is dependency-inverted config on the target field — exactly what our dependsOn does. FormCreate can also write the logic on the target city field: declare listening to province with link, then change options and write back the selected value in update. fcRule still needs ref.

{
  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)
  },
}

You can also write computed.options and computed.value on city, reading form.province directly. Shorter, but again requires fcRule to use ref.

{
  field: 'city',
  ...
  computed: {
    options: (form) => cities(form.province),
    value: (form) => cities(form.province)[0]?.value,
  },
}

Both live near the target field and superficially resemble configuring dependsOn on city and listening through scope.province. link still needs a listener declaration, then mutating rules and writing values back; computed reads the whole form data rather than a field-level dependency declaration, and has no current node. FormCreate has no stable dependency config dedicated to target fields; you switch among source update, target link, and computed, and all need ref for options to refresh.

List examples

Requirement: an insurance beneficiary list supporting dynamic add and delete of rows. Each row fills name, relationship, share; adding appends a record to the array, deleting removes the row by its index; view and data model stay in sync.

Ignoring styling, look at the key behaviors first:

JuanForm's effect

test6.gif

FormCreate's effect

test7.gif

JuanForm first. A list corresponds to an array in the data model. List sits in the first row's items; the row template is its children; the delete button sits on the same row as name, relationship, and share. The add button is not inside List; it's on the same level as List in the next row's items. Both are ordinary Buttons, using row-scope $parent and $index and the parent array to add/remove.

{
  children: [
    {
      items: [
        {
          prop: 'beneficiaries',
          type: 'List',
          children: [{
            items: [
              { prop: 'name', type: 'Input', ... },
              { prop: 'relation', type: 'Select', ... },
              { prop: 'share', type: 'Input', ... },
              {
                label: 'Delete',
                type: 'Button',
                props: {
                  onClick: {
                    handle: (scope) => {
                      scope.$parent.beneficiaries.splice(scope.$index, 1)
                    },
                  },
                },
              },
            ],
          }],
        },
      ],
    },
    {
      items: [
        {
          label: 'Add beneficiary',
          type: 'Button',
          props: {
            onClick: {
              handle: (scope) => {
                scope.beneficiaries.push({})
              },
            },
          },
        },
      ],
    },
  ],
}

Extensibility: add/delete are page fields, not built-in list-control behavior. Button position, text, visibility, and whether a click needs confirmation, all adjust through field config; you can also inject list utilities or keep using dependsOn at the row level. The later list-length and add-limit scenarios still follow this one scope chain, without a separate list API.

FormCreate instead places one group at the top of the rule array; row fields go in that rule's props.rule; add/remove are provided by the component's built-in button, so you can't write an add button outside the group like we can.

{
  type: 'group',
  field: 'beneficiaries',
  props: {
    rule: [
      { type: 'input', field: 'name', ... },
      { type: 'select', field: 'relation', ... },
      { type: 'input', field: 'share', ... },
    ],
    button: true,
    sortBtn: false,
  },
}

This wrapper is closed. Add/delete position and interaction are decided inside group; the config layer can't place the add button outside the list, nor replace delete with a custom handle. Each row isn't an independent scope node; to operate on a row from outside, you must again fetch the subform through the group's api — the same nesting as the cross-layer getSubForm above.

So for the same beneficiary list, JuanForm aligns with layers, rows, scope, and field config; FormCreate aligns with the built-in group component. Once customization exceeds what button and sortBtn provide, the config bends to the component's internals.

Dynamic row/column styles

Requirement: in the beneficiary list, set a background color for the whole row based on relationship; when share is greater than 3, the share column's background turns red.

Effects:

JuanForm's dynamic row/column styles

test8.gif

FormCreate's dynamic row/column styles

test9.gif

JuanForm first. Row and column styles both live on the List field beneficiaries, in that field's dependsOn, not on any one column's config. This config runs per row: node.current() is the current row node and writes the row background; node.at('share') reaches the row's share field and writes the column background. The listeners remain relation and share in row scope.

{
  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: [ ... ],
}

Extensibility: both row and column nodes are write targets for styling. To change color by other fields, add classes, or toggle whole-row visibility, you still use inject: ['node'] and scope checks — no extra layout fields needed for style.

FormCreate: group has no row node. Whole-row coloring and share-column coloring both go inside the group's props.rule: first add a native row layer with computed.style reading form.relation; then write computed.style on the share's col.

{
  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,
    ...
  },
}

Styles can only be written on layout nodes, not field nodes. To get a computable style, the list row is rewritten as row/col nesting. This differs from JuanForm writing props.style directly on row and column nodes: FormCreate stacks a grid structure for style, and config deepens as visual needs grow.

So for the same row/column coloring, JuanForm aligns with scope and nodes; FormCreate aligns with the layout wrappers added for style. Once group lacks a row node, customization bends to wherever computed can sit inside it.

List-length text hint

Requirement: when the beneficiary count exceeds 3, show a red hint reading "More than 3 beneficiaries" below the list; when the count returns to 3 or fewer, the hint disappears.

For the same requirement, JuanForm can use two config paths: one creates and deletes the hint node at runtime; the other pre-places the hint in the layout and uses if to control rendering. FormCreate has no runtime node-creation capability; the hint must be pre-written and its display controlled with hidden.

First: runtime node creation. The layout's children contain no hint. Unlike the row-style dependsOn above, this listens to the list's overall length; the config key is listDependsOn, on the List field beneficiaries. Above 3 people, node.add with { below: list } creates an Html node below the list; back at 3 or fewer, node.delete removes it. The add button shares the same row as List in items.

{
  children: [
    {
      items: [
        {
          prop: 'beneficiaries',
          type: 'List',
          listDependsOn: {
            inject: ['node'],
            handle: (scope, node) => {
              const tip = {
                id: 'beneficiaries-length-tip',
                type: 'Html',
                text: 'More than 3 beneficiaries',
                ...
              }
              const list = node.at('beneficiaries')
              if (scope.beneficiaries.length > 3) node.add(tip, { below: list })
              else node.delete(tip)
            },
          },
          children: [ ... ],
        },
        { label: 'Add beneficiary', type: 'Button', ... },
      ],
    },
  ],
}

This is runtime node creation: the hint isn't a field pre-declared in the schema; it's created and deleted by length condition. Insert position, text, and style remain ordinary field config.

Second: without dynamic creation, pre-write the Html in the next row's items and use if reading length. It doesn't render when the condition fails. List and the add button stay in the previous row.

{
  children: [
    {
      items: [
        { prop: 'beneficiaries', type: 'List', children: [ ... ] },
        { label: 'Add beneficiary', type: 'Button', ... },
      ],
    },
    {
      items: [
        {
          type: 'Html',
          text: 'More than 3 beneficiaries',
          if: (scope) => scope.beneficiaries.length > 3,
        },
      ],
    },
  ],
}

Both paths produce the same result; pick by scenario: use node.add when you need to insert content the layout doesn't originally contain by condition; pre-place and use if when the position is decided. The same requirement doesn't have to be one fixed style.

FormCreate: the official API has no interface to create or delete rule nodes at runtime. The hint must be pre-written in the rule array as the next item after group: a native div using computed.hidden reading length. fcRule needs ref.

[
  {
    type: 'group',
    field: 'beneficiaries',
    props: {
      rule: [ ... ],
      button: true,
      ...
    },
  },
  {
    type: 'div',
    native: true,
    children: ['More than 3 beneficiaries'],
    computed: {
      hidden: (form) => form.beneficiaries.length <= 3,
    },
  },
]

So for the same count-based hint, we have both runtime node creation and pre-placed if control, selectable by scenario. FormCreate can only pre-place and control with hidden, and it depends on the whole rule array being reactive.

Limiting add length

Requirement: at most 3 beneficiaries. When there are already 3, clicking add again must not append to the array.

JuanForm first. The cap lives in the add button's onClick.handle. The button sits on the same level as List, in the same row's items. The handle reads the array length, returns if it hits 3, otherwise pushes an empty record.

{
  children: [
    {
      items: [
        { prop: 'beneficiaries', type: 'List', children: [ ... ] },
        {
          label: 'Add beneficiary',
          type: 'Button',
          props: {
            onClick: {
              handle: (scope) => {
                const list = scope.beneficiaries
                if (list.length >= 3) return
                list.push({})
              },
            },
          },
        },
      ],
    },
  ],
}

Flexibility: the cap constrains the click handling of an ordinary button field, not a built-in count limit of the list control. Intercepting adds, hiding the button, changing the text, or adding a confirm all stay on that field's config — no separate list API.

FormCreate: add and delete are handled by the group's built-in button. The limit must be written on the group's props, on the same level as button: true, using the official onBeforeAdd: return false when length is 3. The button still displays; clicking no longer appends. The config can't rewrite button position nor turn it into field-level click handling.

{
  type: 'group',
  field: 'beneficiaries',
  props: {
    rule: [ ... ],
    button: true,
    onBeforeAdd: (list) => {
      if (list.length >= 3) return false
    },
  },
}

So for the same count limit, ours lives on the add button's click handling, sharing one scope chain with add/delete, with visibility, text, and confirm still configured on that button field; FormCreate's lives on the group component's built-in hook, with button visibility and position decided by the component internals.

Comparison summary

The previous sections compared the same set of linking requirements. Mechanically, the differences:

Aspect JuanForm FormCreate
Dependency location Written on the linked target fields. hidden, expression, dependsOn read scope fields, establishing the dependency. Mostly on source fields with control targeting others. When written on target fields, must pick among link, update, computed.
Runtime structure Layers, scope, nodes. Cross-layer uses node.at; each list row is a node; visibility mirrors Vue conditional rendering. Rule tree and instance api. Cross-layer uses getSubForm; lists correspond to a closed group component.
Replaceable implementations The same config patterns carry across cross-layer, lists, and runtime node creation. Hints can use node.add or pre-placed if. Beyond control, computed, group, only component hooks and layout remain.
Extensibility New links still go on hidden, expression, dependsOn; inject modules with inject. List buttons, hints, limits are fields, no second list API. New links usually mean changing interfaces: control, link, update, computed, or group's onBeforeAdd. When components run out, rewrite layout or call instance APIs.
Performance Schema is a plain object. Links listen only to fields actually read in scope; no need to make the whole config reactive. computed in practice needs the entire rule wrapped in ref. As fields and links grow, listeners on the config object become a burden.
Use cases Better for complex linking and larger field-config volumes, and can reach page level. Cross-layer, list rows, conditional node creation, button rewrites all stay on the same field config. Better for limited fields, source-controlled targets, standard forms where group's built-in add/remove suffices; scope mostly form-level.

Concrete config differences:

Item JuanForm FormCreate
Show/hide Configure hidden on target. The function reads scope fields; the engine creates or destroys nodes by those values and cleans the data model. Multiple fields sharing a condition use Html el: 'template' with if, matching <template v-if>. Use source control with method if or display. Or computed.hidden on target, but the whole rule needs ref. Hidden fields stay in form data unless ignoreHiddenFields is set.
Evaluation expression on the target field. computed.value, also needs ref.
Generic linking dependsOn reads scope for dependencies, inject brings tools like node. Layer, field, list share one style. No target-field dependency declaration; pick among update, link, control, computed.
Cross-layer write node.at('a.b.c') resolves a node by path and writes its value. Chained getSubForm calls. Before the subform initializes, it returns undefined. First write needs origin === 'init' plus api.nextTick; source shows this nextTick isn't Vue's but bindNextTick firing via setTimeout(..., 10).
In-list linking Row and column are nodes. Row style on node.current(), column style on node.at('share'). Add, delete, cap live in plain button click handling. group has no row node. Row/column styles need extra native row/col in props.rule. Add/delete/cap use built-in button and onBeforeAdd.
Conditional nodes Call node.add/node.delete in config to create/remove by relative position, or pre-place with if. Both satisfy the same requirement. Instance API offers append and removeRule to mutate the rule tree directly, not a node tool usable in field handle. Normally pre-write rules and control with hidden or computed.
How links take effect Plain-object schema; engine listens to fields actually read in scope. The whole rule must be reactive for computed to work. As links multiply, listeners on the config object become a burden.

Why choose JuanForm

JuanForm fits complex linking and larger field-config volumes better, and can reach page level. FormCreate fits stable, small standard forms better.

Linking is configured on target fields; the usual entries are hidden, expression, and dependsOn. Cross-layer node lookup, in-list style changes, adding and deleting rows, and conditional hints all use the same scope chain. The same requirement can use runtime node creation or if, without switching among multiple linking interfaces or making the entire rule array reactive. As fields grow and linking gets complex, the config still lives on these few entry points.

Comments

Top 1 from juejin.cn, machine-translated. The original thread is authoritative.

全栈小卷

An author spent 8 years developing a Vue low-code form, and then an AI analysis of the open-source code reveals a whole range of design and extensibility problems 🤣