博客 / 詳情

返回

【解讀 ahooks 源碼系列】DOM篇(三)

前言

本文是 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 如下:

image.png

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 ArrowDown
useKeyPress(40, () => {
  // TODO
});

// 監聽組合按鍵
useKeyPress('ctrl.alt.c', () => {
  // TODO
});

// 開啓精確匹配。比如按 [shift + c] ,不會觸發 [c]
useKeyPress(
  ['c'],
  () => {
    // TODO
  },
  {
    exactMatch: true,
  },
);

// 監聽多個按鍵。如下 a s d f, Backspace, 8
useKeyPress([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 };
}

完整源碼

user avatar ivyzhang 頭像 esunr 頭像 flymon 頭像 weirdo_5f6c401c6cc86 頭像 nanian_5cd6881d3cc98 頭像 thepoy 頭像 frontoldman 頭像 b_a_r_a_n 頭像 user_p5fejtxs 頭像 fehaha 頭像 nihaojob 頭像 icezero 頭像
23 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.