封装三个 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
组件封装了复制到剪贴板的功能:
- 它接受要复制的文本、复制选项和一个回调函数作为 props。
- 它要求有一个子元素,这个子元素会被赋予点击复制的功能。
- 当点击发生时,它会执行复制操作,调用可能存在的回调函数,并保留子元素原有的点击行为。
- 通过
React.cloneElement
,它在不改变子元素其他属性的情况下,添加或覆盖了 onClick 事件处理。
这种设计使得 CopyToClipboard
组件非常灵活,可以包裹任何可点击的 React 元素,赋予其复制到剪贴板的功能。
特点
Props 设计
text: 要复制的文本
options: 复制配置项
onCopy: 复制后回调
children: 单个 React 元素
非侵入式设计
通过 React.cloneElement 注入点击事件
保留原有 onClick 行为
不修改子元素其他属性
实现原理
使用 copy-to-clipboard 库
包装现有组件
组合模式设计
参考
这些组件都采用了 React 组合模式,通过包装现有组件来扩展功能,保持了代码的可复用性和灵活性。