用于业务应用的 Vue 3 表单架构:可复用模式
Vue 3 表单架构:可复用字段组件、清晰的验证规则,以及将服务器错误映射到各输入的实用方法。

为什么在真实的业务应用中表单代码会崩溃
业务应用中的表单很少保持小巧。它一开始只是“几个输入”,然后慢慢扩展成几十个字段、条件分区、权限控制和必须与后端逻辑保持一致的规则。经过几次产品变更后,表单仍能工作,但代码会变得脆弱。
Vue 3 的表单架构很重要,因为表单往往是“临时修复”堆积的地方:再加一个 watcher、再加一个特例、再复制一个组件。今天看起来可行,但变得难以信任,也难以修改。
警告信号很熟悉:不同页面重复的输入行为(标签、格式、必填标记、提示)、不一致的错误展示、分散在各处的验证规则,以及把后端错误简化为一个通用的 toast,无法告诉用户该修正什么。
这些不一致不仅仅是代码风格问题。它们会变成 UX 问题:用户反复提交表单、支持工单增多,团队也会避免动表单,因为担心在隐藏的边缘情况把东西弄坏。
一个好的架构能让表单变得乏味(最好的那种)。有了可预测的结构,你可以添加字段、改变规则、处理服务器响应,而不用重接一切。
你希望有一个表单系统,能提供可复用(同一个字段在任何地方表现一致)、清晰(规则和错误处理易于审查)、可预测的行为(touched、dirty、reset、submit)以及更好的反馈(服务器端错误出现在需要关注的确切输入上)。下面的模式侧重于可复用字段组件、可读的验证,以及把服务器错误映射回特定输入的方法。
表单结构的一个简单心理模型
能经受时间考验的表单是由少量清晰部分组成的小系统,而不是一堆输入。
把表单想成四层,它们单向通信:UI 收集输入,表单状态存储它,验证说明哪里有问题,API 层加载并保存。
四层以及各自负责的内容
- 字段 UI 组件:渲染输入、标签、提示和错误文本。发出值更改事件。
- 表单状态:存储值和错误(以及 touched 和 dirty 标记)。
- 验证规则:纯函数,读取值并返回错误消息。
- API 调用:加载初始数据、提交更改,并把服务器响应翻译成字段错误。
这种分离使变更局限于单一层。遇到新需求时,你只需更新一层而不至于破坏其他层。
字段内部应包含什么,父表单应负责什么
可复用的字段组件应当很“无趣”。它不应该知道你的 API、数据模型或验证规则。它只应显示一个值并展示错误。
父表单负责协调其它所有事情:哪些字段存在、值存放在哪里、何时验证以及如何提交。
一个简单规则有用:如果逻辑依赖于其他字段(比如“State”仅在“Country”为 US 时才必填),把它放在父表单或验证层,而不是字段组件内部。
当新增字段确实非常轻量时,你通常只改默认值或 schema、放置字段的标记,以及字段的验证规则。如果添加一个输入会迫使你改动无关组件,那说明边界不清晰。
可复用字段组件:需要标准化的内容
当表单增长时,最快的提升是别再把每个输入都当成一次性来造。字段组件应当让人觉得可预测,这使得它们使用快速且易于审查。
一组实用的构件:
- BaseField:标签、提示、错误文本、间距和无障碍属性的包装。
- 输入组件:TextInput、SelectInput、DateInput、Checkbox 等。每个组件只关注控件本身。
- FormSection:将相关字段分组,带标题、简短帮助文本和一致的间距。
对于 props,保持一组精简的字段并在所有地方统一使用。更改一个 prop 名称会让你在 40 个表单里修改,这很痛苦。
以下通常能立刻见效:
modelValue和update:modelValue用于v-modellabelrequireddisablederror(单条消息,或者如果你喜欢也可以是数组)hint
Slots 是在不破坏一致性的前提下允许灵活性的地方。保持 BaseField 的布局稳定,但允许小的变体,比如右侧动作(“发送验证码”)或前置图标。如果某种变体出现两次,做成 slot 比分叉组件更好。
统一渲染顺序(标签、控件、提示、错误)。用户浏览更快,测试更简单,服务器错误映射也变得直接,因为每个字段都有一个显而易见的地方显示消息。
表单状态:values、touched、dirty 和 reset
业务应用中大多数表单错误并不是来自输入控件本身,而是来自分散的状态:值放在一处、错误放在另一处,重置按钮只重置了一半。一个干净的 Vue 3 表单架构从一个一致的状态形态开始。
首先,为字段键选择一个命名规则并坚持它。最简单的规则是:字段键等于 API 的 payload 键。如果服务器期望 first_name,那么你的表单键也应该是 first_name。这个小决定会让验证、保存和服务器错误映射容易得多。
把表单状态保存在一个地方(composable、Pinia store 或父组件),让每个字段通过这个状态读写。对于大多数页面,扁平结构就足够了。只有在 API 真正嵌套时才使用嵌套。
const state = reactive({
values: { first_name: '', last_name: '', email: '' },
touched: { first_name: false, last_name: false, email: false },
dirty: { first_name: false, last_name: false, email: false },
errors: { first_name: '', last_name: '', email: '' },
defaults: { first_name: '', last_name: '', email: '' }
})
一种实用的方式去理解这些标记:
touched:用户是否与该字段交互过?dirty:值是否与默认值(或上次保存的值)不同?errors:当前用户应看到的消息是什么?defaults:我们重置回什么?
重置行为应该是可预测的。当你加载一个已有记录时,从同一来源设置 values 和 defaults。然后 reset() 复制 defaults 回 values,清除 touched、dirty 和 errors。
示例:客户档案表单从服务器加载 email。如果用户编辑了它,dirty.email 会变为 true。如果他们点击重置,邮箱会回到加载时的值(而不是空字符串),界面看起来又干净了。
保持可读的验证规则
可读的验证更多关乎你如何表述规则,而不是用哪个库。如果你能一眼看懂某个字段的规则,表单代码就更容易维护。
选择一种你能坚持的规则风格
大多数团队会选择下面几种方式之一:
- 按字段规则:规则与字段使用位置靠近。易于扫描,适合小到中等表单。
- 基于 schema 的规则:规则集中在一个对象或文件里。适合多个界面重用同一模型时使用。
- 混合:简单规则放在字段附近,复杂或共享规则放在中央 schema。
无论你选择哪种,保持规则名称和消息可预测。几个常见规则(required、length、format、range)要比一长串零散的单用工具函数更好。
把规则写成像自然语言一样
好的规则读起来像一句话:“邮箱是必填且必须像邮箱格式”。避免用巧妙的一行代码隐藏意图。
对于大多数业务表单,每次只返回一个字段的消息(第一个失败项)能让 UI 保持冷静并更快帮助用户修复问题。
常见且友好的规则:
- Required:只有当用户真需要填写时才用。
- Length:使用实际数字(例如 2 到 50 个字符)。
- Format:邮箱、电话、邮编等格式校验,但不要用过严的正则拒绝真实输入。
- Range:比如“日期不能晚于今天”或“数量在 1 到 999 之间”。
让异步校验变得明显
像“用户名已被占用”这类异步校验如果静默触发会让人困惑。
在失焦时或短暂暂停后触发检查,显示清晰的“Checking...”状态,并在用户继续输入时取消或忽略过时请求。
决定何时运行验证
时机和规则同样重要。对用户友好的设置通常是:
- 对于有实时反馈价值的字段(例如密码强度)在变更时进行,但要温和。
- 大多数字段在失焦时验证,让用户能在输入时不被频繁报错打断。
- 在提交时对整个表单进行一次完整校验作为最终保障。
将服务器错误映射到正确的输入
客户端检查只是故事的一半。在业务应用中,服务器会基于浏览器无法知道的规则拒绝保存:重复项、权限检查、数据过期、状态变更等。良好的表单体验取决于把这些响应转成在正确输入旁边显示的清晰消息。
规范化为一种内部形态
后端很少在错误格式上达成一致。有的返回单个对象,有的返回列表,有的返回按字段键的嵌套映射。把接收到的任何格式都转换为表单可渲染的统一内部形态。
// what your form code consumes
{
fieldErrors: { "email": ["Already taken"], "address.street": ["Required"] },
formErrors: ["You do not have permission to edit this customer"]
}
保持一些一致的规则:
- 把字段错误存成数组(即便只有一条消息)。
- 把不同的路径样式转换成一种样式(点路径通常很好:
address.street)。 - 把非字段错误单独放在
formErrors。 - 保留原始服务器负载用于日志,但不要直接渲染它。
把服务器路径映射到你的字段键
难点是把后端所谓的“路径”与表单的字段键对齐。为每个字段组件决定键(例如 email、profile.phone、contacts.0.type)并坚持使用。
然后写一个小型映射器来处理常见情况:
address.street(点式)address[0].street(数组的中括号表示)/address/street(JSON Pointer 风格)
规范化之后,<Field name="address.street" /> 应该能直接读取 fieldErrors["address.street"] 而不需特殊处理。
在必要时支持别名。如果后端返回 customer_email 而你的 UI 用的是 email,在规范化阶段保持像 { customer_email: "email" } 这样的映射。
字段错误、表单级错误与聚焦
并非所有错误都属于某个输入。如果服务器返回“超出套餐限制”或“需要付款”,把它显示在表单顶部作为表单级消息。
对于字段错误,把消息显示在输入旁并引导用户到第一个问题:
- 设置服务器错误后,在
fieldErrors中找到第一个在当前渲染表单中存在的键。 - 把它滚动到可见并聚焦(使用每个字段的 ref 和
nextTick)。 - 当用户再次编辑该字段时,清除该字段的服务器错误。
逐步把架构拼起来
当你早早决定哪些属于表单状态、UI、验证和 API,然后用几个小函数把它们串起来时,表单会保持冷静。
一个适用于大多数业务应用的顺序:
- 以一个表单模型和稳定字段键开始。这些键成为组件、验证器和服务器错误之间的契约。
- 创建一个 BaseField 包装器来处理标签、帮助文本、必填标记和错误展示。让输入组件尽量小且一致。
- 添加一个可以按字段运行并能在提交时验证整个表单的验证层。
- 提交到 API。如果失败,把服务器错误翻译成
{ [fieldKey]: message },这样对应输入会显示对应消息。 - 把成功处理分离开(重置、提示、导航),不要让它们泄露进组件或验证器中。
一个简单的状态起点:
const values = reactive({ email: '', name: '', phone: '' })
const touched = reactive({ email: false, name: false, phone: false })
const errors = reactive({}) // { email: '...', name: '...' }
你的 BaseField 接收 label、error,可能还有 touched,并在一个地方渲染消息。每个输入组件只关心绑定和发出更新。
对于验证,使用与模型相同键的位置保存规则:
const rules = {
email: v => (!v ? 'Email is required' : /@/.test(v) ? '' : 'Enter a valid email'),
name: v => (v.length < 2 ? 'Name is too short' : ''),
}
function validateAll() {
Object.keys(rules).forEach(k => {
const msg = rules[k](values[k])
if (msg) errors[k] = msg
else delete errors[k]
touched[k] = true
})
return Object.keys(errors).length === 0
}
当服务器返回错误时,使用相同的键进行映射。如果 API 返回 { "field": "email", "message": "Already taken" },设置 errors.email = 'Already taken' 并把它标记为 touched。如果是全局错误(比如“权限被拒绝”),在表单上方显示。
示例场景:编辑客户资料
想象一个内部管理员界面,支持人员在上面编辑客户资料。表单有四个字段:name、email、phone 和 role(Customer、Manager、Admin)。虽然小,但它覆盖了常见问题。
客户端规则应明确:
- Name:必填,最小长度。
- Email:必填,符合邮箱格式。
- Phone:可选,但若填写必须匹配你接受的格式。
- Role:必填,有时有条件(只有拥有相应权限的用户才能分配 Admin)。
一致的组件契约很重要:每个字段接收当前值、当前错误文本(如有)以及一些布尔值如 touched 和 disabled。标签、必填标记、间距和错误样式不应在每个页面上重写。
再看 UX 流程。支持人员编辑邮箱,按 Tab 键离开,如果格式不对会在邮箱下方看到内联提示。他们修正后点击保存,服务器返回:
- email already exists:在 Email 下显示并聚焦该字段。
- phone invalid:在 Phone 下显示。
- permission denied:在表单顶部显示一条表单级消息。
如果你把错误按字段名(email、phone、role)键控,映射就很简单。字段错误显示在输入旁,表单级错误显示在指定区域。
常见错误以及如何避免
把逻辑集中在一个地方
把验证规则复制到每个页面在初期看起来很快,但当政策变更(密码规则、必填税号、允许的邮箱域)时就会显得麻烦。把规则集中(schema、规则文件、共享函数),让表单使用相同的规则集。
同样也别让低级输入做太多事。如果你的 <TextField> 会调用 API、在失败时重试并解析服务器错误负载,它就不再可复用。字段组件应渲染、发出值变化并显示错误。把 API 调用和映射逻辑放在表单容器或 composable 中。
当你混合关注点时的症状:
- 相同的验证消息出现在多个地方。
- 字段组件引入了 API 客户端。
- 更改一个端点会破坏几个不相关的表单。
- 测试需要挂载大量应用才能验证某个输入。
UX 和无障碍的陷阱
单一的错误横幅像“出了点问题”是不够的。人们需要知道哪个字段有问题以及下一步怎么做。把横幅用于全局失败(网络断开、权限被拒),把服务器错误映射到具体输入让用户能快速修复。
加载与重复提交会造成混乱状态。提交时禁用提交按钮,禁用那些在保存时不应改变的字段,并显示明确的忙碌状态。确保重置和取消能把表单恢复到干净状态。
自定义组件常常会忽视无障碍,一些基础选择可以避免真实的痛点:
- 每个输入都有可见标签(而不仅仅是 placeholder)。
- 错误用正确的 aria 属性与字段关联。
- 提交后把焦点移动到第一个无效字段。
- 被禁用的字段是真正不可交互,并能被辅助工具正确读出。
- 键盘导航端到端可用。
快速检查清单与下一步
在发布新表单前,运行一个快速检查清单。它能捕捉那些会演变成支持工单的小问题。
- 每个字段是否有稳定的键并与 payload 和服务器响应匹配(包括像
billing.address.zip这样的嵌套路径)? - 你能否用一致的字段组件 API(值进来、事件出去、错误和提示进来)渲染任一字段?
- 提交时,你是否只验证一次、阻止重复提交,并把焦点移到第一个无效字段以告诉用户从哪里开始?
- 你能否把错误显示在正确的位置:每字段(输入旁)和表单级别(需要时显示的一般消息)?
- 成功后,你是否正确重置状态(values、touched、dirty),以便下一次编辑从干净状态开始?
如果有一项回答是“否”,先修复它。最常见的表单痛点是字段名不匹配:字段名与 API 脱节,或服务器返回的错误形态让 UI 无法放置它们。
如果你在构建内部工具并希望加快速度,AppMaster (appmaster.io) 遵循相同的基本原则:保持字段 UI 一致、集中规则和工作流,并让服务器响应出现在用户可以采取行动的位置。
常见问题
当你在多个页面上反复看到相同的标签、提示、必填标记、间距和错误样式时,就该标准化了。如果一次“小”变更需要修改很多文件,创建一个共享的 BaseField 包装器和一些一致的输入组件会很快省时。
让字段组件保持“哑”的:渲染标签、控件、提示和错误,并触发值更新事件。把跨字段逻辑、条件规则和任何依赖其它值的内容放在父表单或验证层里,这样字段才能保持可复用。
默认使用与 API 负载一致的稳定键,例如 first_name 或 billing.address.zip。这样验证和服务器错误映射就简单多了,因为你不需要在各层之间不断翻译名称。
一个简单的默认做法是用一个状态对象包含 values、errors、touched、dirty 和 defaults。当所有东西都通过相同结构读写时,重置和提交行为就可预测,能避免“半重置”类的错误。
把 values 和 defaults 都从同一份加载的数据初始化。然后 reset() 将 defaults 复制回 values,并清除 touched、dirty 和 errors,这样界面就会回到服务器最后返回的干净状态。
从以字段名为键的简单函数规则开始,规则与表单状态使用相同的键。每次只返回一个明确的错误消息(第一个失败项),这样 UI 保持简洁,用户也更容易知道下一步怎么修复。
大多数字段在 blur 时验证,然后在提交时对整表单进行一次完整校验作为最后保险。仅在确实有帮助的情况下(比如密码强度)才使用实时变更校验,以免用户在输入时被频繁报错打断。
在 blur 或短延时的防抖后运行异步校验,并显示明确的“正在检查”状态。同时取消或忽略过期请求,避免慢响应覆盖较新的输入并产生混乱的错误信息。
把各种后端格式统一规范为一个内部形态,例如 { fieldErrors: { key: [messages] }, formErrors: [messages] }。采用一种路径风格(点式路径通常很好),这样 address.street 字段总能通过 fieldErrors['address.street'] 读取到对应信息,而无需特殊处理。
把表单级错误显示在表单上方,把字段级错误显示在具体输入旁。提交失败后,把焦点移动到第一个有错误的字段,并在用户再次编辑该字段时清除该字段的服务器错误。


