目录

📝 解读 ahooks 源码系列 - DOM

本篇文章是解读 ahooks@3.8.0 源码系列的第六篇 - DOM,欢迎您的指正和点赞。

本文主要解读 useEventListener、useClickAway、useDocumentVisibility、useDrag、useDrop、useEventTarget、useExternal、useTitle、useFavicon、useFullScreen、useHover、useMutationObserver、useInViewport、useKeyPress、useLongPress、useMouse、useResponsive、useScroll、useSize、useFocusWithin 的源码实现。

useEventListener

文档地址

详细代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import type { MutableRefObject } from "react";
import isBrowser from "./isBrowser";
import { isFunction } from ".";

type TargetValue<T> = T | undefined | null;

/**
 * Window: 表示浏览器窗口的接口
 * Document: 表示文档的接口
 * HTMLElement 表示 HTML 元素的接口
 * Element: 表示 DOM 元素的接口
 */

type TargetType = Window | Document | HTMLElement | Element;

export type BasicTarget<T extends TargetType = Element> =
  | (() => TargetValue<T>)
  | TargetValue<T>
  | MutableRefObject<TargetValue<T>>;

export const getTargetElement = <T extends TargetType>(
  target: BasicTarget<T>,
  defaultElement?: T
) => {
  if (!isBrowser) {
    return undefined;
  }
  if (!target) {
    return defaultElement;
  }

  let targetElement: TargetValue<T>;

  if (isFunction(target)) {
    targetElement = target();
  } else if ("current" in target) {
    targetElement = target.current;
  } else {
    targetElement = target;
  }

  return targetElement;
};
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import type { DependencyList } from "react";

const depsAreSame = (
  oldDeps: DependencyList,
  deps: DependencyList
): boolean => {
  if (oldDeps === deps) return true;
  for (let i = 0; i < oldDeps.length; i++) {
    if (!Object.is(oldDeps[i], deps[i])) return false;
  }
  return true;
};

export default depsAreSame;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import {
  useRef,
  type DependencyList,
  type EffectCallback,
  type useEffect,
  type useLayoutEffect,
} from "react";
import { type BasicTarget, getTargetElement } from "./domTarget";
import useUnmount from "@/hooks/useUnmount";
import depsAreSame from "./depsAreSame";

const createEffectWithTarget = (
  useEffectType: typeof useEffect | typeof useLayoutEffect
) => {
  /**
   *
   * @param effect
   * @param deps
   * @param target target should compare ref.current vs ref.current, dom vs dom, ()=>dom vs ()=>dom
   */
  const useEffectWithTarget = (
    effect: EffectCallback,
    deps: DependencyList,
    target: BasicTarget<any> | BasicTarget<any>[]
  ) => {
    // 是否首次挂载
    const hasInitRef = useRef(false);

    // 上一次的目标元素
    const lastElementRef = useRef<(Element | null)[]>([]);
    // 上一次的依赖项
    const lastDepsRef = useRef<DependencyList>([]);

    // 清除副作用函数
    const unLoadRef = useRef<any>();

    useEffectType(() => {
      const targets = Array.isArray(target) ? target : [target];
      const els = targets.map((item) => getTargetElement(item));

      // init run
      if (!hasInitRef.current) {
        hasInitRef.current = true;
        lastElementRef.current = els;
        lastDepsRef.current = deps;

        // 执行回调函数
        unLoadRef.current = effect();
        return;
      }

      if (
        els.length !== lastElementRef.current.length ||
        !depsAreSame(lastElementRef.current, els) ||
        !depsAreSame(lastDepsRef.current, deps)
      ) {
        // 清除副作用
        unLoadRef.current?.();

        lastElementRef.current = els;
        lastDepsRef.current = deps;
        unLoadRef.current = effect();
      }
    });

    useUnmount(() => {
      unLoadRef.current?.();
      // for react-refresh
      hasInitRef.current = false;
    });
  };

  return useEffectWithTarget;
};

export default createEffectWithTarget;
1
2
3
4
5
6
import { useEffect } from "react";
import createEffectWithTarget from "./createEffectWithTarget";

const useEffectWithTarget = createEffectWithTarget(useEffect);

export default useEffectWithTarget;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import { getTargetElement, type BasicTarget } from "@/utils/domTarget";
import useLatest from "../useLatest";
import useEffectWithTarget from "@/utils/useEffectWithTarget";

type noop = (...p: any) => void;

export type Target = BasicTarget<Window | Document | HTMLElement | Element>;

type Options<T extends Target = Target> = {
  target?: T;
  capture?: boolean;
  once?: boolean;
  passive?: boolean;
  // 可选项,是否开启监听
  enable?: boolean;
};

function useEventListener<K extends keyof WindowEventMap>(
  eventName: K,
  handler: (ev: WindowEventMap[K]) => void,
  options?: Options<Window>
): void;
function useEventListener<K extends keyof DocumentEventMap>(
  eventName: K,
  handler: (ev: DocumentEventMap[K]) => void,
  options?: Options<Document>
): void;
function useEventListener<K extends keyof HTMLElementEventMap>(
  eventName: K,
  handler: (ev: HTMLElementEventMap[K]) => void,
  options?: Options<HTMLElement>
): void;
function useEventListener<K extends keyof ElementEventMap>(
  eventName: K,
  handler: (ev: ElementEventMap[K]) => void,
  options?: Options<Element>
): void;
function useEventListener(
  eventName: string,
  handler: (ev: Event) => void,
  options?: Options<Window>
): void;
function useEventListener(
  eventName: string,
  handler: noop,
  options: Options
): void;
function useEventListener(
  eventName: string,
  handler: noop,
  options: Options = {}
) {
  // 默认开启监听
  const { enable = true } = options;

  const handlerRef = useLatest(handler);

  useEffectWithTarget(
    () => {
      // 是否开启监听
      if (!enable) {
        return;
      }

      const targetElement = getTargetElement(options.target, window);
      // 是否支持 addEventListener
      if (!targetElement?.addEventListener) {
        return;
      }

      const eventListener = (event: Event) => {
        return handlerRef.current(event);
      };
      // 为指定元素添加事件监听器
      targetElement.addEventListener(eventName, eventListener, {
        // 指定事件是否在捕获阶段进行处理
        capture: options.capture,
        // 指定事件是否只触发一次
        once: options.once,
        // 指定事件处理函数是否不会调用 preventDefault()
        passive: options.passive,
      });

      // 移除事件监听器
      return () => {
        targetElement.removeEventListener(eventName, eventListener, {
          capture: options.capture,
        });
      };
    },
    [eventName, options.capture, options.once, options.passive, enable],
    options.target
  );
}
export default useEventListener;

useClickAway

文档地址

详细代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { type BasicTarget, getTargetElement } from "./domTarget";

type TargetValue<T> = T | undefined | null;

const checkIfAllInShadow = (targets: BasicTarget[]): boolean => {
  return targets.every((item) => {
    const targetElement = getTargetElement(item);
    if (!targetElement) return false;
    if (targetElement.getRootNode() instanceof ShadowRoot) return true;
    return false;
  });
};

const getShadow = (node: TargetValue<Element>) => {
  if (!node) {
    return document;
  }
  // 返回该元素的根节点
  return node.getRootNode();
};

const getDocumentOrShadow = (
  target: BasicTarget | BasicTarget[]
): Document | Node => {
  if (!target || !document.getRootNode) {
    return document;
  }

  const targets = Array.isArray(target) ? target : [target];

  if (checkIfAllInShadow(targets)) {
    return getShadow(getTargetElement(targets[0]));
  }

  return document;
};

export default getDocumentOrShadow;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import { getTargetElement, type BasicTarget } from "@/utils/domTarget";
import useLatest from "../useLatest";
import useEffectWithTarget from "@/utils/useEffectWithTarget";
import getDocumentOrShadow from "@/utils/getDocumentOrShadow";

type DocumentEventKey = keyof DocumentEventMap;

const useClickAway = <T extends Event = Event>(
  onClickAway: (event: T) => void,
  target: BasicTarget | BasicTarget[],
  eventName: DocumentEventKey | DocumentEventKey[] = "click"
) => {
  const onClickAwayRef = useLatest(onClickAway);

  useEffectWithTarget(
    () => {
      const handler = (event: any) => {
        const targets = Array.isArray(target) ? target : [target];

        if (
          targets.some((item) => {
            const targetElement = getTargetElement(item);
            // 判断点击的 DOM Target 是否在定义的 DOM 元素(列表)中
            return !targetElement || targetElement.contains(event.Target);
          })
        ) {
          return;
        }
        // 触发点击事件
        onClickAwayRef.current(event);
      };

      // 事件代理 - 代理到 shadow root 或 document
      const documentOrShadow = getDocumentOrShadow(target);

      // 事件列表
      const eventNames = Array.isArray(eventName) ? eventName : [eventName];

      // 事件监听
      eventNames.forEach((event) =>
        documentOrShadow.addEventListener(event, handler)
      );

      return () => {
        // 清除事件监听
        eventNames.forEach((event) =>
          documentOrShadow.removeEventListener(event, handler)
        );
      };
    },
    Array.isArray(eventName) ? eventName : [eventName],
    target
  );
};

export default useClickAway;

useDocumentVisibility

文档地址

详细代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import isBrowser from "@/utils/isBrowser";
import useEventListener from "../useEventListener";
import { useState } from "react";

/**
 * hidden: 页面对用户不可见。即文档处于背景标签页、或窗口处于最小化状态,或操作系统正处于"锁屏状态"
 * visible: 页面内容至少部分可见。即文档处于前景标签页并且窗口没有最小化
 * prerender: 页面此时正在渲染中。文档只能从此状态开始,永远不能从其他值变为此状态
 * */
type VisibilityState = "hidden" | "visible" | "prerender" | undefined;

const getVisibility = () => {
  if (!isBrowser) {
    return "visible";
  }
  // 只读属性,返回 document 的可见性,即当前可见元素的上下文环境
  return document.visibilityState;
};

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

  useEventListener(
    "visibilitychange",
    () => {
      setDocumentVisibility(getVisibility());
    },
    {
      target: () => document,
    }
  );

  return documentVisibility;
};

export default useDocumentVisibility;

useDrag

文档地址

详细代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import { useRef } from "react";
import useLatest from "../useLatest";
import useMount from "../useMount";
import { isString } from "@/utils";
import useEffectWithTarget from "@/utils/useEffectWithTarget";
import { getTargetElement, type BasicTarget } from "@/utils/domTarget";

export interface Options {
  onDragStart?: (event: React.DragEvent) => void;
  onDragEnd?: (event: React.DragEvent) => void;
  dragImage?: {
    image: string | Element;
    offsetX?: number;
    offsetY?: number;
  };
}

const useDrag = <T>(data: T, target: BasicTarget, options: Options = {}) => {
  // 额外的配置项
  const optionsRef = useLatest(options);
  // 拖拽的内容
  const dataRef = useLatest(data);
  // 拖拽过程中跟随鼠标指针的图像元素
  const imageElementRef = useRef<Element>();

  const { dragImage } = optionsRef.current;

  useMount(() => {
    // 判断 dragImage.image 的类型,将其存储在 imageElementRef.current 中
    if (dragImage?.image) {
      const { image } = dragImage;

      if (isString(image)) {
        // 如果是字符串,创建对应的图片元素
        const imageElement = new Image();

        imageElement.src = image;
        imageElementRef.current = imageElement;
      } else {
        imageElementRef.current = image;
      }
    }
  });

  useEffectWithTarget(
    () => {
      const targetElement = getTargetElement(target);
      if (!targetElement?.addEventListener) {
        return;
      }

      const onDragStart = (event: React.DragEvent) => {
        // 开始拖拽的回调
        optionsRef.current.onDragStart?.(event);
        // 设置拖拽事件中带有的数据
        event.dataTransfer.setData("custom", JSON.stringify(dataRef.current));

        // 设置拖拽过程中跟随鼠标指针的图像
        if (dragImage?.image && imageElementRef.current) {
          // 鼠标相对于图片的偏移量
          const { offsetX = 0, offsetY = 0 } = dragImage;

          event.dataTransfer.setDragImage(
            imageElementRef.current,
            offsetX,
            offsetY
          );
        }
      };

      const onDragEnd = (event: React.DragEvent) => {
        // 结束拖拽的回调
        optionsRef.current.onDragEnd?.(event);
      };

      // 设置元素可拖拽
      targetElement.setAttribute("draggable", "true");

      // 开始拖拽事件监听器
      targetElement.addEventListener("dragstart", onDragStart as any);
      // 结束拖拽事件监听器
      targetElement.addEventListener("dragend", onDragEnd as any);

      return () => {
        // 清除事件监听器
        targetElement.removeEventListener("dragstart", onDragStart as any);
        targetElement.removeEventListener("dragend", onDragEnd as any);
      };
    },
    [],
    target
  );
};

export default useDrag;

useDrop

文档地址

详细代码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
import useEffectWithTarget from "@/utils/useEffectWithTarget";
import useLatest from "../useLatest";
import { useRef } from "react";
import { type BasicTarget, getTargetElement } from "@/utils/domTarget";

export interface Options {
  onFiles?: (files: File[], event?: React.DragEvent) => void;
  onUri?: (url: string, event?: React.DragEvent) => void;
  onDom?: (content: any, event?: React.DragEvent) => void;
  onText?: (text: string, event?: React.ClipboardEvent) => void;
  onDragEnter?: (event?: React.DragEvent) => void;
  onDragOver?: (event?: React.DragEvent) => void;
  onDragLeave?: (event?: React.DragEvent) => void;
  onDrop?: (event?: React.DragEvent) => void;
  onPaste?: (event?: React.ClipboardEvent) => void;
}

const useDrop = (target: BasicTarget, options: Options = {}) => {
  // 额外的配置项
  const optionsRef = useLatest(options);

  // https://stackoverflow.com/a/26459269
  // 跟踪拖拽进入的目标元素
  const dragEnterTarget = useRef<any>();

  useEffectWithTarget(
    () => {
      const targetElement = getTargetElement(target);
      if (!targetElement?.addEventListener) {
        return;
      }

      // 处理拖放和粘贴事件传输的数据,根据数据类型调用对应的回调函数
      const onData = (
        dataTransfer: DataTransfer,
        event: React.DragEvent | React.ClipboardEvent
      ) => {
        const uri = dataTransfer.getData("text/uri-list");
        const dom = dataTransfer.getData("custom");

        // 拖拽/粘贴自定义 DOM 节点的回调
        if (dom && optionsRef.current.onDom) {
          let data = dom;
          try {
            data = JSON.parse(dom);
          } catch (e) {
            data = dom;
          }
          optionsRef.current.onDom(data, event as React.DragEvent);
          return;
        }

        // 拖拽/粘贴链接的回调
        if (uri && optionsRef.current.onUri) {
          optionsRef.current.onUri(uri, event as React.DragEvent);
          return;
        }

        // 拖拽/粘贴文件的回调
        if (
          dataTransfer.files &&
          dataTransfer.files.length &&
          optionsRef.current.onFiles
        ) {
          optionsRef.current.onFiles(
            Array.from(dataTransfer.files),
            event as React.DragEvent
          );
          return;
        }

        // 拖拽/粘贴文字的回调
        if (
          dataTransfer.items &&
          dataTransfer.items.length &&
          optionsRef.current.onText
        ) {
          dataTransfer.items[0].getAsString((text) => {
            optionsRef.current.onText!(text, event as React.ClipboardEvent);
          });
        }
      };

      // 拖拽进入
      const onDragEnter = (event: React.DragEvent) => {
        event.preventDefault();
        event.stopPropagation();

        dragEnterTarget.current = event.target;
        optionsRef.current.onDragEnter?.(event);
      };

      // 拖拽悬停
      const onDragOver = (event: React.DragEvent) => {
        event.preventDefault();
        optionsRef.current.onDragOver?.(event);
      };

      // 拖拽离开
      const onDragLeave = (event: React.DragEvent) => {
        if (event.target === dragEnterTarget.current) {
          optionsRef.current.onDragLeave?.(event);
        }
      };

      // 放置
      const onDrop = (event: React.DragEvent) => {
        event.preventDefault();
        onData(event.dataTransfer, event);
        optionsRef.current.onDrop?.(event);
      };

      // 粘贴
      const onPaste = (event: React.ClipboardEvent) => {
        onData(event.clipboardData, event);
        optionsRef.current.onPaste?.(event);
      };

      targetElement.addEventListener("dragenter", onDragEnter as any);
      targetElement.addEventListener("dragover", onDragOver as any);
      targetElement.addEventListener("dragleave", onDragLeave as any);
      targetElement.addEventListener("drop", onDrop as any);
      targetElement.addEventListener("paste", onPaste as any);
      return () => {
        targetElement.removeEventListener("dragenter", onDragEnter as any);
        targetElement.removeEventListener("dragover", onDragOver as any);
        targetElement.removeEventListener("dragleave", onDragLeave as any);
        targetElement.removeEventListener("drop", onDrop as any);
        targetElement.removeEventListener("paste", onPaste as any);
      };
    },
    [],
    target
  );
};

export default useDrop;

useEventTarget

文档地址

详细代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { useCallback, useState } from "react";
import useLatest from "../useLatest";
import { isFunction } from "lodash";

interface EventTarget<U> {
  target: {
    value: U;
  };
}

export interface Options<T, U> {
  initialValue?: T;
  transformer?: (value: U) => T;
}

const useEventTarget = <T, U = T>(options?: Options<T, U>) => {
  const { initialValue, transformer } = options || {};
  const [value, setValue] = useState(initialValue);

  const transfomerRef = useLatest(transformer);

  // 重置函数
  const reset = useCallback(() => setValue(initialValue), []);

  // 值发生变化时的回调
  const onChange = useCallback((e: EventTarget<U>) => {
    const _value = e.target.value;
    // 判断自定义回调值的转化配置项是否存在并且为函数
    if (isFunction(transfomerRef.current)) {
      return setValue(transfomerRef.current(_value));
    }
    // no transformer => U and T should be the same
    return setValue(_value as unknown as T);
  }, []);

  return [
    value,
    {
      onChange,
      reset,
    },
  ] as const;
};

export default useEventTarget;

useExternal

文档地址

详细代码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
import { useEffect, useRef, useState } from "react";

type JsOptions = {
  type: "js";
  js?: Partial<HTMLScriptElement>;
  keepWhenUnused?: boolean;
};

type CssOptions = {
  type: "css";
  css?: Partial<HTMLStyleElement>;
  keepWhenUnused?: boolean;
};

type DefaultOptions = {
  type?: never;
  js?: Partial<HTMLScriptElement>;
  css?: Partial<HTMLStyleElement>;
  keepWhenUnused?: boolean;
};

export type Options = JsOptions | CssOptions | DefaultOptions;

// {[path]: count}
// remove external when no used
const EXTERNAL_USED_COUNT: Record<string, number> = {};

/**
 * 加载状态
 * unset - 未设置
 * loading - 加载中
 * ready - 加载完成
 * error - 加载失败
 */
export type Status = "unset" | "loading" | "ready" | "error";

interface LoadResult {
  ref: Element;
  status: Status;
}

type LoadExternal = <T>(path: string, props?: Partial<T>) => LoadResult;

const loadScript: LoadExternal = (path, props = {}) => {
  const script = document.querySelector(`script[src="${path}"]`);

  if (!script) {
    const newScript = document.createElement("script");
    newScript.src = path;

    Object.keys(props).forEach((key) => {
      newScript[key] = props[key];
    });

    newScript.setAttribute("data-status", "loading");
    // 在 body 中插入
    document.body.appendChild(newScript);

    return {
      ref: newScript,
      status: "loading",
    };
  }

  return {
    ref: script,
    status: (script.getAttribute("data-status") as Status) || "ready",
  };
};

const loadCss: LoadExternal = (path, props = {}) => {
  const css = document.querySelector(`link[href="${path}"]`);

  if (!css) {
    const newCss = document.createElement("link");

    newCss.rel = "stylesheet";
    newCss.href = path;

    Object.keys(props).forEach((key) => {
      newCss[key] = props[key];
    });

    // IE9+
    /**
     * 在旧版本的 IE 浏览器中,hideFocus 属性用于控制元素在获得焦点时是否显示虚拟框
     * relList 是一个新的属性,允许开发者访问和操作元素的 rel 属性列表
     * 如果条件满足,将 newCss 元素的 rel 属性设置为 preload(预加载)
     * 将 newCss 元素的 as 属性设置为 'style',告诉浏览器这是一个样式表资源
     * */
    const isLegacyIECss = "hideFocus" in newCss;
    // use preload in IE Edge (to detect load errors)
    if (isLegacyIECss && newCss.relList) {
      newCss.rel = "preload";
      newCss.as = "style";
    }
    newCss.setAttribute("data-status", "loading");
    // 在 head 标签中插入
    document.head.appendChild(newCss);

    return {
      ref: newCss,
      status: "loading",
    };
  }

  return {
    ref: css,
    status: (css.getAttribute("data-status") as Status) || "ready",
  };
};

const useExternal = (path?: string, options?: Options) => {
  const [status, setStatus] = useState<Status>(path ? "loading" : "unset");

  const ref = useRef<Element>();

  useEffect(() => {
    if (!path) {
      setStatus("unset");
      return;
    }
    const pathname = path.replace(/[|#].*$/, "");
    // 判断是 CSS 类型
    if (
      options?.type === "css" ||
      (!options?.type && /(^css!|\.css$)/.test(pathname))
    ) {
      const result = loadCss(path, options?.css);
      ref.current = result.ref;
      setStatus(result.status);
      // 判断是 JS 类型
    } else if (
      options?.type === "js" ||
      (!options?.type && /(^js!|\.js$)/.test(pathname))
    ) {
      const result = loadScript(path, options?.js);
      ref.current = result.ref;
      setStatus(result.status);
    } else {
      // do nothing
      console.error(
        "Cannot infer the type of external resource, and please provide a type ('js' | 'css'). " +
          "Refer to the https://ahooks.js.org/hooks/dom/use-external/#options"
      );
    }
    if (!ref.current) {
      return;
    }

    if (EXTERNAL_USED_COUNT[path] === undefined) {
      EXTERNAL_USED_COUNT[path] = 1;
    } else {
      EXTERNAL_USED_COUNT[path] += 1;
    }

    const handler = (event: Event) => {
      const targetStatus = event.type === "load" ? "ready" : "error";
      ref.current?.setAttribute("data-status", targetStatus);
      setStatus(targetStatus);
    };

    // 加载完成
    ref.current.addEventListener("load", handler);
    // 加载失败
    ref.current.addEventListener("error", handler);
    return () => {
      // 清除
      ref.current?.removeEventListener("load", handler);
      ref.current?.removeEventListener("error", handler);

      EXTERNAL_USED_COUNT[path] -= 1;
      // 在不持有资源的引用后,从 DOM 中移除
      if (EXTERNAL_USED_COUNT[path] === 0 && !options?.keepWhenUnused) {
        ref.current?.remove();
      }

      ref.current = undefined;
    };
  }, [path]);

  return status;
};

export default useExternal;

useTitle

文档地址

详细代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import isBrowser from "@/utils/isBrowser";
import { useEffect, useRef } from "react";
import useUnmount from "../useUnmount";

export interface Options {
  restoreOnUnmount?: boolean;
}

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

const useTitle = (title: string, options: Options = DEFAULT_OPTIONS) => {
  const titleRef = useRef(isBrowser ? document.title : "");
  useEffect(() => {
    // 通过 document.title 设置页面标题
    document.title = title;
  }, [title]);

  useUnmount(() => {
    // 组件卸载时,恢复上一个页面标题
    if (options.restoreOnUnmount) {
      document.title = titleRef.current;
    }
  });
};

export default useTitle;

useFavicon

文档地址

详细代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { useEffect } from "react";

// 存储不同图片的 MIME 类型
const ImgTypeMap = {
  SVG: "image/svg+xml",
  ICO: "image/x-icon",
  GIF: "image/gif",
  PNG: "image/png",
};

type ImgTypes = keyof typeof ImgTypeMap;

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

    // 获取图片后缀
    const cutUrl = href.split(".");
    const imgSuffix = cutUrl[cutUrl.length - 1].toLocaleUpperCase() as ImgTypes;

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

    // 设置 link 标签的 type、href、rel 属性
    link.type = ImgTypeMap[imgSuffix];
    link.href = href;
    link.rel = "shortcut icon";

    // 添加到 head 标签中
    document.getElementsByTagName("head")[0].appendChild(link);
  }, [href]);
};

export default useFavicon;

useFullScreen

文档地址

详细代码

该 Hook 主要依赖  screenfull  的 npm 包,帮助开发者管理全屏模式。

属性:

  • isEnabled: 只读属性,表示当前浏览器是否支持全屏功能
  • isFullscreen: 只读属性,表示当前是否处于全屏状态

方法:

  • request(element): 请求进入全屏模式,可以传入一个 DOM 元素作为参数,该元素将被显示在全屏模式下
  • exit(): 退出全屏模式
  • toggle(element): 切换全屏状态,如果当前处于全屏状态,则退出全屏;如果不是全屏状态,则进入全屏
  • on(eventName, callback): 监听全屏状态变化事件,当全屏状态发生变化时触发回调函数
  • off(eventName, callback): 移除全屏状态变化事件的监听
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
import { isBoolean } from "@/utils";
import { getTargetElement, type BasicTarget } from "@/utils/domTarget";
import screenfull from "screenfull";
import useLatest from "../useLatest";
import { useEffect, useRef, useState } from "react";
import useMemoizedFn from "../useMemoizedFn";
export interface PageFullscreenOptions {
  className?: string;
  zIndex?: number;
}

export interface Options {
  onExit?: () => void;
  onEnter?: () => void;
  pageFullscreen?: boolean | PageFullscreenOptions;
}

const useFullscreen = (target: BasicTarget, options?: Options) => {
  const { onExit, onEnter, pageFullscreen = false } = options || {};

  // 设置 className 和 zIndex 的默认值
  const { className = "ahooks-page-fullscreen", zIndex = 999999 } =
    isBoolean(pageFullscreen) || !pageFullscreen ? {} : pageFullscreen;

  // 当前是否处于全屏状态
  const getIsFullscreen = () =>
    screenfull.isEnabled &&
    !!screenfull.element &&
    screenfull.element === getTargetElement(target);

  const onExitRef = useLatest(onExit);
  const onEnterRef = useLatest(onEnter);

  const [state, setState] = useState(getIsFullscreen);
  // 引用当前的全屏状态
  const stateRef = useRef(getIsFullscreen());

  // 根据全屏状态调用相应的回调函数
  const invokeCallback = (fullscreen: boolean) => {
    if (fullscreen) {
      onEnterRef.current?.();
    } else {
      onExitRef.current?.();
    }
  };

  // 更新全屏状态,触发相应的回调函数
  const updateFullscreenState = (fullscreen: boolean) => {
    // Prevent repeated calls when the state is not changed.
    if (stateRef.current !== fullscreen) {
      invokeCallback(fullscreen);
      setState(fullscreen);
      stateRef.current = fullscreen;
    }
  };

  // 监听全屏状态变化,更新全屏状态
  const onScreenfullChange = () => {
    const fullscreen = getIsFullscreen();
    updateFullscreenState(fullscreen);
  };

  // 切换页面全屏状态
  const togglePageFullscreen = (fullscreen: boolean) => {
    const el = getTargetElement(target);
    if (!el) {
      return;
    }

    let styleElem = document.getElementById(className);

    // 全屏
    if (fullscreen) {
      el.classList.add(className);

      // 全屏样式
      if (!styleElem) {
        styleElem = document.createElement("style");
        styleElem.setAttribute("id", className);
        styleElem.textContent = `
          .${className} {
            position: fixed; left: 0; top: 0; right: 0; bottom: 0;
            width: 100% !important; height: 100% !important;
            z-index: ${zIndex};
          }
        `;
        el.appendChild(styleElem);
      }
    } else {
      // 退出全屏
      el.classList.remove(className);

      if (styleElem) {
        styleElem.remove();
      }
    }

    // 更新全屏状态
    updateFullscreenState(fullscreen);
  };

  // 进入全屏状态
  const enterFullscreen = () => {
    const el = getTargetElement(target);

    if (!el) {
      return;
    }

    // 页面全屏
    if (pageFullscreen) {
      togglePageFullscreen(true);
      return;
    }
    // 元素全屏
    if (screenfull.isEnabled) {
      try {
        screenfull.request(el);
      } catch (error) {
        console.error(error);
      }
    }
  };

  // 退出全屏状态
  const exitFullscreen = () => {
    const el = getTargetElement(target);

    if (!el) {
      return;
    }

    // 页面退出全屏
    if (pageFullscreen) {
      togglePageFullscreen(false);
      return;
    }
    // 元素退出全屏
    if (screenfull.isEnabled && screenfull.element === el) {
      screenfull.exit();
    }
  };

  // 切换全屏状态
  const toggleFullscreen = () => {
    if (state) {
      exitFullscreen();
    } else {
      enterFullscreen();
    }
  };

  useEffect(() => {
    // 当前环境是否支持全屏或页面已经处于全屏
    if (!screenfull.isEnabled || pageFullscreen) {
      return;
    }

    // 监听全屏状态变化
    screenfull.on("change", onScreenfullChange);

    return () => {
      // 取消对全屏状态变化的监听
      screenfull.off("change", onScreenfullChange);
    };
  }, []);

  return [
    state,
    {
      enterFullscreen: useMemoizedFn(enterFullscreen),
      exitFullscreen: useMemoizedFn(exitFullscreen),
      toggleFullscreen: useMemoizedFn(toggleFullscreen),
      isEnabled: screenfull.isEnabled,
    },
  ] as const;
};

export default useFullscreen;

useHover

文档地址

详细代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import type { BasicTarget } from "@/utils/domTarget";
import useBoolean from "../useBoolean";
import useEventListener from "../useEventListener";

export interface Options {
  onEnter?: () => void;
  onLeave?: () => void;
  onChange?: (isHovering: boolean) => void;
}

const useHover = (target: BasicTarget, options?: Options) => {
  const { onEnter, onChange, onLeave } = options || {};

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

  // 监听 mouseenter 事件
  useEventListener(
    "mouseenter",
    () => {
      onEnter?.();
      setTrue();
      onChange?.(true);
    },
    {
      target,
    }
  );

  // 监听 mouseleave 事件
  useEventListener(
    "mouseleave",
    () => {
      onLeave?.();
      setFalse();
      onChange?.(false);
    },
    {
      target,
    }
  );

  return state;
};

export default useHover;

useMutationObserver

文档地址

详细代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { getTargetElement, type BasicTarget } from "@/utils/domTarget";
import useLatest from "../useLatest";
import useDeepCompareWithTarget from "@/utils/useDeepCompareWithTarget";

const useMutationObserver = (
  callback: MutationCallback,
  target: BasicTarget,
  options: MutationObserverInit = {}
): void => {
  const callbackRef = useLatest(callback);

  useDeepCompareWithTarget(
    () => {
      const element = getTargetElement(target);
      if (!element) {
        return;
      }

      // 创建一个观察器实例并传入回调函数
      const observer = new MutationObserver(callbackRef.current);
      // 根据配置开始观察目标节点
      observer.observe(element, options);

      return () => {
        // 停止观察
        observer?.disconnect();
      };
    },
    [options],
    target
  );
};

export default useMutationObserver;

useInViewport

文档地址

详细代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
/**
 * intersection-observer polyfill 处理
 * */
import "intersection-observer";
import { getTargetElement, type BasicTarget } from "@/utils/domTarget";
import { useState } from "react";
import useEffectWithTarget from "@/utils/useEffectWithTarget";

type CallbackType = (entry: IntersectionObserverEntry) => void;

export interface Options {
  rootMargin?: string;
  threshold?: number | number[];
  root?: BasicTarget<Element>;
  callback?: CallbackType;
}

const useInViewport = (
  target: BasicTarget | BasicTarget[],
  options?: Options
) => {
  const { callback, ...option } = options || {};

  const [state, setState] = useState<boolean>();
  const [ratio, setRatio] = useState<number>();

  useEffectWithTarget(
    () => {
      const targets = Array.isArray(target) ? target : [target];
      const els = targets
        .map((element) => getTargetElement(element))
        .filter(Boolean);

      if (!els.length) {
        return;
      }
      /**
       * 创建交叉观察器
       * */
      const observer = new IntersectionObserver(
        (entries) => {
          for (const entry of entries) {
            setRatio(entry.intersectionRatio);
            setState(entry.isIntersecting);
            callback?.(entry);
          }
        },
        {
          ...option,
          root: getTargetElement(options?.root),
        }
      );

      /**
       * 监控多个元素
       * */
      els.forEach((el) => observer.observe(el!));

      return () => {
        observer.disconnect();
      };
    },
    [callback, options?.rootMargin, options?.threshold],
    target
  );
  return [state, ratio] as const;
};

export default useInViewport;

useKeyPress

文档地址

详细代码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
import { isFunction, isNumber, isString } from "@/utils";
import { getTargetElement, type BasicTarget } from "@/utils/domTarget";
import useLatest from "../useLatest";
import useDeepCompareWithTarget from "@/utils/useDeepCompareWithTarget";
import isAppleDevice from "@/utils/isAppleDevice";

/**
 * KeyboardEvent 键盘操作时生成的事件对象
 * altKey: 表示是否按下了 Alt 键
 * ctrlKey: 表示是否按下了 Ctrl 键
 * shiftKey: 表示是否按下了Shift键
 * metaKey: 表示是否按下了 Meta 键(在 Windows 系统上通常对应 Windows 键,在 Mac 系统上对应 Command 键)
 * key: 表示按下的实际按键值,例如 "A"、"Enter"、"ArrowUp" 等
 * code: 表示按下的按键的标准名称,例如 "KeyA"、"Enter"、"ArrowUp" 等
 * keyCode: 表示按下的按键的键码值
 * charCode: 表示按下的按键的字符编码值
 * location: 表示按下的按键的位置,例如左侧的 Ctrl 键、右侧的 Ctrl 键等
 * repeat: 表示按键是否为重复按下
 * type: 表示当前的事件类型,'keyup' 释放按键,'keydown' 按下按键
 * */
export type KeyType = number | string;
export type KeyPredicate = (
  event: KeyboardEvent
) => KeyType | boolean | undefined;
export type KeyFilter =
  | KeyType
  | KeyType[]
  | ((event: KeyboardEvent) => boolean);
export type KeyEvent = "keydown" | "keyup";
export type Target = BasicTarget<HTMLElement | Document | Window>;
export type Options = {
  target?: Target;
  events?: KeyEvent[];
  exactMatch?: boolean;
  useCapture?: boolean;
};

// 键盘事件 keyCode 别名
const aliasKeyCodeMap = {
  "0": 48,
  "1": 49,
  "2": 50,
  "3": 51,
  "4": 52,
  "5": 53,
  "6": 54,
  "7": 55,
  "8": 56,
  "9": 57,
  backspace: 8,
  tab: 9,
  enter: 13,
  shift: 16,
  ctrl: 17,
  alt: 18,
  pausebreak: 19,
  capslock: 20,
  esc: 27,
  space: 32,
  pageup: 33,
  pagedown: 34,
  end: 35,
  home: 36,
  leftarrow: 37,
  uparrow: 38,
  rightarrow: 39,
  downarrow: 40,
  insert: 45,
  delete: 46,
  a: 65,
  b: 66,
  c: 67,
  d: 68,
  e: 69,
  f: 70,
  g: 71,
  h: 72,
  i: 73,
  j: 74,
  k: 75,
  l: 76,
  m: 77,
  n: 78,
  o: 79,
  p: 80,
  q: 81,
  r: 82,
  s: 83,
  t: 84,
  u: 85,
  v: 86,
  w: 87,
  x: 88,
  y: 89,
  z: 90,
  leftwindowkey: 91,
  rightwindowkey: 92,
  meta: isAppleDevice ? [91, 93] : [91, 92],
  selectkey: 93,
  numpad0: 96,
  numpad1: 97,
  numpad2: 98,
  numpad3: 99,
  numpad4: 100,
  numpad5: 101,
  numpad6: 102,
  numpad7: 103,
  numpad8: 104,
  numpad9: 105,
  multiply: 106,
  add: 107,
  subtract: 109,
  decimalpoint: 110,
  divide: 111,
  f1: 112,
  f2: 113,
  f3: 114,
  f4: 115,
  f5: 116,
  f6: 117,
  f7: 118,
  f8: 119,
  f9: 120,
  f10: 121,
  f11: 122,
  f12: 123,
  numlock: 144,
  scrolllock: 145,
  semicolon: 186,
  equalsign: 187,
  comma: 188,
  dash: 189,
  period: 190,
  forwardslash: 191,
  graveaccent: 192,
  openbracket: 219,
  backslash: 220,
  closebracket: 221,
  singlequote: 222,
};

// 修饰键
const modifierKey = {
  // 是否按下 ctrl 键
  ctrl: (event: KeyboardEvent) => event.ctrlKey,
  // 是否按下 shift 键
  shift: (event: KeyboardEvent) => event.shiftKey,
  // 是否按下 alt 键
  alt: (event: KeyboardEvent) => event.altKey,
  meta: (event: KeyboardEvent) => {
    // meta 键被松开
    if (event.type === "keyup") {
      return aliasKeyCodeMap.meta.includes(event.keyCode);
    }
    // 是否按下 metaKey 键
    return event.metaKey;
  },
};

// 判断合法的按键类型
function isValidKeyType(value: unknown): value is string | number {
  return isString(value) || isNumber(value);
}

// 根据 event 计算修饰键被按下的数量
function countKeyByEvent(event: KeyboardEvent) {
  const countOfModifier = Object.keys(modifierKey).reduce((total, key) => {
    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;
}
/**
 * 判断按键是否激活
 * @param [event: KeyboardEvent]键盘事件
 * @param [keyFilter: any] 当前键
 * @returns string | number | boolean
 */
function getFilterKey(
  event: KeyboardEvent,
  keyFilter: KeyType,
  exactMatch: boolean
) {
  // 浏览器自动补全 input 的时候,会触发 keyDown、keyUp 事件,但此时 event.key 等为空
  if (!event.key) {
    return false;
  }
  // 数字类型直接匹配事件的 keyCode
  if (isNumber(keyFilter)) {
    return event.keyCode === keyFilter ? keyFilter : false;
  }
  // 字符串依次判断是否有组合键
  const genArr = keyFilter.split(".");
  let genLen = 0;
  for (const key of genArr) {
    // 是否是修饰键
    const genModifier = modifierKey[key];
    // 是否是 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
      ? keyFilter
      : false;
  }

  return genLen === genArr.length ? keyFilter : false;
}
/**
 * 键盘输入预处理方法,判断按键是否激活
 * @param [keyFilter: any] 当前键
 * @returns () => Boolean
 */
function genKeyFormatter(
  keyFilter: KeyFilter,
  exactMath: boolean
): KeyPredicate {
  // 支持自定义函数
  if (isFunction(keyFilter)) {
    return keyFilter;
  }

  // 支持 keyCode、别名、组合键
  if (isValidKeyType(keyFilter)) {
    return (event: KeyboardEvent) => getFilterKey(event, keyFilter, exactMath);
  }

  // 支持数组
  if (Array.isArray(keyFilter)) {
    return (event: KeyboardEvent) =>
      keyFilter.find((item) => getFilterKey(event, item, exactMath));
  }

  return () => Boolean(keyFilter);
}

const defaultEvents: KeyEvent[] = ["keydown"];

const useKeyPress = (
  keyFilter: KeyFilter,
  eventHandler: (event: KeyboardEvent, key: KeyType) => void,
  option?: Options
) => {
  const {
    events = defaultEvents,
    target,
    exactMatch = false,
    useCapture = false,
  } = option || {};

  const eventHandlerRef = useLatest(eventHandler);
  const keyFilterRef = useLatest(keyFilter);

  useDeepCompareWithTarget(
    () => {
      const el = getTargetElement(target, window);
      if (!el) {
        return;
      }

      const callbackHandler = (event: KeyboardEvent) => {
        const genGuard = genKeyFormatter(keyFilterRef.current, exactMatch);
        const keyGuard = genGuard(event);
        const firedKey = isValidKeyType(keyGuard) ? keyGuard : event.key;

        // 判断是否触发配置 keyFilter 场景
        if (keyGuard) {
          return eventHandlerRef.current?.(event, firedKey);
        }
      };

      // 监听 'keydown' | 'keyup'
      for (const eventName of events) {
        el?.addEventListener?.(eventName, callbackHandler, useCapture);
      }

      // 取消监听 'keydown' | 'keyup'
      return () => {
        for (const eventName of events) {
          el?.removeEventListener?.(eventName, callbackHandler, useCapture);
        }
      };
    },
    [events],
    target
  );
};

export default useKeyPress;

useLongPress

文档地址

详细代码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
import { type BasicTarget, getTargetElement } from "@/utils/domTarget";
import isBrowser from "@/utils/isBrowser";
import useLatest from "../useLatest";
import { useRef } from "react";
import useEffectWithTarget from "@/utils/useEffectWithTarget";

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

/**
 * 判断是否支持 touch 事件
 * 如果支持,则监听 touchstart - 触摸开始、touchend - 触摸结束、touchmove - 触摸移动
 * 如果不支持,则监听 mousedown - 按下鼠标、mouseup - 松开鼠标、mousemove - 鼠标移动、mouseleave - 鼠标离开元素
 * */
const touchSupported =
  isBrowser &&
  ("ontouchstart" in window ||
    // @ts-ignore
    (window.DocumentTouch && document instanceof DocumentTouch));
const useLongPress = (
  onLongPress: (event: EventType) => void,
  target: BasicTarget,
  { delay = 20, moveThreshold, onClick, onLongPressEnd }: Options = {}
) => {
  const onLongPressRef = useLatest(onLongPress);
  const onClickRef = useLatest(onClick);
  const onLongPressEndRef = useLatest(onLongPressEnd);

  const timeRef = useRef<ReturnType<typeof setTimeout>>();
  const isTriggeredRef = useRef(false);
  const pervPositionRef = useRef({ x: 0, y: 0 });
  const hasMoveThreshold = !!(
    (moveThreshold?.x && moveThreshold.x > 0) ||
    (moveThreshold?.y && moveThreshold.y > 0)
  );

  useEffectWithTarget(
    () => {
      const targetElement = getTargetElement(target);
      if (!targetElement?.addEventListener) {
        return;
      }

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

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

      const onStart = (event: EventType) => {
        if (hasMoveThreshold) {
          // 按下后计算 clientX, clientY
          const { clientX, clientY } = getClientPosition(event);
          pervPositionRef.current.x = clientX;
          pervPositionRef.current.y = clientY;
        }

        // 设置定时器
        timeRef.current = setTimeout(() => {
          onLongPressRef.current(event);
          // 只有定时器执行完,isTriggeredRef.current 才会设置为 true,触发长按事件
          isTriggeredRef.current = true;
        }, delay);
      };

      const onMove = (event: EventType) => {
        // 按下后移动,如果超出移动阈值,则不触发长按事件
        if (timeRef.current && overThreshold(event)) {
          clearTimeout(timeRef.current);
          timeRef.current = undefined;
        }
      };

      const onEnd = (event: EventType, shouldTriggerClick: boolean = false) => {
        // clear 开始的定时器
        if (timeRef.current) {
          clearTimeout(timeRef.current);
        }

        // 判断是否达到长按时间(即触发过长按事件)
        if (isTriggeredRef.current) {
          onLongPressEndRef.current?.(event);
        }

        // 是否触发 onClick 事件,只有 timeRef 定时器执行过,isTriggeredRef.current 才为 true
        if (
          shouldTriggerClick &&
          !isTriggeredRef.current &&
          onClickRef.current
        ) {
          onClickRef.current(event);
        }

        isTriggeredRef.current = false;
      };

      const onEndWithClick = (event: EventType) => onEnd(event, true);

      /**
       * 不支持 touch 事件
       * */
      if (!touchSupported) {
        targetElement.addEventListener("mousedown", onStart);
        targetElement.addEventListener("mouseup", onEndWithClick);
        targetElement.addEventListener("mouseleave", onEnd);
        if (hasMoveThreshold)
          targetElement.addEventListener("mousemove", onMove);
      } else {
        targetElement.addEventListener("touchstart", onStart);
        targetElement.addEventListener("touchend", onEndWithClick);
        if (hasMoveThreshold)
          targetElement.addEventListener("touchmove", onMove);
      }

      return () => {
        // 清除定时器
        if (timeRef.current) {
          clearTimeout(timeRef.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
  );
};

export default useLongPress;

useMouse

文档地址

详细代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
import { BasicTarget, getTargetElement } from "@/utils/domTarget";
import useEventListener from "../useEventListener";
import useRafState from "../useRafState";

/**
 * screenX: 距离显示器左侧的距离(屏幕)
 * screenY: 距离显示器顶部的距离(屏幕)
 * clientX: 距离当前视窗左侧的距离(视窗)
 * clientY: 距离当前视窗顶部的距离(视窗)
 * pageX: 距离完整页面左侧的距离(clientX + 文档在水平方向上已经滚动的像素数)
 * pageY: 距离完整页面顶部的距离(clientY + 文档在垂直方向上已经滚动的像素数)
 * elementX: 距离指定元素左侧的距离
 * elementY: 距离指定元素顶部的距离
 * elementH: 指定元素的高
 * elementW: 指定元素的宽
 * elementPosX: 指定元素距离完整页面左侧的距离(:left + window.pageXOffset)
 * elementPosY: 指定元素距离完整页面顶部的距离(:top + window.pageYOffset)
 * window.pageXOffset: 表示文档在水平方向上已经滚动的像素数
 * window.pageYOffset: 表示文档在垂直方向上已经滚动的像素数
 * */
export interface CursorState {
  screenX: number;
  screenY: number;
  clientX: number;
  clientY: number;
  pageX: number;
  pageY: number;
  elementX: number;
  elementY: number;
  elementH: number;
  elementW: number;
  elementPosX: number;
  elementPosY: number;
}

const initState: CursorState = {
  screenX: NaN,
  screenY: NaN,
  clientX: NaN,
  clientY: NaN,
  pageX: NaN,
  pageY: NaN,
  elementX: NaN,
  elementY: NaN,
  elementH: NaN,
  elementW: NaN,
  elementPosX: NaN,
  elementPosY: NaN,
};

const useMouse = (target?: BasicTarget) => {
  const [state, setState] = useRafState(initState);

  // 监听 mousemove
  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;
        newState.elementPosY = top + window.pageYOffset;
        newState.elementX = pageX - newState.elementPosX;
        newState.elementY = pageY - newState.elementPosY;
        newState.elementW = width;
        newState.elementH = height;
      }
      setState(newState);
    },
    {
      target: () => document,
    }
  );

  return state;
};

export default useMouse;

useResponsive

文档地址

详细代码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
import isBrowser from "@/utils/isBrowser";
import { useEffect, useState } from "react";

type Subscriber = () => void;

// 全局订阅器
const subscribers = new Set<Subscriber>();

type ResponsiveConfig = Record<string, number>;
type ResponsiveInfo = Record<string, boolean>;

// 全局响应式信息对象
let info: ResponsiveInfo;

// 默认响应式断点配置
let responsiveConfig: ResponsiveConfig = {
  xs: 0,
  sm: 576,
  md: 768,
  lg: 992,
  xl: 1200,
};

// 根据当前视窗可见宽度和响应式断点配置,计算新的响应式信息对象
function calculate() {
  const width = window.innerWidth;
  const newInfo = {} as ResponsiveInfo;
  let shouldUpdate = false;
  for (const key of Object.keys(responsiveConfig)) {
    // 如果视窗可视宽度大于响应式断点配置值,则置为 true
    newInfo[key] = width >= responsiveConfig[key];
    if (newInfo[key] !== info[key]) {
      shouldUpdate = true;
    }
  }
  // 如果有更新,则更新 info 的值
  if (shouldUpdate) {
    info = newInfo;
  }
}

// resize 事件回调函数
function handleResize() {
  const oldInfo = info;
  // 计算新的响应式信息对象
  calculate();
  // 没有更新,直接返回
  if (oldInfo === info) return;
  // 遍历订阅者集合,执行回调
  for (const subscriber of subscribers) {
    subscriber();
  }
}

// 用来避免每个组件都监听 resize 事件,全局只需要拥有一个监听事件即可
let listening = false;

// 自定义响应式断点配置函数
export const configResponsive = (config: ResponsiveConfig) => {
  responsiveConfig = config;
  if (info) calculate();
};

const useResponsive = () => {
  if (isBrowser && !listening) {
    info = {};
    calculate();
    // 监听 resize 事件
    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
        listening = false;
      }
    };
  }, []);

  return state;
};

export default useResponsive;

useScroll

文档地址

详细代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import { type BasicTarget, getTargetElement } from "@/utils/domTarget";
import useRafState from "../useRafState";
import useLatest from "../useLatest";
import useEffectWithTarget from "@/utils/useEffectWithTarget";

type Position = { left: number; top: number };

export type Target = BasicTarget<Document | Element>;
export type ScrollListenController = (val: Position) => boolean;

const useScroll = (
  target?: Target,
  shouldUpdate: ScrollListenController = () => true
): Position | undefined => {
  const [position, setPosition] = useRafState<Position>();

  const shouldUpdateRef = useLatest(shouldUpdate);

  useEffectWithTarget(
    () => {
      const el = getTargetElement(target, document);
      if (!el) {
        return;
      }
      const updatePosition = () => {
        let newPosition: Position;
        // document
        if (el === document) {
          /**
           * scrollingElement(Document 的只读属性)返回滚动文档的 Element 对象的引用
           * 标准模式下,这是文档的根元素,document.documentElement
           * */
          if (document.scrollingElement) {
            newPosition = {
              left: document.scrollingElement.scrollLeft,
              top: document.scrollingElement.scrollTop,
            };
          } else {
            /**
             * 怪异模式下,scrollingElement 属性返回 HTML body 元素(若不存在返回 null)
             * */
            // When in quirks mode, the scrollingElement attribute returns the HTML body element if it exists and is potentially scrollable, otherwise it returns null.
            // 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 {
          // DOM
          newPosition = {
            left: (el as Element).scrollLeft,
            top: (el as Element).scrollTop,
          };
        }
        if (shouldUpdateRef.current(newPosition)) {
          setPosition(newPosition);
        }
      };

      updatePosition();

      // 注册 scroll 事件监听器
      el.addEventListener("scroll", updatePosition);
      return () => {
        // 清除 scroll 事件监听器
        el.removeEventListener("scroll", updatePosition);
      };
    },
    [],
    target
  );

  return position;
};

export default useScroll;

useSize

文档地址

详细代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import ResizeObserver from "resize-observer-polyfill";
import { type BasicTarget, getTargetElement } from "@/utils/domTarget";
import useIsomorphicLayoutEffectWithTarget from "@/utils/useIsomorphicLayoutEffectWithTarget";
import useRafState from "../useRafState";

type Size = { width: number; height: number };

const useSize = (target: BasicTarget): Size | undefined => {
  const [state, setState] = useRafState(() => {
    // 目标元素
    const el = getTargetElement(target);
    return el ? { width: el.clientWidth, height: el.clientHeight } : undefined;
  });

  useIsomorphicLayoutEffectWithTarget(
    () => {
      // 目标元素
      const el = getTargetElement(target);

      if (!el) {
        return;
      }

      /**
       * 使用 ResizeObserver API 监听对应目标的尺寸变化
       * 新建一个观察者,传入一个当尺寸发生变化时的回调函数
       * entries 是 ResizeObserverEntry 的数组,包含两个属性:
       * ResizeObserverEntry.contentRect: 包含尺寸信息(x, y, width, height, top, right, left, bottom)
       * ResizeObserverEntry.target: 目标对象,即当前观察到尺寸变化的元素
       * */
      const resizeObserver = new ResizeObserver((entries) => {
        entries.forEach((entry) => {
          const { clientWidth, clientHeight } = entry.target;
          setState({ width: clientWidth, height: clientHeight });
        });
      });

      // 监听
      resizeObserver.observe(el);

      return () => {
        // 销毁
        resizeObserver.disconnect();
      };
    },
    [],
    target
  );

  return state;
};

export default useSize;

useFocusWithin

文档地址

详细代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import { useState } from "react";
import useEventListener from "../useEventListener";
import type { BasicTarget } from "@/utils/domTarget";

export interface Options {
  onFocus?: (e: FocusEvent) => void;
  onBlur?: (e: FocusEvent) => void;
  onChange?: (isFocusWithin: boolean) => void;
}

/**
 * focusin、focusout、focus、blur 都是与用户输入焦点相关的事件
 * focusin、focusout 在元素或其子元素获得或失去焦点时触发(冒泡到祖先元素)
 * focus、blur 只在元素自身获得或失去焦点时触发
 * */
const useFocusWithin = (target: BasicTarget, options?: Options) => {
  const [isFocusWithin, setIsFocusWithin] = useState(false);

  const { onFocus, onBlur, onChange } = options || {};

  useEventListener(
    "focusin",
    (e: FocusEvent) => {
      if (!isFocusWithin) {
        onFocus?.(e);
        onChange?.(true);
        setIsFocusWithin(true);
      }
    },
    {
      target,
    }
  );

  useEventListener(
    "focusout",
    (e: FocusEvent) => {
      /**
       * e.currentTarget 表示当前正在处理事件的元素,即绑定了 focusout 事件监听器的元素
       * e.relatedTarget 表示与事件相关的目标元素,即导致元素失去焦点的元素
       * 在 focusout 事件中,表示 e.relatedTarget 获取了焦点的新元素,如果焦点移出了文档,则为 null
       * */
      if (
        isFocusWithin &&
        !(e.currentTarget as Element)?.contains?.(e.relatedTarget as Element)
      ) {
        onBlur?.(e);
        onChange?.(false);
        setIsFocusWithin(false);
      }
    },
    {
      target,
    }
  );
  return isFocusWithin;
};

export default useFocusWithin;