ahooks源码系列(六):DOM相关(二)

useHover

useHover是用于监听 DOM 元素是否有鼠标悬停,它的API配置项如下

主要实现原理是监听 mouseenter 触发 onEnter 事件,切换状态为 true,监听 mouseleave 触发 onLeave 事件,切换状态为 false

源码如下:

js 复制代码
export default (target: BasicTarget, options?: Options): boolean => {
  const { onEnter, onLeave } = options || {};
  // useBoolean 设置 元素是否处于 hover 的状态
  const [state, { setTrue, setFalse }] = useBoolean(false);
  // 通过监听 mouseenter 判断有鼠标悬停
  useEventListener(
    'mouseenter',
    () => {
      onEnter?.();
      setTrue();
      onChange?.(true);
    },
    {
      target,
    },
  );

  // mouseleave 没有鼠标悬停
  useEventListener(
    'mouseleave',
    () => {
      onLeave?.();
      setFalse();
      onChange?.(true);
    },
    {
      target,
    },
  );

  return state;
};

思路如下

  • 通过监听 mouseentermouseleave 分别触发 onEnteronLeave 回调
  • 然后调用 setTruesetFalse 来设置 DOM 元素处于 hover 的状态
  • 此时 hover 状态变化,触发 onChange 回调
  • 最后返回状态 state,也就是官方文档中的 isHovering

整体代码简单清晰。

useInViewport

useInViewport 用于观察元素是否在可见区域,以及元素可见比例

其实现原理就是 Intersection Observer API。并使用 intersection-observer 这个 npm 包进行 polyfill 处理

Intersection Observer API 提供了一种异步检测目标元素与祖先元素或 viewport 相交情况变化的方法。

源码如下:

js 复制代码
import 'intersection-observer';
import { useState } from 'react';
import type { BasicTarget } from '../utils/domTarget';
import { getTargetElement } from '../utils/domTarget';
import useEffectWithTarget from '../utils/useEffectWithTarget';

export interface Options {
  rootMargin?: string;
  threshold?: number | number[];
  root?: BasicTarget<Element>;
}

function useInViewport(target: BasicTarget, options?: Options) {
  const [state, setState] = useState<boolean>();
  const [ratio, setRatio] = useState<number>();

  useEffectWithTarget(
    () => {
      const el = getTargetElement(target);
      if (!el) {
        return;
      }

      const observer = new IntersectionObserver(
        (entries) => {
          for (const entry of entries) {
            setRatio(entry.intersectionRatio);
            setState(entry.isIntersecting);
          }
        },
        {
          ...options,
          root: getTargetElement(options?.root),
        },
      );

      observer.observe(el);

      return () => {
        observer.disconnect();
      };
    },
    [options?.rootMargin, options?.threshold],
    target,
  );

  return [state, ratio] as const;
}

export default useInViewport;

没什么好说的,下一个

useKeyPress

useKeyPress用于监听键盘按键,支持组合键,支持按键别名。

其实现原理是监听 keydown 或者 keyup 事件,在回调对 keyFilter 配置进行判断,如果触发了配置的场景,则触发我们传入的 eventHandler 回调

主函数代码比较简单:

js 复制代码
const defaultEvents: KeyEvent[] = ['keydown'];
// 监听键盘按键,支持组合键,支持按键别名。
function useKeyPress(
  keyFilter: KeyFilter,
  eventHandler: EventHandler,
  option?: Options,
) {
  // 默认 defaultEvents 是 keydown
  const { events = defaultEvents, target, exactMatch = false } = option || {};
  const eventHandlerRef = useLatest(eventHandler);
  const keyFilterRef = useLatest(keyFilter);

  useDeepCompareEffectWithTarget(
    () => {
      const el = getTargetElement(target, window);
      if (!el) {
        return;
      }

      const callbackHandler = (event: KeyboardEvent) => {
        const genGuard: KeyPredicate = genKeyFormater(
          keyFilterRef.current,
          exactMatch,
        );
        // 判断是否触发配置 keyFilter 场景
        if (genGuard(event)) {
          return eventHandlerRef.current?.(event);
        }
      };

      // 监听传入事件
      for (const eventName of events) {
        el?.addEventListener?.(eventName, callbackHandler);
      }
      // 取消监听
      return () => {
        for (const eventName of events) {
          el?.removeEventListener?.(eventName, callbackHandler);
        }
      };
    },
    [events],
    target,
  );
}

主要是它其中调用的 genKeyFormatter 键盘输入预处理方法,主要是针对几种传入的场景进行处理,留意注释。其中返回的是一个判断函数,假如传入的是 keyFilter 是一个自定义函数,则直接根据 event 参数判断按键是否激活。

另外支持 keyCode、别名、组合键、数组。其内部都是调用的 genFilterKey 判断按键是否激活。

js 复制代码
/**
 * 键盘输入预处理方法
 * @param [keyFilter: any] 当前键
 * @returns () => Boolean
 */
function genKeyFormater(
  keyFilter: KeyFilter,
  exactMatch: boolean,
): KeyPredicate {
  // 支持自定义函数
  if (isFunction(keyFilter)) {
    return keyFilter;
  }
  // 支持 keyCode、别名、组合键
  if (isString(keyFilter) || isNumber(keyFilter)) {
    return (event: KeyboardEvent) => genFilterKey(event, keyFilter, exactMatch);
  }
  // 支持数组
  if (Array.isArray(keyFilter)) {
    return (event: KeyboardEvent) =>
      keyFilter.some(item => genFilterKey(event, item, exactMatch));
  }
  return keyFilter ? () => true : () => false;
}

getFilterKey如下:

js 复制代码
/**
 * 判断按键是否激活
 * @param [event: KeyboardEvent]键盘事件
 * @param [keyFilter: any] 当前键
 * @returns Boolean
 */
function genFilterKey(
  event: KeyboardEvent,
  keyFilter: keyType,
  exactMatch: boolean,
) {
  // 浏览器自动补全 input 的时候,会触发 keyDown、keyUp 事件,但此时 event.key 等为空
  if (!event.key) {
    return false;
  }

  // 数字类型直接匹配事件的 keyCode
  if (isNumber(keyFilter)) {
    return event.keyCode === keyFilter;
  }

  // 字符串依次判断是否有组合键
  const genArr = keyFilter.split('.');
  let genLen = 0;

  for (const key of genArr) {
    // 组合键
    const genModifier = modifierKey[key];
    // keyCode 别名
    const aliasKeyCode = aliasKeyCodeMap[key.toLowerCase()];

    if (
      (genModifier && genModifier(event)) ||
      (aliasKeyCode && aliasKeyCode === event.keyCode)
    ) {
      genLen++;
    }
  }

  /**
   * 需要判断触发的键位和监听的键位完全一致,判断方法就是触发的键位里有且等于监听的键位
   * genLen === genArr.length 能判断出来触发的键位里有监听的键位
   * countKeyByEvent(event) === genArr.length 判断出来触发的键位数量里有且等于监听的键位数量
   * 主要用来防止按组合键其子集也会触发的情况,例如监听 ctrl+a 会触发监听 ctrl 和 a 两个键的事件。
   */
  if (exactMatch) {
    return genLen === genArr.length && countKeyByEvent(event) === genArr.length;
  }
  return genLen === genArr.length;
}

useMouse

useMouse 用于监听鼠标位置

主要实现原理是通过监听 mousemove 方法,获取鼠标的位置。并通过 getBoundingClientRect 获取到 target 元素的属性,从而计算出鼠标相对于元素的位置。

ts 复制代码
import useRafState from '../useRafState';
import useEventListener from '../useEventListener';
import type { BasicTarget } from '../utils/domTarget';
import { getTargetElement } from '../utils/domTarget';

export interface CursorState {
  screenX: number;
  screenY: number;
  clientX: number;
  clientY: number;
  pageX: number;
  pageY: number;
  elementX: number;
  elementY: number;
  elementH: number;
  elementW: number;
  elementPosX: number;
  elementPosY: number;
}

const initState: CursorState = {
  screenX: NaN,
  screenY: NaN,
  clientX: NaN,
  clientY: NaN,
  pageX: NaN,
  pageY: NaN,
  elementX: NaN,
  elementY: NaN,
  elementH: NaN,
  elementW: NaN,
  elementPosX: NaN,
  elementPosY: NaN,
};

export default (target?: BasicTarget) => {
  const [state, setState] = useRafState(initState);

  useEventListener(
    'mousemove',
    (event: MouseEvent) => {
      const { screenX, screenY, clientX, clientY, pageX, pageY } = event;
      const newState = {
        screenX,
        screenY,
        clientX,
        clientY,
        pageX,
        pageY,
        elementX: NaN,
        elementY: NaN,
        elementH: NaN,
        elementW: NaN,
        elementPosX: NaN,
        elementPosY: NaN,
      };
      const targetElement = getTargetElement(target);
      if (targetElement) {
        const { left, top, width, height } = targetElement.getBoundingClientRect();
        newState.elementPosX = left + window.pageXOffset;
        newState.elementPosY = top + window.pageYOffset;
        newState.elementX = pageX - newState.elementPosX;
        newState.elementY = pageY - newState.elementPosY;
        newState.elementW = width;
        newState.elementH = height;
      }
      setState(newState);
    },
    {
      target: () => document,
    },
  );

  return state;
};

思路如下

  • 首先从 event 对象中获取到screenX 等属性
  • 然后调用 getTargetElement 获取目标元素,再通过 getBoundingClientRect 方法获取到目标元素得属性去做一些计算
  • 最后返回 state

useScroll

useScroll 用于监听元素的滚动位置。

实现原理是通过监听目标的 scroll 事件,更新目标位置信息。

主函数代码如下:

js 复制代码
function useScroll(
  target?: Target,
  shouldUpdate: ScrollListenController = () => true,
): Position | undefined {
  const [position, setPosition] = useRafState<Position>();

  const shouldUpdateRef = useLatest(shouldUpdate);

  useEffectWithTarget(
    () => {
      const el = getTargetElement(target, document);
      if (!el) {
        return;
      }
      // 更新位置
      const updatePosition = () => {
        // ...单独拿出来讲
      };
      updatePosition();
      // 监听 scroll 事件
      el.addEventListener('scroll', updatePosition);
      return () => {
        el.removeEventListener('scroll', updatePosition);
      };
    },
    [],
    target,
  );

  return position;
}

主函数做的事情很简单,通过getTargetElement 获取目标元素,然后监听scroll事件,事件的回调是 updatePosition 方法,我们来单独看这个方法做了什么

updatePosition:

js 复制代码
// 更新位置
const updatePosition = () => {
  let newPosition: Position;
  // 如果是 document 的处理
  if (el === document) {
    if (document.scrollingElement) {
      newPosition = {
        left: document.scrollingElement.scrollLeft,
        top: document.scrollingElement.scrollTop,
      };
    } else {
      // When in quirks mode, the scrollingElement attribute returns the HTML body element if it exists and is potentially scrollable, otherwise it returns null.
      // https://developer.mozilla.org/zh-CN/docs/Web/API/Document/scrollingElement
      // https://stackoverflow.com/questions/28633221/document-body-scrolltop-firefox-returns-0-only-js
      newPosition = {
        left: Math.max(
          window.pageYOffset,
          document.documentElement.scrollTop,
          document.body.scrollTop,
        ),
        top: Math.max(
          window.pageXOffset,
          document.documentElement.scrollLeft,
          document.body.scrollLeft,
        ),
      };
    }
  } else {
    // 获取到元素的位置
    newPosition = {
      left: (el as Element).scrollLeft,
      top: (el as Element).scrollTop,
    };
  }
  // 是否更新位置信息函数
  if (shouldUpdateRef.current(newPosition)) {
    setPosition(newPosition);
  }
};

留意当为 document 的时候,当在怪异模式下, scrollingElement 属性返回 HTML body 元素(若不存在返回 null )。这个时候可以取 window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop 三者中最大值。参考

useSize

useSize 是用于监听 DOM 节点尺寸变化的 Hook。

主要实现原理就是使用 ResizeObserver API 监听对应的目标的尺寸变化。

代码比较简单:

js 复制代码
function useSize(
  // 目标 DOM
  target: BasicTarget,
): Size | undefined {
  const [state, setState] = useRafState<Size>();

  useIsomorphicLayoutEffectWithTarget(
    () => {
      // 获取到当前目标元素
      const el = getTargetElement(target);
      if (!el) {
        return;
      }
      const resizeObserver = new ResizeObserver(entries => {
        // 监听的 DOM 大小发生变化的时候,则获取到最新的值
        entries.forEach(entry => {
          // DOM 节点的尺寸
          const { clientWidth, clientHeight } = entry.target;
          setState({
            width: clientWidth,
            height: clientHeight,
          });
        });
      });
      // 监听目标的元素
      resizeObserver.observe(el);
      // 销毁相应的事件,防止造成内存泄露
      return () => {
        resizeObserver.disconnect();
      };
    },
    // 依赖项
    [],
    // 目标元素
    target,
  );

  return state;
}

useFocusWithin

监听当前焦点是否在某个区域之内

实现原理主要是监听 focusinfocusout 方法。当元素即将失去焦点时,focusout 事件被触发。当元素即将聚焦时,focusin 事件被触发。它们和 focus 和 blur 方法的主要区别是后面两个不会进行冒泡。

代码实现也比较简单:

js 复制代码
export default function useFocusWithin(target: BasicTarget, options?: Options) {
  const [isFocusWithin, setIsFocusWithin] = useState(false);
  const { onFocus, onBlur, onChange } = options || {};

  // https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-within
  // 监听 focusin 和 focusout
  useEventListener(
    'focusin',
    (e: FocusEvent) => {
      if (!isFocusWithin) {
        // 获取焦点时触发
        onFocus?.(e);
        // 焦点变化时触发
        onChange?.(true);
        setIsFocusWithin(true);
      }
    },
    {
      target,
    },
  );

  useEventListener(
    'focusout',
    (e: FocusEvent) => {
      // @ts-ignore
      if (isFocusWithin && !e.currentTarget?.contains?.(e.relatedTarget)) {
        // 失去焦点时触发
        onBlur?.(e);
        onChange?.(false);
        setIsFocusWithin(false);
      }
    },
    {
      target,
    },
  );

  return isFocusWithin;
}

useHover 差不多

最后

往期的 ahooks 源码解析文章可以看专栏 ahooks源码系列

相关推荐
黑客老陈1 小时前
新手小白如何挖掘cnvd通用漏洞之存储xss漏洞(利用xss钓鱼)
运维·服务器·前端·网络·安全·web3·xss
正小安1 小时前
Vite系列课程 | 11. Vite 配置文件中 CSS 配置(Modules 模块化篇)
前端·vite
暴富的Tdy1 小时前
【CryptoJS库AES加密】
前端·javascript·vue.js
neeef_se1 小时前
Vue中使用a标签下载静态资源文件(比如excel、pdf等),纯前端操作
前端·vue.js·excel
m0_748235612 小时前
web 渗透学习指南——初学者防入狱篇
前端
z千鑫2 小时前
【前端】入门指南:Vue中使用Node.js进行数据库CRUD操作的详细步骤
前端·vue.js·node.js
m0_748250743 小时前
Web入门常用标签、属性、属性值
前端
m0_748230443 小时前
SSE(Server-Sent Events)返回n ,前端接收数据时被错误的截断【如何避免SSE消息中的换行符或回车符被解释为事件消息的结束】
前端