有的时候用户可能对网站上的一些操作流程感到困惑,这时候我们需要为用户创建引导视图。为了插入指引而专门去更改组件的渲染函数,显然是不合逻辑的,创建指引视图应该是一种对源代码低侵入的行为,我们可以遵循某一套约定,使之变成一种类插件化的机制。
为需要引导的网页元素指定唯一的 id
,当引导开始时,创建一个全屏的遮罩,当引导到该元素时,高亮该元素,并创建一个辅助元素挂载到具有唯一 id
的该目标元素上,当指引切换或结束时,移除辅助元素。
要实现这个思路,我们需要以下数据:
createRoot 工具函数:
import ReactDOM from "react-dom";
let createRoot = (targetDocument: Element) => {
return {
render: (element: JSX.Element) => {
ReactDOM.render(element, targetDocument);
},
};
};
if ("createRoot" in ReactDOM) {
// Adapt to React 18
createRoot = ReactDOM.createRoot as typeof createRoot;
}
ReactDOM 的 createRoot 方法,在 React 18 + 处于 deprecated
代码实现
function useGuide(
steps: Step[],
callback?: StepCallback,
config?: {
containerStyle?: Partial<CSSStyleDeclaration>;
containerClassName?: string;
maskConfig?: MaskConfig;
}
): [number, Guider]
export type Render = {
id: string;
render: (
id: string,
name: string,
data: any,
ids: string[]
) => React.ReactNode;
containerStyle?: Partial<CSSStyleDeclaration>;
containerClassName?: string;
};
export type Step = {
ids?: string[];
name?: string;
data?: any;
renders?: Render[];
};
export interface Guider {
start: () => void;
stop: () => void;
next: () => void;
last: () => void;
go: (step: number) => void;
}
export type MaskConfig = {
backgroundColor?: string;
opacity?: number;
zIndex?: number;
pointerEvents?:
| "none !important"
| "auto"
| React.CSSProperties["pointerEvents"];
};
export type StepCallback = (step: number, stepConfig: Step) => void;
先定义一个 ref 来保存遮罩层元素的 dom
const maskRef = useRef<HTMLDivElement | null>(null);
定义一个创建遮罩mask的函数,传入遮罩层配置
const createMask = (config?: MaskConfig) => {
const mask = document.createElement("div");
mask.style.position = "fixed";
mask.style.top = "0";
mask.style.right = "0";
mask.style.bottom = "0";
mask.style.left = "0";
mask.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
mask.style.zIndex = "999";
mask.style.cursor = "default";
mask.style.userSelect = "none";
mask.style.webkitUserSelect = "none";
mask.style.pointerEvents = "none !important";
const maskConfig = config;
if (maskConfig) {
if (maskConfig.backgroundColor) {
mask.style.backgroundColor = maskConfig.backgroundColor;
}
if (maskConfig.opacity) {
mask.style.opacity = maskConfig.opacity.toString();
}
if (maskConfig.zIndex) {
mask.style.zIndex = maskConfig.zIndex.toString();
}
}
return mask;
};
其中,默认设置遮罩层的鼠标时间为绝对 none,图层高度为 999。
记录当前步:
const [step, setStep] = useState(-1);
检测当前步,并挑选出对应的辅助元素渲染器,并渲染:
useEffect(() => {
const currentStep = steps[step];
const rootDom = document.body;
const mask = createMask(config?.maskConfig);
if (currentStep && rootDom) {
rootDom.appendChild(mask);
maskRef.current = mask;
}
currentStep?.ids?.forEach((id) => {
const element = document.getElementById(id);
if (element) {
element.style.zIndex = "1000";
}
});
const renders = currentStep?.renders?.map(
({ id, render, containerStyle, containerClassName }) => {
const target = document.getElementById(id);
const container = document.createElement("div");
container.style.zIndex = "1001";
container.style.position = "relative";
if (config?.containerStyle) {
Object.keys(config.containerStyle).forEach((key) => {
// @ts-ignore
container.style[key] = config.containerStyle[key];
});
}
if (containerStyle) {
Object.keys(containerStyle).forEach((key) => {
// @ts-ignore
container.style[key] = containerStyle[key];
});
}
if (config?.containerClassName) {
container.className = config.containerClassName;
}
if (containerClassName) {
container.className = containerClassName;
}
// 默认挂载到目标元素上
target?.appendChild(container);
if (container && target) {
// @ts-ignore
createRoot(container).render(
// @ts-ignore
render(id, currentStep.name, currentStep.data, currentStep.ids)
);
return container;
}
}
);
callback?.(step, currentStep);
return () => {
if (currentStep && rootDom && maskRef.current) {
rootDom.removeChild(mask);
maskRef.current = null;
}
renders?.forEach((elem) => elem?.remove());
};
}, [step, steps]);
其中,每次渲染,我们需要获取到目标元素,并创建一个包装容器 div,借助 createRoot 渲染并挂载辅助元素到目标元素上。(为什么要包装?辅助render可能返回一个被 Pure 元素包裹的组件)
return [
step,
{
start,
stop,
next,
last,
go,
},
];
封装常用的开始(约定step数组是顺序的,索引 -1 时不处于引导中),结束,上一步,下一步和跳转某步的操作。
const start = useCallback(() => setStep(0), []);
const stop = useCallback(() => setStep(-1), []);
const next = useCallback(
() => setStep((prev) => Math.min(prev + 1, steps.length - 1)),
[steps]
);
const last = useCallback(() => setStep((prev) => Math.max(prev - 1, 0)), []);
const go = useCallback(
(step: number) => setStep(Math.max(0, Math.min(step, steps.length - 1))),
[steps]
);
我们需要存储元素被高亮前的 zIndex,后续恢复它们
const zIndexes = useRef<Map<string, string>>(new Map());
在渲染前拉高 zIndex,并记录原zIndex,渲染结束或组件卸载后恢复
// 监测当前步的那个副作用
useEffect(()=>{
//...
currentStep?.ids?.forEach((id) => {
const element = document.getElementById(id);
if (element) {
zIndexes.current.set(id, element.style.zIndex); // 记录原值
element.style.zIndex = "1000"; //拉高图层以实现高亮
}
});
return () => {
//...
renders?.forEach((elem) => elem?.remove());
// 当不再需要引导元素时,恢复原始的 zIndex
zIndexes.current.forEach((zIndex, id) => {
const element = document.getElementById(id);
if (element) {
element.style.zIndex = zIndex;
}
});
zIndexes.current.clear();
}
}, [step, steps])
上面采取了用 createRoot 和原生 js 插入辅助元素的方案,接下来我们引入另一种方案,创建一个高阶的Pure组件(Target组件)包裹目标组件,将辅助元素通过 createPortal 挂载在下面:
拓展 Guider:
暴露 step, 传参的config,register 和 unregister,后两个 api 是让 Target 向 guider 注册自己,告诉 guider 某个 id 归它管了,你不需要渲染辅助元素了。
export interface Guider {
start: () => void;
stop: () => void;
next: () => void;
last: () => void;
go: (step: number) => void;
// Not for user to use
step: number;
options?: {
steps?: Step[];
callback?: StepCallback;
config?: {
containerStyle?: Partial<CSSStyleDeclaration>;
containerClassName?: string;
maskConfig?: MaskConfig;
};
};
register: (id: string) => void;
unregister: (id: string) => void;
}
Target,用于包裹目标组件
interface TargetProps {
id: string;
guider: Guider;
children: React.ReactNode;
}
export const Target: React.FC<TargetProps> = ({ id, guider, children }) => {
const [guide, setGuide] = useState<React.ReactNode>(null);
const { step, options } = guider;
const { steps } = options || {};
const currentStep = steps?.[step];
useEffect(() => {
guider.register(id);
const render = currentStep?.renders?.find((r) => r.id === id)?.render;
if (render) {
// @ts-ignore
setGuide(render(id, currentStep.name, currentStep.data, currentStep.ids));
} else {
setGuide(null);
}
return () => {
guider.unregister(id);
};
}, [id, currentStep]);
const element = document.getElementById(id);
return (
<>
{children}
{element && ReactDOM.createPortal(guide, element)}
</>
);
};
useGuide 内部需要存储被 Target 注册的 id:
const registered = useRef<Set<string>>(new Set());
const register = useCallback((id: string) => {
registered.current.add(id);
}, []);
const unregister = useCallback((id: string) => {
registered.current.delete(id);
}, []);
return [
step,
{
start,
stop,
next,
last,
go,
step,
options: { steps, callback, config },
register,
unregister,
},
];
在 useGuide 渲染时跳过注册的 id :
const renders = currentStep?.renders?.map(
({ id, render, containerStyle, containerClassName }) => {
if (registered.current.has(id)) {
// 如果已经注册,跳过渲染步骤
return;
}
//...
import React, { useState, useEffect, useCallback, useRef } from "react";
import ReactDOM from "react-dom";
let createRoot = (targetDocument: Element) => {
return {
render: (element: JSX.Element) => {
ReactDOM.render(element, targetDocument);
},
};
};
if ("createRoot" in ReactDOM) {
// Adapt to React 18
createRoot = ReactDOM.createRoot as typeof createRoot;
}
export type Render = {
id: string;
render: (
id: string,
name: string,
data: any,
ids: string[]
) => React.ReactNode;
containerStyle?: Partial<CSSStyleDeclaration>;
containerClassName?: string;
};
export type Step = {
ids?: string[];
name?: string;
data?: any;
renders?: Render[];
};
export interface Guider {
start: () => void;
stop: () => void;
next: () => void;
last: () => void;
go: (step: number) => void;
// Not for user to use
step: number;
options?: {
steps?: Step[];
callback?: StepCallback;
config?: {
containerStyle?: Partial<CSSStyleDeclaration>;
containerClassName?: string;
maskConfig?: MaskConfig;
};
};
register: (id: string) => void;
unregister: (id: string) => void;
}
export type MaskConfig = {
backgroundColor?: string;
opacity?: number;
zIndex?: number;
pointerEvents?:
| "none !important"
| "auto"
| React.CSSProperties["pointerEvents"];
};
export type StepCallback = (step: number, stepConfig: Step) => void;
const createMask = (config?: MaskConfig) => {
const mask = document.createElement("div");
mask.style.position = "fixed";
mask.style.top = "0";
mask.style.right = "0";
mask.style.bottom = "0";
mask.style.left = "0";
mask.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
mask.style.zIndex = "999";
mask.style.cursor = "default";
mask.style.userSelect = "none";
mask.style.webkitUserSelect = "none";
mask.style.pointerEvents = "none !important";
const maskConfig = config;
if (maskConfig) {
if (maskConfig.backgroundColor) {
mask.style.backgroundColor = maskConfig.backgroundColor;
}
if (maskConfig.opacity) {
mask.style.opacity = maskConfig.opacity.toString();
}
if (maskConfig.zIndex) {
mask.style.zIndex = maskConfig.zIndex.toString();
}
}
return mask;
};
function useGuide(
steps: Step[],
callback?: StepCallback,
config?: {
containerStyle?: Partial<CSSStyleDeclaration>;
containerClassName?: string;
maskConfig?: MaskConfig;
}
): [number, Guider] {
const [step, setStep] = useState(-1);
const maskRef = useRef<HTMLDivElement | null>(null);
const zIndexes = useRef<Map<string, string>>(new Map());
const registered = useRef<Set<string>>(new Set());
const register = useCallback((id: string) => {
registered.current.add(id);
}, []);
const unregister = useCallback((id: string) => {
registered.current.delete(id);
}, []);
useEffect(() => {
const currentStep = steps[step];
const rootDom = document.body;
const mask = createMask(config?.maskConfig);
if (currentStep && rootDom) {
rootDom.appendChild(mask);
maskRef.current = mask;
}
currentStep?.ids?.forEach((id) => {
const element = document.getElementById(id);
if (element) {
zIndexes.current.set(id, element.style.zIndex);
element.style.zIndex = "1000";
}
});
const renders = currentStep?.renders?.map(
({ id, render, containerStyle, containerClassName }) => {
if (registered.current.has(id)) {
// 如果已经注册,跳过渲染步骤
return;
}
const target = document.getElementById(id);
const container = document.createElement("div");
container.style.zIndex = "1001";
container.style.position = "relative";
if (config?.containerStyle) {
Object.keys(config.containerStyle).forEach((key) => {
// @ts-ignore
container.style[key] = config.containerStyle[key];
});
}
if (containerStyle) {
Object.keys(containerStyle).forEach((key) => {
// @ts-ignore
container.style[key] = containerStyle[key];
});
}
if (config?.containerClassName) {
container.className = config.containerClassName;
}
if (containerClassName) {
container.className = containerClassName;
}
// 默认位于父元素的最后
target?.appendChild(container);
if (container && target) {
// @ts-ignore
createRoot(container).render(
// @ts-ignore
render(id, currentStep.name, currentStep.data, currentStep.ids)
);
return container;
}
}
);
callback?.(step, currentStep);
return () => {
if (currentStep && rootDom && maskRef.current) {
rootDom.removeChild(mask);
maskRef.current = null;
}
renders?.forEach((elem) => elem?.remove());
// 当不再需要引导元素时,恢复原始的 zIndex
zIndexes.current.forEach((zIndex, id) => {
const element = document.getElementById(id);
if (element) {
element.style.zIndex = zIndex;
}
});
zIndexes.current.clear();
};
}, [step, steps]);
const start = useCallback(() => setStep(0), []);
const stop = useCallback(() => setStep(-1), []);
const next = useCallback(
() => setStep((prev) => Math.min(prev + 1, steps.length - 1)),
[steps]
);
const last = useCallback(() => setStep((prev) => Math.max(prev - 1, 0)), []);
const go = useCallback(
(step: number) => setStep(Math.max(0, Math.min(step, steps.length - 1))),
[steps]
);
return [
step,
{
start,
stop,
next,
last,
go,
step,
options: { steps, callback, config },
register,
unregister,
},
];
}
export default useGuide;
interface TargetProps {
id: string;
guider: Guider;
children: React.ReactNode;
}
export const Target: React.FC<TargetProps> = ({ id, guider, children }) => {
const [guide, setGuide] = useState<React.ReactNode>(null);
const { step, options } = guider;
const { steps } = options || {};
const currentStep = steps?.[step];
useEffect(() => {
guider.register(id);
const render = currentStep?.renders?.find((r) => r.id === id)?.render;
if (render) {
// @ts-ignore
setGuide(render(id, currentStep.name, currentStep.data, currentStep.ids));
} else {
setGuide(null);
}
return () => {
guider.unregister(id);
};
}, [id, currentStep]);
const element = document.getElementById(id);
return (
<>
{children}
{element && ReactDOM.createPortal(guide, element)}
</>
);
};
以下代码创建了一个九宫格引导视图($css 是全局注册的 @emotion)
import useGuide from "@hooks/useGuide";
const View = () => {
const [currentStep, guider] = useGuide(
Array.from({ length: 9 }, (_, i) => i + 1).map((i) => ({
ids: [`s${i}`],
name: `Step ${i}`,
data: {},
renders: [
{
id: `s${i}`,
render(id, name, data, ids) {
console.log(id, name, data, ids);
const onClick = i === 9 ? guider.stop : guider.next;
return (
<div
css={$css`
display: flex;
align-items: center;
width: fit-content;
position: absolute;
background: #fff;
padding: 4px 20px;
border-radius: 6px;
transform: translate(-50%, 50%);
`}
>
<div css={$css`width: 60px;`}>{name}</div>
<div
css={$css`padding: 4px 12px; &:hover { cursor: pointer; background: #eee; border-radius: 4px;}`}
onClick={onClick}
>
{i === 9 ? "End" : "Next"}
</div>
</div>
);
},
},
],
}))
);
return (
<div css={style.containerCss}>
<div id="s1" css={style.boxCss("red")} onClick={guider.start}>
Start
</div>
<div id="s2" css={style.boxCss("green")}>
2
</div>
<div id="s3" css={style.boxCss("blue")}>
3
</div>
<div id="s4" css={style.boxCss("black")}>
4
</div>
<div id="s5" css={style.boxCss("purple")}>
5
</div>
<div id="s6" css={style.boxCss("pink")}>
6
</div>
<div id="s7" css={style.boxCss("cyan")}>
7
</div>
<div id="s8" css={style.boxCss("magenta")}>
8
</div>
<div id="s9" css={style.boxCss("orange")}>
9
</div>
</div>
);
};
module style {
export const containerCss = $css`
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: 1fr 1fr 1fr;
gap: 10px;
width: 300px;
height: 300px;
`;
export const boxCss = (color: string) => $css`
color: ${color};
display: flex;
justify-content: center;
align-items: center;
border-radius: 6px;
cursor: pointer;
`;
}
效果演示:
Bingo ! 一个实用的 useGuide 就这样实现了!需要注意的是,使用 Target 包裹目标元素 和 不使用 将导致最终渲染结果的一定差异,因为 Target 不再创建 div 包裹辅助元素,因此不建议混用 Target 和 无 Target。