封装自定义hook - useLayer - 2. 适配UI库

上一篇文章,我们使用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.xvisible
antd 5.xopen

antd Modal 和 Drawer组件,所需要的关闭回调事件也不一致:

组件关闭回调事件
antd ModalonCancel
antd DraweronClose

如果我们直接注入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),这两种适配都可以通过传入映射关系去解决。

5. 源码+运行环境

存放在CodeSandBox环境