在上一篇文章,我们使用antd5作为示例,简单实现一个useLayer hook用于管理弹层状态。
但实际项目开发,我们未必会使用antd,可能是其他的UI库,这个时候需要考虑如何适配UI库。
1. 分析
实现前,我们先分析并回顾一下,整体的流程:
传入Schemas -> hook内部维护openKeys状态 -> 根据openKeys状态生成对应的Views
hook内部提供Actions用于改变openKeys状态 -> 生成新的View
View这一层通过state去更新,更新的实现大致是这样的:
import { Modal, Drawer } from "antd";
// 我们维护了type以及UI组件的映射关系
const layerMap = { Modal, Drawer };
//根据打开的弹层key,生成视图
const views = useMemo(() => {
return openKeys.reduce(
(total, current) => {
const schema = schemas.find((item) => item.key === current);
if (schema) {
const Layer: any = layerMap[schema.type];
const Children = schema.children;
const layerProps = schema.props;
const childrenProps = schema.childrenProps;
// 额外注入的props,用于开启弹层
const injectProps = {
key: current,
open: true,
// 暂时注入onCancel和onClose,用于关闭弹层
onCancel: () => {
actions.close(current);
},
onClose: () => {
actions.close(current);
},
};
const view = (
<Layer {...layerProps} {...injectProps}>
{Children && <Children {...childrenProps} />}
</Layer>
);
total[schema.type === "Modal" ? "modalView" : "drawerView"].push(
view
);
}
return total;
},
{ modalView: [], drawerView: [] } as Views
);
}, [schemas, openKeys]);
我们可以看到,根据具体的Schema的type去获取对应的映射关系,拿对应的组件去渲染。同时,为弹层以及弹层的子组件去注入props。这就是适配的关键。
适配大致上需要做两步:
支持传入自定义的layerMap,让开发者指定useLayer运行,使用什么UI组件作为弹层
支持自定义注入props,避免注入的props无法适配UI组件
我们可以先约定layerMap的类型:
import { type ModalProps, type DrawerProps } from "antd";
import { type ReactNode, type ComponentType } from "react";
/**弹层的类型,弹窗或者抽屉 */
export type LayerType = "Modal" | "Drawer";
/**弹层映射类型
* @example
* import {Modal, Drawer} from 'some-ui-lib
* const layerMap:LayerMap = { Modal, Drawer}
*/
export type LayerMap = Record<LayerType, ComponentType<any>>;
2. UI适配的实现
2.1 useLayer增加layerMap配置
运行useLayer的时候,我们可以传一个LayerMap类型的配置,选择我们本次需要使用的组件,对应的useLayer逻辑实现,具体看_layerMap这个变量,读取layerMap的配置,增加容错,读取失败则读取Fragment(具体可以根据实际的业务需要,把这部分容错调整为你正在使用的UI库,比如:读取不到则默认使用antd)
import { useState, useMemo, Fragment } from "react";
import {
type Schemas,
type Views,
type Actions,
type LayerMap,
} from "./typing";
export function useLayer(schemas: Schemas, layerMap: Partial<LayerMap> = {}) {
const [openKeys, setOpenKeys] = useState<string[]>([]); // 打开的弹层,对应的key
const _layerMap = useMemo(() => {
return {
Modal: layerMap.Modal || Fragment,
Drawer: layerMap.Drawer || Fragment,
};
}, [layerMap]);
// 开关方法
const actions: Actions = useMemo(() => {
return {
close: (layerKey: string) => {
setOpenKeys((prev) => prev.filter((item) => item !== layerKey));
},
closeAll: () => {
setOpenKeys([]);
},
open: (layerKey: string) => {
setOpenKeys((prev) => [...prev, layerKey]);
},
};
}, []);
//根据打开的弹层key,生成视图
const views = useMemo(() => {
return openKeys.reduce(
(total, current) => {
const schema = schemas.find((item) => item.key === current);
if (schema) {
const Layer: any = _layerMap[schema.type];
const Children = schema.children;
const layerProps = schema.props;
const childrenProps = schema.childrenProps;
// 额外注入的props,用于开启弹层
const injectProps = {
key: current,
open: true,
// 暂时注入onCancel和onClose,用于关闭弹层
onCancel: () => {
actions.close(current);
},
onClose: () => {
actions.close(current);
},
};
const view = (
<Layer {...layerProps} {...injectProps}>
{Children && <Children {...childrenProps} />}
</Layer>
);
total[schema.type === "Modal" ? "modalView" : "drawerView"].push(
view
);
}
return total;
},
{ modalView: [], drawerView: [] } as Views
);
}, [schemas, openKeys]);
return {
actions,
views,
};
}
2.2 Provider组件的实现
hooks虽然增加layerMap配置,但其实不是最优化的实现方式,假设我们有50个页面需要使用useLayer,每一次需要声明layerMap其实不合理。
应该有一个统一的配置,我们借助React Context的特性,实现一个Provider组件(LayerProvider),将layerMap作为配置,作为上下文传递,也因此,我们实际使用的layerMap应该有优先级:
优先读取hook运行的layerMap参数配置
否则,读取Context的配置
否则,读取容错的配置 (Fragement或者你指定的UI组件)
先约定Context需要实现的类型:
/**
* 弹层上下文的Provider组件
* @example
* import {Modal,Drawer} from 'your-ui-lib';
* <LayerProvider Modal={Modal} Drawer={Drawer}>
* <App />
* </LayerProvider>
*/
export type LayerProviderProps = PropsWithChildren<LayerMap>;
实现LayerProvider,其实就是就是Context.Provider将value属性打平传入,这点是参考antd ConfigProvider组件
// src/context.tsx
import { createContext } from "react";
import { type LayerMap, type LayerProviderProps } from "./typing";
export const LayerContext = createContext<LayerMap>(null!);
export const LayerProvider = (props: LayerProviderProps) => {
const { children, ...restProps } = props;
return (
<LayerContext.Provider value={restProps}>{children}</LayerContext.Provider>
);
};
我们实现了LayerContext 以及 LayerProvider,可以去useLayer hook调整我们的实现:
// src/hook.tsx
export function useLayer(schemas: Schemas, layerMap: Partial<LayerMap> = {}) {
const [openKeys, setOpenKeys] = useState<string[]>([]); // 打开的弹层,对应的key
const context = useContext(LayerContext);
// 优先读取hook配置,其次读取Provider配置,否则读取容错
const _layerMap = useMemo(() => {
return {
Modal: layerMap.Modal || context.Modal || Fragment,
Drawer: layerMap.Drawer || context.Drawer || Fragment,
};
}, [layerMap]);
}
通过这种方式,我们可以在项目入口,调整实现,进行UI组件的适配:
比如:我们想适配tdesign组件,可以这样做:
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
// import { Modal, Drawer } from "antd";
import { LayerProvider } from "./src/context.tsx";
// 引入tdesign组件
import "tdesign-react/es/style/index.css";
import { Dialog, Drawer } from "tdesign-react";
ReactDOM.createRoot(document.getElementById("root")!).render(
<LayerProvider Modal={Dialog} Drawer={Drawer}>
<App />
</LayerProvider>
);
3.注入props的适配
因为各个弹层组件所需要的props其实可能不一样,我们以antd Modal组件为例:
版本 | 控制显示隐藏的props |
antd 4.x | visible |
antd 5.x | open |
antd Modal 和 Drawer组件,所需要的关闭回调事件也不一致:
组件 | 关闭回调事件 |
antd Modal | onCancel |
antd Drawer | onClose |
如果我们直接注入visible属性,可能会不兼容,所以我们可以增加一组映射,用来适配各个组件需要的props。
先回顾注入Layer props的实现:
// src/hook.tsx
//根据打开的弹层key,生成视图
const views = useMemo(() => {
return openKeys.reduce(
(total, current) => {
const schema = schemas.find((item) => item.key === current);
if (schema) {
const Layer: any = _layerMap[schema.type];
const Children = schema.children;
const layerProps = schema.props;
const childrenProps = schema.childrenProps;
// 额外注入的props,用于开启弹层
const injectProps = {
key: current,
open: true,
// 暂时注入onCancel和onClose,用于关闭弹层
onCancel: () => {
actions.close(current);
},
onClose: () => {
actions.close(current);
},
};
const view = (
<Layer {...layerProps} {...injectProps}>
{Children && <Children {...childrenProps} />}
</Layer>
);
total[schema.type === "Modal" ? "modalView" : "drawerView"].push(
view
);
}
return total;
},
{ modalView: [], drawerView: [] } as Views
);
}, [schemas, openKeys]);
我们可以增加配置,用于指定Modal类型以及Drawer类型需要转换哪些props。
同样的道理,Provider组件也可以拓展这个配置
3.1 类型约定
还是先定好ts类型的定义
我们约定默认注入的props为visible以及onClose
可以传入一个映射,用于转换
/**
* 弹层注入的默认props key
*/
export type LayerInjectPropsKeys = "visible" | "onClose";
/**
* 单个弹层的props映射
* @example
* const propsMap:LayerPropsMap = {visible: "open", onClose: "onCancel"}
*/
export type LayerPropsMap = Record<LayerInjectPropsKeys, string>;
/**
* 传入映射,用于props转换
* 转换后的props将注入到弹层组件
* @example
* const propsMap:LayerInjectPropsMap = {Modal:{visible: "open", onClose: "onCancel"},Drawer:{visible: "open", onClose: "onCancel"}}
*/
export type LayerInjectPropsMap = Partial<Record<LayerType,LayerPropsMap>>;
入口的使用方式:
// src/main.tsx
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { Modal, Drawer } from "antd";
import { LayerProvider } from "./src/context.tsx";
// layerMap:使用ant design组件,Dialog作为Modal组件,使用Drawer作为Drawer组件
// 约定注入到弹层的props Modal: visible转为 "open", onClose转为"onCancel",
//Drawer visible转为 "open", onClose转为"onClose"
ReactDOM.createRoot(document.getElementById("root")!).render(
<LayerProvider
layerMap={{ Modal, Drawer }}
propsMap={{
Drawer: {
visible: "open",
},
Modal: {
visible: "open",
onClose: "onCancel",
},
}}
>
<App />
</LayerProvider>
);
当然,hooks里面也可以传入参数进行转换:
所以我们对hooks的使用方式也做一些调整:
为了避免参数过多,我们可以将其他配置,作为options传入
由于我们的context格式有变动,对应的获取layerMap的逻辑也相应变动
import { useState, useMemo, Fragment, useContext } from "react";
import {
type Schemas,
type Views,
type Actions,
type LayerContextType,
} from "./typing";
import { LayerContext } from "./context";
export function useLayer(schemas: Schemas, options?: LayerContextType) {
}
const _layerMap = useMemo(() => {
return {
Modal: layerMap?.Modal || context.layerMap?.Modal || Fragment,
Drawer: layerMap?.Drawer || context.layerMap?.Drawer || Fragment,
};
}, [layerMap]);
我们到hooks文件去处理具体的转换逻辑:
优先读取options的propsMap,读取不到则读取context,否则读取默认
// 需要注入的props
const _propsMap = useMemo(() => {
return {
modalVisiblePropKey:
propsMap?.Modal?.visible ||
context.propsMap?.Modal?.visible ||
"visible",
modalOnClosePropKey:
propsMap?.Modal?.onClose ||
context.propsMap?.Modal?.onClose ||
"onClose",
drawerVisiblePropKey:
propsMap?.Drawer?.visible ||
context.propsMap?.Drawer?.visible ||
"visible",
drawerOnClosePropKey:
propsMap?.Drawer?.onClose ||
context.propsMap?.Drawer?.onClose ||
"onClose",
};
}, [propsMap, context]);
//根据打开的弹层key,生成视图
const views = useMemo(() => {
return openKeys.reduce(
(total, current) => {
const schema = schemas.find((item) => item.key === current);
if (schema) {
const Layer: any = _layerMap[schema.type];
const Children = schema.children;
const layerProps = schema.props;
const childrenProps = schema.childrenProps;
// 额外注入的props,用于开启弹层
const injectProps = {
key: current,
[schema.type === "Modal"
? _propsMap.modalVisiblePropKey
: _propsMap.drawerVisiblePropKey]: true,
[schema.type === "Modal"
? _propsMap.modalOnClosePropKey
: _propsMap.drawerOnClosePropKey]: () => {
actions.close(current);
},
};
const view = (
<Layer {...layerProps} {...injectProps}>
{Children && <Children {...childrenProps} />}
</Layer>
);
total[schema.type === "Modal" ? "modalView" : "drawerView"].push(
view
);
}
return total;
},
{ modalView: [], drawerView: [] } as Views
);
}, [schemas, openKeys, _propsMap]);
4.总结
我们通过React Context的上下文传递特性,配合useContext,可以实现全局的配置,避免hooks反复增加配置。
UI的适配,适配的是需要渲染的组件,以及对应的入参(props),这两种适配都可以通过传入映射关系去解决。