如何在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 内部的组件渲染
相关推荐
Hilaku37 分钟前
我为什么觉得 React 正在逐渐失去吸引力?
前端·react.js·前端框架
架构个驾驾43 分钟前
React 18 核心特性详解:Props、State 与 Context 的深度实践
react.js·前端框架
itslife43 分钟前
实现 render 函数 - 初始化更新队列
前端·react.js·前端框架
WildBlue1 小时前
🚀 React组件化实战:用TodoList项目搭乐高式开发!🎉
前端·react.js
wen's1 小时前
React Native 弹窗组件优化实战:解决 Modal 闪烁与动画卡顿问题
javascript·react native·react.js
今禾1 小时前
从零开始学React组件化开发:构建一个简单的TodoList应用
javascript·react.js
菥菥爱嘻嘻1 小时前
React---day11
前端·react.js·前端框架
前端双越老师2 小时前
学不动了?没事,前端娱乐圈也更新不动了
javascript·react.js·ai编程
葬送的代码人生19 小时前
React组件化哲学:如何优雅地"变秃也变强"
前端·javascript·react.js
小马虎本人19 小时前
如果接口返回的数据特别慢?要怎么办?难道就要在当前页面一直等吗
前端·react.js·aigc