【react.js + hooks】useGuide 创建用户引导视图

有的时候用户可能对网站上的一些操作流程感到困惑,这时候我们需要为用户创建引导视图。为了插入指引而专门去更改组件的渲染函数,显然是不合逻辑的,创建指引视图应该是一种对源代码低侵入的行为,我们可以遵循某一套约定,使之变成一种类插件化的机制。

useGuide 设计思路

为需要引导的网页元素指定唯一id,当引导开始时,创建一个全屏的遮罩,当引导到该元素时,高亮该元素,并创建一个辅助元素挂载到具有唯一 id 的该目标元素上,当指引切换或结束时,移除辅助元素。

要实现这个思路,我们需要以下数据:

  1. 获取每一步的目标元素 id 和辅助元素
  2. 记录当前的步数
  3. 记录每次高亮的元素 id,之后取消高亮

useGuide 准备工作

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

useGuide

代码实现

定义 useGuide 传参和返回
  • 传参:
    • steps - 数组,每个元素代表指引的每一步对应的所有目标元素id,这一步的名字,data为这一步搭载的可能需要的数据,renders为每一个目标元素各自的辅助元素的渲染函数
    • callback - 指引步变化时的回调函数
    • config - 配置项
      • containerStyle - 为辅助元素套的一层 div,指定该 div 的样式
      • containerClassName - 指定该辅助元素的套壳 div 的 css 类名
      • maskConfig - 指定当前步时,遮罩层的一些属性
  • 返回(数组):
    • [0] number - 当前步
    • [1] Guider - 对一些引导行为封装的对象
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;
        }
        //...
useGuide 完整代码
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)}
    </>
  );
};
useGuide 使用示例

以下代码创建了一个九宫格引导视图($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;
`;
}

效果演示:useGuide九宫格引导视图
Bingo ! 一个实用的 useGuide 就这样实现了!需要注意的是,使用 Target 包裹目标元素 和 不使用 将导致最终渲染结果的一定差异,因为 Target 不再创建 div 包裹辅助元素,因此不建议混用 Target 和 无 Target。

相关推荐

  1. 创建数据库用户

    2023-12-25 08:32:03       59 阅读
  2. 创建数据库用户

    2023-12-25 08:32:03       31 阅读
  3. Docker 创建mysql用户

    2023-12-25 08:32:03       26 阅读
  4. MySQL——创建视图

    2023-12-25 08:32:03       32 阅读

最近更新

  1. docker php8.1+nginx base 镜像 dockerfile 配置

    2023-12-25 08:32:03       98 阅读
  2. Could not load dynamic library ‘cudart64_100.dll‘

    2023-12-25 08:32:03       106 阅读
  3. 在Django里面运行非项目文件

    2023-12-25 08:32:03       87 阅读
  4. Python语言-面向对象

    2023-12-25 08:32:03       96 阅读

热门阅读

  1. 介绍 TensorFlow 的基本概念和使用场景。

    2023-12-25 08:32:03       62 阅读
  2. uniapp三元表达式判断状态更改字体颜色?

    2023-12-25 08:32:03       47 阅读
  3. RISC-V搭建嵌入式QT开发环境

    2023-12-25 08:32:03       45 阅读
  4. 栈与队列part02 开心消消乐

    2023-12-25 08:32:03       57 阅读
  5. 设计模式(全)

    2023-12-25 08:32:03       49 阅读
  6. C++中vector返回值的最高效返回

    2023-12-25 08:32:03       58 阅读
  7. Python(四十九)——requests和httpx

    2023-12-25 08:32:03       45 阅读
  8. JDBC连接Mysql数据库

    2023-12-25 08:32:03       49 阅读