我在项目中总结的React组件使用避坑指南

端木智慧 工具 阅读 1,039
赞 16 收藏
二维码
手机扫码查看
反馈

先上代码,别整虚的

今天要聊的是一个我在项目里反复用到的组件:可编辑表格行(Editable Row in Table)。不是那种花里胡哨的拖拽排序表格,就是最朴素的需求——点击编辑,输入框弹出来,改完保存。听起来简单吧?但真写起来,各种状态管理、表单校验、防抖、取消回滚,搞不好就把自己绕进去了。

我在项目中总结的React组件使用避坑指南

我之前在 jztheme.com 的后台系统里做用户管理模块,就碰到了这个需求。一开始想偷懒,直接用现成的 antd 的 Table + Form,结果发现 inline 编辑体验很差,交互卡顿不说,还一堆 warning。折腾了半天,最后自己封装了一个基于 React + useState 的方案,亲测有效,现在已经在三个项目里复用了。

import React, { useState } from 'react';

const EditableRow = ({ record, onSave, children }) => {
  const [editing, setEditing] = useState(false);
  const [formValues, setFormValues] = useState({});

  const handleEdit = () => {
    setFormValues({ ...record });
    setEditing(true);
  };

  const handleSave = () => {
    if (onSave) {
      onSave(formValues);
    }
    setEditing(false);
  };

  const handleCancel = () => {
    setEditing(false);
  };

  const handleChange = (key, value) => {
    setFormValues((prev) => ({ ...prev, [key]: value }));
  };

  if (!editing) {
    return (
      <tr>
        {children}
        <td>
          <button onClick={handleEdit}>编辑</button>
        </td>
      </tr>
    );
  }

  return (
    <tr>
      {React.Children.map(children, (child) => {
        if (React.isValidElement(child) && child.props['data-editable']) {
          const key = child.props['data-key'];
          const type = child.props['data-type'] || 'text';
          return (
            <td key={key}>
              <input
                type={type}
                value={formValues[key] || ''}
                onChange={(e) => handleChange(key, e.target.value)}
                style={{ padding: '4px', fontSize: '14px' }}
              />
            </td>
          );
        }
        return child;
      })}
      <td>
        <button onClick={handleSave} style={{ marginRight: '8px' }}>保存</button>
        <button onClick={handleCancel}>取消</button>
      </td>
    </tr>
  );
};

export default EditableRow;

这玩意怎么用?看个完整例子

上面那个组件看着小,但够用。下面是个完整的表格调用示例,假设我们要编辑用户姓名和年龄:

import React, { useState } from 'react';
import EditableRow from './EditableRow';

const UserTable = () => {
  const [users, setUsers] = useState([
    { id: 1, name: '张三', age: 25 },
    { id: 2, name: '李四', age: 30 },
  ]);

  const handleSave = (id, newValues) => {
    setUsers((prev) =>
      prev.map((user) => (user.id === id ? { ...user, ...newValues } : user))
    );

    // 这里可以加 API 请求
    // fetch('https://jztheme.com/api/users/' + id, {
    //   method: 'PUT',
    //   body: JSON.stringify(newValues),
    // })
  };

  return (
    <table border="1" cellPadding="8" cellSpacing="0">
      <thead>
        <tr>
          <th>姓名</th>
          <th>年龄</th>
          <th>操作</th>
        </tr>
      </thead>
      <tbody>
        {users.map((user) => (
          <EditableRow
            key={user.id}
            record={user}
            onSave={(values) => handleSave(user.id, values)}
          >
            <td>{user.name}</td>
            <td>{user.age}</td>
            {/* 标记可编辑字段 */}
            <td data-editable data-key="name" />
            <td data-editable data-key="age" data-type="number" />
          </EditableRow>
        ))}
      </tbody>
    </table>
  );
};

export default UserTable;

这里注意,我踩过好几次坑

第一个大坑是 输入框失焦后数据没更新。最开始我没用 useState 管理 formValues,而是直接拿 props.record 做受控组件,结果一改就影响原数据,点取消也回不去了。后来才意识到:进入编辑模式时必须深拷贝一份记录。

setFormValues({ ...record }); // 必须拷贝!不能直接引用

第二个坑是 连续快速点击编辑会出问题。比如你双击两行,状态混乱。解决方案很简单:在 handleEdit 里加个判断,如果已经在编辑其他行,先提示或阻止。

const handleEdit = () => {
  if (editing) {
    alert('请先保存当前编辑项');
    return;
  }
  setFormValues({ ...record });
  setEditing(true);
};

第三个坑是 数字输入框类型没指定。如果你编辑的是年龄、价格这类字段,记得加上 data-type="number",不然输入的时候还能打字母,表单校验会疯掉。虽然可以用正则限制,但浏览器原生支持才是王道。

高级技巧:自动聚焦和回车保存

用户体验这块不能将就。我后来加了个功能:点击编辑后,第一个输入框自动聚焦。实现方式是在 input 上加 ref,通过 useEffect 控制。

const inputRef = React.useRef(null);

React.useEffect(() => {
  if (editing && inputRef.current) {
    inputRef.current.focus();
  }
}, [editing]);

// 在 input 上绑定 ref
<input ref={inputRef} type={type} value={formValues[key]} onChange={...} />

再进一步,我让输入框支持回车保存:

const handleKeyDown = (e) => {
  if (e.key === 'Enter') {
    handleSave();
  }
};

// 绑定到 input
<input onKeyDown={handleKeyDown} ... />

这样用户改完按回车就走人,效率提升不少。不过要注意:如果是多字段编辑,可能要考虑是否所有字段都合法再保存,这里我就没搞太复杂,毕竟产品说“简单点就行”。

异步保存怎么办?加个 loading 吧

真实场景中,onSave 通常是发请求。这时候你得加 loading 状态,避免重复提交。

const [loading, setLoading] = useState(false);

const handleSave = async () => {
  setLoading(true);
  try {
    await onSave(formValues); // 假设 onSave 返回 promise
    setEditing(false);
  } catch (err) {
    console.error('保存失败', err);
    // 可以加个 toast 提示
  } finally {
    setLoading(false);
  }
};

然后按钮上加上 disabled:

<button disabled={loading} onClick={handleSave}>
  {loading ? '保存中...' : '保存'}
</button>

这里有个细节:如果接口失败了,要不要保持编辑状态?我的做法是 保持,让用户有机会修改重试。不要直接退出,否则体验很差。

能抽成 HOC 或自定义 Hook 吗?当然可以

如果你多个页面都有这种需求,完全可以把逻辑抽成一个 useEditable hook:

const useEditable = (initialValue, onSave) => {
  const [editing, setEditing] = useState(false);
  const [value, setValue] = useState(initialValue);

  const startEdit = () => {
    setValue(initialValue);
    setEditing(true);
  };

  const save = async () => {
    setLoading(true);
    try {
      await onSave(value);
      setEditing(false);
    } catch (err) {
      // 失败不退出
    } finally {
      setLoading(false);
    }
  };

  const cancel = () => setEditing(false);

  return { editing, value, setValue, startEdit, save, cancel, loading };
};

这样组件里就干净多了,不过我还是建议先从上面那个 EditableRow 开始,毕竟 hook 抽象过度容易后期难维护。

别忘了移动端适配

这个组件在 PC 上没问题,但在手机上,input 弹起可能会导致页面缩放或者布局错乱。建议在 head 里加上:

<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />

还有,移动端按钮别太小,至少 44px 高,不然手指点不准。这点我被测试怼过好几次。

结语:这东西没那么完美,但够用

说实话,这个方案不是最优解。比如它不支持嵌套字段(如 address.city),也不支持动态新增字段。但大多数业务场景下,它已经能扛住压力了。改完之后还有两个小问题:一是快速切换编辑行时偶尔会聚焦错位,二是 number 类型输入法在 iOS 上有时弹不出来。但都没影响上线,也就没深究了。

以上是我个人对这个可编辑表格组件的完整讲解,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多,比如结合 useForm、支持批量编辑、带权限控制的编辑等,后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论