跳到主要内容
预计阅读 74 分钟

表单与数据交互 —— 打造生产级交互体验

表单是 Web 应用中最古老也最核心的交互方式——从用户注册、搜索框到后台管理系统的数百个筛选项,几乎所有”用户输入”的场景都离不开表单。但在 React 中,表单的处理方式和传统 HTML 有一个根本性的区别:React 用 state 驱动 UI,而 HTML 表单元素天然有自己的内部状态。这两套状态系统的”主权之争”,催生了受控组件和非受控组件两种模式。更进一步,当表单提交后,数据需要通过网络请求发往服务器,“加载中、成功、失败”三种状态的处理又成了新的挑战。本章将从最基础的 <input> 开始,一路走到完整的”注册表单 + 数据提交 + 反馈”实战,帮你建立起处理表单和数据交互的完整心智模型。

📋 开篇自测:你已经知道多少?

  1. 你能说清楚”受控组件”和”非受控组件”的核心区别吗?在什么场景下你会选择非受控?
  2. 当一个表单有 10 个字段、5 条校验规则,你会怎么组织状态和校验逻辑,而不是写一堆零散的 useState?
  3. 用 useEffect 发请求时,你知道为什么必须处理”竞态条件”吗?不处理会出什么 bug?

一、受控表单 —— 用 state 完全掌控表单数据

1.1 谁来当”老板”?

在传统 HTML 中,<input> 自己管理值——用户输入什么,它就显示什么,你通过 document.getElementById('myInput').value 去”偷看”它的值。这就像一个员工自己决定做什么,老板只能事后查看结果。

在 React 的受控模式中,情况完全反转:state 才是数据的唯一来源<input> 只是一个”显示器”。用户的每次输入会触发 onChange,你在 onChange 中更新 state,state 变化后 React 重新渲染,input 显示的值才会更新。整个流程形成一个闭环:

用户输入 → onChange 触发 → setState 更新 → React 重渲染 → input 显示新值

这就像老板收到邮件后才决定是否批准、如何修改,最终由老板决定屏幕上显示什么。

1.2 最基本的受控 input

import { useState } from 'react';

function LoginForm() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault(); // 阻止浏览器默认的表单提交行为
    console.log('提交的数据:', { username, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>用户名:</label>
        <input
          type="text"
          value={username}           // state 决定显示什么
          onChange={(e) => setUsername(e.target.value)}  // 用户输入更新 state
        />
      </div>
      <div>
        <label>密码:</label>
        <input
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </div>
      <button type="submit">登录</button>
    </form>
  );
}

关键点:value={username} 是受控模式的标志。如果你只写了 value 却没有 onChange,React 会在控制台报警告,而且 input 会变成”只读”的——因为 state 没有更新机制,值永远不会变。

1.3 受控模式的超能力

受控模式的价值不在于”能拿到值”——非受控也能拿到值。它的真正价值在于你可以在用户输入和显示之间插入任意逻辑

function PhoneInput() {
  const [phone, setPhone] = useState('');

  const handleChange = (e) => {
    // 只允许输入数字,最多 11 位
    const value = e.target.value.replace(/\D/g, '').slice(0, 11);
    setPhone(value);
  };

  // 格式化显示:138-0000-0000
  // 注意:此正则在输入不足 3 位时无法匹配,生产环境需要更健壮的处理
  const displayValue = phone.replace(/(\d{3})(\d{0,4})(\d{0,4})/, (_, a, b, c) => {
    let result = a;
    if (b) result += '-' + b;
    if (c) result += '-' + c;
    return result;
  });

  return <input value={displayValue} onChange={handleChange} placeholder="请输入手机号" />;
}

用户随便输入什么字符,你都可以过滤、格式化、限制长度。这在非受控模式下几乎无法实现——你没有拦截输入的时机。

🤔 想一想 当你在一个搜索框中需要做”防抖搜索”(用户停止输入 300ms 后才发请求),这个场景更适合受控还是非受控模式?为什么?


二、非受控表单 —— 用 useRef 直接读取 DOM

2.1 有时候,当甩手掌柜更好

受控模式虽然强大,但也有代价:每次用户按下一个键,都会触发一次 setState 和重渲染。对于简单的表单,这完全没问题。但如果一个页面有几十个表单字段,每个字段的每次输入都触发整个表单重渲染,性能可能成为问题。

更重要的是,很多时候你根本不需要”实时掌控”——你只需要在用户点击”提交”按钮时拿到最终的值就够了。这时候,非受控模式更简洁。

2.2 用 useRef 读取 DOM 值

import { useRef } from 'react';

function SimpleForm() {
  const nameRef = useRef(null);
  const emailRef = useRef(null);

  const handleSubmit = (e) => {
    e.preventDefault();
    // 提交时才去读取值
    const data = {
      name: nameRef.current.value,
      email: emailRef.current.value,
    };
    console.log('提交的数据:', data);
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>姓名:</label>
        <input ref={nameRef} defaultValue="张三" />   {/* 注意是 defaultValue,不是 value */}
      </div>
      <div>
        <label>邮箱:</label>
        <input ref={emailRef} type="email" />
      </div>
      <button type="submit">提交</button>
    </form>
  );
}

注意两个关键区别:

  • defaultValue 而不是 valuedefaultValue 只设置初始值,之后由 DOM 自己管理。
  • 不需要 onChange,也不需要 state。用户输入时组件不会重渲染。

2.3 还有一种更原生的方式:FormData

其实浏览器自带一个非常好用的 API——FormData,它可以自动收集表单内所有带 name 属性的字段:

function NativeForm() {
  const handleSubmit = (e) => {
    e.preventDefault();
    const formData = new FormData(e.target);
    const data = Object.fromEntries(formData);
    console.log('表单数据:', data);
    // 输出:{ username: '...', email: '...', role: '...' }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="username" placeholder="用户名" />
      <input name="email" type="email" placeholder="邮箱" />
      <select name="role">
        <option value="user">普通用户</option>
        <option value="admin">管理员</option>
      </select>
      <button type="submit">提交</button>
    </form>
  );
}

不需要 useState,不需要 useRef,不需要 onChange。对于简单的”填完就交”的表单,这是最简洁的方式

2.4 受控 vs 非受控:如何选择?

场景推荐模式理由
需要实时校验(输入时立即提示错误)受控需要实时获取值来运行校验逻辑
需要格式化输入(手机号、金额)受控需要拦截并修改用户输入
需要联动(A 字段的值影响 B 字段的选项)受控需要在 state 变化时重新计算
简单的登录/搜索表单非受控提交时拿值即可,不需要过程控制
文件上传(<input type="file">非受控文件 input 无法被受控,因为它的值是只读的

经验法则: 如果你不确定选哪个,先用受控模式。受控模式覆盖面更广,后续要加实时校验、格式化等功能时不需要重构。


三、表单验证 —— 实时校验、提交校验、错误提示

3.1 表单验证的三个时机

一个好的表单验证体验,需要在三个时机触发校验:

  1. 实时校验(onChange): 用户输入时立即反馈。适合格式类校验,比如”邮箱格式不正确”。
  2. 失焦校验(onBlur): 用户离开输入框时校验。比实时校验更温和,不会在用户还没输完就报错。
  3. 提交校验(onSubmit): 点击提交按钮时做全量校验。这是最后一道防线。

最佳实践是组合使用——首次输入时不急着报错(用失焦校验),一旦报过错就切换为实时校验,这样既不打扰用户,又能在用户修改时给出即时反馈。

3.2 从零实现表单验证

import { useState } from 'react';

function ValidatedForm() {
  const [values, setValues] = useState({ email: '', password: '' });
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});  // 记录哪些字段被"触碰"过

  // 校验规则
  const validate = (name, value) => {
    switch (name) {
      case 'email':
        if (!value) return '邮箱不能为空';
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return '邮箱格式不正确';
        return '';
      case 'password':
        if (!value) return '密码不能为空';
        if (value.length < 8) return '密码至少 8 位';
        if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value)) return '密码需包含大小写字母和数字';
        return '';
      default:
        return '';
    }
  };

  // 统一的 onChange 处理
  const handleChange = (e) => {
    const { name, value } = e.target;
    setValues((prev) => ({ ...prev, [name]: value }));
    // 如果字段已经被触碰过(报过错),则实时校验
    if (touched[name]) {
      setErrors((prev) => ({ ...prev, [name]: validate(name, value) }));
    }
  };

  // 失焦时校验
  const handleBlur = (e) => {
    const { name, value } = e.target;
    setTouched((prev) => ({ ...prev, [name]: true }));
    setErrors((prev) => ({ ...prev, [name]: validate(name, value) }));
  };

  // 提交时全量校验
  const handleSubmit = (e) => {
    e.preventDefault();
    const newErrors = {};
    Object.keys(values).forEach((key) => {
      newErrors[key] = validate(key, values[key]);
    });
    setErrors(newErrors);
    setTouched({ email: true, password: true });

    const hasError = Object.values(newErrors).some((msg) => msg !== '');
    if (!hasError) {
      console.log('校验通过,提交数据:', values);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>邮箱:</label>
        <input
          name="email"
          value={values.email}
          onChange={handleChange}
          onBlur={handleBlur}
          style={{ borderColor: errors.email ? 'red' : '#ccc' }}
        />
        {errors.email && <p style={{ color: 'red', fontSize: 12 }}>{errors.email}</p>}
      </div>
      <div>
        <label>密码:</label>
        <input
          name="password"
          type="password"
          value={values.password}
          onChange={handleChange}
          onBlur={handleBlur}
          style={{ borderColor: errors.password ? 'red' : '#ccc' }}
        />
        {errors.password && <p style={{ color: 'red', fontSize: 12 }}>{errors.password}</p>}
      </div>
      <button type="submit">提交</button>
    </form>
  );
}

这里有三个状态在协同工作:values 保存表单值,errors 保存每个字段的错误信息,touched 记录哪些字段被用户操作过。三者配合实现了”首次友好、后续严格”的校验体验。


四、复杂表单状态管理 —— useReducer 管理多字段表单

4.1 当 useState 开始失控

上一节的表单只有两个字段,我们就需要三个 useState(values、errors、touched)。想象一下,如果表单有 10 个字段,还需要处理加载状态、提交状态、服务端返回的错误——用一堆独立的 useState 管理,代码会像一盘散沙,改一个功能要改五六个地方。

这时候该让 useReducer 登场了。如果你在第五章已经学过 useReducer,你会知道它的核心思想是:把所有状态修改逻辑集中到一个 reducer 函数里,组件只需要”发命令”(dispatch action)

4.2 用 useReducer 重构表单

import { useReducer } from 'react';

// 定义初始状态
const initialState = {
  values: { username: '', email: '', password: '', confirmPassword: '' },
  errors: {},
  touched: {},
  isSubmitting: false,
};

// 所有状态修改逻辑集中在此
function formReducer(state, action) {
  switch (action.type) {
    case 'FIELD_CHANGE':
      return {
        ...state,
        values: { ...state.values, [action.field]: action.value },
        // 如果该字段被触碰过,实时清除错误
        errors: state.touched[action.field]
          ? { ...state.errors, [action.field]: '' }
          : state.errors,
      };
    case 'FIELD_BLUR':
      return {
        ...state,
        touched: { ...state.touched, [action.field]: true },
        errors: { ...state.errors, [action.field]: action.error },
      };
    case 'SET_ERRORS':
      return {
        ...state,
        errors: action.errors,
        touched: Object.keys(state.values).reduce(
          (acc, key) => ({ ...acc, [key]: true }),
          {}
        ),
      };
    case 'SUBMIT_START':
      return { ...state, isSubmitting: true };
    case 'SUBMIT_END':
      return { ...state, isSubmitting: false };
    case 'RESET':
      return initialState;
    default:
      return state;
  }
}

function RegistrationForm() {
  const [state, dispatch] = useReducer(formReducer, initialState);
  const { values, errors, isSubmitting } = state;

  const validateField = (name, value) => {
    switch (name) {
      case 'username':
        if (!value) return '用户名不能为空';
        if (value.length < 3) return '用户名至少 3 个字符';
        return '';
      case 'email':
        if (!value) return '邮箱不能为空';
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return '邮箱格式不正确';
        return '';
      case 'password':
        if (!value) return '密码不能为空';
        if (value.length < 8) return '密码至少 8 位';
        return '';
      case 'confirmPassword':
        if (value !== values.password) return '两次密码不一致';
        return '';
      default:
        return '';
    }
  };

  const handleChange = (e) => {
    dispatch({ type: 'FIELD_CHANGE', field: e.target.name, value: e.target.value });
  };

  const handleBlur = (e) => {
    const { name, value } = e.target;
    const error = validateField(name, value);
    dispatch({ type: 'FIELD_BLUR', field: name, error });
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    // 全量校验
    const newErrors = {};
    Object.entries(values).forEach(([key, value]) => {
      newErrors[key] = validateField(key, value);
    });

    const hasError = Object.values(newErrors).some(Boolean);
    if (hasError) {
      dispatch({ type: 'SET_ERRORS', errors: newErrors });
      return;
    }

    dispatch({ type: 'SUBMIT_START' });
    try {
      // 模拟 API 请求
      await new Promise((resolve) => setTimeout(resolve, 1500));
      console.log('注册成功:', values);
      dispatch({ type: 'RESET' });
    } catch (err) {
      console.error('注册失败');
    } finally {
      dispatch({ type: 'SUBMIT_END' });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {['username', 'email', 'password', 'confirmPassword'].map((field) => (
        <div key={field} style={{ marginBottom: 12 }}>
          <label>{field}:</label>
          <input
            name={field}
            type={field.includes('password') || field.includes('Password') ? 'password' : 'text'}
            value={values[field]}
            onChange={handleChange}
            onBlur={handleBlur}
          />
          {errors[field] && <p style={{ color: 'red', fontSize: 12 }}>{errors[field]}</p>}
        </div>
      ))}
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? '提交中...' : '注册'}
      </button>
    </form>
  );
}

对比之前的 useState 版本,useReducer 版本的优势显而易见:所有状态变更逻辑都在 formReducer,组件里只有 dispatch 调用。当你需要新增一个”重置某个字段”的功能时,只需在 reducer 里加一个 case,组件代码几乎不用改。

🤔 想一想 useReducer 的 reducer 函数是一个纯函数——给定相同的 state 和 action,永远返回相同的新 state。这意味着它可以被单独测试,不依赖 React 组件。试着想象你会怎么给这个 formReducer 写单元测试?


五、数据请求 —— fetch/axios + useEffect 加载数据

5.1 React 中发请求的基本模式

表单填完提交,数据要发到服务器;页面加载时,也需要从服务器拉取数据。在 React 中,最常见的做法是 useEffect + fetch

import { useState, useEffect } from 'react';

function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('https://jsonplaceholder.typicode.com/users')
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((data) => {
        setUsers(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err.message);
        setLoading(false);
      });
  }, []);  // 空依赖数组 = 只在组件挂载时执行一次

  if (loading) return <p>加载中...</p>;
  if (error) return <p>出错了:{error}</p>;
  if (users.length === 0) return <p>暂无数据</p>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name} - {user.email}</li>
      ))}
    </ul>
  );
}

5.2 必须处理的竞态条件

上面的代码有一个隐藏的 bug。假设这个组件接收一个 userId prop,每次 userId 变化就重新请求:

useEffect(() => {
  fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(data => setUser(data));
}, [userId]);

问题来了:如果用户快速切换——先请求 userId=1,再请求 userId=2。如果请求 2 先返回、请求 1 后返回,setUser 会被请求 1 的结果覆盖,页面显示的是 userId=1 的数据,但用户期望看到的是 userId=2

这就是竞态条件(Race Condition)。解决方式是用一个标志位忽略过时的响应:

useEffect(() => {
  let isCancelled = false;  // 标志位

  fetch(`/api/users/${userId}`)
    .then((res) => res.json())
    .then((data) => {
      if (!isCancelled) {   // 只有最新的请求才更新状态
        setUser(data);
      }
    });

  return () => {
    isCancelled = true;     // 组件卸载或 userId 变化时,标记为"已取消"
  };
}, [userId]);

useEffect 的清理函数(return 部分)会在下一次 effect 执行前组件卸载时被调用。当 userId 从 1 变为 2 时,清理函数先把 isCancelled 设为 true,这样即使请求 1 后来返回了,也不会更新状态。

5.3 用 AbortController 做更彻底的取消

标志位方案虽然能防止”过时数据”污染状态,但网络请求本身仍在进行。如果你想真正取消网络请求(节省带宽和服务端资源),可以使用浏览器原生的 AbortController

useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then((res) => res.json())
    .then((data) => setUser(data))
    .catch((err) => {
      if (err.name !== 'AbortError') {
        setError(err.message);  // 只处理非取消的错误
      }
    });

  return () => controller.abort();  // 真正取消网络请求
}, [userId]);

5.4 封装自定义 Hook:useFetch

请求逻辑在多个组件中都会用到,抽成自定义 Hook 是最佳实践:

import { useState, useEffect } from 'react';

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((data) => {
        setData(data);
        setLoading(false);
      })
      .catch((err) => {
        if (err.name !== 'AbortError') {
          setError(err.message);
          setLoading(false);
        }
      });

    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}

// 使用
function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);

  if (loading) return <p>加载中...</p>;
  if (error) return <p>出错了:{error}</p>;
  return <h2>{user.name}</h2>;
}

🤔 想一想 useFetch 目前只处理 GET 请求。如果你要支持 POST 请求(比如提交表单),你会怎么扩展这个 Hook 的 API 设计?是加一个 method 参数,还是干脆拆分成 useFetchuseMutation 两个 Hook?


六、加载与错误状态处理 —— Loading、Error、Empty 三态模式

6.1 每个异步操作都有三种结局

任何与服务器交互的操作,都会经历三种状态:

  • Loading(加载中): 请求已发出,等待响应
  • Error(出错): 请求失败,需要告诉用户发生了什么
  • Success(成功): 数据到手,又分为”有数据”和”空数据”两种情况

这就构成了经典的三态模式——在 UI 上,你需要为这三种状态分别设计展示方案。忽略任何一种都会带来糟糕的用户体验:没有 Loading 状态,用户会以为页面卡死了;没有 Error 状态,用户看到一片空白不知道发生了什么;没有 Empty 状态,用户不知道是”加载中”还是”真的没数据”。

6.2 封装通用的异步状态容器

与其每次手动写三个 if,不如封装一个通用组件:

function AsyncView({ loading, error, data, onRetry, children }) {
  if (loading) {
    return (
      <div style={{ textAlign: 'center', padding: 40 }}>
        <p>加载中...</p>
      </div>
    );
  }

  if (error) {
    return (
      <div style={{ textAlign: 'center', padding: 40, color: 'red' }}>
        <p>出错了:{error}</p>
        {onRetry && <button onClick={onRetry}>重试</button>}
      </div>
    );
  }

  // data 为空的情况
  if (!data || (Array.isArray(data) && data.length === 0)) {
    return (
      <div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
        <p>暂无数据</p>
      </div>
    );
  }

  // 有数据,渲染 children
  return children(data);
}

使用时代码非常清爽:

function UserListPage() {
  const { data, loading, error } = useFetch('/api/users');

  const handleRetry = () => window.location.reload();

  return (
    <AsyncView loading={loading} error={error} data={data} onRetry={handleRetry}>
      {(users) => (
        <ul>
          {users.map((user) => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      )}
    </AsyncView>
  );
}

这种模式叫 Render Props(第六章讲过)——AsyncView 通过 children 函数把数据”交给”调用者去渲染。它的好处是:三态逻辑只写一次,所有页面复用。

6.3 表单提交的状态处理

不只是数据加载,表单提交同样需要状态管理。一个生产级的提交按钮至少需要处理这些状态:

function SubmitButton({ isSubmitting, isSuccess }) {
  if (isSuccess) {
    return <button disabled style={{ background: '#52c41a', color: '#fff' }}>提交成功 ✓</button>;
  }

  return (
    <button type="submit" disabled={isSubmitting}>
      {isSubmitting ? '提交中...' : '提交'}
    </button>
  );
}

防重复提交是另一个必须处理的问题。用户双击按钮或者网络慢时连续点击,可能导致重复提交。解决方式很简单——提交时通过 isSubmitting 禁用按钮,请求结束后恢复。


七、React 19 新特性预览 —— use() hook 与 Server Actions

7.1 use() —— 在组件中直接”等待” Promise

React 19 引入了 use() hook,它可以在组件渲染过程中直接读取 Promise 的结果。配合 Suspense,数据加载的写法发生了巨大变化:

import { use, Suspense } from 'react';

// 注意:fetchUsers 必须在组件外创建或被缓存
// 不能在组件内每次渲染都创建新的 Promise
const usersPromise = fetch('/api/users').then((res) => res.json());

function UserList() {
  const users = use(usersPromise);  // 直接"等待"结果,不需要 useState + useEffect

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

function App() {
  return (
    <Suspense fallback={<p>加载中...</p>}>
      <UserList />
    </Suspense>
  );
}

对比传统的 useState + useEffect 方式,use() 的写法更接近同步代码——你不用再操心 loading 状态和 setData,Suspense 帮你处理了加载中的 UI。

重要限制: use() 接收的 Promise 不能在每次渲染时重新创建。如果你在组件内部写 use(fetch(...)) 会导致无限循环。实际使用中,通常需要搭配框架层面的数据缓存方案(如 Next.js 的数据层)。

7.2 Server Actions —— 表单提交的新范式

React 19 还引入了 useActionState(之前叫 useFormState),可以让表单提交直接关联一个服务端函数:

import { useActionState } from 'react';

// 模拟一个 Server Action(实际需要框架支持,如 Next.js)
async function registerAction(previousState, formData) {
  const username = formData.get('username');
  const email = formData.get('email');

  // 模拟服务端校验
  if (username.length < 3) {
    return { error: '用户名至少 3 个字符' };
  }

  // 模拟写入数据库
  await new Promise((resolve) => setTimeout(resolve, 1000));
  return { success: true, message: `用户 ${username} 注册成功` };
}

function RegisterForm() {
  const [state, formAction, isPending] = useActionState(registerAction, null);

  return (
    <form action={formAction}>
      <input name="username" placeholder="用户名" />
      <input name="email" type="email" placeholder="邮箱" />
      <button type="submit" disabled={isPending}>
        {isPending ? '注册中...' : '注册'}
      </button>
      {state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
      {state?.success && <p style={{ color: 'green' }}>{state.message}</p>}
    </form>
  );
}

注意 <form action={formAction}> 这种写法——action 不再是一个 URL,而是一个函数。React 会自动收集 FormData 并传给这个函数,同时管理 pending 状态。不再需要 e.preventDefault()、不再需要手动管理 isSubmitting

目前状态: Server Actions 需要搭配支持 RSC(React Server Components)的框架(如 Next.js App Router)才能真正在服务端执行。在纯客户端应用中,useActionState 仍然可以用,但函数会在客户端执行。


八、实战:构建一个完整的用户注册表单

让我们把本章学到的所有知识串联起来,构建一个生产级的注册表单。这个表单需要:多字段输入与校验、密码强度提示、提交时禁用按钮防重复提交、服务端响应后的成功/失败反馈。

8.1 定义表单 Reducer

const initialState = {
  values: { username: '', email: '', password: '', confirmPassword: '' },
  errors: {},
  touched: {},
  isSubmitting: false,
  submitResult: null,  // { type: 'success' | 'error', message: string }
};

function registerReducer(state, action) {
  switch (action.type) {
    case 'FIELD_CHANGE': {
      const newValues = { ...state.values, [action.field]: action.value };
      const newErrors = { ...state.errors };
      // 已触碰的字段实时校验
      if (state.touched[action.field]) {
        newErrors[action.field] = validateField(action.field, action.value, newValues);
      }
      // 密码变化时,如果确认密码已触碰,联动校验
      if (action.field === 'password' && state.touched.confirmPassword) {
        newErrors.confirmPassword = validateField('confirmPassword', newValues.confirmPassword, newValues);
      }
      return { ...state, values: newValues, errors: newErrors, submitResult: null };
    }
    case 'FIELD_BLUR':
      return {
        ...state,
        touched: { ...state.touched, [action.field]: true },
        errors: { ...state.errors, [action.field]: action.error },
      };
    case 'VALIDATE_ALL':
      return {
        ...state,
        errors: action.errors,
        touched: Object.keys(state.values).reduce((acc, k) => ({ ...acc, [k]: true }), {}),
      };
    case 'SUBMIT_START':
      return { ...state, isSubmitting: true, submitResult: null };
    case 'SUBMIT_SUCCESS':
      return { ...initialState, submitResult: { type: 'success', message: action.message } };
    case 'SUBMIT_ERROR':
      return { ...state, isSubmitting: false, submitResult: { type: 'error', message: action.message } };
    default:
      return state;
  }
}

8.2 校验逻辑

function validateField(name, value, allValues) {
  switch (name) {
    case 'username':
      if (!value.trim()) return '请输入用户名';
      if (value.length < 3 || value.length > 20) return '用户名长度 3~20 个字符';
      if (!/^[a-zA-Z0-9_\u4e00-\u9fa5]+$/.test(value)) return '用户名只能包含字母、数字、下划线和中文';
      return '';
    case 'email':
      if (!value.trim()) return '请输入邮箱';
      if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return '请输入有效的邮箱地址';
      return '';
    case 'password':
      if (!value) return '请输入密码';
      if (value.length < 8) return '密码至少 8 位';
      if (!/(?=.*[a-z])(?=.*[A-Z])/.test(value)) return '密码需包含大小写字母';
      if (!/(?=.*\d)/.test(value)) return '密码需包含至少一个数字';
      return '';
    case 'confirmPassword':
      if (!value) return '请再次输入密码';
      if (value !== allValues.password) return '两次密码不一致';
      return '';
    default:
      return '';
  }
}

// 密码强度计算
function getPasswordStrength(password) {
  if (!password) return { level: 0, text: '', color: '#eee' };
  let score = 0;
  if (password.length >= 8) score++;
  if (password.length >= 12) score++;
  if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++;
  if (/\d/.test(password)) score++;
  if (/[^a-zA-Z0-9]/.test(password)) score++;

  if (score <= 2) return { level: score, text: '弱', color: '#ff4d4f' };
  if (score <= 3) return { level: score, text: '中', color: '#faad14' };
  return { level: score, text: '强', color: '#52c41a' };
}

8.3 完整表单组件

import { useReducer } from 'react';

function RegisterPage() {
  const [state, dispatch] = useReducer(registerReducer, initialState);
  const { values, errors, touched, isSubmitting, submitResult } = state;

  const handleChange = (e) => {
    dispatch({ type: 'FIELD_CHANGE', field: e.target.name, value: e.target.value });
  };

  const handleBlur = (e) => {
    const { name, value } = e.target;
    dispatch({
      type: 'FIELD_BLUR',
      field: name,
      error: validateField(name, value, values),
    });
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    // 全量校验
    const allErrors = {};
    Object.entries(values).forEach(([key, value]) => {
      allErrors[key] = validateField(key, value, values);
    });

    if (Object.values(allErrors).some(Boolean)) {
      dispatch({ type: 'VALIDATE_ALL', errors: allErrors });
      return;
    }

    dispatch({ type: 'SUBMIT_START' });
    try {
      const res = await fetch('/api/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          username: values.username,
          email: values.email,
          password: values.password,
        }),
      });

      if (!res.ok) {
        const errorData = await res.json();
        throw new Error(errorData.message || '注册失败');
      }

      dispatch({ type: 'SUBMIT_SUCCESS', message: '注册成功!欢迎加入。' });
    } catch (err) {
      dispatch({ type: 'SUBMIT_ERROR', message: err.message });
    }
  };

  const strength = getPasswordStrength(values.password);

  const fieldConfig = [
    { name: 'username', label: '用户名', type: 'text', placeholder: '3~20 个字符' },
    { name: 'email', label: '邮箱', type: 'email', placeholder: 'your@email.com' },
    { name: 'password', label: '密码', type: 'password', placeholder: '至少 8 位,包含大小写和数字' },
    { name: 'confirmPassword', label: '确认密码', type: 'password', placeholder: '再次输入密码' },
  ];

  return (
    <div style={{ maxWidth: 400, margin: '40px auto' }}>
      <h1>用户注册</h1>

      {submitResult && (
        <div style={{
          padding: '12px 16px',
          marginBottom: 16,
          borderRadius: 4,
          background: submitResult.type === 'success' ? '#f6ffed' : '#fff2f0',
          border: `1px solid ${submitResult.type === 'success' ? '#b7eb8f' : '#ffccc7'}`,
          color: submitResult.type === 'success' ? '#52c41a' : '#ff4d4f',
        }}>
          {submitResult.message}
        </div>
      )}

      <form onSubmit={handleSubmit}>
        {fieldConfig.map(({ name, label, type, placeholder }) => (
          <div key={name} style={{ marginBottom: 16 }}>
            <label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>
              {label} <span style={{ color: 'red' }}>*</span>
            </label>
            <input
              name={name}
              type={type}
              placeholder={placeholder}
              value={values[name]}
              onChange={handleChange}
              onBlur={handleBlur}
              disabled={isSubmitting}
              style={{
                width: '100%',
                padding: '8px 12px',
                border: `1px solid ${errors[name] ? '#ff4d4f' : '#d9d9d9'}`,
                borderRadius: 4,
                boxSizing: 'border-box',
              }}
            />
            {/* 密码强度条 */}
            {name === 'password' && values.password && (
              <div style={{ marginTop: 4 }}>
                <div style={{ display: 'flex', gap: 4 }}>
                  {[1, 2, 3, 4, 5].map((i) => (
                    <div key={i} style={{
                      flex: 1,
                      height: 4,
                      borderRadius: 2,
                      background: i <= strength.level ? strength.color : '#eee',
                    }} />
                  ))}
                </div>
                <span style={{ fontSize: 12, color: strength.color }}>
                  密码强度:{strength.text}
                </span>
              </div>
            )}
            {errors[name] && (
              <p style={{ color: '#ff4d4f', fontSize: 12, margin: '4px 0 0' }}>
                {errors[name]}
              </p>
            )}
          </div>
        ))}

        <button
          type="submit"
          disabled={isSubmitting}
          style={{
            width: '100%',
            padding: '10px 0',
            background: isSubmitting ? '#d9d9d9' : '#1677ff',
            color: '#fff',
            border: 'none',
            borderRadius: 4,
            fontSize: 16,
            cursor: isSubmitting ? 'not-allowed' : 'pointer',
          }}
        >
          {isSubmitting ? '注册中...' : '立即注册'}
        </button>
      </form>
    </div>
  );
}

这个实战案例覆盖了本章的核心知识点:

  • 受控表单:每个字段的值由 state 管理
  • useReducer:集中管理 values、errors、touched、isSubmitting 四组状态
  • 三时机校验:失焦首次校验、已触碰字段实时校验、提交全量校验
  • 密码联动:修改密码时,自动重新校验”确认密码”
  • 提交状态管理:Loading 禁用按钮、成功重置表单、失败展示错误
  • 密码强度可视化:将安全规则转化为用户可感知的进度条

📝 掌握度自测

  1. 以下哪种写法会导致 input 无法输入?

    • A) <input defaultValue="hello" />
    • B) <input value="hello" />
    • C) <input value={name} onChange={(e) => setName(e.target.value)} />
    • D) <input ref={inputRef} />
  2. 用 useEffect 发起网络请求时,清理函数的核心作用是什么?

    • A) 清除浏览器缓存
    • B) 取消或忽略过时的请求结果,防止竞态条件
    • C) 释放组件占用的内存
    • D) 重置 useState 的初始值
  3. 以下哪种表单校验时机的用户体验最好?

    • A) 每输入一个字符就报错
    • B) 只在提交时校验
    • C) 首次失焦时校验,之后实时校验
    • D) 随机时机校验
  4. useReducer 相比多个 useState 管理表单的主要优势是什么?

    • A) 性能更好,渲染次数更少
    • B) 状态修改逻辑集中在 reducer 中,可独立测试,扩展方便
    • C) 不需要写 switch-case 语句
    • D) 可以跨组件共享状态
  5. 关于 React 19 的 use() hook,以下哪项描述是正确的?

    • A) use() 可以替代所有的 useEffect 调用
    • B) use() 接收的 Promise 可以在每次渲染时重新创建
    • C) use() 需要配合 Suspense 使用,由 Suspense 处理加载状态
    • D) use() 只能在服务端组件中使用

💡 自我评估

  • 答对 5 题:你已经完全掌握了 React 表单和数据交互的核心模式,可以独立构建生产级的表单系统。
  • 答对 3-4 题:基础扎实,建议重点回顾竞态条件的处理和 useReducer 管理复杂表单的实践,这是面试和实战的高频考点。
  • 答对 0-2 题:建议动手实现文末的注册表单实战,边写边对照每一节的知识点,实践是最好的老师。

参考答案: 1-B, 2-B, 3-C, 4-B, 5-C

购买课程解锁全部内容

从组件到架构:12 章系统掌握现代 React

¥29.90