生成式 UI 表单校验契约:动态联动规则与 Zod/JSON Schema 双向绑定
在企业级中后台、政企审批流以及低代码搭建平台中,动态表单生成(Dynamic Form Generation)一直是生成式 UI(Generative UI)最具商业价值的杀手级场景。业务人员输入一句“帮我生成一个支持跨境电商报关的申报表单,要求企业用户填报海关代码,个人用户仅需护照”,大模型瞬间就能输出几十个字段的输入结构。
然而,表单生成的“快乐”往往止步于点击提交的那一刻。
前端工程师面对的真实业务表单,从来不是若干个输入框的简单堆叠,而是充斥着错综复杂的条件联动(Conditional Dependencies)与动态校验规则:
- 当用户将“注册类型”切换为“企业”,下方的“统一社会信用代码”必须从隐藏变为显示,且必填校验规则动态生效;
- 当“申报金额”超过 5000 美元时,必须动态追加“外汇审批凭单号”字段,同时触发正则合法性校验。
很多初级实现为了图省事,允许大模型在 JSON 里返回诸如visibleWhen: "model.type === 'company'"这样的内联 JavaScript 代码字符串,然后在前端使用eval或new Function动态求值。这不仅直接撕开了灾难性的 XSS 安全口子,而且在大规模表单重排时会让主线程性能彻底崩溃。
打造工业级的生成式表单体系,核心在于建立一套安全、声明式、支持双向绑定的动态校验协议(Validation Contract)。
声明式联动协议设计:解耦代码执行与逻辑表达
我们要绝对禁止任何动态执行字符串的注入,将所有联动逻辑降维为可被静态解析的声明式表达式结构。
我们使用 Zod 来构建这份强类型的协议蓝图:
import{z}from'zod';// 基础比较操作符枚举exportconstConditionOperatorEnum=z.enum(['equals','notEquals','greaterThan','lessThan','in','notIn']);// 单个联动触发条件声明exportconstVisibilityConditionSchema=z.object({targetField:z.string().describe('依赖的目标字段名'),operator:ConditionOperatorEnum.describe('比较操作符'),expectedValue:z.union([z.string(),z.number(),z.boolean(),z.array(z.string())]),});// 动态字段元数据契约exportconstDynamicFormFieldSchema=z.object({name:z.string().min(1),label:z.string().min(1),component:z.enum(['Input','Select','NumberInput','DatePicker','Switch']),defaultValue:z.any().optional(),placeholder:z.string().optional(),// 选项数据源(针对下拉选框)options:z.array(z.object({label:z.string(),value:z.union([z.string(),z.number()])})).optional(),// 核心声明式联动规则:满足该条件时当前字段才可见visibleCondition:VisibilityConditionSchema.optional(),// 动态校验规则:必填与正则约束validation:z.object({required:z.boolean().default(false),requiredWhen:VisibilityConditionSchema.optional(),// 满足条件时才动态变为必填pattern:z.string().optional().describe('正则表达式字符串'),errorMessage:z.string().optional(),}).default({required:false}),});exportconstDynamicFormSchema=z.object({formId:z.string(),title:z.string(),fields:z.array(DynamicFormFieldSchema),});exporttypeDynamicFormField=z.infer<typeofDynamicFormFieldSchema>;exporttypeDynamicForm=z.infer<typeofDynamicFormSchema>;请仔细注意VisibilityConditionSchema的设计:它完全由纯数据组成。大模型只能组合使用合法的操作符(如equals、in)和目标字段名,从根源上杜绝了任何脚本注入的可能性。
安全的客户端条件求值器
在客户端,我们编写一个零依赖、零eval的纯函数求值器,负责在表单数据流转时,纳秒级判断某个字段是否应当显示或必填:
exportclassFormConditionEvaluator{// 核心求值入口staticisConditionMet(condition:z.infer<typeofVisibilityConditionSchema>|undefined,formData:Record<string,any>):boolean{if(!condition)returntrue;// 无条件则默认满足constactualValue=formData[condition.targetField];constexpected=condition.expectedValue;switch(condition.operator){case'equals':returnactualValue===expected;case'notEquals':returnactualValue!==expected;case'greaterThan':returntypeofactualValue==='number'&&typeofexpected==='number'&&actualValue>expected;case'lessThan':returntypeofactualValue==='number'&&typeofexpected==='number'&&actualValue<expected;case'in':returnArray.isArray(expected)&&expected.includes(actualValue);case'notIn':returnArray.isArray(expected)&&!expected.includes(actualValue);default:returnfalse;}}}没有动态作用域查找,没有 AST 解释开销,即便表单包含上百个联动字段,单次全量条件判定的耗时也严格控制在 0.1 毫秒以内。
Vue 3 动态表单组件的响应式绑定与脏数据清理
在表单呈现层,最容易被忽视的隐患是**“隐藏字段的脏数据残留”**:当用户选了“企业”,填了税号,随后又后悔改成了“个人”。此时税号输入框虽然在界面上隐藏了,但响应式表单对象里依然残留着刚才输入的税号。如果不加处理直接提交给后端,就会引发严重的业务脏数据污染。
我们实现一个具备隐藏自愈清理机制的动态表单驱动器:
<template> <form @submit.prevent="handleSubmit" class="dynamic-form-wrapper"> <h3 class="form-title">{{ schema.title }}</h3> <div v-for="field in activeFields" :key="field.name" class="form-item-row" > <label :class="{ 'is-required': isFieldRequired(field) }"> {{ field.label }} </label> <!-- 动态原子输入组件挂载 --> <input v-if="field.component === 'Input'" v-model="formModel[field.name]" :placeholder="field.placeholder" class="form-control" /> <select v-else-if="field.component === 'Select'" v-model="formModel[field.name]" class="form-control" > <option v-for="opt in field.options" :key="opt.value" :value="opt.value" > {{ opt.label }} </option> </select> <!-- 行内错误信息呈现 --> <span v-if="validationErrors[field.name]" class="error-tip"> {{ validationErrors[field.name] }} </span> </div> <button type="submit" class="submit-btn">立即提交</button> </form> </template> <script setup lang="ts"> import { reactive, computed, watch } from 'vue'; import { type DynamicForm, type DynamicFormField } from './schema'; import { FormConditionEvaluator } from './evaluator'; const props = defineProps<{ schema: DynamicForm; }>(); const emit = defineEmits<{ (e: 'submit', validData: Record<string, any>): void; }>(); // 响应式表单存储底座 const formModel = reactive<Record<string, any>>({}); const validationErrors = reactive<Record<string, string>>({}); // 初始化默认值 props.schema.fields.forEach((field) => { formModel[field.name] = field.defaultValue ?? ''; }); // 计算当前满足可见性条件的活跃字段子集 const activeFields = computed(() => { return props.schema.fields.filter((field) => { return FormConditionEvaluator.isConditionMet(field.visibleCondition, formModel); }); }); // 动态必填判定 function isFieldRequired(field: DynamicFormField): boolean { if (field.validation.required) return true; return FormConditionEvaluator.isConditionMet(field.validation.requiredWhen, formModel); } // 核心防御:当某字段从可见变为隐藏时,自动擦除其数据与校验报错(防止脏数据泄漏) watch(activeFields, (newActiveList) => { const activeNameSet = new Set(newActiveList.map(f => f.name)); props.schema.fields.forEach((field) => { if (!activeNameSet.has(field.name)) { // 字段已被隐藏,主动清理残留数据 if (formModel[field.name] !== undefined) { formModel[field.name] = field.defaultValue ?? ''; } delete validationErrors[field.name]; } }); }, { deep: true }); function handleSubmit() { // 清理上一轮报错 Object.keys(validationErrors).forEach(key => delete validationErrors[key]); let hasError = false; // 仅对当前可见的活跃字段执行校验 activeFields.value.forEach((field) => { const value = formModel[field.name]; const isReq = isFieldRequired(field); if (isReq && (value === '' || value === null || value === undefined)) { validationErrors[field.name] = `${field.label}不能为空`; hasError = true; return; } if (field.validation.pattern && value) { const reg = new RegExp(field.validation.pattern); if (!reg.test(String(value))) { validationErrors[field.name] = field.validation.errorMessage || '格式不正确'; hasError = true; } } }); if (!hasError) { // 仅提取可见字段的清洁数据向外派发 const cleanPayload: Record<string, any> = {}; activeFields.value.forEach(f => { cleanPayload[f.name] = formModel[f.name]; }); emit('submit', cleanPayload); } } </script> <style scoped> .dynamic-form-wrapper { max-width: 540px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 8px; } .form-item-row { margin-bottom: 16px; display: flex; flex-direction: column; gap: 6px; } .form-control { padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px; } .is-required::after { content: ' *'; color: #ff4d4f; } .error-tip { font-size: 12px; color: #ff4d4f; } .submit-btn { padding: 10px 20px; background: #1677ff; color: white; border: none; border-radius: 4px; cursor: pointer; } </style>两项生产落地的架构避坑指南
- 防范深层级联引发的循环依赖(Dependency Loops):在大模型生成复杂的四五层级联时,可能会发生 A 依赖 B,B 依赖 C,C 反向依赖 A 的荒唐闭环。在解析大模型返回的 Schema 时,必须在前端运行时先对所有的
targetField运行一次有向无环图(DAG)拓扑检测,一旦发现闭环,强制将后置依赖断开,防止computed触发无限响应式递归。 - 正则表达式的安全防线(ReDoS 拦截):大模型偶尔会输出存在回溯炸弹的低效正则表达式(如
^(a+)+$)。在前端将字符串转为new RegExp()之前,应当使用轻量级的正则安全性检测工具,或者对正则匹配过程设置超时限制,防止用户在输入一个特定字符串时瞬间锁死浏览器主线程。
用确定的数据结构驯服不确定的生成内容,把复杂的联动逻辑沉淀为干净的声明式契约。这才是前端手艺人在生成式 UI 时代不可替代的工程壁垒。