如何使用 useReducer 把可编辑表格的操作封装成自定义 hook

478 阅读1分钟

业务背景

想必做 PC 后台管理的时候经常遇到产品要一个可编辑表格的 “表单控件” 吧,类似如下图:

image.png

市场上又很多类似的 “组件” 已经有类似的功能,比如 @alipay/techu-ui 的可编辑表格,同时具备校验防抖等功能,但是本文只是封装行为,毕竟业务定制化很多时候不一样,我们也疲于看文档。

useOperate

reducer 部分

const reducer = (state, action) => {
  switch (action.type) {
    case 'add': {
      const _dataSource = [...state.dataSource, { id: uuidv4(), ...action.payload }];
      return { ...state, dataSource: _dataSource };
    }
    case 'delete': {
      const _dataSource = state.dataSource.filter(v => v.id !== action.payload);
      return { ...state, dataSource: _dataSource };
    }
    case 'update': {
      const _dataSource = state.dataSource.map((v) => {
        if (v.id === action.payload.id) {
          // const newValues = merge(cloneDeep(v), action.payload.values);
          // console.log(newValues, 'newValues');
          const newValues = action.payload.values;
          return newValues;
        }
        return v;
      });

      return {
        ...state,
        dataSource: _dataSource,
      };
    }
    case 'copy': {
      const _dataSource = [...state.dataSource];
      const values = _dataSource.find(v => v.id === action.payload.id);
      const index = _dataSource.findIndex(v => v.id === action.payload.id);
      // 使用 slice 方法创建数组副本并插入新的元素
      const newDataSource = _dataSource.slice();
      newDataSource.splice(index + 1, 0, { ...values, id: uuidv4() });

      return {
        ...state,
        dataSource: newDataSource,
      };
    }
    case 'UPDATE_DATA_SOURCE': {
      const _dataSource = [...action.payload];
      return {
        ...state, dataSource: _dataSource
      };
    }
    default:
      throw new Error(`Unknown action type: ${action.type}`);
  }
};

init 函数

主要是为了保证操作行数据的唯一性

这里用的唯一值可以参考 import { v4 as uuidv4 } from 'uuid';

const init = initialState => ({
  dataSource: initialState?.dataSource?.map(v => ({ ...v, id: v?.id || uuidv4() }))
});

useOperate 部分

function useOperate(value = []) {
  const [state, dispatch] = useReducer(reducer, { dataSource: value }, init);

  const onAdd = useMemo(() => (defaultValues) => {
    dispatch({ type: 'add', payload: defaultValues });
  }, []);

  const onDelete = useMemo(() => (id) => {
    dispatch({ type: 'delete', payload: id });
  }, []);

  const onUpdate = useMemo(() => (id, updateValues) => {
    dispatch({ type: 'update', payload: { id, values: updateValues } });
  }, []);


  const onCopy = useMemo(() => (id) => {
    dispatch({ type: 'copy', payload: { id } });
  }, []);

  const getItem = useMemo(() => id => state.dataSource.find(v => v.id === id), [state.dataSource]);

  const setData = (data) => {
    dispatch({ type: 'UPDATE_DATA_SOURCE', payload: data });
  };

  return {
    dataSource: state.dataSource,
    onAdd,
    onDelete,
    onUpdate,
    onCopy,
    getItem,
    setData
  };
}

如何实践,请参考以下业务代码

这里我写了一个组件为 ConfigTable,其中点击配置后有一个弹窗,这时候弹窗内容也为一个可编辑表格,同时可编辑表格每一行都可以展开

如下图

image.png

其中 IP 批次的数量和展开的内容的数字输入框数量一样

import React, { useState } from 'react';
import {
  Table, Modal, InputNumber, Space, Select
} from 'antd';
import { useBoolean, useDeepCompareEffect } from 'ahooks';
import { useOperate } from '@/core/hooks';

const rangeOptions = [
  {
    label: '所有', value: 'ALL'
  },
  {
    label: '核心', value: 'L1'
  },
  {
    label: '非核心', value: 'L2'
  },
];

/**
 * "buAppConfigs [bu应用的批次配置]": [
    {
      "buNo [bu编码]": "string",
      "range [部署应用范围 所有:ALL, 核心:L1 非核心:L2]": "string",
      "excludeList": [
        "string"
      ],
      "batch0 [非核心应用]": [
        {
          "batchNum [批次号]": "integer[int32]",
          "ratio [灰度比例]": "integer[int32]",
          "publishStatus [发布单状态]": "integer[int32]",
          "batchList [ip批次配置]": [
            "#/definitions/Batch"
          ]
        }
      ],
      "batch1 [核心应用]": [
        {
          "batchNum [批次号]": "integer[int32]",
          "ratio [灰度比例]": "integer[int32]",
          "publishStatus [发布单状态]": "integer[int32]",
          "batchList [ip批次配置]": [
            "#/definitions/Batch"
          ]
        }
      ]
    }
  ]
 */

function ConfigTable(props) {
  const { value = [], onChange, defaultBus } = props;

  const [currId, setCurrId] = useState();

  const {
    onDelete, onAdd, onUpdate, onCopy, dataSource, getItem
  } = useOperate(value?.length > 0 ? value : defaultBus);

  const {
    onDelete: onSubDel,
    onAdd: onSubAdd,
    onCopy: onSubCopy, dataSource: subData, onUpdate: onSubUpdate, getItem: getSubItem, setData: setSubData
  } = useOperate([{
    batchNum: 1, ratio: 10, batch: 1, id: 1
  }]);

  const [visible, { setTrue, setFalse }] = useBoolean();

  const handleCopy = (id) => {
    onCopy(id);
  };

  const handleDelete = (id) => {
    onDelete(id);
  };

  const handleAdd = (id) => {
    onAdd(id);
  };

  const handleOpen = (id) => {
    const currItem = getItem(id);
    setCurrId(id);
    setTrue();

    // 手动更新子组件状态
    const _sData = currItem?.batches?.length > 0 ? currItem?.batches : [{
      batchNum: 1, ratio: 10, batch: 1, id: 1
    }];
    setSubData(_sData);
  };

  const handleUpdate = (id, key, val) => {
    const item = getItem(id);
    const newItem = { ...item, [key]: val };
    onUpdate(id, newItem);
  };

  const handleSubCopy = (id) => {
    onSubCopy(id);
  };

  const handleSubDelete = (id) => {
    onSubDel(id);
  };

  const handleSubAdd = (id) => {
    onSubAdd(id);
  };

  const handleSubUpdate = (id, val, key) => {
    const subItem = getSubItem(id);
    const newItem = { ...subItem, [key]: val };
    onSubUpdate(id, newItem);
  };

  const handleSubIpUpdate = (id, batch, val) => {
    // batchList
    const subItem = getSubItem(id);
    const newItem = { ...subItem, batchList: subItem?.batchList || [] };
    newItem.batchList[batch] = {
      batchNum: batch + 1,
      ratio: val,
    };
    onSubUpdate(id, newItem);
  };

  const onOk = () => {
    const currItem = getItem(currId);
    // 需要更新外部的 batches;
    onUpdate(currId, { ...currItem, batches: subData });
    setFalse();
  };

  const onCancel = () => {
    setFalse();
  };

  const subDataSource = subData;

  const tableProps = {
    columns: [
      {
        dataIndex: 'buCode',
        title: 'BU',
        render: (v, { id }) => (
          <Select
            value={v}
            mode="multiple"
            options={defaultBus}
            placeholder="请选择"
            onChange={val => handleUpdate(id, 'buCode', val)}
          />
        )
      },
      {
        title: '是否核心',
        dataIndex: 'range',
        width: 130,
        render: (v, { id }) => (
          <Select
            onChange={val => handleUpdate(id, 'range', val)}
            value={v}
            options={rangeOptions}
            placeholder="请选择"
          />
        )
      },
      {
        title: '批次配置',
        dataIndex: 'batchConfig',
        width: 90,
        render: (_, { id }) => <a onClick={() => handleOpen(id)}>配置</a>
      },
      {
        title: '操作',
        width: 150,
        render: (_, { id }, index) => {
          const isLast = index === dataSource?.length - 1;
          return (
            <Space>
              <a onClick={() => handleCopy(id)}>复制</a>
              <a onClick={() => handleDelete(id)}>删除</a>
              {
                isLast && <a onClick={() => handleAdd(id)}>添加</a>
              }
            </Space>
          );
        }
      }
    ],
    pagination: {
      hideOnSinglePage: true
    },
    rowKey: 'id',
    dataSource,
  };

  const mtableProps = {
    columns: [
      {
        title: '应用批次', dataIndex: 'batchNum', render: (v, r, i) => `第 ${i + 1} 批`
      },
      {
        title: '应用比例',
        dataIndex: 'ratio',
        render: (v, { id }) => (
          <InputNumber
            min={1}
            onChange={val => handleSubUpdate(id, val, 'ratio')}
            value={v}
            placeholder="请输入"
            formatter={item => `${item}%`}
          />
        )
      },
      {
        title: '应用下 IP 批次',
        dataIndex: 'batch',
        render: (v, { id }) => (
          <InputNumber
            min={1}
            onChange={val => handleSubUpdate(id, val, 'batch')}
            value={v}
            placeholder="请输入"
            formatter={item => `${item}批`}
          />
        )
      },
      {
        title: '操作',
        width: 150,
        render: (_, { id }, index) => {
          const isLast = index === subData?.length - 1;
          return (
            <Space>
              <a onClick={() => handleSubCopy(id)}>复制</a>
              <a onClick={() => handleSubDelete(id)}>删除</a>
              {
                isLast && <a onClick={() => handleSubAdd(id)}>添加</a>
              }
            </Space>
          );
        }
      }
    ],
    dataSource: subDataSource,
    pagination: {
      hideOnSinglePage: true
    },
    rowKey: 'id',
    expandable: {
      expandIcon: () => false,
      expandedRowRender: ({ batch, id, batchList }) => {
        const currBatch = batchList || [];

        const ipbatchList = Array(batch).fill().map((_, i) => (
          <div style={{ width: 106 }}>
            <div>第 {i + 1} 批 IP 比例</div>
            <InputNumber
              value={currBatch[i]?.ratio || null}
              onChange={val => handleSubIpUpdate(id, i, val)}
              min={1}
              formatter={v => `${v}%`}
            />
          </div>
        ));
        return (
          <div style={{ display: 'flex', flexWrap: 'wrap' }}>
            {ipbatchList}
          </div>
        );
      },
      expandedRowKeys: subData?.map(v => v.id)
    },

  };

  const modalProps = {
    visible,
    onOk,
    onCancel,
    title: '应用批次配置',
    width: 720,
  };

  useDeepCompareEffect(() => {
    onChange(dataSource);
  }, [dataSource]);

  return (
    <div>
      <Table {...tableProps} />
      <Modal {...modalProps}>
        <Table {...mtableProps} />
      </Modal>
    </div>
  );
}

export default ConfigTable;