完整代码:https://github.com/Xav1erSue/tour
本文于 2025 年 5 月 22 日完全重写,现为最新版
前言——为什么我们需要 Tour 组件
用户第一次使用我们的产品或者产品添加新功能时,我们通常需要让用户第一时间感知到功能的变化、使用方式等。一个最简单有效的方法就是,带着用户走一遍流程,这就是 Tour 组件所实现的事。
因此我们需要一个统一的引导组件去帮我们做这些脏活,提高代码的可读性和可维护性。
如何实现一个 Tour 组件
在着手实现之前,我们先来总结一下 Tour 组件需要具备的能力:
蒙层高亮:高亮是 Tour 组件最基本的能力之一
浮动定位:基于指定的高亮元素,我们需要将提示信息以指定的方位渲染在其身边
即时重绘:当页面发生滚动、元素发生位移,或是页面尺寸发生变化,我们要按照最新的页面尺寸以及元素位置更新高亮蒙层和浮动定位。
除了这几个能力之外,结合笔者负责业务线的特点,我们还提出了一个新的要求——跨平台,至少要同时能够支持 H5 和 Taro 小程序。
带着这些目标,我们逐一来看:
跨平台
在着手实现代码之前,我们先来聊聊跨平台的思路。
跨平台,也叫跨端,本质上是允许开发者通过前端的技术栈(HTML、CSS、JS)来编写不同设备端的应用,主流的思路如 electron 是直接打包 Chromium 来渲染网页,小程序、Tauri 等则是利用不同操作系统自带的 webview 来渲染网页,而 React Native 则做的更加彻底,直接使用原生组件渲染页面,只使用 JavaScript 引擎来执行 JS 代码。
现代前端开发离不开前端框架,实际上 React 就是一个非常适合做跨端的框架,其核心设计是平台无关的,其核心库仅包含 JS 代码,在浏览器环境中运行时,我们通过引入 React DOM 来完成页面渲染,这也是为什么诸如 RN、Taro 等跨端框架采用 React 的原因。
本文的目标只是实现一个 React 组件,我们希望的是,让这个组件可以在各种环境中都能使用,因此,我们在设计组件时,应当避免调用平台特定的一些 API,如在小程序 webview 环境无法访问 window 对象,也不能通过 document.appendChild 等方法来创建、挂载节点,其都有各自的语法。
参照一些设计的比较好的跨平台基础库,如 ReactDnD,面对此种场景的解法是将库进行分层,即平台无关的核心层,以及平台适配层,两者结合,就可以实现跨平台。
1
2
3
4
5
6
7
8
9
10
| import { HTML5Backend } from 'react-dnd-html5-backend'
import { DndProvider } from 'react-dnd'
export default function MyReactApp() {
return (
<DndProvider backend={HTML5Backend}>
/* your drag-and-drop application */
</DndProvider>
)
}
|
那么我们也可以参照这种设计,将平台相关的 API 抽象出去,在核心库内调用抽象的方法,即可适配不同的平台。我们可以写出这样的一个类:
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
| import { Promisable } from './types';
export interface Rect {
x: number;
y: number;
width: number;
height: number;
}
export interface VirtualElement {
readonly id: string;
[key: string]: any;
}
export interface VirtualWindow {
readonly innerWidth: number;
readonly innerHeight: number;
}
export abstract class Platform {
abstract readonly window: VirtualWindow;
/** 通过 id 查找元素 */
abstract getElementById(id: string): Promisable<VirtualElement | null>;
/** 通过 id 查找元素的 Rect */
abstract getElementRectById(id: string): Promisable<Rect | null>;
/** 创建元素 */
abstract createElement(): React.ReactElement;
/** 创建 SVG 元素 */
abstract createSVGImageElement(src: string): React.ReactElement;
/** 获取设备像素比 */
abstract getDevicePixelRatio(): number;
/** 监听窗口 resize 事件 */
abstract onResize(callback: () => void): { cleanup: () => void };
/** 监听窗口 scroll 事件 */
abstract onScroll(callback: () => void): { cleanup: () => void };
}
|
将我们可能需要的平台方法进行抽象定义,随后在需要适配的具体平台内重新实现这个类:
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
| class PlatformH5 extends Platform {
window = window;
getElementById(id: string) {
return document.getElementById(id) as VirtualElement;
}
getElementRectById(id: string) {
const element = document.getElementById(id);
if (!element) {
return null;
}
return element.getBoundingClientRect();
}
createElement(): React.ReactElement {
return <div />;
}
createSVGImageElement(src: string) {
return <img src={src} />;
}
getDevicePixelRatio() {
return window.devicePixelRatio;
}
onResize(callback: () => void) {
window.addEventListener('resize', callback);
return {
cleanup: () => window.removeEventListener('resize', callback),
};
}
onScroll(callback: () => void) {
window.addEventListener('scroll', callback);
return {
cleanup: () => window.removeEventListener('scroll', callback),
};
}
}
|
并且我们同样使用 Context 来透传这些参数:
1
2
3
4
5
6
7
| const App: React.FC<React.PropsWithChildren> = ({ children }) => {
return (
<TourContext.Provider value={{ platform: new PlatformH5() }}>
{children}
</TourContext.Provider>
);
};
|
并且在组件内使用 useContext 来获取这些方法,如此一来,我们就可以实现跨平台。
蒙层高亮
目前在古茗内部的几个应用如门店宝和古茗学院等项目的引导功能,都是基于绝对定位展示图片,或是简单粗暴重新渲染一遍高亮组件的方式实现的,前者要求组件不能有可变换的形态(因为图片是固定的),后者则需要保证元素离开原有层级后样式不会丢失(例如后代选择器指定的样式)
这两种方案都对业务有较强的侵入性,并且不具备通用性和灵活性,我们参考一些老牌的 Tour 组件如 Driver.js、ReacTours 等三方库,发现其实现方式大体都是基于 svg 绘制蒙层,给目标高亮元素挖洞的形式。
绘制 SVG
我们知道,SVG 可以通过 path 去绘制任何我们想要的路径,那么,我们完全可以在获取到元素属性后,给一张覆盖全屏的 SVG 蒙层挖洞。path 的 d 属性是由一个又一个命令拼合而成的,一个命令由字母+数字组合而成,例如
1
| <path d="M150 0 L75 200 L225 200 Z" />
|
这样的一条路径,就表示「开始于位置 150 0,到达位置 75 200,然后从那里开始到 225 200,最后在 150 0 关闭路径。」
具体的命令类型有如下这些:
- M = moveto
- L = lineto
- H = horizontal lineto
- V = vertical lineto
- C = curveto
- S = smooth curveto
- Q = quadratic Bézier curve
- T = smooth quadratic Bézier curveto
- A = elliptical Arc
- Z = closepath
p.s. 以上所有命令均允许小写字母。大写表示绝对定位,小写表示相对定位
基于这些命令,我们就可以写出一个生成蒙层路径的函数:
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
| interface GenerateOverlayOptions {
/** window 宽度,在浏览器环境下是 window.innerWidth */
windowInnerWidth: number;
/** window 高度,在浏览器环境下是 window.innerHeight */
windowInnerHeight: number;
/** stage padding */
stagePadding?: number;
/** stage radius */
stageRadius?: number;
}
const generateOverlaySvgPathString = (
referenceRect: Rect,
options: GenerateOverlayOptions,
) => {
const {
windowInnerWidth,
windowInnerHeight,
stagePadding = 8,
stageRadius = 5,
} = options;
const stageWidth = referenceRect.width + stagePadding * 2;
const stageHeight = referenceRect.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 = referenceRect.x - stagePadding + normalizedRadius;
const highlightBoxY = referenceRect.y - stagePadding;
const highlightBoxWidth = stageWidth - normalizedRadius * 2;
const highlightBoxHeight = stageHeight - normalizedRadius * 2;
return `M${windowInnerWidth},0L0,0L0,${windowInnerHeight}L${windowInnerWidth},${windowInnerHeight}L${windowInnerWidth},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`;
};
|
有了这个函数,我们就可以拼接出一个 SVG 图片字符串了。
渲染 SVG
有了 SVG 图片字符串,如何渲染也是一个问题,在浏览器环境中,我们可以使用 new Blob 和 URL.createObjectUrl 来将 SVG 字符串转为图片地址,然后利用 img 标签的 src 属性直接渲染 SVG 图片。
1
2
3
4
5
6
7
8
9
10
| 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);
|
但在小程序中,我们无法访问 Blob 对象,这种方案显然是不行的,我们的另一个思路是,将 SVG 图片转为 Base64 字符串,并使用 Base64 字符串渲染图片,但是很可惜小程序中仍然不支持 bota 方法,你可以自行实现一个,也可以使用现成的 base64 库如 js-base64。随后,我们再使用一个平台无关的 createSVGImageElement 方法来创建渲染 SVG 图片的元素,如此一来就可以成功渲染 SVG 图片了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| const Overlay: React.FC<OverlayProps> = (props) => {
const { referenceRect, windowInnerWidth, windowInnerHeight } = props;
const { platform, overlayClassName = 'tour-overlay' } =
useContext(TourContext);
const pathString = generateOverlaySvgPathString(referenceRect, {
windowInnerWidth,
windowInnerHeight,
});
const svgStr = `<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${windowInnerWidth} ${windowInnerHeight}"><path d="${pathString}" /></svg>`;
const svgUrl = 'data:image/svg+xml;base64,' + encode(svgStr);
const element = platform.createSVGImageElement(svgUrl);
return React.cloneElement(element, { className: overlayClassName });
};
|
效果如下:

浮动定位
有了蒙层之后,我们还需要渲染出 Popover,这里有一个浮动定位的逻辑在,就是我们经常看的环绕布局,这个有现成的库,Floating UI,Floating UI 也是一个支持跨平台的库,但是因为我们整个库都已经做了跨平台的设计,因此再实现一遍 Floating UI 的平台适配也意义不大,我们可以按照 Floating UI 的思路写一个简化版。
Floating UI 是以中间件的形式,通过对一次定位指定多个不同功能的中间件,共同得出最后的结果,例如一次 bottom-start 的定位可能会经历如下几个中间件:
- 根据高亮元素的位置算出自己绝对定位于元素左下角的位置
- 加上既定的偏移量
- 根据高亮元素的尺寸,设置浮动元素的尺寸
- 计算浮动元素相对视口是否产生了溢出,如果有溢出,则翻转处理
这样我们就可以计算出浮动元素最终的位置了。
computePosition
Floating UI 的核心是 computePosition 函数,其接受两个元素(参考元素和浮动元素)及一个配置选项,我们定义其类型如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| type Side = 'top' | 'right' | 'bottom' | 'left';
type Align = 'start' | 'end' | 'center';
type Placement = `${Side}` | `${Side}-${Align}`;
interface ComputePositionOptions {
placement?: Placement;
platform: Platform;
middlewares: Middleware<unknown>[];
}
function computePosition(
reference: VirtualElement,
floating: VirtualElement,
options: ComputePositionOptions,
): Promise<Position>;
|
首先我们使用 platform 中的 getElementRectById 方法获取参考元素和浮动元素的原始位置及尺寸:
1
2
3
4
| const [referenceRect, floatingRect] = await Promise.all([
platform.getElementRectById(reference.id),
platform.getElementRectById(floating.id),
]);
|
随后,将定位选项 placement 解析为方位和排列,并计算出不经过中间件的初始位置。这里的逻辑较为繁琐,我们可以按照方位和排列分别来看:
方位分为 top、bottom、left、right 即上下左右四个方位,如果浮动元素定位在上方,我们可以确定其 y 坐标应当为参考元素的 y 坐标减去过那个元素的高度,如下图所示:

而 x 坐标则会受到排列方式的影响,无法确定,因此我们在这一步只记录 y 坐标。其他方位计算方式也同理,我们可以得到基础坐标:
1
2
3
4
5
6
| const sideOffsets: Record<Side, Position> = {
top: { x: 0, y: referenceRect.y - floatingRect.height },
bottom: { x: 0, y: referenceRect.y + referenceRect.height },
left: { x: referenceRect.x - floatingRect.width, y: 0 },
right: { x: referenceRect.x + referenceRect.width, y: 0 },
};
|
再结合排列,如 top-end,我们就可以确定其 x 坐标了,如下图所示:

同理,我们可以得到结合了 side 和 align 的坐标计算结果:
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
| const alignOffsets: Record<Align, Record<Side, Position>> = {
start: {
top: { x: referenceRect.x, y: 0 },
bottom: { x: referenceRect.x, y: 0 },
left: {
x: 0,
y: referenceRect.y + (referenceRect.height - floatingRect.height) / 2,
},
right: {
x: 0,
y: referenceRect.y + (referenceRect.height - floatingRect.height) / 2,
},
},
end: {
top: {
x: referenceRect.x + referenceRect.width - floatingRect.width,
y: 0,
},
bottom: {
x: referenceRect.x + referenceRect.width - floatingRect.width,
y: 0,
},
left: {
x: 0,
y: referenceRect.y + (referenceRect.height - floatingRect.height) / 2,
},
right: {
x: 0,
y: referenceRect.y + (referenceRect.height - floatingRect.height) / 2,
},
},
center: {
top: {
x: referenceRect.x + (referenceRect.width - floatingRect.width) / 2,
y: 0,
},
bottom: {
x: referenceRect.x + (referenceRect.width - floatingRect.width) / 2,
y: 0,
},
left: {
x: 0,
y: referenceRect.y + (referenceRect.height - floatingRect.height) / 2,
},
right: {
x: 0,
y: referenceRect.y + (referenceRect.height - floatingRect.height) / 2,
},
},
};
|
最终将两者相结合,就可以得到基础的坐标定位了:
1
2
3
4
5
| const sideOffset = sideOffsets[side];
const alignOffset = alignOffsets[align][side];
x = sideOffset.x + alignOffset.x;
y = sideOffset.y + alignOffset.y;
|
中间件
单纯有了基础坐标定位,还不能成为一个通用组件,在实际业务开发中,我们往往会遇到各种各样的场景,例如选择器的下拉框,要和输入框保持一致的宽度;浮动元素超出视口时,要将其翻转到对称的下方。我们可以使用中间件的形式来进行自定义实现。
类似于后端开发中的中间件概念,其本质上就是一系列共享同一份上下文的函数,每一次执行都会将执行结果传递给下一个中间件。参考 Koa 的中间件逻辑,我们使用一个 ctx 对象来传递上下文,其包含参考元素、浮动元素、坐标、平台适配器、方位和排列等内容。
1
2
3
4
5
6
7
8
| interface MiddlewareContext {
reference: VirtualElement;
floating: VirtualElement;
position: Position;
platform: Platform;
side: Side;
align: Align;
}
|
中间件则通过传入指定的配置项,返回处理函数完成注册:
1
2
3
4
5
6
7
8
9
10
11
12
| interface Middleware<T = any> {
name: string;
options: T;
fn: (ctx: MiddlewareContext) => Promisable<MiddlewareContext>;
}
const middleware = (options: MiddlewareOptions): Middleware<OffsetOptions> => ({
name: 'middleware',
options,
fn: (context) => context
});
|
以一个基础的偏移(Offset)中间件为例,其逻辑如下:
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
| export interface OffsetOptions {
offsetX?: number;
offsetY?: number;
}
export const offset = (options: OffsetOptions): Middleware<OffsetOptions> => ({
name: 'offset',
options,
fn: (context) => {
if (context.side === 'top') {
if (options.offsetY) {
context.position.y = context.position.y - options.offsetY;
}
if (options.offsetX) {
context.position.x = context.position.x + options.offsetX;
}
}
if (context.side === 'bottom') {
if (options.offsetY) {
context.position.y = context.position.y + options.offsetY;
}
if (options.offsetX) {
context.position.x = context.position.x + options.offsetX;
}
}
if (context.side === 'left') {
if (options.offsetX) {
context.position.x = context.position.x - options.offsetX;
}
if (options.offsetY) {
context.position.y = context.position.y + options.offsetY;
}
}
if (context.side === 'right') {
if (options.offsetX) {
context.position.x = context.position.x + options.offsetX;
}
if (options.offsetY) {
context.position.y = context.position.y + options.offsetY;
}
}
},
});
|
拿到配置选择中的 x 和 y 坐标的偏移,结合方位,来决定往哪个方向进行偏移。
同理我们可以写出一个翻转(Flip)中间件,以 top 方位布局为例,我们检测浮动元素的 x 坐标是否小于 0,如果小于则说明其发生了移除,需要进行翻转,我们更新 side 属性为翻转后的方位,并将 y 坐标相对参考元素进行翻转,如下图所示:

Flip 中间件通常在最后执行,否则可能会出现在其之后调用的中间件修改了定位导致溢出的情况
其他方位的翻转也大同小异,画个图就能理清楚,这里就不多赘述,直接上完整代码:
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
| const FLIP_MAP: Record<Side, Side> = {
top: 'bottom',
bottom: 'top',
left: 'right',
right: 'left',
};
export const flip = (): Middleware => ({
name: 'flip',
options: {},
fn: async (context) => {
const [referenceRect, floatingRect] = await Promise.all([
context.platform.getElementRectById(context.reference.id),
context.platform.getElementRectById(context.floating.id),
]);
if (!referenceRect || !floatingRect) return;
if (context.side === 'top' && context.position.y <= 0) {
context.side = FLIP_MAP[context.side];
context.position.y =
referenceRect.y +
referenceRect.height +
referenceRect.y -
context.position.y -
floatingRect.height;
}
if (context.side === 'left' && context.position.x <= 0) {
context.side = FLIP_MAP[context.side];
context.position.x =
referenceRect.x +
referenceRect.width +
referenceRect.x -
context.position.x -
floatingRect.width;
}
if (
context.side === 'bottom' &&
context.position.y + floatingRect.height >=
context.platform.window.innerHeight
) {
context.side = FLIP_MAP[context.side];
context.position.y =
referenceRect.y -
floatingRect.height -
context.position.y +
referenceRect.y +
referenceRect.height;
}
if (
context.side === 'right' &&
context.position.x + floatingRect.width >=
context.platform.window.innerWidth
) {
context.side = FLIP_MAP[context.side];
context.position.x =
referenceRect.x -
floatingRect.width -
context.position.x +
referenceRect.x +
referenceRect.width;
}
},
});
|
如果你想要实现其他效果,也可以按照类似的方式进行实现。随后我们将注册中间件的逻辑补充到主函数 computePosition 中:
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
| const computePosition = async (
reference: VirtualElement,
floating: VirtualElement,
options: ComputePositionOptions,
): Promise<Position> => {
const { placement = 'bottom', platform, middlewares } = options;
const [referenceRect, floatingRect] = await Promise.all([
platform.getElementRectById(reference.id),
platform.getElementRectById(floating.id),
]);
let x = 0;
let y = 0;
if (!referenceRect || !floatingRect) {
return { x, y };
}
const [side = 'bottom', align = 'center'] = placement.split('-') as [
Side,
Align,
];
const sideOffsets: Record<Side, Position> = {};
const alignOffsets: Record<Align, Record<Side, Position>> = {};
const sideOffset = sideOffsets[side];
const alignOffset = alignOffsets[align][side];
x = sideOffset.x + alignOffset.x;
y = sideOffset.y + alignOffset.y;
const ctx: MiddlewareContext = {
reference,
floating,
position: { x, y },
platform,
side,
align,
};
for (const middleware of middlewares) {
await middleware.fn(ctx);
}
return ctx.position;
};
|
至此,我们已经完成了浮动定位的核心逻辑。
useFloating
单有核心逻辑还不够,为了适配 React 组件,我们还需要将其封装进一个 Hook 内,来完成调用,我们期望的调用形式是这样的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| export default function () {
const { refs, floatingStyles, update } = useFloating({
placement: "bottom",
platform,
middlewares: [offset({}), flip()],
});
useEffect(() => {
update();
}, [update]);
return (
<React.Fragment>
<div ref={refs.reference}></div>
<div ref={refs.floating} style={floatingStyles}></div>
</React.Fragment>
);
}
|
也有什么特殊逻辑,直接看代码吧,几个注意点是自定义 Hook 的返回函数尽可能使用 useCallback 和 ref 来保证外部调用时可以随心所欲地将函数加入依赖数组,并且避免闭包陷阱(这里的 useLatest 可以参考 ahooks )
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
| export const useFloating = (options: UseFloatingOptions) => {
const { placement, platform, middlewares, strategy = 'fixed' } = options;
const placementRef = useLatest(placement);
const platformRef = useLatest(platform);
const middlewaresRef = useLatest(middlewares);
const reference = useRef<VirtualElement | null>(null);
const setReference = useCallback((element: VirtualElement | null) => {
reference.current = element;
}, []);
const floating = useRef<VirtualElement | null>(null);
const setFloating = useCallback((element: VirtualElement | null) => {
floating.current = element;
}, []);
const refs = useMemo(
() => ({
reference,
setReference,
floating,
setFloating,
}),
[reference, setReference, floating, setFloating],
);
const [position, setPosition] = useState<Position>();
const update = useCallback(async () => {
if (!reference.current || !floating.current) {
return;
}
const options = {
placement: placementRef.current,
platform: platformRef.current,
middlewares: middlewaresRef.current,
};
const position = await computePosition(
reference.current,
floating.current,
options,
);
setPosition(position);
}, [placementRef, platformRef, middlewaresRef]);
const floatingStyles = useMemo<React.CSSProperties>(() => {
if (!position)
return {
position: strategy,
left: '0',
top: '0',
};
const dpr = platform.getDevicePixelRatio();
const toPx = (value: number) => `${Math.round(value * dpr) / dpr}px`;
return {
position: strategy,
left: toPx(position.x),
top: toPx(position.y),
};
}, [position, strategy, platform]);
return {
refs,
floatingStyles,
update,
};
};
|
至此,我们完成了浮动定位的全部逻辑。
动态更新
上文有提到因滚动、页面尺寸变化等原因导致需要重新生成蒙层和定位,在 Floating UI 中是使用 autoUpdate 实现的,其原理也是在元素挂载后监听 resize 和 scroll 事件,这个逻辑我们就不在浮动定位模块内包了,而是再封装一个 useHighlight Hook 统一处理。
在 useHighlight 中,我们期望最终就是给外部暴露一个 highlight 函数来进行针对指定 id 元素的高亮,在其内部我们需要封装蒙层/弹出层的渲染、元素获取以及事件监听等逻辑。
值得一提的是,由于我们期望做的是平台无关的组件库,因此事件监听我们使用 platform 提供的抽象方法,代码如下:
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
| export const useHighlight = (props: UseHighlightProps) => {
const { onDestroy } = props;
const onDestroyRef = useRef(onDestroy);
onDestroyRef.current = onDestroy;
const { platform } = useContext(TourContext);
const [placement, setPlacement] = useState<Placement>();
const [referenceRect, setReferenceRect] = useState<Rect | null>(null);
const [popover, setPopover] = useState<React.ReactNode>();
const { refs, floatingStyles, update } = useFloating({
placement,
platform,
middlewares: [offset({ offsetY: 20 }), flip()], // 中间件注册
});
const destroy = useCallback(() => {
setReferenceRect(null);
setPlacement(undefined);
setPopover(undefined);
refs.setReference(null);
refs.setFloating(null);
onDestroyRef.current?.();
}, [refs]);
useLayoutEffect(() => {
const handleResize = async () => {
if (!refs.reference.current) return;
const rect = await platform.getElementRectById(refs.reference.current.id);
setReferenceRect(rect);
};
const { cleanup: cleanupResize } = platform.onResize(handleResize);
const { cleanup: cleanupScroll } = platform.onScroll(handleResize);
return () => {
cleanupResize();
cleanupScroll();
};
}, [refs.reference, platform, update]);
const highlight = useCallback(
async (
id: string,
popover: React.ReactNode,
placement: Placement = 'bottom',
) => {
const [targetNode, targetNodeRect] = await Promise.all([
platform.getElementById(id),
platform.getElementRectById(id),
]);
if (!targetNode || !targetNodeRect) {
return console.error(`targetNode is not found!`);
}
refs.setReference({ ...targetNode, id });
setReferenceRect(targetNodeRect);
setPopover(popover);
setPlacement(placement);
},
[refs, platform],
);
useEffect(() => {
update();
}, [referenceRect, update, placement]);
const renderOverlay = () => {
if (!referenceRect) return null;
return (
<Overlay
referenceRect={referenceRect}
windowInnerWidth={platform.window.innerWidth}
windowInnerHeight={platform.window.innerHeight}
/>
);
};
const renderPopover = () => {
if (!referenceRect) return null;
return (
<Popover style={floatingStyles} setRef={refs.setFloating}>
{popover}
</Popover>
);
};
return {
highlight,
destroy,
renderOverlay,
renderPopover,
};
};
|
至此,所有核心逻辑都已完成
API 设计
上述底层的 API 只是为了实现功能,但真正想要打造一款优秀的三方库,API 设计是重中之重。好的 API 设计,不仅可以帮助开发者理解使用方式,还能够拥有极高的灵活性和可拓展性。
一个很好的思路是,先设计 API,再实现代码,这有点类似于 DDD(Domain-Driven Design,领域驱动设计),在写三方库的时候是一个很不错的思路。
因为我们使用 React 技术栈,因此我们的顶层 API 会暴露一个 useTour Hook,结合 Driver.js 的配置化,我们期望的调用形式是这样的:
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
| const steps: TourStep[] = [
{
id: 'step-1',
placement: 'bottom-start',
popover: ({ next, destroy }) => (
<Card
title="step 1"
actions={[
{ label: 'cancel', onClick: () => destroy() },
{ label: 'next', onClick: () => next() },
]}
>
<div>content</div>
</Card>
),
},
{
id: 'step-2',
placement: 'bottom-start',
popover: ({ next, destroy }) => (
<Card
title="step 2"
actions={[
{ label: 'cancel', onClick: () => destroy() },
{ label: 'next', onClick: () => next() },
]}
>
<div>content</div>
</Card>
),
},
{
id: 'step-3',
placement: 'bottom-start',
popover: ({ destroy }) => (
<Card
title="step 3"
actions={[{ label: 'cancel', onClick: () => destroy() }]}
>
<div>content</div>
</Card>
),
},
];
export default function() {
const { start, renderOverlay, renderPopover } = useTour({ steps });
return (
<React.Fragment>
<button onClick={() => start()}>start</button>
<div id="step-1">step 1</div>
<div id="step-2">step 2</div>
<div id="step-3">step 3</div>
{renderOverlay()}
{renderPopover()}
</React.Fragment>
);
};
|
用一个 step 参数维护需要高亮的元素、弹层内容以及顺序。此外我们将 render 方法暴露出去,避免对 React 做手动渲染(如调用 ReactDOM 的 createRoot 等),完全交由外部进行渲染,这样天然就规避了跨平台操作节点的繁琐代码。
废话不多说,直接上代码,非常简单,因为我们已经将高亮的逻辑受控到 useHighlight 中了:
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
| import { useCallback, useEffect, useState } from 'react';
import { TourStepContext, UseTourProps } from './types';
import { useHighlight } from './use-highlight';
export const useTour = (props: UseTourProps) => {
const { steps } = props;
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
const { highlight, destroy, renderOverlay, renderPopover } = useHighlight({
onDestroy: () => setCurrentStepIndex(-1),
});
const next = useCallback(() => {
if (currentStepIndex < steps.length - 1) {
setCurrentStepIndex(currentStepIndex + 1);
}
}, [currentStepIndex, steps]);
const start = useCallback(
(index: number = 0) => setCurrentStepIndex(index),
[],
);
useEffect(() => {
const currentStep =
currentStepIndex >= 0 ? steps[currentStepIndex] : undefined;
if (!currentStep) return;
const context: TourStepContext = {
next,
destroy,
index: currentStepIndex,
step: currentStep,
};
const popover =
typeof currentStep.popover === 'function'
? currentStep.popover(context)
: currentStep.popover;
highlight(currentStep.id, popover, currentStep.placement);
}, [currentStepIndex, highlight, steps, next, destroy]);
return { next, destroy, start, renderOverlay, renderPopover };
};
|
看看效果:

非常棒,我们成功实现了一个 Tour 组件。
总结
本文介绍了从零开始构建一个完整的跨平台 Tour 组件的设计思路和实现方式,参考了老牌引导组件库 Driver.js、ReacTour 和定位库 Floating UI 的实现方式,取长补短,使代码拥有了比较良好的架构设计、较强的可读性和可维护性,并且还拥有很强的拓展性。
相关代码具备一个引导组件的主要基础功能,但想作为一个主流的方案选型仍需要花费较多精力打磨,因此本文主要目的还是为读者和笔者自身提供学习思路,提高编码和设计能力。
相关阅读