【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;
`;
}

效果演示:【react.js + hooks】useGuide 创建用户引导视图_第1张图片
Bingo ! 一个实用的 useGuide 就这样实现了!需要注意的是,使用 Target 包裹目标元素 和 不使用 将导致最终渲染结果的一定差异,因为 Target 不再创建 div 包裹辅助元素,因此不建议混用 Target 和 无 Target。

你可能感兴趣的:(react.js,前端,前端框架,用户体验)