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 就是进行了类似的封装。