Vue 基础体系 · 第 8/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 表单工程:受控输入、校验、异步提交、错误与可访问性

表单不是“把几个输入框放进 <form>”这么简单。一个可维护的 Vue 表单至少要同时处理五类问题:

  1. 输入值由谁持有,DOM 与响应式状态如何保持一致;
  2. 何时校验、校验什么,以及客户端校验与服务端校验的边界;
  3. 提交期间如何处理异步状态、重复提交、超时和请求竞态;
  4. 如何区分字段错误、业务错误和网络错误;
  5. 键盘用户、屏幕阅读器用户和使用辅助技术的用户,能否理解并完成流程。

下面的示例基于 Vue 3、Composition API、TypeScript 和 Vite。代码使用 Vue 3 稳定 API,不依赖实验性能力。


一、先建立表单状态模型

1. “受控输入”是什么意思

受控输入(controlled input)指的是:输入框当前显示的值,以应用状态为唯一事实来源

以一个邮箱输入框为例,理想的数据流是:

用户输入
  ↓
input 事件
  ↓
更新响应式状态 form.email
  ↓
Vue 重新渲染
  ↓
<input> 的 value 等于 form.email

可以把它写成两个条件:

DOM.value = state.email
state.email' = event.target.value

其中:

  • state.email 是 Vue 响应式状态中的邮箱值;
  • DOM.value 是浏览器输入框当前显示的值;
  • state.email' 表示处理输入事件后的新状态;
  • event.target.value 是用户刚刚输入的值。

如果只修改 DOM 而不更新状态,下一次 Vue 渲染可能覆盖用户输入;如果只修改状态而没有把状态绑定给 DOM,界面又不会反映程序修改。受控输入要求这两个方向都存在。

在 Vue 中,文本输入的:

<input v-model="email">

大致等价于:

<input
  :value="email"
  @input="email = ($event.target as HTMLInputElement).value"
/>

v-model 不是一个独立的数据存储系统,而是对“属性绑定 + 事件监听”的语法封装。

2. v-model 在不同元素上的语义不同

文本框通常使用 valueinput 事件:

<input v-model="form.email">

复选框使用 checkedchange 事件,绑定结果通常是布尔值或数组:

<input type="checkbox" v-model="form.accepted">

下拉框使用 valuechange 事件:

<select v-model="form.role">
  <option value="developer">开发者</option>
  <option value="designer">设计师</option>
</select>

因此,不能机械地认为所有表单元素都等价于“读取 event.target.value”。例如复选框的语义是 event.target.checked,日期、数字和多选也有各自的数据表示。

.number 修饰符也不是类型安全的替代品:

<input v-model.number="age">

它会尝试把输入值转换为数字,但空值、非法值等情况仍需要业务代码处理。TypeScript 的类型声明不会自动验证运行时输入。

3. 组件上的 v-model

在 Vue 3 中,组件默认的 v-model 契约是:

父组件传入:modelValue
子组件发出:update:modelValue

父组件:

<UserNameInput v-model="form.name" />

子组件可以等价地理解为:

<UserNameInput
  :model-value="form.name"
  @update:model-value="form.name = $event"
/>

子组件不能直接修改 props,因为 props 是父组件传入的数据。一个最小的输入组件如下:

<!-- BaseTextField.vue -->
<script setup lang="ts">
defineProps<{
  modelValue: string
  label: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()
</script>

<template>
  <label>
    <span>{{ label }}</span>
    <input
      :value="modelValue"
      @input="
        emit(
          'update:modelValue',
          ($event.target as HTMLInputElement).value
        )
      "
    >
  </label>
</template>

这里的组件契约是明确的:

  • modelValue 是输入;
  • update:modelValue 是唯一的更新通道;
  • 子组件不会修改父组件的对象;
  • 父组件仍然拥有表单数据的所有权。

如果组件需要多个双向绑定,例如同时编辑 firstNamelastName,Vue 3 还支持参数化的 v-model:first-name,对应的契约是 firstNameupdate:firstName。但组件应只暴露真实存在、可解释的模型,不要为了省事把整个表单对象隐式透传进去。


二、一个可运行的表单示例

下面的 App.vue 展示了一个注册表单,包含:

  • 受控输入;
  • 字段级同步校验;
  • 提交时完整校验;
  • 模拟服务端字段错误;
  • 提交状态;
  • 通用错误;
  • 错误后的焦点移动;
  • labelaria-invalidaria-describedby 和状态播报。

它可以直接放入由 Vite 创建的 Vue 3 TypeScript 项目中运行。

<script setup lang="ts">
import { computed, nextTick, reactive, ref } from 'vue'

type FormModel = {
  name: string
  email: string
  password: string
  accepted: boolean
}

type FieldName = keyof FormModel
type FieldErrors = Partial<Record<FieldName, string>>

type ServerValidationError = Error & {
  fieldErrors?: FieldErrors
}

const formEl = ref<HTMLFormElement | null>(null)

const form = reactive<FormModel>({
  name: '',
  email: '',
  password: '',
  accepted: false,
})

const touched = reactive<Record<FieldName, boolean>>({
  name: false,
  email: false,
  password: false,
  accepted: false,
})

const errors = reactive<FieldErrors>({})
const submitted = ref(false)
const submitting = ref(false)
const submitMessage = ref('')

const fields: FieldName[] = [
  'name',
  'email',
  'password',
  'accepted',
]

function validateField(field: FieldName): string | undefined {
  switch (field) {
    case 'name':
      if (!form.name.trim()) return '请输入姓名'
      if (form.name.trim().length < 2) return '姓名至少需要 2 个字符'
      return undefined

    case 'email':
      if (!form.email.trim()) return '请输入邮箱'
      if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
        return '请输入格式正确的邮箱'
      }
      return undefined

    case 'password':
      if (!form.password) return '请输入密码'
      if (form.password.length < 8) return '密码至少需要 8 个字符'
      return undefined

    case 'accepted':
      if (!form.accepted) return '请先同意服务条款'
      return undefined
  }
}

function validateAll(): boolean {
  for (const field of fields) {
    delete errors[field]

    const message = validateField(field)
    if (message) {
      errors[field] = message
    }
  }

  return Object.keys(errors).length === 0
}

function visibleError(field: FieldName): string | undefined {
  if (!submitted.value && !touched[field]) {
    return undefined
  }

  return errors[field]
}

function updateField<K extends FieldName>(
  field: K,
  value: FormModel[K],
) {
  form[field] = value

  // 用户开始修正字段后,移除旧错误。
  // 新错误会在 blur 或再次提交时重新计算。
  delete errors[field]
  submitMessage.value = ''
}

function handleInput(field: FieldName, event: Event) {
  const target = event.target

  if (field === 'accepted') {
    updateField(
      field,
      (target as HTMLInputElement).checked,
    )
    return
  }

  updateField(
    field,
    (target as HTMLInputElement).value,
  )
}

function handleBlur(field: FieldName) {
  touched[field] = true

  const message = validateField(field)

  if (message) {
    errors[field] = message
  } else {
    delete errors[field]
  }
}

async function focusFirstInvalidField() {
  await nextTick()

  formEl.value
    ?.querySelector<HTMLElement>('[aria-invalid="true"]')
    ?.focus()
}

function isServerValidationError(
  error: unknown,
): error is ServerValidationError {
  return (
    error instanceof Error &&
    'fieldErrors' in error
  )
}

/**
 * 这是一个可运行的模拟 API。
 * 真实项目中通常将它替换为 fetch/axios 请求。
 */
async function fakeRegisterRequest(
  data: FormModel,
): Promise<void> {
  await new Promise((resolve) => {
    window.setTimeout(resolve, 700)
  })

  if (data.email === 'taken@example.com') {
    const error = new Error('邮箱已经被注册') as ServerValidationError
    error.fieldErrors = {
      email: '该邮箱已经被注册,请更换其他邮箱',
    }
    throw error
  }

  if (data.name.trim().toLowerCase() === 'server-error') {
    throw new Error('服务暂时不可用,请稍后重试')
  }
}

async function handleSubmit() {
  if (submitting.value) {
    return
  }

  submitted.value = true
  submitMessage.value = ''

  const valid = validateAll()

  if (!valid) {
    await focusFirstInvalidField()
    return
  }

  submitting.value = true

  try {
    // 提交快照,而不是在异步过程中反复读取可变的 form。
    const payload: FormModel = {
      name: form.name.trim(),
      email: form.email.trim(),
      password: form.password,
      accepted: form.accepted,
    }

    await fakeRegisterRequest(payload)

    submitMessage.value = '注册成功'
  } catch (error: unknown) {
    if (isServerValidationError(error) && error.fieldErrors) {
      Object.assign(errors, error.fieldErrors)
      submitMessage.value = '请修正表单中的错误'
      await focusFirstInvalidField()
    } else {
      submitMessage.value =
        '提交失败,可能是网络或服务器问题,请稍后重试'
    }
  } finally {
    submitting.value = false
  }
}

const hasVisibleErrors = computed(() => {
  return fields.some((field) => Boolean(visibleError(field)))
})
</script>

<template>
  <main>
    <h1>创建账号</h1>

    <form
      ref="formEl"
      novalidate
      @submit.prevent="handleSubmit"
    >
      <div
        v-if="submitMessage"
        role="status"
        aria-live="polite"
      >
        {{ submitMessage }}
      </div>

      <div>
        <label for="name">姓名</label>
        <input
          id="name"
          name="name"
          type="text"
          autocomplete="name"
          :value="form.name"
          :aria-invalid="
            visibleError('name') ? 'true' : undefined
          "
          :aria-describedby="
            visibleError('name') ? 'name-error' : undefined
          "
          @input="handleInput('name', $event)"
          @blur="handleBlur('name')"
        />
        <p
          v-if="visibleError('name')"
          id="name-error"
          role="alert"
        >
          {{ visibleError('name') }}
        </p>
      </div>

      <div>
        <label for="email">邮箱</label>
        <input
          id="email"
          name="email"
          type="email"
          autocomplete="email"
          :value="form.email"
          :aria-invalid="
            visibleError('email') ? 'true' : undefined
          "
          :aria-describedby="
            visibleError('email') ? 'email-error' : undefined
          "
          @input="handleInput('email', $event)"
          @blur="handleBlur('email')"
        />
        <p
          v-if="visibleError('email')"
          id="email-error"
          role="alert"
        >
          {{ visibleError('email') }}
        </p>
      </div>

      <div>
        <label for="password">密码</label>
        <input
          id="password"
          name="password"
          type="password"
          autocomplete="new-password"
          :value="form.password"
          :aria-invalid="
            visibleError('password') ? 'true' : undefined
          "
          :aria-describedby="
            visibleError('password')
              ? 'password-error'
              : undefined
          "
          @input="handleInput('password', $event)"
          @blur="handleBlur('password')"
        />
        <p
          v-if="visibleError('password')"
          id="password-error"
          role="alert"
        >
          {{ visibleError('password') }}
        </p>
      </div>

      <div>
        <label>
          <input
            name="accepted"
            type="checkbox"
            :checked="form.accepted"
            :aria-invalid="
              visibleError('accepted') ? 'true' : undefined
            "
            :aria-describedby="
              visibleError('accepted')
                ? 'accepted-error'
                : undefined
            "
            @change="handleInput('accepted', $event)"
            @blur="handleBlur('accepted')"
          />
          我同意服务条款
        </label>

        <p
          v-if="visibleError('accepted')"
          id="accepted-error"
          role="alert"
        >
          {{ visibleError('accepted') }}
        </p>
      </div>

      <button
        type="submit"
        :disabled="submitting"
        :aria-busy="submitting ? 'true' : undefined"
      >
        {{ submitting ? '提交中…' : '注册' }}
      </button>

      <p v-if="hasVisibleErrors">
        表单中仍有需要修正的内容。
      </p>
    </form>
  </main>
</template>

这个示例中的关键状态

form 保存用户真正输入的数据:

const form = reactive<FormModel>({
  name: '',
  email: '',
  password: '',
  accepted: false,
})

touched 表示字段是否经历过失焦。它不代表字段一定有效,只代表用户已经离开过该字段。

errors 保存当前已计算出的错误。它也不等于“所有潜在错误”:在用户尚未操作字段时,可以选择不展示错误。

submitted 用来区分提交前和提交后的展示策略。提交后,即使某字段从未获得焦点,也应该向用户展示其错误,否则用户可能不知道为什么提交没有成功。

submitting 是异步状态的锁。它不仅用于显示“提交中”,还用于阻止同一个表单同时发出多个相同请求。


三、校验不是一个正则表达式,而是一个状态转换过程

1. 校验的形式化定义

设表单模型为:

x = (name, email, password, accepted)

每个字段有一个校验函数:

r_name(x)       → 错误信息或空
r_email(x)      → 错误信息或空
r_password(x)   → 错误信息或空
r_accepted(x)   → 错误信息或空

整个表单有效,当且仅当:

valid(x) =
  r_name(x) = 空
  ∧ r_email(x) = 空
  ∧ r_password(x) = 空
  ∧ r_accepted(x) = 空

validateAll() 的执行过程就是:

  1. 清除旧的字段错误;
  2. 按字段调用规则;
  3. 将每个非空结果写入 errors
  4. errors 为空时返回 true

例如:

name = "A"
email = "taken@example.com"
password = "12345678"
accepted = true

客户端规则的中间结果可能是:

r_name       = "姓名至少需要 2 个字符"
r_email      = 空
r_password   = 空
r_accepted   = 空

因此客户端校验失败,不能提交。即便所有客户端规则都通过,也只能推出:

客户端规则通过

不能推出:

服务端一定接受

邮箱是否已注册、当前用户是否有权限、价格是否仍然有效,都必须由服务端最终判断。

2. 什么时候校验

常见触发点有三种:

输入时校验

每次输入都运行规则:

input → 更新值 → 运行校验 → 显示错误

优点是反馈及时,缺点是用户刚输入第一个字符就可能看到“格式错误”,容易造成噪声。密码强度指示器适合输入时更新,但必填错误不一定适合从第一个字符开始显示。

失焦时校验

blur → 标记 touched → 运行该字段校验

这通常是较平衡的默认策略:用户完成一个字段后,离开它时获得反馈。

提交时校验

提交时运行全部规则,并将错误焦点移动到第一个错误字段。这是不可省略的兜底路径,因为用户可能直接按 Enter 提交,也可能通过浏览器自动填充表单而没有触发预期的交互事件。

示例中的策略是:

  • 输入时清除旧错误;
  • 失焦时校验当前字段;
  • 提交时校验全部字段;
  • 提交失败后显示全部相关错误。

3. novalidate 的含义和边界

示例中的:

<form novalidate>

会关闭浏览器默认的约束验证 UI,但不会关闭 type="email"required 等属性本身的语义,也不会阻止应用代码校验。

这样做的原因是避免出现两套互相竞争的错误展示:

  • 浏览器弹出一套原生提示;
  • Vue 又渲染另一套提示。

如果团队决定完全使用浏览器原生验证,也可以不写 novalidate,并通过 requiredminlengthpattern 等属性定义约束。但原生验证气泡的文案、样式和跨浏览器交互不完全由 Vue 控制。无论采用哪种方式,服务端校验都不能省略。

4. 同步校验与异步校验必须分层

同步校验适合检查:

  • 是否为空;
  • 长度;
  • 字符格式;
  • 数值范围;
  • 多个字段之间的简单关系。

异步校验适合检查:

  • 用户名是否已占用;
  • 邀请码是否有效;
  • 服务端计算出的业务规则。

异步校验不能简单塞进同步的 validateField() 中。例如下面的逻辑会产生竞态:

用户输入 a       → 请求 A
用户输入 ab      → 请求 B
请求 B 先返回:ab 有效
请求 A 后返回:a 无效

如果没有版本判断,旧请求 A 会覆盖新状态,界面最终错误地显示 ab 无效。

一种通用解决方法是为每个字段维护请求序号:

let emailCheckVersion = 0
const emailChecking = ref(false)
const emailAsyncError = ref('')

async function checkEmailRemotely(email: string) {
  const version = ++emailCheckVersion
  emailChecking.value = true
  emailAsyncError.value = ''

  try {
    const available = await checkEmailApi(email)

    // 只有最后一次请求有权更新当前状态
    if (version !== emailCheckVersion) {
      return
    }

    if (!available) {
      emailAsyncError.value = '该邮箱已经被注册'
    }
  } finally {
    if (version === emailCheckVersion) {
      emailChecking.value = false
    }
  }
}

如果后端支持取消请求,还可以使用 AbortController 节省资源:

let emailController: AbortController | undefined

async function checkEmail(email: string) {
  emailController?.abort()

  const controller = new AbortController()
  emailController = controller

  const response = await fetch(
    `/api/email-availability?email=${encodeURIComponent(email)}`,
    { signal: controller.signal },
  )

  if (!response.ok) {
    throw new Error('邮箱检查失败')
  }

  return response.json() as Promise<{ available: boolean }>
}

AbortController 解决的是取消旧请求,不应被误认为是完整的业务竞态解决方案。服务端仍可能已经处理了请求,客户端也应在需要时保留请求序号或请求标识判断。


四、异步提交是一个有状态的流程

一个提交过程可以建模为:

idle
  ├─ 本地校验失败 → invalid
  └─ 本地校验通过 → submitting
                         ├─ 成功 → succeeded
                         ├─ 字段错误 → server-invalid
                         ├─ 业务错误 → failed
                         └─ 网络/超时 → failed
stateDiagram-v2
    [*] --> idle
    idle --> invalid: validateAll() 失败
    invalid --> idle: 用户修改字段
    idle --> submitting: validateAll() 通过
    submitting --> succeeded: HTTP 成功且业务成功
    submitting --> serverInvalid: 服务端返回字段错误
    submitting --> failed: 网络、超时或通用错误
    serverInvalid --> idle: 用户修正字段
    failed --> idle: 用户重试
    succeeded --> [*]

这个模型揭示了一个重要事实:loading 不是唯一状态。至少要区分:

  • 是否正在提交;
  • 客户端是否有字段错误;
  • 服务端是否返回字段错误;
  • 是否发生通用失败;
  • 是否已经成功。

1. 为什么要在提交前再次校验

即使每次失焦都校验过,提交时仍应重新校验,因为:

  • 用户可能直接按 Enter;
  • 程序可能修改了表单模型;
  • 某些字段可能没有经历失焦;
  • 校验规则可能依赖多个字段;
  • 异步校验结果可能已经过期。

示例中的顺序是:

submitted.value = true
submitMessage.value = ''

const valid = validateAll()

if (!valid) {
  await focusFirstInvalidField()
  return
}

submitting.value = true

只有本地规则通过后,才进入网络请求阶段。这样不会用一个明确可以在客户端发现的错误去消耗服务端资源。

2. 为什么要提交数据快照

示例中没有直接把可变的 form 传给异步函数,而是创建了:

const payload: FormModel = {
  name: form.name.trim(),
  email: form.email.trim(),
  password: form.password,
  accepted: form.accepted,
}

原因是异步请求执行期间,用户可能继续编辑表单。如果请求函数在多个异步步骤中反复读取 form.email,一次请求可能前后看到不同的值。

提交快照把这次请求绑定到一个确定的数据版本:

用户点击提交
  ↓
读取 form
  ↓
生成 payload
  ↓
请求只使用 payload

这也方便日志记录、重试和测试。

3. 禁用按钮不是并发控制的全部

<button :disabled="submitting">
  {{ submitting ? '提交中…' : '注册' }}
</button>

这能阻止常见的重复点击,但不能替代服务端幂等性。重复请求可能来自:

  • 浏览器重试;
  • 网络代理重传;
  • 用户刷新页面;
  • 多个客户端同时操作;
  • 前端状态异常。

涉及创建订单、支付、注册等操作时,服务端应支持幂等键。典型流程是:

客户端生成 requestId
  ↓
每次重试携带同一个 requestId
  ↓
服务端对同一 requestId 只产生一次业务结果

前端的 submitting 解决交互层重复提交,服务端幂等键解决系统层重复执行,两者不是同一层问题。

4. 真实请求中的响应判断

fetch 在 HTTP 400 或 500 时通常不会自动 throw。因此不能只写:

const response = await fetch('/api/register')
const data = await response.json()

而应明确判断:

async function register(payload: FormModel) {
  const response = await fetch('/api/register', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(payload),
  })

  let body: unknown

  try {
    body = await response.json()
  } catch {
    body = undefined
  }

  if (!response.ok) {
    const error = new Error('注册请求失败') as ServerValidationError

    if (
      typeof body === 'object' &&
      body !== null &&
      'fieldErrors' in body
    ) {
      error.fieldErrors = (
        body as { fieldErrors?: FieldErrors }
      ).fieldErrors
    }

    throw error
  }

  return body
}

这里的因果关系是:

  1. response.ok 只代表 HTTP 状态是否在 200–299;
  2. 非 2xx 必须由应用代码转成可处理的错误;
  3. 返回体可能不是合法 JSON,因此解析也要防御;
  4. 字段错误需要转换为前端自己的 FieldErrors 结构;
  5. 模板只渲染可信的纯文本错误,不应直接使用 v-html 输出服务端内容。

五、错误应该按来源和作用分类

1. 字段级客户端错误

例如:

请输入邮箱
密码至少需要 8 个字符
请先同意服务条款

它们与具体字段对应,适合放在控件附近,并通过 aria-describedby 关联。

2. 服务端字段错误

例如邮箱已经注册:

{
  "fieldErrors": {
    "email": "该邮箱已经被注册,请更换其他邮箱"
  }
}

这类错误不能由前端本地规则推导出来,但仍然应映射到对应字段:

Object.assign(errors, error.fieldErrors)

服务端字段名必须经过边界转换。不能假设后端返回的任意字符串都一定是前端字段:

const allowedFields: FieldName[] = [
  'name',
  'email',
  'password',
  'accepted',
]

function pickFieldErrors(
  input: Record<string, unknown>,
): FieldErrors {
  const result: FieldErrors = {}

  for (const field of allowedFields) {
    if (typeof input[field] === 'string') {
      result[field] = input[field]
    }
  }

  return result
}

这样可以避免后端错误数据污染无关的 UI 状态。

3. 通用错误

网络断开、请求超时、服务端 500 等错误通常没有可靠的字段归属:

提交失败,可能是网络或服务器问题,请稍后重试

它们应显示在表单顶部或提交按钮附近,并通过 role="status"aria-live 让辅助技术感知状态变化。

通用错误不应伪装成“邮箱格式错误”。错误归因错误会让用户修改无关字段,也会增加排障难度。

4. 成功状态

成功消息也需要可访问:

<p role="status" aria-live="polite">
  {{ submitMessage }}
</p>

role="status" 的语义适合播报不需要用户立即打断当前操作的状态。严重、需要立即注意的错误才应谨慎使用更强的通知语义。不要给每一次按键都使用 role="alert",否则屏幕阅读器会被连续打断。


六、可访问性不是给输入框加几个 ARIA 属性

1. 可见标签优先于占位符

正确关系是:

<label for="email">邮箱</label>
<input id="email" name="email">

placeholder 只是输入示例,不是稳定的字段名称,因为:

  • 用户输入后占位符消失;
  • 对比度可能不足;
  • 屏幕阅读器和认知辅助用户不应依赖它理解字段;
  • 复杂表单需要持续可见的字段上下文。

2. 错误必须与字段建立程序关系

下面三个属性承担不同职责:

:aria-invalid="error ? 'true' : undefined"
:aria-describedby="error ? 'email-error' : undefined"
  • aria-invalid="true" 表示当前值未通过校验;
  • aria-describedby 指向错误说明元素;
  • id="email-error" 必须与引用完全匹配。

错误元素应位于 DOM 中,并且内容是用户能理解的行动提示,而不是只写:

Invalid

更好的文本是:

请输入包含 @ 的有效邮箱地址

3. 提交失败后必须处理焦点

只把错误文字渲染出来并不等于用户能找到错误。提交失败后,示例执行:

await nextTick()

formEl.value
  ?.querySelector<HTMLElement>('[aria-invalid="true"]')
  ?.focus()

nextTick() 是必要的,因为错误状态更新后,错误 DOM 和 aria-invalid 属性要等 Vue 完成下一轮 DOM 更新才存在。

错误顺序应与视觉和 DOM 顺序一致。通常把焦点放到第一个错误字段,而不是把焦点强行移到页面顶部。这样键盘用户可以立即修正问题。

如果表单位于模态框、抽屉或路由页面中,还要处理容器级焦点:

  • 打开时将焦点放入容器;
  • 关闭时将焦点还给触发按钮;
  • 模态框打开时限制焦点在模态框内部;
  • 页面切换后将焦点放到新页面标题或主要内容。

这些行为不是 v-model 自动提供的,需要由组件或页面逻辑明确实现。

4. 键盘操作不能依赖鼠标事件

提交按钮应使用:

<button type="submit">提交</button>

而不是:

<div @click="handleSubmit">提交</div>

原生 <button> 自带:

  • 键盘操作;
  • 焦点行为;
  • 表单提交语义;
  • 辅助技术可识别的角色。

@submit.prevent 作用于 <form>,因此鼠标点击提交按钮、在输入框中按 Enter 等路径可以统一进入同一个提交处理函数。

不要用 @keydown 自己模拟所有按钮和输入行为,除非确实是在实现原生元素无法提供的交互。

5. disabledaria-busy 和“看起来禁用”不同

<button
  :disabled="submitting"
  :aria-busy="submitting ? 'true' : undefined"
>
  提交
</button>
  • disabled 会阻止按钮获得焦点和交互;
  • aria-busy 表示相关区域仍在处理;
  • CSS 中降低透明度,只是视觉效果,不能替代语义。

如果提交期间允许用户继续编辑字段,可以只禁用提交按钮;如果必须冻结整个表单,需要额外考虑键盘用户是否还能获知当前状态,以及错误返回后焦点是否仍然合理。不要为了防止重复提交而把焦点从用户正在编辑的输入框强行移走。


七、透传属性与表单组件边界

封装输入组件时,经常需要将父组件传入的 idnamedisabledaria-* 等属性传给真正的原生控件。

组件只有一个根元素时,Vue 默认会将未声明的属性透传到根元素。但表单组件常常有多个根节点,或者根节点是包裹元素,此时不能依赖默认行为。

<script setup lang="ts">
defineOptions({
  inheritAttrs: false,
})

defineProps<{
  modelValue: string
  label: string
  error?: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()
</script>

<template>
  <div class="field">
    <label :for="$attrs.id as string">
      {{ label }}
    </label>

    <input
      v-bind="$attrs"
      :value="modelValue"
      :aria-invalid="error ? 'true' : undefined"
      :aria-describedby="
        error ? `${String($attrs.id)}-error` : undefined
      "
      @input="
        emit(
          'update:modelValue',
          ($event.target as HTMLInputElement).value
        )
      "
    />

    <p
      v-if="error"
      :id="`${String($attrs.id)}-error`"
      role="alert"
    >
      {{ error }}
    </p>
  </div>
</template>

这里有几个边界:

  1. $attrs 包含未声明的属性和监听器,不是类型完整、结构固定的业务对象;
  2. v-bind="$attrs" 可能把不希望进入原生 <input> 的属性一起传下去;
  3. id 必须存在且唯一,否则 label foraria-describedby 会失效;
  4. 如果组件同时接收 error 并自行设置 aria-invalid,父组件传入的同名属性可能产生冲突;
  5. 复杂组件应显式声明关键属性,而不是无限制透传。

因此,透传属性适合处理原生 HTML 边界,不能替代组件契约。对于 modelValue、错误文本、提交状态等业务状态,应使用明确的 propsemits

一个字段组件的 API 可以设计为:

type FieldProps = {
  modelValue: string
  label: string
  error?: string
  disabled?: boolean
}

type FieldEmits = {
  'update:modelValue': [value: string]
  blur: []
}

父组件负责表单模型和提交,字段组件负责标签、输入控件、错误关联和局部交互。这样组件职责不会滑向“字段组件偷偷提交整个表单”。


八、常见错误及诊断方式

1. 直接修改 props

错误:

props.modelValue = value

表现通常是 Vue 警告,或者下一次父组件更新后子组件值被覆盖。正确方式是发出更新事件:

emit('update:modelValue', value)

2. 只监听 click,不监听表单提交

错误:

<button @click="handleSubmit">提交</button>

这可能遗漏 Enter 提交路径,也破坏了原生表单语义。应将逻辑放在:

<form @submit.prevent="handleSubmit">

3. 只在前端校验,不处理服务端拒绝

表现是客户端显示“校验通过”,点击后没有任何可解释反馈,或者把所有错误都归为“网络错误”。

诊断时检查:

  • 是否判断了 response.ok
  • 是否解析了服务端错误结构;
  • 是否将服务端字段错误映射到了 errors
  • 是否在 finally 中恢复 submitting

4. 请求失败后按钮永久禁用

常见原因是只在成功分支执行:

submitting.value = false

异常分支没有恢复状态。异步提交必须使用 finally

try {
  await request()
} catch {
  showError()
} finally {
  submitting.value = false
}

5. 错误文字可见,但辅助技术读不到

按以下顺序排查:

  1. label[for] 是否等于输入框 id
  2. aria-describedby 是否指向实际存在的元素;
  3. 错误元素是否在错误状态时渲染;
  4. aria-invalid 是否只在真实错误时设置;
  5. 错误是否被 CSS 隐藏到辅助技术也无法访问;
  6. 提交后焦点是否移动到了第一个错误字段。

6. 旧请求覆盖新输入

如果界面出现“已经改正确,却又显示旧错误”,通常是异步校验或搜索请求竞态。检查:

  • 是否取消前一个请求;
  • 是否维护请求序号;
  • 是否只允许最新请求更新状态;
  • 组件卸载时是否中止仍在进行的请求。

九、生产环境中的取舍

客户端校验与服务端校验

客户端校验的主要价值是即时反馈和减少明显无效请求;服务端校验负责安全性、数据完整性和最终业务决策。客户端规则可以与服务端共享规则描述,但不能因为“前端已经校验过”就信任客户端数据。

展示错误的时机

全部输入都实时显示错误,会让界面嘈杂;只在提交后显示错误,又会延迟反馈。常见折中是“失焦校验 + 提交兜底”,但密码强度、搜索过滤等交互可以采用输入时反馈。触发策略应由字段语义决定,而不是所有字段统一处理。

加载状态与取消

普通提交通常应锁定提交按钮,避免重复操作。长时间请求可以提供取消能力:

const controller = new AbortController()

fetch('/api/register', {
  method: 'POST',
  signal: controller.signal,
})

// 用户主动取消
controller.abort()

取消不是失败。用户取消请求时,不应提示“服务器错误”;应根据产品流程决定保持输入、清除加载状态,还是显示“已取消”。

密码与日志

密码不应写入普通调试日志、错误上报 payload 或 URL。提交数据快照时也要避免:

console.log(payload)

生产环境的错误追踪应对敏感字段脱敏。autocomplete="new-password" 能帮助浏览器正确处理新密码,但不能代替服务端安全策略。


十、判断一个表单是否真正完成

可以用以下因果链检查实现,而不是只检查页面是否“能点”:

输入框显示值
  是否始终来自响应式模型?
        ↓
字段变化
  是否通过明确事件更新模型?
        ↓
校验失败
  是否关联到具体字段并可被读出?
        ↓
提交开始
  是否阻止重复提交并显示忙碌状态?
        ↓
请求返回
  是否区分成功、字段错误、业务错误、网络错误?
        ↓
提交失败
  用户焦点是否到达第一个可修正的问题?
        ↓
请求重试或并发
  旧结果是否可能覆盖新状态?

Vue 提供了 refreactivecomputedv-model、生命周期钩子和模板事件绑定,但它不会自动决定校验时机、错误分类、请求幂等性或焦点策略。表单工程的核心,是把这些状态和边界显式建模:

  • 用受控输入保证数据流单一;
  • 用分层校验保证反馈及时且服务端仍拥有最终权威;
  • 用明确的异步状态处理提交生命周期;
  • 用字段错误和通用错误分别表达可修正问题与系统故障;
  • 用原生语义、稳定标签、ARIA 关系和焦点管理保证不同用户都能完成操作。

系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。