前言

本文是 ahooks 源码系列的第四篇,往期文章:

  • 【解读 ahooks 源码系列】(开篇)如何获取和监听 DOM 元素:useEffectWithTarget
  • 【解读 ahooks 源码系列】DOM篇(一):useEventListener、useClickAway、useDocumentVisibility、useDrop、useDrag
  • 【解读 ahooks 源码系列】DOM篇(二):useEventTarget、useExternal、useTitle、useFavicon、useFullscreen、useHover

本文次要解读 useMutationObserveruseInViewportuseKeyPressuseLongPress 源码实现

useMutationObserver

一个监听指定的 DOM 树发生变化的 Hook

官网文档

MutationObserver API

MutationObserver 接口提供了监督对 DOM 树所做更改的能力。利用 MutationObserver API 咱们能够监督 DOM 的变动,比方节点的减少、缩小、属性的变动、文本内容的变动等等。

可参考学习:

  • MutationObserver
  • 你不晓得的 MutationObserver

根本用法

官网在线 Demo

点击按钮,扭转 width,触发 div 的 width 属性变更,打印的 mutationsList 如下:

import { useMutationObserver } from 'ahooks';import React, { useRef, useState } from 'react';const App: React.FC = () => {  const [width, setWidth] = useState(200);  const [count, setCount] = useState(0);  const ref = useRef<HTMLDivElement>(null);  useMutationObserver(    (mutationsList) => {      mutationsList.forEach(() => setCount((c) => c + 1));    },    ref,    { attributes: true },  );  return (    <div>      <div ref={ref} style={{ width, padding: 12, border: '1px solid #000', marginBottom: 8 }}>        current width:{width}      </div>      <button onClick={() => setWidth((w) => w + 10)}>widening</button>      <p>Mutation count {count}</p>    </div>  );};

外围实现

这个实现比较简单,次要还是了解 MutationObserver API:

useMutationObserver(  callback: MutationCallback, // 触发的回调函数  target: Target,  options?: MutationObserverInit, // 设置项:https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe#parameters);
const useMutationObserver = (  callback: MutationCallback,  target: BasicTarget,  options: MutationObserverInit = {},): void => {  const callbackRef = useLatest(callback);  useDeepCompareEffectWithTarget(    () => {      const element = getTargetElement(target);      if (!element) {        return;      }      // 创立一个观察器实例并传入回调函数      const observer = new MutationObserver(callbackRef.current);      observer.observe(element, options); // 启动监听,指定所要察看的 DOM 节点      return () => {        if (observer) {          observer.disconnect(); // 进行察看变动        }      };    },    [options],    target,  );};

残缺源码

useInViewport

察看元素是否在可见区域,以及元素可见比例。

官网文档

根本用法

官网在线 Demo

import React, { useRef } from 'react';import { useInViewport } from 'ahooks';export default () => {  const ref = useRef(null);  const [inViewport] = useInViewport(ref);  return (    <div>      <div style={{ width: 300, height: 300, overflow: 'scroll', border: '1px solid' }}>        scroll here        <div style={{ height: 800 }}>          <div            ref={ref}            style={{              border: '1px solid',              height: 100,              width: 100,              textAlign: 'center',              marginTop: 80,            }}          >            observer dom          </div>        </div>      </div>      <div style={{ marginTop: 16, color: inViewport ? '#87d068' : '#f50' }}>        inViewport: {inViewport ? 'visible' : 'hidden'}      </div>    </div>  );};

应用场景

  • 图片懒加载:当图片滚动到可见地位的时候才加载
  • 有限滚动加载:滑动到底部时开始加载新的内容
  • 检测广告的曝光率:广告是否被用户看到
  • 用户看到某个区域时执行工作或播放动画

IntersectionObserver API

IntersectionObserver API,能够主动"察看"元素是否可见。因为可见(visible)的实质是,指标元素与视口产生一个穿插区,所以这个 API 叫做"穿插观察器"。
  • Intersection Observer API
  • 可参考学习:IntersectionObserver API 应用教程

实现思路

  1. 监听指标元素,反对传入原生 IntersectionObserver API 选项
  2. IntersectionObserver 构造函数的回调函数设置可见状态与可见比例值
  3. 借助 intersection-observer 库实现 polyfill

外围实现

export interface Options {  // 根(root)元素的外边距  rootMargin?: string;  // 能够管制在可见区域达到该比例时触发 ratio 更新。默认值是 0 (意味着只有有一个 target 像素呈现在 root 元素中,回调函数将会被执行)。该值为 1.0 含意是当 target 齐全呈现在 root 元素中时候 回调才会被执行。  threshold?: number | number[];  // 指定根(root)元素,用于查看指标的可见性  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) => {          // callback函数的参数(entries)是一个数组,每个成员都是一个IntersectionObserverEntry对象。如果同时有两个被察看的对象的可见性发生变化,entries数组就会有两个成员。          for (const entry of entries) {            setRatio(entry.intersectionRatio); // 设置以后指标元素的可见比例            setState(entry.isIntersecting); // isIntersecting:如果指标元素与交加观察者的根相交,则该值为true          }        },        {          ...options,          root: getTargetElement(options?.root),        },      );      observer.observe(el); // 开始监听一个指标元素      return () => {        observer.disconnect(); // 进行监听指标      };    },    [options?.rootMargin, options?.threshold],    target,  );  return [state, ratio] as const;}

残缺源码

useKeyPress

监听键盘按键,反对组合键,反对按键别名。

官网文档

KeyEvent 根底

JS 的键盘事件

  • keydown:触发于键盘按键按下的时候。
  • keyup:在按键被松开时触发。
  • (已过期)keypress:按下有值的键时触发,即按下 Ctrl、Alt、Shift、Meta 这样无值的键,这个事件不会触发。对于有值的键,按下时先触发 keydown 事件,再触发 keypress 事件

对于 keyCode
(已过期)event.keyCode(返回按下键的数字代码),尽管目前大部分代码仍然应用并放弃兼容。但如果咱们本人实现的话应该尽可能应用 event.key(按下的键的理论值)属性。具体可见KeyboardEvent

如何监听按键组合

润饰键有四个

const modifierKey = {  ctrl: (event: KeyboardEvent) => event.ctrlKey,  shift: (event: KeyboardEvent) => event.shiftKey,  alt: (event: KeyboardEvent) => event.altKey,  meta: (event: KeyboardEvent) => {    if (event.type === 'keyup') {      // 这里应用数组判断是因为 meta 键分右边和左边的键(MetaLeft 91、MetaRight 93)      return aliasKeyCodeMap['meta'].includes(event.keyCode);    }    return event.metaKey;  },};
  • 当按下的组合键蕴含 Ctrl 键时,event.ctrlKey 属性为 true
  • 当按下的组合键蕴含 Shift 键时,event.shiftKey 属性为 true
  • 当按下的组合键蕴含 Alt 键时,event.altKey 属性为 true
  • 当按下的组合键蕴含 meta 键时,event.meta 属性为 true(Mac 是 command 键,Windows 电脑是 win 键)

如按下 Alt+K 组合键,会触发两次 keydown事件,其中 Alt 键和 K 键打印的 altKey 都为 true,能够这么判断:

if (event.altKey && keyCode === 75) {  console.log("按下了 Alt + K 键");}

在线测试

这里举荐个在线网站 Keyboard Events Playground测试键盘事件,只须要输出任意键即可查看无关它打印的信息,还能够通过复选框来过滤事件,辅助咱们开发验证。

根本用法

官网在线 Demo

在看源码之前,须要理解下该 Hook 反对的用法:

// 反对键盘事件中的 keyCode 和别名useKeyPress('uparrow', () => {  // TODO});// keyCode value for ArrowDownuseKeyPress(40, () => {  // TODO});// 监听组合按键useKeyPress('ctrl.alt.c', () => {  // TODO});// 开启准确匹配。比方按 [shift + c] ,不会触发 [c]useKeyPress(  ['c'],  () => {    // TODO  },  {    exactMatch: true,  },);// 监听多个按键。如下 a s d f, Backspace, 8useKeyPress([65, 83, 68, 70, 8, '8'], (event) => {  setKey(event.key);});// 自定义监听形式。反对接管一个返回 boolean 的回调函数,本人解决逻辑。const filterKey = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];useKeyPress(  (event) => !filterKey.includes(event.key),  (event) => {    // TODO  },  {    events: ['keydown', 'keyup'],  },);// 自定义 DOM。默认监听挂载在 window 上的事件,也能够传入 DOM 指定监听区域,如常见的监听输入框事件useKeyPress(  'enter',  (event: any) => {    // TODO  },  {    target: inputRef,  },);

useKeyPress 的参数:

type keyType = number | string;// 反对 keyCode、别名、组合键、数组,自定义函数type KeyFilter = keyType | keyType[] | ((event: KeyboardEvent) => boolean);// 回调函数type EventHandler = (event: KeyboardEvent) => void;type KeyEvent = 'keydown' | 'keyup';type Options = {  events?: KeyEvent[]; // 触发事件  target?: Target; // DOM 节点或者 ref  exactMatch?: boolean; // 准确匹配。如果开启,则只有在按键齐全匹配的状况下触发事件。比方按键 [shif + c] 不会触发 [c]  useCapture?: boolean; // 是否阻止事件冒泡};// useKeyPress 参数useKeyPress(  keyFilter: KeyFilter,  eventHandler: EventHandler,  options?: Options);

实现思路

  1. 监听 keydownkeyup 事件,处理事件回调函数。
  2. 在事件回调函数中传入 keyFilter 配置进行判断,兼容自定义函数、keyCode、别名、组合键、数组,反对准确匹配
  3. 如果满足该回调最终判断后果,则触发 eventHandler 回调

外围实现

  • genKeyFormatter:键盘输入预处理办法
  • genFilterKey:判断按键是否激活

沿着上述三点,咱们来看这部分精简代码:

function useKeyPress(keyFilter: KeyFilter, eventHandler: EventHandler, option?: Options) {  const { events = defaultEvents, target, exactMatch = false, useCapture = 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 = genKeyFormatter(keyFilterRef.current, exactMatch);        // 判断是否匹配 keyFilter 配置后果,返回 true 则触发传入的回调函数        if (genGuard(event)) {          return eventHandlerRef.current?.(event);        }      };      // 监听事件(默认事件:keydown)      for (const eventName of events) {        el?.addEventListener?.(eventName, callbackHandler, useCapture);      }      return () => {        // 勾销监听        for (const eventName of events) {          el?.removeEventListener?.(eventName, callbackHandler, useCapture);        }      };    },    [events],    target,  );}

下面的代码看起来比拟好了解,须要斟酌的就是 genKeyFormatter 函数。

/** * 键盘输入预处理办法 * @param [keyFilter: any] 以后键 * @returns () => Boolean */function genKeyFormatter(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;  return () => Boolean(keyFilter);}

看完发现下面的重点实现还是在 genFilterKey 函数:

  • aliasKeyCodeMap

这段逻辑须要各位代入理论数值帮忙了解,如输出组合键 shift.c

/** * 判断按键是否激活 * @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('.'); // 如 keyFilter 能够传 ctrl.alt.c,['shift.c']  let genLen = 0;  for (const key of genArr) {    // 组合键    const genModifier = modifierKey[key]; // ctrl/shift/alt/meta    // keyCode 别名    const aliasKeyCode: number | number[] = 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;}// 依据 event 计算激活键数量function countKeyByEvent(event: KeyboardEvent) {  // 计算激活的润饰键数量  const countOfModifier = Object.keys(modifierKey).reduce((total, key) => {    // (event: KeyboardEvent) => Boolean    if (modifierKey[key](event)) {      return total + 1;    }    return total;  }, 0);  // 16 17 18 91 92 是润饰键的 keyCode,如果 keyCode 是润饰键,那么激活数量就是润饰键的数量,如果不是,那么就须要 +1  return [16, 17, 18, 91, 92].includes(event.keyCode) ? countOfModifier : countOfModifier + 1;}

残缺源码

useLongPress

监听指标元素的长按事件。

官网文档

根本用法

反对参数:

export interface Options {  delay?: number;  moveThreshold?: { x?: number; y?: number };  onClick?: (event: EventType) => void;  onLongPressEnd?: (event: EventType) => void;}

官网在线 Demo

import React, { useState, useRef } from 'react';import { useLongPress } from 'ahooks';export default () => {  const [counter, setCounter] = useState(0);  const ref = useRef<HTMLButtonElement>(null);  useLongPress(() => setCounter((s) => s + 1), ref);  return (    <div>      <button ref={ref} type="button">        Press me      </button>      <p>counter: {counter}</p>    </div>  );};

touch 事件

  • touchstart:在一个或多个触点与触控设施外表接触时被触发
  • touchmove:在触点于触控立体上挪动时触发
  • touchend:当触点来到触控立体时触发 touchend 事件

实现思路

  1. 判断以后环境是否反对 touch 事件:反对则监听 touchstarttouchend 事件,不反对则监听 mousedownmouseupmouseleave 事件
  2. 依据触发监听事件和定时器独特来判断是否达到长按事件,达到则触发内部回调
  3. 如果内部有传 moveThreshold(按下后挪动阈值)参数 ,则须要监听 mousemovetouchmove 事件进行解决

外围实现

依据[实现思路]第一条,很容易看懂实现大抵框架代码:

type EventType = MouseEvent | TouchEvent;// 是否反对 touch 事件const touchSupported =  isBrowser &&  // @ts-ignore  ('ontouchstart' in window || (window.DocumentTouch && document instanceof DocumentTouch));function useLongPress(  onLongPress: (event: EventType) => void,  target: BasicTarget,  { delay = 300, moveThreshold, onClick, onLongPressEnd }: Options = {},) {  const onLongPressRef = useLatest(onLongPress);  const onClickRef = useLatest(onClick);  const onLongPressEndRef = useLatest(onLongPressEnd);  const timerRef = useRef<ReturnType<typeof setTimeout>>();  const isTriggeredRef = useRef(false);  // 是否有设置挪动阈值  const hasMoveThreshold = !!(    (moveThreshold?.x && moveThreshold.x > 0) ||    (moveThreshold?.y && moveThreshold.y > 0)  );  useEffectWithTarget(    () => {      const targetElement = getTargetElement(target);      if (!targetElement?.addEventListener) {        return;      }      const overThreshold = (event: EventType) => {};      function getClientPosition(event: EventType) {}      const onStart = (event: EventType) => {};      const onMove = (event: TouchEvent) => {};      const onEnd = (event: EventType, shouldTriggerClick: boolean = false) => {};      const onEndWithClick = (event: EventType) => onEnd(event, true);      if (!touchSupported) {        // 不反对 touch 事件        targetElement.addEventListener('mousedown', onStart);        targetElement.addEventListener('mouseup', onEndWithClick);        targetElement.addEventListener('mouseleave', onEnd);        if (hasMoveThreshold) targetElement.addEventListener('mousemove', onMove);      } else {        // 反对 touch 事件        targetElement.addEventListener('touchstart', onStart);        targetElement.addEventListener('touchend', onEndWithClick);        if (hasMoveThreshold) targetElement.addEventListener('touchmove', onMove);      }      // 卸载函数解绑监听事件      return () => {        // 革除定时器,重置状态        if (timerRef.current) {          clearTimeout(timerRef.current);          isTriggeredRef.current = false;        }        if (!touchSupported) {          targetElement.removeEventListener('mousedown', onStart);          targetElement.removeEventListener('mouseup', onEndWithClick);          targetElement.removeEventListener('mouseleave', onEnd);          if (hasMoveThreshold) targetElement.removeEventListener('mousemove', onMove);        } else {          targetElement.removeEventListener('touchstart', onStart);          targetElement.removeEventListener('touchend', onEndWithClick);          if (hasMoveThreshold) targetElement.removeEventListener('touchmove', onMove);        }      };    },    [],    target,  );}

对于是否反对 touch 事件的判断代码,须要理解一种场景,在搜的时候发现一篇文章能够看下:touchstart 与 click 不得不说的故事

如何判断长按事件

  1. 在 onStart 设置一个定时器 setTimeout 用来判断长按工夫,在定时器回调将 isTriggeredRef.current 设置为 true,示意触发了长按事件;
  2. 在 onEnd 革除定时器并判断 isTriggeredRef.current 的值,true 代表触发了长按事件;false 代表没触发 setTimeout 外面的回调,则不触发长按事件。
const onStart = (event: EventType) => {  timerRef.current = setTimeout(() => {    // 达到设置的长按工夫    onLongPressRef.current(event);    isTriggeredRef.current = true;  }, delay);};const onEnd = (event: EventType, shouldTriggerClick: boolean = false) => {  // 革除 onStart 设置的定时器  if (timerRef.current) {    clearTimeout(timerRef.current);  }  // 判断是否达到长按工夫  if (isTriggeredRef.current) {    onLongPressEndRef.current?.(event);  }  // 是否触发点击事件  if (shouldTriggerClick && !isTriggeredRef.current && onClickRef.current) {    onClickRef.current(event);  }  // 重置  isTriggeredRef.current = false;};

实现了[实现思路]的前两点,接下来须要实现第三点,传 moveThreshold 的状况

const hasMoveThreshold = !!(  (moveThreshold?.x && moveThreshold.x > 0) ||  (moveThreshold?.y && moveThreshold.y > 0));
clientX、clientY:点击地位间隔以后 body 可视区域的 x,y 坐标
const onStart = (event: EventType) => {  if (hasMoveThreshold) {    const { clientX, clientY } = getClientPosition(event);    // 记录首次点击/触屏时的地位    pervPositionRef.current.x = clientX;    pervPositionRef.current.y = clientY;  }  // ...};// 传 moveThreshold 需绑定 onMove 事件const onMove = (event: TouchEvent) => {  if (timerRef.current && overThreshold(event)) {    // 超过挪动阈值不触发长按事件,并革除定时器    clearInterval(timerRef.current);    timerRef.current = undefined;  }};// 判断是否超过挪动阈值const overThreshold = (event: EventType) => {  const { clientX, clientY } = getClientPosition(event);  const offsetX = Math.abs(clientX - pervPositionRef.current.x);  const offsetY = Math.abs(clientY - pervPositionRef.current.y);  return !!(    (moveThreshold?.x && offsetX > moveThreshold.x) ||    (moveThreshold?.y && offsetY > moveThreshold.y)  );};function getClientPosition(event: EventType) {  if (event instanceof TouchEvent) {    return {      clientX: event.touches[0].clientX,      clientY: event.touches[0].clientY,    };  }  if (event instanceof MouseEvent) {    return {      clientX: event.clientX,      clientY: event.clientY,    };  }  console.warn('Unsupported event type');  return { clientX: 0, clientY: 0 };}

残缺源码