【声明式UI实用开发技术学与练】第5课 表单处理:受控与非受控

适用技术栈:React、Vue、SwiftUI、Flutter、Jetpack Compose。以 React 为主要教学语言,思维跨框架通用。

核心理念:不讲空理论,每个知识点都落地到能直接用于工作的实用技能。

本课产出: 能写出无 bug 的受控表单;能正确组织多字段表单的状态;能实现即时校验、失焦校验、提交校验;能处理错误展示、自动聚焦、防重复提交;能处理动态字段和数组字段。

一句话预览: 表单是用户输入的唯一入口,也是 bug 最密集的地方。表单处理得好不好,直接决定应用的可用性和健壮性。


一、本课目标

学完本课,你应该能:

  1. 写出标准的受控组件:value + onChange。
  2. 决定多字段表单用单对象还是多状态。
  3. 判断什么时候用受控,什么时候用非受控。
  4. 实现三种校验时机:即时、失焦、提交时。
  5. 处理错误展示、错误聚焦、错误清除。
  6. 防止重复提交、处理提交中的状态。
  7. 处理动态字段:添加、删除、重排。
  8. 用状态机管理表单的提交状态。

二、从一个真实痛点说起

场景:一个注册表单

需求:邮箱、密码、确认密码。要求:邮箱格式校验、密码长度校验、两次密码一致、提交时禁用按钮、提交失败显示错误。

一个开发者这样写:

jsx 复制代码
function RegisterForm() {
  const [email, setEmail] = useState("")
  const [password, setPassword] = useState("")
  const [confirm, setConfirm] = useState("")
  const [emailError, setEmailError] = useState("")
  const [passwordError, setPasswordError] = useState("")
  const [confirmError, setConfirmError] = useState("")
  const [submitting, setSubmitting] = useState(false)

  function handleSubmit() {
    // 校验
    if (!email.includes("@")) {
      setEmailError("邮箱格式不正确")
    }
    if (password.length < 6) {
      setPasswordError("密码至少 6 位")
    }
    if (password !== confirm) {
      setConfirmError("两次密码不一致")
    }
    if (emailError || passwordError || confirmError) return

    setSubmitting(true)
    api.register({ email, password })
      .then(() => { /* 成功 */ })
      .catch(err => { /* 失败 */ })
      .finally(() => setSubmitting(false))
  }

  return (
    <form>
      <input value={email} onChange={e => setEmail(e.target.value)} />
      {emailError && <p>{emailError}</p>}
      <input value={password} onChange={e => setPassword(e.target.value)} />
      {passwordError && <p>{passwordError}</p>}
      <input value={confirm} onChange={e => setConfirm(e.target.value)} />
      {confirmError && <p>{confirmError}</p>}
      <button onClick={handleSubmit} disabled={submitting}>注册</button>
    </form>
  )
}

能跑,但问题很多:

  1. 校验在提交时一次性执行。 用户必须点提交才能看到错误,体验差。
  2. 错误不会自动清除。 用户改了邮箱,错误还显示着。
  3. 校验逻辑重复。 每次提交都要重新校验,代码冗余。
  4. if (emailError || ...) 用的是旧状态。 setEmailError 是异步的,这里读到的还是上一次的值,校验逻辑失效。
  5. 没有失焦校验。 用户填完一项,不知道对不对。
  6. 没有错误聚焦。 提交失败后,用户不知道错在哪。

这段代码的 bug 会很隐蔽。 比如第 4 点,setEmailError 后立即读 emailError,读到的是旧值,导致校验形同虚设。

本课要讲的,就是如何写出健壮的表单。


三、受控组件:标准写法

基础受控组件

jsx 复制代码
function TextField({ value, onChange, ...props }) {
  return (
    <input
      value={value}
      onChange={e => onChange(e.target.value)}
      {...props}
    />
  )
}

用法:

jsx 复制代码
const [email, setEmail] = useState("")
<TextField value={email} onChange={setEmail} />

关键点

  1. value 必须由状态提供。 不能是 undefined,否则变成非受控。
  2. onChange 必须更新状态。 否则输入框无法输入。
  3. onChange 的签名是 (newValue) => void。 不是 (event) => void,把 event 处理封装在组件内部。

一个常见错误:value 为 undefined

jsx 复制代码
// ❌ value 是 undefined,变成非受控
const [email, setEmail] = useState()  // 初始值是 undefined
<input value={email} onChange={...} />

// ✅ 初始值必须是空字符串
const [email, setEmail] = useState("")

为什么? React 判断受控还是非受控,看 value 是不是 undefined。undefined 表示非受控,"" 表示受控。从非受控切到受控会警告。

数字输入的处理

jsx 复制代码
// ❌ 直接存数字,中间态无法表示
const [age, setAge] = useState(0)
<input
  type="number"
  value={age}
  onChange={e => setAge(Number(e.target.value))}
/>
// 用户清空输入框时,Number("") === 0,无法清空

// ✅ 存字符串,提交时转换
const [age, setAge] = useState("")
<input
  type="number"
  value={age}
  onChange={e => setAge(e.target.value)}
/>
// 提交时 Number(age)

原则:输入框的值始终存字符串,需要数字时在提交或使用时转换。

原因: 输入过程中有很多中间态(空、"-"、"1."),只有字符串能表示。

复选框

jsx 复制代码
// 单个复选框
const [agreed, setAgreed] = useState(false)
<input
  type="checkbox"
  checked={agreed}
  onChange={e => setAgreed(e.target.checked)}
/>
// 注意用 checked 不是 value,用 e.target.checked 不是 e.target.value
jsx 复制代码
// 多个复选框
const [selected, setSelected] = useState([])
<input
  type="checkbox"
  checked={selected.includes("a")}
  onChange={e => {
    setSelected(prev =>
      e.target.checked
        ? [...prev, "a"]
        : prev.filter(x => x !== "a")
    )
  }}
/>

下拉框

jsx 复制代码
const [city, setCity] = useState("beijing")
<select value={city} onChange={e => setCity(e.target.value)}>
  <option value="beijing">北京</option>
  <option value="shanghai">上海</option>
</select>
jsx 复制代码
// 多选下拉框
const [cities, setCities] = useState([])
<select
  multiple
  value={cities}
  onChange={e => {
    const selected = Array.from(e.target.selectedOptions, o => o.value)
    setCities(selected)
  }}
>
  <option value="beijing">北京</option>
  <option value="shanghai">上海</option>
</select>

单选框

jsx 复制代码
const [gender, setGender] = useState("male")
<label>
  <input
    type="radio"
    name="gender"
    value="male"
    checked={gender === "male"}
    onChange={e => setGender(e.target.value)}
  />
  男
</label>
<label>
  <input
    type="radio"
    name="gender"
    value="female"
    checked={gender === "female"}
    onChange={e => setGender(e.target.value)}
  />
  女
</label>

注意: 单选框需要 name 属性相同,才能互斥。checked 由 gender === value 决定。


四、多字段表单:单对象 vs 多状态

方案一:多状态

jsx 复制代码
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [phone, setPhone] = useState("")
const [address, setAddress] = useState("")

优点: 每个字段独立,更新简单。

缺点: 字段多时代码冗长,提交时要组装对象。

方案二:单对象

jsx 复制代码
const [form, setForm] = useState({
  name: "",
  email: "",
  phone: "",
  address: ""
})

function updateField(field, value) {
  setForm(prev => ({ ...prev, [field]: value }))
}

优点: 状态集中,提交时直接拿 form,容易重置。

缺点: 每个字段更新都要创建新对象。

怎么选

场景 方案
字段少(≤3),独立性强 多状态
字段多(≥4),需要整体提交 单对象
字段之间有联动 单对象
需要整体重置 单对象
需要整体校验 单对象

经验法则:字段多、需要一起提交、需要一起校验 → 单对象。否则多状态。

单对象的完整模式

jsx 复制代码
function Form() {
  const [form, setForm] = useState({
    name: "",
    email: "",
    phone: ""
  })

  // 更新单个字段
  function updateField(field, value) {
    setForm(prev => ({ ...prev, [field]: value }))
  }

  // 重置表单
  function reset() {
    setForm({ name: "", email: "", phone: "" })
  }

  // 提交
  async function submit() {
    await api.submit(form)
  }

  return (
    <form>
      <input
        value={form.name}
        onChange={e => updateField("name", e.target.value)}
      />
      <input
        value={form.email}
        onChange={e => updateField("email", e.target.value)}
      />
      <input
        value={form.phone}
        onChange={e => updateField("phone", e.target.value)}
      />
    </form>
  )
}

注意: updateField 是通用的,任何字段都用同一个函数。

通用输入组件

当表单字段很多时,可以封装通用组件:

jsx 复制代码
function Field({ label, value, onChange, ...props }) {
  return (
    <label>
      <span>{label}</span>
      <input
        value={value}
        onChange={e => onChange(e.target.value)}
        {...props}
      />
    </label>
  )
}

// 用法
<Field
  label="姓名"
  value={form.name}
  onChange={v => updateField("name", v)}
/>
<Field
  label="邮箱"
  value={form.email}
  onChange={v => updateField("email", v)}
/>

用计算属性简化更新

jsx 复制代码
function updateField(field) {
  return value => setForm(prev => ({ ...prev, [field]: value }))
}

// 用法
<Field
  label="姓名"
  value={form.name}
  onChange={updateField("name")}
/>

这个模式叫"柯里化",能进一步简化代码。


五、受控 vs 非受控:选择标准

非受控组件的写法

jsx 复制代码
function Form() {
  const nameRef = useRef()
  const emailRef = useRef()

  function handleSubmit(e) {
    e.preventDefault()
    const data = {
      name: nameRef.current.value,
      email: emailRef.current.value
    }
    api.submit(data)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input ref={nameRef} defaultValue="" />
      <input ref={emailRef} defaultValue="" />
      <button>提交</button>
    </form>
  )
}

关键:

  • 用 defaultValue 而不是 value。
  • 用 ref 读取值,不用 onChange。
  • 只在需要时读值。

对比表

维度 受控 非受控
值来源 状态 DOM
更新方式 onChange 改状态 用户输入自动更新 DOM
读取方式 直接从状态读 ref.current.value
即时校验 容易 困难
格式化输入 容易 困难
联动其他状态 容易 困难
动态字段 容易 困难
代码量 多 少
性能 每次输入重新渲染 不重新渲染
推荐度 默认 特殊场景

什么时候用非受控

合理场景:

  1. 文件上传。 <input type="file" /> 只能用 ref 读。
  2. 极简单的表单。 比如只有一个搜索框,只在提交时读值。
  3. 集成非 React 库。 某些富文本编辑器、日期选择器需要直接操作 DOM。
  4. 性能极度敏感的大表单。 几十个字段,每次输入都渲染代价高。
  5. 表单值不需要实时知道。 只在提交时读取。

不合理场景:

  1. 需要即时校验。 非受控做不到。
  2. 需要格式化输入。 比如手机号自动加空格。
  3. 需要联动。 比如选择省份后更新城市选项。
  4. 需要动态字段。 非受控的 ref 管理很麻烦。

混合使用

实践中常常混合: 大部分字段受控,个别字段非受控。

jsx 复制代码
function Form() {
  const [name, setName] = useState("")
  const fileRef = useRef()

  function handleSubmit() {
    const data = {
      name,
      file: fileRef.current.files[0]
    }
    api.submit(data)
  }

  return (
    <form>
      <input value={name} onChange={e => setName(e.target.value)} />
      <input type="file" ref={fileRef} />
      <button onClick={handleSubmit}>提交</button>
    </form>
  )
}

原则:默认受控,特殊场景非受控。 不要为了"性能优化"而用非受控,除非有明确的性能数据。


六、表单校验:三种时机

时机一:即时校验

每次输入都校验。

jsx 复制代码
const [email, setEmail] = useState("")
const emailError = validateEmail(email)  // 派生

<input value={email} onChange={e => setEmail(e.target.value)} />
{emailError && <p>{emailError}</p>}

优点: 即时反馈,用户立即知道对不对。

缺点: 输入过程中会频繁显示错误。比如用户刚输入 "a",就显示"邮箱格式不正确",体验差。

改进: 只在用户输入过之后再校验。

jsx 复制代码
const [email, setEmail] = useState("")
const [touched, setTouched] = useState(false)
const emailError = touched ? validateEmail(email) : ""

<input
  value={email}
  onChange={e => setEmail(e.target.value)}
  onBlur={() => setTouched(true)}
/>

适用场景: 格式简单、用户容易理解的字段(如邮箱、手机号)。

时机二:失焦校验

用户离开输入框时校验。

jsx 复制代码
const [email, setEmail] = useState("")
const [error, setError] = useState("")

function handleBlur() {
  setError(validateEmail(email))
}

<input
  value={email}
  onChange={e => setEmail(e.target.value)}
  onBlur={handleBlur}
/>
{error && <p>{error}</p>}

优点: 不打扰输入过程,用户填完一项立即知道对不对。

缺点: 用户可能一直不离开输入框。

适用场景: 大多数字段。失焦校验是推荐默认。

时机三:提交时校验

点击提交时校验所有字段。

jsx 复制代码
function handleSubmit() {
  const errors = validateAll(form)
  if (Object.keys(errors).length > 0) {
    setErrors(errors)
    focusFirstError(errors)
    return
  }
  api.submit(form)
}

优点: 一次性告诉用户所有错误。

缺点: 用户要等到提交才知道错。

适用场景: 作为兜底,配合失焦校验使用。

推荐组合:失焦 + 提交

最佳实践:

  • 失焦时校验单个字段。 用户填完立即知道。
  • 提交时校验所有字段。 兜底,防止遗漏。
  • 提交失败时聚焦第一个错误。 用户体验好。
jsx 复制代码
function Form() {
  const [form, setForm] = useState({ email: "", password: "" })
  const [errors, setErrors] = useState({})

  function validateField(field, value) {
    if (field === "email" && !value.includes("@")) {
      return "邮箱格式不正确"
    }
    if (field === "password" && value.length < 6) {
      return "密码至少 6 位"
    }
    return ""
  }

  function handleBlur(field) {
    const error = validateField(field, form[field])
    setErrors(prev => ({ ...prev, [field]: error }))
  }

  function handleChange(field, value) {
    setForm(prev => ({ ...prev, [field]: value }))
    // 如果已有错误,输入时重新校验
    if (errors[field]) {
      const error = validateField(field, value)
      setErrors(prev => ({ ...prev, [field]: error }))
    }
  }

  function handleSubmit() {
    const newErrors = {}
    Object.keys(form).forEach(field => {
      const error = validateField(field, form[field])
      if (error) newErrors[field] = error
    })

    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors)
      focusFirstError(newErrors)
      return
    }

    api.submit(form)
  }

  return (
    <form>
      <input
        value={form.email}
        onChange={e => handleChange("email", e.target.value)}
        onBlur={() => handleBlur("email")}
      />
      {errors.email && <p>{errors.email}</p>}

      <input
        type="password"
        value={form.password}
        onChange={e => handleChange("password", e.target.value)}
        onBlur={() => handleBlur("password")}
      />
      {errors.password && <p>{errors.password}</p>}

      <button onClick={handleSubmit}>提交</button>
    </form>
  )
}

这个模式涵盖了三种时机:

  • 失焦时校验 → handleBlur
  • 输入时清除错误 → handleChange 里判断 errors[field]
  • 提交时校验所有 → handleSubmit

校验函数应该是纯函数

jsx 复制代码
// ✅ 纯函数,只依赖输入
function validateEmail(email) {
  if (!email) return "邮箱不能为空"
  if (!email.includes("@")) return "邮箱格式不正确"
  return ""
}

// ❌ 依赖外部状态,难以测试
function validateEmail() {
  if (!form.email) return "邮箱不能为空"
  // ...
}

好处:

  • 可独立测试。
  • 可复用。
  • 可组合。

用校验库

实际项目中,常用校验库:

  • React Hook Form: 基于非受控,性能好。
  • Formik: 基于受控,生态成熟。
  • Zod / Yup: schema 校验,和表单库配合。
jsx 复制代码
// React Hook Form + Zod 示例
const schema = z.object({
  email: z.string().email("邮箱格式不正确"),
  password: z.string().min(6, "密码至少 6 位")
})

function Form() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(schema)
  })

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}
      <input {...register("password")} />
      {errors.password && <p>{errors.password.message}</p>}
      <button>提交</button>
    </form>
  )
}

库的选择标准:

  • 表单简单 → 自己写。
  • 表单复杂(多字段、动态字段、复杂校验)→ 用库。
  • React Hook Form 性能好,推荐。
  • Formik 生态成熟,文档多。

本课讲原理,理解了原理,用什么库都能上手。


七、提交:防重复、加载态、错误

提交状态用状态机

jsx 复制代码
const [status, setStatus] = useState("editing")
// "editing" | "submitting" | "success" | "error"
const [error, setError] = useState(null)

async function handleSubmit() {
  if (!validate()) return

  setStatus("submitting")
  try {
    await api.submit(form)
    setStatus("success")
  } catch (e) {
    setError(e.message)
    setStatus("error")
  }
}

不要用多个布尔:

jsx 复制代码
// ❌
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [isError, setIsError] = useState(false)

防重复提交

方式一:禁用按钮。

jsx 复制代码
<button disabled={status === "submitting"} onClick={handleSubmit}>
  {status === "submitting" ? "提交中..." : "提交"}
</button>

方式二:事件处理器守卫。

jsx 复制代码
async function handleSubmit() {
  if (status === "submitting") return
  // ...
}

方式三:用 form 的 onSubmit。

jsx 复制代码
<form onSubmit={handleSubmit}>
  <button disabled={status === "submitting"}>提交</button>
</form>

三种方式结合使用,最稳妥。

提交中的界面

jsx 复制代码
<button disabled={status === "submitting"}>
  {status === "submitting" ? (
    <>
      <Spinner size="small" />
      提交中...
    </>
  ) : "提交"}
</button>

提交中要给用户明确的反馈: 按钮禁用、文字变化、加载图标。

提交成功的处理

jsx 复制代码
async function handleSubmit() {
  setStatus("submitting")
  try {
    await api.submit(form)
    setStatus("success")
    // 跳转、重置表单、显示成功提示
    resetForm()
    navigate("/success")
  } catch (e) {
    setError(e.message)
    setStatus("error")
  }
}

提交失败的处理

jsx 复制代码
{status === "error" && (
  <div className="error">
    <p>提交失败:{error}</p>
    <button onClick={handleSubmit}>重试</button>
  </div>
)}

错误处理三要素: 错误信息、重试按钮、不丢失用户输入。

表单重置

jsx 复制代码
const INITIAL_FORM = { email: "", password: "" }

function reset() {
  setForm(INITIAL_FORM)
  setErrors({})
  setStatus("editing")
  setError(null)
}

用常量定义初始值,重置时直接引用,避免遗漏字段。


八、动态字段:数组字段

场景

一个表单,用户可以添加多个邮箱地址,每个邮箱可以删除。

状态设计

jsx 复制代码
const [emails, setEmails] = useState([""])

// 或更完整的结构
const [emails, setEmails] = useState([
  { id: 1, value: "" }
])

用 id 而不是 index: 因为要增删排序,index 会变。

添加字段

jsx 复制代码
function addEmail() {
  setEmails(prev => [...prev, { id: Date.now(), value: "" }])
}

删除字段

jsx 复制代码
function removeEmail(id) {
  setEmails(prev => prev.filter(e => e.id !== id))
}

更新字段

jsx 复制代码
function updateEmail(id, value) {
  setEmails(prev => prev.map(e =>
    e.id === id ? { ...e, value } : e
  ))
}

完整实现

jsx 复制代码
function EmailListForm() {
  const [emails, setEmails] = useState([
    { id: Date.now(), value: "" }
  ])

  function addEmail() {
    setEmails(prev => [...prev, { id: Date.now(), value: "" }])
  }

  function removeEmail(id) {
    setEmails(prev => prev.filter(e => e.id !== id))
  }

  function updateEmail(id, value) {
    setEmails(prev => prev.map(e =>
      e.id === id ? { ...e, value } : e
    ))
  }

  return (
    <div>
      {emails.map((email, index) => (
        <div key={email.id}>
          <input
            value={email.value}
            onChange={e => updateEmail(email.id, e.target.value)}
            placeholder={`邮箱 ${index + 1}`}
          />
          {emails.length > 1 && (
            <button onClick={() => removeEmail(email.id)}>删除</button>
          )}
        </div>
      ))}
      <button onClick={addEmail}>添加邮箱</button>
    </div>
  )
}

关键点:

  1. 每项有唯一 id。 用 Date.now() 或 uuid。
  2. key 用 id,不用 index。 增删时 index 会变。
  3. 删除时保留至少一项。 用 emails.length > 1 判断。
  4. 更新用 map 不可变。

嵌套数组字段

更复杂的场景:一个订单,有多个商品,每个商品有名称和数量。

jsx 复制代码
const [items, setItems] = useState([
  { id: 1, name: "", quantity: 1 }
])

function updateItem(id, field, value) {
  setItems(prev => prev.map(item =>
    item.id === id ? { ...item, [field]: value } : item
  ))
}

function addItem() {
  setItems(prev => [...prev, { id: Date.now(), name: "", quantity: 1 }])
}

function removeItem(id) {
  setItems(prev => prev.filter(item => item.id !== id))
}

嵌套字段的更新,用 [field] 动态 key,通用性强。

动态字段的校验

jsx 复制代码
const errors = emails.map(e => validateEmail(e.value))

{emails.map((email, index) => (
  <div key={email.id}>
    <input
      value={email.value}
      onChange={e => updateEmail(email.id, e.target.value)}
    />
    {errors[index] && <p>{errors[index]}</p>}
  </div>
))}

错误按 id 存储更稳定:

jsx 复制代码
const [errors, setErrors] = useState({})  // { [id]: "错误信息" }

{errors[email.id] && <p>{errors[email.id]}</p>}

九、错误展示与聚焦

错误展示的位置

方式一:字段下方。

jsx 复制代码
<label>
  <span>邮箱</span>
  <input value={email} onChange={...} />
  {error && <p className="error">{error}</p>}
</label>

方式二:字段右侧。

jsx 复制代码
<div className="field">
  <input value={email} onChange={...} />
  {error && <span className="error">{error}</span>}
</div>

方式三:统一错误提示区。

jsx 复制代码
{Object.keys(errors).length > 0 && (
  <div className="error-summary">
    <p>请修正以下错误:</p>
    <ul>
      {Object.entries(errors).map(([field, msg]) => (
        <li key={field}>{msg}</li>
      ))}
    </ul>
  </div>
)}

推荐: 字段下方 + 提交时统一提示。字段下方让用户知道具体哪里错,统一提示让用户知道有几个错误。

错误聚焦

提交失败后,聚焦到第一个错误的字段:

jsx 复制代码
function focusFirstError(errors) {
  const firstErrorField = Object.keys(errors)[0]
  const el = document.querySelector(`[name="${firstErrorField}"]`)
  el?.focus()
}

更好的方式:用 ref。

jsx 复制代码
const fieldRefs = {
  email: useRef(),
  password: useRef()
}

function focusFirstError(errors) {
  const firstErrorField = Object.keys(errors)[0]
  fieldRefs[firstErrorField]?.current?.focus()
}

聚焦是命令式操作,必须用 ref。 这是第 13 课会讲的"逃生舱"。

无障碍

jsx 复制代码
<input
  id="email"
  value={email}
  onChange={...}
  aria-invalid={!!errors.email}
  aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
  <p id="email-error" role="alert">{errors.email}</p>
)}

关键点:

  • aria-invalid 标记字段是否有效。
  • aria-describedby 关联错误信息。
  • role="alert" 让屏幕阅读器播报错误。

无障碍不是可选项,是专业开发的标配。


十、常见误区与避坑

误区 1:校验时读旧状态

jsx 复制代码
// ❌ setEmailError 是异步的,读到的还是旧值
function handleSubmit() {
  if (!email.includes("@")) {
    setEmailError("邮箱格式不正确")
  }
  if (emailError) return  // emailError 还是旧值
  // ...
}

// ✅ 用局部变量收集错误
function handleSubmit() {
  const newErrors = {}
  if (!email.includes("@")) {
    newErrors.email = "邮箱格式不正确"
  }
  if (Object.keys(newErrors).length > 0) {
    setErrors(newErrors)
    return
  }
  // ...
}

原因: setState 是异步的,调用后立即读取状态拿到的还是旧值。校验应该用局部变量收集错误,最后一次性 setErrors。

误区 2:数字输入直接存数字

jsx 复制代码
// ❌ 无法清空,因为 Number("") === 0
const [age, setAge] = useState(0)
<input
  type="number"
  value={age}
  onChange={e => setAge(Number(e.target.value))}
/>

// ✅ 存字符串
const [age, setAge] = useState("")

误区 3:value 为 undefined

jsx 复制代码
// ❌ 从非受控切到受控
const [value, setValue] = useState()
<input value={value} onChange={...} />

// ✅ 初始值给空字符串
const [value, setValue] = useState("")

误区 4:把 props 复制到 state

jsx 复制代码
// ❌ 父组件的 initialValue 变了,子组件不同步
function Child({ initialValue }) {
  const [value, setValue] = useState(initialValue)
}

// ✅ 受控
function Child({ value, onChange }) {
  return <input value={value} onChange={e => onChange(e.target.value)} />
}

误区 5:表单提交不阻止默认行为

jsx 复制代码
// ❌ 提交后页面刷新
function handleSubmit() {
  api.submit(form)
}

// ✅ 阻止默认行为
function handleSubmit(e) {
  e.preventDefault()
  api.submit(form)
}

原因: <form> 的默认提交行为是刷新页面。必须 e.preventDefault()。

误区 6:不处理提交中的状态

jsx 复制代码
// ❌ 用户可以连续点击,发起多次请求
<button onClick={handleSubmit}>提交</button>

// ✅ 提交中禁用
<button disabled={submitting} onClick={handleSubmit}>
  {submitting ? "提交中..." : "提交"}
</button>

误区 7:错误不清除

jsx 复制代码
// ❌ 用户改了邮箱,错误还显示着
<input value={email} onChange={e => setEmail(e.target.value)} />
{errors.email && <p>{errors.email}</p>}

// ✅ 输入时重新校验
function handleChange(value) {
  setEmail(value)
  if (errors.email) {
    setErrors(prev => ({ ...prev, email: validateEmail(value) }))
  }
}

误区 8:动态字段用 index 作为 key

jsx 复制代码
// ❌ 删除中间项后,后面的输入框内容错乱
{emails.map((email, index) => (
  <input key={index} value={email} />
))}

// ✅ 用 id
{emails.map(email => (
  <input key={email.id} value={email.value} />
))}

误区 9:不处理表单重置

jsx 复制代码
// ❌ 提交成功后表单数据还在
async function handleSubmit() {
  await api.submit(form)
}

// ✅ 重置
async function handleSubmit() {
  await api.submit(form)
  reset()
}

误区 10:把所有字段都放在一个大对象里

jsx 复制代码
// ❌ 一个字段变化,整个对象重建,所有字段重新渲染
const [form, setForm] = useState({
  field1: "", field2: "", /* ... 20 个字段 */
})

// ✅ 如果字段独立性强,考虑拆开
const [field1, setField1] = useState("")
const [field2, setField2] = useState("")

注意: 这条建议有争议。大对象方便整体提交,但会导致所有字段重新渲染。实践中,如果表单字段多且独立,可以拆开或使用表单库(如 React Hook Form)优化性能。


十一、跨框架对照

受控输入

React:

jsx 复制代码
const [value, setValue] = useState("")
<input value={value} onChange={e => setValue(e.target.value)} />

Vue 3:

vue 复制代码
<script setup>
import { ref } from 'vue'
const value = ref("")
</script>

<template>
  <input v-model="value" />
</template>

SwiftUI:

swift 复制代码
@State private var value = ""
TextField("输入", text: $value)

Flutter:

dart 复制代码
final controller = TextEditingController();
TextField(controller: controller)

Jetpack Compose:

kotlin 复制代码
var value by remember { mutableStateOf("") }
TextField(
    value = value,
    onValueChange = { value = it }
)

注意: Vue 的 v-model 是双向绑定的语法糖。SwiftUI 的 $value 是双向绑定。Flutter 的 TextEditingController 内部管理值。Compose 是显式的受控。

思维一致: 值由状态驱动,变化通过回调更新状态。

表单提交

React:

jsx 复制代码
<form onSubmit={e => { e.preventDefault(); submit() }}>

Vue 3:

vue 复制代码
<form @submit.prevent="submit">

SwiftUI:

swift 复制代码
Button("提交") { submit() }

Flutter:

dart 复制代码
ElevatedButton(onPressed: submit, child: Text("提交"))

Jetpack Compose:

kotlin 复制代码
Button(onClick = { submit() }) { Text("提交") }

十二、练一练:15 道多元化习题

1. 选择题

题目: 受控组件的两个关键 props 是什么?

A. defaultValue 和 onChange

B. value 和 onChange

C. value 和 onInput

D. ref 和 onChange

参考答案: B

解读: 受控组件的值由 value 提供,变化通过 onChange 通知。defaultValue 是非受控组件的初始值。onInput 也能用,但 onChange 是 React 的标准。


2. 判断题

题目: 数字输入框的值应该直接存为数字类型。

参考答案: 错误。

解读: 输入过程中有很多中间态(空、"-"、"1."),只有字符串能表示。如果直接存数字,用户无法清空输入框(Number("") === 0)。正确做法是存字符串,使用时转换。


3. 填空题

题目: 表单校验的三种时机是 、、______。

参考答案: 即时校验、失焦校验、提交时校验。

解读: 即时校验每次输入都校验,反馈及时但可能打扰。失焦校验用户离开输入框时校验,是推荐的默认。提交时校验一次性校验所有字段,作为兜底。最佳实践是失焦 + 提交组合。


4. 代码阅读题

题目: 下面代码有什么问题?

jsx 复制代码
function handleSubmit() {
  if (!email.includes("@")) {
    setEmailError("邮箱格式不正确")
  }
  if (emailError) return
  api.submit(form)
}

参考答案: setEmailError 是异步的,调用后立即读 emailError 拿到的还是旧值,校验形同虚设。

修复:

jsx 复制代码
function handleSubmit() {
  const newErrors = {}
  if (!email.includes("@")) {
    newErrors.email = "邮箱格式不正确"
  }
  if (Object.keys(newErrors).length > 0) {
    setErrors(newErrors)
    return
  }
  api.submit(form)
}

解读: setState 是异步的,不能调用后立即读取。校验应该用局部变量收集错误,最后一次性 setErrors。这是表单校验最常见的坑。


5. 找错题

题目: 下面代码有什么问题?

jsx 复制代码
const [age, setAge] = useState(0)
<input
  type="number"
  value={age}
  onChange={e => setAge(Number(e.target.value))}
/>

参考答案: 用户清空输入框时,Number("") 是 0,无法清空。应该存字符串:

jsx 复制代码
const [age, setAge] = useState("")
<input
  type="number"
  value={age}
  onChange={e => setAge(e.target.value)}
/>
// 提交时 Number(age)

解读: 输入框的值始终存字符串,需要数字时在提交或使用时转换。这是处理数字输入的标准做法。


6. 状态组织题

题目: 一个表单有 8 个字段,需要整体提交和整体重置。应该用单对象还是多状态?

参考答案: 用单对象。

jsx 复制代码
const [form, setForm] = useState({
  field1: "", field2: "", field3: "", field4: "",
  field5: "", field6: "", field7: "", field8: ""
})

function updateField(field, value) {
  setForm(prev => ({ ...prev, [field]: value }))
}

function reset() {
  setForm({ field1: "", /* ... */ })
}

解读: 字段多、需要整体提交、需要整体重置 → 单对象。单对象便于组装提交数据、重置表单、整体校验。字段少(≤3)且独立性强 → 多状态。


7. 受控 vs 非受控题

题目: 下面三个场景,哪些应该用受控,哪些可以用非受控?

a. 登录表单的邮箱和密码

b. 文件上传的 <input type="file" />

c. 一个只在提交时读取值的搜索框

参考答案:

a. 受控。需要即时校验、按钮禁用联动、错误提示。

b. 非受控。<input type="file" /> 只能用 ref 读取。

c. 可以非受控。只在提交时读取,不需要实时知道。

解读: 默认受控,特殊场景非受控。需要即时校验、格式化、联动 → 受控。文件上传、极简单表单、只在提交时读取 → 非受控。


8. 动态字段题

题目: 实现一个"添加多个邮箱"的表单。要求:初始一个输入框,可以添加、删除,至少保留一个。

参考答案:

jsx 复制代码
function EmailListForm() {
  const [emails, setEmails] = useState([
    { id: Date.now(), value: "" }
  ])

  function addEmail() {
    setEmails(prev => [...prev, { id: Date.now(), value: "" }])
  }

  function removeEmail(id) {
    setEmails(prev => prev.filter(e => e.id !== id))
  }

  function updateEmail(id, value) {
    setEmails(prev => prev.map(e =>
      e.id === id ? { ...e, value } : e
    ))
  }

  return (
    <div>
      {emails.map((email, index) => (
        <div key={email.id}>
          <input
            value={email.value}
            onChange={e => updateEmail(email.id, e.target.value)}
            placeholder={`邮箱 ${index + 1}`}
          />
          {emails.length > 1 && (
            <button onClick={() => removeEmail(email.id)}>删除</button>
          )}
        </div>
      ))}
      <button onClick={addEmail}>添加</button>
    </div>
  )
}

解读: 关键点:

  1. 每项有唯一 id,用 Date.now() 或 uuid。
  2. key 用 id,不用 index。
  3. 删除时保留至少一项。
  4. 更新用 map 不可变。

9. 防重复提交题

题目: 有哪几种方式防止重复提交?推荐哪种?

参考答案:

方式一:禁用按钮。

jsx 复制代码
<button disabled={submitting}>提交</button>

方式二:事件处理器守卫。

jsx 复制代码
async function handleSubmit() {
  if (submitting) return
  // ...
}

方式三:用 <form> 的 onSubmit。

jsx 复制代码
<form onSubmit={handleSubmit}>
  <button disabled={submitting}>提交</button>
</form>

推荐:三种结合。 按钮禁用给用户视觉反馈,事件处理器守卫防止程序调用,<form> 的 onSubmit 天然支持回车提交。

解读: 防重复提交是表单的基本要求。用户可能连续点击、按回车、或网络慢时重复提交。三种方式结合最稳妥。


10. 校验时机题

题目: 一个邮箱输入框,应该在什么时机校验?为什么?

参考答案: 推荐"失焦 + 提交"组合。

  • 失焦校验: 用户填完邮箱,离开输入框时校验。不打扰输入过程,但用户立即知道对不对。
  • 提交校验: 兜底,防止用户没失焦就直接提交。
  • 输入时清除错误: 如果已有错误,用户修改时重新校验,错误消失。

不推荐即时校验: 用户刚输入 "a" 就显示"邮箱格式不正确",体验差。

解读: 校验时机的选择影响用户体验。即时校验打扰输入,提交校验反馈太晚。失焦校验是平衡点,配合提交校验兜底,是最佳实践。


11. 错误聚焦题

题目: 提交失败后,如何聚焦到第一个错误的字段?

参考答案:

jsx 复制代码
const fieldRefs = {
  email: useRef(),
  password: useRef()
}

function focusFirstError(errors) {
  const firstErrorField = Object.keys(errors)[0]
  fieldRefs[firstErrorField]?.current?.focus()
}

// 在 handleSubmit 中
if (Object.keys(newErrors).length > 0) {
  setErrors(newErrors)
  focusFirstError(newErrors)
  return
}

解读: 聚焦是命令式操作,必须用 ref。这是声明式 UI 的"逃生舱",第 13 课会深入讲。错误聚焦提升用户体验,特别是长表单。


12. 表单重置题

题目: 提交成功后,如何正确重置表单?

参考答案:

jsx 复制代码
const INITIAL_FORM = { email: "", password: "" }

function reset() {
  setForm(INITIAL_FORM)
  setErrors({})
  setStatus("editing")
  setError(null)
}

解读: 用常量定义初始值,重置时直接引用。注意要重置所有相关状态:表单数据、错误、提交状态。遗漏任何一项都会导致残留状态。


13. 提交状态机题

题目: 把一个表单的提交状态从多个布尔改成状态机。

jsx 复制代码
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [isError, setIsError] = useState(false)

参考答案:

jsx 复制代码
const [status, setStatus] = useState("editing")
// "editing" | "submitting" | "success" | "error"
const [error, setError] = useState(null)

async function handleSubmit() {
  if (status === "submitting") return
  setStatus("submitting")
  try {
    await api.submit(form)
    setStatus("success")
  } catch (e) {
    setError(e.message)
    setStatus("error")
  }
}

解读: 三个布尔有 8 种组合,只有 4 种合法。状态机用枚举值替代,从结构上杜绝非法组合。这是第 2 课状态机思维在表单场景的具体应用。


14. 无障碍题

题目: 表单错误如何做无障碍处理?

参考答案:

jsx 复制代码
<input
  id="email"
  value={email}
  onChange={...}
  aria-invalid={!!errors.email}
  aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
  <p id="email-error" role="alert">{errors.email}</p>
)}

关键点:

  • aria-invalid 标记字段是否有效。
  • aria-describedby 关联错误信息。
  • role="alert" 让屏幕阅读器播报错误。

解读: 无障碍不是可选项,是专业开发的标配。表单是无障碍问题的重灾区,做好无障碍能服务更多用户。


15. 综合题

题目: 实现一个完整的登录表单,要求:

  • 邮箱、密码
  • 失焦校验格式
  • 提交时校验所有字段
  • 提交中禁用按钮
  • 提交失败显示错误
  • 提交失败聚焦第一个错误字段

参考答案:

jsx 复制代码
function LoginForm() {
  const [form, setForm] = useState({ email: "", password: "" })
  const [errors, setErrors] = useState({})
  const [status, setStatus] = useState("editing")
  const [error, setError] = useState(null)

  const emailRef = useRef()
  const passwordRef = useRef()
  const fieldRefs = { email: emailRef, password: passwordRef }

  function validateField(field, value) {
    if (field === "email") {
      if (!value) return "邮箱不能为空"
      if (!value.includes("@")) return "邮箱格式不正确"
    }
    if (field === "password") {
      if (!value) return "密码不能为空"
      if (value.length < 6) return "密码至少 6 位"
    }
    return ""
  }

  function handleChange(field, value) {
    setForm(prev => ({ ...prev, [field]: value }))
    if (errors[field]) {
      setErrors(prev => ({ ...prev, [field]: validateField(field, value) }))
    }
  }

  function handleBlur(field) {
    setErrors(prev => ({
      ...prev,
      [field]: validateField(field, form[field])
    }))
  }

  async function handleSubmit(e) {
    e.preventDefault()
    if (status === "submitting") return

    const newErrors = {}
    Object.keys(form).forEach(field => {
      const err = validateField(field, form[field])
      if (err) newErrors[field] = err
    })

    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors)
      const firstField = Object.keys(newErrors)[0]
      fieldRefs[firstField]?.current?.focus()
      return
    }

    setStatus("submitting")
    try {
      await api.login(form)
      setStatus("success")
    } catch (err) {
      setError(err.message)
      setStatus("error")
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <input
          ref={emailRef}
          value={form.email}
          onChange={e => handleChange("email", e.target.value)}
          onBlur={() => handleBlur("email")}
          aria-invalid={!!errors.email}
          placeholder="邮箱"
        />
        {errors.email && <p role="alert">{errors.email}</p>}
      </div>

      <div>
        <input
          ref={passwordRef}
          type="password"
          value={form.password}
          onChange={e => handleChange("password", e.target.value)}
          onBlur={() => handleBlur("password")}
          aria-invalid={!!errors.password}
          placeholder="密码"
        />
        {errors.password && <p role="alert">{errors.password}</p>}
      </div>

      {status === "error" && <p role="alert">登录失败:{error}</p>}

      <button disabled={status === "submitting"}>
        {status === "submitting" ? "登录中..." : "登录"}
      </button>
    </form>
  )
}

解读: 这道题综合了本课所有知识点:

  1. 单对象组织表单状态。
  2. 失焦校验 + 提交校验。
  3. 输入时清除错误。
  4. 提交状态机。
  5. 防重复提交。
  6. 错误聚焦。
  7. 无障碍。
  8. 纯函数校验。

十三、本课检查清单

设计表单时,按顺序问自己:

状态组织:

  • 字段有几个?
  • 少且独立 → 多状态
  • 多或需要整体提交 → 单对象
  • 有动态字段 → 数组 + id

受控 vs 非受控:

  • 需要即时校验、格式化、联动吗?需要 → 受控
  • 文件上传、极简单表单 → 可以非受控
  • 默认用受控

校验:

  • 校验函数是纯函数吗?
  • 校验时机是什么?失焦 + 提交?
  • 输入时清除错误吗?
  • 数字输入存字符串吗?

提交:

  • 提交状态用状态机吗?
  • 防重复提交了吗?
  • 提交成功重置表单了吗?
  • 提交失败保留用户输入了吗?

错误:

  • 错误展示在字段下方吗?
  • 提交失败聚焦第一个错误了吗?
  • 无障碍属性加了吗?

动态字段:

  • 每项有唯一 id 吗?
  • key 用 id 吗?
  • 更新用不可变吗?

十四、课后作业

基础题


题目 1: 下面代码有什么问题?

jsx 复制代码
const [age, setAge] = useState(0)
<input
  type="number"
  value={age}
  onChange={e => setAge(Number(e.target.value))}
/>

参考答案: 用户清空输入框时,Number("") 是 0,无法清空。应该存字符串:

jsx 复制代码
const [age, setAge] = useState("")
<input
  type="number"
  value={age}
  onChange={e => setAge(e.target.value)}
/>
// 使用时 Number(age)

解读: 输入框的值始终存字符串。输入过程中有中间态(空、"-"、"1."),只有字符串能表示。这是处理数字输入的标准做法。


题目 2: 把下面代码改成受控组件。

jsx 复制代码
function SearchBox() {
  const inputRef = useRef()
  return (
    <>
      <input ref={inputRef} />
      <button onClick={() => console.log(inputRef.current.value)}>
        搜索
      </button>
    </>
  )
}

参考答案:

jsx 复制代码
function SearchBox({ value, onChange, onSearch }) {
  return (
    <>
      <input
        value={value}
        onChange={e => onChange(e.target.value)}
      />
      <button onClick={() => onSearch(value)}>搜索</button>
    </>
  )
}

// 父组件
function Page() {
  const [keyword, setKeyword] = useState("")
  return (
    <SearchBox
      value={keyword}
      onChange={setKeyword}
      onSearch={keyword => console.log(keyword)}
    />
  )
}

解读: 非受控改受控的关键:用 value + onChange 替代 ref。子组件变成纯受控,父组件持有状态。这样父组件能实时知道输入值,可以做即时校验、联动等。


题目 3: 下面校验代码有什么问题?

jsx 复制代码
function handleSubmit() {
  if (!email.includes("@")) {
    setEmailError("邮箱格式不正确")
  }
  if (emailError) return
  api.submit(form)
}

参考答案: setEmailError 是异步的,调用后立即读 emailError 拿到的还是旧值,校验失效。

修复:

jsx 复制代码
function handleSubmit() {
  const newErrors = {}
  if (!email.includes("@")) {
    newErrors.email = "邮箱格式不正确"
  }
  if (Object.keys(newErrors).length > 0) {
    setErrors(newErrors)
    return
  }
  api.submit(form)
}

解读: setState 异步是 React 的核心设计。校验必须用局部变量收集错误,最后一次性 setErrors。这是表单校验最常见的坑。


进阶题


题目 4: 实现一个"多步骤表单",3 步,每步有若干字段。要求:能前进、后退、提交,提交中有加载态,提交失败显示错误。

参考答案:

jsx 复制代码
function Wizard() {
  const [step, setStep] = useState(0)
  const [form, setForm] = useState({
    name: "", email: "", company: "", role: "", plan: "free"
  })
  const [status, setStatus] = useState("editing")
  const [error, setError] = useState(null)

  function updateField(field, value) {
    setForm(prev => ({ ...prev, [field]: value }))
  }

  function next() { setStep(s => s + 1) }
  function prev() { setStep(s => s - 1) }

  async function submit() {
    if (status === "submitting") return
    setStatus("submitting")
    try {
      await api.submit(form)
      setStatus("success")
    } catch (e) {
      setError(e.message)
      setStatus("error")
    }
  }

  return (
    <div>
      {step === 0 && (
        <div>
          <input
            value={form.name}
            onChange={e => updateField("name", e.target.value)}
            placeholder="姓名"
          />
          <input
            value={form.email}
            onChange={e => updateField("email", e.target.value)}
            placeholder="邮箱"
          />
        </div>
      )}

      {step === 1 && (
        <div>
          <input
            value={form.company}
            onChange={e => updateField("company", e.target.value)}
            placeholder="公司"
          />
          <input
            value={form.role}
            onChange={e => updateField("role", e.target.value)}
            placeholder="职位"
          />
        </div>
      )}

      {step === 2 && (
        <div>
          <select
            value={form.plan}
            onChange={e => updateField("plan", e.target.value)}
          >
            <option value="free">免费版</option>
            <option value="pro">专业版</option>
          </select>
        </div>
      )}

      <div>
        <button disabled={step === 0} onClick={prev}>上一步</button>
        {step < 2 && <button onClick={next}>下一步</button>}
        {step === 2 && (
          <button disabled={status === "submitting"} onClick={submit}>
            {status === "submitting" ? "提交中..." : "提交"}
          </button>
        )}
      </div>

      {status === "error" && <p>提交失败:{error}</p>}
    </div>
  )
}

解读: 关键点:

  1. step 和 form 分开管理,职责清晰。
  2. form 是单对象,所有步骤的数据集中。
  3. 提交状态用状态机。
  4. 提交按钮只在最后一步显示。
  5. 每步的字段独立渲染。

题目 5: 实现一个"动态字段"表单。用户可以添加多个联系人,每个联系人有姓名、电话。要求:添加、删除、校验。

参考答案:

jsx 复制代码
function ContactsForm() {
  const [contacts, setContacts] = useState([
    { id: Date.now(), name: "", phone: "" }
  ])
  const [errors, setErrors] = useState({})

  function add() {
    setContacts(prev => [...prev, { id: Date.now(), name: "", phone: "" }])
  }

  function remove(id) {
    setContacts(prev => prev.filter(c => c.id !== id))
    setErrors(prev => {
      const { [id]: _, ...rest } = prev
      return rest
    })
  }

  function update(id, field, value) {
    setContacts(prev => prev.map(c =>
      c.id === id ? { ...c, [field]: value } : c
    ))
  }

  function validate() {
    const newErrors = {}
    contacts.forEach(c => {
      if (!c.name) newErrors[`${c.id}-name`] = "姓名不能为空"
      if (!/^\d{11}$/.test(c.phone)) newErrors[`${c.id}-phone`] = "电话格式不正确"
    })
    setErrors(newErrors)
    return Object.keys(newErrors).length === 0
  }

  function handleSubmit(e) {
    e.preventDefault()
    if (!validate()) return
    api.submit(contacts)
  }

  return (
    <form onSubmit={handleSubmit}>
      {contacts.map(contact => (
        <div key={contact.id}>
          <input
            value={contact.name}
            onChange={e => update(contact.id, "name", e.target.value)}
            placeholder="姓名"
          />
          {errors[`${contact.id}-name`] && (
            <p>{errors[`${contact.id}-name`]}</p>
          )}

          <input
            value={contact.phone}
            onChange={e => update(contact.id, "phone", e.target.value)}
            placeholder="电话"
          />
          {errors[`${contact.id}-phone`] && (
            <p>{errors[`${contact.id}-phone`]}</p>
          )}

          {contacts.length > 1 && (
            <button type="button" onClick={() => remove(contact.id)}>
              删除
            </button>
          )}
        </div>
      ))}

      <button type="button" onClick={add}>添加联系人</button>
      <button type="submit">提交</button>
    </form>
  )
}

解读: 关键点:

  1. 每项有唯一 id。
  2. key 用 id。
  3. 错误用 ${id}-${field} 作为 key,避免 id 冲突。
  4. 删除时同时清除对应错误。
  5. type="button" 防止按钮触发提交。

题目 6: 把下面表单的错误处理改成无障碍友好的。

jsx 复制代码
<input value={email} onChange={...} />
{errors.email && <p>{errors.email}</p>}

参考答案:

jsx 复制代码
<input
  id="email"
  value={email}
  onChange={...}
  aria-invalid={!!errors.email}
  aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
  <p id="email-error" role="alert">{errors.email}</p>
)}

解读:

  • aria-invalid 告诉屏幕阅读器字段无效。
  • aria-describedby 关联错误信息,屏幕阅读器会读出错误。
  • role="alert" 让错误出现时立即播报。
  • id 用于关联。

无障碍是专业开发的标配,不是可选项。


思考题


题目 7: 为什么数字输入框的值应该存字符串而不是数字?

参考答案:

因为输入过程中有中间态:

  • 空字符串(用户清空)
  • "-"(负号,还没输入数字)
  • "1."(小数点,还没输入小数)
  • "1.0"(末尾的 0 有意义)

如果存数字:

  • Number("") 是 0,用户无法清空。
  • Number("-") 是 NaN,状态变成 NaN。
  • Number("1.") 是 1,用户输入的小数点丢失。

正确做法: 输入框存字符串,需要数字时在提交或使用时转换。

解读: 这是受控组件的常见陷阱。输入框的值始终是字符串,这是 HTML 的规则。 尊重这个规则,可以避免很多 bug。


题目 8: 为什么校验函数应该是纯函数?

参考答案:

纯函数的好处:

  1. 可独立测试。 不需要渲染组件就能测试。
  2. 可复用。 同一个校验函数可以用在不同表单。
  3. 可组合。 多个校验函数可以组合。
  4. 可预测。 相同输入,相同输出,容易推理。

反例:

jsx 复制代码
// ❌ 依赖外部状态
function validateEmail() {
  if (!form.email) return "邮箱不能为空"
}

正例:

jsx 复制代码
// ✅ 纯函数
function validateEmail(email) {
  if (!email) return "邮箱不能为空"
  if (!email.includes("@")) return "邮箱格式不正确"
  return ""
}

解读: 校验逻辑是最适合写成纯函数的部分。把校验抽出来,代码更清晰,测试更容易,复用更方便。


题目 9: 表单校验的三种时机各有什么优缺点?为什么推荐"失焦 + 提交"组合?

参考答案:

时机 优点 缺点
即时校验 反馈最及时 输入过程中频繁报错,体验差
失焦校验 不打扰输入,用户填完立即知道 用户可能一直不离开输入框
提交时校验 一次性告诉所有错误 反馈太晚

推荐"失焦 + 提交"的原因:

  • 失焦校验:用户填完一项立即知道,不打扰输入。
  • 提交时校验:兜底,防止用户没失焦就提交。
  • 输入时清除错误:如果已有错误,用户修改时重新校验,错误消失。

解读: 校验时机的选择是用户体验的平衡。即时校验太打扰,提交校验太晚,失焦校验是平衡点。加上提交兜底和输入时清除,形成完整的校验体验。


挑战题


题目 10: 设计一个"表单"组件库,要求:

  • 通用的 Field 组件,支持文本、密码、数字、下拉框
  • 支持错误显示
  • 支持无障碍
  • 支持布局(水平/垂直)
  • 用组合的方式使用

参考答案:

jsx 复制代码
// 通用字段容器
function Field({ label, error, required, children, layout = "vertical" }) {
  const id = useId()
  const errorId = `${id}-error`

  return (
    <div className={`field field-${layout}`}>
      <label htmlFor={id}>
        {label}
        {required && <span className="required">*</span>}
      </label>
      <div className="field-control">
        {React.cloneElement(children, {
          id,
          "aria-invalid": !!error,
          "aria-describedby": error ? errorId : undefined
        })}
        {error && (
          <p id={errorId} className="error" role="alert">
            {error}
          </p>
        )}
      </div>
    </div>
  )
}

// 文本输入
function TextInput({ value, onChange, ...props }) {
  return (
    <input
      type="text"
      value={value}
      onChange={e => onChange(e.target.value)}
      {...props}
    />
  )
}

// 密码输入
function PasswordInput({ value, onChange, ...props }) {
  return (
    <input
      type="password"
      value={value}
      onChange={e => onChange(e.target.value)}
      {...props}
    />
  )
}

// 数字输入
function NumberInput({ value, onChange, ...props }) {
  return (
    <input
      type="number"
      value={value}
      onChange={e => onChange(e.target.value)}
      {...props}
    />
  )
}

// 下拉框
function Select({ value, onChange, options, ...props }) {
  return (
    <select
      value={value}
      onChange={e => onChange(e.target.value)}
      {...props}
    >
      {options.map(opt => (
        <option key={opt.value} value={opt.value}>
          {opt.label}
        </option>
      ))}
    </select>
  )
}

// 使用
function LoginForm() {
  const [form, setForm] = useState({ email: "", password: "" })
  const [errors, setErrors] = useState({})

  function updateField(field, value) {
    setForm(prev => ({ ...prev, [field]: value }))
  }

  return (
    <form>
      <Field label="邮箱" error={errors.email} required>
        <TextInput
          value={form.email}
          onChange={v => updateField("email", v)}
        />
      </Field>

      <Field label="密码" error={errors.password} required>
        <PasswordInput
          value={form.password}
          onChange={v => updateField("password", v)}
        />
      </Field>
    </form>
  )
}

设计要点:

  1. Field 是容器。 负责标签、错误、无障碍、布局,不关心具体输入类型。
  2. 输入组件独立。 TextInput、PasswordInput 等,各自处理自己的 value / onChange。
  3. 组合使用。 Field 包裹输入组件,通过 cloneElement 注入无障碍属性。
  4. useId 生成唯一 id。 React 18 的 useId 保证 SSR 和客户端一致。
  5. 无障碍内建。 Field 自动处理 aria-invalid、aria-describedby。

解读: 这是表单组件库的核心设计思路。容器组件负责布局和无障碍,输入组件负责值管理,通过组合实现灵活复用。 这也是大多数 UI 库(如 Ant Design、Material UI)的表单实现方式。


十五、本课小结

受控组件

值来自 props,变化通过回调通知。

jsx 复制代码
<input value={value} onChange={e => onChange(e.target.value)} />
  • 初始值用 "",不用 undefined。
  • 数字输入存字符串。
  • 复选框用 checked + e.target.checked。

多字段组织

场景 方案
字段少且独立 多状态
字段多或整体提交 单对象
动态字段 数组 + id

三种校验时机

时机 优点 缺点
即时 反馈及时 打扰输入
失焦 不打扰,立即反馈 用户可能不离开
提交 一次性所有错误 反馈太晚

推荐:失焦 + 提交 + 输入时清除错误。

提交状态

用状态机,不用多个布尔。

jsx 复制代码
const [status, setStatus] = useState("editing")
// "editing" | "submitting" | "success" | "error"

防重复提交

  • 禁用按钮。
  • 事件处理器守卫。
  • 用 <form> 的 onSubmit。

动态字段

  • 每项有唯一 id。
  • key 用 id,不用 index。
  • 更新用不可变。

错误处理

  • 错误展示在字段下方。
  • 提交失败聚焦第一个错误字段。
  • 无障碍:aria-invalid、aria-describedby、role="alert"。

关键规则

  1. 校验用局部变量收集错误,最后一次性 setErrors。
  2. 数字输入存字符串。
  3. 校验函数是纯函数。
  4. 提交状态用状态机。
  5. 错误输入时清除。

一句话记住本课

表单是用户输入的唯一入口,健壮的表单处理是专业开发的标志。

实用价值回顾

  1. 无 bug 的受控表单。 避免"value 为 undefined""数字无法清空"等常见坑。
  2. 清晰的校验时机。 失焦 + 提交,用户体验好。
  3. 健壮的提交。 状态机 + 防重复 + 错误处理。
  4. 灵活的动态字段。 添加、删除、校验都清晰。
  5. 无障碍。 服务更多用户,专业开发的标配。

十六、下一课预告

第 6 课 副作用管理:Effect 的正确用法

本课讲了表单输入,下一课讲与外部世界的交互。核心内容:

  • 什么该放 Effect,什么不该
  • 依赖数组的正确理解
  • 清理函数:取消请求、取消订阅
  • 常见陷阱:无限循环、竞态条件、闭包陷阱
  • 数据获取的模式
  • 事件监听与订阅

表单是内部交互,副作用是与外部交互。两者结合,覆盖了完整的应用场景。

相关推荐
传奇开心果编程3 小时前
【声明式UI开发实用技术学与练】第9课 派生状态与计算
ui·移动开发·ux·编程框架·声明式ui
ZARD9965 小时前
Codex 连接 Figma:让 AI 读取 Figma UI 设计稿数据
ui·figma·codex
事圆则缓6 小时前
Android 一次性事件怎么设计:Channel、SharedFlow 还是 UI State?
android·ui
我命由我123456 小时前
Photoshop - Photoshop 使用魔棒工具选择单独的区域
学习·ui·职场和发展·求职招聘·职场发展·学习方法·photoshop
特立独行的猫a6 小时前
用仓颉语言给娃写了个打字练习游戏:cj-tauri 项目实战
游戏·ui·框架·tauri·仓颉·cangjie·cj-tauri
传奇开心果编程6 小时前
【声明式UI开发实用技术学与练】第8课 状态提升与下放
学习·flutter·react native·ui·swiftui·composer
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第7课 平台适配与互操作
android·windows·学习·ui·ios·kotlin·composer
传奇开心果编程1 天前
【现代声明式UI学与练】第4课 列表渲染与 key——如何高效渲染列表、key 的作用、列表重排时的状态保持
学习·flutter·react native·ui·swiftui·android jetpack
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第5课 网络与数据层
android·网络·学习·ui·ios·kotlin·composer