本篇文章围绕着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%