Skip to content

在原生 DOM 中优雅地插入 React 组件 ​

背景 ​

近期在开发一个富文本编辑器相关的功能,需要点击工具栏按钮后弹出一个 Antd 的 <Select> 组件。但因为使用的富文本编辑器是 Jodit Editor,其按钮点击后出现的弹窗是使用原生 DOM 实现的,不支持 React 组件,所以需要一个方法在原生 DOM 中优雅地插入 React 组件。

实现 ​

效果示例:https://stackblitz.com/~/github.com/RJiazhen/create-portal-ref-demo

代码仓库:https://github.com/RJiazhen/create-portal-ref-demo

效果演示: jodit-editor-react-component

父组件代码:

tsx
import JoditEditor from 'jodit-react';
import type { IJodit } from 'jodit/esm/types';
import type { ComponentProps } from 'react';
import { useAntdSelect } from './AntdSelect';

/**
 * 主应用组件
 * 演示使用原生 DOM 方法创建元素,并通过 Portal 在元素中渲染内容
 */
function App() {
  // 使用元素内容管理 Hook(useState 版本)
  const { createElement, Select } = useAntdSelect();
  // 应用根元素的引用,用于挂载新创建的元素
  const editorRef = useRef<IJodit>(null);

  const config: ComponentProps<typeof JoditEditor>['config'] = useMemo(
    () => ({
      toolbarAdaptive: false,
      buttons: [
        {
          name: 'antd',
          text: '点击后出现antd组件',
          popup: () => {
            const element = createElement(); // 使用 createElement 方法创建一个原生 DOM 元素
            return element; // 返回这个元素,Jodit Editor 会把这个元素作为弹窗的容器
          },
        },
      ],
    }),
    [createElement],
  );

  const [value, setValue] = useState<string>('');

  return (
    <div className="app-container">
      <div style={{ marginTop: '20px' }}>
        <JoditEditor
          ref={editorRef}
          config={config}
          value={value}
          onChange={(value) => {
            setValue(value);
          }}
        />
      </div>
      {/* 直接插入 useAntdSelect 创建的 Select 组件 */}
      <Select
        options={Array.from({ length: 100 }, (_, index) => ({
          label: `选项 ${index + 1}`,
          value: `option${index + 1}`,
        }))}
        onChange={(value) => {
          editorRef.current?.selection.insertHTML(value as string);
        }}
      />
    </div>
  );
}

这里可以看到,Jodit Editor 的按钮配置是通过 config.buttons 参数配置的,其中** popup 属性会在点击按钮后执行**,并返回一个原生 DOM 元素,Jodit Editor 会把这个元素作为弹窗内容进行渲染和插入。

而示例中,用来创建 DOM 元素的是 useAntdSelect 这个 hook 提供的 createElement 方法,这个方法除了返回一个原生 DOM 元素外,还在内部执行了一些别的逻辑用来控制 Antd 的 <Select> 组件渲染到这个 DOM 元素中。

useAntdSelect 组件代码:

tsx
import { Select as AntdSelect } from 'antd';
import type { ComponentProps } from 'react';
import { createPortal } from 'react-dom';

/**
 * Antd Select 组件 Hook
 *
 * 提供一个创建 Antd Select 组件的 dom 元素容器的函数,当调用该函数创建 dom 元素容器后,
 * 会通过 createPortal 将 Antd Select 组件渲染到该 dom 元素容器中。
 * @returns 包含 createElement 方法和 Select 组件的对象
 */
export function useAntdSelect() {
  /** Antd 弹窗的 dom 元素引用 */
  const [popupRef, setPopupRef] = useState<HTMLElement>();

  /**
   * 创建 Antd Select 组件的 dom 元素
   * @returns 创建的 HTMLElement
   */
  const createElement = useCallback(() => {
    const popupContainer = document.createElement('div');
    // 设置弹窗宽高,以保证 Antd 组件有足够的显示空间
    popupContainer.style.width = '200px';
    popupContainer.style.height = '300px';
    setPopupRef(popupContainer); // 更新 popupRef 这个 state,触发 Select 组件重新渲染
    return popupContainer;
  }, []);

  /**
   * Select 组件
   *
   * 接收和 Antd 的 Select 组件相同的 props,并将其渲染到 popupRef 对应的 dom 元素中。
   */
  const Select = (props: React.ComponentProps<typeof AntdSelect>) => {
    const ref: ComponentProps<typeof AntdSelect>['ref'] | null = useRef(null);

    useEffect(() => {
      ref.current?.focus();
    }, []);

    if (!popupRef) return null;

    return createPortal(
      <AntdSelect<any>
        // 设置 dropdown 的样式,防止被 jodit editor 的 popup 遮挡
        styles={{
          root: {
            zIndex: 100000000,
          },
        }}
        // 确保 dropdown 渲染在当前容器中
        getPopupContainer={(triggerNode) => {
          return triggerNode || document.body;
        }}
        // 手动设置宽度,因为只有在 form 中时,select 组件才会设置 .ant-select-in-form-item,然后才会默认设置宽度为 100%。如果不这么设置,则宽度不会被设置为父元素的宽度
        style={{
          width: '100%',
        }}
        showSearch
        // 过滤选项,不然默认的 Select 组件虽然有搜索框,但没有任何搜索功能
        filterOption={(input, option) => {
          return (
            option?.label
              ?.toString()
              .toLowerCase()
              .includes(input?.toLowerCase() || '') || false
          );
        }}
        // 默认展开下拉列表
        open
        ref={ref}
        {...props}
      />,
      popupRef,
    );
  };

  return {
    createElement,
    Select,
  };
}

这里可以看到,useAntdSelect 这个 hook 的实现包括两个关键点:

  1. 使用一个 state 存储 DOM 元素,从而在 DOM 元素更新后触发重新渲染;
  2. 使用 createPortal 将 Antd 的 <Select> 组件渲染到这个 DOM 元素中。

不过,除了直接使用 state 存储 DOM 元素,还有一种方法是使用 ref 存储 DOM 元素,然后用 state 单独存储一个开关,用来触发重新渲染。

理论上来说,不应该使用 state 存储像是 DOM 元素这种无法被序列化的内容,但目前未观察到明显的性能区别,可能需要进一步研究。

demo 中也提供了这种方式的实现,可以对比查看。

总结 ​

作为成熟的前端框架,React 是提供了方法用来完全兼容原生浏览器 API 的,所以遇到类似情况时,不妨先翻阅一下 React 的官方文档,看看是否提供了相关的 API。

本次示例中,也可以将 useAntdSelect 这个 hook 封装成一个公共 hook,以便于在其他地方使用。

例如 react-portal 就是进行了类似的封装。