foreword
This article is the fourth in the ahooks source code series, previous articles:
- [Interpretation of ahooks source code series] (beginning) How to obtain and monitor DOM elements: useEffectWithTarget
- [Interpretation of ahooks source code series] DOM articles (1): useEventListener,useClickAway,useDocumentVisibility,useDrop,useDrag
- [Interpretation of ahooks source code series] DOM articles (2): useEventTarget,useExternal,useTitle,useFavicon,useFullscreen,useHover
This article mainly interprets the source code implementation of useMutationObserver, useInViewport, useKeyPress, and useLongPress
useMutationObserver
A Hook that listens for changes in the specified DOM tree
MutationObserver API
The MutationObserver interface provides the ability to monitor changes made to the DOM tree. Using the MutationObserver API, we can monitor changes in the DOM, such as the increase and decrease of nodes, changes in attributes, changes in text content, and so on.
Can refer to study:
basic usage
Click the button to change the width and trigger the change of the width property of the div. The printed mutationsList is as follows:
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> ); };
core implementation
This implementation is relatively simple, mainly to understand the MutationObserver API:
useMutationObserver( callback: MutationCallback, // Triggered callback function target: Target, options?: MutationObserverInit, // Setting items: 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; } // Create an observer instance and pass in the callback function const observer = new MutationObserver(callbackRef.current); observer.observe(element, options); // Start monitoring and specify the DOM node to be observed return () => { if (observer) { observer.disconnect(); // stop watching changes } }; }, [options], target, ); };
useInViewport
Observe whether the element is in the visible area, and the visible ratio of the element.
basic usage
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> ); };
scenes to be used
- Image lazy loading: when the image is scrolled to a visible position, it is loaded
- Infinite scroll loading: start loading new content when swiping to the bottom
- Detect the exposure rate of the advertisement: whether the advertisement is seen by the user
- Perform a task or play an animation when the user sees an area
IntersectionObserver API
IntersectionObserver API, which can automatically "observe" whether an element is visible. Since the essence of visible (visible) is that the target element and the viewport generate an intersection area, this API is called "intersecting observer".
- Intersection Observer API
- Can refer to study: IntersectionObserver API Tutorial
Implementation ideas
- Monitor the target element, support passing in native IntersectionObserver API options
- Set the visibility state and visibility scale value to the callback function of the IntersectionObserver constructor
- with the help of intersection-observer Library implementation polyfill
core implementation
export interface Options { // The margin of the root element rootMargin?: string; // It can be controlled to trigger ratio update when the visible area reaches this ratio. The default value is 0 (meaning that the callback function will be executed whenever there is a target pixel present in the root element). A value of 1.0 means that the callback will only be executed when the target completely appears within the root element. threshold?: number | number[]; // Specify the root element to check the visibility of the target root?: BasicTarget<Element>; }
function useInViewport(target: BasicTarget, options?: Options) { const [state, setState] = useState<boolean>(); // Is it visible const [ratio, setRatio] = useState<number>(); // currently visible scale useEffectWithTarget( () => { const el = getTargetElement(target); if (!el) { return; } // Can automatically observe whether the element is visible, and return an observer instance const observer = new IntersectionObserver( (entries) => { // The parameter (entries) of the callback function is an array, and each member is an IntersectionObserverEntry object. If the visibility of two observed objects changes at the same time, the entries array will have two members. for (const entry of entries) { setRatio(entry.intersectionRatio); // Sets the visible scale of the current target element setState(entry.isIntersecting); // isIntersecting: true if the target element intersects the intersection observer's root } }, { ...options, root: getTargetElement(options?.root), }, ); observer.observe(el); // Start listening to a target element return () => { observer.disconnect(); // stop listening to target }; }, [options?.rootMargin, options?.threshold], target, ); return [state, ratio] as const; }
useKeyPress
Monitor keyboard keys, support key combinations, and support key aliases.
KeyEvent Basics
JS keyboard events
- keydown : Triggered when a keyboard key is pressed.
- keyup : Triggered when the key is released.
- (obsolete) keypress : Triggered when a key with a value is pressed, that is, when a key without a value such as Ctrl, Alt, Shift, Meta is pressed, this event will not be triggered. For a key with a value, when pressed, the keydown event is triggered first, and then the keypress event is triggered
About keyCode
(Deprecated) event.keyCode (returns the numeric code of the key pressed), although most of the code is still used and remains compatible. But if we implement it ourselves, we should use the event.key (the actual value of the key pressed) property as much as possible. Specific visible KeyboardEvent
How to Listen for Key Combinations
There are four modifier keys
const modifierKey = { ctrl: (event: KeyboardEvent) => event.ctrlKey, shift: (event: KeyboardEvent) => event.shiftKey, alt: (event: KeyboardEvent) => event.altKey, meta: (event: KeyboardEvent) => { if (event.type === 'keyup') { // The use of array judgment here is because the meta key is divided into left and right keys (MetaLeft 91, MetaRight 93) return aliasKeyCodeMap['meta'].includes(event.keyCode); } return event.metaKey; }, };
- The event.ctrlKey property is true when the pressed key combination contains the Ctrl key
- The event.shiftKey property is true when the pressed key combination contains the Shift key
- The event.altKey property is true when the pressed key combination contains the Alt key
- The event.meta property is true when the pressed key combination contains the meta key (command key on Mac, win key on Windows PC)
For example, pressing the Alt+K key combination will trigger two keydown events, and the altKey printed by the Alt key and the K key are both true, which can be judged as follows:
if (event.altKey && keyCode === 75) { console.log("pressed Alt + K key"); }
online test
Here is an online site recommended Keyboard Events Playground To test keyboard events, you only need to enter any key to view the information it prints, and you can also filter events through check boxes to assist us in development and verification.
basic usage
Before looking at the source code, you need to understand the usage supported by this Hook:
// Support keyCode and aliases in keyboard events useKeyPress('uparrow', () => { // TODO }); // keyCode value for ArrowDown useKeyPress(40, () => { // TODO }); // Monitor key combinations useKeyPress('ctrl.alt.c', () => { // TODO }); // Turn on exact match. For example, pressing [shift + c] will not trigger [c] useKeyPress( ['c'], () => { // TODO }, { exactMatch: true, }, ); // Listen for multiple keystrokes. As follows a s d f, Backspace, 8 useKeyPress([65, 83, 68, 70, 8, '8'], (event) => { setKey(event.key); }); // Customize the monitoring method. Support receiving a callback function that returns boolean, and handle the logic by yourself. const filterKey = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; useKeyPress( (event) => !filterKey.includes(event.key), (event) => { // TODO }, { events: ['keydown', 'keyup'], }, ); // Customize the DOM. By default, it listens to the events mounted on the window, and can also pass in the DOM to specify the listening area, such as common listening input box events useKeyPress( 'enter', (event: any) => { // TODO }, { target: inputRef, }, );
Parameters of useKeyPress:
type keyType = number | string; // Support keyCode, alias, composite key, array, custom function type KeyFilter = keyType | keyType[] | ((event: KeyboardEvent) => boolean); // Callback type EventHandler = (event: KeyboardEvent) => void; type KeyEvent = 'keydown' | 'keyup'; type Options = { events?: KeyEvent[]; // trigger event target?: Target; // DOM node or ref exactMatch?: boolean; // exact match. If enabled, the event will only be fired if the keypress is an exact match. For example, pressing [shif + c] will not trigger [c] useCapture?: boolean; // Whether to prevent event bubbling }; // useKeyPress parameter useKeyPress( keyFilter: KeyFilter, eventHandler: EventHandler, options?: Options );
Implementation ideas
- Listen to the keydown or keyup event and handle the event callback function.
- Pass in the keyFilter configuration in the event callback function for judgment, compatible with custom functions, keyCode, aliases, composite keys, arrays, and support exact matching
- If the final judgment result of the callback is satisfied, the eventHandler callback is triggered
core implementation
- genKeyFormatter: keyboard input preprocessing method
- genFilterKey: Determine whether the key is activated
Following the above three points, let's look at this part of the streamlined code:
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); // Listen for elements (deep comparison) useDeepCompareEffectWithTarget( () => { const el = getTargetElement(target, window); if (!el) { return; } // Event callback function const callbackHandler = (event: KeyboardEvent) => { // Keyboard input preprocessing method const genGuard: KeyPredicate = genKeyFormatter(keyFilterRef.current, exactMatch); // Determine whether it matches the keyFilter configuration result, return true to trigger the incoming callback function if (genGuard(event)) { return eventHandlerRef.current?.(event); } }; // Listen for events (default event: keydown) for (const eventName of events) { el?.addEventListener?.(eventName, callbackHandler, useCapture); } return () => { // unlisten for (const eventName of events) { el?.removeEventListener?.(eventName, callbackHandler, useCapture); } }; }, [events], target, ); }
The above code looks relatively easy to understand, what needs to be considered is the genKeyFormatter function.
/** * Keyboard input preprocessing method * @param [keyFilter: any] current key * @returns () => Boolean */ function genKeyFormatter(keyFilter: KeyFilter, exactMatch: boolean): KeyPredicate { // Support for custom functions if (isFunction(keyFilter)) { return keyFilter; } // Support keyCode, alias, key combination if (isString(keyFilter) || isNumber(keyFilter)) { return (event: KeyboardEvent) => genFilterKey(event, keyFilter, exactMatch); } // support array if (Array.isArray(keyFilter)) { return (event: KeyboardEvent) => keyFilter.some((item) => genFilterKey(event, item, exactMatch)); } // Equivalent to return keyFilter ? () => true : () => false; return () => Boolean(keyFilter); }
After reading it, I found that the key implementation above is still in the genFilterKey function:
This logic requires you to substitute actual values to help understand, such as entering the key combination shift.c
/** * Determine whether the button is activated * @param [event: KeyboardEvent]keyboard events * @param [keyFilter: any] current key * @returns Boolean */ function genFilterKey(event: KeyboardEvent, keyFilter: keyType, exactMatch: boolean) { // When the browser automatically completes the input, the keyDown and keyUp events will be triggered, but at this time event.key etc. are empty if (!event.key) { return false; } // The numeric type directly matches the keyCode of the event if (isNumber(keyFilter)) { return event.keyCode === keyFilter; } // The string is sequentially judged whether there is a composite key const genArr = keyFilter.split('.'); // For example, keyFilter can pass ctrl.alt.c, ['shift.c'] let genLen = 0; for (const key of genArr) { // Key combination const genModifier = modifierKey[key]; // ctrl/shift/alt/meta // keyCode alias const aliasKeyCode: number | number[] = aliasKeyCodeMap[key.toLowerCase()]; if ((genModifier && genModifier(event)) || (aliasKeyCode && aliasKeyCode === event.keyCode)) { genLen++; } } /** * It is necessary to judge that the triggered key is exactly the same as the monitored key. The method of judgment is that the triggered key has and is equal to the monitored key. * genLen === genArr.length It can be judged that there are monitoring keys in the triggered keys * countKeyByEvent(event) === genArr.length It is judged that the number of triggered keys is equal to the number of monitored keys * It is mainly used to prevent the case where a subset of key combinations is also triggered. For example, listening to ctrl+a will trigger the event of listening to the two keys of ctrl and a. */ if (exactMatch) { return genLen === genArr.length && countKeyByEvent(event) === genArr.length; } return genLen === genArr.length; } // Calculate the number of activation keys according to the event function countKeyByEvent(event: KeyboardEvent) { // Count the number of active modifier keys 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 is the keyCode of the modifier key, if the keyCode is a modifier key, then the number of activations is the number of modifier keys, if not, then +1 is required return [16, 17, 18, 91, 92].includes(event.keyCode) ? countOfModifier : countOfModifier + 1; }
useLongPress
Listen to the long press event of the target element.
basic usage
Support parameters:
export interface Options { delay?: number; moveThreshold?: { x?: number; y?: number }; onClick?: (event: EventType) => void; onLongPressEnd?: (event: EventType) => void; }
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 event
- touchstart : Fired when one or more touch points come into contact with the touch device surface
- touchmove : Triggered when the touch point moves on the touch surface
- touchend : The touchend event is triggered when the touch point leaves the touch surface
Implementation ideas
- Determine whether the current environment supports touch events: if supported, monitor touchstart and touchend events; if not supported, monitor mousedown, mouseup, mouseleave events
- According to the trigger monitoring event and the timer, it is judged whether the long press event is reached, and the external callback is triggered when it is reached
- If there is a moveThreshold (moving threshold after pressing) parameter, you need to listen to mousemove or touchmove events for processing
core implementation
According to the first article of [Implementation Ideas], it is easy to understand the general framework code:
type EventType = MouseEvent | TouchEvent; // Whether to support touch event 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); // Is there a mobile threshold set 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) { // Does not support touch events targetElement.addEventListener('mousedown', onStart); targetElement.addEventListener('mouseup', onEndWithClick); targetElement.addEventListener('mouseleave', onEnd); if (hasMoveThreshold) targetElement.addEventListener('mousemove', onMove); } else { // Support touch event targetElement.addEventListener('touchstart', onStart); targetElement.addEventListener('touchend', onEndWithClick); if (hasMoveThreshold) targetElement.addEventListener('touchmove', onMove); } // The uninstall function unbinds the listening event return () => { // clear timer, reset state 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, ); }
For the judgment code of whether to support the touch event, you need to understand a scene. When searching, you can find an article that you can read: The story that touchstart and click have to tell
How to judge the long press event:
- Set a timer setTimeout in onStart to judge the long press time, and set isTriggeredRef.current to true in the timer callback, indicating that the long press event is triggered;
- Clear the timer in onEnd and judge the value of isTriggeredRef.current. true means the long press event is triggered; false means the callback in setTimeout is not triggered, and the long press event is not triggered.
const onStart = (event: EventType) => { timerRef.current = setTimeout(() => { // The set long press time is reached onLongPressRef.current(event); isTriggeredRef.current = true; }, delay); }; const onEnd = (event: EventType, shouldTriggerClick: boolean = false) => { // Clear the timer set by onStart if (timerRef.current) { clearTimeout(timerRef.current); } // Determine whether the long press time has been reached if (isTriggeredRef.current) { onLongPressEndRef.current?.(event); } // Whether to trigger the click event if (shouldTriggerClick && !isTriggeredRef.current && onClickRef.current) { onClickRef.current(event); } // reset isTriggeredRef.current = false; };
The first two points of [Implementation Ideas] have been realized, and the third point needs to be realized next, which is the case of moveThreshold
const hasMoveThreshold = !!( (moveThreshold?.x && moveThreshold.x > 0) || (moveThreshold?.y && moveThreshold.y > 0) );
clientX, clientY: the x, y coordinates of the click position from the current visible area of the body
const onStart = (event: EventType) => { if (hasMoveThreshold) { const { clientX, clientY } = getClientPosition(event); // Record the location of the first click/touch pervPositionRef.current.x = clientX; pervPositionRef.current.y = clientY; } // ... }; // pass moveThreshold need to bind onMove event const onMove = (event: TouchEvent) => { if (timerRef.current && overThreshold(event)) { // Exceeding the movement threshold does not trigger a long press event and clears the timer clearInterval(timerRef.current); timerRef.current = undefined; } }; // Determine whether the movement threshold is exceeded 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 }; }