【实践】使用antd开发中后台项目 · 通用技巧

2,294 阅读7分钟

前言

antd 是一套优秀的React组件库,已成为使用 React 技术栈的团队开发中后台项目的一项 基础设施 ,刚好笔者最近一直在使用TS+React+antd开发中后台项目,所以,想把一些使用经验梳理一下,分享给大家。

image.png

这是之前笔者整理的一个数据,这个比例应该在别的公司也不会相差很多,毕竟绝大大部分的中后台页面都是CRUD类型的,新建提交-表单查询-列表展示-数据更新 这基本就涵盖了大部分的常见开发场景。

表单场景

区分新建或更新

新建与更新的表单字段大部分情况下是高度一致的 , 所以组件可以复用,详见下方的培训配置一个例子:

image.png

虽然表单组件可以复用,但其实他们在逻辑层面还是有很大区别的:

  • 更新场景需要传一个列表的唯一ID,而新建场景不需要
  • 更新需要回填之前的数据,新建不需要 这就需要在复用组件的情况下,去区分逻辑:
  • 第一种,可以传一个type属性来区分,type Type = 'create' | 'update';
  • 第二种,直接判断父组件传入的 props中是否存在一个更新ID进行区分,存在,就是更新,不存在,就是新建; 这里更推荐第二种,因为足够简洁,不用传额外的属性。

一个好的组件封装原则:在不影响组件功能实现的前提下,props越简洁越利于维护

image.png

Form回填

react里实现双向绑定比vue里的v-model指令方式会麻烦许多,同时也考虑到其他的设计上取舍,antd-Form目前采用了完全非受控的设计,想修改表单内部状态,就必须要通过调用Form实例方法来完成,比如 数据回填 就是一例。
ForminitialValues可以用来设置默认值,但它有个重要的特点:只在组件首次初始化时生效一次。但在实际场景中,我们的回填数据,往往是从后端接口异步获取的,所以就需要配合一些其他手段来完成这项工作。 这里提供两种思路。

  • 第一种: initialValues属性 + form.resetFields()
    将异步请求到的回填数据,保存成一个state,同时调用form.resetFields()
const [form] = Form.useForm();
    const [detail, setDetail] = useState<Record<string, any>>();
    useEffect(() => {
        axios.get('/api/xxx').then(data => {
            setDetail(data);
            form.resetFields();
        });
    }, [form]);
    return (
        <>
            <Form form={form} initialValues={detail}>
                <Form.Item name="age" label="age">
                    <Input />
                </Form.Item>
            </Form>
        </>
    );
  • 第二种 直接使用form.setFieldsValue()更新表单值。
const [form] = Form.useForm();
    useEffect(() => {
        axios.get('/api/xxx').then(data => {
            form.setFieldsValue(data);
        });
    }, [form]);
    return (
        <>
            <Form form={form}>
                <Form.Item name="age" label="age">
                    <Input />
                </Form.Item>
            </Form>
        </>
    );

通常情况下,更推荐第二种方式, 不需要多余的状态去配合处理,但如果你还想用获取到的数据,去做点别的事情,那么你应该会更青睐第一种方式。

数据格式转换

后端回填的数据格式,往往与前端这需要的不一致,这在表单场景下尤为明显。

比如,一个date的时间字段,后端回填的是时间戳类型的值:1628897838278,但前端这antd的日期组件默认需要的是Moment类型,用TypeScriptinterface来抽象描述 , 就大概是下图这个样子:

image.png

接下来就是要实现两个转换函数

  • 将后端的数据格式(ServerParams)转换为Form需要的格式(FormValues
  • 将前端Form(FormValues)收集的数据格式转换为后端需要的格式(ServerParams

image.png

image.png

image.png

而且,笔者建议只要是开发表单场景,就提前定义好两个转换方法,可以让前后端的数据转换逻辑变得更清晰,并且随着后续需求迭代,表单字段会变得越来越复杂,那么这种做法的收益就越大。

多场景提交

大部分表单,一般都只有一个提交主按钮,这样用一个htmlType = 'submit'的按钮,配合Form的onFinish回调就可以完成。

image.png

image.png

但是,凡事总有例外,比如页面现在设计为两个主操作:【保存草稿】 与 【提交】

  • 保存草稿:只是将数据暂存起来,并不会对业务实时生效
  • 提交:会对业务实时生效 这意味着他们是不同的逻辑,也许对应后端不同的接口,那么用onFinish提交这个常规流程就不能用了,因为你无法在同一个方法里,区分两种不同的逻辑,你可以这样做:
  1. 用不同的方法区分操作类型 image.png

  2. 如果需要校验,调await form.validateFields验证,顺便就拿到获取表单值,不需要的话,直接调 form.getFieldsValue获取表单值,接下来就可以通过调不同的后端接口实现不同的功能逻辑了。

image.png

image.png

列表

列表项配置

antd的列表项有时候会非常长,希望抽到一个单独文件中去维护,但是列表项的配置又依赖一些方法,那么就可以用一个参数包含actions函数去解决。

/**
 * @param actions 操作列表
 * @returns TableColumnType
 */
export function getTableColumn(actions = {}) {
    return [
	...
        {
            title: '培训城市',
            dataIndex: 'cityName',
        },
        {
            title: '培训主题',
            dataIndex: 'title',
        },
        {
            title: '操作',
            fixed: 'right',
            render(_, row) {
                return (
                    <Button
                        onClick={() => actions.handleSeeDetail(row)}
                        type="primary"
                        size="small"
                    > 查看
                    </Button>
                );
            },
        },
    ];
}

分页请求

抽一个公共的hook,去处理分页:

import { useState, useEffect, useRef } from 'react';
import { message, TableProps } from 'antd';

export interface PageParams {
  pageNum:number
  pageSize:number
}

type RequestHandler<RecordType> = (pageParams:PageParams) => Promise<{ data: Array<RecordType>; total: number }>; // 请求数据的方法

interface Result<RecordType> {
    tableProps:TableProps<RecordType> // 表格属性
    resetTable:()=>void // 手动重置表格 页码会重置为第一页
    reloadTable:()=>void // 手动刷新当前页的表格数据
}
/**
 * @description 方便使用antd表格,提供了分页自动处理功能
 * @param request 请求方法
 * @param deps 表格重置的依赖项,任意依赖项发生变化,重置页码为1,自动调用request方法
 * @returns Result<T>
 */
export default function useAntdTable<T = object>(request:RequestHandler<T>, deps:any[] = [], tableProps:TableProps<T> = {}):Result<T> {
    const [dataList, setDataList] = useState([]); // 列表数据
    const defaultPageSize = (tableProps?.pagination && tableProps?.pagination.defaultPageSize) || 10;
    const [pageParams, setPageParams] = useState({ pageNum: 1, pageSize: defaultPageSize }); // 当前页码及每页条数

    const [total, setTotal] = useState(0); // 数据总条数
    const [isFetching, setIsFetching] = useState(false); // 是否处于请求中
    const isInitFlagRef = useRef(true); // 标示下是否是首次初始化渲染
    const fetchData = async () => {
        try {
            setIsFetching(true);
      
            const { data, total } = await request(pageParams);

            setDataList(data || []);
            setTotal(total || 0);
        } catch (error) {
            message.error(error.message || error.msg || '加载列表出错');
            setDataList([]);
        }

        isInitFlagRef.current = false;
        setIsFetching(false);
    };

    useEffect(() => {
        fetchData();
    }, [pageParams]);

    useEffect(() => {
        const isNeedRest = !isInitFlagRef.current;// 除去首次渲染,之后的更新场景,如果重置依赖项deps,任意元素改变,触发重置

        isNeedRest && setPageParams({
            ...pageParams,
            pageNum: 1,
        });
    }, deps);

    const onChange = ({ current, pageSize }) => {
        setPageParams({
            pageNum: pageSize === pageParams.pageSize ? current : 1,
            pageSize,
        });
    };

    return {
        tableProps: {
            ...tableProps,
            loading: isFetching,
            onChange,
            dataSource: dataList,
            pagination: tableProps.pagination !== false && {
                ...tableProps.pagination,
                current: pageParams.pageNum,
                pageSize: pageParams.pageSize,
                total,
            },
        },
        resetTable() {
            setPageParams({
                ...pageParams,
                pageNum: 1,
            });
            setTotal(0);
            setDataList([]);
        },
        reloadTable() {
            fetchData();
            setTotal(0);
            setDataList([]);
        },
    };
}

有了这个useAntdTablehook,在开发中使用分页,就非常简单了。

image.png

列表操作

列表的操作,交互形态大多是以按钮点击触发展示的Modal框作为操作的载体,需要将列表ID及其他列表项参数进行传入,实现对应业务逻辑。

image.png

这里我讲解两种解决方案:

  • 第一种:数据流的方式。主要是将Modal+操作Button组合起来封装成一个包含功能逻辑的增强按钮组件,列表ID通过正常的数据流从高到低自然的传入。

image.png

下边就是其中操作按钮ShowNameListBtn的实现。

image.png

优势: 按钮组件更加自治,列表的ID及其他列表字段都通过数据流的形式传入,无需特殊处理
缺点: 性能不高,有多少条数据,组件就得渲染多少次。

  • 第二种:用一组专门的状态来维护当前正在操作的那条数据的信息,也是比较常规的一种方式。
  1. 新建一组状态。包含Modal展示,列表ID,及其他列表项数据。 image.png

  2. 给操作按钮绑定更新事件,将当前点击的列表项信息更新到上一步提到的状态上。 image.png

image.png

  1. 父组件状态更新,引发重渲染,将最新数据传给弹框组件。

image.png

优势:渲染开销小。Modal只需在父组件引用渲染一次。
缺点:组件划分不够解耦,父组件多了一组状态的维护。列表ID这些参数严格意义上并不应该属于父组件的state的范畴。

两种方案各有利弊,看自己需求,我自己用第一种多一些,因为这样组件职责更加明确,按钮组件实现自治,能大幅降低父组件的代码量。

持续更新中...

需求不停止,文章也会持续更新下去... 希望大家评论区多多交流自己的经验,或者给予批评指正。