前言
在工作过程中经常会遇到开发一个具有新增、编辑、详情的页面,如果一个一个页面的编写会出现工作量大、难以维护等等问题。这里提供一种开发常见业务页面的方法。
流程
代码结构如下:
├──src
│ ├──pages // 页面
│ ├──ExamplePage
│ ├──components // 该页面专用组件
│ ├──Add // 新增页
│ ├──Edit // 编辑页
│ ├──Detail // 详情页
│ ├──hooks // 该页面用到的一些自定义hooks
│ ├──hoc // 该页面用到的一些自定义hoc
│ ├──constants.js // 该页面用到的一些常量
│ ├──context.js // createContext
│ ├──index.scss // 该页面用到的scss
1. 准备工作
定义插槽组件和供插槽消费的hoc。之后开发页面可以进行复用。
- 插槽组件本体:
import React, { forwardRef, useContext } from 'react';
import withSlot, { SlotElementContext } from './withSlot';
function Slot(props, ref) {
const { name, ...rest } = props;
const elementDict = useContext(SlotElementContext);
const targetElement = elementDict[name];
const { props: targetProps } = targetElement || {};
const targetChildren = targetProps?.children;
if (typeof targetChildren === 'function') {
return React.cloneElement(targetElement, {
...targetProps,
ref,
children: targetChildren({ ...rest }), // 占位组件 Slot 将Container 内部的状态 通过 render props 传出
});
}
return targetElement
? React.cloneElement(targetElement, { ...rest, ...targetProps, ref: targetElement.ref ?? ref })
: null;
}
export default forwardRef(Slot);
- withSlot
import React, { createContext, useMemo } from 'react';
export const SlotElementContext = createContext(null);
// NOTE: 收集插槽子元素,并注入到「SlotElementContext」当中,供「Slot」组件消费
const withSlot = (CustomComponent) => {
const Wrapper = (props) => {
const { children: originChildren } = props;
// 转换为数组,每个元素上均会带一个key属性
const [slotElementDic, restChildren, slotList] = useMemo(() => {
const [restChildren, slotList] = [[], []];
const slotElementDic = React.Children.toArray(originChildren)
.filter((childElement) => {
const { props: elementProps } = childElement;
if (!elementProps?.slot) {
restChildren.push(childElement);
} else {
slotList.push(elementProps?.slot);
}
return elementProps?.slot;
})
.reduce(
(acc, slotElement) => ({
...acc,
[slotElement.props.slot]: slotElement,
}),
{},
);
return [slotElementDic, restChildren, slotList];
}, [originChildren]);
return (
<SlotElementContext.Provider value={slotElementDic}>
<CustomComponent {...props} slotList={slotList}>
{restChildren}
</CustomComponent>
</SlotElementContext.Provider>
);
};
return Wrapper;
};
export default withSlot;
2. 定义页面的基本结构
假设现在有一个页面包含新增、编辑和详情,页面主体包含一个表单、一个表格和附件。 首先定义页面通用组件Container组件,用于包裹页面结构。
const Container = () => (
<div>
<Slot name="baseInfo" />
<Slot name="tableInfo" />
<Slot name="fileList" />
</div>
);
export default withSlot(Container);
然后是定义具体新增、编辑、详情页。这里给出新增页的页面结构,编辑和详情页类似。 新增页:
const Add = () => {
// somothing
return (
<Container>
<Form slot="baseInfo" />
<Table slot="tableInfo" />
<Upload slot="fileList" />
</Container>
);
};
export default Add;
3. 页面具体处理
现在页面的大致布局已经出来了,但是还缺少一些东西,比如Form表单的属性、Form表单的数据等等。这些我们还没进行处理。 这里通过自定义hooks来处理页面。
1. 全局数据存储和传递
React父子组件进行数据传递可以通过props和回调,还可以通过React提供的useContext来进行数据的传递,这里对useContext的使用进行一些封装,方便使用。
首先定义一个useStore用于定义一些数据。useStore的大致代码如下:
const useStore = () => {
// baseInfoRef将会通过provider传递给Form表单的ref属性
const baseInfoRef = useRef();
const tableRef = useRef();
const fileListRef = useRef();
const readonlyRef = useRef();
const [fileList, setFileList] = useState();
// getMethodsByRef用于获取表单的一些方法,包含set和get,用于之后取值和设置值,同样传出去
const baseInfoMethods = useMemo(() => getMethodsByRef(baseInfoRef), []);
return useMemo(
() => ({
baseInfoRef,
tableRef,
fileListRef,
readonlyRef,
fileList,
setFileList,
baseInfoMethods
}),
[
],
);
}
useStore作为生产者,包含了页面的一些数据和方法,需要给消费者(具体页面)使用,
通过react提供的creatContext和useContext来暴露给消费者使用。 这里定义一个hoc(withProvider)和hook(usePageContext),方便之后Add、Edit、Detail页面复用。
首先创建一个 Context
import { createContext } from 'react';
const TextContext = createContext({});
export default TextContext;
withProvider用于提供数据,withProvider代码如下:
import React from 'react';
import TextContext from '../context';
import { useStore } from '../hooks';
const Provider = ({ children }) => {
const store = useStore();
return <TextContext.Provider value={store}>{children}</TextContext.Provider>;
};
const withProvider = (Component) => (props) => (
<Provider>
<Component {...props} />
</Provider>
);
export default withProvider;
定义自定义hooks usePageContext,之后都在usePageContext中取数据。 usePageContext的代码如下:
import { useContext } from 'react';
import TextContext from '../context';
const usePageContext = () => useContext(TextContext);
export default usePageContext;
可以看到其实就是用到了createContext和useContext来进行数据的传递,不过进行了一些封装,方便之后的使用。
使用:将新增、编辑、详情页通过withProvider包一层,之后在其子组件、子页面中可以通过usePageContext直接取到相关数据。 使用示例:
const Add = () => {
// somothing
return (
<Container>
<Form slot="baseInfo" />
<Table slot="tableInfo" />
<Upload slot="fileList" />
</Container>
);
};
// 通过withProvider包一层
export default withProvider(Add);
Add页面下的子组件、hooks中均可以通过usePageContext来取到useStore中保存的值。
2. 页面的属性设置
现在已经处理好了如何存储一些临时数据和传递这些数据,但是页面中的组件还缺少关键的属性配置,比如表单的配置、表格的配置等等。
对表格、表单进行属性配置,这里采用自定义hooks的方式来注入属性。
定义一个usePageProps钩子,主要作用是设置页面的属性。代码如下:
const usePageProps = () => {
// 使用前面定义好的usePageContext来取值
const {
baseInfoRef,
tableRef,
fileListRef,
readonlyRef,
fileList,
setFileList,
baseInfoMethods
} = usePageContext();
const baseInfoProps = {
ref: baseInfoRef,
schema: SCHEMA(),
layout: 'horizontal',
};
const tableProps = {
ref: tableRef,
}
return {
baseInfoProps,
tableProps,
};
};
页面中通过usePageProps来获取页面的属性配置,再将具体的属性配置设置到对应的组件当中。
const Add = () => {
const { baseInfoProps, tableProps } = usePageProps();
return (
<Container>
<Form slot="baseInfo" {...baseInfoProps} />
<Table slot="tableInfo" {...tableProps} />
<Upload slot="fileList" />
</Container>
);
};
// 通过withProvider包一层
export default withProvider(Add);
3. 页面的数据设置
现在页面的数据传递、属性设置都有了,但是还缺少获取后台数据,设置后台数据到页面中的操作。
同样这里通过自定义hooks来设置后台获取的值到页面当中。
定义usePageRecord钩子,获取后台数据,代码如下:
const usePageRecord = (pageMode) => {
const { id, type } = history.location.query;
const location = useLocation();
const {
baseInfoRef,
tableRef,
fileListRef,
readonlyRef,
fileList,
setFileList,
baseInfoMethods
} = usePageContext();
// setData就是表单提供的set方法的封装
const { setData } = baseInfoMethods;
const setDefaultData = async (record) => {
if (record) {
// 在设值前可以进行一些转换
setData(record);
}
};
const updateRecord = async () => {
const { id, type } = location.query;
const record = await getRecord(id);
await setDefaultData(record);
};
useEffect(() => {
updateRecord();
}, [location]);
if (pageMode === 'add') {
// 新增页可以给一些初始值
setData();
}
};