Skip to content

迁移现有表单 ​

一个 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 收拢生命周期 ​

vue
<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 管依赖关系 ​

ts
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') 
vue
<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、提交锁、接口字段错误和草稿兼容代码也会在每个页面重复出现。

改用按字段追踪 ​

ts
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) 
}
vue
<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,保留独立宿主 ​

ts
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() 
}
vue
<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 和行级宿主重复维护生命周期 ​

ts
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
  }
}
vue
<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:

bash
pnpm dlx vformjs skill install
# 安装到 Claude skill 目录
pnpm dlx vformjs skill install --agent claude

迁移任务可以直接写成:

text
使用已安装的 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

继续阅读:

MIT licensed. Built for forms that already have a UI.