简历项目经验描述
版本1 - 适合初中级
开发基于 JSON Schema 的动态表单组件,支持 15+ 种表单控件
- 实现表单配置化开发,通过 JSON 配置生成表单,开发效率提升 70%
- 支持字段联动、条件显示、动态校验等高级特性
- 封装常用业务表单模板,新增表单平均耗时从 1 天降至 2 小时
版本2 - 适合高级
设计并实现企业级动态表单引擎,支持复杂业务场景
- 建立表单依赖图和表达式引擎,解决多层级联动性能问题,字段更新延迟 < 50ms
- 实现分步表单向导,支持步骤跳转、数据缓存、进度保存
- 开发可视化表单设计器,非技术人员可自主配置表单,降低 60% 开发成本
- 优化大型表单性能,200+ 字段表单交互延迟从 500ms 降至 50ms
版本3 - 适合架构方向
主导设计表单领域 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:
{
\"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 的边。
当某个字段值变化时,我会:
- 找到所有依赖这个字段的节点(直接和间接的)
- 用拓扑排序得到更新顺序
- 按顺序依次更新每个字段的状态(visible/disabled/value)
举个例子,假设 A -> B -> C -> D 这样的依赖链。当 A 变化时,更新顺序是 B、C、D。这样保证 B 更新时能拿到最新的 A 值,C 更新时能拿到最新的 B 值。
为了避免循环依赖,我在构建图时会做检测。如果发现环,会打印警告并终止,提示开发者修改配置。
性能优化方面,我做了几个点:
- 状态计算用缓存,相同的表达式不重复求值
- 只更新真正需要变化的字段,没有变化的跳过
- 批量更新,不是每个字段变化都立即渲染,而是收集变化,统一 nextTick 后渲染
还有个细节是表达式引擎。我支持简单的逻辑表达式,比如:
{
\"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 结构是:
{
\"steps\": [
{
\"title\": \"基本信息\",
\"fields\": [...]
},
{
\"title\": \"详细信息\",
\"fields\": [...]
}
]
}
用一个 currentStep 变量记录当前步骤,根据步骤渲染对应的字段。顶部显示步骤条,点击可以跳转(已完成的步骤才能跳)。
难点在于步骤间的数据共享和校验。前面步骤填的数据,后面步骤可能会用到。我用一个统一的 formData 对象存所有步骤的数据,各步骤共享这个对象。
校验策略是:
- 下一步时,校验当前步骤的字段
- 上一步不校验,可以随时回退
- 提交时,校验所有步骤
还支持步骤跳过。有些步骤可能根据前面的选择条件显示。我在 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+ 个字段,其中很多字段互相依赖,用户输入时表单卡顿明显,输入延迟严重。
解决方案
"这个问题我是这样解决的。
首先分析了性能瓶颈,发现主要是两个地方:一是依赖计算,二是组件渲染。
依赖计算的优化:
- 缓存表达式结果 - 用 Map 缓存表达式的求值结果,key 是表达式和依赖值的组合,value 是求值结果。相同的表达式和值不重复计算。
- 剪枝优化 - 如果某个字段的状态没变化,就不继续计算它的依赖项。比如 A 影响 B,B 影响 C。如果 A 变化后 B 的状态没变,就不用更新 C。
- 批量更新 - 把短时间内的多次更新合并成一次,用 debounce 防抖。
组件渲染的优化:
- 按需渲染 - 只渲染 visible 为 true 的字段,隐藏的字段不渲染 DOM,用 v-if 而不是 v-show。
- 局部更新 - 字段变化时只更新受影响的字段,不触发整个表单重新渲染。用 computed 和 watch 精确控制更新范围。
- 虚拟化长表单 - 超过 50 个字段的表单,用虚拟滚动,只渲染可视区域的字段。
还有个优化是异步校验的处理。有些字段需要调接口校验唯一性,比如用户名是否已存在。我用了 debounce + AbortController,用户输入时不立即校验,停止输入 500ms 后才校验。如果用户继续输入,会取消之前的请求。
优化后,100 个字段的表单,输入延迟从 500ms 降到 50ms 以内,用户基本感觉不到卡顿。"
难点2: 自定义校验规则的灵活性
问题描述: 内置的校验规则(required、min、max、pattern)无法满足复杂的业务校验,比如两个字段的比较、异步校验等。
解决方案
"校验规则的设计我参考了 async-validator,支持多种类型的校验。
基础校验:
{
\"rules\": [
{ \"required\": true, \"message\": \"必填\" },
{ \"min\": 3, \"max\": 20, \"message\": \"长度 3-20\" },
{ \"pattern\": \"^[a-zA-Z0-9_]+$\", \"message\": \"只能包含字母数字下划线\" }
]
}
函数校验: 支持传入校验函数,可以访问整个表单数据,做跨字段校验。
{
validator: (rule, value, callback, formData) => {
if (value < formData.minAge) {
callback(new Error('不能小于最小年龄'))
} else {
callback()
}
}
}
异步校验: 返回 Promise,可以调接口校验。
{
asyncValidator: (rule, value) => {
return checkUsernameExists(value).then(exists => {
if (exists) {
return Promise.reject('用户名已存在')
}
return Promise.resolve()
})
}
}
还支持自定义错误提示。可以用字符串模板,动态插入字段值:
{
\"min\": 18,
\"message\": \"年龄不能小于 {{min}},当前值为 {{value}}\"
}
校验时机也支持配置:
- onChange: 输入时校验
- onBlur: 失焦时校验
- onSubmit: 提交时校验
我还做了一个校验规则库,把常用的校验封装成可复用的规则,比如手机号、邮箱、身份证号等,直接引用就行:
{
\"rules\": [\"phone\", \"required\"]
}
```"
### 难点3: 表单数据的嵌套和数组处理
#### 问题描述:
表单数据不都是扁平的,有些是嵌套对象或数组,比如联系人列表、多个地址等,如何处理?
##### 解决方案:
"嵌套数据处理我做了两种方式。
###### 方式一:字段名用路径表示
用点号表示嵌套层级,比如:
```json
{
\"fields\": [
{
\"name\": \"user.name\",
\"label\": \"姓名\"
},
{
\"name\": \"user.address.city\",
\"label\": \"城市\"
}
]
}
表单值会自动解析成嵌套对象:
{
user: {
name: '张三',
address: {
city: '北京'
}
}
}
方式二:嵌套表单组件 定义一个 Object 类型的字段,它的 fields 是子字段:
{
\"name\": \"address\",
\"type\": \"object\",
\"label\": \"地址\",
\"fields\": [
{ \"name\": \"province\", \"label\": \"省份\" },
{ \"name\": \"city\", \"label\": \"城市\" }
]
}
数组类型字段用 Array 类型,支持动态增减:
{
\"name\": \"contacts\",
\"type\": \"array\",
\"label\": \"联系人\",
\"itemSchema\": {
\"fields\": [
{ \"name\": \"name\", \"label\": \"姓名\" },
{ \"name\": \"phone\", \"label\": \"电话\" }
]
}
}
渲染时会显示一个列表,每项是一个子表单,可以添加、删除、排序。
数组字段的值是对象数组:
{
contacts: [
{ name: '张三', phone: '138xxxx' },
{ name: '李四', phone: '139xxxx' }
]
}
校验也支持嵌套,可以给数组项设置校验规则:
{
\"type\": \"array\",
\"rules\": [
{ \"required\": true, \"message\": \"至少添加一个联系人\" },
{ \"min\": 1, \"max\": 5, \"message\": \"联系人数量 1-5 个\" }
],
\"itemSchema\": {
\"fields\": [
{
\"name\": \"phone\",
\"rules\": [\"required\", \"phone\"]
}
]
}
}
这样可以校验数组长度,也可以校验每项的内容。"
完整技术实现
1. DynamicForm 核心组件
<!-- 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 字段渲染组件
<!-- 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. 数组字段组件
<!-- 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. 依赖图和表达式引擎
// 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
}
}
// 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. 使用示例
<!-- 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:
{
\"label\": \"user.name\",
\"placeholder\": \"user.name.placeholder\"
}
渲染时通过 i18n 库翻译成对应语言。我们的表单组件内部集成了 vue-i18n,会自动处理翻译。"
Q: 大型表单如何优化首屏加载?
"大型表单我做了懒加载和按需渲染:
- 分组折叠 - 把字段分组,默认只展开第一组,其他组折叠,点击才展开渲染
- 虚拟滚动 - 字段超过 50 个时,用虚拟滚动,只渲染可视区域
- 异步 Schema - Schema 支持异步加载,可以分批请求字段配置
- 组件懒加载 - 一些复杂的自定义组件用动态 import 懒加载
这些优化让 200 个字段的表单首屏时间从 3s 降到 500ms。"
项目经验总结
踩过的坑
- watch深度监听性能问题 - 对整个 formData 做深度监听导致性能差,改为只监听变化的字段
- 异步校验的竞态问题 - 快速输入时多个校验请求并发,用 AbortController 取消旧请求
- 数组字段的 key 问题 - 用 index 作为 key 导致删除错乱,改用唯一 ID
- 表达式循环依赖 - 没有检测循环依赖导致死循环,加了环检测算法
性能数据
- 100 字段表单初始化:< 200ms
- 字段联动更新延迟:< 50ms
- 表单提交校验时间:< 100ms
- Schema 解析时间:< 10ms
可以吹的点
- 支持 15+ 种表单控件,满足 90% 业务场景
- 表单配置化开发,效率提升 70%
- 可视化表单设计器,非技术人员也能配置
- 完善的类型定义和文档,团队接受度高