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源码系列

相关推荐
加班是不可能的,除非双倍日工资4 小时前
css预编译器实现星空背景图
前端·css·vue3
wyiyiyi5 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
gnip5 小时前
vite和webpack打包结构控制
前端·javascript
excel5 小时前
在二维 Canvas 中模拟三角形绕 X、Y 轴旋转
前端
阿华的代码王国6 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
一条上岸小咸鱼6 小时前
Kotlin 基本数据类型(三):Booleans、Characters
android·前端·kotlin
Jimmy6 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
ZXT6 小时前
promise & async await总结
前端
Jerry说前后端6 小时前
RecyclerView 性能优化:从原理到实践的深度优化方案
android·前端·性能优化
画个太阳作晴天6 小时前
A12预装app
linux·服务器·前端