blog icon indicating copy to clipboard operation
blog copied to clipboard

React 弹窗管理优化的思考

Open mengxiong10 opened this issue 5 years ago • 3 comments

常见写法

将弹窗组件嵌套在调用的组件下面, 弹窗组件的显示隐藏由父组件控制.


function Dialog({ visible, onCancel, id}) {
  return (
    <Modal visible={visible} onCancel={onCancel}>
      我是弹窗组件{id}
    </Modal>
  )
}

function Parent() {
  const [visible, setVisible] = useState(false);
  const [currentId, setCurrentId] = useState(0);
  const open = () => {
     setVisible(true);
     setCurrentId(1);
  }

  return (
    <div>
      <Button onClick={open}>open</Button>
      <Dialog id={currentId} visible={visible} onCancel={() => setVisible(false)} />
    </div>
  )
}

遇到问题

  1. 如果一个弹窗组件有多个组件需要调用, 那么需要在每个组件下面都重复嵌套一次, 处理的逻辑基本相同重复.
  2. 性能思考: 因为弹窗组件的显示隐藏的逻辑是在父组件里面, 所以show和hide的时候父组件不可避免的重新render,如果父组件很重的话, 能不能不重新渲染, 当然可以通过useMemo将父组件下面的昂贵组件和visible的逻辑分开, 但是麻烦.

第一次优化

针对第一个问题, 因为hooks的出现, 直接将弹窗的显示和隐藏的逻辑抽象成为一个钩子函数.

import { useReducer, useCallback, Reducer } from 'react';

type ReducerState<T> = { visible: boolean } & T;

interface ReducerAction<T> {
  type: 'open' | 'close';
  payload?: Partial<T>;
}

function reducer<T>(state: ReducerState<T>, action: ReducerAction<T>) {
  const { type, payload } = action;
  switch (type) {
    case 'open':
      return { ...state, visible: true, ...payload };
    case 'close':
      return { ...state, visible: false, ...payload };
    default:
      throw new Error();
  }
}

export default function useDialog<T extends object>(initial: T) {
  const [state, dispatch] = useReducer<Reducer<ReducerState<T>, ReducerAction<T>>>(reducer, {
    visible: false,
    ...initial
  });
  const close = useCallback(
    (payload: Partial<T> = {}) => {
      dispatch({ type: 'close', payload });
    },
    [dispatch]
  );
  // 调用open的时间直接赋值其他参数过去.
  const open = useCallback(
    (payload: Partial<T> = {}) => {
      dispatch({ type: 'open', payload });
    },
    [dispatch]
  );
  return {
    state,
    close,
    open
  };
}

function Parent() {
  const { open, close, state } = useDialog({ id: 0 })
  return (
    <div>
      <Button onClick={() => open({id: 1})}>open</Button>
      <Dialog {...state} onCancel={close} />
    </div>
  )
}

上面写法只是解决了在一个组件下面调用的时候的简化.

第二次优化

可以将弹窗也看成一个页面, 和管理路由一样, 集中管理. 使用context和钩子实现. 通过useModal 钩子获取modal 关闭弹窗modal.hide(), 类似history.back() 打开弹窗modal.show(), 类似history.push()

import React, { useReducer, useContext, useMemo } from 'react';

export interface ModalManagerProps {
  children: React.ReactNode;
  modalComponentMap: {
    [prop: string]: React.ComponentType<any>;
  };
}

interface ModalState {
  modalType: string | number;
  modalProps: any;
}

type ModalAction =
  | {
      type: 'SHOW_MODAL';
      modalType: string | number;
      modalProps: any;
    }
  | {
      type: 'HIDE_MODAL';
    };

// state设计成数组, 可以同时显示多个弹窗. 按照栈的方式后进先关闭.
function reducer(state: ModalState[], action: ModalAction) {
  switch (action.type) {
    case 'SHOW_MODAL':
      return state.concat({ modalType: action.modalType, modalProps: action.modalProps });
    case 'HIDE_MODAL': {
      const newState = state.slice();
      newState.pop();
      return newState;
    }
    default:
      return state;
  }
}

const initialState: ModalState[] = [];

const ModalContext = React.createContext<{
  hide(): void;
  show<T>(modalType: string, modalProps?: T | undefined): void;
  dispatch: React.Dispatch<ModalAction>;
}>({} as any);


export const useModal = () => {
  return useContext(ModalContext);
};

function ModalManager({ children, modalComponentMap }: ModalManagerProps) {
  const [state, dispatch] = useReducer(reducer, initialState);

  const modal = useMemo(() => {
    return {
      hide() {
        dispatch({
          type: 'HIDE_MODAL' as 'HIDE_MODAL'
        });
      },
      show<T>(modalType: string, modalProps?: T) {
        dispatch({
          type: 'SHOW_MODAL' as 'SHOW_MODAL',
          modalType,
          modalProps
        });
      },
      dispatch
    };
  }, []);

  const renderModal = (modalDescription: ModalState) => {
    const { modalType, modalProps = {} } = modalDescription;
    const ModalComponent = modalComponentMap[modalType];
    if (!ModalComponent) {
      // eslint-disable-next-line no-console
      console.warn(`${modalType} is not in the modalComponentMap`);
      return null;
    }
    return <ModalComponent {...modalProps} key={modalType} />;
  };

  return (
    <ModalContext.Provider value={modal}>
      {children}
      {state.map(renderModal)}
    </ModalContext.Provider>
  );
}

export default ModalManager;


使用方法, 将ModalManager 放到根组件, 通过context传递modal到下面的组件调用.


const modalComponentMap = {
  dialog1: DialogComponent1,
  dialog2: DialogComponent2
}

const App = () => {
  return (
      <ModalManager modalComponentMap={modalComponentMap}>
         <Router history={history}>{renderRoutes(routes)}</Router>
      </ModalManager>
  )
}

调用方法

function Parent() {
  const modal = useModal();
  const open = () => {
   modal.show('dialog1', { id: 1 })
  }
  return (
    <div>
      <Button onClick={open}>open</Button>
    </div>
  )
}

function Dialog({ id}) {
  const modal = useModal();

  // 这里使用的antd的modal, visible要直接设置成true
  return (
    <Modal visible  onCancel={modal.close}>
      我是弹窗组件{id}
    </Modal>
  )
}

~~## 后续可改进~~ ~~现在的弹窗关闭之后都是直接销毁, 使用antd的modal,调用关系之后关闭的动画可能会没有了. 因为是直接连同modal组件一起销毁, 组件的状态都是不能保存, 重新调用之后重新渲染. 后续应该可以通过设置prop控制直接销毁还是不显示.~~

第三次优化

通过ModalStore组件内部直接代理管理visible和onClose方法, 这样也可以直接控制所有的modal是否直接销毁或者是隐藏. 完整代码: https://github.com/mengxiong10/react-modal-store

mengxiong10 avatar Jun 13 '19 02:06 mengxiong10

大佬优化思路很好不过,请问对于父组件弹窗很重导致性能问题这种前提有具体的测试过或者深入了解过嘛,对这一点有些疑惑

Riguangzhixia avatar Sep 19 '20 10:09 Riguangzhixia

@PENGFEI-CN 对的, 上面的方式是每次都销毁组件, 可以设计成不销毁弹窗组件(只是通过注入属性隐藏和显示), 其实实际中就是这么做的, 有时间我会更新一下..

mengxiong10 avatar Sep 21 '20 06:09 mengxiong10

@mengxiong10 老哥写的很好啊, 等后续...

zjhjszwx avatar Oct 29 '20 08:10 zjhjszwx