React 表单(二):受控模式下的复杂场景实战 —— 联动、动态与分步表单全解析

85 阅读7分钟

一、复杂表单

在第一篇《深入理解 React 受控 / 非受控模式》中,我们掌握了表单数据管理的基础逻辑,但实际开发中,表单往往不是 “单个输入框 + 提交按钮” 的简单形态 —— 比如电商的收货地址表单需要省市区联动,CRM 系统的客户信息表单支持动态添加联系人,注册流程需要分步骤填写信息。这些 “复杂场景” 会暴露基础用法的不足:

  • 多字段联动时,手动管理 state 容易出现 “数据不同步”;

  • 动态添加字段时,用数组维护状态会遇到 key 冲突、校验遗漏等问题;

  • 多步骤表单中,跨步骤数据传递、回退时的状态重置会让逻辑变得混乱。

本文将基于 受控模式(复杂场景下更易保证数据可控性),通过 3 个实战案例,拆解复杂表单的核心解决方案,每个案例均包含 “需求描述 + 完整代码 + 避坑指南”,确保看完就能落地。

二、实战案例 1:多字段联动 —— 从 “单向响应” 到 “双向关联”

1. 典型场景:省市区三级联动

需求描述:用户选择 “省份” 后,“城市” 下拉框加载对应省份的城市列表;选择 “城市” 后,“区县” 下拉框加载对应城市的区县列表;最终需要收集完整的省 / 市 / 区名称及编码,且不允许出现 “选了北京却显示上海区县” 的无效状态。

2. 实现步骤与代码

(1)准备基础数据与状态

首先定义省份、城市、区县的映射数据(实际项目中通常通过接口请求获取),再用 useState 分别管理 “选中值” 和 “下拉列表选项”,避免状态混在一起导致维护困难:

import { useState, useEffect } from 'react';
// 模拟省市区数据(实际项目中替换为接口请求)
const provinceData = [  { code: '110000', name: '北京市' },  { code: '310000', name: '上海市' },  { code: '440000', name: '广东省' }];
const cityData = {
  '110000': [{ code: '110100', name: '北京市' }],
  '310000': [{ code: '310100', name: '上海市' }],
  '440000': [
    { code: '440100', name: '广州市' },
    { code: '440300', name: '深圳市' }
  ]
};
const districtData = {
  '110100': [
    { code: '110101', name: '东城区' },
    { code: '110102', name: '西城区' }
  ],
  '310100': [
    { code: '310101', name: '黄浦区' },
    { code: '310104', name: '徐汇区' }
  ],
  '440100': [
    { code: '440103', name: '荔湾区' },
    { code: '440104', name: '越秀区' }
  ],
  '440300': [
    { code: '440305', name: '南山区' },
    { code: '440306', name: '宝安区' }
  ]
};
function AddressLinkageForm() {
  // 选中值状态:存储省/市/区的编码(便于后续提交给后端)
  const [selected, setSelected] = useState({
    provinceCode: '',
    cityCode: '',
    districtCode: ''
  });
  // 下拉列表选项状态:存储当前可选择的城市、区县列表
  const [options, setOptions] = useState({
    cities: [],
    districts: []
  });
  // ...后续逻辑
}
(2)实现联动逻辑:useEffect 监听状态变化

联动的核心是 “上级选项变化时,更新下级列表并重置下级选中值”。这里用两个 useEffect 分别监听 “省份变化” 和 “城市变化”,精准控制状态更新:

// 监听省份变化:更新城市列表 + 重置市、区选中值
useEffect(() => {
  if (selected.provinceCode) {
    // 1. 加载当前省份对应的城市列表
    const matchedCities = cityData[selected.provinceCode] || [];
    setOptions(prev => ({
      ...prev,
      cities: matchedCities
    }));
    // 2. 重置市、区的选中值(避免选中无效数据)
    setSelected(prev => ({
      ...prev,
      cityCode: '',
      districtCode: ''
    }));
    // 3. 清空区县列表(因为城市已重置,区县自然无数据)
    setOptions(prev => ({ ...prev, districts: [] }));
  } else {
    // 未选省份时,清空所有下级列表和选中值
    setOptions({ cities: [], districts: [] });
    setSelected({ provinceCode: '', cityCode: '', districtCode: '' });
  }
}, [selected.provinceCode]); // 仅依赖省份编码,避免多余渲染
// 监听城市变化:更新区县列表 + 重置区选中值
useEffect(() => {
  if (selected.cityCode) {
    // 1. 加载当前城市对应的区县列表
    const matchedDistricts = districtData[selected.cityCode] || [];
    setOptions(prev => ({
      ...prev,
      districts: matchedDistricts
    }));
    // 2. 重置区的选中值
    setSelected(prev => ({ ...prev, districtCode: '' }));
  } else {
    // 未选城市时,清空区县列表和区选中值
    setOptions(prev => ({ ...prev, districts: [] }));
    setSelected(prev => ({ ...prev, districtCode: '' }));
  }
}, [selected.cityCode]); // 仅依赖城市编码,确保触发时机准确
(3)渲染表单与处理选择事件

定义 handleSelectChange 函数统一处理下拉框选择事件,通过 type 参数区分 “省份 / 城市 / 区县”,避免写三个重复的事件处理器:

// 统一处理下拉框选择事件
const handleSelectChange = (type, code) => {
  setSelected(prev => ({
    ...prev,
    [`${type}Code`]: code // 动态设置对应字段的编码(如 type=province 则更新 provinceCode)
  }));
};
// 提交时获取完整地址信息(编码 + 名称)
const handleSubmit = () => {
  // 根据选中的编码,匹配对应的名称
  const selectedProvince = provinceData.find(p => p.code === selected.provinceCode);
  const selectedCity = cityData[selected.provinceCode]?.find(c => c.code === selected.cityCode);
  const selectedDistrict = districtData[selected.cityCode]?.find(d => d.code === selected.districtCode);
  const fullAddress = {
    province: selectedProvince?.name || '',
    city: selectedCity?.name || '',
    district: selectedDistrict?.name || '',
    provinceCode: selected.provinceCode,
    cityCode: selected.cityCode,
    districtCode: selected.districtCode
  };
  console.log('提交的完整地址:', fullAddress);
  alert(`已选择:${fullAddress.province}${fullAddress.city}${fullAddress.district}`);
};
// 渲染表单
return (
  <div style={{ padding: '20px', maxWidth: '500px' }}>
    <h3>省市区三级联动</h3>
    <div style={{ marginBottom: '15px' }}>
      <label style={{ display: 'block', marginBottom: '5px' }}>省份:</label>
      <select
        value={selected.provinceCode}
        onChange={(e) => handleSelectChange('province', e.target.value)}
        style={{ width: '100%', padding: '8px', borderRadius: '4px' }}
        required
      >
        <option value="">请选择省份</option>
        {provinceData.map(province => (
          <option key={province.code} value={province.code}>
            {province.name}
          </option>
        ))}
      </select>
    </div>
    <div style={{ marginBottom: '15px' }}>
      <label style={{ display: 'block', marginBottom: '5px' }}>城市:</label>
      <select
        value={selected.cityCode}
        onChange={(e) => handleSelectChange('city', e.target.value)}
        style={{ width: '100%', padding: '8px', borderRadius: '4px' }}
        required
        disabled={!selected.provinceCode} // 未选省份时禁用,提升体验
      >
        <option value="">请选择城市</option>
        {options.cities.map(city => (
          <option key={city.code} value={city.code}>
            {city.name}
          </option>
        ))}
      </select>
    </div>
    <div style={{ marginBottom: '15px' }}>
      <label style={{ display: 'block', marginBottom: '5px' }}>区县:</label>
      <select
        value={selected.districtCode}
        onChange={(e) => handleSelectChange('district', e.target.value)}
        style={{ width: '100%', padding: '8px', borderRadius: '4px' }}
        required
        disabled={!selected.cityCode} // 未选城市时禁用
      >
        <option value="">请选择区县</option>
        {options.districts.map(district => (
          <option key={district.code} value={district.code}>
            {district.name}
          </option>
        ))}
      </select>
    </div>
    <button
      onClick={handleSubmit}
      style={{
        padding: '8px 16px',
        backgroundColor: '#1890ff',
        color: 'white',
        border: 'none',
        borderRadius: '4px',
        cursor: 'pointer'
      }}
      disabled={!selected.districtCode} // 未选区县时禁用提交
    >
      确认选择
    </button>
  </div>
);

3. 联动场景的避坑点

  • 依赖数组必须精准:useEffect 的依赖只能是需要监听的状态(如 selected.provinceCode),若写成 [selected],会导致其他字段变化时也触发更新,造成冗余渲染;
  • 必须重置无效状态:切换上级选项(如从 “北京” 切到 “广东”)时,一定要重置下级的选中值和列表,否则会出现 “广东 + 北京市 + 东城区” 的无效组合;
  • 禁用无效操作:当下级列表未加载时(如未选省份),禁用下级下拉框,避免用户点击无效选项,提升交互体验。

三、实战案例 2:动态表单 —— 支持 “添加 / 删除” 字段

1. 典型场景:动态添加联系人

需求描述:用户点击 “添加联系人” 按钮可新增一行表单(包含姓名、电话、邮箱);每行表单右侧有 “删除” 按钮,点击可删除当前行;提交时需校验所有行的字段不能为空,且收集所有联系人数据。

2. 实现步骤与代码

(1)用数组 state 管理动态字段

动态表单的核心是 “将每个字段组作为数组的一项”,通过数组的 map 渲染所有行,通过 push/filter 实现添加 / 删除:

import { useState } from 'react';
function DynamicContactForm() {
  // 动态联系人列表:每个元素是一个联系人对象(id + 字段)
  const [contacts, setContacts] = useState([
    { id: 1, name: '', phone: '', email: '' } // 默认显示1行
  ]);
  // 生成唯一id:避免用index作为key(删除中间行会导致DOM复用bug)
  const generateUniqueId = () => Date.now() + Math.floor(Math.random() * 1000);
  // ...后续逻辑
}
(2)实现 “添加 / 删除” 逻辑
  • 添加:在数组末尾新增一个带唯一 id 的空联系人对象;
  • 删除:根据 id 过滤掉需要删除的项,返回新数组(不直接修改原数组,符合 React 状态不可变原则):
// 添加联系人
const handleAddContact = () => {
  setContacts(prevContacts => [
    ...prevContacts,
    { id: generateUniqueId(), name: '', phone: '', email: '' }
  ]);
};
// 删除联系人:根据id删除对应行
const handleDeleteContact = (id) => {
  // 至少保留1行,避免表单为空
  if (contacts.length <= 1) {
    alert('至少保留1个联系人');
    return;
  }
  setContacts(prevContacts => prevContacts.filter(contact => contact.id !== id));
};
(3)统一处理字段输入变化

由于字段是动态的,需要通过 “id + 字段名” 定位到具体要更新的字段,避免修改所有行数据:

// 处理字段输入变化:id定位行,field定位字段
const handleFieldChange = (id, field, value) => {
  setContacts(prevContacts =>
    prevContacts.map(contact =>
      // 匹配到当前行时,更新对应字段;否则保持不变
      contact.id === id ? { ...contact, [field]: value } : contact
    )
  );
};
(4)提交校验与渲染表单

提交前需遍历所有行,校验字段是否为空;渲染时用 map 遍历数组,生成所有表单行:

// 提交表单:校验 + 收集数据
const handleSubmit = (e) => {
  e.preventDefault();
  // 1. 校验所有字段:不能为空
  const invalidRow = contacts.find(
    contact => !contact.name.trim() || !contact.phone.trim() || !contact.email.trim()
  );
  if (invalidRow) {
    alert('所有联系人的姓名、电话、邮箱不能为空');
    return;
  }
  // 2. 校验手机号格式(可选)
  const phoneRegex = /^1[3-9]\d{9}$/;
  const invalidPhoneRow = contacts.find(contact => !phoneRegex.test(contact.phone));
  if (invalidPhoneRow) {
    alert('请输入正确的手机号');
    return;
  }
  // 3. 提交数据(实际项目中替换为接口请求)
  console.log('所有联系人数据:', contacts);
  alert('提交成功!联系人数据已打印到控制台');
  // 4. 重置表单(可选)
  setContacts([{ id: generateUniqueId(), name: '', phone: '', email: '' }]);
};
// 渲染表单
return (
  <form onSubmit={handleSubmit} style={{ padding: '20px', maxWidth: '800px' }}>
    <h3>动态联系人表单</h3>
    <div style={{ marginBottom: '15px' }}>
      {/* 渲染所有联系人行 */}
      {contacts.map(contact => (
        <div
          key={contact.id} // 用唯一id作为key,避免DOM复用bug
          style={{
            display: 'flex',
            gap: '10px',
            alignItems: 'center',
            marginBottom: '10px',
            padding: '10px',
            border: '1px solid #eee',
            borderRadius: '4px'
          }}
        >
          {/* 姓名输入框 */}
          <input
            type="text"
            placeholder="姓名"
            value={contact.name}
            onChange={(e) => handleFieldChange(contact.id, 'name', e.target.value)}
            style={{ padding: '8px', flex: 1 }}
          />
          {/* 电话输入框 */}
          <input
            type="tel"
            placeholder="手机号"
            value={contact.phone}
            onChange={(e) => handleFieldChange(contact.id, 'phone', e.target.value)}
            style={{ padding: '8px', flex: 1 }}
          />
          {/* 邮箱输入框 */}
          <input
            type="email"
            placeholder="邮箱"
            value={contact.email}
            onChange={(e) => handleFieldChange(contact.id, 'email', e.target.value)}
</doubaocanvas>