Skip to content

本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com

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

这种组件叫做 OnBoarding 或者 Tour。

在 antd5 也加入了这种组件:

那它是怎么实现的呢?

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

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

加上 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 class>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);}

案例代码上传了 react 小册仓库:https://github.com/QuarkGluonPlasma/react-course-code/tree/main/onboarding-component

总结

今天我们实现了 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 组件就完成了。