前言

本文是 ahooks 源码(v3.7.4)系列的第五篇,也是 DOM 篇的完结篇,往期文章:

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

本文次要解读 useMouseuseResponsiveuseScrolluseSizeuseFocusWithin的源码实现

useMouse

监听鼠标地位。

官网文档

根本用法

API:

const state: {  screenX: number, // 间隔显示器左侧的间隔  screenY: number, // 间隔显示器顶部的间隔  clientX: number, // 间隔以后视窗左侧的间隔  clientY: number, // 间隔以后视窗顶部的间隔  pageX: number, // 间隔残缺页面左侧的间隔  pageY: number, // 间隔残缺页面顶部的间隔  elementX: number, // 间隔指定元素左侧的间隔  elementY: number, // 间隔指定元素顶部的间隔  elementH: number, // 指定元素的高  elementW: number, // 指定元素的宽  elementPosX: number, // 指定元素间隔残缺页面左侧的间隔  elementPosY: number, // 指定元素间隔残缺页面顶部的间隔} = useMouse(target?: Target);

官网在线 Demo

import React, { useRef } from 'react';import { useMouse } from 'ahooks';export default () => {  const ref = useRef(null);  const mouse = useMouse(ref.current);  return (    <>      <div        ref={ref}        style={{          width: '200px',          height: '200px',          backgroundColor: 'gray',          color: 'white',          lineHeight: '200px',          textAlign: 'center',        }}      >        element      </div>      <div>        <p>          Mouse In Element - x: {mouse.elementX}, y: {mouse.elementY}        </p>        <p>          Element Position - x: {mouse.elementPosX}, y: {mouse.elementPosY}        </p>        <p>          Element Dimensions - width: {mouse.elementW}, height: {mouse.elementH}        </p>      </div>    </>  );};

外围实现

实现原理:通过监听 mousemove 办法,获取鼠标的地位。通过 getBoundingClientRect(提供了元素的大小及其绝对于视口的地位) 获取到 target 元素的地位大小,计算出鼠标绝对于元素的地位。

export default (target?: BasicTarget) => {  const [state, setState] = useRafState(initState);  useEventListener(    'mousemove',    (event: MouseEvent) => {      const { screenX, screenY, clientX, clientY, pageX, pageY } = event;      const newState = {        screenX,        screenY,        clientX,        clientY,        pageX,        pageY,        elementX: NaN,        elementY: NaN,        elementH: NaN,        elementW: NaN,        elementPosX: NaN,        elementPosY: NaN,      };      const targetElement = getTargetElement(target);      if (targetElement) {        const { left, top, width, height } = targetElement.getBoundingClientRect();        // 计算鼠标绝对于元素的地位        newState.elementPosX = left + window.pageXOffset; // window.pageXOffset:window.scrollX 的别名        newState.elementPosY = top + window.pageYOffset; // scrollY 的别名        newState.elementX = pageX - newState.elementPosX;        newState.elementY = pageY - newState.elementPosY;        newState.elementW = width;        newState.elementH = height;      }      setState(newState);    },    {      target: () => document,    },  );  return state;};

残缺源码

useResponsive

获取响应式信息。

官网文档

根本用法

官网在线 Demo

import React from 'react';import { configResponsive, useResponsive } from 'ahooks';configResponsive({  small: 0,  middle: 800,  large: 1200,});export default function () {  const responsive = useResponsive();  return (    <>      <p>Please change the width of the browser window to see the effect: </p>      {Object.keys(responsive).map((key) => (        <p key={key}>          {key} {responsive[key] ? '✔' : '✘'}        </p>      ))}    </>  );}

实现思路

  1. 监听 resize 事件,在 resize 事件处理函数中须要计算,且判断是否须要更新解决(性能优化)。
  2. 计算:遍历比照 window.innerWidth 与配置项的每一种屏幕宽度,大于设置为 true,否则为 false

外围实现

type Subscriber = () => void;const subscribers = new Set<Subscriber>();type ResponsiveConfig = Record<string, number>;type ResponsiveInfo = Record<string, boolean>;let info: ResponsiveInfo;// 默认的响应式配置和 bootstrap 是统一的let responsiveConfig: ResponsiveConfig = {  xs: 0,  sm: 576,  md: 768,  lg: 992,  xl: 1200,};function handleResize() {  const oldInfo = info;  calculate();  if (oldInfo === info) return; // 没有更新,不解决  for (const subscriber of subscribers) {    subscriber();  }}let listening = false; // 防止屡次监听// 计算以后的屏幕宽度与配置比拟function calculate() {  const width = window.innerWidth; // 返回窗口的的宽度  const newInfo = {} as ResponsiveInfo;  let shouldUpdate = false; // 判断是否须要更新  for (const key of Object.keys(responsiveConfig)) {    newInfo[key] = width >= responsiveConfig[key];    if (newInfo[key] !== info[key]) {      shouldUpdate = true;    }  }  if (shouldUpdate) {    info = newInfo;  }}// 自定义配置响应式断点(只需配置一次)export function configResponsive(config: ResponsiveConfig) {  responsiveConfig = config;  if (info) calculate();}export function useResponsive() {  if (isBrowser && !listening) {    info = {};    calculate();    window.addEventListener('resize', handleResize);    listening = true;  }  const [state, setState] = useState<ResponsiveInfo>(info);  useEffect(() => {    if (!isBrowser) return;    // In React 18's StrictMode, useEffect perform twice, resize listener is remove, so handleResize is never perform.    // https://github.com/alibaba/hooks/issues/1910    if (!listening) {      window.addEventListener('resize', handleResize);    }    const subscriber = () => {      setState(info);    };    // 增加订阅    subscribers.add(subscriber);    return () => {      // 组件卸载时勾销订阅      subscribers.delete(subscriber);      // 当全局订阅器不再有订阅器,则移除 resize 监听事件      if (subscribers.size === 0) {        window.removeEventListener('resize', handleResize);        listening = false;      }    };  }, []);  return state;}

残缺源码

useScroll

监听元素的滚动地位。

官网文档

根本用法

官网在线 Demo,下方代码的执行后果

import React, { useRef } from 'react';import { useScroll } from 'ahooks';export default () => {  const ref = useRef(null);  const scroll = useScroll(ref);  return (    <>      <p>{JSON.stringify(scroll)}</p>      <div        style={{          height: '160px',          width: '160px',          border: 'solid 1px #000',          overflow: 'scroll',          whiteSpace: 'nowrap',          fontSize: '32px',        }}        ref={ref}      >        <div>          Lorem ipsum dolor sit amet, consectetur adipisicing elit. A aspernatur atque, debitis ex          excepturi explicabo iste iure labore molestiae neque optio perspiciatis        </div>        <div>          Aspernatur cupiditate, deleniti id incidunt mollitia omnis! A aspernatur assumenda          consequuntur culpa cumque dignissimos enim eos, et fugit natus nemo nesciunt        </div>        <div>          Alias aut deserunt expedita, inventore maiores minima officia porro rem. Accusamus ducimus          magni modi mollitia nihil nisi provident        </div>        <div>          Alias aut autem consequuntur doloremque esse facilis id molestiae neque officia placeat,          quia quisquam repellendus reprehenderit.        </div>        <div>          Adipisci blanditiis facere nam perspiciatis sit soluta ullam! Architecto aut blanditiis,          consectetur corporis cum deserunt distinctio dolore eius est exercitationem        </div>        <div>Ab aliquid asperiores assumenda corporis cumque dolorum expedita</div>        <div>          Culpa cumque eveniet natus totam! Adipisci, animi at commodi delectus distinctio dolore          earum, eum expedita facilis        </div>        <div>          Quod sit, temporibus! Amet animi fugit officiis perspiciatis, quis unde. Cumque          dignissimos distinctio, dolor eaque est fugit nisi non pariatur porro possimus, quas quasi        </div>      </div>    </>  );};

外围实现

function useScroll(  target?: Target, // DOM 节点或者 ref  shouldUpdate: ScrollListenController = () => true, // 管制是否更新滚动信息): Position | undefined {  const [position, setPosition] = useRafState<Position>();  const shouldUpdateRef = useLatest(shouldUpdate); // 管制是否更新滚动信息,默认值: () => true  useEffectWithTarget(    () => {      const el = getTargetElement(target, document);      if (!el) {        return;      }      // 外围解决      const updatePosition = () => {};      updatePosition();      // 监听 scroll 事件      el.addEventListener('scroll', updatePosition);      return () => {        el.removeEventListener('scroll', updatePosition);      };    },    [],    target,  );  return position; // 滚动容器以后的滚动地位}

接下来看看updatePosition办法的实现:

const updatePosition = () => {    let newPosition: Position;    // target属性传 document    if (el === document) {      // scrollingElement 返回滚动文档的 Element 对象的援用。      // 在规范模式下,这是文档的根元素, document.documentElement。      // 当在怪异模式下,scrollingElement 属性返回 HTML body 元素(若不存在返回 null)      if (document.scrollingElement) {        newPosition = {          left: document.scrollingElement.scrollLeft,          top: document.scrollingElement.scrollTop,        };      } else {        // 怪异模式的解决:取 window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop 三者中最大值        // https://developer.mozilla.org/zh-CN/docs/Web/API/Document/scrollingElement        // https://stackoverflow.com/questions/28633221/document-body-scrolltop-firefox-returns-0-only-js        newPosition = {          left: Math.max(            window.pageXOffset,            document.documentElement.scrollLeft,            document.body.scrollLeft,          ),          top: Math.max(            window.pageYOffset,            document.documentElement.scrollTop,            document.body.scrollTop,          ),        };      }    } else {      newPosition = {        left: (el as Element).scrollLeft, // 获取滚动条到元素右边的间隔(滚动条滚动了多少像素)        top: (el as Element).scrollTop,      };    }    //     判断是否更新滚动信息    if (shouldUpdateRef.current(newPosition)) {      setPosition(newPosition);    }};
  • Element.scrollLeft 获取滚动条到元素右边的间隔
  • Element.scrollTop 获取滚动条到元素顶部的间隔

useSize

监听 DOM 节点尺寸变动的 Hook。

官网文档

根本用法

官网在线 Demo

import React, { useRef } from 'react';import { useSize } from 'ahooks';export default () => {  const ref = useRef(null);  const size = useSize(ref);  return (    <div ref={ref}>      <p>Try to resize the preview window </p>      <p>        width: {size?.width}px, height: {size?.height}px      </p>    </div>  );};

外围实现

这里波及 ResizeObserver

源码较容易了解,就不开展了

//     指标 DOM 节点的尺寸type Size = { width: number; height: number };function useSize(target: BasicTarget): Size | undefined {  const [state, setState] = useRafState<Size>();  useIsomorphicLayoutEffectWithTarget(    () => {      const el = getTargetElement(target);      if (!el) {        return;      }      // Resize Observer API 提供了一种高性能的机制,通过该机制,代码能够监督元素的大小更改,并且每次大小更改时都会向观察者传递告诉      const resizeObserver = new ResizeObserver((entries) => {        entries.forEach((entry) => {          // 返回 DOM 节点的尺寸          const { clientWidth, clientHeight } = entry.target;          setState({            width: clientWidth,            height: clientHeight,          });        });      });      // 监听指标元素      resizeObserver.observe(el);      return () => {        resizeObserver.disconnect();      };    },    [],    target,  );  return state;}

残缺源码

useFocusWithin

监听以后焦点是否在某个区域之内,同 css 属性: focus-within

官网文档

根本用法

官网在线 Demo

应用 ref 设置须要监听的区域。能够通过鼠标点击内部区域,或者应用键盘的 tab 等按键来切换焦点。

import React, { useRef } from 'react';import { useFocusWithin } from 'ahooks';import { message } from 'antd';export default () => {  const ref = useRef(null);  const isFocusWithin = useFocusWithin(ref, {    onFocus: () => {      message.info('focus');    },    onBlur: () => {      message.info('blur');    },  });  return (    <div>      <div        ref={ref}        style={{          padding: 16,          backgroundColor: isFocusWithin ? 'red' : '',          border: '1px solid gray',        }}      >        <label style={{ display: 'block' }}>          First Name: <input />        </label>        <label style={{ display: 'block', marginTop: 16 }}>          Last Name: <input />        </label>      </div>      <p>isFocusWithin: {JSON.stringify(isFocusWithin)}</p>    </div>  );};

外围实现

次要还是监听了 focusin 和 focusout 事件

  • focusin:当元素聚焦时会触发。和 focus 一样,只是 focusin 事件反对冒泡;
  • focusout:当元素行将失去焦点时会被触发。和 blur 一样,只是 focusout 事件反对冒泡。

触发程序:

在同时反对四种事件的浏览器中,当焦点在两个元素之间切换时,触发程序如下(不同浏览器成果可能不同):

  • focusin 在第一个指标元素取得焦点前触发
  • focus  在第一个指标元素取得焦点后触发
  • focusout  第一个指标失去焦点时触发
  • focusin  第二个元素取得焦点前触发
  • blur  第一个元素失去焦点时触发
  • focus  第二个元素取得焦点后触发

参考:focus/blur VS focusin/focusout

MouseEvent.relatedTarget 属性返回与触发鼠标事件的元素相干的元素:

export default function useFocusWithin(target: BasicTarget, options?: Options) {  const [isFocusWithin, setIsFocusWithin] = useState(false);  const { onFocus, onBlur, onChange } = options || {};  // 监听 focusin 事件  useEventListener(    'focusin',    (e: FocusEvent) => {      if (!isFocusWithin) {        onFocus?.(e);        onChange?.(true);        setIsFocusWithin(true);      }    },    {      target,    },  );  // 监听 focusout 事件  useEventListener(    'focusout',    (e: FocusEvent) => {      // relatedTarget 属性返回与触发鼠标事件的元素相干的元素。      // https://developer.mozilla.org/zh-CN/docs/Web/API/MouseEvent/relatedTarget      if (isFocusWithin && !(e.currentTarget as Element)?.contains?.(e.relatedTarget as Element)) {        onBlur?.(e);        onChange?.(false);        setIsFocusWithin(false);      }    },    {      target,    },  );  return isFocusWithin; // 焦点是否在以后区域}

残缺源码