首页 / 文章 / Next.js中带输入控件与回调功能的经过验证的出生日期表单

Next.js中带输入控件与回调功能的经过验证的出生日期表单

构建一个小的 Next.js 客户端表单,使用 useState 跟踪输入内容,拒绝无效的出生日期,显示易于理解的错误信息,并将处理干净的数据传递给父组件。

2384 词

一个占星应用在生成预测结果之前需要访问者提供两样信息:姓名和出生日期。这听起来像是个只需五分钟就能填完的表单,但即便如此简单的部分也需要做出实际的设计决策:它在Next.js应用中的运行位置、输入数据的归属权、如何处理无效日期,以及成功提交后由谁来决定后续操作。

本教程将逐步构建这个表单。完成后,你将拥有一个可输入、可控制的表单组件,它能验证输入内容、以直观方式显示错误信息,并仅向上传递有效数据;同时你还会掌握一个清晰的思维模型,可重复用于任何包含多个字段的表单。

明确组件的职责

在编写任何JSX代码之前,先列出该组件的功能是很有必要的。这个表单恰好有三项职责:

  1. 记住用户输入的内容。
  2. 在表单提交前检查输入内容。
  • 将有效数据传回父组件。
  • 列表之外的操作,比如调用 API 或渲染读取结果,应放在其他地方处理。保持列表简洁才能让整体设计更简单明了。

    为何表单必须是客户端组件

    在 App Router 中,除非主动选择其他方式,否则每个组件都是服务器组件。服务器组件在服务器端渲染,不会包含任何交互式 JavaScript,因此无法存储状态或响应事件。所以表单必须以客户端指令开头。

    "use client";
    

    该组件依赖于仅在浏览器中存在的若干功能:

    • 用于存储当前值的 useState,
    • 输入框上的 onChange 处理函数,
    • 表单上的 onSubmit 处理函数,
    • 与用户的持续交互,
  • 在任何数据发送之前都会进行验证。
  • 简而言之,该组件不仅负责显示信息,还必须对用户的操作做出响应。这正是将其标记为客户端组件的依据。一个良好的习惯是让这类组件保持较小规模并位于页面结构的最底层,这样指令就不会将页面的大部分内容纳入客户端打包中。

    输入数据与属性

    该表单会导入共享的Profile类型以及useState。

    import { Profile } from "../types";
    import { useState } from "react";
    

    明确声明提交数据的结构意味着TypeScript可以检查所有生成或使用该数据的地方,而不会让任意对象在应用中随意传递。

    export type Profile ={
     name: string;
     dob: string;
    }
    

    接下来是组件的属性,只有一个:由父组件提供的回调函数。

    type HoroscopeFormProps = {
      onSubmit: (info: Profile) => void;
    };
    

    让父组件决定后续操作

    该属性决定了组件边界的绘制位置。表单负责收集数据,但无权决定如何处理这些数据。根据屏幕的不同,父组件可能会:

    • 调用 API,
    • 生成占星结果,
    • 保存用户资料,
    • 显示结果,
    • 更新其他状态。

    若将上述任一操作直接硬编码在表单中,都会使其仅适用于某一特定屏幕。而使用 onSubmit 函数则能让表单具备复用性。该函数的类型说明表明它接收一个 Profile 对象并返回无值(void),因此表单触发该函数后会继续执行后续操作。整体流程如下:

    User enters information
            ↓
    HoroscopeForm collects it
            ↓
    HoroscopeForm validates it
            ↓
    onSubmit(user)
            ↓
    Parent decides what happens next
    

    每个步骤都有唯一的负责人,表单在调用回调函数后便完成其职责。

    将输入数据存储在 React 状态中

    表单需要一个地方来存储当前值,三个状态变量即可涵盖所有需求。

    const [name, setName] = useState<string>("");
    const [dob, setDob] = useState<string>("");
    const [error, setError] = useState<string>("");
    

    先看看名称。

    const [name, setName] = useState<string>("");
    

    useState会返回一个值对。name表示当前值,而setName则是用于替换该值的函数,同时会触发重新渲染。由于尚未输入任何内容,初始值为空字符串。对于受控输入而言,使用字符串而非undefined作为初始值很重要:当输入的value从undefined变为字符串时,React会发出警告,表明该输入已从不受控状态转为受控状态。

    出生日期也遵循相同的规则。

    const [dob, setDob] = useState<string>("");
    

    最后一个状态变量用于存储当前的错误信息。

    const [error, setError] = useState<string>("");
    

    空字符串表示当前没有错误。当出现异常时,验证机制会在此字段中写入相应提示信息,而在输入通过验证后则会清除该信息。

    将日期验证单独放在函数中

    验证规则往往会越来越多,因此不应将它们全部堆放在提交处理函数中,而应把日期检查功能放入专门的函数中。该函数的参数说明即代表了其功能规范。

    function validateDOB(dob: string): string | null {
    

    只有两种结果:无效日期会返回说明问题的字符串;有效日期则返回 null。

    Valid date
       ↓
    return null
    
    Invalid date
       ↓
    return error message
    

    由于该函数仅回答“这个出生日期是否有效?”这一个问题,因此既易于理解,也无需渲染任何内容即可方便地进行单元测试;如果日后在服务器端也需要进行验证,它也便于重复使用。通过返回消息而非抛出异常,可以简化调用代码:只需检查结果,有则显示即可。

    应拒绝哪些日期

    对于出生日期,有两条合理的规则:

    • 不能是未来日期。没有人可能出生在尚未到来的日子。
    • 要有合理的下限。超过150年前的日期几乎可以肯定是由于输入错误,因此应被拒绝。

    在考虑时间因素之前,日期看起来很简单。<input type="date">会生成格式为YYYY-MM-DD的字符串,而new Date("2024-05-01")会将该字符串理解为UTC时间午夜,不过通过new Date()生成的“今天”则包含当地的时、分、秒。由于用户所在时区不同,简单的比较方式可能会错误地将明天视为今天或拒绝接受今天。两种可靠的解决方案是:在比较之前先将两侧数据都转换为当天的起始时间,或者直接比较YYYY-MM-DD格式的字符串,因为这类文本可以正确排序。无论你采用哪种方法,也无论是否有AI助手帮你编写代码,都必须能够解释每项比较的必要性;日期相关的错误往往就隐藏在那些没人理解的部分之中。

    在提交处理函数中统一处理所有相关内容

    在状态和验证准备就绪后,handleSubmit会将它们整合起来。当用户提交表单时,它必须:

    1. 阻止浏览器的默认提交行为,因为那样会导致页面重新加载或跳转,
    2. 确认两个字段都有内容,
    3. 验证出生日期的有效性,
    4. 如果出现任何问题则显示错误信息,
    5. 否则将数据传递给父组件。

    它的实现始于此处。

    const handleSubmit = (e: React.SubmitEvent) => {
      e.preventDefault();
    

    默认情况下,表单提交会发送请求并重新加载页面。由于此处由 React 处理提交操作,因此必须取消这一默认行为。

    e.preventDefault();
    

    从现在开始,只有组件本身能决定提交操作的具体内容。关于事件类型的说明:许多代码库会将此参数定义为 React.FormEvent<HTMLFormElement>。请查看您所安装的 @types/react 版本支持哪些提交事件类型,并选择项目中一致使用的那种。

    通过提前返回拒绝空字段

    在检查日期是否有效之前,先确认是否有内容被输入。

    if (!name || !dob) {
      setError("Please enter in information");
      return;
    }
    

    如果任一字段为空,处理程序会记录错误并立即返回。这就是提前返回(或保护条款)模式:一旦确定输入无效,就无需再执行任何操作,因此函数直接退出,而不会用另一层if语句来包裹剩余逻辑。每个保护条款负责处理一种错误情况,而“正常路径”则保持简单结构。

    执行日期检查

    一旦确定存在日期,就会将其传递给验证器。

    const dobError = validateDOB(dob);
    

    返回结果要么是消息,要么是null,因此只需一次检查即可。

    if (dobError) {
      setError(dobError);
      return;
    }
    

    如果是消息,处理程序会显示该消息并停止执行;如果是null,则表示日期有效,程序将继续执行。

    将处理后的数据传递给上层

    达到此状态意味着所有检查均已通过,因此之前尝试中产生的任何旧错误都会被清除。

    setError("");
    

    随后,父组件的回调函数会接收到已验证的配置信息。

    onSubmit({ name, dob });
    

    这就是早期设计决策带来的好处。表单并不需要知道或关心后续会发生什么;它只需声明已有有效数据,由父组件决定如何处理。同一个组件今天可以用于生成星座运势,明天又可以用于配置界面,而无需进行任何修改。

    将逻辑与标记关联起来

    表单元素负责将数据提交操作与处理函数连接起来。

    <form onSubmit={handleSubmit}>
    

    这告诉 React,无论是通过点击按钮还是按回车键提交表单,都应执行 handleSubmit 函数。接下来是姓名输入字段。

    <input
      type="text"
      value={name}
      onChange={(e) => setName(e.target.value)}
    />
    

    受控输入如何保持同步

    这是一个受控输入:其值的真实来源是 React 状态,而非 DOM。每当用户输入时,变更处理函数就会被触发。

    onChange={(e) => setName(e.target.value)}
    

    它会从事件中读取新文本并将其存储在状态中。完整的循环流程如下:

    User types
        ↓
    onChange fires
        ↓
    setName(new value)
        ↓
    name state updates
        ↓
    value={name}
        ↓
    Input displays updated value
    

    由于输入框始终显示 name 所存储的内容,因此你验证的值必定是屏幕上显示的数值。日期字段也采用相同的处理方式。

    <input
      type="date"
      value={dob}
      onChange={(e) => setDob(e.target.value)}
    />
    

    状态用于记录选中的日期,每次变化都会调用 setDob 函数。还有一个值得考虑的改进点,那就是将原生 max 属性设置为今天日期,这样就能阻止大多数日期选择器显示未来的日期,同时你的验证机制仍能防范错误输入及旧版浏览器的问题。

    每个输入字段都应配有可见的 <label>。占位符或附近的标题无法替代标签;正是标签会被屏幕阅读器读出,也使得该字段可通过其标题被点击。

    仅在存在错误时显示

    只有在有错误时才应显示错误信息。条件渲染功能可实现这一点。

    {error && (
      <p role="alert">
        {error}
      </p>
    )}
    

    当 error 变量包含文本时,对应段落会被渲染;而当其为空字符串(属于假值)时,则不会显示任何内容。由于此处值为字符串,使用 && 缩写是安全的。但对于数字则可能出问题:数值 0 会被直接显示为文字“0”。

    该段落还具备 ARIA 角色功能。

    role="alert"
    

    role="alert" 可告知辅助技术该内容非常重要且具有时间敏感性,因此屏幕阅读器会在其出现时立即播报。只需修改这一个属性,就能让无法看到弹出消息的用户也能获取验证反馈。为进一步提升清晰度,还可以使用 aria-invalid 标记有问题的字段,并通过 aria-describedby 将其与相关提示关联起来。

    添加提交按钮

    最后需要的是一个被明确声明为提交按钮的元素。

    <button type="submit">
      Submit
    </button>
    

    在表单中,类型为 type="submit" 的按钮会触发表单的 onSubmit 方法,进而触发 handleSubmit 方法。表单中的按钮默认就是用于提交数据的,但明确指定类型可以避免后续有人添加了其他用途的按钮(如清空表单字段)时出现意外。

    完整的数据流

    从整体上看,该组件是按单一方向传递数据的:

    State
      ↓
    User input
      ↓
    Submit
      ↓
    Validation
      ↓
    Parent callback
    
    • useState 用于存储用户输入的内容。
    • 每次输入内容变化时,相关输入字段都会更新该状态。
    • 提交表单时会执行 handleSubmit 方法。
    • handleSubmit 会验证输入数据的有效性。
    • 如果输入无效,就会设置错误状态并停止后续处理。
    • 如果输入有效,数据会通过 onSubmit 传递给父组件,由父组件进一步处理。

    下一步该怎么做

    这种手动编写的方式非常适合学习,对于只有两个字段的表单也完全足够。当表单的字段数量增多,需要跨字段规则或服务器端校验时,建议使用模式库,以便在客户端和服务器端应用相同的规则;一种实现方式就是在React前端和Node后端之间共享同一个Zod模式。客户端校验虽然能提升用户体验,但绝不能替代服务器端的校验,因为任何请求都可能被人为构造。

    关键要点

    • 仅对交互式组件使用"use client"标签,并保持其规模较小。
    • 让表单承担单一功能:收集数据、进行校验、然后传递数据。通过类型化的回调函数让父组件处理相关副作用。
  • 使用以字符串初始化的受控输入,这样屏幕上显示的值就是您所验证的值。
  • 将验证规则放在返回消息或null的纯函数中;这样既便于测试,也能重复使用。
  • 处理日期时要格外小心:对时间进行标准化处理,或比较YYYY-MM-DD格式的字符串,以避免时区导致的计算错误。
  • 通过提前返回让提交处理函数结构简洁,并使用role="alert"及合适的标签,以便用户查看错误信息。
  • 能够解释每行代码的存在原因,尤其是那些由人工智能助手建议的代码行,也是完成工作的关键环节。