前言
本文是 ahooks 源碼系列的第四篇,往期文章:
- 【解讀 ahooks 源碼系列】(開篇)如何獲取和監聽 DOM 元素:useEffectWithTarget
- 【解讀 ahooks 源碼系列】DOM篇(一):useEventListener、useClickAway、useDocumentVisibility、useDrop、useDrag
- 【解讀 ahooks 源碼系列】DOM篇(二):useEventTarget、useExternal、useTitle、useFavicon、useFullscreen、useHover
本文主要解讀 useMutationObserver、useInViewport、useKeyPress、useLongPress 源碼實現
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 使用教程
實現思路
- 監聽目標元素,支持傳入原生
IntersectionObserverAPI 選項 - 對
IntersectionObserver構造函數的回調函數設置可見狀態與可見比例值 - 藉助 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
);
實現思路
- 監聽
keydown或keyup事件,處理事件回調函數。 - 在事件回調函數中傳入 keyFilter 配置進行判斷,兼容自定義函數、keyCode、別名、組合鍵、數組,支持精確匹配
- 如果滿足該回調最終判斷結果,則觸發 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 事件
實現思路
- 判斷當前環境是否支持 touch 事件:支持則監聽
touchstart、touchend事件,不支持則監聽mousedown、mouseup、mouseleave事件 - 根據觸發監聽事件和定時器共同來判斷是否達到長按事件,達到則觸發外部回調
- 如果外部有傳
moveThreshold(按下後移動閾值)參數 ,則需要監聽mousemove或touchmove事件進行處理
核心實現
根據[實現思路]第一條,很容易看懂實現大致框架代碼:
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 不得不説的故事
如何判斷長按事件:
- 在 onStart 設置一個定時器 setTimeout 用來判斷長按時間,在定時器回調將 isTriggeredRef.current 設置為 true,表示觸發了長按事件;
- 在 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 };
}
完整源碼