高度封装antd带通用搜索类表格

本篇文章围绕着antd中Form组件及Table,从手写Form到封装通用组件高效的封装Form和Table。

旨在在后台类项目中最大节省代码量,在实践中只需要无脑cv,并极低的定义方式实现带搜索操作的表格通用页面。

例如

头部表单搜索 + 表格 + 表格操作 + 创建、编辑弹窗

亮点:

  • renderFormItem 支持antd原始写法、FormHOC管理写法、对象配置写法
  • modal组件高度封装,一体化创建和编辑中的表单项
  • useGetTableList 包含表格所有属性配置及request封装

此篇文章只有一个目的 👇🏻

代码仓库: github.com/WangTianyu-... 在开始之前,强烈建议把代码下载run起来,然后结合此篇文章进行理解和总结。

万事不理根,先学习下antd中的Form是如何实现的

原理篇-TForm

需要有哪些内内容?

  • Form组件
    • initalValues初始值
    • onFinish 递交时的回调
    • onFinishFailed 提交错误的回调
    • Form实例 用于获取表单全部数据
  • Form.Item
    • lable
    • name
    • rules
    • children 被包裹的表单组件
  • Context
    • Form里保存Store到Context,然后在Item里面取出Context的Store来,同步表单值到store

FormContext

typescript 复制代码
import { createContext } from "react";

/**
 * FormContextProps
 * 在context里保存values也就是Store的值
 * onValueChange用于value变化
 * validateRegister用于注册校验方法,也就是rules指定的那些
 */

export interface TFormContextProps {
  values?: Record<string, any>;
  setValues?: (values: Record<string, any>) => void;
  onValueChange?: (key: string, value: any) => void;
  validateRegister?: (name: string, cb: Function) => void;
}

export default createContext<TFormContextProps>({});

TForm

定义Form的props

tsx 复制代码
/**
 * 样式名
 * 行内样式
 * 提交表单校验成功的回调
 * 提交表单校验失败的回调
 * 参数传入初始值initialValues
 * 被包裹的表单元素
 */

export interface TFormProps extends React.HTMLAttributes<HTMLFormElement> {
  className?: string;
  style?: CSSProperties;
  onFinish?: (values: Record<string, any>) => void;
  onFinishFailed?: (errors: Record<string, any>) => void;
  initialValues?: Record<string, any>;
  children?: ReactNode;
}

const TForm:FC<TFormProps> = (props) => {
   const {
    className,
    style,
    children,
    initialValues,
    onFinish,
    onFinishFailed,
    ...others
  } = props;

  ...其他
}

将表单状态通过useState存储,用useRef保存errors和validator

validatorMap 及 errors 是通过ref 将值保存在current属性上,修改state时不需要触发重新渲染的数据

  • values, setValues 表单的数据
  • validatorMap 需要被校验的函数
  • errors 校验失败的表单
tsx 复制代码
const TForm:FC<TFormProps> = (props) => {
 // values用于保存表单的值
  const [values, setValues] = useState<Record<string, any>>(
    initialValues || {}
  );
  // 为什么不都用useState?
  // 因为修改state调用setValues会触发重新渲染
  // 而ref的值保存在current属性上,修改它不会触发重新渲染
  // errors\validator这种就是不需要重新触发渲染的数据
  const validatorMap = useRef(new Map<string, Function>());
  const errors = useRef<Record<string, any>>({});
... 
}

设置表单数据的方法 onValueChange

提交表单handleSubmit ,需要查看表单内是否全部校验通过,也就是对validatorMap进行遍历,如果全部通过则onFinish 否则校验未通过 onFinishFailed

注册校验方法 validatorMap

tsx 复制代码
const TForm:FC<TFormProps> = (props) => {
  const [values, setValues] = useState<Record<string, any>>(
    initialValues || {}
  );
  const validatorMap = useRef(new Map<string, Function>());
  const errors = useRef<Record<string, any>>({});

  // 同步修改表单值
  const onValueChange = useCallback(
    (key: string, value: any) => {
      setValues((prev) => {
        return {
          ...prev,
          [key]: value,
        };
      });
    },
    [setValues]
  );

  // 表单提交进行校验
 const handleSubmit = useCallback(
  (e: FormEvent) => {
    e.preventDefault();
    // 1. 遍历validatorMap,对所有的validator对值的校验
    for (const [key, callbackFunc] of validatorMap.current) {
      if (typeof callbackFunc === "function") {
        errors.current[key] = callbackFunc(values[key]);
      }
    }
    // 2. 查看是否有错误,如果有错误,调用onFinishFailed回调
    const errorList = Object.keys(errors.current)
      .map((key) => {
        return errors.current[key];
      })
      .filter(Boolean);
    if (errorList.length) {
      onFinishFailed?.(errors.current);
    } else {
      onFinish?.(values);
    }
  },
  [values, onFinish, onFinishFailed]
);

 // 注册校验方法
 const handleValidateRegister = useCallback(
  (name: string, cb: Function) => {
    validatorMap.current.set(name, cb);
  },
  [validatorMap.current]
 );
}

将需要的方法和数据给到Context,并给原始form 元素添加onSubmit方法

tsx 复制代码
const cls = classNames('t-antd-form', className);

return (
    <TFormContext.Provider
        value={{
          onValueChange,
          values,
          setValues: (v) => setValues(v),
          validateRegister: handleValidateRegister,
        }}
      >
        <form {...others} className={cls} style={style} onSubmit={handleSubmit}>
          {children}
        </form>
      </TFormContext.Provider>
)

export default memo(TForm)

TForm.Item

首选传入必要的props

  • classNams
  • style
  • label
  • name
  • valuePropName 默认是 value,当 checkbox 等表单项就要取 checked 属性了
  • rules
  • children
tsx 复制代码
export interface TItemProps {
  className?: string;
  style?: CSSProperties;
  label?: ReactNode;
  name?: string;
  valuePropName?: string; // valuePropName 默认是 value,当 checkbox 等表单项就要取 checked 属性了
  rules?: Array<Record<string, any>>;
  children: ReactElement; // 这里 children 类型为 ReactElement 而不是 ReactNode。 因为ReactNode除了ReactElement还包括了字符串、数字等
}

如果没有传入name值则直接返回children

比如

tsx 复制代码
const TFormItem: FC<TItemProps> = (props) => {
  const { className, style, label, name, valuePropName, rules, children } =
    props;

  // 如果没有传入 name 参数,那就直接返回 children。
  if (!name) {
    return children;
  }

}

给个item项都要存储value及error,并需要同步value

tsx 复制代码
const TFormItem: FC<TItemProps> = (props) => {
  const { className, style, label, name, valuePropName, rules, children } =
    props;
  
  ....
  const { onValueChange, values, validateRegister } = useContext(IFromContext);
  const [value, setValue] = useState<string | number | boolean>();
  const [error, setError] = useState<string>("");

  // 从 context 中读取对应 name 的 values 的值,
  // 同步设置 Item中的value
  useEffect(() => {
    if (value !== values?.[name]) {
      setValue(values?.[name]);
    }
  }, [value, values?.[name]]);


}

处理被Form.Item包裹的表单

Children.toArray 判断如果传入的children 是否大于1,如果是则直接返回children 否则对表单项进行克隆并传入新的props

如果下面这种情况则直接返回children

注意需要对特殊的表单例如switch radio的value进行兼容 并给到表单组件的value或checked

tsx 复制代码
// 兼容checkbox、radio等表单项的值
const getValueFormEvent = (e: ChangeEvent<HTMLInputElement>) => {
  const { target } = e;
  if (target.type === "checkbox") {
    return target.checked;
  } else if (target.type === "radio") {
    return target.value;
  }
  return target.value;
};
const propsName: Record<string, any> = {};
  if (valuePropName) {
    propsName[valuePropName] = value;
  } else {
    propsName.value = value;
  }
  console.log("🚀 ~ propsName:", propsName);

  // 调用 Children.toArray(children) 能够通过 children 处理并创建一个数组。
  // React.cloneElement 会克隆一个元素并传入新的 props。
  const childEle = useMemo(() => {
    return React.Children.toArray(children).length > 1
      ? children
      : React.cloneElement(children, {
          ...propsName,
          onChange: (e: ChangeEvent<HTMLInputElement>) => {
            const value = getValueFormEvent(e);
            setValue(value); // 设置当前组件的value
            onValueChange?.(name, value); // 设置Form组件的value
            handleValidate(value); // 表单校验 看下文
          },
        });
  }, [children, propsName, value, name, onValueChange, handleValidate]);

handleValidate:然后是校验 rules,这个是用 async-validator 这个包

tsx 复制代码
  const handleValidate = (value: any) => {
    let errorMsg = null;
    const isRulesLength = Array.isArray(rules) && rules.length;
    if (isRulesLength) {
      const validate = new Schema({
        [name]: rules.map((rule) => {
          return {
            type: "string",
            ...rule,
          };
        }),
      });

      validate.validate({ [name]: value }, (errors) => {
        if (errors) {
          if (errors?.length) {
            setError(errors[0].message!);
            errorMsg = errors[0].message!;
          }
        } else {
          setError("");
          errorMsg = null;
        }
      });
    }
    return errorMsg;
  };

在 context 注册 name 对应的 validator 函数:

也就是给Form组件中的这部分逻辑进行注册

tsx 复制代码
  useEffect(() => {
    validateRegister?.(name, () => handleValidate(value));
  }, [value, children]);

然后 Item 组件渲染 label、children、error

tsx 复制代码
const TFormItem:FC<TItemProps> = (props)=> {
  
  // ....忽略以上代码
  
  const cls = classNames("ty-ant-form-item", className);

  return (
    <div className={cls} style={style}>
      <div>{label && <label>{label}</label>}</div>
      <div>
        {childEle} {error && <div style={{ color: "red" }}>{error}</div>}
      </div>
    </div>
  );
}

export default memo(TFormItem);

Form实例

例如 htmlType 不是submit 就不会触发表单提交

例如需要获取、修改表单所有的信息

这里可以简单化 传递ref 在Form组件中抛出 getFilesValue 、setFieldsValue

tsx 复制代码
export interface TFormRefApi {
  getFilesValue: () => Record<string, any>;
  setFieldsValue: (values: Record<string, any>) => void;
}

// 组件实例
const formRef = useRef<TFormRefApi>(null);

const handleGetFormValues = () => {
  formRef.current?.getFilesValue()
}

return (
  <>
  <Button onClick="handleGetFormValues">获取form数据</Button>
  <TForm
    initialValues={{ remember: true, username: "帅气的TianYu" }}
    onFinish={onFinish}
    onFinishFailed={onFinishFailed}
    ref={formRef}
  >
   ...
   </TForm>
  </>
)
tsx 复制代码
const TFrom = forwardRef<TFormRefApi, TFormProps>((props: TFormProps, ref) => {

  //...其他代码
  
  useImperativeHandle(
    ref,
    () => {
      return {
        getFilesValue() {
          return values;
        },
        setFieldsValue(values) {
          setValues(values);
        },
      };
    },
    [ref, values]
  );
}

封装 - FormItemHOC

目的是为了简化html写法

通过FormItemHOC,集中管理被注册的表单

将需要传递给 Form.Item组件及被包裹的表单组件的props进行传递

FormItemProps 是FormItem组件的类型

itemProps 是需要传递表单元素的类型

新增:resetKeys 是输入内容时,将指定的表单 name 的value进行清楚

index.d.ts

tsx 复制代码
// 表单元素的类型
export type CustomFormItemType = "input" | 'select' ...;

index.tsx

tsx 复制代码
// 通过Wrapper包裹组件,传递props
function Wrapper<T>(type: CustomFormItemType) {
  return (props: FormItemProps & { itemProps?: T; resetKeys?: NamePath[] }) => {
    return <CustomFormItem type={type} {...(props as any)} />;
  };
}
// 统一管理表单组件
export const FormItemHOC = {
  FInput: Wrapper<InputProps>("input"),
  FSelect ....,
  ...
};
  • 将props分别传递给Form.Item 及对于的表单组件
  • type 用来区分是什么表单组件,例如input select..
  • 传入resetKeys,当输入表单内容,则清空对应的resetKeys命中name的表单项
  • render动态渲染组件
tsx 复制代码
type CFormItemProps = FormItemProps & CustomFormItemUtils;
export interface CustomFormItemProps<P, T = CustomFormItemType>
  extends CommonProps {
  type: T;
  itemProps?: P;
  col?: number;
}

export type CustomFormItemUtils = CustomFormItemProps<InputProps, "input">;

function CustomFormItem(props: CFormItemProps): ReactNode {
  const { type, itemProps, resetKeys } = props;
  const form = Form.useFormInstance();

  // 重置指定的key
  const resetKeysFunc = useCallback(() => {
    if (Array.isArray(resetKeys)) {
      form.setFields(
        resetKeys.map((item) => ({ name: item, value: undefined }))
      );
    }
  }, [form, resetKeys]);

  // 通过type获取对应的组件
  const Render = useMemo(() => CustomFormItemMap[type], [type]);

  // 删除props中的itemProps,destroyed,resetKeys 为什么?
  // 因为这些props不需要传递给Form.Item
  const formItemProps = useModifyProps(props, [
    "itemProps",
    "destroyed",
    "resetKeys",
  ]);

  return (
    <Form.Item {...formItemProps}>
      <Render itemProps={itemProps} resetKeysFunc={resetKeysFunc} />
    </Form.Item>
  );
}

CustomFormItemMap 动态渲染组件

index.tsx

Map 管理所有的表单组件

tsx 复制代码
export const CustomFormItemMap: Record<
  CustomFormItemType,
  FC<{
    itemProps?: any; // 需要传递给表单的props
    onChange?: any; // 修改值
    value?: any; // 表单值
    resetKeysFunc?: any; // 被清空value的方法
  }>
> = {
  input: InputItem, // key 对应 表单组件
  .... 可添加其他表单组件
};

注册input组件

input表单 根据需要传入默认值

index.d.ts

tsx 复制代码
interface CommonProps {
  resetKeys?: NamePath[];
  destroyed?: boolean;
}

export interface FieldItemProps<T> extends CommonProps {
  itemProps?: T;
  onChange?: any;
  value?: any;
  resetKeysFunc?: () => void;
}

input.tsx

tsx 复制代码
import { Input, InputProps } from "antd";
import { ChangeEvent, FC, useCallback } from "react";
import type { FieldItemProps } from "./index.d";

// 默认值
const InputItemDefaultProps: InputProps = {
  placeholder: "请输入",
  allowClear: true,
  style: { width: "100%" },
};

const InputItem: FC<FieldItemProps<InputProps>> = (props) => {
  const { itemProps, onChange, value, resetKeysFunc } = props;

  // 修改表单项
  const onValueChange = useCallback(
    (e: ChangeEvent<HTMLInputElement>) => {
      const value = e.target.value;
      // 清空指定name的表单值
      resetKeysFunc?.();
      // 执行onChange方法
      itemProps?.onChange?.(e);
      // 同步表单项值
      onChange?.(value);
    },
    [itemProps, onChange, resetKeysFunc]
  );

  return (
    <Input
      value={value}
      {...InputItemDefaultProps}
      {...itemProps}
      onChange={onValueChange}
    />
  );
};
export default InputItem;

现在试一下

tsx 复制代码
import { Button, Form } from "antd";
import React, { FC } from "react";
import { FormItemHOC } from "../\bcustom-form-item";
const FormTest: FC = () => {
  const [form] = Form.useForm();

  const onFinish = (values: any) => {
    console.log("🚀 ~ FormTest ~ values", values);
  };
  return (
    <Form form={form} onFinish={onFinish}>
      <FormItemHOC.FInput
        name="name"
        label="userName"
        itemProps={{
          placeholder: "请输入用户名",
          maxLength: 10,
        }}
      />
      <FormItemHOC.FInput
        name="age"
        label="age"
        resetKeys={["name"]}
        itemProps={{
          placeholder: "age",
          maxLength: 10,
        }}
      />
      <Form.Item>
        <Button type="primary" htmlType="submit">
          submit
        </Button>
      </Form.Item>
    </Form>
  );
};
export default FormTest;

注册InputNumber+ InputTextArea组件

tsx 复制代码
const TextAreaItemDefaultProps: TextAreaProps = {
  showCount: true,
  rows: 1,
  style: { width: "100%" },
  placeholder: "请输入",
  allowClear: true,
};
export const TextAreaItem: React.FC<FieldItemProps<TextAreaProps>> = (
  props
) => {
  const { itemProps, onChange, value, resetKeysFunc } = props;

  const onValueChange: React.ChangeEventHandler<HTMLTextAreaElement> =
    useCallback(
      (e) => {
        resetKeysFunc?.();
        itemProps?.onChange?.(e);
        onChange?.(e.target.value);
      },
      [itemProps, onChange, resetKeysFunc]
    );
  return (
    <Input.TextArea
      value={value}
      {...TextAreaItemDefaultProps}
      {...itemProps}
      onChange={onValueChange}
    />
  );
};


const InputNumberItemDefaultProps: InputNumberProps = {
  style: { width: "100%" },
  placeholder: "请输入",
};
export const InputNumberItem: React.FC<FieldItemProps<InputNumberProps>> = (
  props
) => {
  const { itemProps, value, onChange, resetKeysFunc } = props;
  const onValueChange: React.ChangeEventHandler<HTMLInputElement> = useCallback(
    (e: any) => {
      resetKeysFunc?.();
      itemProps?.onChange?.(e);
      onChange?.(e);
    },
    [itemProps, onChange, resetKeysFunc]
  );
  return (
    <InputNumber
      value={value}
      {...InputNumberItemDefaultProps}
      {...itemProps}
      onChange={onValueChange}
    />
  );
};

添加对应的类型及对应组件

tsx 复制代码
// 通过Wrapper包裹组件,并且传入对应的type
export const FormItemHOC = {
  FInput: Wrapper<InputProps>("input"),
  FTextArea: Wrapper<TextAreaProps>("textArea"),
  FInputNumber: Wrapper<InputNumberProps>("inputNumber"),
};

export type CustomFormItemType = "input" | "textArea" | "inputNumber";

export const CustomFormItemMap: Record<
  CustomFormItemType,
  FC<{
    itemProps?: any;
    onChange?: any;
    value?: any;
    resetKeysFunc?: any;
  }>
> = {
  input: InputItem,
  textArea: TextAreaItem,
  inputNumber: InputNumber,
};

牛刀小试

tsx 复制代码
import { Form } from "antd";
import { FC } from "react";
import { FormItemHOC } from "../\bcustom-form-item";
const FormTest: FC = () => {
  const [form] = Form.useForm();

  const onFinish = (values: any) => {
    alert(`表单提交成功: ${JSON.stringify(values)}`);
  };

  const onFinishFailed = (values: any) => {
    alert("表单提交失败: " + JSON.stringify(values));
  };
  return (
    <Form
      form={form}
      onFinish={onFinish}
      onFinishFailed={onFinishFailed}
      layout="vertical"
    >
      <FormItemHOC.FInput
        name="name"
        label="userName"
        rules={[{ required: true, message: "请输入用户名" }]}
        itemProps={{
          placeholder: "请输入用户名",
          maxLength: 10,
        }}
      />
      <FormItemHOC.FTextArea
        name="textArea"
        label="textArea"
        rules={[{ required: true, message: "请输入" }]}
        itemProps={{
          placeholder: "textArea",
          rows: 5,
        }}
      />
      <FormItemHOC.FButton
        btnName="提交"
        itemProps={{
          htmlType: "submit",
        }}
      />
    </Form>
  );
};
export default FormTest;

封装 - SearchForm

接下来需要封装的是头部的表单条件搜索区域

定义props

  • onSubmitCallback 搜索提交事件
  • itemList 表单项
  • addAfter 在表单最后需要渲染的元素
  • hiddeResetBtn 是否隐藏重置按钮

包括需要传递form配置的默认值等

tsx 复制代码
type BasicFormProps = React.PropsWithChildren<FormProps<any>> & {
  onSubmitCallback?: (value?: any) => void;
  itemList: JsonFormItemProps[];
  addAfter?: ReactNode;
  hiddeResetBtn?: boolean;
};

export const defaultProps: React.PropsWithChildren<FormProps<any>> = {
  // labelCol: { span: 8 },
  // wrapperCol: { span: 16 },
  labelAlign: 'left',
  colon: false,
  autoComplete: 'off',
  scrollToFirstError: true,
  style: { margin: '10px' },
  validateMessages: {
    required: '该项不能为空',
  },
};

const SearchForm: React.FC<BasicFormProps> = (props) => {
  const {
    onSubmitCallback,
    itemList,
    form,
    addAfter,
    hiddeResetBtn = false,
  } = props;
  
  const { btnLoading, onSubmit, onReset } = useFormInit({
    onSubmitCallback,
    form,
  });

  return (
    <Form {...defaultProps} {...formProps}>
       <>
        {hiddeResetBtn ? null : <Button onClick={onReset}>重置</Button>}
        <Button loading={btnLoading} type="primary" onClick={onSubmit}>
          查询
        </Button>
        {addAfter}
       </>
    </Form>
  );
};

export default SearchForm;

useFormInit 控制提交按钮loading 以及表单校验 的rest 功能

校验通过 将表单数据传递给回调

通过 useGetTableList hooks中 searchTableList 更新postData即可触发搜素请求,因为内部useEffect监听了postData更新,如果更新则触发请求

tsx 复制代码
import { FormInstance } from "antd";
import { useCallback, useState } from "react";

interface FormInitProps {
  onSubmitCallback?: (values: any) => void;
  form: FormInstance;
}

export function useFormInit(props: FormInitProps) {
  const { onSubmitCallback, form } = props;
  const [btnLoading, setBtnLoading] = useState(false);

  const onSubmit = useCallback(async () => {
    const values = await form.validateFields();
    try {
      setBtnLoading(true);
      onSubmitCallback && onSubmitCallback(values);
    } finally {
      setBtnLoading(false);
    }
  }, [form, onSubmitCallback]);

  const onReset = useCallback(() => {
    form?.resetFields();
  }, [form]);

  return {
    btnLoading,
    onSubmit,
    onReset,
  };
}
tsx 复制代码
 const {
    ...其他结构的方法
    searchTableList,
  } = useGetTableList('/businessLine/list', {
    initPostData: { page: 1, pageSize: 10 },
  });

const [form] = Form.useForm()
  
 <SearchForm
    form={form}
    itemList={itemList}
    onSubmitCallback={searchTableList}
    addAfter={
        <Button
          type="primary"
          onClick={() => {
            // 后续逻辑
          }}
        >
          新增
        </Button>
      }
  />

itemList就是需要渲染的form表单项

之前注册了FormItemHOC表单组件的集合,因此可以通过这个组件来进行传递,但是还有更加简便方式

JsonFormItemProps 类型包含了FOrmHOC的类型 也包含了antdFormItem的类型。所以这里需要处理的是:

  • Form.Item antd组件库提供的方式
  • FormItemHOC.FInput 的方式
  • 对象配置的方式
tsx 复制代码
import type { CustomFormItemUtils } from '../custom-form-item/index.td';
import type { FormItemProps } from 'antd';

export type JsonFormItemProps = CustomFormItemUtils & FormItemProps;

const itemList: JsonFormItemProps[] = [
    { label: '业务线 id', name: 'id', type: 'input', itemProps: {} },
    { label: '业务线名称1', name: 'name', type: 'input', itemProps: {} },
    { label: '业务线名称2', name: 'qwe', type: 'input', itemProps: {} },
    {
      label: '业务线名称3',
      name: 'qwe',
      type: 'input',
      itemProps: {},
      destroyed: true, // 代表不渲染此表单项
    },
    <FormItemHOC.FInput label="分类id" name="sortId" />,
    <Form.Item name="test" label="再来一个" style={{ marginLeft: 15 }}>
      <Input />
    </Form.Item>,
  ];

SearchForm中处理 itemList 逻辑

RenderItem 是通过type在FormItemHOCTypeMap集合中找到对应的组件

<math xmlns="http://www.w3.org/1998/Math/MathML"> t y p e o f : R e a c t 元素(如 J S X 创建的元素)会有一个 typeof: React 元素(如 JSX 创建的元素)会有一个 </math>typeof:React元素(如JSX创建的元素)会有一个typeof 属性,其值是一个独特的符号(Symbol),用于表示这是一个 React 元素。

destroyed 代表不需要渲染

回忆一下 FormItemHOCTypeMap

tsx 复制代码
function renderItemList(
  itemList: JsonFormItemProps[],
  defaultCol: number,
  expand: boolean,
  expandAt: number,
) {
  return itemList.map((item, index) => {
    const { type, name, wrapperCol, label, destroyed, col, hidden } = item;
    const RenderItem = FormItemHOCTypeMap[type];

    // 如果没有通过对象配置,也就是通过JSX手动添加就走这里的逻辑,直接渲染
    if ((item as any).$$typeof) {
      return item;
    }

    // 不需要渲染
    if (destroyed) {
      return null;
    }
    
    const colSpan =
      (!expand && index > expandAt) || hidden ? 0 : col || defaultCol;

    return (
      <Col
        span={colSpan}
        key={name || index}
      >
        // 最终以对象配置的方式会走到这里
        // 通过对象集合找到对应的组件
        <RenderItem
          wrapperCol={label ? wrapperCol : { span: 24 }}
          {...(item as any)}
        />
      </Col>
    );
  });
}

const SearchForm: React.FC<BasicFormProps> = (props) => {
  {RenderItemList(itemList, newCol, expand, expandAt)}
  // .. 其余逻辑
}

代码区域

表单组件的配置、SearchForm组件

封装 - Table

将Table中的方法放在一个Hooks中统一管理

要求

  • 分页功能
  • 传入接口地址及可同步dataSource
  • 根据外部容器大小控制表格高度和纵向滚动
  • 一行溢出后 控制 Tooltip 是否显示
  • 若某一项没有值,则默认显示 --

首先定义 columns

tsx 复制代码
const columns = useCreateCommonColumns([
    {
      title: "ID",
      dataIndex: "id",
    },
    {
      title: "name",
      dataIndex: "name",
    },
    {
      title: "age",
      dataIndex: "age",
    },
    {
      title: "phoneNumber",
      dataIndex: "phoneNumber",
    },
    {
      title: "操作",
      dataIndex: "operation",
      render: () => {
        return (
          <>
            <Space>
              <Button type="link">编辑</Button>
              <Button type="link">查看</Button>
              <Button
                type="link"
                onClick={() => {
                  onDel({
                    requestPostData: { id: 1 },
                    skipError: true,
                  });
                }}
              >
                删除
              </Button>
            </Space>
          </>
        );
      },
    },
  ]);

useCreateCommonColumns的作用就是生成通用columns、展示省略号的通用配置 如果没有值 则展示--

这里增加了宽度控制 若改行为操作栏(operation 可自定义设置)则宽度增加至250

tsx 复制代码
// 展示省略号的通用配置 如果没有值 则展示--
const commonColProps: any = {
  render: (text: any) => (
    <Tooltip placement="topLeft" title={text?.length > 10 ? text : null}>
      {text || text === 0 ? text : "--"}
    </Tooltip>
  ),
  // 居中显示
  align: "center",
  // 超过是否展示省略号
  ellipsis: true,
};

// 生成通用columns
export function useCreateCommonColumns(
  columns: ColumnsType<Record<string, any>>
) {
  const memoColumns = useMemo(() => {
    return columns.map((col: Record<string, any>) => ({
      ...commonColProps,
      ...col,
      width:
        col.dataIndex === "operation"
          ? col.width || "250px"
          : col.width || "150px",
    }));
  }, [columns]);

  return memoColumns;
}

目的是需要通过一个hooks来进行统一的逻辑处理

处理参数

url接口地址、请求参数

默认是初始化及切换分页时自动请求数据

设置表格默认的请求参数,如果没有传入 initPostData 则 使用 默认

tsx 复制代码
interface ListPostDataProps {
  page?: number;
  pageSize?: number;
  // // propName为占位符 代表任意属性的key 值为any
  [propName: string]: any; 
}
interface Params {
  initPostData?: ListPostDataProps;
  // 是否自动加载数据 默认为false
  manual?: boolean;
}

// 表格默认的请求参数
const defaultInitPostData: ListPostDataProps = {
  page: 1,
  pageSize: 10,
};


export function useGetTableList(url:string, params: Params) {
  // 请求参数
  const [postData,setPostData] = useState(params.initPostData || defaultInitPostData)
}

存储dataSource至useState

可以兼容多种接口返回的格式 例如 data: { items: [xxx] } data: { records: [xxx] } data: { list: [xxx] }

tsx 复制代码
interface ListData {
  list?: any[];
  records?: any[];
  items: any[];
}

export function useGetTableList(url:string, params: Params) {
  // ... 忽略
  const [listData, setListData] = useState<ListData>({
    list: [],
    records: [],
    items: [],
  });
}

设置分页参数及请求数据

当触发请求时,更新分页器数据 更新列表数据

tsx 复制代码
export function useGetTableList(url:string, params: Params) {


  const [postData, setPostData] = useState(
    params?.initPostData || defaultInitPostData
  );

    ...中间忽略

  
  const [pagination, setPagination] = useState<PaginationProps>({
    total: 0,
    current: 1,
    size: "default",
    defaultCurrent: 1, // 默认当前页数
    showQuickJumper: true, // 是否可以快速跳转至某页
    onChange: (page, pageSize) => {
      setPostData((p) => ({ ...p, page, pageSize }));
    },
    showTotal: (total: number) => `共 ${total ?? 0} 条`,
  });

  // 请求table数据
  // 因为修改分页器后 会修改 postData 数据 而 useEffect 监控了 变化就会run发请求
  const { loading, run } = useRequest(fetchData, {
    manual: true,
    onSuccess(res) {
      const { data, ret } = res;
      if (ret === 0) {
        const { total = 0 } = data;
        const newPagination: any = {
          total,
          current: postData.page,
        };
        // 更新分页器数据 始终保持当前页数
        setPagination((p) => ({ ...p, ...newPagination }));
        // 更新列表数据
        setListData(data);
      }
    },
  });

}

增加搜索table数据 和更新table数据的方法

一般表格头部都会有表单进行条件搜索 及 编辑完表格数据需要立即请求一次数据更新,这些都需要改变搜索表单的参数

通过useEffect来监听postData变化从而手动发起请求,并判断如传入了 manual 则不会自动获取数据,而且为了保持只会请求一次,需要一个ref来控制防止重复请求

这里用useRef是为了避免更新postData时刷新,但ref上的current不会刷新的特点,防止刷新

tsx 复制代码
export function useGetTableList(url:string, params: Params) {
  
  const searchedRef = useRef(false);
  const manulRef = useRef(params?.manual);
  // 忽略
    // 搜索table数据
  const searchTableList = useCallback((assignParams?: Record<string, any>) => {
    // 每次搜索将页数重置为1
    setPostData((originData) => {
      const newPostData = { ...originData, ...assignParams, page: 1 };
      return newPostData;
    });
  }, []);

  // 更新table数据
  const updateTableList = useCallback((assignParams?: Record<string, any>) => {
    setPostData((originData) => {
      const newPostData = { ...originData, ...assignParams };
      return newPostData;
    });
  }, []);

  // 触发异步请求
  useEffect(() => {
    if (!manulRef.current) {
      run(url, { data: postData, method: "POST" });
    }
    searchedRef.current = true;
  }, [postData, run, url]);

}

当删除表格时,通常需要二次确定后才进行删除

这里也需要提供这个方法,一个是删除的异步请求函数,一个是成功后的回调方法,方便做二次处理

并且将confirm弹层的props常用的参数可手动传入定制,例如标题,文案等

当操作请求成功后,刷新列表展示最新数据

tsx 复制代码
const createChangeStatusConfirmFunc: <P>(
    changeRequestFunc: (params: P) => Promise<any>,
    successCallbackFunc?: (res?: any) => void
  ) => (props: {
    confirmTitle?: string;
    requestPostData: P;
    confirmContent?: string;
    skipError?: boolean;
  }) => void = useCallback(
    (changeRequestFunc, successCallbackFunc) => {
      return (props) => {
        const {
          confirmTitle = "提示",
          requestPostData,
          confirmContent = "确定要删除该项吗?",
          skipError = false,
        } = props;
        Modal.confirm({
          title: confirmTitle,
          content: confirmContent,
          okButtonProps: { style: { background: "#f60" } },
          onOk: async () => {
            const res = await changeRequestFunc(requestPostData);
            if (res.ret === 0 && skipError === false) {
              message.success("操作完成");
              // 更新table数据
              updateTableList();
              // 请求成功之后的回调
              successCallbackFunc?.();
            }
            // 如果传入 skipError 那么 就可以在外面拿到接口的返回值
            // 然后在回调中就可以根据返回值来进行额外操作
            if (skipError === true) {
              console.log(res);
              successCallbackFunc?.(res);
            }
          },
        });
      };
    },
    [updateTableList]
  );

使用方法

注意 这里加了skipError属性,目的是为了回调函数中是否可以拿到对应操作接口的返回值,从而可以进行二次定制,例如删除成功或失败做对应的弹窗提示。

最后将Table需要的属性进行统一集合

tsx 复制代码
 const tableProps = useMemo(() => {
    const { height } = nonTableMsg || { height: 0 };

    // 计算表格高度 154为表格上方的高度 47为分页器高度 132为表格下方的高度
    const tableHeight = windowHeight - 154 - height - 47 - 132;

    return {
      ...defaultInitPostData,
      loading,
      pagination, // 分页器信息
      dataSource: listData.list || listData.records || listData.items || [],
      scroll: { y: tableHeight }, // 表格滚动高度
    };
  }, [
    nonTableMsg,
    windowHeight,
    loading,
    pagination,
    listData.list,
    listData.records,
    listData.items,
  ]);

hooks对外抛出对应的数据和方法

tsx 复制代码
export function useGetTableList(url:string, params: Params) {
  // ...省略
  return {
    updateTableList, // 更新table数据
    searchTableList, // 搜索table数据
    tableProps, // 表格参数
    listData, // 表格参数 包含loading dateSource pagination scroll等
    nonTableRef, // 给外层容器的ref 控制表格高度
    setListData, // 设置表格数据
    createChangeStatusConfirmFunc, // 创建确认弹窗函数
  }
}

相比于未封装的table 少了很多html及请求,简洁提升upup!

封装 - DetailModal

Modal部分

由Modal和Form组成

将ref提升,至顶层引用的组件,组件内可暴露出方法来

目的是点击编辑按钮时,record数据可以给Form组件进行回显

其次是传递 useGetTableList 中 updateTableList 用于给run获取最新table数据

tsx 复制代码
export interface DetailModalRefProps {
  toggle?: (record?: any) => Promise<void>;
}
const {
    updateTableList,
     ..其他方法忽略
  } = useGetTableList('/businessLine/list', {
    initPostData: { page: 1, pageSize: 10 },
  });

const modalRef = useRef<DetailModalRefProps>(null)

<DetailModal ref={modalRef} onSaveCallback={updateTableList} />

定义 DetailModal props

这里 modalProps 通过 hooks统一处理逻辑

detail 是 编辑时回显给Form的数据

tsx 复制代码
const DetailModal = forwardRef<DetailModalRefProps, DetailModalProps>(
  (props, ref) => {
    return (
      <>
        <Modal {...modalProps}>
          <ContentForm value={detail} ref={formRef} />
        </Modal>
      </>
    );
  },
);

下一步就是要控制Modal和Form的联动关系

传入给useInitModal的参数有三个

  • detailModalRefProps 也就是 updateTableList更新table
  • fetchDetailFunc 回显获取详情的方式
  • onSaveFunc 保存的方法

返回了

  • toggle 暴露给 使用 DetailModal 的父组件方法
  • detail ContentForm组件回显需要的详情
  • modalProps Modal配置
  • formRef ContentForm组件实例

看看是内部是怎么做的

tsx 复制代码
const DetailModal = forwardRef<DetailModalRefProps, DetailModalProps>(
  (props, ref) => {
    const { toggle, detail, modalProps, formRef } = useInitModal({
      detailModalRefProps: props,
      fetchDetailFunc: async (record: Record<string, any>) => {
        return { ret: 0, data: record };
      },
      onSaveFunc: saveBusinessLine,
    });
    useImperativeHandle(ref, () => ({
      toggle,
    }));
    return (
      <>
        <Modal {...modalProps}>
          <ContentForm value={detail} ref={formRef} />
        </Modal>
      </>
    );
  },
);

export default DetailModal;

定义props

接口返回的数据格式

保存

传递的组件Ref 用于 useImperativeHandle

tsx 复制代码
export interface InitModalProps {
  fetchDetailFunc?: (args: any) => Promise<{ ret: number; data: any }>;
  onSaveFunc?: (values: any) => Promise<any>;
  detailModalRefProps?: DetailModalProps;
}

用useState存储modal开关、loading、给Form的详情、接口所有的返回值。 ref存储

tsx 复制代码
export function useInitModal(props: InitModalProps) {
  
  const { fetchDetailFunc, onSaveFunc, detailModalRefProps } = props;
  
  const [confirmLoading, setConfirmLoading] = useState<boolean>(false);
  
  const [visible, setVisible] = useState<boolean>(false);
  
  const [detail, setDetail] = useState<Record<string, any>>();
  
  const formRef = useRef<{ form: FormInstance }>(null);
  
  const [result, setResult] = useState<Record<string, any>>();

}

切换表单打开状态,给表单赋值或清空

tsx 复制代码
const toggle = useCallback(
  async (record?: any) => {
    if (!visible) {
      // 只有打开弹窗时,才会请求回显
      if (record && fetchDetailFunc) {
        const { ret, data } = await fetchDetailFunc(record);
        if (ret === 0) {
          setDetail(data);
        }
      }
    } else {
      setDetail(undefined);
      setResult(undefined);
    }
    setVisible((v) => !v);
  },
  [fetchDetailFunc, visible],
);

这里toggle可以获取record的原因是

table中点击时传入这一行数据,所以在DetailModal中就可以获取到,或者也可以id请求详情后的数据

如果是新建数据那么就不需要record,不给值就可以了

提交表单要做的事情:

校验、loading、发请求、updatetable数据 关闭弹窗

这里需要注意 如果请求失败就不关闭弹窗,所以将toggle放在最后的逻辑中执行

onSaveFunc 是 传递过来获取最新table数据的方法

tsx 复制代码
const { fetchDetailFunc, onSaveFunc, detailModalRefProps } = props;

const onSave = useCallback(async () => {
    try {
      setConfirmLoading(true);
      const values = await formRef.current?.form?.validateFields?.();
      if (onSaveFunc) {
        const res = await onSaveFunc(values);

        if (res?.ret === 0) {
          message.success('操作完成');
        }
        setResult(res);
        // 关闭后重新请求列表
        detailModalRefProps?.onSaveCallback?.();
        detailModalRefProps?.onSaveCallbackWithValue?.(values);
        // 兼容导入,如果有错误信息,则不关闭
        if (res?.ret === 0 && res?.data?.errorMsg) {
          return;
        }
      }

      toggle();
    } finally {
      setConfirmLoading(false);
    }
  }, [detailModalRefProps, onSaveFunc, toggle]);

最后修改一下title 这里也可以将 编辑 名称 props传递进去

tsx 复制代码
const title = useMeno(()=> detail?.id ? `${props.editTitle} ?? 编辑` : '新建' )

最后将属性和方法返回

tsx 复制代码
  return {
    modalProps: {
      open: visible,
      confirmLoading,
      onCancel: () => toggle(),
      onOk: onSave,
      title,
      destroyOnClose: true,
      width: 550,
      maskClosable: false,
    },
    toggle, 
    detail,
    formRef,
    confirmLoading,
    result,
  };

ContentForm部分

编辑回显数据有了 控制弹窗也有了,这里就需要将useInitModal中的数据传递给Form

ContentForm

这里快速过一下 主要是定义 itemList 上面已介绍过 可通过HOC、对象配置、antd原生方法三种来个性化定义

tsx 复制代码
const ContentForm = forwardRef(
  (props: { value?: Record<string, any> }, ref) => {
    const { value } = props;
    const [form] = Form.useForm();
    const itemList: JsonFormItemProps[] = [
      {
        label: '业务线id',
        name: 'id',
        type: 'input',
        itemProps: {
          disabled: value?.id,
        },
        destroyed: !value?.id,
      },
      {
        label: '业务线名称',
        name: 'name',
        type: 'input',
        rules: [{ required: true }],
      },
    ];
    useImperativeHandle(ref, () => ({
      form,
    }));
    return (
      <BasicForm
        wrapperCol={{ span: 24 }}
        value={value}
        form={form}
        itemList={itemList}
      />
    );
  },
);

BasicForm组件就是仅用来展示

renderItemList 方法上面也已介绍过,复用即可

tsx 复制代码
const BasicForm: React.FC<BasicFormProps> = (props) => {
  const { itemList, value, form } = props;
  const formProps = useModifyProps(props, ['itemList']);

  // initvalue
  useEffect(() => {
    form?.setFieldsValue(value);
  }, [form, value]);

  return (
    <Form {...defaultProps} {...formProps}>
      {renderItemList(itemList)}
    </Form>
  );
};

export default BasicForm;

总结

一个页面的 表单搜索 + 表格 curd功能的入口文件

优点:

  • 无脑CV

缺点:

  • CV键损坏率提升50%
相关推荐
Front思6 分钟前
根据输入的详细地址解析经纬度
前端·javascript
光影少年7 分钟前
前端文件上传组件流程的封装
前端·reactjs
纳尼亚awsl8 分钟前
css实现边框双色凹凸半圆
前端·css
前端郭德纲9 分钟前
一些CSS的基础知识点
前端·css
zqwang88810 分钟前
Performance API 实现前端资源监控
前端·javascript
HC1825808583214 分钟前
零基础学西班牙语,柯桥专业小语种培训泓畅学校
前端·javascript·vue.js
图扑软件14 分钟前
掌控物体运动艺术:图扑 Easing 函数实践应用
大数据·前端·javascript·人工智能·信息可视化·智慧城市·可视化
奶糖 肥晨1 小时前
React 组件生命周期与 Hooks 简明指南
前端·javascript·react.js
鑫宝Code1 小时前
【React】React 18:新特性与重大更新解析
前端·react.js·前端框架
Star Universe1 小时前
【React系列六】—React学习历程的分享
前端·javascript·学习·react.js·es6