在 React 项目中 Editable Table 的实现

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。
本文作者:佳岚

可编辑表格在数栈产品中是一种比较常见的表单数据交互方式,一般都支持动态的新增、删除、排序等基础功能。

交互分类

可编辑表格一般为两种交互形式:

  1. 实时保存的表格,即所有单元格都可以直接进行编辑。
  2. 可编辑行表格,即需要手动点击编辑才能进入行编辑状态。 对比两种交互形式:
  3. 第一种交互更加友好,但对应的性能开销会非常大,不需要手动进入单元格编辑状态。
  4. 对于第二种交互方式,更多的场景是在数据量很大,不需要频繁修改,或者批量更新会对后端数据库操作会有较大性能影响的场景下。它还有一个很好的好处就是在编辑状态时,能够对已填入数据进行回退。

数栈产品中绝大多数都采用了第一种交互方式。 要实现一个可编辑表格,Table 组件肯定是不可或缺,是否要引入 Form 做数据收集,还要具体场景具体分析。 如果不引入 Form , 采用自行管理数据收集的方式, 其一般实现如下。

tsx 复制代码
const EditableTable = () => {
  const [dataSource, setDataSource] = useState([]);

  const handleAdd = () => {
    const newData = {
      key: shortid(),
      name: 'New User',
    };
    setDataSource([...dataSource, newData]);
  };

  const handleDelete = (key) => {
    const newData = dataSource.filter(item => item.key !== key);
    setDataSource(newData);
  };

  const handleChange = (value, key, field) => {
    const newData = dataSource.map(item => {
      if (item.key === key) {
        return { ...item, [field]: value };
      }
      return item;
    });
    setDataSource(newData);
  };

  const handleMove = (key, direction) => {
    const index = dataSource.findIndex(item => item.key === key);
    const newData = [...dataSource];
    const [item] = newData.splice(index, 1);
    newData.splice(direction === 'up' ? index - 1 : index + 1, 0, item);
    setDataSource(newData);
  };

  const columns = [
    {
      title: 'Name',
      dataIndex: 'name',
      render: (text, record) => (
        <Input
          value={text}
          onChange={e => handleChange(e.target.value, record.key, 'name')}
        />
      ),
    },
    {
      title: 'Action',
      dataIndex: 'action',
      render: (_, record) => (
        <span>
          <Button
            onClick={() => handleMove(record.key, 'up')}
          >
            上移
          </Button>
          <Button
            onClick={() => handleMove(record.key, 'down')}
          >
           下移
          </Button>
          <Button onClick={() => handleDelete(record.key)}>
              删除
           </Button>
        </span>
      ),
    },
  ];

  return (
    <div>
      <Button
        onClick={handleAdd}
      >
        添加
      </Button>
      <Table
        columns={columns}
        dataSource={dataSource}
        pagination={false}
      />
    </div>
  );
};

export default EditableTable;

存在的问题:

  1. 无法对每行进行单独校验。
  2. 组件完全受控,表单数量很多时输入会卡顿严重。

优点:

  1. 非常灵活。
  2. 不用考虑 Form 的依赖渲染问题。
  3. 可进行表格前端分页,这能一定程度上解决性能问题。

如果使用 Form ,最正确的做法是通过 Form.List 来实现。 Form 在绑定字段时,namePath 如果是字符串数组 ["user", "name"],则会收集为对象结构 user.name ,如果 namePath 包含整型,则收集为数组 ["users", 0, "name"]users[0].nameForm.List 中会暴露出维护的 fields 元数据与增删移动操作的 opeartion , 那么与 table 相结合,实现起来会变得更加简单。 其中 field 对象包含 keynamekey 是单调递增无重复的,如果删除了该数据,则 name 为其在数组中的下标。 我们为 FormItem 注册的 name 虽然是 [0, "name"] ,但是处于 Form.List 中的 Form.Item 组件都会自动拼上 parentNamePrefix 前缀,也就是最终会变成 ["users", 0, "name"]

tsx 复制代码
<Form form={form}>
    <Form.List name="users">
        {(fields, operation) => (
            <>
                <Table
                    key="key"
                    dataSource={fields}
                    columns={[
                        {
                            title: "姓名",
                            key: "name",
                            render: (_, field) => (
                                <FormItem name={[field.name, "name"]}>
                                    <Input />
                                </FormItem>
                            ),
                        },
                        {
                            title: "操作",
                            key: "actions",
                            render: (_, field) => (
                                <Button
                                    onClick={() =>
                                        operation.remove(field.name)
                                    }
                                >
                                    删除
                                </Button>
                            ),
                        },
                    ]}
                    pagination={{ pageSize: 3 }}
                />
                <Button onClick={() => operation.add({ name: "Jack" })}>
                    添加
                </Button>
            </>
        )}
    </Form.List>
</Form>

我们可以看到,使用 Form.List 实现,甚至可以使用分页,我们通过 form.getFieldsValue() 查看,数据是正常的。 为何被销毁的第一页的表单数据能够保存下来? 默认情况下 preservetrue 的字段在销毁时仍能保存数据,只是需要通过 getFieldsValue(true) 才能拿到,但对于 Form.List , 不需要加 true 参数也能拿到所有数据。 Form.List 本身内部也是一个 Form.Item ,不过添加了 isList 来区分,不光是 List 中的子项,其本身也会被注册。如下图所示,表格中有 5 条数据,由于分页原因只有当前页的数据表单会在 Form 中注册收集, 额外的会将 users 也单独作为一个字段进行收集。 然后,在 getFieldsValue 源码中,直接就取了 Form.List 注册的值。 因此,使用 Form.List 完成分页,从源码层面分析下来是可行的,但实际没怎么见到有人这样配合用过。

应用

案例 1

以运行参数为例,其实现使用了 Table 的自定义 components , 在 EditableCell 中再去定义表单如何渲染。

tsx 复制代码
const RunParamsEditTable = () => {
    const [dataSource, setDataSource] = useState([])
    const components = {
        body: {
            row: EditableFormRow,
            cell: EditableCell,
        },
    };

    const initColumns = () => {
        return [
           // xxx字段
        ];
    };

    const columns = initColumns().map((col) => {
        if (!col.editable) {
            return col;
        }
        return {
            ...col,
            onCell: (record, index) => ({
                index,
                record,
                editable: col.editable,
                dataIndex: col.dataIndex,
                title: record[col.dataIndex] || col.title,
                errorTitle: col.title,
                save,
                // 还有很多其他状态需要传递
            }),
        };
    });
    return (
        <div>
            <Table components={components} dataSource={dataSource} columns={columns} />
            <span onClick={this.handleAdd}>添加运行参数</span>
        </div>
    );
};

EditableCell 中, 通常需要传递大量的 props 来和父组件进行通讯,且表格列定义与表单定义拆分成两个组件,这样写个人感觉太割裂了,且对于产品中绝大部分 EditableTable 来说使用自定义 components 有点大题小用。

tsx 复制代码
const EditableCell = ({ editable, dataIndex, children, save, ...restProps }) => {
    const renderCell = () => {
        switch (dataIndex) {
            case 'name':
                return (
                    <Form.Item name={dataIndex} onChange={(v) => save(v)}>
                        <Input />
                    </Form.Item>
                );
            // 所有其他字段
        }
    };
    return <td>{editable ? renderCell() : children}</td>;
};

在代码中,实际又自定义了 Row 来为每一行创建一个 Form ,这样才实现的同时编辑多个行, 且 Form 只是用来做校验的,后面都通过 save 来手动收集的。假如改为上述 Form.List 的形式,那么这将会变得很好维护,在 onValuesChange 中将列表数据同步到上层 store 中。 个人认为 Table 的自定义 components 应在表格行或单元格要维护一些自身状态时才应该去考虑,如行列拖拽,单元格可在编辑状态进行切换等场景下使用。

案例 2

每个表单项都是下拉框,且下拉选项是通过级联请求过来的。 在这里,我们可能会这样做,维护一个 state 用来存放不用数据库对应的数据表列表, 并以 dbId 为键。

tsx 复制代码
const [tableOptionsMap, setTableOptionsMap] = useState(new Map())

columns render 中直接消费对应的 tableOptions 进行渲染。

tsx 复制代码
<FormItem dependencies={[["list", field.name, "dbId"]]}>
    {() => {
        const dbId = form.getFieldValue(["list", field.name, "dbId"]);
        const tableOptions = tableOptionsMap.get(dbId);
        return (
            <FormItem name={[field.name, "table"]}>
                <Select options={tableOptions} />
            </FormItem>
        );
    }}
</FormItem>;

这一切正常,但当我把数据加到百行数量级的时候,卡顿已经非常明显了 由于我们是把 state 存放在父组件的,每次请求会造成 table 进行 render 一遍,如果再加入 loading 等状态,render 次数会更多。Table 组件默认情况下没有对 rerender 行为做优化,父组件更新,如果 columns 中的提供了自定义 render 方法, 对应的每个 Cell 都会重新 render 。 针对这种情况我们就需要进行优化,根据 shouldCellUpdate 来自定义渲染时机。 那么每个 Cell 的渲染时机应该是:

  1. FormItem 增删位置变动时
  2. Cell 消费的对应 tableOptions 变动时

第一种情况很好判断, Form.Listfield.name 指代下标,只需比较即可

javascript 复制代码
 shouldCellUpdate: (prev, curr) => {
    return prev.name !== curr.name;
}

第二种情况我们没法直接知道 tableOptions 是否有变化,所以需要自行写个 hooks usePreviousStateRef ,这里需要非常注意的点:返回的是 ref 而不是 ref.current ,在 shouldCellUpdate 中使用会有闭包问题。

typescript 复制代码
const usePreviousStateRef = <T>(state: T): React.MutableRefObject<T> => {
    const ref = React.useRef<typeof state>();

    useEffect(() => {
        ref.current = state;
    }, [state]);

    return ref;
};

const prevTableOptionsMapRef = usePreviousStateRef(tableOptionsMap);

那么组合起来,重新渲染的条件就变成了

tsx 复制代码
shouldCellUpdate: (prev, curr) => {
  // 位置变化直接渲染
  if (prev.name !== curr.name) return true;

  // 只对数据表下拉数据变动的行进行重新渲染
  const dbId = form.getFieldValue(['list', curr.name, 'dbName']),
  const prevTableInfo = prevTableOptionsMapRef.current?.get(dbId);
  const currTableInfo = tableOptionsMap?.get(dbId);

  return prevTableInfo !== currTableInfo;
},

改完后明细流畅许多 通过 shouldCellUpdate 可解决性能问题,但对应的如果 render 中依赖了外部 state, 就要自行保存 prevState 去判断了。

总结:

Form.List + Table 的组合能满足绝大部分需求,所以后续开发中最先应该考虑这种方式,当每行中存在各自状态需要维护时再尝试采用自定义 components ,永远不要 state 与 Form 混用! 此外还需要考虑足够的性能因素,特别是面对存在大量下拉框时。

最后

欢迎关注【袋鼠云数栈UED团队】~

袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star

相关推荐
xiao-xiang15 分钟前
jenkins-通过api获取所有job及最新build信息
前端·servlet·jenkins
C语言魔术师32 分钟前
【小游戏篇】三子棋游戏
前端·算法·游戏
匹马夕阳2 小时前
Vue 3中导航守卫(Navigation Guard)结合Axios实现token认证机制
前端·javascript·vue.js
你熬夜了吗?2 小时前
日历热力图,月度数据可视化图表(日活跃图、格子图)vue组件
前端·vue.js·信息可视化
screct_demo3 小时前
詳細講一下在RN(ReactNative)中,6個比較常用的組件以及詳細的用法
javascript·react native·react.js
桂月二二8 小时前
探索前端开发中的 Web Vitals —— 提升用户体验的关键技术
前端·ux
hunter2062069 小时前
ubuntu向一个pc主机通过web发送数据,pc端通过工具直接查看收到的数据
linux·前端·ubuntu
qzhqbb9 小时前
web服务器 网站部署的架构
服务器·前端·架构
刻刻帝的海角9 小时前
CSS 颜色
前端·css
九酒10 小时前
从UI稿到代码优化,看Trae AI 编辑器如何帮助开发者提效
前端·trae