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

当应用加了新功能的时候,都会通过这种方式来告诉用户怎么用:

这种组件叫做 OnBoarding 或者 Tour。

在 antd5 也加入了这种组件:

那它是怎么实现的呢?

调试下可以发现,遮罩层由 4 个 rect 元素组成。

当点击上一步、下一步的时候,遮罩层的宽高会变化:

加上 transition,就产生了上面的动画效果。

其实还可以进一步简化一下:

用一个 div,设置 width、height 还有上下左右不同的 border-width。

点击上一步、下一步的时候,修改 width、height、border-width,也能达到一样的效果。

比起 antd 用 4 个 rect 来实现,更简洁一些。

原理就是这样,还是挺简单的。

下面我们来写一下:

npx create-vite

创建个 vite + react 的项目。

进入项目,把 index.css 的样式去掉:

然后新建 OnBoarding/Mask.tsx

import React, { CSSProperties, useEffect, useState } from "react";
import { getMaskStyle } from "./getMaskStyle";

interface MaskProps {
    element: HTMLElement;

    container?: HTMLElement;

    renderMaskContent?: (wrapper: React.ReactNode) => React.ReactNode;
}

export const Mask: React.FC<MaskProps> = (props) => {
    const { element, renderMaskContent, container } = props;

    const [style, setStyle] = useState<CSSProperties>({});

    useEffect(() => {
        if (!element) {
            return;
        }

        element.scrollIntoView({
            block: "center",
            inline: "center",
        });

        const style = getMaskStyle(
            element,
            container || document.documentElement
        );

        setStyle(style);
    }, [element, container]);

    const getContent = () => {
        if (!renderMaskContent) {
            return null;
        }
        return renderMaskContent(
            <div
                className={"mask-content"}
                style={{ width: "100%", height: "100%" }}
            />
        );
    };

    return (
        <div style={style} className="mask">
            {getContent()}
        </div>
    );
};

这里传入的 element、container 分别是目标元素、遮罩层所在的容器。

而 getMaskContent 是用来定制这部分内容的:

可以是 Popover 也可以是别的。

前面分析过,主要是确定目标元素的 width、height、border-width。

首先,把目标元素滚动到可视区域:

这个用 scrollIntoView 方法实现:

在 MDN 上可以看到它的介绍:

设置 block、inline 为 center 是把元素中心滚动到可视区域中心的意思:

滚动完成后,就可以拿到元素的位置,计算 width、height、border-width 的样式了:

新建 OnBoarding/getMaskStyle.ts

export const getMaskStyle = (element: HTMLElement, container: HTMLElement) => {
    if (!element) {
        return {};
    }

    const { height, width, left, top } = element.getBoundingClientRect();

    const elementTopWithScroll = container.scrollTop + top;
    const elementLeftWithScroll = container.scrollLeft + left;

    return {
        width: container.scrollWidth,
        height: container.scrollHeight,
        borderTopWidth: Math.max(elementTopWithScroll, 0),
        borderLeftWidth: Math.max(elementLeftWithScroll, 0),
        borderBottomWidth: Math.max(
            container.scrollHeight - height - elementTopWithScroll,
            0
        ),
        borderRightWidth: Math.max(
            container.scrollWidth - width - elementLeftWithScroll,
            0
        ),
    };
};

width、height 就是容器的包含滚动区域的宽高。

然后 border-width 分为上下左右 4 个方向:

top 和 left 的分别用 scrollTop、scrollLeft 和元素在可视区域里的 left、top 相加计算出来。

bottom 和 right 的就用容器的包含滚动区域的高度宽度 scrollHeight、scrollWidth 减去 height、width 再减去 scrollTop、scrollLeft 计算出来。

然后我们在内部又加了一个宽高为 100% 的 div,把它暴露出去,外部就可以用它来加 Popover 或者其他内容:

然后在 OnBoarding/index.scss 里写下样式:

.mask {
    position: absolute;
    left: 0;
    top: 0;

    z-index: 999;

    border-style: solid;
    box-sizing: border-box;
    border-color: rgba(0, 0, 0, 0.6);

    transition: all 0.2s ease-in-out;
}

mask 要绝对定位,然后设置下 border 的颜色。

我们先测试下现在的 Mark 组件:

把开发服务跑起来:

npm install
npm run dev

我们就在 logo 上试一下吧:

<Mask
    element={document.getElementById("xxx")!}
    renderMaskContent={(wrapper) => {
        return wrapper;
    }}></Mask>

container 就是默认的根元素。

内容我们先不加 Popover。

看一下效果:

没啥问题。

然后加上 Popover 试试。

安装 antd:

npm install --save antd

然后引入下:

<Mask
    element={document.getElementById("xxx")!}
    renderMaskContent={(wrapper) => {
        return (
            <Popover
                content={
                    <div style={{ width: 300 }}>
                        <p>hello</p>
                        <Button type="primary">下一步</Button>
                    </div>
                }
                open={true}>
                {wrapper}
            </Popover>
        );
    }}></Mask>

没啥问题。

接下来在外面包装一层,改下 Popover 的样式就行了。

我们希望 OnBoarding 组件可以这么用:

传入 steps,包含每一步在哪个元素(selector),显示什么内容(renderConent),在什么方位(placement)。

所以类型这样写:

并且还有 beforeForward、beforeBack 也就是点上一步、下一步的回调。

step 是可以直接指定显示第几步。

onStepsEnd 是在全部完成后的回调。

内部有一个 state 来记录 currentStep,点击上一步、下一步会切换:

在切换前也会调用 beforeBack、beforeForward 的回调。

然后准备下 Popover 的内容:

渲染下:

这里用 createPortal 把 mask 渲染到容器元素下,比如 document.body。

注意,我们要给元素加上引导,那得元素渲染完才行。

所以这里加个 setState,在 useEffect 里执行。

效果就是在 dom 渲染完之后,触发重新渲染,从而渲染这个 OnBoarding 组件:

第一次渲染的时候,元素是 null,触发重新渲染之后,就会渲染下面的 Mask 了:

Onboarding/index.tsx 的全部代码如下:

import React, { FC, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Button, Popover } from "antd";
import { Mask } from "./Mask";
import { TooltipPlacement } from "antd/es/tooltip";
import "./index.scss";

export interface OnBoardingStepConfig {
    selector: () => HTMLElement | null;

    placement?: TooltipPlacement;

    renderContent?: (currentStep: number) => React.ReactNode;

    beforeForward?: (currentStep: number) => void;

    beforeBack?: (currentStep: number) => void;
}

export interface OnBoardingProps {
    step?: number;

    steps: OnBoardingStepConfig[];

    getContainer?: () => HTMLElement;

    onStepsEnd?: () => void;
}

export const OnBoarding: FC<OnBoardingProps> = (props) => {
    const { step = 0, steps, onStepsEnd, getContainer } = props;

    const [currentStep, setCurrentStep] = useState<number>(0);

    const currentSelectedElement = steps[currentStep]?.selector();

    const currentContainerElement =
        getContainer?.() || document.documentElement;

    const getCurrentStep = () => {
        return steps[currentStep];
    };

    const back = async () => {
        if (currentStep === 0) {
            return;
        }

        const { beforeBack } = getCurrentStep();
        await beforeBack?.(currentStep);
        setCurrentStep(currentStep - 1);
    };

    const forward = async () => {
        if (currentStep === steps.length - 1) {
            await onStepsEnd?.();
            return;
        }

        const { beforeForward } = getCurrentStep();
        await beforeForward?.(currentStep);
        setCurrentStep(currentStep + 1);
    };

    useEffect(() => {
        setCurrentStep(step!);
    }, [step]);

    const renderPopover = (wrapper: React.ReactNode) => {
        const config = getCurrentStep();
        if (!config) {
            return wrapper;
        }

        const { renderContent } = config;
        const content = renderContent ? renderContent(currentStep) : null;

        const operation = (
            <div className={"onboarding-operation"}>
                {currentStep !== 0 && (
                    <Button className={"back"} onClick={() => back()}>
                        {"上一步"}
                    </Button>
                )}
                <Button
                    className={"forward"}
                    type={"primary"}
                    onClick={() => forward()}>
                    {currentStep === steps.length - 1 ? "我知道了" : "下一步"}
                </Button>
            </div>
        );

        return (
            <Popover
                content={
                    <div>
                        {content}
                        {operation}
                    </div>
                }
                open={true}
                placement={getCurrentStep()?.placement}>
                {wrapper}
            </Popover>
        );
    };

    const [, setRenderTick] = useState<number>(0);

    useEffect(() => {
        setRenderTick(1);
    }, []);

    if (!currentSelectedElement) {
        return null;
    }

    const mask = (
        <Mask
            container={currentContainerElement}
            element={currentSelectedElement}
            renderMaskContent={(wrapper) => renderPopover(wrapper)}
        />
    );

    return createPortal(mask, currentContainerElement);
};

其实这个组件主要就是切换上一步下一步用的。

然后加下上一步下一步按钮的样式:

.onboarding-operation {
    width: 100%;
    display: flex;
    justify-content: flex-end;
    margin-top: 12px;

    .back {
        margin-right: 12px;
        min-width: 80px;
    }

    .forward {
        min-width: 80px;
    }
}

在 App.tsx 里测试下:

import { OnBoarding } from "./OnBoarding";
import { Button, Flex } from "antd";

function App() {
    return (
        <div className="App">
            <Flex gap="small" wrap="wrap" id="btn-group1">
                <Button type="primary">Primary Button</Button>
                <Button>Default Button</Button>
                <Button type="dashed">Dashed Button</Button>
                <Button type="text">Text Button</Button>
                <Button type="link">Link Button</Button>
            </Flex>

            <div style={{ height: "1000px" }}></div>

            <Flex wrap="wrap" gap="small">
                <Button type="primary" danger>
                    Primary
                </Button>
                <Button danger>Default</Button>
                <Button type="dashed" danger id="btn-group2">
                    Dashed
                </Button>
                <Button type="text" danger>
                    Text
                </Button>
                <Button type="link" danger>
                    Link
                </Button>
            </Flex>

            <div style={{ height: "500px" }}></div>

            <Flex wrap="wrap" gap="small">
                <Button type="primary" ghost>
                    Primary
                </Button>
                <Button ghost>Default</Button>
                <Button type="dashed" ghost>
                    Dashed
                </Button>
                <Button type="primary" danger ghost id="btn-group3">
                    Danger
                </Button>
            </Flex>

            <OnBoarding
                steps={[
                    {
                        selector: () => {
                            return document.getElementById("btn-group1");
                        },
                        renderContent: () => {
                            return "神说要有光";
                        },
                        placement: "bottom",
                    },
                    {
                        selector: () => {
                            return document.getElementById("btn-group2");
                        },
                        renderContent: () => {
                            return "于是就有了光";
                        },
                        placement: "bottom",
                    },
                    {
                        selector: () => {
                            return document.getElementById("btn-group3");
                        },
                        renderContent: () => {
                            return "你相信光么";
                        },
                        placement: "bottom",
                    },
                ]}
            />
        </div>
    );
}

export default App;

我用 id 选中了三个元素:

指定三步的元素和渲染的内容:

跑一下:

没啥问题,选中的元素、mask 的样式都是对的。

只是现在结束后,mask 不会消失:

这个加个状态标识就好了:

此外,还有两个小问题:

一个是在窗口改变大小的时候,没有重新计算 mask 样式:

这个在 Mask 组件里用 ResizeObserver 监听下 container 大小改变就好了:

useEffect(() => {
    const observer = new ResizeObserver(() => {
        const style = getMaskStyle(
            element,
            container || document.documentElement
        );

        setStyle(style);
    });
    observer.observe(container || document.documentElement);
}, []);

变了重新计算和设置 mask 的 style。

再就是现在 popover 位置会闪一下:

那是因为 mask 的样式变化有个动画的过程,要等动画结束计算的 style 才准确。

所以给 Mask 组件加一个动画开始和结束的回调:

import React, { CSSProperties, useEffect, useState } from "react";
import { getMaskStyle } from "./getMaskStyle";

import "./index.scss";

interface MaskProps {
    element: HTMLElement;

    container?: HTMLElement;

    renderMaskContent?: (wrapper: React.ReactNode) => React.ReactNode;

    onAnimationStart?: () => void;

    onAnimationEnd?: () => void;
}

export const Mask: React.FC<MaskProps> = (props) => {
    const {
        element,
        renderMaskContent,
        container,
        onAnimationStart,
        onAnimationEnd,
    } = props;

    useEffect(() => {
        onAnimationStart?.();
        const timer = setTimeout(() => {
            onAnimationEnd?.();
        }, 200);

        return () => {
            window.clearTimeout(timer);
        };
    }, [element]);

    const [style, setStyle] = useState<CSSProperties>({});

    useEffect(() => {
        const observer = new ResizeObserver(() => {
            const style = getMaskStyle(
                element,
                container || document.documentElement
            );

            setStyle(style);
        });
        observer.observe(container || document.documentElement);
    }, []);

    useEffect(() => {
        if (!element) {
            return;
        }

        element.scrollIntoView({
            block: "center",
            inline: "center",
        });

        const style = getMaskStyle(
            element,
            container || document.documentElement
        );

        setStyle(style);
    }, [element, container]);

    const getContent = () => {
        if (!renderMaskContent) {
            return null;
        }
        return renderMaskContent(
            <div
                className={"mask-content"}
                style={{ width: "100%", height: "100%" }}
            />
        );
    };

    return (
        <div style={style} className="mask">
            {getContent()}
        </div>
    );
};

然后在 OnBoarding 组件加一个 state:

动画开始和结束修改这个 state:

动画结束才会渲染 Popover:

这样 Popover 位置就不会闪了:

import React, { FC, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Button, Popover } from "antd";
import { Mask } from "./Mask";
import { TooltipPlacement } from "antd/es/tooltip";

export interface OnBoardingStepConfig {
    selector: () => HTMLElement | null;

    placement?: TooltipPlacement;

    renderContent?: (currentStep: number) => React.ReactNode;

    beforeForward?: (currentStep: number) => void;

    beforeBack?: (currentStep: number) => void;
}

export interface OnBoardingProps {
    step?: number;

    steps: OnBoardingStepConfig[];

    getContainer?: () => HTMLElement;

    onStepsEnd?: () => void;
}

export const OnBoarding: FC<OnBoardingProps> = (props) => {
    const { step = 0, steps, onStepsEnd, getContainer } = props;

    const [currentStep, setCurrentStep] = useState<number>(0);

    const currentSelectedElement = steps[currentStep]?.selector();

    const currentContainerElement =
        getContainer?.() || document.documentElement;

    const [done, setDone] = useState(false);

    const [isMaskMoving, setIsMaskMoving] = useState<boolean>(false);

    const getCurrentStep = () => {
        return steps[currentStep];
    };

    const back = async () => {
        if (currentStep === 0) {
            return;
        }

        const { beforeBack } = getCurrentStep();
        await beforeBack?.(currentStep);
        setCurrentStep(currentStep - 1);
    };

    const forward = async () => {
        if (currentStep === steps.length - 1) {
            await onStepsEnd?.();
            setDone(true);
            return;
        }

        const { beforeForward } = getCurrentStep();
        await beforeForward?.(currentStep);
        setCurrentStep(currentStep + 1);
    };

    useEffect(() => {
        setCurrentStep(step!);
    }, [step]);

    const renderPopover = (wrapper: React.ReactNode) => {
        const config = getCurrentStep();

        if (!config) {
            return wrapper;
        }

        const { renderContent } = config;
        const content = renderContent ? renderContent(currentStep) : null;

        const operation = (
            <div className={"onboarding-operation"}>
                {currentStep !== 0 && (
                    <Button className={"back"} onClick={() => back()}>
                        {"上一步"}
                    </Button>
                )}
                <Button
                    className={"forward"}
                    type={"primary"}
                    onClick={() => forward()}>
                    {currentStep === steps.length - 1 ? "我知道了" : "下一步"}
                </Button>
            </div>
        );

        return isMaskMoving ? (
            wrapper
        ) : (
            <Popover
                content={
                    <div>
                        {content}
                        {operation}
                    </div>
                }
                open={true}
                placement={getCurrentStep()?.placement}>
                {wrapper}
            </Popover>
        );
    };

    const [, setRenderTick] = useState<number>(0);

    useEffect(() => {
        setRenderTick(1);
    }, []);

    if (!currentSelectedElement || done) {
        return null;
    }

    const mask = (
        <Mask
            onAnimationStart={() => {
                setIsMaskMoving(true);
            }}
            onAnimationEnd={() => {
                setIsMaskMoving(false);
            }}
            container={currentContainerElement}
            element={currentSelectedElement}
            renderMaskContent={(wrapper) => renderPopover(wrapper)}
        />
    );

    return createPortal(mask, currentContainerElement);
};

组件外部通过 step 的 props 来切换上一步下一步。

那如果想直接调用 forward、back 的方法来切换上一步下一步呢?

这种可以通过 forwardRef + useImperativeHandle 来暴露 api 出去。

案例代码上传了小册仓库

总结

今天我们实现了 OnBoarding 组件,就是 antd5 里加的 Tour 组件。

antd 里是用 4 个 rect 元素实现的,我们是用一个 div 设置 width、height、四个方向不同的 border-width 实现的。

通过设置 transition,然后改变 width、height、border-width 就可以实现 mask 移动的动画。

然后我们在外层封装了一层,加上了上一步下一步的切换。

并且用 ResizeObserver 在窗口改变的时候重新计算 mask 样式。

此外,还要注意,mask 需要在 dom 树渲染完之后才能拿到 dom 来计算样式,所以需要 useEffect + setState 来触发一次额外渲染。

这样,OnBoarding 组件就完成了。

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
35.组件实战:ColorPicker颜色选择器(二)
Next
37.组件实战:Upload拖拽上传