Vue 3 动态表单的实现原理与三种场景实战
!IMPORTANT
精华总结:
动态表单的实现原理可以归纳为四件事:定义 schema、按 schema 渲染、维护双向数据流、根据 schema 派生衍生数据。
四件事的对应工具:
- 定义 schema → 用 TypeScript 字面量联合声明字段类型与配置
- 按 schema 渲染 →
v-for+v-if/v-else-if分支器分发到element-plus控件- 双向数据流 → 用
defineModel让父子组件共享同一个对象引用,避免循环watch- 派生衍生数据 → 用
computed把 schema 自动转换为校验规则、可见字段、默认值等同一套原理可以覆盖三种典型业务场景:可视化字段编辑、业务联动表单、通用搜索筛选器。
本文记录我在一个 Vue 3 项目里实现动态表单的全过程。目标不是写一个"能跑就行"的 demo,而是把动态表单背后的一套原理讲透,并通过三个真实业务场景说明同一套原理可以怎么应用。
如果你做过后台系统,一定遇到过这类问题:表单字段从接口来、字段之间有联动、十几张列表页都长得差不多。本文要回答的就是:这些问题背后是不是同一回事?我们能不能用同一套思路覆盖它们?
配套代码全部在这个仓库里:
txt
src/views/
├── dynamic-forms/ # 场景一:可视化字段编辑器
├── complex-form/ # 场景二:业务联动表单
└── search-filter/ # 场景三:通用搜索筛选器
三个页面都接入了 vue-router,路由分别是 /dynamicForms、/complexForm、/searchFilter。技术栈是 Vue 3.5 + TypeScript + Element Plus + SCSS,跑 npm run s 即可启动。
动态表单的定义
"动态"这个词容易被滥用。在我看来,动态表单有两层含义:
==字段动态:==字段不是写死在模板里的,而是由一份数据描述。这份数据可以来自前端硬编码、来自后端接口,也可以由运营在低代码平台里拖出来。前端要做的只有一件事:把这份数据渲染成表单。
==关系动态:==字段之间不是孤立的:选一级分类才能选二级;启用规格之后单价/库存就要隐藏;选了"包邮"就不用填运费。这些规则使表单内部存在一个看不见的状态机,每一次用户操作都可能让 UI 重新组装。
很多人讨论"动态表单"时只看到第一层,把它当成"渲染器"。但真实业务里更折磨人的是第二层。本文要把这两层都覆盖到。
在开始前,先约定一下三个目标画面:
==/dynamicForms:==左侧可视化编辑字段、右侧实时渲染。改 schema、加字段、改类型,右侧立刻跟上。
==/complexForm:==商品发布表单。三级分类异步级联、规格表动态行、促销分支切换、依赖型校验,业务里能想到的联动几乎都有。
==/searchFilter:==订单列表的查询面板。基础筛选 + 高级筛选展开、已选条件 chip、模拟接口分页加载。
这三个场景看似不同,背后是同一套原理。先用一张图把三个场景的位置感建立起来:
#mermaid-svg-xXZ0VLaNqWTgcKHd{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-xXZ0VLaNqWTgcKHd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-xXZ0VLaNqWTgcKHd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-xXZ0VLaNqWTgcKHd .error-icon{fill:#552222;}#mermaid-svg-xXZ0VLaNqWTgcKHd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-xXZ0VLaNqWTgcKHd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-xXZ0VLaNqWTgcKHd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-xXZ0VLaNqWTgcKHd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-xXZ0VLaNqWTgcKHd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-xXZ0VLaNqWTgcKHd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-xXZ0VLaNqWTgcKHd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-xXZ0VLaNqWTgcKHd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-xXZ0VLaNqWTgcKHd .marker.cross{stroke:#333333;}#mermaid-svg-xXZ0VLaNqWTgcKHd svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-xXZ0VLaNqWTgcKHd p{margin:0;}#mermaid-svg-xXZ0VLaNqWTgcKHd .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-xXZ0VLaNqWTgcKHd .cluster-label text{fill:#333;}#mermaid-svg-xXZ0VLaNqWTgcKHd .cluster-label span{color:#333;}#mermaid-svg-xXZ0VLaNqWTgcKHd .cluster-label span p{background-color:transparent;}#mermaid-svg-xXZ0VLaNqWTgcKHd .label text,#mermaid-svg-xXZ0VLaNqWTgcKHd span{fill:#333;color:#333;}#mermaid-svg-xXZ0VLaNqWTgcKHd .node rect,#mermaid-svg-xXZ0VLaNqWTgcKHd .node circle,#mermaid-svg-xXZ0VLaNqWTgcKHd .node ellipse,#mermaid-svg-xXZ0VLaNqWTgcKHd .node polygon,#mermaid-svg-xXZ0VLaNqWTgcKHd .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-xXZ0VLaNqWTgcKHd .rough-node .label text,#mermaid-svg-xXZ0VLaNqWTgcKHd .node .label text,#mermaid-svg-xXZ0VLaNqWTgcKHd .image-shape .label,#mermaid-svg-xXZ0VLaNqWTgcKHd .icon-shape .label{text-anchor:middle;}#mermaid-svg-xXZ0VLaNqWTgcKHd .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-xXZ0VLaNqWTgcKHd .rough-node .label,#mermaid-svg-xXZ0VLaNqWTgcKHd .node .label,#mermaid-svg-xXZ0VLaNqWTgcKHd .image-shape .label,#mermaid-svg-xXZ0VLaNqWTgcKHd .icon-shape .label{text-align:center;}#mermaid-svg-xXZ0VLaNqWTgcKHd .node.clickable{cursor:pointer;}#mermaid-svg-xXZ0VLaNqWTgcKHd .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-xXZ0VLaNqWTgcKHd .arrowheadPath{fill:#333333;}#mermaid-svg-xXZ0VLaNqWTgcKHd .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-xXZ0VLaNqWTgcKHd .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-xXZ0VLaNqWTgcKHd .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xXZ0VLaNqWTgcKHd .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-xXZ0VLaNqWTgcKHd .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xXZ0VLaNqWTgcKHd .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-xXZ0VLaNqWTgcKHd .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-xXZ0VLaNqWTgcKHd .cluster text{fill:#333;}#mermaid-svg-xXZ0VLaNqWTgcKHd .cluster span{color:#333;}#mermaid-svg-xXZ0VLaNqWTgcKHd div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-xXZ0VLaNqWTgcKHd .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-xXZ0VLaNqWTgcKHd rect.text{fill:none;stroke-width:0;}#mermaid-svg-xXZ0VLaNqWTgcKHd .icon-shape,#mermaid-svg-xXZ0VLaNqWTgcKHd .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xXZ0VLaNqWTgcKHd .icon-shape p,#mermaid-svg-xXZ0VLaNqWTgcKHd .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-xXZ0VLaNqWTgcKHd .icon-shape .label rect,#mermaid-svg-xXZ0VLaNqWTgcKHd .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xXZ0VLaNqWTgcKHd .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-xXZ0VLaNqWTgcKHd .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-xXZ0VLaNqWTgcKHd :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 核心原理:schema 驱动
FieldSchema\[\] / FilterField\[\]
(纯数据)
渲染器
(v-if 分支器)
派生数据
(computed)
双向数据流
(defineModel)
场景一
dynamic-forms
「配置即数据」
schema 被编辑
场景二
complex-form
「业务规则显式化」
watch / computed 描述联动
场景三
search-filter
「组件可复用」
schema 是接口契约
底部三个场景是同一原理的不同应用------读完「核心原理」一章你就理解了上半部分,读完后续三个场景章节你会看清下半部分。下面先把原理拆出来。
核心原理
抛开具体业务,动态表单的实现可以归纳为四件事:定义 schema、按 schema 渲染、维护双向数据流、根据 schema 派生衍生数据(校验规则、默认值)。
下面四个小节分别说。
Schema 设计
新手最常见的错误是上来就写组件。但表单类工具的核心是数据结构。如果 schema 设计得好,后面写组件几乎是体力活;如果 schema 一开始就有缺陷,后面会一直补丁打补丁。
我在 dynamic-forms 项目里定义的 FieldSchema 是这样:
24:60:src/views/dynamic-forms/lib/types.ts
/** 当前支持的字段类型 */
export type FieldType =
| "input"
| "textarea"
| "number"
| "select"
| "radio"
| "checkbox"
| "switch"
| "slider"
| "date"
| "time";
/** 下拉框 / 单选 / 多选 等的选项 */
export interface FieldOption {
label: string;
value: string | number | boolean;
}
/** 单个字段的配置 */
export interface FieldSchema {
type: FieldType;
prop: string;
label: string;
placeholder?: string;
defaultValue?: any;
required?: boolean;
disabled?: boolean;
options?: FieldOption[];
min?: number;
max?: number;
step?: number;
rows?: number;
span?: number;
rules?: FormItemRule | FormItemRule[];
}
几个值得说明的设计取舍:
==type 用字面量联合而不是 string:==写 schema 时编辑器能直接补全,多打一个字符立刻报错。
==span 控制布局:==表单本质就是栅格场景,把布局信息塞进 schema 比配模板简单很多。
==rules 允许扩展:==内置规则只处理 required,复杂规则让业务自己写 validator 函数。JSON 表达不了函数,承认这件事而不是硬把函数塞进 schema,是务实的选择。
类似的,搜索筛选场景下我又写了一个 FilterField:
12:43:src/views/search-filter/lib/types.ts
export type FilterFieldType =
| "input"
| "select"
| "multiSelect"
| "dateRange"
| "numberRange"
| "switch";
export interface FilterOption {
label: string;
value: string | number | boolean;
}
export interface FilterField {
/** 控件类型 */
type: FilterFieldType;
/** 数据 key */
prop: string;
/** 表单标签 */
label: string;
/** 占位符(input / select) */
placeholder?: string;
/** 下拉/多选选项 */
options?: FilterOption[];
/** 是否归入"高级筛选"(默认 false,显示在基础区) */
advanced?: boolean;
/** 栅格占据(默认 6) */
span?: number;
字段范围更小,因为筛选场景用到的控件比一般表单少。但额外多了 advanced 这一项,它就是"基础 / 高级"区域的标志,让 schema 自带 UI 表达力。
这两份 schema 的共同点是:用最少的字段描述完业务。schema 不是越复杂越好,能用一个 boolean 解决的不要塞一个对象。
Schema 驱动的渲染
有了 schema,渲染就是一件没什么悬念的事:遍历字段,按 type 分发到对应控件。一张图说清楚:
#mermaid-svg-KyP6S5EMQGJk1ZCK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-KyP6S5EMQGJk1ZCK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KyP6S5EMQGJk1ZCK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KyP6S5EMQGJk1ZCK .error-icon{fill:#552222;}#mermaid-svg-KyP6S5EMQGJk1ZCK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KyP6S5EMQGJk1ZCK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KyP6S5EMQGJk1ZCK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KyP6S5EMQGJk1ZCK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KyP6S5EMQGJk1ZCK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KyP6S5EMQGJk1ZCK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KyP6S5EMQGJk1ZCK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KyP6S5EMQGJk1ZCK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KyP6S5EMQGJk1ZCK .marker.cross{stroke:#333333;}#mermaid-svg-KyP6S5EMQGJk1ZCK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KyP6S5EMQGJk1ZCK p{margin:0;}#mermaid-svg-KyP6S5EMQGJk1ZCK .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-KyP6S5EMQGJk1ZCK .cluster-label text{fill:#333;}#mermaid-svg-KyP6S5EMQGJk1ZCK .cluster-label span{color:#333;}#mermaid-svg-KyP6S5EMQGJk1ZCK .cluster-label span p{background-color:transparent;}#mermaid-svg-KyP6S5EMQGJk1ZCK .label text,#mermaid-svg-KyP6S5EMQGJk1ZCK span{fill:#333;color:#333;}#mermaid-svg-KyP6S5EMQGJk1ZCK .node rect,#mermaid-svg-KyP6S5EMQGJk1ZCK .node circle,#mermaid-svg-KyP6S5EMQGJk1ZCK .node ellipse,#mermaid-svg-KyP6S5EMQGJk1ZCK .node polygon,#mermaid-svg-KyP6S5EMQGJk1ZCK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-KyP6S5EMQGJk1ZCK .rough-node .label text,#mermaid-svg-KyP6S5EMQGJk1ZCK .node .label text,#mermaid-svg-KyP6S5EMQGJk1ZCK .image-shape .label,#mermaid-svg-KyP6S5EMQGJk1ZCK .icon-shape .label{text-anchor:middle;}#mermaid-svg-KyP6S5EMQGJk1ZCK .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-KyP6S5EMQGJk1ZCK .rough-node .label,#mermaid-svg-KyP6S5EMQGJk1ZCK .node .label,#mermaid-svg-KyP6S5EMQGJk1ZCK .image-shape .label,#mermaid-svg-KyP6S5EMQGJk1ZCK .icon-shape .label{text-align:center;}#mermaid-svg-KyP6S5EMQGJk1ZCK .node.clickable{cursor:pointer;}#mermaid-svg-KyP6S5EMQGJk1ZCK .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-KyP6S5EMQGJk1ZCK .arrowheadPath{fill:#333333;}#mermaid-svg-KyP6S5EMQGJk1ZCK .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-KyP6S5EMQGJk1ZCK .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-KyP6S5EMQGJk1ZCK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KyP6S5EMQGJk1ZCK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-KyP6S5EMQGJk1ZCK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KyP6S5EMQGJk1ZCK .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-KyP6S5EMQGJk1ZCK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-KyP6S5EMQGJk1ZCK .cluster text{fill:#333;}#mermaid-svg-KyP6S5EMQGJk1ZCK .cluster span{color:#333;}#mermaid-svg-KyP6S5EMQGJk1ZCK div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-KyP6S5EMQGJk1ZCK .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-KyP6S5EMQGJk1ZCK rect.text{fill:none;stroke-width:0;}#mermaid-svg-KyP6S5EMQGJk1ZCK .icon-shape,#mermaid-svg-KyP6S5EMQGJk1ZCK .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KyP6S5EMQGJk1ZCK .icon-shape p,#mermaid-svg-KyP6S5EMQGJk1ZCK .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-KyP6S5EMQGJk1ZCK .icon-shape .label rect,#mermaid-svg-KyP6S5EMQGJk1ZCK .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KyP6S5EMQGJk1ZCK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-KyP6S5EMQGJk1ZCK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-KyP6S5EMQGJk1ZCK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} v-for
field.type
input
textarea
number
select
radio
checkbox
...
schema: FieldSchema\[\]
遍历每一项
分支判断
el-input
el-input type=textarea
el-input-number
el-select
el-radio-group
el-checkbox-group
其余 4 种控件
10:25:src/views/dynamic-forms/cop/DynamicForm.vue
<el-row :gutter="16">
<el-col
v-for="field in schema"
:key="field.prop"
:span="field.span || 24"
>
<el-form-item :label="field.label" :prop="field.prop">
<!-- 单行输入框 -->
<el-input
v-if="field.type === 'input'"
v-model="formData[field.prop]"
:placeholder="field.placeholder"
:disabled="field.disabled"
clearable
/>
后面就是一连串 v-else-if,每个分支对应一个控件。十种类型就十个分支,没什么聪明的写法。
有人会问:为什么不用动态组件 <component :is="..."> 替代 v-else-if?因为每种控件 props 名字不一样(min/max/options/rows),用动态组件就得写一套"prop 转换映射",写出来比 v-else-if 还啰嗦。简单胜过聪明,这里就是体力活。
还有一个细节藏在 v-for 的 key 里:我用了 field.prop 而不是 index。这一条非常重要,后面会专门讲。
双向数据流
这是动态表单实现里最容易踩坑的一点,也是 Vue 3.4 引入 defineModel 之后变化最大的一点。
最早我是这样写 DynamicForm 内部数据的:在子组件里用 ref 维护一个内部 formData,把父组件传来的 modelValue 拷一份进来,然后用三个 watch 来回同步:
ts
const formData = ref({ ...props.modelValue }); // 内部副本
watch(() => props.schema, ensureDefaults, { deep: true });
watch(() => props.modelValue, val => formData.value = { ...val }, { deep: true });
watch(formData, val => emit("update:modelValue", val), { deep: true });
看着挺合理,三个 watch 各管一摊。然后我就发现表单不响应字段配置的变化了。
跟踪了一阵子,画成时序图就一目了然了:
watch formData watch modelValue watch schema 子组件 formData (内部副本) 父组件 schema 用户操作 watch formData watch modelValue watch schema 子组件 formData (内部副本) 父组件 schema 用户操作 #mermaid-svg-a2p65vz98EvzCVcd{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-a2p65vz98EvzCVcd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-a2p65vz98EvzCVcd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-a2p65vz98EvzCVcd .error-icon{fill:#552222;}#mermaid-svg-a2p65vz98EvzCVcd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-a2p65vz98EvzCVcd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-a2p65vz98EvzCVcd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-a2p65vz98EvzCVcd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-a2p65vz98EvzCVcd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-a2p65vz98EvzCVcd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-a2p65vz98EvzCVcd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-a2p65vz98EvzCVcd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-a2p65vz98EvzCVcd .marker.cross{stroke:#333333;}#mermaid-svg-a2p65vz98EvzCVcd svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-a2p65vz98EvzCVcd p{margin:0;}#mermaid-svg-a2p65vz98EvzCVcd .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-a2p65vz98EvzCVcd text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-a2p65vz98EvzCVcd .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-a2p65vz98EvzCVcd .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-a2p65vz98EvzCVcd .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-a2p65vz98EvzCVcd .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-a2p65vz98EvzCVcd #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-a2p65vz98EvzCVcd .sequenceNumber{fill:white;}#mermaid-svg-a2p65vz98EvzCVcd #sequencenumber{fill:#333;}#mermaid-svg-a2p65vz98EvzCVcd #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-a2p65vz98EvzCVcd .messageText{fill:#333;stroke:none;}#mermaid-svg-a2p65vz98EvzCVcd .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-a2p65vz98EvzCVcd .labelText,#mermaid-svg-a2p65vz98EvzCVcd .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-a2p65vz98EvzCVcd .loopText,#mermaid-svg-a2p65vz98EvzCVcd .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-a2p65vz98EvzCVcd .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-a2p65vz98EvzCVcd .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-a2p65vz98EvzCVcd .noteText,#mermaid-svg-a2p65vz98EvzCVcd .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-a2p65vz98EvzCVcd .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-a2p65vz98EvzCVcd .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-a2p65vz98EvzCVcd .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-a2p65vz98EvzCVcd .actorPopupMenu{position:absolute;}#mermaid-svg-a2p65vz98EvzCVcd .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-a2p65vz98EvzCVcd .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-a2p65vz98EvzCVcd .actor-man circle,#mermaid-svg-a2p65vz98EvzCVcd line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-a2p65vz98EvzCVcd :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ⚠️ 控件绑定的引用一直在被新对象覆盖表面看起来就是"动态表单不响应式" 改了 FieldEditor 里的 label1schema 深度变化2ensureDefaults 替换 formData.value3触发 watch formData4emit update:modelValue5modelValue 变化6formData.value = {...val} 又替换一次7又触发!8又 emit!9
!WARNING
不是死循环,但
element-plus控件绑定的formData[field.prop]引用一直在被"新对象"覆盖,像有人在背后不停地清空你的输入框。表面看起来就是"动态表单不响应式"。这是动态表单实现里最常见的反模式:子组件内部维护一份 props 的副本,再用多个
watch互相同步。
解决方案是把所有内部副本都丢掉,改用 defineModel:
157:163:src/views/dynamic-forms/cop/DynamicForm.vue
/**
* 使用 defineModel 直接代理父组件的 ref,
* 不再维护"内部 ref + 三个互相同步的 watch",避免循环更新。
*/
const formData = defineModel<Record<string, any>>({
required: true,
});
一行代码替代了三个 watch。父子组件共享同一个 reactive 对象,再也没有"内部副本"这种东西。前后对比:
#mermaid-svg-BqT0d0y0j5fa7p1b{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-BqT0d0y0j5fa7p1b .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-BqT0d0y0j5fa7p1b .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-BqT0d0y0j5fa7p1b .error-icon{fill:#552222;}#mermaid-svg-BqT0d0y0j5fa7p1b .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-BqT0d0y0j5fa7p1b .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-BqT0d0y0j5fa7p1b .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-BqT0d0y0j5fa7p1b .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-BqT0d0y0j5fa7p1b .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-BqT0d0y0j5fa7p1b .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-BqT0d0y0j5fa7p1b .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-BqT0d0y0j5fa7p1b .marker{fill:#333333;stroke:#333333;}#mermaid-svg-BqT0d0y0j5fa7p1b .marker.cross{stroke:#333333;}#mermaid-svg-BqT0d0y0j5fa7p1b svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-BqT0d0y0j5fa7p1b p{margin:0;}#mermaid-svg-BqT0d0y0j5fa7p1b .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-BqT0d0y0j5fa7p1b .cluster-label text{fill:#333;}#mermaid-svg-BqT0d0y0j5fa7p1b .cluster-label span{color:#333;}#mermaid-svg-BqT0d0y0j5fa7p1b .cluster-label span p{background-color:transparent;}#mermaid-svg-BqT0d0y0j5fa7p1b .label text,#mermaid-svg-BqT0d0y0j5fa7p1b span{fill:#333;color:#333;}#mermaid-svg-BqT0d0y0j5fa7p1b .node rect,#mermaid-svg-BqT0d0y0j5fa7p1b .node circle,#mermaid-svg-BqT0d0y0j5fa7p1b .node ellipse,#mermaid-svg-BqT0d0y0j5fa7p1b .node polygon,#mermaid-svg-BqT0d0y0j5fa7p1b .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-BqT0d0y0j5fa7p1b .rough-node .label text,#mermaid-svg-BqT0d0y0j5fa7p1b .node .label text,#mermaid-svg-BqT0d0y0j5fa7p1b .image-shape .label,#mermaid-svg-BqT0d0y0j5fa7p1b .icon-shape .label{text-anchor:middle;}#mermaid-svg-BqT0d0y0j5fa7p1b .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-BqT0d0y0j5fa7p1b .rough-node .label,#mermaid-svg-BqT0d0y0j5fa7p1b .node .label,#mermaid-svg-BqT0d0y0j5fa7p1b .image-shape .label,#mermaid-svg-BqT0d0y0j5fa7p1b .icon-shape .label{text-align:center;}#mermaid-svg-BqT0d0y0j5fa7p1b .node.clickable{cursor:pointer;}#mermaid-svg-BqT0d0y0j5fa7p1b .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-BqT0d0y0j5fa7p1b .arrowheadPath{fill:#333333;}#mermaid-svg-BqT0d0y0j5fa7p1b .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-BqT0d0y0j5fa7p1b .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-BqT0d0y0j5fa7p1b .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BqT0d0y0j5fa7p1b .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-BqT0d0y0j5fa7p1b .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BqT0d0y0j5fa7p1b .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-BqT0d0y0j5fa7p1b .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-BqT0d0y0j5fa7p1b .cluster text{fill:#333;}#mermaid-svg-BqT0d0y0j5fa7p1b .cluster span{color:#333;}#mermaid-svg-BqT0d0y0j5fa7p1b div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-BqT0d0y0j5fa7p1b .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-BqT0d0y0j5fa7p1b rect.text{fill:none;stroke-width:0;}#mermaid-svg-BqT0d0y0j5fa7p1b .icon-shape,#mermaid-svg-BqT0d0y0j5fa7p1b .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BqT0d0y0j5fa7p1b .icon-shape p,#mermaid-svg-BqT0d0y0j5fa7p1b .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-BqT0d0y0j5fa7p1b .icon-shape .label rect,#mermaid-svg-BqT0d0y0j5fa7p1b .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BqT0d0y0j5fa7p1b .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-BqT0d0y0j5fa7p1b .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-BqT0d0y0j5fa7p1b :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 重构
✅ 正解:defineModel 共享引用
共享同一个对象引用
父组件
const formData = ref({})
子组件
defineModel
❌ 反例:内部副本 + 三个 watch
copy
emit
watch
watch
父组件
modelValue
子组件
formData (副本)
可以这样理解:
- 子组件
v-model="formData[field.prop]"直接读写父组件持有的对象 ensureDefaults也直接 mutateformData.value的属性- 没有
emit、没有副本同步,数据流是一根直线
!TIP
这一条是动态表单(其实是所有双向绑定组件)的基本原则:宁可让父子共享同一个引用,也不要在组件内部复制一份再做同步 。Vue 3.4+ 的
defineModel就是为了简化这种场景。
派生数据
整个表单内部其实有三套数据:schema 是源头 ,rules / defaults / 可见字段都是从它"派生"出来的。理解它们的依赖方向,整个组件就不需要再 watch 来 watch 去:
#mermaid-svg-7OVKFzqhFqQKGe4e{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7OVKFzqhFqQKGe4e .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7OVKFzqhFqQKGe4e .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7OVKFzqhFqQKGe4e .error-icon{fill:#552222;}#mermaid-svg-7OVKFzqhFqQKGe4e .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7OVKFzqhFqQKGe4e .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7OVKFzqhFqQKGe4e .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7OVKFzqhFqQKGe4e .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7OVKFzqhFqQKGe4e .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7OVKFzqhFqQKGe4e .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7OVKFzqhFqQKGe4e .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7OVKFzqhFqQKGe4e .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7OVKFzqhFqQKGe4e .marker.cross{stroke:#333333;}#mermaid-svg-7OVKFzqhFqQKGe4e svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7OVKFzqhFqQKGe4e p{margin:0;}#mermaid-svg-7OVKFzqhFqQKGe4e .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-7OVKFzqhFqQKGe4e .cluster-label text{fill:#333;}#mermaid-svg-7OVKFzqhFqQKGe4e .cluster-label span{color:#333;}#mermaid-svg-7OVKFzqhFqQKGe4e .cluster-label span p{background-color:transparent;}#mermaid-svg-7OVKFzqhFqQKGe4e .label text,#mermaid-svg-7OVKFzqhFqQKGe4e span{fill:#333;color:#333;}#mermaid-svg-7OVKFzqhFqQKGe4e .node rect,#mermaid-svg-7OVKFzqhFqQKGe4e .node circle,#mermaid-svg-7OVKFzqhFqQKGe4e .node ellipse,#mermaid-svg-7OVKFzqhFqQKGe4e .node polygon,#mermaid-svg-7OVKFzqhFqQKGe4e .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7OVKFzqhFqQKGe4e .rough-node .label text,#mermaid-svg-7OVKFzqhFqQKGe4e .node .label text,#mermaid-svg-7OVKFzqhFqQKGe4e .image-shape .label,#mermaid-svg-7OVKFzqhFqQKGe4e .icon-shape .label{text-anchor:middle;}#mermaid-svg-7OVKFzqhFqQKGe4e .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-7OVKFzqhFqQKGe4e .rough-node .label,#mermaid-svg-7OVKFzqhFqQKGe4e .node .label,#mermaid-svg-7OVKFzqhFqQKGe4e .image-shape .label,#mermaid-svg-7OVKFzqhFqQKGe4e .icon-shape .label{text-align:center;}#mermaid-svg-7OVKFzqhFqQKGe4e .node.clickable{cursor:pointer;}#mermaid-svg-7OVKFzqhFqQKGe4e .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-7OVKFzqhFqQKGe4e .arrowheadPath{fill:#333333;}#mermaid-svg-7OVKFzqhFqQKGe4e .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-7OVKFzqhFqQKGe4e .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-7OVKFzqhFqQKGe4e .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7OVKFzqhFqQKGe4e .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-7OVKFzqhFqQKGe4e .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7OVKFzqhFqQKGe4e .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-7OVKFzqhFqQKGe4e .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7OVKFzqhFqQKGe4e .cluster text{fill:#333;}#mermaid-svg-7OVKFzqhFqQKGe4e .cluster span{color:#333;}#mermaid-svg-7OVKFzqhFqQKGe4e div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-7OVKFzqhFqQKGe4e .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7OVKFzqhFqQKGe4e rect.text{fill:none;stroke-width:0;}#mermaid-svg-7OVKFzqhFqQKGe4e .icon-shape,#mermaid-svg-7OVKFzqhFqQKGe4e .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7OVKFzqhFqQKGe4e .icon-shape p,#mermaid-svg-7OVKFzqhFqQKGe4e .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-7OVKFzqhFqQKGe4e .icon-shape .label rect,#mermaid-svg-7OVKFzqhFqQKGe4e .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7OVKFzqhFqQKGe4e .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7OVKFzqhFqQKGe4e .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7OVKFzqhFqQKGe4e :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} schema (源数据)
formRules
computed
默认值兜底
ensureDefaults
可见字段
visibleFields (search-filter)
操作栏宽度
actionSpan (search-filter)
el-form :rules
formDatafield.prop
v-for 渲染
栅格布局
中间这一层用 computed 表达即可,不需要写 watch------这是声明式编程的力量。
el-form 的 :rules 是一个跟 :model 平行的对象:
ts
const rules = {
name: [{ required: true, message: '姓名不能为空' }],
age: [{ type: 'number', min: 0 }],
};
既然 schema 里已经写了 required: true 和 rules: [...],那就用一个 computed 把 schema 转成 FormRules:
201:223:src/views/dynamic-forms/cop/DynamicForm.vue
/** 根据 schema 自动生成校验规则(响应式:label / required / rules 变化即更新) */
const formRules = computed<FormRules>(() => {
const rules: FormRules = {};
props.schema.forEach((field) => {
const list: FormItemRule[] = [];
if (field.required) {
list.push({
required: true,
message: `${field.label}不能为空`,
trigger:
field.type === "input" || field.type === "textarea"
? "blur"
: "change",
type: field.type === "checkbox" ? "array" : undefined,
});
}
if (field.rules) {
list.push(...(Array.isArray(field.rules) ? field.rules : [field.rules]));
}
if (list.length) rules[field.prop] = list;
});
return rules;
});
两个细节藏在这里:
==trigger 按类型区分:==input / textarea 用 blur 触发更友好,select / radio 这些选择型用 change。
==checkbox 必须加 type: 'array':==multi-select 的"必填"语义是数组非空,不指明类型 element-plus 会按 string 校验,永远校验不过。
用 computed 而不是 watch 的好处是:schema 一变,rules 自动跟着变,不需要手动同步。这是声明式编程的力量。
默认值兜底也类似。element-plus 控件对 undefined 不友好,比如 el-input-number v-model="x" 当 x 是 undefined 时控制台会冒警告。所以渲染前必须给每个字段一个默认值,而且 schema 变化时(用户新增字段、删除字段)兜底逻辑要跟着跑:
167:199:src/views/dynamic-forms/cop/DynamicForm.vue
const resolveDefault = (field: FieldSchema) => {
if (field.defaultValue !== undefined) return field.defaultValue;
switch (field.type) {
case "checkbox":
return [];
case "switch":
return false;
case "number":
case "slider":
return field.min ?? 0;
default:
return "";
}
};
/**
* 同步 schema 与 formData:
* 1. 给缺失字段补默认值
* 2. 移除 schema 中已不存在的字段,避免删除后还残留旧值
* (直接 mutate 父组件 ref 持有的对象,依赖 reactive 自动通知)
*/
const ensureDefaults = () => {
if (!formData.value) return;
const validProps = new Set(props.schema.map((f) => f.prop));
Object.keys(formData.value).forEach((key) => {
if (!validProps.has(key)) delete formData.value![key];
});
props.schema.forEach((field) => {
if (formData.value![field.prop] === undefined) {
formData.value![field.prop] = resolveDefault(field);
}
});
};
ensureDefaults 既要补默认值,也要清掉已经从 schema 移除的字段。否则用户加一个字段、再删掉它,提交给后端的数据里仍然挂着这个字段,越积越多。
触发时机也有讲究:
229:239:src/views/dynamic-forms/cop/DynamicForm.vue
watch(
() =>
props.schema.map((f) => ({
prop: f.prop,
type: f.type,
defaultValue: f.defaultValue,
min: f.min,
})),
() => ensureDefaults(),
{ immediate: true, deep: true }
);
我没有写 watch(() => props.schema, ..., { deep: true })------那样改个 label 都要跑一次 ensureDefaults,浪费。只 map 出真正影响默认值的字段,纯展示属性(label、placeholder)变化不触发兜底逻辑。
到这里,动态表单的四件事就齐了:schema、渲染、双向绑定、派生数据。下面把这套原理放到三个具体场景里。
场景一:dynamic-forms 可视化字段编辑
第一个场景的业务定位是"配置即表单":让 schema 本身可以被可视化编辑。这一类工具的典型用户是低代码平台、问卷设计器、表单引擎的运营后台。

页面布局是左右双栏:左侧是字段配置面板(FieldEditor.vue),右侧是按 schema 实时渲染出的表单(DynamicForm.vue)。改左边,右边立刻跟上。
这个交互架构看起来像两个独立组件在通信,本质上是两个组件订阅同一份 schema:
#mermaid-svg-mLE6HfU54k9VuShc{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-mLE6HfU54k9VuShc .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-mLE6HfU54k9VuShc .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-mLE6HfU54k9VuShc .error-icon{fill:#552222;}#mermaid-svg-mLE6HfU54k9VuShc .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-mLE6HfU54k9VuShc .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-mLE6HfU54k9VuShc .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-mLE6HfU54k9VuShc .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-mLE6HfU54k9VuShc .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-mLE6HfU54k9VuShc .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-mLE6HfU54k9VuShc .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-mLE6HfU54k9VuShc .marker{fill:#333333;stroke:#333333;}#mermaid-svg-mLE6HfU54k9VuShc .marker.cross{stroke:#333333;}#mermaid-svg-mLE6HfU54k9VuShc svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-mLE6HfU54k9VuShc p{margin:0;}#mermaid-svg-mLE6HfU54k9VuShc .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-mLE6HfU54k9VuShc .cluster-label text{fill:#333;}#mermaid-svg-mLE6HfU54k9VuShc .cluster-label span{color:#333;}#mermaid-svg-mLE6HfU54k9VuShc .cluster-label span p{background-color:transparent;}#mermaid-svg-mLE6HfU54k9VuShc .label text,#mermaid-svg-mLE6HfU54k9VuShc span{fill:#333;color:#333;}#mermaid-svg-mLE6HfU54k9VuShc .node rect,#mermaid-svg-mLE6HfU54k9VuShc .node circle,#mermaid-svg-mLE6HfU54k9VuShc .node ellipse,#mermaid-svg-mLE6HfU54k9VuShc .node polygon,#mermaid-svg-mLE6HfU54k9VuShc .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-mLE6HfU54k9VuShc .rough-node .label text,#mermaid-svg-mLE6HfU54k9VuShc .node .label text,#mermaid-svg-mLE6HfU54k9VuShc .image-shape .label,#mermaid-svg-mLE6HfU54k9VuShc .icon-shape .label{text-anchor:middle;}#mermaid-svg-mLE6HfU54k9VuShc .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-mLE6HfU54k9VuShc .rough-node .label,#mermaid-svg-mLE6HfU54k9VuShc .node .label,#mermaid-svg-mLE6HfU54k9VuShc .image-shape .label,#mermaid-svg-mLE6HfU54k9VuShc .icon-shape .label{text-align:center;}#mermaid-svg-mLE6HfU54k9VuShc .node.clickable{cursor:pointer;}#mermaid-svg-mLE6HfU54k9VuShc .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-mLE6HfU54k9VuShc .arrowheadPath{fill:#333333;}#mermaid-svg-mLE6HfU54k9VuShc .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-mLE6HfU54k9VuShc .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-mLE6HfU54k9VuShc .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-mLE6HfU54k9VuShc .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-mLE6HfU54k9VuShc .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-mLE6HfU54k9VuShc .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-mLE6HfU54k9VuShc .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-mLE6HfU54k9VuShc .cluster text{fill:#333;}#mermaid-svg-mLE6HfU54k9VuShc .cluster span{color:#333;}#mermaid-svg-mLE6HfU54k9VuShc div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-mLE6HfU54k9VuShc .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-mLE6HfU54k9VuShc rect.text{fill:none;stroke-width:0;}#mermaid-svg-mLE6HfU54k9VuShc .icon-shape,#mermaid-svg-mLE6HfU54k9VuShc .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-mLE6HfU54k9VuShc .icon-shape p,#mermaid-svg-mLE6HfU54k9VuShc .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-mLE6HfU54k9VuShc .icon-shape .label rect,#mermaid-svg-mLE6HfU54k9VuShc .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-mLE6HfU54k9VuShc .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-mLE6HfU54k9VuShc .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-mLE6HfU54k9VuShc :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} DynamicForm (右)
FieldEditor (左)
index.vue (父组件)
v-model:schema (修改)
v-bind :schema (订阅)
defineModel (共享对象)
const schema = ref(...)
const formData = ref({})
增删字段
改 label / type / options
按 schema 渲染表单
用户填写控件
schema 在父组件,由 FieldEditor 写、DynamicForm 读。这种"两个组件共享一份数据"的模式是动态表单可玩性的来源------理解了这一点,你就能想出来"加一个 JSON 编辑器组件直接编辑 schema"也是同样的事。

DynamicForm 我已经在上一节讲透了,是动态表单的"核心引擎"。这里重点说说 FieldEditor 的实现,以及我在它上面踩过的两个坑。
增删字段不能直接 mutate props
最初的写法是这样:
ts
// 别这么写!
const handleAdd = () => {
props.schema.push(newField); // 直接 mutate props
emit("update:schema", [...props.schema]);
};
虽然能工作,但违反 Vue 单向数据流的原则,严格模式下会警告。正确写法是 emit 一个全新数组,让父组件去更新:
209:230:src/views/dynamic-forms/cop/FieldEditor.vue
const handleAdd = () => {
const newProp = genUniqueProp();
const newField: FieldSchema = {
type: "input",
prop: newProp,
label: "新字段",
span: 12,
};
emit("update:schema", [...props.schema, newField]);
activeNames.value.push(newProp);
};
const handleRemove = (index: number) => {
const removed = props.schema[index];
emit(
"update:schema",
props.schema.filter((_, i) => i !== index)
);
if (removed) {
activeNames.value = activeNames.value.filter((n) => n !== removed.prop);
}
};
v-for 与 el-collapse 的 key 选择
这一条是 Vue 老坑了,但放在动态列表里特别痛。如果 v-for :key="index",Vue 会按位置复用 DOM。删除中间一个字段时,下方所有控件的内容会"串行"错位------你看到的是"字段 A 的内容跑到了字段 B 的位置",会以为响应式坏了。
el-collapse-item 的 :name 也一样。如果 name 用 index,删除一个字段后,剩余字段的索引前移,但 activeNames 里存的还是旧索引,展开状态全错位。
正确做法是用 field.prop 作为稳定 ID:
10:15:src/views/dynamic-forms/cop/FieldEditor.vue
<el-collapse v-model="activeNames" class="editor-list">
<el-collapse-item
v-for="(field, index) in schema"
:key="field.prop"
:name="field.prop"
>
但 field.prop 是用户可改的,可能撞车。所以新增字段时主动保证唯一:
200:207:src/views/dynamic-forms/cop/FieldEditor.vue
/** 生成一个不会与现有 prop 冲突的唯一 prop */
const genUniqueProp = () => {
const exist = new Set(props.schema.map((f) => f.prop));
let prop = `field_${Date.now()}`;
let i = 1;
while (exist.has(prop)) prop = `field_${Date.now()}_${i++}`;
return prop;
};
并且监听 schema 的 prop 变化,自动清理 activeNames 里失效的项,避免"幽灵展开":
253:259:src/views/dynamic-forms/cop/FieldEditor.vue
watch(
() => props.schema.map((f) => f.prop),
(newProps) => {
const set = new Set(newProps);
activeNames.value = activeNames.value.filter((n) => set.has(n));
}
);
场景小结
!IMPORTANT
dynamic-forms是"原理的最纯粹形态"。它说明:只要 schema 是数据,编辑 schema 也只是编辑数据 。你不需要写一个新的"表单编辑器组件",让FieldEditor也通过 v-model 双向绑定 schema,再让DynamicForm在另一边订阅同一份 schema,整个交互就出来了。这种"两个组件共享同一份 schema"的模式可以延展出很多东西:实时预览、版本对比、协作编辑。只要把数据当成第一公民,UI 都是它的投影。
场景二:complex-form 业务联动表单
第二个场景的业务定位是"业务规则在数据中"。它跟场景一不一样------这里的字段是硬编码的(商品发布的字段就那些),但字段之间的关系非常复杂。

这就是开篇说的"第二层动态"。我用商品发布表单作为蓝本,把后台业务表单里常见的四种联动模式全做了一遍。
数据组织:reactive 与 ref
字段一多,这个选择就有讲究。ref 包对象的好处是替换整个对象时引用变化容易追踪,但坏处是每访问一层都要 .value。我选 reactive:
435:457:src/views/complex-form/index.vue
const createDefaultForm = () => ({
name: "",
categoryPath: [undefined, undefined, undefined] as Array<number | undefined>,
brandId: undefined as number | undefined,
useSpec: false,
price: 0,
stock: 0,
specs: [] as SpecRow[],
delivery: [] as string[],
shipFee: 0,
pickupAddress: "",
promotion: "none" as "none" | "discount" | "fullReduction",
discountValue: 9.0,
discountRange: [] as string[],
fullReductions: [] as LadderRow[],
useShopContact: true,
contact: { name: "", phone: "", email: "" },
agree: false,
});
const form = reactive(createDefaultForm());
注意两个细节:
==抽一个 createDefaultForm() 工厂函数:==重置表单时用 Object.assign(form, createDefaultForm()),绝对不要写 form = ...------那样会丢响应式。
==每个字段都给好初始值:==哪怕是 undefined 也明确写出来,类型推断更稳,element-plus 也少几个警告。
异步级联
三级商品分类是经典级联:选一级触发加载二级,选二级触发加载三级,三级决定品牌选项。整个流程的状态机长这样:
#mermaid-svg-drCFOfgWX8poRSvq{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-drCFOfgWX8poRSvq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-drCFOfgWX8poRSvq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-drCFOfgWX8poRSvq .error-icon{fill:#552222;}#mermaid-svg-drCFOfgWX8poRSvq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-drCFOfgWX8poRSvq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-drCFOfgWX8poRSvq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-drCFOfgWX8poRSvq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-drCFOfgWX8poRSvq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-drCFOfgWX8poRSvq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-drCFOfgWX8poRSvq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-drCFOfgWX8poRSvq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-drCFOfgWX8poRSvq .marker.cross{stroke:#333333;}#mermaid-svg-drCFOfgWX8poRSvq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-drCFOfgWX8poRSvq p{margin:0;}#mermaid-svg-drCFOfgWX8poRSvq defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-drCFOfgWX8poRSvq g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-drCFOfgWX8poRSvq g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-drCFOfgWX8poRSvq g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-drCFOfgWX8poRSvq g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-drCFOfgWX8poRSvq g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-drCFOfgWX8poRSvq .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-drCFOfgWX8poRSvq .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-drCFOfgWX8poRSvq .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-drCFOfgWX8poRSvq .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-drCFOfgWX8poRSvq .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-drCFOfgWX8poRSvq .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-drCFOfgWX8poRSvq .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-drCFOfgWX8poRSvq .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-drCFOfgWX8poRSvq .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-drCFOfgWX8poRSvq .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-drCFOfgWX8poRSvq .edgeLabel .label text{fill:#333;}#mermaid-svg-drCFOfgWX8poRSvq .label div .edgeLabel{color:#333;}#mermaid-svg-drCFOfgWX8poRSvq .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-drCFOfgWX8poRSvq .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-drCFOfgWX8poRSvq .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-drCFOfgWX8poRSvq .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-drCFOfgWX8poRSvq .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-drCFOfgWX8poRSvq .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-drCFOfgWX8poRSvq .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-drCFOfgWX8poRSvq #statediagram-barbEnd{fill:#333333;}#mermaid-svg-drCFOfgWX8poRSvq .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-drCFOfgWX8poRSvq .cluster-label,#mermaid-svg-drCFOfgWX8poRSvq .nodeLabel{color:#131300;}#mermaid-svg-drCFOfgWX8poRSvq .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-drCFOfgWX8poRSvq .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-drCFOfgWX8poRSvq .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-drCFOfgWX8poRSvq .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-drCFOfgWX8poRSvq .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-drCFOfgWX8poRSvq .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-drCFOfgWX8poRSvq .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-drCFOfgWX8poRSvq .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-drCFOfgWX8poRSvq .note-edge{stroke-dasharray:5;}#mermaid-svg-drCFOfgWX8poRSvq .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-drCFOfgWX8poRSvq .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-drCFOfgWX8poRSvq .statediagram-note text{fill:black;}#mermaid-svg-drCFOfgWX8poRSvq .statediagram-note .nodeLabel{color:black;}#mermaid-svg-drCFOfgWX8poRSvq .statediagram .edgeLabel{color:red;}#mermaid-svg-drCFOfgWX8poRSvq #dependencyStart,#mermaid-svg-drCFOfgWX8poRSvq #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-drCFOfgWX8poRSvq .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-drCFOfgWX8poRSvq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 页面加载
选一级分类
(loadCategories 二级)
选二级分类
(loadCategories 三级)
选三级分类
(loadBrands)
选品牌
继续填表
切回一级
清空下级 + 品牌
切一级
清空二/三级 + 品牌
切二级
清空三级 + 品牌
切三级
清空品牌
初始
一级已选
二级已选
三级已选
品牌已选
关键:每次切上级
必须 await 清下级
否则下级"留尾巴"
!WARNING
注意所有"向左的箭头"(用户切上级)都伴随着"清空下级"的副作用------这是异步级联最容易写漏的部分。
接口模拟用 setTimeout 包一层:
96:106:src/views/complex-form/api/mock.ts
const delay = <T>(data: T, ms = 400): Promise<T> =>
new Promise((resolve) => setTimeout(() => resolve(data), ms));
/** 按 parentId 查询子分类(parentId 为 null 时返回一级分类) */
export const fetchCategories = (parentId: number | null = null) => {
const list = ALL_CATEGORIES.filter((c) => c.parentId === parentId);
return delay(list, 300);
};
/** 按三级分类 id 查询品牌 */
export const fetchBrands = (categoryId: number) => {
return delay(BRAND_MAP[categoryId] || [], 350);
};
业务接对接时把这两个函数换成真实 axios 调用即可,签名一致。
新手最容易忽略的一点是:用户切换上级分类后,下级数据要"擦干净"。
如果只 await loadCategories(level + 1, value) 而不清下级,会出现"我已经在三级选好了 T 恤,现在切到二级 iPhone,结果三级还显示 T 恤"这种灵异现象。所以 onCategoryChange 第一步必须先清干净:
480:494:src/views/complex-form/index.vue
const onCategoryChange = async (level: number, value?: number) => {
for (let i = level + 1; i < 3; i++) {
form.categoryPath[i] = undefined;
categoryOptions[i] = [];
}
form.brandId = undefined;
brandOptions.value = [];
if (value != null && level < 2) {
await loadCategories(level + 1, value);
}
if (level === 2 && value != null) {
await loadBrands(value);
}
};
外加每个下拉框都加上 :disabled="!form.categoryPath[level-1]":禁用比清空更显眼,用户能立刻意识到"必须先选上级",体验比让他点空下拉框好。
条件显隐与动态校验
商品发布表单里"配送方式"决定了好几个字段的命运。把它们之间的依赖画出来:
#mermaid-svg-1haTRoQzC3qFMJDQ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1haTRoQzC3qFMJDQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1haTRoQzC3qFMJDQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1haTRoQzC3qFMJDQ .error-icon{fill:#552222;}#mermaid-svg-1haTRoQzC3qFMJDQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1haTRoQzC3qFMJDQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1haTRoQzC3qFMJDQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1haTRoQzC3qFMJDQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1haTRoQzC3qFMJDQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1haTRoQzC3qFMJDQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1haTRoQzC3qFMJDQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1haTRoQzC3qFMJDQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1haTRoQzC3qFMJDQ .marker.cross{stroke:#333333;}#mermaid-svg-1haTRoQzC3qFMJDQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1haTRoQzC3qFMJDQ p{margin:0;}#mermaid-svg-1haTRoQzC3qFMJDQ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-1haTRoQzC3qFMJDQ .cluster-label text{fill:#333;}#mermaid-svg-1haTRoQzC3qFMJDQ .cluster-label span{color:#333;}#mermaid-svg-1haTRoQzC3qFMJDQ .cluster-label span p{background-color:transparent;}#mermaid-svg-1haTRoQzC3qFMJDQ .label text,#mermaid-svg-1haTRoQzC3qFMJDQ span{fill:#333;color:#333;}#mermaid-svg-1haTRoQzC3qFMJDQ .node rect,#mermaid-svg-1haTRoQzC3qFMJDQ .node circle,#mermaid-svg-1haTRoQzC3qFMJDQ .node ellipse,#mermaid-svg-1haTRoQzC3qFMJDQ .node polygon,#mermaid-svg-1haTRoQzC3qFMJDQ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-1haTRoQzC3qFMJDQ .rough-node .label text,#mermaid-svg-1haTRoQzC3qFMJDQ .node .label text,#mermaid-svg-1haTRoQzC3qFMJDQ .image-shape .label,#mermaid-svg-1haTRoQzC3qFMJDQ .icon-shape .label{text-anchor:middle;}#mermaid-svg-1haTRoQzC3qFMJDQ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-1haTRoQzC3qFMJDQ .rough-node .label,#mermaid-svg-1haTRoQzC3qFMJDQ .node .label,#mermaid-svg-1haTRoQzC3qFMJDQ .image-shape .label,#mermaid-svg-1haTRoQzC3qFMJDQ .icon-shape .label{text-align:center;}#mermaid-svg-1haTRoQzC3qFMJDQ .node.clickable{cursor:pointer;}#mermaid-svg-1haTRoQzC3qFMJDQ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-1haTRoQzC3qFMJDQ .arrowheadPath{fill:#333333;}#mermaid-svg-1haTRoQzC3qFMJDQ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-1haTRoQzC3qFMJDQ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-1haTRoQzC3qFMJDQ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1haTRoQzC3qFMJDQ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-1haTRoQzC3qFMJDQ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1haTRoQzC3qFMJDQ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-1haTRoQzC3qFMJDQ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-1haTRoQzC3qFMJDQ .cluster text{fill:#333;}#mermaid-svg-1haTRoQzC3qFMJDQ .cluster span{color:#333;}#mermaid-svg-1haTRoQzC3qFMJDQ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-1haTRoQzC3qFMJDQ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-1haTRoQzC3qFMJDQ rect.text{fill:none;stroke-width:0;}#mermaid-svg-1haTRoQzC3qFMJDQ .icon-shape,#mermaid-svg-1haTRoQzC3qFMJDQ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1haTRoQzC3qFMJDQ .icon-shape p,#mermaid-svg-1haTRoQzC3qFMJDQ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-1haTRoQzC3qFMJDQ .icon-shape .label rect,#mermaid-svg-1haTRoQzC3qFMJDQ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1haTRoQzC3qFMJDQ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-1haTRoQzC3qFMJDQ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-1haTRoQzC3qFMJDQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
是
否
是
是
否
form.delivery (多选)
包含
到付?
包含
包邮?
包含
自提?
needShipFee = true
needShipFee = false
显示 + 必填 运费
隐藏运费
-
clearValidate('shipFee')
form.shipFee = 0
显示 + 必填 自提地址
隐藏 + 清空地址 -
clearValidate('pickupAddress')
!WARNING
注意所有红色框------隐藏字段时必须
clearValidate,否则旧错误会黏在 form 实例里,下次提交时校验仍然不通过,但页面又没有可见的错误,用户和你都会一脸懵。
"不选包邮就得填运费"这种规则用一个 computed 表达:
535:538:src/views/complex-form/index.vue
/** 是否需要运费:选择了"到付"且未选"包邮" */
const needShipFee = computed(
() => form.delivery.includes("cod") && !form.delivery.includes("free")
);
模板里用 v-if="needShipFee" 控制显示,规则跟着 needShipFee 动态切换。
但这里有个非常隐蔽的坑 :用户先没选包邮,运费报错了;改主意勾上包邮,运费这一行 v-if 隐藏掉......但 element-plus 内部已经记下了 shipFee 字段的报错状态,下次提交时校验仍然不通过,但页面上又没有任何错误可以看到。
要手动给它收尾:
637:646:src/views/complex-form/index.vue
// 自动校验"已显示的字段",隐藏的字段移除已有错误
watch(needShipFee, (v) => {
if (!v) formRef.value?.clearValidate("shipFee");
});
watch(
() => form.delivery.includes("selfPickup"),
(v) => {
if (!v) formRef.value?.clearValidate("pickupAddress");
}
);
!IMPORTANT
clearValidate(prop)是隐藏字段时必带的一步。如果你写联动表单老是出现"明明字段都填了为啥提交不了",99% 是这个原因。
类似地,"取消勾选自提"要清空地址,"勾选包邮"要清零运费:
540:547:src/views/complex-form/index.vue
// 取消勾选自提时清空地址;包邮时运费置零
watch(
() => [...form.delivery],
(val) => {
if (!val.includes("selfPickup")) form.pickupAddress = "";
if (val.includes("free")) form.shipFee = 0;
}
);
!TIP
注意
() => [...form.delivery]我特意展开了数组。如果写成() => form.delivery,watch只比较引用,数组内容变了但引用没变,watch不会触发。展开成新数组让watch能感知到内部变化。
嵌套动态行
启用多规格后,单价/库存隐藏,变成一张"规格表"。每行有颜色、尺寸、价格、库存。
我先定义行的类型,带一个 _id 字段:
420:432:src/views/complex-form/index.vue
interface SpecRow {
_id: number;
color: string;
size: string;
price: number;
stock: number;
}
interface LadderRow {
_id: number;
full: number;
reduce: number;
}
const genId = () => Date.now() + Math.floor(Math.random() * 1000);
!WARNING
_id不是业务字段,纯粹是给v-for :key用的稳定 ID。别用 index、别用color+size这种业务字段做 key ------这跟场景一里v-for用 index 是同一个坑:删中间行的时候 Vue 复用 DOM,输入框里的值会串行错位。
嵌套校验这块我做了一个有点反常识的选择:放弃 element-plus 的 prop 路径式校验,提交前手写一个校验函数。
648:660:src/views/complex-form/index.vue
/** 校验已显示规格行的颜色/尺寸/价格/库存是否完整 */
const validateSpecs = (): string | null => {
if (!form.useSpec) return null;
if (form.specs.length === 0) return "请至少添加一条规格";
for (let i = 0; i < form.specs.length; i++) {
const s = form.specs[i];
if (!s.color || !s.size) return `第 ${i + 1} 行规格未选择颜色或尺寸`;
if (s.price == null || s.price < 0) return `第 ${i + 1} 行价格无效`;
if (s.stock == null || s.stock < 0) return `第 ${i + 1} 行库存无效`;
}
return null;
};
<el-form-item prop="specs.0.color"> 这种路径式校验在动态行场景里不好维护:索引会变、新增/删除都得重算。与其和框架对抗,不如写个普通函数。这种"简单胜过聪明"的取舍在我做表单时反复出现。
互斥分支切换
促销类型有三种:无 / 限时折扣 / 满减。选不同会展示不同的子字段。最容易写漏的是"切走时清旧值":
555:569:src/views/complex-form/index.vue
// 切换促销类型时清空对应字段,避免脏数据
watch(
() => form.promotion,
(val) => {
if (val !== "discount") {
form.discountValue = 9.0;
form.discountRange = [];
}
if (val !== "fullReduction") {
form.fullReductions = [];
} else if (form.fullReductions.length === 0) {
addLadder();
}
}
);
想象用户:"先选限时折扣 + 填好时间范围 → 改主意切到满减 → 又改回限时折扣"。如果不清,时间范围还是上次填的,但用户做了"切走"的动作,他默认是"重新开始"。
!TIP
这种"切走即清空"的语义需要前端主动做,
element-plus不会自动帮你。
场景小结
!IMPORTANT
complex-form说明:联动的本质不是"写一些 if-else",而是把字段间的状态机显式描述出来 。每一个watch、每一个computed、每一次clearValidate,都是在告诉系统"这里有一条业务规则"。把这些规则集中写在脚本里,比散落在模板里要清晰得多。一年后回头看代码,你能很快找到"运费什么时候显示"在哪里------因为它就在
needShipFee这个computed里,命名即文档。
场景三:search-filter 通用搜索筛选器
第三个场景是后台系统里最常见的:列表页的搜索栏。

为什么不直接用 el-form 写筛选?因为它跟普通表单有几个微妙但重要的差别:
==选填语义:==筛选条件大都是选填的。值为空时不应作为查询参数发出去。
==可视化已选条件:==要让用户看到当前选了什么。不只是控件里的回显,最好顶上有一排标签(chip)能一眼看明白。
==高级筛选折叠:==筛选条件可能很多。要支持"高级筛选"折叠,避免顶部一长条占满屏幕。
==特殊控件形态:==日期范围、数字范围、多选下拉,每种都得能用。
这些需求让"通用搜索筛选器"值得专门抽一个组件出来。下面是它的核心实现。
渲染与高级筛选展开
跟场景一的 DynamicForm 一样的分支器,加上一个 visibleFields 处理高级筛选:
182:188:src/views/search-filter/cop/SearchFilter.vue
const hasAdvanced = computed(() => props.schema.some((f) => f.advanced));
const expanded = ref(false);
/** 当前可见字段:基础字段始终显示,高级字段仅在展开时显示 */
const visibleFields = computed(() =>
props.schema.filter((f) => !f.advanced || expanded.value)
);
模板上 v-for="field in visibleFields",渲染就行。
数字范围的双控件设计
numberRange 是个特别的存在------element-plus 没有原生的"双值数字范围"控件。我用两个 el-input-number 拼一个:
vue
<div v-else-if="field.type === 'numberRange'" class="range-row">
<el-input-number v-model="rangeMin[field.prop]" @change="syncRange(field.prop)" ... />
<span class="range-sep">~</span>
<el-input-number v-model="rangeMax[field.prop]" @change="syncRange(field.prop)" ... />
</div>
对外的数据形态仍然是 [min, max] 数组 ,对内拆成两个独立 reactive 状态:
197:224:src/views/search-filter/cop/SearchFilter.vue
// ============= 数字范围辅助 =============
/** numberRange 在 UI 上拆成两个 input,但对外仍是 [min, max] 数组 */
const rangeMin = reactive<Record<string, number | undefined>>({});
const rangeMax = reactive<Record<string, number | undefined>>({});
/** 把 [min, max] 从 modelValue 同步到内部 reactive */
const hydrateRange = () => {
props.schema.forEach((f) => {
if (f.type === "numberRange") {
const arr = props.modelValue[f.prop];
rangeMin[f.prop] = Array.isArray(arr) ? arr[0] : undefined;
rangeMax[f.prop] = Array.isArray(arr) ? arr[1] : undefined;
}
});
};
hydrateRange();
watch(() => props.modelValue, hydrateRange, { deep: true });
const syncRange = (prop: string) => {
const min = rangeMin[prop];
const max = rangeMax[prop];
if (min == null && max == null) {
delete props.modelValue[prop];
} else {
props.modelValue[prop] = [min, max];
}
emit("update:modelValue", { ...props.modelValue });
};
hydrateRange 是从外向内的"水合":外部初始化 modelValue 时(比如从 URL 还原查询条件),要把 [min, max] 拆出来塞给两个 input。syncRange 是从内向外的同步:两个 input 任何一个变了,都把当前值合成数组写回 modelValue。
两个 input 都为空就 delete props.modelValue[prop],从 modelValue 里彻底剔掉这个键。下游接口不会收到一个 amountRange: [undefined, undefined] 这种半成品。
已选条件 chip 反查 label
筛选区填了一堆条件后,用户希望看到自己选了什么。但单纯把 modelValue 平铺成 JSON 用户看不懂------status: "paid" 不如 状态:已付款 直观。
所以我做了一个 formatChipValue() 反查 label:
233:260:src/views/search-filter/cop/SearchFilter.vue
const formatChipValue = (field: FilterField, raw: any): string | null => {
if (raw == null || raw === "") return null;
switch (field.type) {
case "select": {
const opt = field.options?.find((o) => o.value === raw);
return opt?.label ?? String(raw);
}
case "multiSelect": {
if (!Array.isArray(raw) || raw.length === 0) return null;
return raw
.map((v) => field.options?.find((o) => o.value === v)?.label ?? v)
.join(" / ");
}
case "dateRange": {
if (!Array.isArray(raw) || raw.length < 2 || !raw[0]) return null;
return `${raw[0]} ~ ${raw[1]}`;
}
case "numberRange": {
if (!Array.isArray(raw) || (raw[0] == null && raw[1] == null)) return null;
return `${raw[0] ?? "*"} ~ ${raw[1] ?? "*"}`;
}
case "switch":
return raw ? "是" : null;
case "input":
default:
return String(raw);
}
};
!TIP
四种格式化方式对应不同类型。返回
null表示"不显示这个 chip"------空值、未选、关闭的开关都过滤掉,chip 区只展示真正生效的条件。
更体贴的细节是关闭 chip 时立即触发搜索,不用用户再去点"查询":
273:280:src/views/search-filter/cop/SearchFilter.vue
const removeChip = (chip: Chip) => {
const next = { ...props.modelValue };
delete next[chip.prop];
rangeMin[chip.prop] = undefined;
rangeMax[chip.prop] = undefined;
emit("update:modelValue", next);
emit("search", next);
};
操作按钮自适应栅格
最后一个细节:6 个筛选项各占不同的栅格 span,最后那一列"查询/重置/展开"按钮该占多少?
如果固定写 :span="6",前面字段加起来不是 18 就要换行,按钮被挤到下一行很丑。用一个 computed 算剩余栅格:
190:195:src/views/search-filter/cop/SearchFilter.vue
/** 操作按钮列占多大:剩下的栅格全部给它,最少占 6 */
const actionSpan = computed(() => {
const used = visibleFields.value.reduce((s, f) => s + (f.span || 6), 0);
const rest = (24 - (used % 24)) % 24;
return rest === 0 ? 24 : rest;
});
逻辑很简单:算总栅格对 24 取模的余数,剩多少给按钮多少。所有字段占满整行时,按钮独占下一行。
!TIP
能在组件里"算"出来的事情就别让业务侧操心,这是抽组件时的一条朴素原则。
场景小结
!IMPORTANT
search-filter说明:schema 驱动不是表单专属。任何"业务侧只关心配置、UI 由配置生成"的场景都可以用同一套思路。后台系统里有十几张列表页面都长得差不多,抽出一个
SearchFilter是收益最高的几件事之一。一次抽完,后面所有筛选页都变成"写 schema + 写两个 handler",加班时间能省一半。
场景对比与本质提炼
写完三个场景,我们回头看共通点。
==schema 是数据:==无论是 FieldSchema 还是 FilterField,它们本质都是描述 UI 的纯数据结构。脱离 Vue 也能存在------你可以把 schema 写到后端数据库,也可以序列化到 localStorage,也可以两个开发者用相同的渲染器去读同一份 schema。当你把 UI 描述抽离成数据,UI 就成了数据的投影。
渲染是分发:v-if / v-else-if 一连串看起来不优雅,但它们就是 schema 数据到 Vue 模板的桥梁。没必要试图用动态组件去"简化"它------每种控件 props 不一样,写映射比写分支还累。Vue 模板本来就允许命令式的分支,用就是了。
==双向绑定走单线:==三个场景的核心组件(DynamicForm、SearchFilter)都用了 defineModel 或者 computed 代理父组件的 modelValue。没有谁在内部维护副本 + watch 同步。这条线越短,问题越少。
==派生数据用 computed:==校验规则、可见字段、actionSpan 这些都不是状态,是 schema 的派生量。用 computed 表达,让响应式系统帮你保持同步。
差别的关键在于交互的目的不同。一张图对比三个场景:
#mermaid-svg-ypQrlAAAnimtvXBZ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ypQrlAAAnimtvXBZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ypQrlAAAnimtvXBZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ypQrlAAAnimtvXBZ .error-icon{fill:#552222;}#mermaid-svg-ypQrlAAAnimtvXBZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ypQrlAAAnimtvXBZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ypQrlAAAnimtvXBZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ypQrlAAAnimtvXBZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ypQrlAAAnimtvXBZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ypQrlAAAnimtvXBZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ypQrlAAAnimtvXBZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ypQrlAAAnimtvXBZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ypQrlAAAnimtvXBZ .marker.cross{stroke:#333333;}#mermaid-svg-ypQrlAAAnimtvXBZ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ypQrlAAAnimtvXBZ p{margin:0;}#mermaid-svg-ypQrlAAAnimtvXBZ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ypQrlAAAnimtvXBZ .cluster-label text{fill:#333;}#mermaid-svg-ypQrlAAAnimtvXBZ .cluster-label span{color:#333;}#mermaid-svg-ypQrlAAAnimtvXBZ .cluster-label span p{background-color:transparent;}#mermaid-svg-ypQrlAAAnimtvXBZ .label text,#mermaid-svg-ypQrlAAAnimtvXBZ span{fill:#333;color:#333;}#mermaid-svg-ypQrlAAAnimtvXBZ .node rect,#mermaid-svg-ypQrlAAAnimtvXBZ .node circle,#mermaid-svg-ypQrlAAAnimtvXBZ .node ellipse,#mermaid-svg-ypQrlAAAnimtvXBZ .node polygon,#mermaid-svg-ypQrlAAAnimtvXBZ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ypQrlAAAnimtvXBZ .rough-node .label text,#mermaid-svg-ypQrlAAAnimtvXBZ .node .label text,#mermaid-svg-ypQrlAAAnimtvXBZ .image-shape .label,#mermaid-svg-ypQrlAAAnimtvXBZ .icon-shape .label{text-anchor:middle;}#mermaid-svg-ypQrlAAAnimtvXBZ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ypQrlAAAnimtvXBZ .rough-node .label,#mermaid-svg-ypQrlAAAnimtvXBZ .node .label,#mermaid-svg-ypQrlAAAnimtvXBZ .image-shape .label,#mermaid-svg-ypQrlAAAnimtvXBZ .icon-shape .label{text-align:center;}#mermaid-svg-ypQrlAAAnimtvXBZ .node.clickable{cursor:pointer;}#mermaid-svg-ypQrlAAAnimtvXBZ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ypQrlAAAnimtvXBZ .arrowheadPath{fill:#333333;}#mermaid-svg-ypQrlAAAnimtvXBZ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ypQrlAAAnimtvXBZ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ypQrlAAAnimtvXBZ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ypQrlAAAnimtvXBZ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ypQrlAAAnimtvXBZ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ypQrlAAAnimtvXBZ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ypQrlAAAnimtvXBZ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ypQrlAAAnimtvXBZ .cluster text{fill:#333;}#mermaid-svg-ypQrlAAAnimtvXBZ .cluster span{color:#333;}#mermaid-svg-ypQrlAAAnimtvXBZ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ypQrlAAAnimtvXBZ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ypQrlAAAnimtvXBZ rect.text{fill:none;stroke-width:0;}#mermaid-svg-ypQrlAAAnimtvXBZ .icon-shape,#mermaid-svg-ypQrlAAAnimtvXBZ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ypQrlAAAnimtvXBZ .icon-shape p,#mermaid-svg-ypQrlAAAnimtvXBZ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ypQrlAAAnimtvXBZ .icon-shape .label rect,#mermaid-svg-ypQrlAAAnimtvXBZ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ypQrlAAAnimtvXBZ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ypQrlAAAnimtvXBZ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ypQrlAAAnimtvXBZ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} search-filter
「组件可复用」
schema = 接口契约
业务侧只写 schema
- 两个 handler
典型场景
列表页 / 数据筛选
complex-form
「业务规则要显式」
schema = 硬编码字段
watch / computed
描述字段间状态机
典型场景
商品发布 / 订单录入
dynamic-forms
「配置即数据」
schema = 被编辑的对象
FieldEditor 写
DynamicForm 读
典型场景
低代码平台 / 问卷设计
schema 驱动
统一原理
==dynamic-forms:==目的是"配置即数据",schema 是被编辑的对象。
==complex-form:==目的是"业务规则要显式",schema 是固定的,但字段间的状态机需要用 watch / computed 表达。
==search-filter:==目的是"组件复用",schema 是接口契约,把 UI 抽到框架级别。
!IMPORTANT
如果让我用一句话总结:动态表单的实现原理就是把 UI 抽象成数据,再把数据派生成 UI。听起来像句废话,但当你真正按这个原则去写代码,你会发现很多原本"需要硬写一遍又一遍"的事情突然变得自然。
选用建议与扩展方向
最后给一点选用建议,方便你判断什么场景该用哪一种思路。
==字段定义会变(来自接口、用户配置):==用 dynamic-forms 的渲染器,schema 当成普通数据来管理。
==字段固定但规则复杂:==用 complex-form 的写法,watch / computed 把规则集中到脚本里。
==多个页面有相似的筛选/表单:==用 search-filter 的思路,抽出一个 schema 驱动的可复用组件。
很多项目其实是这三种的混合。比如一个商品管理后台,列表用 SearchFilter 抽组件,详情用 complex-form 写联动,运营配置项用 dynamic-forms 让运营自己拖。
可以扩展的方向也有不少:
==分组与嵌套:==当前 schema 是平铺数组,业务复杂时可以加 type: 'group'、type: 'array' 支持分组和嵌套对象。
==远程 schema:==把 schema 改成从后端拉取,前后端就有了一个"通过数据通信"的边界。运营改字段不用前端发版。
==URL 持久化:==搜索筛选场景里,把 filters 序列化到 URL query,刷新页面后筛选条件还在。这是后台系统的高级体验。
==Schema 版本控制:==当 schema 被存到后端,就需要考虑版本兼容------老 schema 在新渲染器上要还能跑。
写到这里,回过头看,这三个场景虽然形态各异,但解决思路是同一个:"把变化的部分抽成数据,把不变的部分抽成代码"。这是软件工程里很老的一条原则,放到 Vue 3 的世界里依然有效。希望这篇文章能让你下一次写表单时少踩几个坑。