• Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • React 通关秘籍

    • 1.关于本小册
    • 2.一网打尽组件常用Hook
    • 3.Hook的闭包陷阱的成因和解决方案
    • 4.React组件如何写TypeScript类型
    • 5.React组件如何调试
    • 6.受控模式VS非受控模式
    • 7.组件实战:迷你Calendar
    • 8.组件实战:Calendar日历组件(上)
    • 9.组件实战:Calendar日历组件(下)
    • 10.快速掌握Storybook
    • 11.React组件如何写单测
    • 12.深入理解Suspense和ErrorBoundary
    • 13.组件实战:Icon图标组件
    • 14.组件实战:Space间距组件
    • 15.React.Children和它的两种替代方案
    • 16.三个简单组件的封装
    • 17.浏览器的5种Observer
    • 18.组件实战:Watermark防删除水印组件
    • 19.手写react-lazyload
    • 20.图解网页的各种距离
    • 21.自定义hook练习
    • 22.自定义hook练习(二)
    • 23.用react-spring做弹簧动画
    • 24.react-spring结合use-gesture手势库实现交互动画
    • 25.用react-transition-group和react-spring做过渡动画
    • 26.快速掌握Tailwind:最流行的原子化CSS框架
    • 27.用CSSModules避免样式冲突
    • 28.CSSInJS:快速掌握styled-components
    • 29.react-spring实现滑入滑出的转场动画
    • 30.组件实战:Message全局提示组件
    • 31.组件实战:Popover气泡卡片组件
    • 32.项目里如何快速定位组件源码
    • 33.一次超爽的React调试体验
    • 34.组件实战:ColorPicker颜色选择器(一)
    • 35.组件实战:ColorPicker颜色选择器(二)
    • 36.组件实战:onBoarding漫游式引导组件
    • 37.组件实战:Upload拖拽上传
    • 38.组件实战:Form表单组件
    • 39.React组件库都是怎么构建的
    • 40.组件库实战:构建esm和cjs产物,发布到npm
    • 41.组件库实战:构建umd产物,通过unpkg访问
    • 42.数据不可变:immutable和immer
    • 43.基于ReactRouter实现keepalive
    • 44.Historyapi和ReactRouter实现原理
    • 45.ReactContext的实现原理和在antd里的应用
    • 46.ReactContext的性能缺点和解决方案
    • 47.手写一个Zustand
    • 48.原子化状态管理库Jotai
    • 49.用react-intl实现国际化
    • 50.国际化资源包如何通过Excel和GoogleSheet分享给产品经理
    • 51.基于react-dnd实现拖拽排序
    • 52.react-dnd实战:拖拽版TodoList
    • 53.ReactPlayground项目实战:需求分析、实现原理
    • 54.ReactPlayground项目实战:布局、代码编辑器
    • 55.ReactPlayground项目实战:多文件切换
    • 56.ReactPlayground项目实战:babel编译、iframe预览
    • 57.ReactPlayground项目实战:文件增删改
    • 58.ReactPlayground项目实战:错误显示、主题切换
    • 59.ReactPlayground项目实战:链接分享、代码下载
    • 60.ReactPlayground项目实战:WebWorker性能优化
    • 61.ReactPlayground项目实战:总结
    • 62.手写MiniReact:思路分析
    • 63.手写MiniReact:代码实现
    • 64.手写MiniReact:和真实React源码的对比
    • 65.React18的并发机制是怎么实现的
    • 66.Ref的实现原理
    • 67.低代码编辑器:核心数据结构、全局store
    • 68.低代码编辑器:拖拽组件到画布、拖拽编辑json
    • 69.低代码编辑器:画布区hover展示高亮框
    • 70.低代码编辑器:画布区click展示编辑框
    • 71.低代码编辑器:组件属性、样式编辑
    • 72.低代码编辑器:预览、大纲
    • 73.低代码编辑器:事件绑定
    • 74.低代码编辑器:动作弹窗
    • 75.低代码编辑器:自定义JS
    • 76.低代码编辑器:组件联动
    • 77.低代码编辑器:拖拽优化、Table组件
    • 78.低代码编辑器:Form组件、store持久化
    • 79.低代码编辑器:项目总结
    • 80.快速掌握ReactFlow画流程图
    • 81.ReactFlow振荡器调音:项目介绍
    • 82.ReactFlow振荡器调音:流程图绘制
    • 83.ReactFlow振荡器调音:合成声音
    • 84.AudioContext实现在线钢琴
    • 85.React服务端渲染:从SSR到hydrate
    • 86.小册总结

最近遇到一些组件,它们只是对 api 的一层简易封装,用起来也和直接用 api 差不多。

但是这种组件的下载量还是挺多的。

今天我们一起来写三个这样的组件,大家来感受下和直接用 api 的区别。

Portal

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

用起来也很简单:

import { createPortal } from "react-dom";

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

    return createPortal(content, document.body);
}

export default App;

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

接收 attach、children 参数,attach 就是挂载到的 dom 节点,默认是 document.body

然后提供一个 getAttach 方法,如果传入的是 string,就作为选择器来找到对应的 dom,如果是 HTMLElement,则直接作为挂载节点,否则,返回 document.body:

然后在 attach 的元素下添加一个 dom 节点作为容器:

当组件销毁时,删除这个容器 dom。

最后,用 createPortal 把 children 渲染到 container 节点下。

此外,通过 forwardRef + useImperativeHandle 把容器 dom 返回:

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

export interface PortalProps {
    attach?: HTMLElement | string;
    children: React.ReactNode;
}

const Portal = forwardRef((props: PortalProps, ref) => {
    const { attach = document.body, children } = props;

    const container = useMemo(() => {
        const el = document.createElement("div");
        el.className = `portal-wrapper`;
        return el;
    }, []);

    useEffect(() => {
        const parentElement = getAttach(attach);
        parentElement?.appendChild?.(container);

        return () => {
            parentElement?.removeChild?.(container);
        };
    }, [container, attach]);

    useImperativeHandle(ref, () => container);

    return createPortal(children, container);
});

export default 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;
}

这个 Portal 组件用起来是这样的:

import Portal from "./portal";

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

    return <Portal attach={document.body}>{content}</Portal>;
}

export default App;

还可以通过 ref 获取内部的容器 dom:

import { useEffect, useRef } from "react";
import Portal from "./portal";

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

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

    useEffect(() => {
        console.log(containerRef);
    }, []);

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

export default App;

看下效果:

这个 Portal 组件是对 createPortal 的简单封装。

内部封装了选择 attach 节点的逻辑,还会创建容器 dom 并通过 ref 返回。

还是有一些封装的价值。

再来看一个:

MutateObserver

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

这样用:

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

export default function App() {
    const [className, setClassName] = useState("aaa");

    useEffect(() => {
        setTimeout(() => setClassName("bbb"), 2000);
    }, []);

    const containerRef = useRef(null);

    useEffect(() => {
        const targetNode = containerRef.current!;

        const callback = function (mutationsList: MutationRecord[]) {
            console.log(mutationsList);
        };

        const observer = new MutationObserver(callback);

        observer.observe(targetNode, {
            attributes: true,
            childList: true,
            subtree: true,
        });
    }, []);

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

声明一个 className 的状态,从 aaa 切换到 bbb,渲染的内容也会改变。

用 useRef 获取到 container 的 dom 节点,然后用 MutationObserver 监听它的变化。

可以看到,2s 后 dom 发生改变,MutationObserver 监听到了它子节点的变化,属性的变化。

observe 的时候可以指定 options。

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

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

这个 api 用起来也不麻烦,但可以封装成自定义 hooks 或者组件。

ahooks 里就有这个 hook:

而 antd 里更是把它封装成了组件:

这样用:

我们也来写一下:

首先封装 useMutateObserver 的 hook:

import { useEffect } from "react";

const defaultOptions: MutationObserverInit = {
    subtree: true,
    childList: true,
    attributeFilter: ["style", "class"],
};

export default function useMutateObserver(
    nodeOrList: HTMLElement | HTMLElement[],
    callback: MutationCallback,
    options: MutationObserverInit = defaultOptions
) {
    useEffect(() => {
        if (!nodeOrList) {
            return;
        }

        let instance: MutationObserver;

        const nodeList = Array.isArray(nodeOrList) ? nodeOrList : [nodeOrList];

        if ("MutationObserver" in window) {
            instance = new MutationObserver(callback);

            nodeList.forEach((element) => {
                instance.observe(element, options);
            });
        }
        return () => {
            instance?.takeRecords();
            instance?.disconnect();
        };
    }, [options, nodeOrList]);
}

支持单个节点,多个节点的 observe。

设置了默认的 options。

在销毁的时候,调用 takeRecords 删掉所有剩余通知,调用 disconnect 停止接收新的通知:

然后封装 MutateObserver 组件:

import React, { useLayoutEffect } from "react";
import useMutateObserver from "./useMutateObserver";

interface MutationObserverProps {
    options?: MutationObserverInit;
    onMutate?: (
        mutations: MutationRecord[],
        observer: MutationObserver
    ) => void;
    children: React.ReactElement;
}

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

    const elementRef = React.useRef<HTMLElement>(null);

    const [target, setTarget] = React.useState<HTMLElement>();

    useMutateObserver(target!, onMutate, options);

    useLayoutEffect(() => {
        setTarget(elementRef.current!);
    }, []);

    if (!children) {
        return null;
    }

    return React.cloneElement(children, { ref: elementRef });
};

export default MutateObserver;

useMutateObserver 的 hook 封装了 MutationObserver 的调用。

而 MutateObserver 组件封装了 ref 的获取。

通过 React.cloneElement 给 children 加上 ref 来获取 dom 节点。

然后在 useLayoutEffect 里拿到 ref 通过 setState 触发更新。

再次渲染的时候,调用 useMutateObserver 就有 dom 了,可以用 MutationObserver 来监听 dom 变化。

用一下:

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

export default function App() {
    const [className, setClassName] = useState("aaa");

    useEffect(() => {
        setTimeout(() => setClassName("bbb"), 2000);
    }, []);

    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>
    );
}

效果一样:

但是现在不用再 useRef 获取 ref 了,MutateObserver 里会做 ref 的获取,然后用 useMutateObserver 来监听。

这个组件和 hook 的封装都算是有用的。

再来看一个

CopyToClipboard

有这样一个周下载量百万级的组件:

它是做复制的。

基于 copy-to-clipboard 这个包。

我们也来写写看。

直接用 copy-to-clipboard 是这样的:

import copy from "copy-to-clipboard";

export default function App() {
    function onClick() {
        const res = copy("神说要有光666");
        console.log("done", res);
    }

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

用 react-copy-to-clipboard 是这样的:

import { CopyToClipboard } from "react-copy-to-clipboard";

export default function App() {
    return (
        <CopyToClipboard
            text={"神说要有光2"}
            onCopy={() => {
                console.log("done");
            }}>
            <div>复制</div>
        </CopyToClipboard>
    );
}

如果元素本来有 onClick 的处理:

import { CopyToClipboard } from "react-copy-to-clipboard";

export default function App() {
    return (
        <CopyToClipboard
            text={"神说要有光2"}
            onCopy={() => {
                console.log("done");
            }}>
            <div onClick={() => alert(1)}>复制</div>
        </CopyToClipboard>
    );
}

只会在原来的基础上添加复制的功能:

我们也来实现下这个组件:

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

interface CopyToClipboardProps {
    text: string;
    onCopy?: (text: string, result: boolean) => void;
    children: ReactElement;
    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);

        if (onCopy) {
            onCopy(text, result);
        }

        if (typeof elem?.props?.onClick === "function") {
            elem.props.onClick(event);
        }
    }

    return React.cloneElement(elem, { onClick });
};

export default CopyToClipboard;

React.Children.only 是用来断言 children 只有一个元素,如果不是就报错:

然后用 cloneElement 给元素加上 onClick 事件,执行复制,并且还会调用元素原来的 onClick 事件:

换成我们自己的组件:

效果一样:

这个组件也挺简单的,作用就是被包装的元素,在原来的 click 事件处理函数的基础上,多了复制文本的功能。

也算是有用的,不用把 copy 写的 onClick 函数里了。

总结

今天我们实现了三个 react 组件,它们是对 api 的简单封装。

直接用这些 api 也挺简单,但是封装一下会多一些额外的好处。

Portal 组件:对 createPortal 的封装,多了根据 string 选择 attach 节点,自动创建 container 的 dom 的功能

MutateObserver 组件:对 MutationObserver 的封装,通过 cloneElement 实现了内部自动获取 ref 然后监听的功能,省去了调用方获取 ref 的麻烦。

CopyToClipboard 组件:对 copy-to-clipboard 包的封装,不用侵入元素的 onClick 处理函数,只是额外多了复制的功能

这三个 api,直接用也是很简单的,可封装也可不封装。

你会选择直接用,还是封装成组件呢?

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
15.React.Children和它的两种替代方案
Next
17.浏览器的5种Observer