• 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.小册总结

组件库一般都有 Popover 和 Tooltip 这两个组件,它们非常相似。

不过应用场景是有区别的:

Tooltip(文字提示) 是用来代替 title 的,做一个文案解释。

而 Popover(气泡卡片)可以放更多的内容,可以交互:

所以说,这俩虽然长得差不多,但确实要分为两个组件来写。

这个组件看起来比较简单,但实现起来很麻烦。

你可能会说,不就是写好样式,然后绝对定位到元素上面么?

不只是这样。

首先,placement 参数可以指定 12 个方向,top、topleft、topright、bottom 等:

这些不同方向的位置计算都要实现。

而且,就算你指定了 left,当左边空间不够的时候,也得做下处理,展示在右边:

而且当方向不同时,箭头的显示位置也不一样:

所以要实现这样一个 Popover 组件,光计算浮层的显示位置就是不小的工作量。

不过好在这种场景有专门的库做了封装,完全没必要自己写。

它就是 floating-ui。

看介绍就可以知道,它是专门用来创建 tooltip、popover、dropdown 这类浮动的元素的。

它的 logo 也很形象:

那它怎么用呢?

我们新建个项目试试看:

npx create-vite

用 create-vite 创建个 react 项目。

进入项目,安装依赖,然后把服务跑起来:

npm install
npm run dev

没啥问题。

改下 main.tsx,去掉 index.css,并且把 StrictMode 去掉,它会导致重复渲染:

然后安装下 floating-ui 的包:

npm install --save @floating-ui/react

改下 App.tsx

import { useInteractions, useFloating, useHover } from "@floating-ui/react";
import { useState } from "react";

export default function App() {
    const [isOpen, setIsOpen] = useState(false);

    const { refs, floatingStyles, context } = useFloating({
        open: isOpen,
        onOpenChange: setIsOpen,
    });

    const hover = useHover(context);

    const { getReferenceProps, getFloatingProps } = useInteractions([hover]);

    return (
        <>
            <button ref={refs.setReference} {...getReferenceProps()}>
                hello
            </button>
            {isOpen && (
                <div
                    ref={refs.setFloating}
                    style={floatingStyles}
                    {...getFloatingProps()}>
                    光光光光光
                </div>
            )}
        </>
    );
}

先看看效果:

可以看到,hover 的时候浮层会在下面出现。

看下代码:

首先,用到了 useFloating 这个 hook,它的作用就是计算浮层位置的。

给它相对的元素、浮层元素的 ref,它就会计算出浮层的 style 来。

它可以指定浮层出现的方向:

比如当 placement 指定为 right 时,效果就是这样的:

再就是 useInteractions 这个 hook:

你可以传入 click、hover 等交互方式,然后把它返回的 props 设置到元素上,就可以绑定对应的交互事件。

比如把交互事件换成 click:

现在就是点击的时候浮层出现和消失了:

不过现在有个问题:

只有点击按钮,浮层才会消失,点击其他位置不会。

这时候可以加上 dismiss 的处理:

现在点击其它位置,浮层就会消失,并且按 ESC 键也会消失:

也就是说 useFloating 是用来给浮层确定位置的,useInteractions 是用来绑定交互事件的。

有的同学会说,这也不好看啊。

我们加一下样式就好了:

加上 className,然后在 App.css 里写下样式:

.popover-floating {
    padding: 4px 8px;
    border: 1px solid #000;
    border-radius: 4px;
}

引入看下:

但是现在的定位有点问题,离着太近了,能不能修改下定位呢?

可以。

加一个 offset 的 middleware 就好了:

它的效果就是修改两者距离的:

箭头也不用自己写,有对应的中间件:

import {
    useInteractions,
    useFloating,
    useHover,
    useClick,
    useDismiss,
    offset,
    arrow,
    FloatingArrow,
} from "@floating-ui/react";
import { useRef, useState } from "react";

import "./App.css";

export default function App() {
    const arrowRef = useRef(null);

    const [isOpen, setIsOpen] = useState(false);

    const { refs, floatingStyles, context } = useFloating({
        open: isOpen,
        onOpenChange: setIsOpen,
        placement: "right",
        middleware: [
            offset(10),
            arrow({
                element: arrowRef,
            }),
        ],
    });

    const click = useClick(context);
    const dismiss = useDismiss(context);

    const { getReferenceProps, getFloatingProps } = useInteractions([
        click,
        dismiss,
    ]);

    return (
        <>
            <button ref={refs.setReference} {...getReferenceProps()}>
                hello
            </button>
            {isOpen && (
                <div
                    className="popover-floating"
                    ref={refs.setFloating}
                    style={floatingStyles}
                    {...getFloatingProps()}>
                    光光光
                    <FloatingArrow ref={arrowRef} context={context} />
                </div>
            )}
        </>
    );
}

这样箭头就有了。

只不过样式不大对,我们修改下:

<FloatingArrow
    ref={arrowRef}
    context={context}
    fill="#fff"
    stroke="#000"
    strokeWidth={1}
/>

这样,箭头位置就有了。

给 button 加一些 margin,我们试试其它位置的 popover 对不对:

分别设置不同 placement:

top-end

left-start

left

都没问题。

不过现在并没有做边界的处理:

设置 top 的时候,浮层超出可视区域,这时候应该显示在下面。

加上 flip 中间件就好了:

这样,popover 的功能就完成了。

我们封装下 Popover 组件。

新建 Popover/index.tsx

import { CSSProperties, PropsWithChildren, ReactNode } from "react";
import {
    useInteractions,
    useFloating,
    useClick,
    useDismiss,
    offset,
    arrow,
    FloatingArrow,
    flip,
    useHover,
} from "@floating-ui/react";
import { useRef, useState } from "react";
import "./index.css";

type Alignment = "start" | "end";
type Side = "top" | "right" | "bottom" | "left";
type AlignedPlacement = `${Side}-${Alignment}`;

interface PopoverProps extends PropsWithChildren {
    content: ReactNode;
    trigger?: "hover" | "click";
    placement?: Side | AlignedPlacement;
    open?: boolean;
    onOpenChange?: (open: boolean) => void;
    className?: string;
    style?: CSSProperties;
}

export default function Popover(props: PopoverProps) {
    const {
        open,
        onOpenChange,
        content,
        children,
        trigger = "hover",
        placement = "bottom",
        className,
        style,
    } = props;

    const arrowRef = useRef(null);

    const [isOpen, setIsOpen] = useState(open);

    const { refs, floatingStyles, context } = useFloating({
        open: isOpen,
        onOpenChange: (open) => {
            setIsOpen(open);
            onOpenChange?.(open);
        },
        placement,
        middleware: [
            offset(10),
            arrow({
                element: arrowRef,
            }),
            flip(),
        ],
    });

    const interaction =
        trigger === "hover" ? useHover(context) : useClick(context);

    const dismiss = useDismiss(context);

    const { getReferenceProps, getFloatingProps } = useInteractions([
        interaction,
        dismiss,
    ]);

    return (
        <>
            <span
                ref={refs.setReference}
                {...getReferenceProps()}
                className={className}
                style={style}>
                {children}
            </span>
            {isOpen && (
                <div
                    className="popover-floating"
                    ref={refs.setFloating}
                    style={floatingStyles}
                    {...getFloatingProps()}>
                    {content}
                    <FloatingArrow
                        ref={arrowRef}
                        context={context}
                        fill="#fff"
                        stroke="#000"
                        strokeWidth={1}
                    />
                </div>
            )}
        </>
    );
}

Popover/index.css

.popover-floating {
    padding: 4px 8px;
    border: 1px solid #000;
    border-radius: 4px;
}

整体代码和之前差不多,有几处不同:

参数继承 PropsWithChildren,可以传入 children 参数。

可以传入 content,也就是浮层的内容。

trigger 参数是触发浮层的方式,可以是 click 或者 hover。

placement 就是 12 个方向。

而 open、onOpenChange 则是可以在组件外控制 popover 的显示隐藏。

className 和 style 设置到内层的 span 元素上:

在 App.tsx 里引入下:

import Popover from "./Popover";

export default function App() {
    const popoverContent = (
        <div>
            光光光
            <button
                onClick={() => {
                    alert(1);
                }}>
                111
            </button>
        </div>
    );

    return (
        <Popover
            content={popoverContent}
            placement="bottom"
            trigger="click"
            style={{ margin: "200px" }}>
            <button>点我点我</button>
        </Popover>
    );
}

这样,Popover 组件的基本功能就完成了。

但现在 Popover 组件还有个问题:

浮层使用 position:absolute 定位的,应该是相对于 body 定位,但如果中间有个元素也设置了 position: relative 或者 absolute,那样定位就是相对于那个元素了。

所以,要把浮层用 createPortal 渲染到 body 之下。

const el = useMemo(() => {
    const el = document.createElement("div");
    el.className = `wrapper`;

    document.body.appendChild(el);
    return el;
}, []);

const floating = isOpen && (
    <div
        className="popover-floating"
        ref={refs.setFloating}
        style={floatingStyles}
        {...getFloatingProps()}>
        {content}
        <FloatingArrow
            ref={arrowRef}
            context={context}
            fill="#fff"
            stroke="#000"
            strokeWidth={1}
        />
    </div>
);

return (
    <>
        <span
            ref={refs.setReference}
            {...getReferenceProps()}
            className={className}
            style={style}>
            {children}
        </span>
        {createPortal(floating, el)}
    </>
);

这样,Popover 浮层就渲染到了 body 下:

至此,Popover 组件就封装完了。

其实 floating-ui 用的非常多,比如下一节会讲的 click-to-react-component,它就用到了 floating-ui 来实现的:

案例代码上传了小册仓库

总结

今天我们封装了 Popover 组件。

如果完全自己实现,计算位置还是挺麻烦的,有 top、right、left 等不同位置,而且到达边界的时候也要做特殊处理。

所以我们直接基于 floating-ui 来做,它是专门用于 tooltip、popover、dropdown 等浮动组件的。

用 useFloating 的 hook 来计算位置,用 useIntersections 的 hook 来处理交互。

它支持很多中间件,比如 offset 来设置偏移、arrow 来处理箭头位置,可以完成各种复杂的定位功能。

我们封装了一层,加了一些参数,然后把浮层用 createPortal 渲染到了 body 下。

这样就是一个功能完整的 Popover 组件了。

如果完全自己实现 Popover 组件,还是挺麻烦的,但是基于 floating-ui 封装,就很简单。

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
30.组件实战:Message全局提示组件
Next
32.项目里如何快速定位组件源码