Featured image of post 如何打造一个完善的跨平台 Tour 引导组件

如何打造一个完善的跨平台 Tour 引导组件

基于 Taro 实现

前言——为什么我们需要 Tour 组件

用户第一次使用我们的产品或者产品添加新功能时,我们通常需要让用户第一时间感知到功能的变化、使用方式等。一个最简单有效的方法就是,带着用户走一遍流程,这就是 Tour 组件所实现的事。

目前在古茗内部的几个应用如门店宝和古茗学院等都在使用引导,但是都是基于绝对定位+图片/重新渲染一遍高亮组件的方式实现,使得编写和维护起来非常困难。并且代码完全不具备任何可读性,因此我们需要一个统一的引导组件去帮我们做这些脏活,提高代码的可读性和可维护性。

如何实现一个 Tour 组件

参照一些老牌的 Tour 组件如 Driver.jsreact.tours 等三方库,其实现方式大体都是基于 svg 绘制蒙层,给目标高亮元素挖洞的形式,我们首先总结一下一个好的 Tour 组件需要具备的几个要素:

  1. 配置化:我们不关心元素的位置,只需要指定元素的 selector,组件会自动替我们计算出需要高亮的元素所在的位置。并且我们只需要配置引导的顺序,也不需要关心引导触发的时机。
  2. 动态化:元素的位置发生变化时,或者元素存在动画时,我们需要能够及时感知并做好重绘。我们可以在指定的时机主动触发引导的启闭,也可以在引导已经触发后动态更改引导的相关配置。
  3. 跨平台:我们的组件需要是平台无关的,不管是小程序、Web 还是 RN 场景,都能够适配。

基于这样的目标我们开始着手实现

配置化

API 设计

配置化能力参照 ReacTour 的调用方式,是基于 Context 让所有组件共享一个引导域,然后在组件内调用对应方法来触发

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { TourProvider } from '@reactour/tour'

ReactDOM.render(
  <TourProvider steps={steps}>
    <App />
  </TourProvider>,
  document.getElementById('root')
)

const steps = [
  {
    selector: '.first-step',
    content: 'This is my first Step',
  },
  // ...
]

// App.tsx
import { useTour } from '@reactour/tour'

function App() {
  const { setIsOpen } = useTour()
  return (
    <>
      <p className="first-step">
        Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent at
        finibus nulla, quis varius justo. Vestibulum lorem lorem, viverra porta
        metus nec, porta luctus orci
      </p>
      <button onClick={() => setIsOpen(true)}>Open Tour</button>
    </>
  )
}

而 DriverJS 虽然是纯 js 库,但是 API 同样简单精炼:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { driver } from "driver.js";
import "driver.js/dist/driver.css";

const driverObj = driver({
  showProgress: true,
  steps: [
    { element: '.page-header', popover: { title: 'Title', description: 'Description' } },
    { element: '.top-nav', popover: { title: 'Title', description: 'Description' } },
    { element: '.sidebar', popover: { title: 'Title', description: 'Description' } },
    { element: '.footer', popover: { title: 'Title', description: 'Description' } },
  ]
});

driverObj.drive();

// or just invoke once
driverObj.highlight({
  element: '#some-element',
  popover: {
    title: 'Title for the Popover',
    description: 'Description for it',
  },
});

但是这两者其实都有个问题,就是我们的配置信息可能不是固定的,例如古茗学院学习地图的引导,是需要根据后端返回的内容来进入对应页面的,所以写死的配置其实并不是一个最优解,我们可能仍然需要在每个涉及到引导的页面去做单独的 runtime configure,调用方式如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import { TourProvider } from '@/components/tour'

const tourConfig = {
  total: 5,
  nextText: "下一步",
  lastText: "开始学习",
  storage: "localStorage",
  storageKey: "guide-index",
}

ReactDOM.render(
  <TourProvider config={tourConfig}>
    <App />
  </TourProvider>,
  document.getElementById('root')
)

// App.tsx
import { useTour } from '@/components/tour'

function App() {
  const { showTour } = useTour({
    step: 3,
    selector: "#guide-3"
  })

  const { data } = useRequest(service.queryHomeworkList, {
    onSuccess: (res) => {
      Taro.nextTick(() => {
        showTour(() => {
        	Taro.navigateTo({ url: `/pages/homework-detail?id=${res?.[0]?.id}`})
    		})
      })
    }
  })

  return (
    <div>
      {data?.map((homework, index) => (
        <HomeworkItem key={index} id={index === 0 ? "guide-3" : "" } {...homework} />
      )}
    </div>
  )
}

我们可以在运行时修改覆盖对应的配置,并且手动触发引导的显隐。

生成蒙层

接下来是引导组件最核心的一个设计:动态生成蒙层

我们知道,SVG 可以通过 Path 去绘制任何我们想要的路径,那么,我们完全可以在获取到元素属性后,给一张覆盖全屏的 SVG 蒙层挖洞。path 的 d 属性是由一个又一个命令拼合而成的,一个命令由字母+数字组合而成,例如

1
<path d="M150 0 L75 200 L225 200 Z" />

这样的一条路径,就表示「开始于位置150 0,到达位置75 200,然后从那里开始到225 200,最后在150 0关闭路径。」

具体的命令类型有如下这些:

  1. M = moveto
  2. L = lineto
  3. H = horizontal lineto
  4. V = vertical lineto
  5. C = curveto
  6. S = smooth curveto
  7. Q = quadratic Bézier curve
  8. T = smooth quadratic Bézier curveto
  9. A = elliptical Arc
  10. Z = closepath

注:以上所有命令均允许小写字母。大写表示绝对定位,小写表示相对定位

基于这些命令,我们就可以写出一个生成蒙层路径的函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
export interface StageDefinition {
  x: number;
  y: number;
  width: number;
  height: number;
}

export const generateStageSvgPathString = (stage: StageDefinition) => {
  const windowX = window.innerWidth;
  const windowY = window.innerHeight;

  const stagePadding = 8;
  const stageRadius = 5;

  const stageWidth = stage.width + stagePadding * 2;
  const stageHeight = stage.height + stagePadding * 2;

  // prevent glitches when stage is too small for radius
  const limitedRadius = Math.min(stageRadius, stageWidth / 2, stageHeight / 2);

  // no value below 0 allowed + round down
  const normalizedRadius = Math.floor(Math.max(limitedRadius, 0));

  const highlightBoxX = stage.x - stagePadding + normalizedRadius;
  const highlightBoxY = stage.y - stagePadding;
  const highlightBoxWidth = stageWidth - normalizedRadius * 2;
  const highlightBoxHeight = stageHeight - normalizedRadius * 2;

  return `M${windowX},0L0,0L0,${windowY}L${windowX},${windowY}L${windowX},0Z
    M${highlightBoxX},${highlightBoxY} h${highlightBoxWidth} a${normalizedRadius},${normalizedRadius} 0 0 1 ${normalizedRadius},${normalizedRadius} v${highlightBoxHeight} a${normalizedRadius},${normalizedRadius} 0 0 1 -${normalizedRadius},${normalizedRadius} h-${highlightBoxWidth} a${normalizedRadius},${normalizedRadius} 0 0 1 -${normalizedRadius},-${normalizedRadius} v-${highlightBoxHeight} a${normalizedRadius},${normalizedRadius} 0 0 1 ${normalizedRadius},-${normalizedRadius} z`;
};

Taro.createSelectorQuery()
  .select(selector)
	.boundingClientRect()
  .exec(([definition]) => {
    if (!definition) return;
    const stagePosition: StageDefinition = {
      x: definition.left,
      y: definition.top,
      width: definition.width,
      height: definition.height,
    };
    generateStageSvgPathString(stagePosition)
});

有了这个函数,我们就可以拼合一个 SVG 字符串,然后将其转为文件路径作为 Image 组件的传参了

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const SvgOverlay: React.FC<SvgOverlayProps> = (props) => {
  const { stagePosition } = props;

  const svgStr = `
  <svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${window.innerWidth} ${
    window.innerHeight
  }">
      <path d="${generateStageSvgPathString(stagePosition)}" />
  </svg>`;

  const svgFile = new Blob([svgStr], { type: 'image/svg+xml' });
  const svgUrl = URL.createObjectURL(SvgFile);

  return <Image className="tour-overlay" src={svgUrl} />;
};

如此以来,我们就可以生成一个蒙层了:

img

Popover 定位

有了蒙层之后,我们还需要渲染出 popover,这是一个非常复杂的逻辑,因为我们需要考虑到 popover 和 viewport 之间的关系,这里我们可以参考下 Floating UI - Create tooltips, popovers, dropdowns, and more 的实现

参考阅读:不吹牛,完爆ant deisgn的定位组件!floating-ui来也

按照 DriverJS 的实现,光是主函数就有这么长:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
export function repositionPopover(element: Element, step: DriveStep) {
  const popover = getState("popover");
  if (!popover) {
    return;
  }

  const { align = "start", side = "left" } = step?.popover || {};

  // Configure the popover positioning
  const requiredAlignment: Alignment = align;
  const requiredSide: Side = element.id === "driver-dummy-element" ? "over" : side;
  const popoverPadding = getConfig("stagePadding") || 0;

  const popoverDimensions = getPopoverDimensions()!;
  const popoverArrowDimensions = popover.arrow.getBoundingClientRect();
  const elementDimensions = element.getBoundingClientRect();

  const topValue = elementDimensions.top - popoverDimensions!.height;
  let isTopOptimal = topValue >= 0;

  const bottomValue = window.innerHeight - (elementDimensions.bottom + popoverDimensions!.height);
  let isBottomOptimal = bottomValue >= 0;

  const leftValue = elementDimensions.left - popoverDimensions!.width;
  let isLeftOptimal = leftValue >= 0;

  const rightValue = window.innerWidth - (elementDimensions.right + popoverDimensions!.width);
  let isRightOptimal = rightValue >= 0;

  const noneOptimal = !isTopOptimal && !isBottomOptimal && !isLeftOptimal && !isRightOptimal;
  let popoverRenderedSide: Side = requiredSide;

  if (requiredSide === "top" && isTopOptimal) {
    isRightOptimal = isLeftOptimal = isBottomOptimal = false;
  } else if (requiredSide === "bottom" && isBottomOptimal) {
    isRightOptimal = isLeftOptimal = isTopOptimal = false;
  } else if (requiredSide === "left" && isLeftOptimal) {
    isRightOptimal = isTopOptimal = isBottomOptimal = false;
  } else if (requiredSide === "right" && isRightOptimal) {
    isLeftOptimal = isTopOptimal = isBottomOptimal = false;
  }

  if (requiredSide === "over") {
    const leftToSet = window.innerWidth / 2 - popoverDimensions!.realWidth / 2;
    const topToSet = window.innerHeight / 2 - popoverDimensions!.realHeight / 2;

    popover.wrapper.style.left = `${leftToSet}px`;
    popover.wrapper.style.right = `auto`;
    popover.wrapper.style.top = `${topToSet}px`;
    popover.wrapper.style.bottom = `auto`;
  } else if (noneOptimal) {
    const leftValue = window.innerWidth / 2 - popoverDimensions?.realWidth! / 2;
    const bottomValue = 10;

    popover.wrapper.style.left = `${leftValue}px`;
    popover.wrapper.style.right = `auto`;
    popover.wrapper.style.bottom = `${bottomValue}px`;
    popover.wrapper.style.top = `auto`;
  } else if (isLeftOptimal) {
    const leftToSet = Math.min(
      leftValue,
      window.innerWidth - popoverDimensions?.realWidth - popoverArrowDimensions.width
    );

    const topToSet = calculateTopForLeftRight(requiredAlignment, {
      elementDimensions,
      popoverDimensions,
      popoverPadding,
      popoverArrowDimensions,
    });

    popover.wrapper.style.left = `${leftToSet}px`;
    popover.wrapper.style.top = `${topToSet}px`;
    popover.wrapper.style.bottom = `auto`;
    popover.wrapper.style.right = "auto";

    popoverRenderedSide = "left";
  } else if (isRightOptimal) {
    const rightToSet = Math.min(
      rightValue,
      window.innerWidth - popoverDimensions?.realWidth - popoverArrowDimensions.width
    );
    const topToSet = calculateTopForLeftRight(requiredAlignment, {
      elementDimensions,
      popoverDimensions,
      popoverPadding,
      popoverArrowDimensions,
    });

    popover.wrapper.style.right = `${rightToSet}px`;
    popover.wrapper.style.top = `${topToSet}px`;
    popover.wrapper.style.bottom = `auto`;
    popover.wrapper.style.left = "auto";

    popoverRenderedSide = "right";
  } else if (isTopOptimal) {
    const topToSet = Math.min(
      topValue,
      window.innerHeight - popoverDimensions!.realHeight - popoverArrowDimensions.width
    );
    let leftToSet = calculateLeftForTopBottom(requiredAlignment, {
      elementDimensions,
      popoverDimensions,
      popoverPadding,
      popoverArrowDimensions,
    });

    popover.wrapper.style.top = `${topToSet}px`;
    popover.wrapper.style.left = `${leftToSet}px`;
    popover.wrapper.style.bottom = `auto`;
    popover.wrapper.style.right = "auto";

    popoverRenderedSide = "top";
  } else if (isBottomOptimal) {
    const bottomToSet = Math.min(
      bottomValue,
      window.innerHeight - popoverDimensions?.realHeight - popoverArrowDimensions.width
    );

    let leftToSet = calculateLeftForTopBottom(requiredAlignment, {
      elementDimensions,
      popoverDimensions,
      popoverPadding,
      popoverArrowDimensions,
    });

    popover.wrapper.style.left = `${leftToSet}px`;
    popover.wrapper.style.bottom = `${bottomToSet}px`;
    popover.wrapper.style.top = `auto`;
    popover.wrapper.style.right = "auto";

    popoverRenderedSide = "bottom";
  }

  // Popover stays on the screen if the element scrolls out of the visible area.
  // Render the arrow again to make sure it's in the correct position
  // e.g. if element scrolled out of the screen to the top, the arrow should be rendered
  // pointing to the top. If the element scrolled out of the screen to the bottom,
  // the arrow should be rendered pointing to the bottom.
  if (!noneOptimal) {
    renderPopoverArrow(requiredAlignment, popoverRenderedSide, element);
  } else {
    popover.arrow.classList.add("driver-popover-arrow-none");
  }
}

还不算中间引入的其他函数。

floating ui 是以中间件的形式,通过对一次定位指定多个不同功能的中间件,共同得出最后的结果,例如一次 bottom-left 的定位可能会经历如下几个中间件:

  1. 根据高亮元素的位置算出自己绝对定位于元素左下角的位置
  2. 根据上一步的结果,计算相对视口是否产生了溢出,如果有溢出,则需要做水平/垂直方向的调整
  3. 根据上一步的结果,加上既定的偏移量
  4. 最终处理所有的参数得出结果

这样的思路,会让定位变得有迹可循非常清晰有条理。

动态化

即时重绘

在 Taro 下,路由切换是存在动画的,我们指定了一个元素绘制好蒙版之后,切换页面生成的是第一帧动画时元素所在的位置的蒙版,这显然不是我们想要的,我们需要在高亮元素位置发生改变时及时做重绘,这一点有几种实现方式:

  1. 定时器实现:我们可以指定每100毫秒就做一次重绘,但是这样会对页面造成极大的性能开销,只要有涉及到引导的页面,就会有一个定时器不停地触发重绘,对于小程序贫瘠的性能来说更是雪上加霜。
  2. MutaionObserver?:这个 API 似乎无法监听动画导致的 dom 变化,因为 dom 属性确实是没有变)

所以可能还是需要结合定时器,传入指定时间,到时之后就不再监听。

跨平台

关于跨平台的思路,可以参考 ReactDnd 的实现方式,主库单独无法使用,必须引入一个 backend 作为底层库,主库内调用的都是平台无关的抽象代码,通过 backend 底层去抹平 dom 操作的差异。

在 Taro 的前提下,我们可以不用这么做,只需要使用 Taro 提供的 DOM API 去实现即可。

结果

使用了引导组件之后,页面的代码就变得非常非常简单了,下面给出两个对比:

重构前 重构后

Built with Hugo
主题 StackJimmy 设计