返回笔记首页

3.2 DynamicForm 动态表单 - 深度剖析

主题配置

简历项目经验描述

版本1 - 适合初中级

plain
开发基于 JSON Schema 的动态表单组件,支持 15+ 种表单控件
- 实现表单配置化开发,通过 JSON 配置生成表单,开发效率提升 70%
- 支持字段联动、条件显示、动态校验等高级特性
- 封装常用业务表单模板,新增表单平均耗时从 1 天降至 2 小时

版本2 - 适合高级

plain
设计并实现企业级动态表单引擎,支持复杂业务场景
- 建立表单依赖图和表达式引擎,解决多层级联动性能问题,字段更新延迟 < 50ms
- 实现分步表单向导,支持步骤跳转、数据缓存、进度保存
- 开发可视化表单设计器,非技术人员可自主配置表单,降低 60% 开发成本
- 优化大型表单性能,200+ 字段表单交互延迟从 500ms 降至 50ms

版本3 - 适合架构方向

plain
主导设计表单领域 DSL 和渲染引擎,支撑核心业务流程
- 抽象表单元模型,实现跨端(Web/Mobile/桌面)表单一致性渲染
- 建立表单版本管理和灰度发布机制,支持表单在线热更新
- 设计表单数据流转和状态机,实现复杂审批流程的自动化处理

面试标准回答话术

Q1: 你们的动态表单引擎是怎么设计的?

标准回答

"我们的动态表单引擎核心是用 JSON Schema 来描述表单结构,然后通过渲染器把 Schema 转换成真实的表单组件。

整个系统分三层。最底层是控件库,包含 Input、Select、DatePicker 这些基础表单组件,每个组件都遵循统一的 props 接口,方便替换和扩展。中间层是渲染引擎,负责解析 Schema,递归渲染表单项,处理布局、样式、校验这些通用逻辑。最上层是业务层,提供常用的表单模板和配置工具。

Schema 的设计借鉴了 JSON Schema 规范,但做了简化和扩展。每个字段包含这些核心属性:

  • name: 字段名
  • type: 控件类型(input/select/date 等)
  • label: 字段标签
  • rules: 校验规则
  • props: 传给控件的属性
  • dependencies: 依赖的字段
  • visible/disabled: 显隐和禁用的表达式

举个例子,一个用户信息表单的 Schema:

json
{
  \"fields\": [
    {
      \"name\": \"username\",
      \"type\": \"input\",
      \"label\": \"用户名\",
      \"rules\": [{ \"required\": true, \"min\": 3, \"max\": 20 }]
    },
    {
      \"name\": \"type\",
      \"type\": \"select\",
      \"label\": \"类型\",
      \"props\": {
        \"options\": [
          { \"label\": \"个人\", \"value\": \"person\" },
          { \"label\": \"企业\", \"value\": \"company\" }
        ]
      }
    },
    {
      \"name\": \"idCard\",
      \"type\": \"input\",
      \"label\": \"身份证\",
      \"visible\": { \"$eq\": [\"type\", \"person\"] },
      \"dependencies\": [\"type\"]
    }
  ]
}

这个 Schema 定义了三个字段,其中身份证号字段只在选择'个人'类型时显示,这就是字段联动。

渲染引擎会遍历 fields 数组,根据 type 渲染对应的控件,同时处理依赖关系。当 type 字段变化时,会触发依赖它的字段重新计算 visible 表达式,决定是否显示。

这套方案的好处是配置即代码,不需要写 Vue 模板,只需要写 JSON 配置。而且配置可以存在数据库里,支持动态加载和热更新,非常灵活。"

Q2: 字段联动的依赖关系是怎么管理的?

标准回答

"字段联动是动态表单最复杂的部分,尤其是多层级的依赖关系,比如 A 影响 B,B 影响 C,C 又影响 D。

我用了依赖图来管理。初始化表单时,遍历所有字段的 dependencies 配置,构建一个有向无环图(DAG)。图的节点是字段名,边表示依赖关系,比如 idCard 依赖 type,就有一条 type -> idCard 的边。

当某个字段值变化时,我会:

  1. 找到所有依赖这个字段的节点(直接和间接的)
  2. 用拓扑排序得到更新顺序
  3. 按顺序依次更新每个字段的状态(visible/disabled/value)

举个例子,假设 A -> B -> C -> D 这样的依赖链。当 A 变化时,更新顺序是 B、C、D。这样保证 B 更新时能拿到最新的 A 值,C 更新时能拿到最新的 B 值。

为了避免循环依赖,我在构建图时会做检测。如果发现环,会打印警告并终止,提示开发者修改配置。

性能优化方面,我做了几个点:

  1. 状态计算用缓存,相同的表达式不重复求值
  2. 只更新真正需要变化的字段,没有变化的跳过
  3. 批量更新,不是每个字段变化都立即渲染,而是收集变化,统一 nextTick 后渲染

还有个细节是表达式引擎。我支持简单的逻辑表达式,比如:

json
{
  \"visible\": {
    \"$and\": [
      { \"$eq\": [\"type\", \"person\"] },
      { \"$gt\": [\"age\", 18] }
    ]
  }
}

这个表达式表示:type 等于 person 且 age 大于 18 时才显示。引擎会递归解析表达式,求值返回布尔值。"

Q3: 表单回显和编辑模式是怎么处理的?

标准回答

"表单回显就是把服务端的数据填充到表单里,编辑模式下显示出来。这个功能看起来简单,实际有不少细节。

首先是数据格式转换。后端返回的数据结构和表单需要的不一定完全一致。比如日期字段,后端可能返回时间戳或 ISO 字符串,表单需要 Date 对象或 dayjs 对象。我做了一套 transformer 系统,每种字段类型有对应的 parse 函数,负责转换数据。

其次是默认值处理。有些字段后端没返回值,但表单需要显示默认值。我在 Schema 里支持 defaultValue 配置,回显时会先用后端数据,没有的话用默认值。

还有个场景是关联数据。比如选择部门时,后端只返回部门 ID,但下拉框需要显示部门名称,这就需要额外请求部门详情。我做了 asyncData 配置,支持异步加载关联数据。

编辑模式下的数据校验也有讲究。有些字段只在创建时必填,编辑时不必填。我在 rules 里支持 editMode 配置,可以根据模式应用不同的校验规则。

最后是数据提交时的序列化。表单里的值需要转成后端需要的格式。比如多选框返回的是数组,但后端可能要逗号分隔的字符串。我提供了 serialize 函数,在提交前把表单值转换。

整个流程是:后端数据 -> parse -> 表单 -> serialize -> 后端。这样前后端的数据格式解耦,各自独立。"

Q4: 分步表单向导是怎么实现的?

标准回答

"分步表单向导就是把一个长表单拆成多个步骤,用户一步步填写,体验更好。

我的实现是把 Schema 按步骤分组,每一步是一个独立的表单。Schema 结构是:

json
{
  \"steps\": [
    {
      \"title\": \"基本信息\",
      \"fields\": [...]
    },
    {
      \"title\": \"详细信息\",
      \"fields\": [...]
    }
  ]
}

用一个 currentStep 变量记录当前步骤,根据步骤渲染对应的字段。顶部显示步骤条,点击可以跳转(已完成的步骤才能跳)。

难点在于步骤间的数据共享和校验。前面步骤填的数据,后面步骤可能会用到。我用一个统一的 formData 对象存所有步骤的数据,各步骤共享这个对象。

校验策略是:

  1. 下一步时,校验当前步骤的字段
  2. 上一步不校验,可以随时回退
  3. 提交时,校验所有步骤

还支持步骤跳过。有些步骤可能根据前面的选择条件显示。我在 step 配置里加了 skip 表达式,动态计算是否跳过这一步。

数据持久化这块,我做了本地缓存。用户填到一半关闭页面,下次打开能恢复。数据存在 sessionStorage,key 是表单 ID。还支持保存草稿,把数据提交到后端,用户可以随时恢复继续填写。

进度提示我做了两层:一是步骤条显示总体进度,二是每步内部显示字段填写进度(已填 / 总数)。这样用户能清楚知道还有多少内容要填。"

Q5: 表单设计器的拖拽配置是怎么实现的?

标准回答

"表单设计器是给非技术人员用的,通过拖拽就能配置表单,不需要写代码。

界面分三部分:左侧是控件面板,列出所有可用的表单控件,像 Input、Select、DatePicker;中间是画布,显示表单预览;右侧是属性面板,配置选中字段的属性。

拖拽用的是 Vue Draggable 这个库,基于 Sortable.js。从控件面板拖控件到画布,会在 Schema 的 fields 数组里插入一个新字段。拖拽调整顺序,就是调整数组元素的位置。

画布渲染的就是动态表单组件,传入当前的 Schema。每个字段都可以点击选中,选中后右侧显示属性配置面板。属性面板也是动态的,根据字段类型显示不同的配置项。

比如选中 Input 字段,属性面板显示:字段名、标签、占位符、最大长度等。选中 Select 字段,属性面板显示:字段名、标签、选项列表、多选等。这些配置项其实也是用动态表单实现的,很有意思,算是'表单的表单'。

高级配置像校验规则、联动表达式,我做了可视化编辑器。校验规则用表格形式,每行是一条规则,可以添加删除。联动表达式用逻辑表达式构建器,选择字段、操作符、值,生成标准的表达式 JSON。

设计好的表单可以导出 Schema JSON,也可以保存到服务器,分配一个表单 ID。业务代码只需要传入表单 ID,动态表单组件会自动加载 Schema 渲染。

我还做了表单模板功能,常用的表单可以保存为模板,下次创建直接从模板开始,不用从头配置。我们积累了 20 多个常用模板,像用户信息、地址信息、公司信息等,大大提升了效率。"


核心难点与解决方案

难点1: 复杂联动的性能优化

问题描述: 表单有 100+ 个字段,其中很多字段互相依赖,用户输入时表单卡顿明显,输入延迟严重。

解决方案

"这个问题我是这样解决的。

首先分析了性能瓶颈,发现主要是两个地方:一是依赖计算,二是组件渲染。

依赖计算的优化:

  1. 缓存表达式结果 - 用 Map 缓存表达式的求值结果,key 是表达式和依赖值的组合,value 是求值结果。相同的表达式和值不重复计算。
  2. 剪枝优化 - 如果某个字段的状态没变化,就不继续计算它的依赖项。比如 A 影响 B,B 影响 C。如果 A 变化后 B 的状态没变,就不用更新 C。
  3. 批量更新 - 把短时间内的多次更新合并成一次,用 debounce 防抖。

组件渲染的优化:

  1. 按需渲染 - 只渲染 visible 为 true 的字段,隐藏的字段不渲染 DOM,用 v-if 而不是 v-show。
  2. 局部更新 - 字段变化时只更新受影响的字段,不触发整个表单重新渲染。用 computed 和 watch 精确控制更新范围。
  3. 虚拟化长表单 - 超过 50 个字段的表单,用虚拟滚动,只渲染可视区域的字段。

还有个优化是异步校验的处理。有些字段需要调接口校验唯一性,比如用户名是否已存在。我用了 debounce + AbortController,用户输入时不立即校验,停止输入 500ms 后才校验。如果用户继续输入,会取消之前的请求。

优化后,100 个字段的表单,输入延迟从 500ms 降到 50ms 以内,用户基本感觉不到卡顿。"

难点2: 自定义校验规则的灵活性

问题描述: 内置的校验规则(required、min、max、pattern)无法满足复杂的业务校验,比如两个字段的比较、异步校验等。

解决方案

"校验规则的设计我参考了 async-validator,支持多种类型的校验。

基础校验:

json
{
  \"rules\": [
    { \"required\": true, \"message\": \"必填\" },
    { \"min\": 3, \"max\": 20, \"message\": \"长度 3-20\" },
    { \"pattern\": \"^[a-zA-Z0-9_]+$\", \"message\": \"只能包含字母数字下划线\" }
  ]
}

函数校验: 支持传入校验函数,可以访问整个表单数据,做跨字段校验。

javascript
{
  validator: (rule, value, callback, formData) => {
    if (value < formData.minAge) {
      callback(new Error('不能小于最小年龄'))
    } else {
      callback()
    }
  }
}

异步校验: 返回 Promise,可以调接口校验。

javascript
{
  asyncValidator: (rule, value) => {
    return checkUsernameExists(value).then(exists => {
      if (exists) {
        return Promise.reject('用户名已存在')
      }
      return Promise.resolve()
    })
  }
}

还支持自定义错误提示。可以用字符串模板,动态插入字段值:

json
{
  \"min\": 18,
  \"message\": \"年龄不能小于 {{min}},当前值为 {{value}}\"
}

校验时机也支持配置:

  • onChange: 输入时校验
  • onBlur: 失焦时校验
  • onSubmit: 提交时校验

我还做了一个校验规则库,把常用的校验封装成可复用的规则,比如手机号、邮箱、身份证号等,直接引用就行:

json
{
  \"rules\": [\"phone\", \"required\"]
}
```"

### 难点3: 表单数据的嵌套和数组处理

#### 问题描述:
表单数据不都是扁平的,有些是嵌套对象或数组,比如联系人列表、多个地址等,如何处理?

##### 解决方案:

"嵌套数据处理我做了两种方式。

###### 方式一:字段名用路径表示
用点号表示嵌套层级,比如:
```json
{
  \"fields\": [
    {
      \"name\": \"user.name\",
      \"label\": \"姓名\"
    },
    {
      \"name\": \"user.address.city\",
      \"label\": \"城市\"
    }
  ]
}

表单值会自动解析成嵌套对象:

javascript
{
  user: {
    name: '张三',
    address: {
      city: '北京'
    }
  }
}

方式二:嵌套表单组件 定义一个 Object 类型的字段,它的 fields 是子字段:

json
{
  \"name\": \"address\",
  \"type\": \"object\",
  \"label\": \"地址\",
  \"fields\": [
    { \"name\": \"province\", \"label\": \"省份\" },
    { \"name\": \"city\", \"label\": \"城市\" }
  ]
}

数组类型字段用 Array 类型,支持动态增减:

json
{
  \"name\": \"contacts\",
  \"type\": \"array\",
  \"label\": \"联系人\",
  \"itemSchema\": {
    \"fields\": [
      { \"name\": \"name\", \"label\": \"姓名\" },
      { \"name\": \"phone\", \"label\": \"电话\" }
    ]
  }
}

渲染时会显示一个列表,每项是一个子表单,可以添加、删除、排序。

数组字段的值是对象数组:

javascript
{
  contacts: [
    { name: '张三', phone: '138xxxx' },
    { name: '李四', phone: '139xxxx' }
  ]
}

校验也支持嵌套,可以给数组项设置校验规则:

json
{
  \"type\": \"array\",
  \"rules\": [
    { \"required\": true, \"message\": \"至少添加一个联系人\" },
    { \"min\": 1, \"max\": 5, \"message\": \"联系人数量 1-5 个\" }
  ],
  \"itemSchema\": {
    \"fields\": [
      {
        \"name\": \"phone\",
        \"rules\": [\"required\", \"phone\"]
      }
    ]
  }
}

这样可以校验数组长度,也可以校验每项的内容。"


完整技术实现

1. DynamicForm 核心组件

vue
<!-- components/DynamicForm/DynamicForm.vue -->
<template>
  <a-form
    ref="formRef"
    :model="formData"
    :layout="schema.layout || 'vertical'"
    v-bind="$attrs"
  >
    <component
      v-for="field in visibleFields"
      :key="field.name"
      :is="getFieldComponent(field)"
      :field="field"
      :form-data="formData"
      :disabled="getFieldDisabled(field)"
      @update:value="handleFieldChange(field.name, $event)"
    />

    <slot name="footer" :form-data="formData" :validate="validate">
      <a-form-item v-if="showSubmit">
        <a-button type="primary" @click="handleSubmit" :loading="submitting">
          {{ submitText }}
        </a-button>
        <a-button style="margin-left: 8px" @click="handleReset">
          重置
        </a-button>
      </a-form-item>
    </slot>
  </a-form>
</template>

<script setup>
import { ref, reactive, computed, watch, provide } from 'vue'
import FieldGraph from './utils/FieldGraph'
import ExpressionEngine from './utils/ExpressionEngine'
import FormField from './FormField.vue'

const props = defineProps({
  schema: {
    type: Object,
    required: true,
  },
  initialValues: {
    type: Object,
    default: () => ({}),
  },
  showSubmit: {
    type: Boolean,
    default: true,
  },
  submitText: {
    type: String,
    default: '提交',
  },
})

const emit = defineEmits(['submit', 'change'])

const formRef = ref()
const submitting = ref(false)

// 表单数据
const formData = reactive({})

// 字段依赖图
const fieldGraph = new FieldGraph()

// 表达式引擎
const expressionEngine = new ExpressionEngine()

// 字段状态缓存
const fieldStates = reactive({})

// 初始化
function init() {
  // 构建依赖图
  props.schema.fields.forEach(field => {
    if (field.dependencies) {
      field.dependencies.forEach(dep => {
        fieldGraph.addDependency(dep, field.name)
      })
    }
  })

  // 初始化表单数据
  props.schema.fields.forEach(field => {
    const initialValue = props.initialValues[field.name]
    if (initialValue !== undefined) {
      formData[field.name] = initialValue
    } else if (field.defaultValue !== undefined) {
      formData[field.name] = field.defaultValue
    }
  })

  // 初始化字段状态
  props.schema.fields.forEach(field => {
    fieldStates[field.name] = {
      visible: true,
      disabled: false,
    }
  })

  // 计算初始状态
  updateAllFieldStates()
}

init()

// 可见字段
const visibleFields = computed(() => {
  return props.schema.fields.filter(field => {
    return fieldStates[field.name]?.visible !== false
  })
})

// 获取字段组件
function getFieldComponent(field) {
  // 可以根据 field.type 返回不同的组件
  // 这里简化为统一用 FormField
  return FormField
}

// 获取字段禁用状态
function getFieldDisabled(field) {
  return fieldStates[field.name]?.disabled || false
}

// 字段值变化
function handleFieldChange(fieldName, value) {
  formData[fieldName] = value

  // 更新依赖字段
  const affectedFields = fieldGraph.getAffectedFields(fieldName)
  const sortedFields = fieldGraph.getSortedFields(affectedFields)

  sortedFields.forEach(name => {
    updateFieldState(name)
  })

  emit('change', formData)
}

// 更新单个字段状态
function updateFieldState(fieldName) {
  const field = props.schema.fields.find(f => f.name === fieldName)
  if (!field) return

  const state = fieldStates[fieldName]

  // 计算 visible
  if (field.visible !== undefined) {
    if (typeof field.visible === 'boolean') {
      state.visible = field.visible
    } else {
      state.visible = expressionEngine.evaluate(field.visible, formData)
    }
  }

  // 计算 disabled
  if (field.disabled !== undefined) {
    if (typeof field.disabled === 'boolean') {
      state.disabled = field.disabled
    } else {
      state.disabled = expressionEngine.evaluate(field.disabled, formData)
    }
  }
}

// 更新所有字段状态
function updateAllFieldStates() {
  props.schema.fields.forEach(field => {
    updateFieldState(field.name)
  })
}

// 校验表单
async function validate() {
  try {
    await formRef.value.validate()
    return true
  } catch (error) {
    return false
  }
}

// 提交
async function handleSubmit() {
  const valid = await validate()
  if (!valid) return

  submitting.value = true
  try {
    emit('submit', formData)
  } finally {
    submitting.value = false
  }
}

// 重置
function handleReset() {
  formRef.value.resetFields()
}

// 暴露方法
defineExpose({
  validate,
  getFieldsValue: () => formData,
  setFieldsValue: (values) => Object.assign(formData, values),
  resetFields: handleReset,
})
</script>

2. FormField 字段渲染组件

vue
<!-- components/DynamicForm/FormField.vue -->
<template>
  <a-form-item
    :name="field.name"
    :label="field.label"
    :rules="computedRules"
    v-bind="field.formItemProps"
  >
    <!-- Input -->
    <a-input
      v-if="field.type === 'input'"
      :value="formData[field.name]"
      @update:value="handleChange"
      :disabled="disabled"
      v-bind="field.props"
    />

    <!-- Textarea -->
    <a-textarea
      v-else-if="field.type === 'textarea'"
      :value="formData[field.name]"
      @update:value="handleChange"
      :disabled="disabled"
      v-bind="field.props"
    />

    <!-- Select -->
    <a-select
      v-else-if="field.type === 'select'"
      :value="formData[field.name]"
      @update:value="handleChange"
      :disabled="disabled"
      :options="selectOptions"
      v-bind="field.props"
    />

    <!-- DatePicker -->
    <a-date-picker
      v-else-if="field.type === 'date'"
      :value="formData[field.name]"
      @update:value="handleChange"
      :disabled="disabled"
      v-bind="field.props"
    />

    <!-- Number -->
    <a-input-number
      v-else-if="field.type === 'number'"
      :value="formData[field.name]"
      @update:value="handleChange"
      :disabled="disabled"
      v-bind="field.props"
    />

    <!-- Switch -->
    <a-switch
      v-else-if="field.type === 'switch'"
      :checked="formData[field.name]"
      @update:checked="handleChange"
      :disabled="disabled"
      v-bind="field.props"
    />

    <!-- Radio -->
    <a-radio-group
      v-else-if="field.type === 'radio'"
      :value="formData[field.name]"
      @update:value="handleChange"
      :disabled="disabled"
      v-bind="field.props"
    >
      <a-radio
        v-for="option in field.options"
        :key="option.value"
        :value="option.value"
      >
        {{ option.label }}
      </a-radio>
    </a-radio-group>

    <!-- Checkbox -->
    <a-checkbox-group
      v-else-if="field.type === 'checkbox'"
      :value="formData[field.name]"
      @update:value="handleChange"
      :disabled="disabled"
      v-bind="field.props"
    >
      <a-checkbox
        v-for="option in field.options"
        :key="option.value"
        :value="option.value"
      >
        {{ option.label }}
      </a-checkbox>
    </a-checkbox-group>

    <!-- 数组类型(动态增减) -->
    <ArrayField
      v-else-if="field.type === 'array'"
      :field="field"
      :value="formData[field.name]"
      @update:value="handleChange"
    />

    <!-- 自定义渲染 -->
    <component
      v-else-if="field.render"
      :is="field.render"
      :value="formData[field.name]"
      :field="field"
      :form-data="formData"
      @update:value="handleChange"
    />
  </a-form-item>
</template>

<script setup>
import { computed, ref, watch } from 'vue'
import ArrayField from './ArrayField.vue'

const props = defineProps({
  field: {
    type: Object,
    required: true,
  },
  formData: {
    type: Object,
    required: true,
  },
  disabled: {
    type: Boolean,
    default: false,
  },
})

const emit = defineEmits(['update:value'])

// 下拉选项(支持异步加载)
const selectOptions = ref(props.field.options || [])

// 异步加载选项
if (props.field.asyncOptions) {
  const { loader, dependencies } = props.field.asyncOptions

  // 监听依赖字段变化
  watch(
    () => dependencies.map(dep => props.formData[dep]),
    async (values) => {
      // 检查是否所有依赖都有值
      if (values.every(v => v != null)) {
        const depValues = dependencies.reduce((acc, dep, index) => {
          acc[dep] = values[index]
          return acc
        }, {})

        try {
          selectOptions.value = await loader(depValues)
        } catch (error) {
          console.error('加载选项失败', error)
        }
      }
    },
    { immediate: true }
  )
}

// 计算校验规则
const computedRules = computed(() => {
  if (!props.field.rules) return []

  return props.field.rules.map(rule => {
    if (typeof rule === 'string') {
      // 预设规则,如 'required', 'email'
      return getRulePreset(rule)
    }
    return rule
  })
})

// 预设规则
function getRulePreset(name) {
  const presets = {
    required: {
      required: true,
      message: `${props.field.label}不能为空`,
    },
    email: {
      type: 'email',
      message: '请输入正确的邮箱地址',
    },
    phone: {
      pattern: /^1[3-9]\d{9}$/,
      message: '请输入正确的手机号',
    },
    idCard: {
      pattern: /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/,
      message: '请输入正确的身份证号',
    },
  }
  return presets[name] || {}
}

// 值变化
function handleChange(value) {
  emit('update:value', value)
}
</script>

3. 数组字段组件

vue
<!-- components/DynamicForm/ArrayField.vue -->
<template>
  <div class="array-field">
    <div
      v-for="(item, index) in localValue"
      :key="index"
      class="array-item"
    >
      <DynamicForm
        :schema="field.itemSchema"
        :initial-values="item"
        :show-submit="false"
        @change="handleItemChange(index, $event)"
      />
      <a-button
        type="link"
        danger
        @click="handleRemove(index)"
      >
        删除
      </a-button>
    </div>

    <a-button
      type="dashed"
      block
      @click="handleAdd"
      v-if="!maxItems || localValue.length < maxItems"
    >
      <template #icon><PlusOutlined /></template>
      添加{{ field.label }}
    </a-button>
  </div>
</template>

<script setup>
import { ref, watch } from 'vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import DynamicForm from './DynamicForm.vue'

const props = defineProps({
  field: {
    type: Object,
    required: true,
  },
  value: {
    type: Array,
    default: () => [],
  },
})

const emit = defineEmits(['update:value'])

const localValue = ref([...props.value])

const maxItems = props.field.max

watch(() => props.value, (newVal) => {
  localValue.value = [...newVal]
})

function handleAdd() {
  localValue.value.push({})
  emit('update:value', localValue.value)
}

function handleRemove(index) {
  localValue.value.splice(index, 1)
  emit('update:value', localValue.value)
}

function handleItemChange(index, values) {
  localValue.value[index] = values
  emit('update:value', localValue.value)
}
</script>

<style scoped>
.array-item {
  position: relative;
  padding: 16px;
  margin-bottom: 16px;
  border: 1px solid #d9d9d9;
  border-radius: 4px;
}
</style>

4. 依赖图和表达式引擎

javascript
// components/DynamicForm/utils/FieldGraph.js
export default class FieldGraph {
  constructor() {
    this.adjacencyList = new Map()
  }

  addDependency(from, to) {
    if (!this.adjacencyList.has(from)) {
      this.adjacencyList.set(from, new Set())
    }
    this.adjacencyList.get(from).add(to)
  }

  getAffectedFields(field) {
    const affected = new Set()
    const queue = [field]

    while (queue.length > 0) {
      const current = queue.shift()
      const neighbors = this.adjacencyList.get(current)

      if (neighbors) {
        neighbors.forEach(neighbor => {
          if (!affected.has(neighbor)) {
            affected.add(neighbor)
            queue.push(neighbor)
          }
        })
      }
    }

    return Array.from(affected)
  }

  getSortedFields(fields) {
    // 拓扑排序
    const inDegree = new Map()
    const subGraph = new Map()

    fields.forEach(field => {
      inDegree.set(field, 0)
      subGraph.set(field, new Set())
    })

    fields.forEach(field => {
      const neighbors = this.adjacencyList.get(field)
      if (neighbors) {
        neighbors.forEach(neighbor => {
          if (fields.includes(neighbor)) {
            subGraph.get(field).add(neighbor)
            inDegree.set(neighbor, (inDegree.get(neighbor) || 0) + 1)
          }
        })
      }
    })

    const queue = []
    const result = []

    inDegree.forEach((degree, field) => {
      if (degree === 0) {
        queue.push(field)
      }
    })

    while (queue.length > 0) {
      const current = queue.shift()
      result.push(current)

      const neighbors = subGraph.get(current)
      if (neighbors) {
        neighbors.forEach(neighbor => {
          const newDegree = (inDegree.get(neighbor) || 0) - 1
          inDegree.set(neighbor, newDegree)
          if (newDegree === 0) {
            queue.push(neighbor)
          }
        })
      }
    }

    return result
  }
}
javascript
// components/DynamicForm/utils/ExpressionEngine.js
export default class ExpressionEngine {
  evaluate(expression, values) {
    if (expression.$and) {
      return expression.$and.every(expr => this.evaluate(expr, values))
    }

    if (expression.$or) {
      return expression.$or.some(expr => this.evaluate(expr, values))
    }

    if (expression.$not) {
      return !this.evaluate(expression.$not, values)
    }

    if (expression.$eq) {
      const [field, expectedValue] = expression.$eq
      return values[field] === expectedValue
    }

    if (expression.$ne) {
      const [field, expectedValue] = expression.$ne
      return values[field] !== expectedValue
    }

    if (expression.$gt) {
      const [field, threshold] = expression.$gt
      return (values[field] || 0) > threshold
    }

    if (expression.$gte) {
      const [field, threshold] = expression.$gte
      return (values[field] || 0) >= threshold
    }

    if (expression.$lt) {
      const [field, threshold] = expression.$lt
      return (values[field] || 0) < threshold
    }

    if (expression.$lte) {
      const [field, threshold] = expression.$lte
      return (values[field] || 0) <= threshold
    }

    if (expression.$in) {
      const [field, valueList] = expression.$in
      return valueList.includes(values[field])
    }

    if (expression.$nin) {
      const [field, valueList] = expression.$nin
      return !valueList.includes(values[field])
    }

    return true
  }
}

5. 使用示例

vue
<!-- views/FormExample.vue -->
<template>
  <div class="form-example">
    <DynamicForm
      :schema="formSchema"
      :initial-values="initialValues"
      @submit="handleSubmit"
    />
  </div>
</template>

<script setup>
import { reactive } from 'vue'
import { message } from 'ant-design-vue'
import DynamicForm from '@/components/DynamicForm/DynamicForm.vue'

const formSchema = reactive({
  layout: 'vertical',
  fields: [
    {
      name: 'type',
      type: 'select',
      label: '类型',
      options: [
        { label: '个人', value: 'person' },
        { label: '企业', value: 'company' },
      ],
      rules: ['required'],
    },
    {
      name: 'idCard',
      type: 'input',
      label: '身份证号',
      visible: {
        $eq: ['type', 'person'],
      },
      dependencies: ['type'],
      rules: ['required', 'idCard'],
    },
    {
      name: 'businessLicense',
      type: 'input',
      label: '营业执照号',
      visible: {
        $eq: ['type', 'company'],
      },
      dependencies: ['type'],
      rules: ['required'],
    },
    {
      name: 'contacts',
      type: 'array',
      label: '联系人',
      max: 5,
      itemSchema: {
        fields: [
          {
            name: 'name',
            type: 'input',
            label: '姓名',
            rules: ['required'],
          },
          {
            name: 'phone',
            type: 'input',
            label: '电话',
            rules: ['required', 'phone'],
          },
        ],
      },
    },
  ],
})

const initialValues = {
  type: 'person',
  contacts: [],
}

function handleSubmit(values) {
  console.log('表单提交:', values)
  message.success('提交成功')
}
</script>

面试常见追问

Q: 表单Schema如何版本管理?

"我们的 Schema 会存在数据库里,表单配置有版本号字段。每次修改 Schema 都会保存为新版本,保留历史版本。

线上表单使用指定版本的 Schema,不会因为配置修改而影响。要升级表单时,会先在测试环境验证新版本,然后灰度发布到生产环境。

还支持 Schema diff,可以对比两个版本的差异,高亮显示变更的字段,方便 review。"

Q: 如何处理表单的国际化?

"字段的 label、placeholder、校验信息这些文本都支持国际化。Schema 里不直接写中文,而是写 i18n key:

json
{
  \"label\": \"user.name\",
  \"placeholder\": \"user.name.placeholder\"
}

渲染时通过 i18n 库翻译成对应语言。我们的表单组件内部集成了 vue-i18n,会自动处理翻译。"

Q: 大型表单如何优化首屏加载?

"大型表单我做了懒加载和按需渲染:

  1. 分组折叠 - 把字段分组,默认只展开第一组,其他组折叠,点击才展开渲染
  2. 虚拟滚动 - 字段超过 50 个时,用虚拟滚动,只渲染可视区域
  3. 异步 Schema - Schema 支持异步加载,可以分批请求字段配置
  4. 组件懒加载 - 一些复杂的自定义组件用动态 import 懒加载

这些优化让 200 个字段的表单首屏时间从 3s 降到 500ms。"


项目经验总结

踩过的坑

  1. watch深度监听性能问题 - 对整个 formData 做深度监听导致性能差,改为只监听变化的字段
  2. 异步校验的竞态问题 - 快速输入时多个校验请求并发,用 AbortController 取消旧请求
  3. 数组字段的 key 问题 - 用 index 作为 key 导致删除错乱,改用唯一 ID
  4. 表达式循环依赖 - 没有检测循环依赖导致死循环,加了环检测算法

性能数据

  • 100 字段表单初始化:< 200ms
  • 字段联动更新延迟:< 50ms
  • 表单提交校验时间:< 100ms
  • Schema 解析时间:< 10ms

可以吹的点

  • 支持 15+ 种表单控件,满足 90% 业务场景
  • 表单配置化开发,效率提升 70%
  • 可视化表单设计器,非技术人员也能配置
  • 完善的类型定义和文档,团队接受度高