[Interpretation of ahooks source code series] DOM articles

foreword

This article is the fourth in the ahooks source code series, previous articles:

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

official document

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

Official Online Demo

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,
  );
};

full source code

useInViewport

Observe whether the element is in the visible area, and the visible ratio of the element.

official document

basic usage

Official Online 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>
  );
};

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".

Implementation ideas

  1. Monitor the target element, support passing in native IntersectionObserver API options
  2. Set the visibility state and visibility scale value to the callback function of the IntersectionObserver constructor
  3. 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;
}

full source code

useKeyPress

Monitor keyboard keys, support key combinations, and support key aliases.

official document

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

Official Online Demo

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

  1. Listen to the keydown or keyup event and handle the event callback function.
  2. 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
  3. 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;
}

full source code

useLongPress

Listen to the long press event of the target element.

official document

basic usage

Support parameters:

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

Official Online 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 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

  1. Determine whether the current environment supports touch events: if supported, monitor touchstart and touchend events; if not supported, monitor mousedown, mouseup, mouseleave events
  2. 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
  3. 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:

  1. 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;
  2. 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 };
}

full source code

Tags: Front-end React hooks

Posted by San_John on Wed, 22 Mar 2023 00:17:30 +1030