迁移现有表单
一个 CRUD 弹窗通常从 model 和 rules 开始。加入编辑回填、重置、异步选项、动态行、草稿和多个 section 后,页面会逐步堆出 refs、watchers、loading、dirty、errors 和 submit locks。每个页面都在维护一套相似的表单状态机。
vformjs 继续使用 Element Plus、element-ui、Naive UI 或 Ant Design Vue 的 Form,把模式、基线、校验、提交和错误状态收进一个有类型的 form 实例。现有模板和业务组件可以保留;页面变复杂时,仍然沿用同一套 API。
一个同时包含新增、编辑、重置和提交的弹窗就能验证接入结果:编辑记录不会污染下一次新建,提交状态不用重复维护,宿主 Form 的校验反馈保持原样。
| 页面形态 | form 实例统一 | 业务代码保留 |
|---|---|---|
| 常规 CRUD | 默认值基线、模式、校验、重置、提交状态 | API 与成功后的页面动作 |
| 动态表单 | 生效中的条件和规则、稳定行 key、错误重排、选项请求 | 领域条件与数据接口 |
| 大型表单 | 显式追踪、数组操作、草稿、接口字段错误 | 布局、计算、上传和 payload 转换 |
| 多区块表单 | 聚合校验、模式、错误、提交和重置 | section 边界与最终 payload |
1. 常规 CRUD:一个实例管住新增、编辑和重置
五组状态散落在页面里
宿主 ref、默认值、规则、重置顺序和提交锁通常由页面分别维护。用户编辑一条记录后再次新建,旧标识和校验状态也容易跟着留下来。
一个 form 收拢生命周期
<script setup lang="ts">
import { reactive, shallowRef, useTemplateRef } from 'vue'
import { shallowRef } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { r, useElForm } from '@vformjs/element-plus'
interface ProfileForm {
recordId: string | undefined
name: string
email: string
}
const visible = shallowRef(false)
const submitting = shallowRef(false)
const formRef = useTemplateRef<FormInstance>('form')
const model = reactive<ProfileForm>({ recordId: undefined, name: '', email: '' })
const rules: FormRules<ProfileForm> = {
name: [{ required: true, message: '请输入名称' }],
email: [{ type: 'email', message: '邮箱格式不正确' }],
}
const form = useElForm<ProfileForm>({
defaults: { recordId: undefined, name: '', email: '' },
rules: {
name: [r.required()],
email: [r.email()],
},
async onSubmit(values) {
await recordApi.save(values)
visible.value = false
},
})
function openCreate() {
Object.assign(model, { recordId: undefined, name: '', email: '' })
formRef.value?.clearValidate()
form.load('create')
visible.value = true
}
function openEdit(detail: ProfileForm) {
Object.assign(model, detail)
formRef.value?.clearValidate()
form.load('edit', detail)
visible.value = true
}
async function submit() {
await formRef.value?.validate()
submitting.value = true
try {
await recordApi.save(model)
visible.value = false
}
finally {
submitting.value = false
}
await form.submit()
}
</script>
<template>
<el-form ref="form" :model="model" :rules="rules">
<el-form v-bind="form.host">
<el-form-item label="名称" prop="name">
<el-input v-model="model.name" />
<el-input v-model="form.model.name" />
</el-form-item>
<el-button :loading="submitting" @click="submit">保存</el-button>
<el-button :loading="form.submitting" @click="submit">保存</el-button>
</el-form>
</template>业务动作继续留在页面
defaults同时是新建和重置的基线。recordId: undefined留在基线里,下一次新建才不会复用上一条记录的标识。- API 调用进入
onSubmit;成功提示、关闭弹窗和刷新列表继续由页面明确处理。 - FormItem 可以继续使用宿主原生
prop。接口字段错误需要显示在对应控件下方时,再使用form.item(path)。 - 纯查询表单没有模式切换和提交生命周期,宿主原生 Form 已经覆盖所需状态。
2. 动态表单:把条件、动态行和远程选项写进表单定义
字段依赖拆在模板和 watcher 里
字段显隐常写在模板里,级联请求和 loading 留在 watcher 里,动态行再维护一套临时 key、索引规则和错误清理。字段之间的依赖被拆到了页面各处。
让 form 管依赖关系
import { reactive, shallowRef, watch } from 'vue'
import { r, useElForm } from '@vformjs/element-plus'
interface ContactRow {
name: string
phone: string
phoneRequired: boolean
}
const model = reactive({ category: '', region: '', contacts: [] as ContactRow[] })
const regionOptions = shallowRef<Array<{ label: string, value: string }>>([])
const regionLoading = shallowRef(false)
watch(() => model.category, async (category) => {
model.region = ''
regionLoading.value = true
try {
regionOptions.value = await catalogApi.regions(category)
}
finally {
regionLoading.value = false
}
})
function appendContact() {
model.contacts.push({ name: '', phone: '', phoneRequired: false })
}
const form = useElForm({
defaults: { category: '', region: '', contacts: [] as ContactRow[] },
rules: {
'contacts.*.name': r.required(),
'contacts.*.phone': ({ item }) =>
(item as ContactRow).phoneRequired ? r.required() : null,
},
when: {
region: values => values.category === 'regional',
},
options: {
region: {
deps: ['category'],
load: ({ get, signal }) =>
catalogApi.regions(String(get('category')), { signal }),
},
},
})
const contacts = form.list<ContactRow>('contacts', {
defaultItem: () => ({ name: '', phone: '', phoneRequired: false }),
})
const hideRegion = form.hidden('region')
const availableRegions = form.options('region') <template>
<el-form v-bind="form.host">
<el-form-item v-if="model.category === 'regional'" label="区域" prop="region">
<el-form-item v-if="!hideRegion" label="区域" v-bind="form.item('region')">
<el-select v-model="model.region" :loading="regionLoading">
<el-option v-for="option in regionOptions" :key="option.value" v-bind="option" />
<el-select v-model="form.model.region" :loading="availableRegions.loading">
<el-option v-for="option in availableRegions.items" :key="option.value" v-bind="option" />
</el-select>
</el-form-item>
<div v-for="(row, index) in model.contacts" :key="index">
<div v-for="row in contacts.fields" :key="row.key">
<el-form-item :prop="`contacts.${index}.name`">
<el-form-item v-bind="form.item(`contacts.${row.index}.name`)">
<el-input v-model="form.model.contacts[row.index].name" />
</el-form-item>
<el-button @click="model.contacts.splice(index, 1)">删除</el-button>
<el-button @click="contacts.remove(row.index)">删除</el-button>
</div>
<el-button @click="appendContact">新增联系人</el-button>
<el-button @click="contacts.append()">新增联系人</el-button>
</el-form>
</template>请求、行 key 和规则各有归属
options在依赖变化时重置字段、取消旧请求,并只保留最新结果。领域接口仍由业务代码提供。contacts.fields的 key 不进入提交数据。移动或删除行时,已有字段错误会跟着对应的业务行移动。when管字段是否显示,rules中的条件回调管当前生效的规则;隐藏字段会退出本次校验。
3. 大型表单:控制追踪成本,统一草稿和接口错误
全模型追踪放大运行时成本
大型模型每次输入都做 deep watch、clone 和 diff,会把字段数量直接变成运行时成本。数组 key、提交锁、接口字段错误和草稿兼容代码也会在每个页面重复出现。
改用按字段追踪
import { computed, reactive, shallowRef, useTemplateRef, watch } from 'vue'
import type { FormInstance } from 'element-plus'
import { r, submitFail, useElForm } from '@vformjs/element-plus'
interface LineRow {
itemCode: string
quantity: number
}
interface DocumentForm {
documentId: string | undefined
title: string
notes: string
lines: LineRow[]
attachmentIds: string[]
}
function createDefaults(): DocumentForm {
return { documentId: undefined, title: '', notes: '', lines: [], attachmentIds: [] }
}
const hostRef = useTemplateRef<FormInstance>('host')
const model = reactive(createDefaults())
const baseline = shallowRef(structuredClone(model))
const submitting = shallowRef(false)
const changedPaths = shallowRef<string[]>([])
watch(model, () => {
changedPaths.value = diffDocument(baseline.value, model)
}, { deep: true })
const dirty = computed(() => changedPaths.value.length > 0)
const form = useElForm<DocumentForm>({
defaults: createDefaults,
tracking: 'explicit',
rules: {
title: [r.required()],
'lines.*.itemCode': [r.required()],
'lines.*.quantity': [r.numberMin(1)],
},
async onSubmit(values) {
const response = await documentApi.save(values)
if (!response.ok) {
return submitFail(response.error, { errors: response.fieldErrors })
}
},
})
const title = form.field('title')
const lines = form.list<LineRow>('lines', {
defaultItem: () => ({ itemCode: '', quantity: 1 }),
})
async function submit() {
await hostRef.value?.validate()
submitting.value = true
try {
await documentApi.save(model)
}
catch (error) {
projectServerErrors(error, hostRef.value)
}
finally {
submitting.value = false
}
await form.submit()
}
function saveDraft() {
draftStore.save(structuredClone(model))
draftStore.save(form.snapshotDraft())
}
function restoreDraft(snapshot: unknown) {
Object.assign(model, snapshot)
form.restoreDraft(snapshot)
}<template>
<el-form ref="host" :model="model">
<el-form v-bind="form.host">
<el-input v-model="model.title" />
<el-input v-model="title" />
<el-table :data="model.lines">
<el-table :data="form.model.lines" row-key="itemCode">
<!-- 领域列、计算和上传组件保持原样 -->
</el-table>
<el-button @click="model.lines.push({ itemCode: '', quantity: 1 })">新增行</el-button>
<el-button @click="lines.append()">新增行</el-button>
</el-form>
</template>草稿、错误和业务逻辑各有归属
tracking: 'explicit'让字段更新经过form.field(path)、form.set或字段数组方法,避免每次输入都 clone 和 diff 整个模型。form.snapshotDraft()生成版本化快照;form.restoreDraft()会丢弃旧字段、补齐新字段,并把恢复后的草稿保留为未保存状态。- 上传流程、表格列、领域计算和 payload 转换继续留在业务代码里。vformjs 统一表单生命周期和错误合同。
4. 多区块表单:组合 form,保留 section 边界
父页面承担所有编排
父页面通过多个组件 ref 并发校验,再拼接模型、传播 loading、逐个 reset。新增或移除一个 section,父页面里的提交和重置流程都要跟着修改。
组合 form,保留独立宿主
import { reactive, useTemplateRef } from 'vue'
import type { FormInstance } from 'element-plus'
import { useElForm, useFormGroup } from '@vformjs/element-plus'
const baseHost = useTemplateRef<FormInstance>('baseHost')
const linesHost = useTemplateRef<FormInstance>('linesHost')
const reviewHost = useTemplateRef<FormInstance>('reviewHost')
const baseModel = reactive({ title: '' })
const linesModel = reactive({ lines: [] as LineRow[] })
const reviewModel = reactive({ remark: '' })
const baseForm = useElForm({ defaults: { title: '' } })
const linesForm = useElForm({ defaults: { lines: [] as LineRow[] } })
const reviewForm = useElForm({ defaults: { remark: '' } })
const group = useFormGroup({
base: baseForm,
lines: linesForm,
review: reviewForm,
})
async function submit() {
const [baseValid, linesValid, reviewValid] = await Promise.all([
baseHost.value?.validate(),
linesHost.value?.validate(),
reviewHost.value?.validate(),
])
if (!baseValid || !linesValid || !reviewValid)
return
await documentApi.save({ base: baseModel, lines: linesModel, review: reviewModel })
await group.submit(values => documentApi.save(values))
}
function openEdit(detail: GroupedDocument) {
Object.assign(baseModel, detail.base)
Object.assign(linesModel, detail.lines)
Object.assign(reviewModel, detail.review)
group.load('edit', detail)
}
function resetAll() {
baseHost.value?.resetFields()
linesHost.value?.resetFields()
reviewHost.value?.resetFields()
group.reset()
}<template>
<BaseSection ref="baseHost" v-model="baseModel" />
<LinesSection ref="linesHost" v-model="linesModel" />
<ReviewSection ref="reviewHost" v-model="reviewModel" />
<BaseSection :form="baseForm" />
<LinesSection :form="linesForm" />
<ReviewSection :form="reviewForm" />
<el-button :loading="group.submitting" @click="submit">提交全部</el-button>
</template>每个 section 继续独立
- 每个 section 继续绑定自己的 UI Form 和规则,
useFormGroup只组合生命周期,不创建巨型共享宿主。 group.validate()并发校验成员,错误留在对应 section,并滚动到第一个无效成员。group.load()把数据切片交给对应成员。记录里缺少某个 section 时,该成员回到自己的 factory defaults。- 子组件已经拥有 form 时,只向父页面暴露
FormGroupMember需要的接口,内部响应式状态仍由子组件管理。
5. 原子编辑页:把强耦合区块收进一个 form
公开文档使用重构示例
下面的示例是重新构造的通用代码。路由、标识符、字段标签、API 名称和 payload 结构均不来自业务仓库;只保留工程形态:多个可视区块原子提交、 重复行存在跨行规则,并且服务端草稿只校验少数字段。
只有当这些区块共享提交边界和联动关系时,才使用一个 form。如果每个 section 都有独立提交边界,应继续保留独立宿主,并使用上一节的 useFormGroup。
section ref 和行级宿主重复维护生命周期
import { computed, reactive, ref, useTemplateRef, watch } from 'vue'
import { r, useElForm } from '@vformjs/element-plus'
interface VariantRow {
code: string
color: string
notes: string
attributes: Record<string, unknown>
}
interface EditorValues {
summary: {
code: string
notes: string
}
attributes: Record<string, unknown>
variants: VariantRow[]
}
interface EditorPayload {
header: EditorValues['summary']
fields: Record<string, unknown>
entries: VariantRow[]
}
function toPayload(values: EditorValues): EditorPayload {
return {
header: { ...values.summary },
fields: { ...values.attributes },
entries: values.variants.map(row => ({
code: row.code,
color: row.color,
notes: row.notes,
attributes: { ...row.attributes },
})),
}
}
const summaryRef = useTemplateRef<SectionHandle>('summaryRef')
const attributesRef = useTemplateRef<SectionHandle>('attributesRef')
const variantsRef = useTemplateRef<SectionHandle>('variantsRef')
const submitting = ref(false)
const savingDraft = ref(false)
const summaryModel = reactive({ code: '', notes: '' })
const variantRows = ref<VariantRow[]>([])
const colorRules = computed(() => [{
required: variantRows.value.some(row => Boolean(row.color)),
message: 'Required',
}])
watch(
() => summaryModel.notes,
(notes) => {
variantRows.value.forEach((row) => {
row.notes = notes
})
},
)
const form = useElForm<EditorValues>({
defaults: {
summary: { code: '', notes: '' },
attributes: {},
variants: [],
},
tracking: 'explicit',
rules: {
'summary.code': r.required(),
'variants.*.code': r.required(),
'variants.*.color': ({ values }) =>
values.variants.some(row => row.color) ? r.required() : null,
},
linkage: [
{
deps: ['summary.notes'],
run: ({ get, set, values }) => {
const notes = String(get('summary.notes') ?? '')
values.variants.forEach((_row, index) => {
set(`variants.${index}.notes`, notes)
})
},
},
],
})
const variants = form.list<VariantRow>('variants', {
defaultItem: () => ({
code: '',
color: '',
notes: form.model.summary.notes,
attributes: {},
}),
})
async function submit() {
const results = await Promise.allSettled([
summaryRef.value?.validate(),
attributesRef.value?.validate(),
variantsRef.value?.validate(),
])
if (results.some(result => result.status === 'rejected'))
return
submitting.value = true
try {
await editorApi.save({
summary: summaryRef.value?.getValues(),
attributes: attributesRef.value?.getValues(),
variants: variantsRef.value?.getValues(),
})
}
finally {
submitting.value = false
}
await form.submit(values => editorApi.save(toPayload(values)))
}
async function saveDraft() {
summaryRef.value?.clearValidate()
attributesRef.value?.clearValidate()
variantsRef.value?.clearValidate()
await Promise.all([
summaryRef.value?.validateField('code'),
variantsRef.value?.validateField('code'),
])
const result = await form.validateField([
'summary.code',
'variants.*.code',
])
if (!result.ok)
return
savingDraft.value = true
try {
await editorApi.saveDraft(toPayload(form.get()))
}
finally {
savingDraft.value = false
}
}<template>
<SummarySection ref="summaryRef" />
<AttributesSection ref="attributesRef" />
<VariantsSection ref="variantsRef" />
<el-form v-bind="form.host">
<SummarySection :form="form" />
<AttributesSection :form="form" />
<div v-for="row in variants.fields" :key="row.key">
<VariantSection :form="form" :index="row.index" />
<el-button @click="variants.remove(row.index)">移除</el-button>
</div>
</el-form>
<el-button :loading="submitting" @click="submit">提交</el-button>
<el-button :loading="form.submitting" @click="submit">提交</el-button>
<el-button :loading="savingDraft" @click="saveDraft">保存草稿</el-button>
</template>一个状态所有者,边界仍然明确
- 一个宿主统一负责校验顺序、错误、loading 和首个错误滚动。
form.list()把行 key 留在提交值之外,并在插入、删除和移动后重映射 行错误。- wildcard 规则替代逐行注册 validator。任意一行填写
color后,条件规则 会要求每一行都填写该字段。 linkage显式描述跨区块同步。子 section 只渲染字段,不再通过组件 ref 暴露生命周期方法。toPayload()是上面定义的应用层 mapper。运行时字段渲染、上传、计算和 传输层序列化不会迁入 vformjs。- 服务端草稿仍然是 API 动作;
snapshotDraft()是本地版本化快照,不能 替代服务端保存。
6. 用 Agent 迁移存量页面
vformjs CLI 带有与当前版本匹配的迁移 skill。Agent 会读取完整组件、调用方、模型和 API 类型、子表单合同与测试,再把页面里重复的表单状态迁入 vformjs。
确定性 codemod 只能替换语义明确的语法。隐藏值是否清空、级联字段如何重置、payload 怎样转换、多个 section 是否原子提交,需要结合项目代码和产品规则判断。Agent 可以完成代码迁移和验证,维护者只确认这些业务选择。
| 页面形态 | Agent 完成 | 你需要确认 |
|---|---|---|
| 常规 CRUD | 迁移模式、基线、校验、提交和重置,删除旧状态 | 保存成功后的页面动作 |
| 动态表单 | 迁移条件规则、动态行和远程选项 | 隐藏值与级联值的重置规则 |
| 大型表单 | 迁移 tracking、草稿和接口字段错误 | 领域计算、payload 转换和性能目标 |
| 多区块表单 | 每个宿主保留一个 form,再组合提交 | section 归属与原子提交边界 |
安装与 CLI 同版本的 skill:
pnpm dlx vformjs skill install
# 安装到 Claude skill 目录
pnpm dlx vformjs skill install --agent claude迁移任务可以直接写成:
使用已安装的 vformjs skill 迁移当前表单。
读取完整组件、全部调用方、子表单合同、模型/API 类型和现有测试。
保留现有 UI、校验反馈、接口语义和成功后的页面动作。
把新增、编辑、重置、提交、动态字段和接口错误迁入一个明确的 form 实例,
迁移所有调用方,并删除旧 model/rules/ref/reset/submit 状态机。
仓库代码能确定的内容直接完成;隐藏值策略、级联重置、payload 映射或
section 归属存在多种合理业务语义时,再向维护者提问。
运行 typecheck 和目标 build,并实际验证新增、编辑、重置与无效提交。验收迁移结果
- 页面只保留一个表单状态来源,旧 model 不再与
form.model双绑。 - 提交只经过
form.submit(),旧validate()和提交锁已经删除。 - 动态表单覆盖显隐、行移动/删除和旧选项请求竞态。
- 多区块表单覆盖单个 section 失败、全部成功和整体重置。
- typecheck 和 build 通过后,还要运行实际表单路径。
skill 安装包内包含当前 CLI 版本的迁移决策和验证规则,工具升级后不会继续使用旧版迁移合同。
哪些页面值得迁移
新增、编辑、重置、提交和接口错误已经在多个页面重复时,迁入 vformjs 可以删除成套的页面状态。纯查询、纯详情或只有一两个字段的页面,宿主原生 Form 已经足够。
| 当前页面 | 建议 |
|---|---|
| 单宿主、静态规则、标准 CRUD | 从一个新增/编辑弹窗开始 |
| 条件字段、动态行、远程选项 | 列出字段依赖,再配置 when、条件 rules、options 和 form.list() |
| 大表格或深层嵌套模型 | 使用 tracking: 'explicit',逐字段接入 |
| 多个强耦合 section 原子提交 | 优先使用一个宿主;section 只负责渲染,重复行使用 form.list() |
| 多个可独立校验的 section | 每个 section 一个 form,再用 useFormGroup 组合 |
| 纯查询、纯详情、只有一两个字段 | 保留宿主原生 Form |
继续阅读: