Skip to content

封装三个 API

Portal

react 提供了 createPortal 的 api,可以把组件渲染到某个 dom 下。

tsx
import { createPortal } from "react-dom";

function App() {
  const content = (
    <div className="btn">
      <button>按钮</button>
    </div>
  );

  return createPortal(content, document.body);
}

export default App;

我们也可以把它封装成 Portal 组件来用。

tsx
import React, {
  forwardRef,
  useEffect,
  useImperativeHandle,
  useMemo,
} from "react";
import { createPortal } from "react-dom";

// 定义 Portal 组件的 props 接口
export interface PortalProps {
  attach?: HTMLElement | string; // 指定 Portal 挂载的目标元素
  children: React.ReactNode; // Portal 的子元素
}

// 获取挂载目标元素的辅助函数
export function getAttach(attach: PortalProps["attach"]) {
  if (typeof attach === "string") {
    return document.querySelector(attach);
  }

  if (typeof attach === "object" && attach instanceof window.HTMLElement) {
    return attach;
  }

  return document.body; // 默认挂载到 body
}

// 使用 forwardRef 创建 Portal 组件,以便可以传递 ref
const Portal = forwardRef((props: PortalProps, ref) => {
  const { attach = document.body, children } = props;

  // 创建一个 div 作为 Portal 的容器
  const container = useMemo(() => {
    const el = document.createElement("div");
    el.className = "portal-wrapper";
    return el;
  }, []);

  useEffect(() => {
    // 获取挂载目标元素
    const parentElement = getAttach(attach);
    // 将 Portal 容器添加到目标元素中
    parentElement?.appendChild?.(container);

    // 清理函数:组件卸载时移除 Portal 容器
    return () => {
      parentElement?.removeChild?.(container);
    };
  }, [container, attach]);

  // 将 container 组件 暴露给父组件
  useImperativeHandle(ref, () => container);

  // 使用 createPortal 将子元素渲染到 container 中
  return createPortal(children, container);
});

export default Portal;
tsx
import "./App.css";
import Portal from "./Portal";
import { useRef } from "react";

function App() {
  const containerRef = useRef<HTMLElement>(null);

  const content = (
    <div className="btn">
      <button>按钮</button>
    </div>
  );

  return (
    <Portal attach={document.head} ref={containerRef}>
      {content}
    </Portal>
  );
}

export default App;

核心特性

支持 string/HTMLElement 作为挂载点

自动管理容器生命周期

支持 ref 转发

MutateObserver

浏览器提供了 MutationObserver 的 api,可以监听 dom 的变化,包括子节点的变化、属性的变化。

用法:

tsx
import { useEffect, useRef, useState } from "react";

export default function App() {
  // 状态用于控制显示的内容
  const [displayState, setDisplayState] = useState("初始状态");

  // 2秒后更改状态
  useEffect(() => {
    setTimeout(() => setDisplayState("更新状态"), 2000);
  }, []);

  // 创建对容器的引用
  const containerRef = useRef(null);

  // 设置 MutationObserver 来监视 DOM 变化
  useEffect(() => {
    const targetNode = containerRef.current!;

    // 定义回调函数,当观察到变化时被调用
    const callback = function (mutationsList: MutationRecord[]) {
      console.log("DOM 发生了变化:", mutationsList);
    };

    // 创建一个观察器实例并传入回调函数
    const observer = new MutationObserver(callback);

    // 开始观察目标节点
    observer.observe(targetNode, {
      attributes: true, // 观察属性变动
      childList: true, // 观察目标节点的子节点的增加和删除
      subtree: true, // 观察所有后代节点
      attributeFilter: ["class"],
    });

    // 组件卸载时停止观察
    return () => observer.disconnect();
  }, []);

  return (
    <div>
      <div id="container" ref={containerRef}>
        <div className={displayState}>
          {displayState === "初始状态" ? (
            <div>当前是初始状态</div>
          ) : (
            <div>
              <p>状态已更新</p>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

attributes 是监听属性变化,childList 是监听 children 变化,subtree 是连带子节点的属性、children 变化也监听。

attributeFilter 可以指定监听哪些属性的变化。

封装 useMutateObserver 的 hook

ts
import { useEffect } from "react";

// 默认的 MutationObserver 配置选项
const defaultOptions: MutationObserverInit = {
  subtree: true, // 观察目标节点及其所有后代节点
  childList: true, // 观察子节点的添加或删除
  attributeFilter: ["style", "class"], // 只观察 style 和 class 属性的变化
};

export default function useMutateObserver(
  nodeOrList: HTMLElement | HTMLElement[], // 要观察的节点或节点列表
  callback: MutationCallback, // 当观察到变化时调用的回调函数
  options: MutationObserverInit = defaultOptions // 观察选项,默认使用 defaultOptions
) {
  useEffect(() => {
    // 如果没有提供节点,则直接返回
    if (!nodeOrList) {
      return;
    }

    let instance: MutationObserver;

    // 确保 nodeOrList 总是一个数组
    const nodeList = Array.isArray(nodeOrList) ? nodeOrList : [nodeOrList];

    // 检查浏览器是否支持 MutationObserver
    if ("MutationObserver" in window) {
      // 创建 MutationObserver 实例
      instance = new MutationObserver(callback);

      // 对每个节点启动观察
      nodeList.forEach((element) => {
        instance.observe(element, options);
      });
    }

    // 清理函数,在组件卸载或依赖项变化时调用
    return () => {
      // 取出并处理所有未处理的变更记录
      instance?.takeRecords();
      // 停止观察
      instance?.disconnect();
    };
  }, [options, nodeOrList]); // 依赖项:如果这些变化,效果会重新运行
}
tsx
import React, { useLayoutEffect } from "react";
import useMutateObserver from "./useMutateObserver";

// 定义 MutationObserver 组件的 props 接口
interface MutationObserverProps {
  options?: MutationObserverInit; // MutationObserver 的配置选项
  onMutate?: (mutations: MutationRecord[], observer: MutationObserver) => void; // 变化时的回调函数
  children: React.ReactElement; // 子元素,必须是单个 React 元素
}

const MutateObserver: React.FC<MutationObserverProps> = (props) => {
  const { options, onMutate = () => {}, children } = props;

  // 创建一个 ref 来存储被观察的元素
  const elementRef = React.useRef<HTMLElement>(null);

  // 状态用于存储实际被观察的目标元素
  const [target, setTarget] = React.useState<HTMLElement>();

  // 使用自定义 Hook 来设置 MutationObserver
  useMutateObserver(target!, onMutate, options);

  // 使用 useLayoutEffect 确保在 DOM 更新后立即设置目标元素
  useLayoutEffect(() => {
    setTarget(elementRef.current!);
  }, []);

  // 如果没有子元素,返回 null
  if (!children) {
    return null;
  }

  // 克隆子元素并添加 ref
  return React.cloneElement(children, { ref: elementRef });
};

export default MutateObserver;
tsx
import { useEffect, useState } from "react";
import MutateObserver from "./MutateObserver";

export default function App() {
  // 状态用于控制类名
  const [className, setClassName] = useState("aaa");

  // 2秒后更改类名
  useEffect(() => {
    setTimeout(() => setClassName("bbb"), 2000);
  }, []);

  // MutationObserver 的回调函数
  const callback = function (mutationsList: MutationRecord[]) {
    console.log(mutationsList);
  };

  return (
    <div>
      <MutateObserver onMutate={callback}>
        <div id="container">
          <div className={className}>
            {className === "aaa" ? (
              <div>aaa</div>
            ) : (
              <div>
                <p>bbb</p>
              </div>
            )}
          </div>
        </div>
      </MutateObserver>
    </div>
  );
}

CopyToClipboard

基于 copy-to-clipboard 库,封装自己的复制组件。 不用侵入元素的 onClick 处理函数,只是额外多了复制的功能。

tsx
// 直接使用这个库
import copy from "copy-to-clipboard";

export default function App() {
  function onClick() {
    const res = copy("Note");
    console.log("done", res);
  }

  return <div onClick={onClick}>复制</div>;
}

React 的基础上封装这个库

tsx
import React, {
  EventHandler,
  FC,
  PropsWithChildren,
  ReactElement,
} from "react";
import copy from "copy-to-clipboard";

// 定义 CopyToClipboard 组件的 props 接口
interface CopyToClipboardProps {
  text: string; // 要复制到剪贴板的文本
  onCopy?: (text: string, result: boolean) => void; // 复制操作完成后的回调函数
  children: ReactElement; // 子元素,必须是单个 React 元素
  options?: {
    // 复制操作的选项
    debug?: boolean;
    message?: string;
    format?: string;
  };
}

const CopyToClipboard: FC<CopyToClipboardProps> = (props) => {
  const { text, onCopy, children, options } = props;

  // 确保只有一个子元素
  const elem = React.Children.only(children);

  function onClick(event: MouseEvent) {
    // 再次确保只有一个子元素(为了类型安全)
    const elem = React.Children.only(children);

    // 执行复制操作
    const result = copy(text, options);

    // 如果提供了 onCopy 回调,则调用它
    if (onCopy) {
      onCopy(text, result);
    }

    // 如果子元素有自己的 onClick 处理函数,也调用它
    if (typeof elem?.props?.onClick === "function") {
      elem.props.onClick(event);
    }
  }

  // 克隆子元素,并添加 onClick 处理函数
  return React.cloneElement(elem, { onClick });
};

export default CopyToClipboard;

这个 CopyToClipboard 组件封装了复制到剪贴板的功能:

  1. 它接受要复制的文本、复制选项和一个回调函数作为 props。
  2. 它要求有一个子元素,这个子元素会被赋予点击复制的功能。
  3. 当点击发生时,它会执行复制操作,调用可能存在的回调函数,并保留子元素原有的点击行为。
  4. 通过 React.cloneElement,它在不改变子元素其他属性的情况下,添加或覆盖了 onClick 事件处理。

这种设计使得 CopyToClipboard 组件非常灵活,可以包裹任何可点击的 React 元素,赋予其复制到剪贴板的功能。

特点

  1. Props 设计

    text: 要复制的文本

    options: 复制配置项

    onCopy: 复制后回调

    children: 单个 React 元素

  2. 非侵入式设计

    通过 React.cloneElement 注入点击事件

    保留原有 onClick 行为

    不修改子元素其他属性

  3. 实现原理

    使用 copy-to-clipboard 库

    包装现有组件

    组合模式设计

参考

这些组件都采用了 React 组合模式,通过包装现有组件来扩展功能,保持了代码的可复用性和灵活性。

Crafted with care.