如何在react中优雅的使用全局modal?

引言

在 react 中使用全局 Modal 一直是一件比较麻烦的事情。一般我们都是使用 JSX 标签定义Modal组件。

但接下来的问题是,"我们应该在哪里声明标签?"

  1. 最常见的选择是在使用 Modal 的任何地方编写 Modal 标签。

    但以声明方式使用 Modal 组件不仅仅涉及 JSX 标签,还涉及维护Modal的状态,例如 Modal 的可见性、参数等。 每一次编写组件标签就意味着需要可能要多管理一次组件的状态。这是一件比较麻烦的事情。

  2. 另一种选择是将 Modal 提升到公共的父组件上,但是通过父组件维护Modal依旧是非常麻烦。

    比如你需要将 setOpen 传递到需要显示或隐藏 Modal 的子组件中,谁知道会有多少层?

react-nice-modal

介绍

这是一个用于以自然的方式为 React 管理 Modal 组件。 它使用 react context 来全局保存 Modal 状态,以便可以通过 Modal 组件或 id 轻松显示/隐藏 Modal。

基本用法

  1. 使用 Provider 包裹组件
tsx 复制代码
import NiceModal from '@ebay/nice-modal-react';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <NiceModal.Provider>
      <App />
    </NiceModal.Provider>
  </React.StrictMode>
);
  1. 创建一个 Nice Modal
tsx 复制代码
import { Modal } from 'antd';
import NiceModal, { useModal } from '@ebay/nice-modal-react';

export default NiceModal.create(({ name }) => {
  // Use a hook to manage the modal state
  const modal = useModal();
  return (
    <Modal
      title="Hello Antd"
      onOk={() => modal.hide()}
      visible={modal.visible}
      onCancel={() => modal.hide()}
      afterClose={() => modal.remove()}
    >
      Hello {name}!
    </Modal>
  );
});
  1. 然后就可以通过如下代码在任意组件显示/隐藏 Modal 组件
tsx 复制代码
import NiceModal from '@ebay/nice-modal-react';
import MyAntdModal from './my-antd-modal'; // created by above code

function App() {
  // 或者
  // const modal = useModal(MyAntdModal);
  
  const showAntdModal = () => {
    // Show a modal with arguments passed to the component as props
    NiceModal.show(MyAntdModal, { name: 'Nate' })
    
    // 或者
    // modal.show({ name: 'Nate1' })
  };
  
  return (
    <div className="app">
      <h1>Nice Modal Examples</h1>
      <div className="demo-buttons">
        <button onClick={showAntdModal}>Antd Modal</button>
      </div>
    </div>
  );
}

经过 NiceModal 的封装,好处显而易见:

  1. 调用过程干净优雅
  2. 组件依旧存在于上下文中(可以自定义位置,默认在 Provider 下)
  3. show/hide 方法返回值为 Promise,方便链式调用(通过useModal().resolve(val)等方法改变 Promise 状态)

更多用法参考 NiceModal 官方文档

源码分析

  1. 先来看几个数据结构:
ts 复制代码
export interface NiceModalState {
  id: string;
  // Modal Props,可通过 show()方法或者组件标签传入
  args?: Record<string, unknown>;
  visible?: boolean;
  delayVisible?: boolean;
  keepMounted?: boolean;
}

// context,使用useReducer创建
export interface NiceModalStore {
  [key: string]: NiceModalState;
}

// show()/hide()方法返回的promise及相应的变更方法
interface NiceModalCallbacks {
  [modalId: string]: {
    resolve: (args: unknown) => void;
    reject: (args: unknown) => void;
    promise: Promise<unknown>;
  };
}

// 所有已注册的Modal组件和props
const MODAL_REGISTRY: {
  [id: string]: {
    comp: React.FC<any>;
    props?: Record<string, unknown>;
  };
} = {};

const ALREADY_MOUNTED = {}; // Record<string, boolean>

// 修改context的操作函数
let dispatch: React.Dispatch<NiceModalAction>
  1. register / unregister 比较简单,注册和清除 Modal 组件
ts 复制代码
export const register = <T extends React.FC<any>>(
  id: string,
  comp: T,
  props?: Partial<NiceModalArgs<T>>,
): void => {
  if (!MODAL_REGISTRY[id]) {
    MODAL_REGISTRY[id] = { comp, props };
  } else {
    MODAL_REGISTRY[id].props = props;
  }
};

export const unregister = (id: string): void => {
  delete MODAL_REGISTRY[id];
};
  1. Provider
tsx 复制代码
const InnerContextProvider: React.FC = ({ children }) => {
  const arr = useReducer(reducer, initialState);
  const modals = arr[0];
  dispatch = arr[1];
  return (
    <NiceModalContext.Provider value={modals}>
      {children}
      <NiceModalPlaceholder />{/* 该占位组件使用 modals 去创建组件 */}
    </NiceModalContext.Provider>
  );
};

// The placeholder component is used to auto render modals when call modal.show()
// When modal.show() is called, it means there've been modal info
const NiceModalPlaceholder: React.FC = () => {
  const modals = useContext(NiceModalContext);
  const visibleModalIds = Object.keys(modals).filter((id) => !!modals[id]);
 
  const toRender = visibleModalIds
    .filter((id) => MODAL_REGISTRY[id])
    .map((id) => ({
      id,
      ...MODAL_REGISTRY[id],
    }));
    
  // 所有渲染的Modal都是从context中获取的,渲染的是create方法返回的组件
  // props是从MODAL_REGISTRY获取的,MODAL_REGISTRY仅仅通过register方法修改
  // 而register方法虽然提供了修改props的方式,但是NiceModal内部仅在初次注册的时候添加props

  return (
    <>
      {toRender.map((t) => (
        <t.comp key={t.id} id={t.id} {...t.props} />
      ))}
    </>
  );
};
  1. create 方法
tsx 复制代码
export const create = <P extends {}>(
  Comp: React.ComponentType<P>,
): React.FC<P & NiceModalHocProps> => {
  return ({ defaultVisible, keepMounted, id, ...props }) => {
    // 这里的id是怎么来的?
    // NiceModalPlaceholder里面传入的 **这种方式有个坑,先注意下**
    // 或者是你通过使用jsx标签时传入
    const { args, show } = useModal(id);

    // If there's modal state, then should mount it.
    const modals = useContext(NiceModalContext);
    const shouldMount = !!modals[id];

    useEffect(() => {
      // If defaultVisible, show it after mounted.
      if (defaultVisible) {
        show();
      }

      ALREADY_MOUNTED[id] = true;

      return () => {
        delete ALREADY_MOUNTED[id];
      };
    }, [id, show, defaultVisible]);

    useEffect(() => {
      if (keepMounted) setFlags(id, { keepMounted: true });
    }, [id, keepMounted]);

    const delayVisible = modals[id]?.delayVisible;
    // If modal.show is called
    //  1. If modal was mounted, should make it visible directly
    //  2. If modal has not been mounted, should mount it first, then make it visible
    // 注意这里的mount是指修改ALREADY_MOUNTED
    useEffect(() => {
      if (delayVisible) {
        // delayVisible: false => true, it means the modal.show() is called, should show it.
        show(args);
      }
    }, [delayVisible, args, show]);

    if (!shouldMount) return null;
    return (
      {/* 通过NiceModalIdContext传递Modal id,在Comp组件的useModal中使用 */}
      <NiceModalIdContext.Provider value={id}>
        {/* 透传props和useModal及show传入的args */}
        <Comp {...(props as P)} {...args} />
      </NiceModalIdContext.Provider>
    );
  };
};

jsx的方式使用create返回的组件

  1. useModal
typescript 复制代码
export function useModal(): NiceModalHandler;
export function useModal(modal: string, args?: Record<string, unknown>): NiceModalHandler;
export function useModal<C extends any, P extends Partial<NiceModalArgs<React.FC<C>>>>(
  modal: React.FC<C>,
  args?: P,
): Omit<NiceModalHandler, 'show'> & {
  show: (args?: P) => Promise<unknown>;
};
export function useModal(modal?: any, args?: any): any {
  const modals = useContext(NiceModalContext);
  const contextModalId = useContext(NiceModalIdContext);
  let modalId: string | null = null;
  const isUseComponent = modal && typeof modal !== 'string';
  if (!modal) {
    modalId = contextModalId;
  } else {
    // getModalId 会根据你给的类型,如果是 string, 那么直接返回,
    // 如果是 modal,那么会帮你生成一个 id
    modalId = getModalId(modal);
  }

  // Only if contextModalId doesn't exist
  if (!modalId) throw new Error('No modal id found in NiceModal.useModal.');

  const mid = modalId as string;
  // If use a component directly, register it.
  useEffect(() => {
    if (isUseComponent && !MODAL_REGISTRY[mid]) {
      // 注意,这里向MODAL_REGISTRY注册了Modal,同时传入了args
      register(mid, modal as React.FC, args);
    }
  }, [isUseComponent, mid, modal, args]);

  const modalInfo = modals[mid];

  const showCallback = useCallback((args?: Record<string, unknown>) => show(mid, args), [mid]);
  const hideCallback = useCallback(() => hide(mid), [mid]);
  const removeCallback = useCallback(() => remove(mid), [mid]);
  const resolveCallback = useCallback(
    (args?: unknown) => {
      modalCallbacks[mid]?.resolve(args);
      delete modalCallbacks[mid];
    },
    [mid],
  );
  const rejectCallback = useCallback(
    (args?: unknown) => {
      modalCallbacks[mid]?.reject(args);
      delete modalCallbacks[mid];
    },
    [mid],
  );
  const resolveHide = useCallback(
    (args?: unknown) => {
      hideModalCallbacks[mid]?.resolve(args);
      delete hideModalCallbacks[mid];
    },
    [mid],
  );

  return useMemo(
    () => ({
      id: mid,
      // 这个args是从context中获取
      args: modalInfo?.args,
      visible: !!modalInfo?.visible,
      keepMounted: !!modalInfo?.keepMounted,
      show: showCallback,
      hide: hideCallback,
      remove: removeCallback,
      resolve: resolveCallback,
      reject: rejectCallback,
      resolveHide,
    }),
    [
      mid,
      modalInfo?.args,
      modalInfo?.visible,
      modalInfo?.keepMounted,
      showCallback,
      hideCallback,
      removeCallback,
      resolveCallback,
      rejectCallback,
      resolveHide,
    ],
  );
}
  1. show/hide方法,比较简单,通过reducer更新context,同时保存和清除show/hide方法返回值及变更方法
ts 复制代码
export function show<T extends any, C extends any, P extends Partial<NiceModalArgs<React.FC<C>>>>(
  modal: React.FC<C>,
  args?: P,
): Promise<T>;
export function show<T extends any>(modal: string, args?: Record<string, unknown>): Promise<T>;
export function show<T extends any, P extends any>(modal: string, args: P): Promise<T>;
export function show(
  modal: React.FC<any> | string,
  args?: NiceModalArgs<React.FC<any>> | Record<string, unknown>,
) {
  const modalId = getModalId(modal);
  if (typeof modal !== 'string' && !MODAL_REGISTRY[modalId]) {
    register(modalId, modal as React.FC);
  }

  // 通过reducer更新context
  dispatch(showModal(modalId, args));
  if (!modalCallbacks[modalId]) {
    // `!` tell ts that theResolve will be written before it is used
    let theResolve!: (args?: unknown) => void;
    let theReject!: (args?: unknown) => void;
    const promise = new Promise((resolve, reject) => {
      theResolve = resolve;
      theReject = reject;
    });
    modalCallbacks[modalId] = {
      resolve: theResolve,
      reject: theReject,
      promise,
    };
  }
  return modalCallbacks[modalId].promise;
}

export function hide<T>(modal: string | React.FC<any>): Promise<T>;
export function hide(modal: string | React.FC<any>) {
  const modalId = getModalId(modal);
  dispatch(hideModal(modalId));
  // Should also delete the callback for modal.resolve
  delete modalCallbacks[modalId];
  if (!hideModalCallbacks[modalId]) {
    let theResolve!: (args?: unknown) => void;
    let theReject!: (args?: unknown) => void;
    const promise = new Promise((resolve, reject) => {
      theResolve = resolve;
      theReject = reject;
    });
    hideModalCallbacks[modalId] = {
      resolve: theResolve,
      reject: theReject,
      promise,
    };
  }
  return hideModalCallbacks[modalId].promise;
}

主要的源码就是这些,其中我觉得比较复杂的地方是 Modal 组件的 props 的控制

有四种形式给 Modal 组件提供props

  1. 通过show方法
  2. 通过jsx的方式使用create返回的组件
  3. 通过ModalHolder组件
  4. 通过useModal
tsx 复制代码
export const ModalHolder: React.FC<Record<string, unknown>> = ({
  modal,
  handler = {},
  ...restProps
}: {
  modal: string | React.FC<any>;
  handler: any;
  [key: string]: any;
}) => {
  const mid = useMemo(() => getUid(), []);
  const ModalComp = typeof modal === 'string' ? MODAL_REGISTRY[modal]?.comp : modal; 
  handler.show = useCallback((args: any) => show(mid, args), [mid]);
  handler.hide = useCallback(() => hide(mid), [mid]);
  return <ModalComp id={mid} {...restProps} />;
};

记得前面说的坑吗?跟使用useModal传递props有关

ts 复制代码
const modal = useModal(MyAntdModal, { name: 'aaaa' });
// 然后,通过modal.show()就可以渲染出之前的MyAntdModal
 modal.show()

但是,如果需要传递的 props 中有 id,那么,你会发现 Modal 无法打开了,为什么呢?

ini 复制代码
const modal = useModal(MyAntdModal, { name: 'aaaa', id: 'aaa' });

原因在 useModal hook 中

ts 复制代码
  useEffect(() => {
    if (isUseComponent && !MODAL_REGISTRY[mid]) {
      // 注意,这里向MODAL_REGISTRY注册了Modal,同时传入了args
      register(mid, modal as React.FC, args);
    }
  }, [isUseComponent, mid, modal, args]);

那么在 NiceModalPlaceholder 中渲染 Modal 时,t.props 将 id 覆盖了,就会导致 t.comp 里面的 useModal 无法获得 Modal 组件 (id 不对)

ts 复制代码
// ...
const toRender = visibleModalIds
    .filter((id) => MODAL_REGISTRY[id])
    .map((id) => ({
      id,
      ...MODAL_REGISTRY[id],
    }));

  return (
    <>
      {toRender.map((t) => (
        <t.comp key={t.id} id={t.id} {...t.props} />
      ))}
    </>
  );

那为什么通过 show 方法传入的 props 可以有 id 属性呢?

不管是通过 modal.show 还是 NiceModal.show 调用,最终调用的都是 NiceModal.show(ModalOrId, args),没有涉及获取 Modal

总结

学习了 NiceModal 的实现原理, 有的时候,跳出固有的思维逻辑,可能会发现新的天地

下面说下大概流程操作。

  1. Provide 挂载,内部创建 dispatch
  2. create 创建 Modal 组件
  3. register(注册 id)
  4. 页面中使用 NiceModal.show(id)调用
  5. 使用 dispatch 修改该 id 的属性
  6. dispatch 通知 Provider 内部的组件渲染
相关推荐
zqx_718 小时前
随记 前端框架React的初步认识
前端·react.js·前端框架
TonyH20022 天前
webpack 4 的 30 个步骤构建 react 开发环境
前端·css·react.js·webpack·postcss·打包
掘金泥石流2 天前
React v19 的 React Complier 是如何优化 React 组件的,看 AI 是如何回答的
javascript·人工智能·react.js
lucifer3112 天前
深入解析 React 组件封装 —— 从业务需求到性能优化
前端·react.js
秃头女孩y2 天前
React基础-快速梳理
前端·react.js·前端框架
sophie旭2 天前
我要拿捏 react 系列二: React 架构设计
javascript·react.js·前端框架
BHDDGT2 天前
react-问卷星项目(5)
前端·javascript·react.js
liangshanbo12152 天前
将 Intersection Observer 与自定义 React Hook 结合使用
前端·react.js·前端框架
黄毛火烧雪下3 天前
React返回上一个页面,会重新挂载吗
前端·javascript·react.js