跪拜 Guibai
← Back to the summary

Schema-Driven Validation in Vue 3: vue-juan-form Puts Rules in the Schema, Not the Handlers

Schema-Driven Programming with a Vue 3 Page-Level Rendering Engine: Validation for Every Scenario!

👉 Companion code: Github - vue-juan-form-examples

With the Vue 3 stack, few UI frameworks or low-code platforms make validation both very simple and flexibly extensible. So Xiaojuan proudly claims: our vue-juan-form does it! Enough talk, let's get to examples.

Integrating vue-juan-form

First, ensure the project has Vue 3 and Element Plus installed, because v1 of this framework only supports Element Plus; built-in components explicitly import the relevant UI components on demand. Install the dependency:

npm i vue-juan-form

Import styles in entry main.ts:

...
import 'vue-juan-form/style.css'
...

Using the component in a Vue page is simple:

<script setup lang="ts">
import JuanForm from 'vue-juan-form'
import { schema } from '@/schemas/validation/test-01.ts'
</script>

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

All example pages share the same structure; only the schema differs. Let's look at basic validation.

Basic Validation

The rendered page shows asterisks on required-field labels.

20261111182522.png

No error messages appear on initialization. While editing a field, only that field is validated; on submit, the whole form is checked. Reset clears data and error messages.

Fields carry a validation property. Multiple rules are separated by |, rule name and parameters by :, and multiple parameters by ,. The submit button sets nativeType: 'submit' to check the whole form. Reset injects form and calls reset().

Schema:

import type { FormApi, FormSchema, ScopeState } from 'vue-juan-form'

export const schema: FormSchema = {
  children: [
    {
      items: [
        {
          prop: 'name',
          label: '姓名',
          type: 'Input',
          props: { placeholder: '请输入姓名' },
          validation: 'required',
        },
        {
          prop: 'email',
          label: '邮箱',
          type: 'Input',
          props: { placeholder: '请输入邮箱' },
          validation: 'required|email',
        },
      ],
    },
    {
      items: [
        {
          prop: 'age',
          label: '年龄',
          type: 'Input',
          props: { type: 'number', placeholder: '18~60' },
          validation: 'min:18|max:60',
        },
        {
          prop: 'code',
          label: '验证码',
          type: 'Input',
          props: { placeholder: '4 位' },
          validation: 'required|length:4,4',
        },
        {
          prop: 'mobile',
          label: '手机',
          type: 'Input',
          props: { placeholder: '11 位手机号' },
          validation: 'required|matches:/^1[3-9]\d{9}$/',
        },
      ],
    },
    {
      items: [
        {
          type: 'Html',
          col: 'auto',
          plain: true,
          attrs: { style: { justifyContent: 'flex-end', gap: '8px' } },
          children: [
            {
              items: [
                {
                  label: '提交',
                  type: 'Button',
                  props: { type: 'primary', nativeType: 'submit' },
                },
                {
                  label: '重置',
                  type: 'Button',
                  props: {
                    nativeType: 'button',
                    onClick: {
                      inject: ['form'],
                      handle: (_scope: ScopeState, form: FormApi) => {
                        form.reset()
                      },
                    },
                  },
                },
              ],
            },
          ],
        },
      ],
    },
  ],
}

Grid: children is a layer, each item is a row, items are the fields in that row. Row cols defaults to 16; field col defaults to auto, equally distributed. Submit and reset are placed in the same Html with plain: true and col: 'auto', putting both buttons side by side and right-aligned.

Custom Validation

When built-in rules are not enough, write validationRules on the field and throw RuleError on failure.

Confirm Password

Look at the scenario to implement:

test29.gif

Key config: the confirm password field uses validation: 'required|same', and validationRules.same compares the two fields, throwing RuleError on mismatch. Both values must be non-empty before comparison. The rule is attached to one field; changing either password or confirm re-runs it.

Schema:

import { RuleError, type FormSchema, type ScopeState } from 'vue-juan-form'

export const schema: FormSchema = {
  children: [
    {
      items: [
        {
          prop: 'password',
          label: '密码',
          labelWidth: '5em',
          type: 'Input',
          props: { type: 'password', placeholder: '请输入密码' },
          validation: 'required',
        },
        {
          prop: 'confirm',
          label: '确认密码',
          labelWidth: '5em',
          type: 'Input',
          props: { type: 'password', placeholder: '请再输入一次' },
          validation: 'required|same',
          validationRules: {
            same(scope: ScopeState) {
              if (!scope.password || !scope.confirm) return
              if (scope.password !== scope.confirm) throw new RuleError('两次输入的密码不一致')
            },
          },
        },
      ],
    },
    // Submit/reset: Html with plain: true and col: 'auto', two buttons right-aligned. Same as the buttons row in basic validation, so omitted
  ],
}

The above is an inline rule in one table. For reuse across multiple places, register once with defineRule. The first argument of handle is still the scope; current value and parameters after : in validation are read by injecting validationContext. This layer reads the scope directly; cross-layer uses $parent and $root. Write validation: 'same:password' to compare with the password field.

import { defineRule, RuleError, type FormSchema, type ScopeState, type ValidationContext } from 'vue-juan-form'

defineRule('same', {
  inject: ['validationContext'],
  handle: (scope: ScopeState, v: ValidationContext) => {
    const other = scope[v.args[0]!]
    if (!v.value || !other) return
    if (v.value !== other) throw new RuleError('两次输入的密码不一致')
  },
})

export const schema: FormSchema = {
  children: [
    {
      items: [
        // password field: prop password, validation required. Omitted, same as inline rule section
        {
          prop: 'confirm',
          label: '确认密码',
          labelWidth: '5em',
          type: 'Input',
          props: { type: 'password', placeholder: '请再输入一次' },
          validation: 'required|same:password',
        },
      ],
    },
    // Submit/reset: same as above
  ],
}

Validation: Not All Empty

The username, email, and mobile number cannot all be empty. Look at the scenario:

test30.gif

Key config: the rule is written on username, validation: 'length:2,16|+notAllEmpty'. The + means the rule still runs when the current value is empty. Email and mobile only validate with email and matches when they have a value.

Schema:

import { RuleError, type FormSchema, type ScopeState } from 'vue-juan-form'

export const schema: FormSchema = {
  children: [
    {
      items: [
        {
          prop: 'username',
          label: '用户名',
          type: 'Input',
          props: { placeholder: '请输入用户名' },
          validation: 'length:2,16|+notAllEmpty',
          validationRules: {
            notAllEmpty(scope: ScopeState) {
              if (!scope.username && !scope.email && !scope.phone) {
                throw new RuleError('用户名、邮箱、手机号不能都为空')
              }
            },
          },
        },
        {
          prop: 'email',
          label: '邮箱',
          type: 'Input',
          props: { placeholder: '请输入邮箱' },
          validation: 'email',
        },
        {
          prop: 'phone',
          label: '手机号',
          type: 'Input',
          props: { placeholder: '请输入手机号' },
          validation: 'matches:/^1[3-9]\d{9}$/',
        },
      ],
    },
    // Submit/reset: same as before
  ],
}

For reuse, also use defineRule. Write validation: '+notAllEmpty:email,phone', and the parameter names read the email and phone from the scope.

import { defineRule, RuleError, type FormSchema, type ScopeState, type ValidationContext } from 'vue-juan-form'

defineRule('notAllEmpty', {
  inject: ['validationContext'],
  handle: (scope: ScopeState, v: ValidationContext) => {
    if (!v.value && v.args.every((name) => !scope[name])) {
      throw new RuleError('用户名、邮箱、手机号不能都为空')
    }
  },
})

export const schema: FormSchema = {
  children: [
    {
      items: [
        {
          prop: 'username',
          label: '用户名',
          type: 'Input',
          props: { placeholder: '请输入用户名' },
          validation: 'length:2,16|+notAllEmpty:email,phone',
        },
        // email and phone: validation as above, omitted
      ],
    },
    // Submit/reset: same
  ],
}

Layer-Based Validation

Whole-form submission was covered earlier: a nativeType: 'submit' button checks the entire JuanForm. This section has each layer submit itself; inject layer and call check(), which only checks the current layer.

The applicant and insured each form a group with its own submit button. The beneficiary is edited by clicking Modify in a list, then submitting inside a dialog. The three submissions check their respective layers.

Look at the scenario:

test31.gif

test32.gif

Key config: group buttons have nativeType: 'button', onClick injects layer and calls layer.check(). Don't use submit, or the whole form gets checked. The dialog's confirm button checks the dialog layer.

Schema:

import type { DialogApi, FormSchema, LayerApi, ScopeState } from 'vue-juan-form'

const relations = [
  { label: '配偶', value: 'spouse' },
  { label: '子女', value: 'child' },
  { label: '父母', value: 'parent' },
]

export const schema: FormSchema = {
  children: [
    {
      items: [
        {
          prop: 'applicant',
          label: '投保人',
          type: 'Group',
          children: [
            {
              items: [
                {
                  prop: 'name',
                  label: '姓名',
                  type: 'Input',
                  props: { placeholder: '请输入投保人姓名' },
                  validation: 'required',
                },
              ],
            },
            {
              items: [
                {
                  label: '提交',
                  type: 'Button',
                  props: {
                    type: 'primary',
                    nativeType: 'button',
                    style: { textAlign: 'right' },
                    onClick: {
                      inject: ['layer'],
                      handle: (scope: ScopeState, layer: LayerApi) => {
                        if (layer.check()) console.log('投保人通过', scope.name)
                      },
                    },
                  },
                },
              ],
            },
          ],
        },
      ],
    },
    {
      items: [
        {
          prop: 'insured',
          label: '被保人',
          type: 'Group',
          children: [
            {
              items: [
                {
                  prop: 'name',
                  label: '姓名',
                  type: 'Input',
                  props: { placeholder: '请输入被保人姓名' },
                  validation: 'required',
                },
              ],
            },
            {
              items: [
                {
                  label: '提交',
                  type: 'Button',
                  props: {
                    type: 'primary',
                    nativeType: 'button',
                    style: { textAlign: 'right' },
                    onClick: {
                      inject: ['layer'],
                      handle: (scope: ScopeState, layer: LayerApi) => {
                        if (layer.check()) console.log('被保人通过', scope.name)
                      },
                    },
                  },
                },
              ],
            },
          ],
        },
      ],
    },
    {
      items: [
        {
          prop: 'beneficiaries',
          label: '受益人',
          type: 'List',
          col: 'auto',
          key: 'id',
          children: [
            {
              items: [
                {
                  prop: 'name',
                  label: '姓名',
                  type: 'Input',
                  col: 6,
                  props: { placeholder: '受益人姓名' },
                },
                {
                  prop: 'relation',
                  label: '关系',
                  type: 'Select',
                  col: 6,
                  props: {
                    placeholder: '请选择关系',
                    clearable: true,
                    options: relations,
                  },
                },
                {
                  label: '修改',
                  type: 'Button',
                  col: 4,
                  props: {
                    nativeType: 'button',
                    onClick: {
                      inject: ['dialog'],
                      handle: (_scope: ScopeState, dialog: DialogApi) => {
                        dialog.open('editBeneficiary')
                      },
                    },
                  },
                },
              ],
            },
          ],
        },
        {
          name: 'editBeneficiary',
          type: 'Dialog',
          label: '修改受益人',
          children: [
            {
              items: [
                {
                  prop: 'name',
                  label: '姓名',
                  type: 'Input',
                  props: { placeholder: '受益人姓名' },
                  validation: 'required',
                },
                {
                  prop: 'relation',
                  label: '关系',
                  type: 'Select',
                  props: {
                    placeholder: '请选择关系',
                    clearable: true,
                    options: relations,
                  },
                  validation: 'required',
                },
              ],
            },
          ],
        },
      ],
    },
    {
      items: [
        {
          label: '添加受益人',
          type: 'Button',
          props: {
            nativeType: 'button',
            onClick: {
              handle: (scope: ScopeState) => {
                const list = scope.beneficiaries as Record<string, unknown>[]
                list.push({})
              },
            },
          },
        },
      ],
    },
  ],
}

Row-Level Inline Validation

For inline editing in lists and tables, validation still lives in the field's validation: name required, phone matches. The engine shows the error messages itself. The row's check button injects layer and calls check(), which only checks that row. The table's add button sits above the table.

Look at the scenario:

test33.gif

test34.gif

Schema:

import type { FormSchema, LayerApi, ScopeState } from 'vue-juan-form'

const phone = 'matches:/^1[3-9]\d{9}$/'

function checkRow(_scope: ScopeState, layer: LayerApi) {
  layer.check()
}

function addRow(scope: ScopeState, prop: string) {
  const list = scope[prop] as Record<string, unknown>[]
  list.push({})
}

export const schema: FormSchema = {
  children: [
    {
      items: [
        {
          prop: 'listContacts',
          label: '列表',
          type: 'List',
          col: 'auto',
          key: 'id',
          children: [
            {
              items: [
                {
                  prop: 'name',
                  label: '姓名',
                  type: 'Input',
                  col: 6,
                  props: { placeholder: '请输入姓名' },
                  validation: 'required',
                },
                {
                  prop: 'phone',
                  label: '电话',
                  type: 'Input',
                  col: 6,
                  props: { placeholder: '11 位手机号' },
                  validation: phone,
                },
                {
                  label: '检查',
                  type: 'Button',
                  col: 4,
                  props: {
                    nativeType: 'button',
                    onClick: { inject: ['layer'], handle: checkRow },
                  },
                },
              ],
            },
          ],
        },
      ],
    },
    {
      items: [
        {
          label: '添加一行',
          type: 'Button',
          props: {
            nativeType: 'button',
            onClick: {
              handle: (scope: ScopeState) => addRow(scope, 'listContacts'),
            },
          },
        },
      ],
    },
    {
      items: [
        {
          label: '添加一行',
          type: 'Button',
          props: {
            nativeType: 'button',
            onClick: {
              handle: (scope: ScopeState) => addRow(scope, 'tableContacts'),
            },
          },
        },
      ],
    },
    {
      items: [
        {
          prop: 'tableContacts',
          label: '表格',
          type: 'Table',
          col: 'auto',
          key: 'id',
          props: { border: true, style: { width: '100%' } },
          columns: [
            {
              prop: 'name',
              label: '姓名',
              type: 'Input',
              minWidth: 160,
              props: { placeholder: '请输入姓名' },
              validation: 'required',
            },
            {
              prop: 'phone',
              label: '电话',
              type: 'Input',
              minWidth: 180,
              props: { placeholder: '11 位手机号' },
              validation: phone,
            },
            {
              label: '检查',
              width: 100,
              custom: {
                items: [
                  {
                    label: '检查',
                    type: 'Button',
                    props: {
                      nativeType: 'button',
                      onClick: { inject: ['layer'], handle: checkRow },
                    },
                  },
                ],
              },
            },
          ],
        },
      ],
    },
  ],
}

Outer Validation with Nesting

Groups and rows checking themselves were covered earlier. The outermost submit also injects layer and calls check(), same as layer validation. With the button written on the outermost layer, the current layer is the whole form.

Look at the scenario:

test35.gif

Schema:

import type { FormApi, FormSchema, LayerApi, ScopeState } from 'vue-juan-form'

const phone = 'matches:/^1[3-9]\d{9}$/'

function checkRow(_scope: ScopeState, layer: LayerApi) {
  layer.check()
}

export const schema: FormSchema = {
  children: [
    {
      items: [
        {
          prop: 'applicant',
          label: '投保人',
          type: 'Group',
          children: [
            {
              items: [
                {
                  prop: 'name',
                  label: '姓名',
                  type: 'Input',
                  props: { placeholder: '请输入投保人姓名' },
                  validation: 'required',
                },
              ],
            },
            {
              items: [
                {
                  type: 'Html',
                  col: 'auto',
                  plain: true,
                  attrs: { style: { justifyContent: 'flex-end', gap: '8px' } },
                  children: [
                    {
                      items: [
                        {
                          label: '提交',
                          type: 'Button',
                          props: {
                            type: 'primary',
                            nativeType: 'button',
                            onClick: {
                              inject: ['layer'],
                              handle: (scope: ScopeState, layer: LayerApi) => {
                                if (layer.check()) console.log('投保人通过', scope.name)
                              },
                            },
                          },
                        },
                        {
                          label: '重置',
                          type: 'Button',
                          props: {
                            nativeType: 'button',
                            onClick: {
                              inject: ['form'],
                              handle: (_scope: ScopeState, form: FormApi) => {
                                form.reset()
                              },
                            },
                          },
                        },
                      ],
                    },
                  ],
                },
              ],
            },
          ],
        },
      ],
    },
    {
      items: [
        {
          label: '添加一行',
          type: 'Button',
          props: {
            nativeType: 'button',
            onClick: {
              handle: (scope: ScopeState) => {
                const list = scope.contacts as Record<string, unknown>[]
                list.push({})
              },
            },
          },
        },
      ],
    },
    {
      items: [
        {
          prop: 'contacts',
          label: '表格',
          type: 'Table',
          col: 'auto',
          key: 'id',
          props: { border: true, style: { width: '100%' } },
          columns: [
            {
              prop: 'name',
              label: '姓名',
              type: 'Input',
              minWidth: 160,
              props: { placeholder: '请输入姓名' },
              validation: 'required',
            },
            {
              prop: 'phone',
              label: '电话',
              type: 'Input',
              minWidth: 180,
              props: { placeholder: '11 位手机号' },
              validation: phone,
            },
            {
              label: '检查',
              width: 100,
              custom: {
                items: [
                  {
                    label: '检查',
                    type: 'Button',
                    props: {
                      nativeType: 'button',
                      onClick: { inject: ['layer'], handle: checkRow },
                    },
                  },
                ],
              },
            },
          ],
        },
      ],
    },
    {
      items: [
        {
          label: '提交',
          type: 'Button',
          props: {
            type: 'primary',
            nativeType: 'button',
            style: { textAlign: 'right' },
            onClick: {
              inject: ['layer'],
              handle: (_scope: ScopeState, layer: LayerApi) => {
                if (layer.check()) console.log('总提交通过')
              },
            },
          },
        },
      ],
    },
  ],
}

Cross-Row Duplicate Check

Names in the table cannot repeat. When the name is empty, the duplicate check is skipped. The rule still lives on the name's validation.

Look at the scenario:

test36.gif

Key config: name uses validation: '+unique'. The rule injects node, find fetches cells with the same name, sets their background color, and throws RuleError. The + makes the rule run even when the value is empty, which clears the background. The add button sits above the table.

Schema:

import { RuleError, type FormSchema, type NodeApi, type ScopeState } from 'vue-juan-form'

export const schema: FormSchema = {
  children: [
    {
      items: [
        {
          label: '添加一行',
          type: 'Button',
          props: {
            nativeType: 'button',
            onClick: {
              handle: (scope: ScopeState) => {
                const list = scope.beneficiaries as Record<string, unknown>[]
                list.push({})
              },
            },
          },
        },
      ],
    },
    {
      items: [
        {
          prop: 'beneficiaries',
          label: '受益人',
          type: 'Table',
          col: 'auto',
          key: 'id',
          props: { border: true, style: { width: '100%' } },
          columns: [
            {
              prop: 'name',
              label: '姓名',
              type: 'Input',
              minWidth: 200,
              props: { placeholder: '请输入姓名' },
              validation: '+unique',
              validationRules: {
                unique: {
                  inject: ['node'],
                  handle(scope: ScopeState, node: NodeApi) {
                    const cells = node.find('$parent.beneficiaries', { name: scope.name }, 'name')
                    if (!scope.name || cells.length < 2) {
                      for (const cell of cells) cell.props.style = undefined
                      return
                    }
                    let n = 0
                    for (const ch of String(scope.name)) n = n * 31 + ch.charCodeAt(0)
                    for (const cell of cells) cell.props.style = { background: `hsl(${(n * 137) % 360} 70% 90%)` }
                    throw new RuleError('受益人重复')
                  },
                },
              },
            },
            {
              prop: 'share',
              label: '份额',
              type: 'Input',
              minWidth: 120,
              props: { type: 'number', min: 1, max: 10, placeholder: '份额' },
              defaultValue: 1,
            },
          ],
        },
      ],
    },
  ],
}

Table-Level Validation

The sum of each row's share cannot exceed 10. Validation is written on the table's validation, same as fields, with no extra input box. When the total exceeds 10, the add button stops adding rows.

Look at the scenario:

test37.gif

Key config: table uses validation: 'cap', and validationRules.cap sums the array, throwing RuleError when over 10. The add button is above the table; its handle reads the same total and returns if it exceeds 10.

Schema:

import { RuleError, type FormSchema, type ScopeState } from 'vue-juan-form'

export const schema: FormSchema = {
  children: [
    {
      items: [
        {
          label: '添加一行',
          type: 'Button',
          props: {
            nativeType: 'button',
            onClick: {
              handle: (scope: ScopeState) => {
                const list = scope.beneficiaries as Record<string, unknown>[]
                if (list.reduce((n, row) => n + Number(row.share || 0), 0) > 10) return
                list.push({})
              },
            },
          },
        },
      ],
    },
    {
      items: [
        {
          prop: 'beneficiaries',
          label: '受益人',
          type: 'Table',
          col: 'auto',
          key: 'id',
          props: { border: true, style: { width: '100%' } },
          validation: 'cap',
          validationRules: {
            cap(scope: ScopeState) {
              const list = scope.beneficiaries as Record<string, unknown>[]
              if (list.reduce((n, row) => n + Number(row.share || 0), 0) > 10) {
                throw new RuleError('总份额不能超过10')
              }
            },
          },
          columns: [
            {
              prop: 'name',
              label: '姓名',
              type: 'Input',
              minWidth: 160,
              props: { placeholder: '请输入姓名' },
            },
            {
              prop: 'share',
              label: '份额',
              type: 'Input',
              minWidth: 120,
              props: { type: 'number', min: 1, max: 10, placeholder: '份额' },
              defaultValue: 1,
            },
          ],
        },
      ],
    },
  ],
}

Automatic Error Positioning

When a submit check fails, the engine automatically scrolls to the first invalid field and focuses its input. No new config is needed; both default submit and layer check() behave this way.

Look at the scenario:

test38.gif

Schema:

import type { FormSchema } from 'vue-juan-form'

export const schema: FormSchema = {
  children: [
    // Outer Html limits height and adds spacing for demo only, unrelated to positioning, omitted
    {
      items: [
        {
          prop: 'name',
          label: '姓名',
          type: 'Input',
          props: { placeholder: '请输入姓名' },
          validation: 'required',
        },
      ],
    },
    {
      items: [
        {
          prop: 'email',
          label: '邮箱',
          type: 'Input',
          props: { placeholder: '请输入邮箱' },
          validation: 'required|email',
        },
      ],
    },
    {
      items: [
        {
          prop: 'phone',
          label: '手机',
          type: 'Input',
          props: { placeholder: '请输入手机号' },
          validation: 'required',
        },
      ],
    },
    {
      items: [
        {
          label: '提交',
          type: 'Button',
          props: { type: 'primary', nativeType: 'submit', style: { textAlign: 'right' } },
        },
      ],
    },
  ],
}

That covers the validation scenarios. Feel free to try them yourself. If you hit a new validation requirement you can't solve, find me in the comments. Your suggestions and feedback will be incorporated into future versions of vue-juan-form.

👉 Companion code: Github - vue-juan-form-examples

Comments

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

用户97681674246

Is there no source code?

全栈小卷

To avoid commercial disputes, the core source code of the engine is not open-sourced for now 😂. Any needs are welcome; our engine will meet user customization requirements to the greatest extent, unlike comparable open-source libraries on the market. [Fighting]