ahooks 源码解读系列 - 13

890 阅读6分钟

这个系列是将 ahooks 里面的所有 hook 源码都进行解读,通过解读 ahooks 的源码来熟悉自定义 hook 的写法,提高自己写自定义 hook 的能力,希望能够对大家有所帮助。

为了和代码原始注释区分,个人理解部分使用 ///开头,此处和 三斜线指令没有关系,只是为了做区分。

往期回顾

愉快的周二来临,大家应该比较有精神了吧、
今天将一次性看完 Dom 部分的剩余 hook ,谢谢拔亢前来阅读🙏~

useTextSelection

划词翻译可能会用到

/// ...

const initRect: IRect = {
  top: NaN,
  left: NaN,
  bottom: NaN,
  right: NaN,
  height: NaN,
  width: NaN,
};

const initState: IState = {
  text: '',
  ...initRect,
};

function getRectFromSelection(selection: Selection | null): IRect {
  if (!selection) {
    return initRect;
  }

  if (selection.rangeCount < 1) {
    return initRect;
  }
  const range = selection.getRangeAt(0);
  const { height, width, top, left, right, bottom } = range.getBoundingClientRect();
  return {
    height,
    width,
    top,
    left,
    right,
    bottom,
  };
}

/**
 * 获取用户选取的文本或当前光标插入的位置
 * */
function useTextSelection(target?: BasicTarget): IState {
  const [state, setState] = useState(initState);

  const stateRef = useRef(state);
  stateRef.current = state;

  useEffect(() => {
    // 获取 target 需要放在 useEffect 里,否则存在组件未加载好的情况而导致元素获取不到
    const el = getTargetElement(target, document);

    if (!el) {
      return () => {};
    }

    const mouseupHandler = () => {
      let selObj: Selection | null = null;
      let text = '';
      let rect = initRect;
      if (!window.getSelection) return;
      selObj = window.getSelection();
      text = selObj ? selObj.toString() : '';
      if (text) { /// 选取了内容之后才设置最新的选取数据信息
        rect = getRectFromSelection(selObj);
        setState({ ...state, text, ...rect });
      }
    };

    // 任意点击都需要清空之前的 range
    const mousedownHandler = () => {
      /// window.getSelection 是 hook 生效的前置条件,可以放在最前面,这样就不用绑定无用的事件了
      if (!window.getSelection) return;
      if (stateRef.current.text) {
        setState({ ...initState });
      }
      const selObj = window.getSelection();
      if (!selObj) return;
      selObj.removeAllRanges();
    };

    el.addEventListener('mouseup', mouseupHandler);

    document.addEventListener('mousedown', mousedownHandler);

    return () => {
      el.removeEventListener('mouseup', mouseupHandler);
      document.removeEventListener('mousedown', mousedownHandler);
    };
  }, [typeof target === 'function' ? undefined : target]);

  return state;
}

export default useTextSelection;

useHover、useMouse、useDocumentVisibility、useInViewport、useResponsive

一些dom状态标识、dom数据获取的hook

通过监听 mouseenter 、mouseleve事件,实时更新 hover 状态值

/// ...

export default (target: BasicTarget, options?: Options): boolean => {
  const { onEnter, onLeave } = options || {};

  const [state, { setTrue, setFalse }] = useBoolean(false);

  useEventListener(
    'mouseenter',
    () => {
      onEnter && onEnter();
      setTrue();
    },
    {
      target,
    },
  );

  useEventListener(
    'mouseleave',
    () => {
      onLeave && onLeave();
      setFalse();
    },
    {
      target,
    },
  );

  return state;
};

通过监听 mousemove 事件,实时更新鼠标坐标数据

/// ...

export default () => {
  const [state, setState] = useState(initState);

  useEventListener(
    'mousemove',
    (event: MouseEvent) => {
      const { screenX, screenY, clientX, clientY, pageX, pageY } = event;
      setState({ screenX, screenY, clientX, clientY, pageX, pageY });
    },
    {
      target: document,
    },
  );

  return state;
};

通过监听 visibilitychange 事件,实时更新文档可见状态值

export default function canUseDom() {
  return !!(typeof window !== 'undefined' && window.document && window.document.createElement);
}

/// ...

const getVisibility = () => {
  if (!canUseDom()) return 'visible';
  return document.visibilityState;
};

function useDocumentVisibility(): VisibilityState {
  const [documentVisibility, setDocumentVisibility] = useState(() => getVisibility());

  useEventListener(
    'visibilitychange',
    () => {
      setDocumentVisibility(getVisibility());
    },
    {
      target: () => document, /// 这里不用方法直接使用 document 应该也是一样的
    },
  );

  return documentVisibility;
}

export default useDocumentVisibility;

通过 IntersectionObserver api,实时更新是否在视口中的状态值

import { useEffect, useState } from 'react';
import 'intersection-observer';
import { getTargetElement, BasicTarget } from '../utils/dom';

type InViewport = boolean | undefined;

function isInViewPort(el: HTMLElement): InViewport  {
  if (!el) {
    return undefined;
  }

  const viewPortWidth =
    window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
  const viewPortHeight =
    window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
  const rect = el.getBoundingClientRect();
  /// 只要上下左右有任意一边完全出了视口,就认为不在视口中
  if (rect) {
    const { top, bottom, left, right } = rect;
    return bottom > 0 && top <= viewPortHeight && left <= viewPortWidth && right > 0;
  }
  /// rect 没可能为空,所以没必要对 rect 进行判空 https://developer.mozilla.org/zh-CN/docs/Web/API/Element/getBoundingClientRect
  return false;
}

function useInViewport(target: BasicTarget): InViewport {
  const [inViewPort, setInViewport] = useState<InViewport>(() => {
    const el = getTargetElement(target);

    return isInViewPort(el as HTMLElement);
  });

  useEffect(() => {
    const el = getTargetElement(target);
    if (!el) {
      return () => {};
    }
    /// 使用 IntersectionObserver 监听目标和视口的相交状态,从而更新是否在视口的标识
    const observer = new IntersectionObserver((entries) => {
      for (const entry of entries) {
        if (entry.isIntersecting) {
          setInViewport(true);
        } else {
          setInViewport(false);
        }
      }
    });

    observer.observe(el as HTMLElement);

    return () => {
      observer.disconnect();
    };
  }, [target]);

  return inViewPort;
}

export default useInViewport;

通过监听 window 的 resize 事件,实时更新相应式数据

import { useEffect, useState } from 'react';

type Subscriber = () => void;

const subscribers = new Set<Subscriber>();

interface ResponsiveConfig {
  [key: string]: number;
}
interface ResponsiveInfo {
  [key: string]: boolean;
}

let info: ResponsiveInfo;
/// 默认的断点数据
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();
}
/// 使用了 listening、subscribers 来减少对 window 的事件绑定
export function useResponsive() {
  const windowExists = typeof window !== 'undefined';
  /// 使用 listening 全局标识,防止多个 useResponsive 重复注册事件
  if (windowExists && !listening) {
    info = {};
    calculate();
    window.addEventListener('resize', handleResize);
    listening = true;
  }
  const [state, setState] = useState<ResponsiveInfo>(info);

  useEffect(() => {
    if (!windowExists) return;

    const subscriber = () => {
      setState(info);
    };
    /// 使用 subscribers 收集每一个 useResponsive 的状态变更操作方法
    subscribers.add(subscriber);
    return () => {
      subscribers.delete(subscriber);
      /// 如果 subscribers 已经清空,则说明所有的 useResponsive 都已经卸载了,可以移除事件了
      if (subscribers.size === 0) {
        window.removeEventListener('resize', handleResize);
        listening = false;
      }
    };
  }, []);

  return state;
}

useExternal

“一键换肤了解一下”

/// ...

export default function useExternal(path: string, options?: Options): [Status, Action] {
  const isPath = typeof path === 'string' && path !== '';

  const [status, setStatus] = useState<Status>(isPath ? 'loading' : 'unset');

  const [active, setActive] = useState(isPath);

  const ref = useRef<ExternalElement>();

  useEffect(() => {
    /// 先将之前的资源卸载掉
    ref.current?.remove();

    if (!isPath || !active) {
      setStatus('unset');
      ref.current = undefined;
      return;
    }

    setStatus('loading');
    // Create external element
    const pathname = path.replace(/[|#].*$/, '');
    /// 不同类型的资源,使用不同的方式加载
    if (options?.type === 'css' || /(^css!|\.css$)/.test(pathname)) {
      // css
      ref.current = document.createElement('link');
      ref.current.rel = 'stylesheet';
      ref.current.href = path;
      ref.current.media = options?.media || 'all';
      // IE9+
      let isLegacyIECss = 'hideFocus' in ref.current;
      // use preload in IE Edge (to detect load errors)
      if (isLegacyIECss && ref.current.relList) {
        ref.current.rel = 'preload';
        ref.current.as = 'style';
      }
      ref.current.setAttribute('data-status', 'loading');
      document.head.appendChild(ref.current);
    } else if (options?.type === 'js' || /(^js!|\.js$)/.test(pathname)) {
      // javascript
      ref.current = document.createElement('script');
      ref.current.src = path;
      ref.current.async = options?.async === undefined ? true : options?.async;
      ref.current.setAttribute('data-status', 'loading');
      document.body.appendChild(ref.current);
    } else if (options?.type === 'img' || /(^img!|\.(png|gif|jpg|svg|webp)$)/.test(pathname)) {
      // image
      ref.current = document.createElement('img');
      ref.current.src = path;
      ref.current.setAttribute('data-status', 'loading');
      // append to wrapper
      const wrapper = (getTargetElement(options?.target) as HTMLElement) || document.body;
      if (wrapper) {
        wrapper.appendChild(ref.current);
      }
    }else{
      // do nothing
      console.error(
        "Cannot infer the type of external resource, and please provide a type ('js' | 'css' | 'img'). " +
          "Refer to the https://ahooks.js.org/hooks/dom/use-external/#options"
      )
    }

    if(!ref.current) return

    // Bind setAttribute Event
    const setAttributeFromEvent = (event: Event) => {
      ref.current?.setAttribute('data-status', event.type === 'load' ? 'ready' : 'error');
    };
    ref.current.addEventListener('load', setAttributeFromEvent);
    ref.current.addEventListener('error', setAttributeFromEvent);
    const setStateFromEvent = (event: Event) => {
      setStatus(event.type === 'load' ? 'ready' : 'error');
    };
    ref.current.addEventListener('load', setStateFromEvent);
    ref.current.addEventListener('error', setStateFromEvent);
    return () => {
      ref.current?.removeEventListener('load', setStateFromEvent);
      ref.current?.removeEventListener('error', setStateFromEvent);
    };
  }, [path, active]);

  const action = useMemo(() => {
    const unload = () => setActive(false);
    const load = () => setActive(true);
    const toggle = () => setActive((value) => !value);
    return { toggle, load, unload };
  }, [setActive]);

  return [status, action];
}

useFavicon

import { useEffect } from 'react';

// image/vnd.microsoft.icon MIME类型只有当图像真的是ICO文件时才会起作用
// image/x-icon 会同时也适用于位图与GIF
// 主要是为了兼容扩展名为ico的非ico文件
const ImgTypeMap = {
  SVG: 'image/svg+xml',
  ICO: 'image/x-icon',
  GIF: 'image/gif',
  PNG: 'image/png',
};

type ImgTypes = keyof typeof ImgTypeMap;

const useFavicon = (favUrl: string) => {
  useEffect(() => {
    if (!favUrl) return;

    const cutUrl = favUrl.split('.');
    const imgSuffix = cutUrl[cutUrl.length - 1].toLocaleUpperCase() as ImgTypes;

    const link: HTMLLinkElement =
      document.querySelector("link[rel*='icon']") || document.createElement('link');

    link.type = ImgTypeMap[imgSuffix];
    link.href = favUrl;
    // 大部分浏览器只会识别'icon' 只有IE会识别整个名称'shortcut icon'
    link.rel = 'shortcut icon';

    document.getElementsByTagName('head')[0].appendChild(link);
  }, [favUrl]);
};

export default useFavicon;

useFullscreen

screenfull 插件的封装

import { useCallback, useRef, useState } from 'react';
import screenfull from 'screenfull';
import useUnmount from '../useUnmount';
import { BasicTarget, getTargetElement } from '../utils/dom';

export interface Options {
  onExitFull?: () => void;
  onFull?: () => void;
}

export default (target: BasicTarget, options?: Options) => {
  const { onExitFull, onFull } = options || {};
  /// 将事件用 ref 存储起来
  const onExitFullRef = useRef(onExitFull);
  onExitFullRef.current = onExitFull;

  const onFullRef = useRef(onFull);
  onFullRef.current = onFull;

  const [state, setState] = useState(false);

  const onChange = useCallback(() => {
    if (screenfull.isEnabled) {
      const { isFullscreen } = screenfull;
      if (isFullscreen) {
        onFullRef.current && onFullRef.current();
      } else {
        /// 在 change 事件回调中调用 off 而不是在 exitFull 方法中直接调用时因为那会退出操作才刚开始,还没有完成
        screenfull.off('change', onChange);
        onExitFullRef.current && onExitFullRef.current();
      }
      setState(isFullscreen);
    }
  }, []);

  const setFull = useCallback(() => {
    const el = getTargetElement(target);
    if (!el) {
      return;
    }
    /// 如果支持全屏,则设为全屏,并监听全屏状态变更事件
    if (screenfull.isEnabled) {
      try {
        screenfull.request(el as HTMLElement);
        screenfull.on('change', onChange);
      } catch (error) {}
    }
  }, [target, onChange]);

  const exitFull = useCallback(() => {
    if (!state) {
      return;
    }
    /// 直接退出全屏,清理监听事件在监听事件内部处理了
    if (screenfull.isEnabled) {
      screenfull.exit();
    }
  }, [state]);

  const toggleFull = useCallback(() => {
    if (state) {
      exitFull();
    } else {
      setFull();
    }
  }, [state, setFull, exitFull]);

  useUnmount(() => {
    if (screenfull.isEnabled) {
      screenfull.off('change', onChange);
    }
  });

  return [
    state,
    {
      setFull,
      exitFull,
      toggleFull,
    },
  ] as const;
};

useTitle

操作 title 的语法糖

import { useEffect, useRef } from 'react';
import useUnmount from '../useUnmount';

export interface Options {
  restoreOnUnmount?: boolean;
}

const DEFAULT_OPTIONS: Options = {
  restoreOnUnmount: false,
};

function useTitle(title: string, options: Options = DEFAULT_OPTIONS) {
  /// 使用ref存储变更前的title
  const titleRef = useRef(document.title);
  useEffect(() => {
    document.title = title;
  }, [title]);

  useUnmount(() => {
    if (options && options.restoreOnUnmount) {
      /// 如果配置了需要还原,则在组件卸载时还原
      document.title = titleRef.current;
    }
  });
}

export default typeof document !== 'undefined' ? useTitle : () => {};

参考资料

以上内容由于本人水平问题难免有误,欢迎大家进行讨论反馈。