浅谈 React/Vue 中的命令式组件

322 阅读4分钟

问题

对于项目中的弹窗、对话框等组件,传统的使用方式需要在模板中先声明一个组件,然后通过切换 props 来控制显示隐藏

只有一个弹窗时还好,但如果有多个弹窗时,情况就会变得复杂

const Comp = () => {
    const [visible1, setVisible1] = useState(false);
    const [visible2, setVisible2] = useState(false);
    const [visible3, setVisible3] = useState(false);
    
    return (
      <>
        <button onClick={() => setVisible1(true)}>弹窗1</button>
        <Modal visible={visible1} />
        <button onClick={() => setVisible2(true)}>弹窗2</button>
        <Modal visible={visible2} />
        <button onClick={() => setVisible3(true)}>弹窗3</button>
        <Modal visible={visible3} />
      </>
    )
}

这样的写法虽然可以实现功能,但代码比较混乱,再加上组件中的其他功能模块,代码会变得更加难以维护

如果使用调用 showModal 函数的方式触发弹窗,相比之下代码会更加简洁。因此需要命令式组件

在网上搜索了一下,发现大多都是 react 中使用 context API,vue 中使用依赖注入的方式实现命令式组件

这种方式确实是最好的选择,但我还是想分享一下另一种实现方式

React 中的命令式组件

核心思想是通过 createRoot 方法创建命令式组件

封装核心方法 createModal,该方法接收一个 React 组件,并返回一个函数,该函数用于卸载组件

import { createRoot } from "react-dom/client";
import { type ReactNode } from "react";
import type { ModalProps } from "antd";
import ModalContainer from "./components/ModalContainer";

const createModal = (modal: ReactNode) => {
  const container = document.createElement("div");
  document.body.appendChild(container);

  const root = createRoot(container);
  root.render(modal);

  return () => {
    root.unmount();
    document.body.removeChild(container);
  };
};

export const showModal = (props: ModalProps) => {
  const afterClose = props.afterClose;
  const onUnmount = createModal(
    <ModalContainer {...props} afterClose={() => {
      afterClose?.();
      // 异步卸载 root 确保当前渲染已经完成
      Promise.resolve().then(() => onUnmount());
    }} />
  );
  return onUnmount;
};

导出 showModal 函数,使用二次封装 antd 的 Modal 组件为例, ModalContainer 作为容器

import type { ModalProps } from "antd";
import { Modal } from "antd";
import type { FC } from "react";
import { ModalProvider, useModalContext } from "@/context/modalContext";

/**
 * 通过 ModalProvider 包裹 Modal 组件,向子组件中暴露 closeModal 方法用于关闭 Modal
 */
const ModalContainer: FC<ModalProps> = (props) => {
  const { visible, closeModal } = useModalContext();
  
  return (
      <Modal
        {...props}
        open={visible}
        onOk={(e) => {
          closeModal();
          props.onOk?.(e);
        }}
        onCancel={(e) => {
          closeModal();
          props.onCancel?.(e);
        }}
      />
  )
}

const ModalContainerWithProvider: FC<ModalProps> = (props) => {
  return (
    <ModalProvider>
      <ModalContainer {...props} />
    </ModalProvider>
  );
};

export default ModalContainerWithProvider;

其中使用 context API,向子组件暴露 closeModal 方法,用于关闭弹窗

import { createContext, useContext, useState, type FC, type ReactNode } from "react";

// 用于命令式弹窗的上下文
const ModalContext = createContext<{
  visible: boolean;
  closeModal: () => void;
} | null>(null);

export const ModalProvider: FC<{ children: ReactNode }> = ({ children }) => {
  const [visible, setVisible] = useState(true);

  const closeModal = () => {
    setVisible(false);
  };

  return (
    <ModalContext.Provider value={{ closeModal, visible }}>
      {children}
    </ModalContext.Provider>
  );
}

export const useModalContext = () => {
  const context = useContext(ModalContext);
  if (!context) {
    throw new Error("useModalContext must be used within a ModalProvider");
  }
  return context;
};

最终只需要通过 showModal 函数触发弹窗,通过 closeModal 方法关闭弹窗,使用起来非常简洁

const showUserModal = () => {
  showModal({
    children: <UserForm />,
    footer: null,
    title: "注册/登录",
  });
};

重要的是并非只适用于简单的弹窗,也可以用于包含复杂表单的弹窗

modal.gif

当然,缺陷也比较明显,就是脱离了当前的 React 应用,也就没法在传入的组件中使用基于 context API 的功能

例如,状态管理库 redux,上面的例子中使用的是 zustand,并不依赖于 context API

当然也不是没有解决方案,可以在调用 showModal 传入组件时通过 props 传入需要的数据,定义必要的回调函数。例如这样:

const showUserModal = () => {
  showModal({
    children: <UserForm store={store} onOk={onOk} onCancel={onCancel} />,
    footer: null,
    title: "注册/登录",
  });
};

不过这样一来,弹窗中的逻辑就会过于分离,并且杂糅在使用组件中,不利于维护

其实 antd 中的 message 组件也包含了这样的实现逻辑,message 组件拥有多种使用方法

  1. 直接使用
import { message } from "antd";

message.success("成功");
  1. hooks
const [messageApi, contextHolder] = message.useMessage();
  1. App
import { App } from "antd";

const { message } = App.useApp();

其中第一种方法就是使用了上述实现逻辑

Vue 中的命令式组件

核心思想是通过 createApp 方法创建命令式组件

对于方法的封装逻辑和 React 中区别并不大,要做完全通用的只能使用传统方案,重要的是根据使用场景灵活封装,有时候能实现一些优雅的使用场景,下面是一些和具体场景绑定的功能实现

这是一个简单的全局提示组件

import Message from './Message.vue';
import { createApp } from 'vue';

function createContainer() {
  const container = document.createElement('div');
  document.body.appendChild(container);
  return container;
}

/**
 * @param {Object} options
 * @param {String} options.type
 * @param {String} options.content
 * @param {Number} options.duration
 */
export function showMessage(options = {}) {
  const app = createApp(Message, options);
  const container = createContainer();
  app.mount(container);
  setTimeout(
    () => {
      app.unmount();
      document.body.removeChild(container);
    },
    options.duration + 300 || 3300
  );
}

下面是一个低码项目中的文件选择器,用于项目的编辑器部分

import FileManager from "./FileManager.vue";
import { createApp, ref } from "vue";
import type { App } from "vue";


function createContainer() {
  const div = document.createElement("div");
  document.body.appendChild(div);
  return {
    container: div,
    unmount: () => {
      document.body.removeChild(div);
    },
  };
}

let app: App | null = null;
let container: { container: HTMLDivElement; unmount: () => void } | null = null;
const isShow = ref(false);
let resolveCallback: (value: any) => void;

/**
 * 挂载文件管理器
 */
export function mount() {
  container = createContainer();
  app = createApp(FileManager, {
    isShow,
    onClose: (fileUrl?: string) => {
      isShow.value = false;
      resolveCallback(fileUrl);
    },
  });
  app.mount(container.container);
}
/**
 * 卸载文件管理器
 */
export function unmount() {
  if (!app) return;
  app.unmount();
  container?.unmount();
  app = null;
  container = null;
}

/**
 * 选择文件
 * @return Promise<string | undefined>
 */
export function selectFile() {
  return new Promise((resolve, reject) => {
    if (!app) return reject("FileManager not mounted");
    isShow.value = true;
    resolveCallback = resolve;
  });
}

通过返回一个 Promise,等待用户选择,通过 await 关键字可以直接拿到选择结果。并且提供了 mount 和 unmount 方法,可以在需要的时候避免频繁的销毁/挂载。

const handleSelect = async () => {
    const res = await selectFile();
    // ...
}

当然 Vue 中也可以使用 tsx 语法编写组件,更适用于灵活场景和代码聚合


写在最后

总体来说,这种方法编写的代码还算优雅,当然缺点也很明显。通过灵活封装能在特定场景实现意想不到的效果。分享一下,欢迎大家讨论