【功能实现】前端动态表单的实现原理与三种场景实战

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 也直接 mutate formData.value 的属性
  • 没有 emit、没有副本同步,数据流是一根直线

!TIP

这一条是动态表单(其实是所有双向绑定组件)的基本原则:宁可让父子共享同一个引用,也不要在组件内部复制一份再做同步 。Vue 3.4+ 的 defineModel 就是为了简化这种场景。

派生数据

整个表单内部其实有三套数据:schema 是源头 ,rules / defaults / 可见字段都是从它"派生"出来的。理解它们的依赖方向,整个组件就不需要再 watchwatch 去:
#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: truerules: [...],那就用一个 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"xundefined 时控制台会冒警告。所以渲染前必须给每个字段一个默认值,而且 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.deliverywatch 只比较引用,数组内容变了但引用没变,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 模板本来就允许命令式的分支,用就是了。

==双向绑定走单线:==三个场景的核心组件(DynamicFormSearchFilter)都用了 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 的世界里依然有效。希望这篇文章能让你下一次写表单时少踩几个坑。

相关推荐
小雨下雨的雨1 小时前
鸿蒙PC用Electron框架 实现 房产交易系统核心算法深度解析
前端·javascript·算法·华为·electron·鸿蒙系统
snow@li1 小时前
前端:本地电脑和服务器,本质上都是一台计算机。
运维·服务器·前端
吹个口哨写代码1 小时前
IIS 部署 Vue/React 单页应用 (SPA) 刷新页面 404/403.18 报错原因及终极解决方案
前端·vue.js·react.js
向日的葵0061 小时前
前端生成实战手册:从提示词到高完成度页面
前端·页面设计
粉末的沉淀1 小时前
前端:谷歌浏览器拒绝自动播放语音
前端
爱学习的程序媛2 小时前
Flutter 深度解析:从技术内核到名企实践
前端·flutter·前端框架
Moment2 小时前
为什么 Tiptap 做协同编辑离不开 Hocuspocus❓❓❓
前端·后端·面试
老毛肚2 小时前
jeecgboot vue Pinia 拆分01
前端·javascript·vue.js
夜焱辰10 小时前
浏览器端 Agent 的文件版本管理:不用 Git,基于 OPFS + SQLite 自己造了一个
前端·人工智能