sunilwang

V1

2022/07/26阅读:11主题:绿意

使用Antd开发中后台项目通用实践

作者简介

郑瑜栋:在团队拥有一个奇怪的外号“=”哥, 咱也是有标点(签)的人了

前言

Antd 是国内使用最广泛的React中后台组件库。刚好笔者最近一直在使用TS+React+Antd这套经典的技术栈开发项目,顺手把过程中的一些使用经验梳理一下,分享给大家。

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

表单场景

1. 区分新建或更新

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

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

  • 更新场景需要传一个列表的唯一ID,而新建场景不需要。
  • 更新需要回填之前的数据,新建不需要。

这就需要在复用组件的情况下,去区分两种逻辑:

  • 第一种,可以传一个type属性来区分,create代表新建,update代表更新;
  • 第二种,直接判断父组件传入的 props中是否存在一个更新ID进行区分。存在,即为更新场景;不存在,就是新建;

这里更推荐第二种,因为足够简洁,不用传额外的属性。

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

Form回填

react里实现双向绑定相比vue里的v-model指令方式会麻烦许多,同时也考虑到其他方面比如性能等因素,antd-Form v4目前采用了非受控的设计,也就是说:修改表单内部状态,必须要通过调用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来抽象描述 , 就大概是下图这个样子:

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

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

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

多场景提交

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

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

  • 保存草稿:只是将数据暂存起来,并不会对业务实时生效。
  • 提交:会对业务实时生效。

这意味着他们是不同的逻辑,也许对应后端不同的接口,那么用onFinish提交这个常规流程就不能用了,因为你无法在同一个方法里,区分两种不同的逻辑,你可以这样做:

  1. 用不同的方法区分操作类型
  1. 如果需要校验,调await form.validateFields进行表单验证,并且顺便就获取到了表单值。不需要校验的话,直接调 form.getFieldsValue获取表单值,接下来就可以通过调不同的后端接口实现不同的功能逻辑了。

列表

列表项配置

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,去处理分页:

/* eslint-disable react-hooks/exhaustive-deps */
import { useState, useEffect, useRef } from 'react';
import { message, TableProps } from 'antd';

export interface PageParams {
  pageNum: number;
  pageSize: number;
}

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

type Options = {
  isInit?: boolean; // 是否在初始化时候默认触发一次请求 默认为true
};

interface Result<RecordType> {
  tableProps: TableProps<RecordType>; // 表格属性
  resetTable: () => void// 手动重置表格 页码会重置为第一页
  reloadTable: () => void// 手动刷新当前页的表格数据
}
/**
 * @description 方便使用antd表格,提供了分页自动处理功能
 * @param request 请求方法
 * @param deps 表格重置的依赖项,任意依赖项发生变化,重置页码为1,自动调用request方法
 * @param Options 自定义选项
 * @returns Result<T>
 */


export default function useAntdTable<T = object>(
    request: RequestHandler<T>,
    deps: any[] = [],
    tableProps: TableProps<T> = {},
    options?: Options,
): Result<T
{
    const [dataList, setDataList] = useState([]); // 列表数据
    const defaultPageSize = (tableProps?.pagination && tableProps?.pagination.defaultPageSize) || 10;
    const [pageParams, setPageParams] = useState({
        pageNum1,
        pageSize: defaultPageSize,
    }); // 当前页码及每页条数

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

    const { isInit = true } = options || {};

    const fetchData = async () => {
        try {
            setIsFetching(true);
            // eslint-disable-next-line no-shadow
            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);
    };

    const stopInitReq = useRef(isInit === false);

    // 分页请求
    useEffect(() => {
        if (stopInitReq.current) { // isInit选项为true,禁止初始化时候去请求列表
            stopInitReq.current = false;

            return;
        }

        fetchData();
    }, [pageParams]);

    // 依赖项变化,重置分页
    useEffect(() => {
        const isNeedRest = isInitFlagRef.current === false// 只在更新场景才调用重置,首次初始化不需要

        isNeedRest && setPageParams({
            ...pageParams,
            pageNum1,
        });
    }, 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,
                pageNum1,
            });
            setTotal(0);
            setDataList([]);
        },
        reloadTable() {
            fetchData();
            setTotal(0);
            setDataList([]);
        },
    };
}


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

列表操作

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

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

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

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

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

  • 第二种:用一组专门的状态来维护当前正在操作的那条数据的信息,也是比较常规的一种方式。
  1. 新建一组状态。包含Modal展示,列表ID,及其他列表项数据。
  1. 给操作按钮绑定更新事件,将当前点击的列表项信息更新到上一步提到的状态上。
  1. 父组件状态更新,引发重渲染,将最新数据传给弹框组件。
  • 优势: 按钮组件更加自治,列表的ID及其他列表字段都通过数据流的形式传入,无需特殊处理
  • 缺点: 性能不高,有多少条数据,组件就得渲染多少次。

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


LBG开源项目推广:

还在手写 HTML 和 CSS 吗?
还在写布局吗?
快用 Picasso 吧,Picasso 一键生成高可用的前端代码,让你有更多的时间去沉淀和成长,欢迎Star

开源项目地址:https://github.com/wuba/Picasso
官网地址:https://picassoui.58.com

分类:

后端

标签:

后端

作者介绍

sunilwang
V1