你好,我是王沛。今天我们来聊聊如何在 React 中处理对话框。

对话框是前端应用中非常常用的一种界面模式,它们通常是应用中的一个独立窗口,用于展示信息或者输入信息。

但是在 React 中,使用对话框其实并不容易,主要原因在于两点:

一方面,对话框需要先在父组件中声明,才能在子组件中控制其是否显示。

比如说我们需要同时在布局的 header 和 sider 上用菜单去控制某个对话框是否显示,那么这个对话框就必须定义在根组件上。

另一方面,给对话框传递参数只能由 props 传入,这意味着所有的状态管理都需要在更高级别的组件上。而实际上呢,这个对话框的参数可能只在子组件中才会维护,这时我们就需要利用自定义事件将参数回传,非常麻烦。

案例导入:处理对话框的误区

为了方便你理解这两点,我给你举一个实际场景的例子,你就能明白为什么说在 React 中,常用的对话框是比较难处理的。比如说我们需要实现下面这个截图演示的功能:

在这个例子中,我们有一个左右布局的页面。左边栏有一个新建用户的按钮,右边是一个用户列表。点击新建用户的按钮,或者点击表格中的编辑按钮,都会显示同一个对话框。这个对话框根据是否传入用户数据作为参数,来决定是新建还是编辑用户。

这个页面的代码一般会用下面的 JSX 去实现:

<div className="main-layout">
  <Sider />
  <UserList />
</div>

可以看到,这里的主布局包含了Sider 和 UserList 两个同层级的组件。但是它们要使用同一个对话框以显示编辑用户。而我们都知道,在 React 中,所有的 UI 都是状态驱动,这意味着我们必须将对话框相关的状态,以及状态管理逻辑提升到父组件中去实现,也就是这里的 Layout 组件。

那么,一般会用类似下面的代码逻辑去实现:

function MainLayout() {
  const [modalVisible, setModalVisible] = useState(false);
  const [user, setUser] = useState(null);
  const showUserModal = (user) => {
    setModalVisible(true);
    setUser(user);
  }
  return (
    <div className="main-layout">
      <Sider onNewUser={showUserModal}/>
      <UserList onEditUser={user => showUserModal(user)}/>
      <UserInfoModal visible={modalVisible} user={user} />
    </div>
  );
}

在这段代码中,我们将 UserInfoModal 这个对话框组件定义在了父组件 Layout 中,通过 visible 控制其是否显示。然后再在 Sider 和 UserList 这两个组件中,用自定义事件来告知父组件,用户点击了某个按钮了,应该显示对话框。

这样的用法固然是可以正确工作的,也是我看到的大多数同学的常规写法。但这种写法其实隐含着如下两个问题。

第一,语义隔离不明确。MainLayout 这个组件应该只做布局的事情,而不应该有其他的业务逻辑。但是在这里,由于我们加入了用户信息处理的逻辑,就让本不相关的两块功能产生了依赖。

而且,如果要增加另外一个对话框,那意味着又要在 Layout 上增加新的业务逻辑了。这样的话,代码很快就会变得臃肿,且难以理解和维护。

第二,难以扩展。现在我们只是在 MainLayout 下面的两个组件共享了对话框,但是如果和MainLayout 同级的组件也要访问这个对话框呢?又或者, MainLayout 下面的某个深层级的孙子组件也要能显示同一个对话框呢?

这样处理的话就会非常麻烦。前者意味着代码需要重构,继续提升状态到父组件;后者意味着业务逻辑处理更复杂,需要通过层层的自定义事件回调来完成。

所以,按照 React 方式的做法,或者大多数教程上演示的对话框的用法,其实在实际项目中是会遇到上面所述的各种问题。而这些问题的本质就是,一个实现业务逻辑的 Modal 究竟应该在哪个组件中去声明?又该怎么和它进行交互呢?

接下来,我会和你分享在一个比较大型的项目中,如何用一个统一的方式去管理对话框,从而让对话框相关的业务逻辑能够更加模块化,以及和其他业务逻辑进行解耦

不过也特别说明一下,这种方式更多的是我个人经验的总结,并不一定是唯一的,或者说最佳的方式。所以如果你有任何疑问,欢迎在留言区和我交流讨论。

思路:使用全局状态管理所有对话框

要解决上面例子中演示的问题,我们可以先仔细思考下对话框这种 UI 模式的本质。

对话框在本质上,其实是独立于其他界面的一个窗口,用于完成一个独立的功能

如果从视觉角度出发,你会发现在使用对话框的时候,你完全不会关心它是从哪个具体的组件中弹出来的,而只会关心对框本身的内容。

比如说,一个设置用户选项的对话框,它可能是从顶部菜单中点出来的,也可能是在某个具体页面的按钮点出来的,但都自动显示了上下文相关的设置选项。

对话框的这样一个本质,就决定了在组件层级上,它其实是应该独立于各个组件之外的。虽然很可能在一开始这个对话框的实现和某个组件非常高的相关度,但是在整个应用的不断开发和演进过程中,是很可能不断变化的。

所以,在定义一个对话框的时候,其定位基本会等价于定义一个具有唯一 URL 路径的页面。只是前者由弹出层实现,后者是页面的切换。

对于页面级别的 UI 切换,我们很容易理解,就是定义全局的路由嘛。那么同样的,如果我们以同样的方式去思考对话框,其实就是将对话框全局化,然后通过一个全局的机制来管理这些对话框。

这个过程和页面 URL 的切换非常类似,那么我们就可以给每一个对话框定义一个全局唯一的 ID,然后通过这个 ID 去显示或者隐藏一个对话框,并且给它传递参数。

基于这样一个设想,我们就来尝试去设计一个 API 去做对话框的全局管理。假设我们将这个对话框的实现命名为 NiceModal,那么我们的目标就是能够用以下的方式去操作对话框:

// 通过 create API 创建一个对话框,主要为了能够全局的控制对话框的展现
const UserInfoModal = NiceModal.create(
  'user-info-modal',
  RealUserInfoModal
);

// 创建一个 useNiceModal 这样的 Hook,用于获取某个 id 的对话框的操作对象
const modal = useNiceModal('user-info-modal');
// 通过 modal.show 显示一个对话框,并能够给它传递参数
modal.show(args);
// 通过 modal.hide 关闭对话框
modal.hide();

可以看到,如果有这样的 API,那么无论在哪个层级的组件,只要知道某个 Modal 的 ID,那就都可以统一使用这些对话框,而不再需要考虑该在哪个层级的组件去定义了,使用起来会更加直观。

所以,通过上面的思考和验证,我们可以认为对话框这种模式的本质就是一个独立的窗口,它和一个拥有独立 URL 的页面在功能上和形式上都是极为类似的。这就意味着我们可以用和 URL 一样的方法去实现通用的对话框管理。

实现:创建 NiceModal 组件和相关 API

下面,我们就来看看如何去实现这样的一个 NiceModal 机制。为了让你比较好地理解实现的逻辑,我尽量通过代码注释的方式来解释实现思路和原理,所以你要仔细阅读代码,确保理解了实现的细节。

首先要考虑的便是如何管理全局状态,在这里我们以 Redux 为例,来创建一个可以处理所有对话框状态的 reducer:

const modalReducer = (state = { hiding: {} }, action) => {
  switch (action.type) {
    case "nice-modal/show":
      const { modalId, args } = action.payload;
      return {
        ...state,
        // 如果存在 modalId 对应的状态,就显示这个对话框
        [modalId]: args || true,
        // 定义一个 hiding 状态用于处理对话框关闭动画
        hiding: {
          ...state.hiding,
          [modalId]: false,
        },
      };
    case "nice-modal/hide":
     const { modalId, force } = action.payload;
      // 只有 force 时才真正移除对话框
      return action.payload.force
        ? {
            ...state,
            [modalId]: false,
            hiding: { [modalId]: false },
          }
        : { ...state, hiding: { [.modalId]: true } };
    default:
      return state;
  }
};

这段代码的主要思路就是通过 Redux 的 store 去存储每个对话框状态和参数。在这里,我们设计了两个 action ,分别用来显示和隐藏对话框。

特别要注意的是,这里我们加入了 hiding 这样一个状态,用来处理对话框关闭过程的动画,确保用户体验。

为了让 Redux 的 action 使用起来更方便,我们可以定义一个 useNiceModal 这样的 Hook,在其内部封装对 Store 的操作,从而实现对话框状态管理的逻辑重用,并以更友好的方式暴露给用户:

// 使用 action creator 来创建显示和隐藏对话框的 action
function showModal(modalId, args) {
  return {
    type: "nice-modal/show",
    payload: {
      modalId,
      args,
    },
  };
}

function hideModal(modalId, force) {
  return {
    type: "nice-modal/hide",
    payload: {
      modalId,
      force,
    },
  };
}

// 创建自定义 Hook 用于处理对话框逻辑
export const useNiceModal = (modalId) => {
  const dispatch = useDispatch();
  // 封装 Redux action 用于显示对话框
  const show = useCallback((args) => {
    dispatch(showModal(modalId, args));
  }, [
    dispatch,
    modalId,
  ]);
  // 封装 Redux action 用于隐藏对话框
  const hide = useCallback((force) => {
    dispatch(hideModal(modalId, force));
  }, [
    dispatch,
    modalId,
  ]);

  const args = useSelector((s) => s[modalId]);
  const hiding = useSelector((s) => s.hiding[modalId]);

  // 只要有参数就认为对话框应该显示,如果没有传递 args,在reducer 中会使用
  // 默认值 true
  return { args, hiding, visible: !!args, show, hide };
};

同时,我们可以实现 NiceModal 这样一个组件,去封装通用的对话框操作逻辑。比如关闭按钮,确定按钮的事件处理,等等。为了方便演示,我们以 Ant Design 中的 Modal 组件为例:

function NiceModal({ id, children, ...rest }) {
  const modal = useNiceModal(id);
  return (
    <Modal
      onCancel={() => modal.hide()} // 默认点击 cancel 时关闭对话框
      onOk={() => modal.hide()} // 默认点击确定关闭对话框
      afterClose={() => modal.hide(true)} // 动画完成后真正关闭
      visible={!modal.hiding}
      {...rest} // 允许在使用 NiceModal 时透传参数给实际的 Modal
    >
      {children}
    </Modal>
  );
}

最后呢,我们用一个第10讲提到的容器模式,它会在对话框不可见时直接返回 null,从而不渲染任何内容;并且确保即使页面上定义了100个对话框,也不会影响性能:

export const createNiceModal = (modalId, Comp) => {
  return (props) => {
    const { visible, args } = useNiceModal(modalId);
    if (!visible) return null;
    return <Comp {...args} {...props} />;
  };
};

这样,我们就实现了一个 NiceModal 这样的全局对话框管理框架。基于这样一个框架,使用对话框的时候就会非常方便。比如下面的代码:

import { Button } from "antd";
import NiceModal, {
  createNiceModal,
  useNiceModal,
} from "./NiceModal";

const MyModal = createNiceModal("my-modal", () => {
  return (
    <NiceModal id="my-modal" title="Nice Modal">
      Hello NiceModal!
    </NiceModal>
  );
});

function MyModalExample() {
  const modal = useNiceModal("my-modal");
  return (
    <>
      <Button type="primary" onClick={() => modal.show()}>
        Show Modal
      </Button>
      <MyModal />
    </>
  );
}

在这个例子中,我们首先定义了一个简单的 MyModal 组件,这样我们就可以把多画框逻辑写在单独的组件中,而不是嵌入到父组件。在这个 MyModal 组件内部使用了 NiceModal 作为基础,从而可以绑定对话框 ID,并重用通用的对话框逻辑。

通过这个 Modal ID,我们就能够在应用的任何组件中去管理这个对话框了。

可以看到,在这部分我们基本完整实现了一个 NiceModal 的机制,它可以帮助你很好地去全局管理对话框。不过你再仔细点的话,会发现这里其实还缺少了一个直观的机制,那就是如何处理对话框的返回值

处理对话框返回值

如果说对话框和页面这两种 UI 模式基本上是一致的,都是独立窗口完成独立逻辑。但是在用户交互上,却是有一定的差别,

那么基于上面的 NiceModal 实现逻辑,现在的问题就是,我们应该如何让调用者获得返回值呢?

考虑到我们可以把用户在对话框中的操作看成一个异步操作逻辑,那么用户在完成了对话框中内容的操作之后,就认为异步逻辑完成了。因此我们可以利用 Promise 来完成这样的逻辑。

那么,我们要实现的 API 如下所示:

const modal = useNiceModal('my-modal');
// 实现一个 promise API 来处理返回值
modal.show(args).then(result => {});

事实上,要实现这样一个机制并不困难,就是在 useNiceModal 这个 Hook 的实现中提供一个 modal.resolve 这样的方法,能够去 resolve modal.show 返回的 Promise。

实现的代码思路如下所示:

const modal = useNiceModal('my-modal');
// 实现一个 promise API 来处理返回值
modal.show(args).then(result => {});

代码的核心思路就是将 show 和 resolve 两个函数通过 Promise 联系起来。因为两个函数的调用位置不一样,所以我们使用了一个局部的临时变量,来存放 resolve 回调函数。通过这样的机制,就可以在对话框中去调用 modal.resolve 来返回值了。

下面的代码演示了具体使用的一个例子:

// 使用一个 object 缓存 promise 的 resolve 回调函数
const modalCallbacks = {};
export const useNiceModal = (modalId) => {
  const dispatch = useDispatch();
  const show = useCallback(
    (args) => {
      return new Promise((resolve) => {
        // 显示对话框时,返回 promise 并且将 resolve 方法临时存起来
        modalCallbacks[modalId] = resolve;
        dispatch(showModal(modalId, args));
      });
    },
    [dispatch, modalId],
  );
  const resolve = useCallback(
    (args) => {
      if (modalCallbacks[modalId]) {
        // 如果存在 resolve 回调函数,那么就调用
        modalCallbacks[modalId](args);
        // 确保只能 resolve 一次
        delete modalCallbacks[modalId];
      }
    },
    [modalId],
  );
  
  // 其它逻辑...

  // 将 resolve 也作为返回值的一部分
  return { show, hide, resolve, visible, hiding };
};

这段示意代码包括两个部分。

首先是在 UserList 的表格组件中,由编辑按钮触发对话框的显示,并在对话框返回后,将用户输入更新到表格。

第二部分则是在对话框中,用户点击了确定按钮后调用 modal.resolve 方法,将用户输入返回给 UserList 组件,从而完成整个编辑流程。

小结

在这节课我们主要学习了在 React 中使用对话框的一种实践方式:利用全局状态来管理对话框。

其核心思路在于从 UI 模式的角度出发,认识到对话框和页面在很多时候是非常类似的,都是一个独立功能的 UI 展现

因此,用全局的方式去管理对话框就是一种非常合理的方式。这样,我们就能解决很多在 React 开发中经常遇到的各种对话框实现难题,从而让组件的语义更加清楚,代码更容易理解和维护。

这里要着重强调一点。在实现部分,我们用到了 Redux 作为全局状态管理框架来管理对话框的状态,并利用了自定义 Hook useNiceModal 去实现状态管理逻辑的重用。

虽然看上去是实现了一个框架级别的机制,但是实际上核心代码只有100行左右,你在实际项目中完全可以将其复制到你的项目中,并在理解的基础上,根据自己的需求和场景去定制使用。到时候你就能感受到这个机制带给你的惊喜了。

这里也要说明一点,使用了全局方式管理对话框,并不意味着你就不能使用本地状态的对话框了。对于一些非常简单的场景,比如你很确定某个对话框一定只在某个组件内才被使用,也是可以继续使用本地声明的对话框的。我们要明白,全局方式和本地方式是完全不冲突的,是可以共存的。

文中所有的示例代码和运行效果都可以通过 codesandbox 查看:https://codesandbox.io/s/react-hooks-course-20vzg

思考题

在本文中,我们使用的是 Redux 来管理所有对话框的所有状态。但有时候你的项目并不一定使用了 Redux,那么我们其实也可以使用 Context 来管理对话框的全局状态。那么请你思考一下,如果基于 Context ,应该如何实现 NiceModal 呢?

欢迎把你的想法和思考分享在留言区,我会和你交流。同时,我也会把其中一些不错的回答在留言区置顶,供大家学习讨论。