useHover
useHover
是用于监听 DOM 元素是否有鼠标悬停,它的API配置项如下
主要实现原理是监听 mouseenter
触发 onEnter 事件,切换状态为 true,监听 mouseleave
触发 onLeave 事件,切换状态为 false
源码如下:
js
export default (target: BasicTarget, options?: Options): boolean => {
const { onEnter, onLeave } = options || {};
// useBoolean 设置 元素是否处于 hover 的状态
const [state, { setTrue, setFalse }] = useBoolean(false);
// 通过监听 mouseenter 判断有鼠标悬停
useEventListener(
'mouseenter',
() => {
onEnter?.();
setTrue();
onChange?.(true);
},
{
target,
},
);
// mouseleave 没有鼠标悬停
useEventListener(
'mouseleave',
() => {
onLeave?.();
setFalse();
onChange?.(true);
},
{
target,
},
);
return state;
};
思路如下
:
- 通过监听
mouseenter
、mouseleave
分别触发onEnter
、onLeave
回调 - 然后调用
setTrue
、setFalse
来设置 DOM 元素处于 hover 的状态 - 此时 hover 状态变化,触发
onChange
回调 - 最后返回状态
state
,也就是官方文档中的isHovering
整体代码简单清晰。
useInViewport
useInViewport
用于观察元素是否在可见区域,以及元素可见比例
其实现原理就是 Intersection Observer API。并使用 intersection-observer 这个 npm 包进行 polyfill 处理
Intersection Observer API 提供了一种异步检测目标元素与祖先元素或 viewport 相交情况变化的方法。
源码如下:
js
import 'intersection-observer';
import { useState } from 'react';
import type { BasicTarget } from '../utils/domTarget';
import { getTargetElement } from '../utils/domTarget';
import useEffectWithTarget from '../utils/useEffectWithTarget';
export interface Options {
rootMargin?: string;
threshold?: number | number[];
root?: BasicTarget<Element>;
}
function useInViewport(target: BasicTarget, options?: Options) {
const [state, setState] = useState<boolean>();
const [ratio, setRatio] = useState<number>();
useEffectWithTarget(
() => {
const el = getTargetElement(target);
if (!el) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
setRatio(entry.intersectionRatio);
setState(entry.isIntersecting);
}
},
{
...options,
root: getTargetElement(options?.root),
},
);
observer.observe(el);
return () => {
observer.disconnect();
};
},
[options?.rootMargin, options?.threshold],
target,
);
return [state, ratio] as const;
}
export default useInViewport;
没什么好说的,下一个
useKeyPress
useKeyPress
用于监听键盘按键,支持组合键,支持按键别名。
其实现原理是监听 keydown
或者 keyup
事件,在回调对 keyFilter
配置进行判断,如果触发了配置的场景,则触发我们传入的 eventHandler
回调
主函数代码比较简单:
js
const defaultEvents: KeyEvent[] = ['keydown'];
// 监听键盘按键,支持组合键,支持按键别名。
function useKeyPress(
keyFilter: KeyFilter,
eventHandler: EventHandler,
option?: Options,
) {
// 默认 defaultEvents 是 keydown
const { events = defaultEvents, target, exactMatch = false } = option || {};
const eventHandlerRef = useLatest(eventHandler);
const keyFilterRef = useLatest(keyFilter);
useDeepCompareEffectWithTarget(
() => {
const el = getTargetElement(target, window);
if (!el) {
return;
}
const callbackHandler = (event: KeyboardEvent) => {
const genGuard: KeyPredicate = genKeyFormater(
keyFilterRef.current,
exactMatch,
);
// 判断是否触发配置 keyFilter 场景
if (genGuard(event)) {
return eventHandlerRef.current?.(event);
}
};
// 监听传入事件
for (const eventName of events) {
el?.addEventListener?.(eventName, callbackHandler);
}
// 取消监听
return () => {
for (const eventName of events) {
el?.removeEventListener?.(eventName, callbackHandler);
}
};
},
[events],
target,
);
}
主要是它其中调用的 genKeyFormatter
键盘输入预处理方法,主要是针对几种传入的场景进行处理,留意注释。其中返回的是一个判断函数,假如传入的是 keyFilter 是一个自定义函数,则直接根据 event 参数判断按键是否激活。
另外支持 keyCode、别名、组合键、数组。其内部都是调用的 genFilterKey 判断按键是否激活。
js
/**
* 键盘输入预处理方法
* @param [keyFilter: any] 当前键
* @returns () => Boolean
*/
function genKeyFormater(
keyFilter: KeyFilter,
exactMatch: boolean,
): KeyPredicate {
// 支持自定义函数
if (isFunction(keyFilter)) {
return keyFilter;
}
// 支持 keyCode、别名、组合键
if (isString(keyFilter) || isNumber(keyFilter)) {
return (event: KeyboardEvent) => genFilterKey(event, keyFilter, exactMatch);
}
// 支持数组
if (Array.isArray(keyFilter)) {
return (event: KeyboardEvent) =>
keyFilter.some(item => genFilterKey(event, item, exactMatch));
}
return keyFilter ? () => true : () => false;
}
getFilterKey
如下:
js
/**
* 判断按键是否激活
* @param [event: KeyboardEvent]键盘事件
* @param [keyFilter: any] 当前键
* @returns Boolean
*/
function genFilterKey(
event: KeyboardEvent,
keyFilter: keyType,
exactMatch: boolean,
) {
// 浏览器自动补全 input 的时候,会触发 keyDown、keyUp 事件,但此时 event.key 等为空
if (!event.key) {
return false;
}
// 数字类型直接匹配事件的 keyCode
if (isNumber(keyFilter)) {
return event.keyCode === keyFilter;
}
// 字符串依次判断是否有组合键
const genArr = keyFilter.split('.');
let genLen = 0;
for (const key of genArr) {
// 组合键
const genModifier = modifierKey[key];
// keyCode 别名
const aliasKeyCode = 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;
}
return genLen === genArr.length;
}
useMouse
useMouse
用于监听鼠标位置
主要实现原理是通过监听 mousemove 方法,获取鼠标的位置。并通过 getBoundingClientRect
获取到 target 元素的属性,从而计算出鼠标相对于元素的位置。
ts
import useRafState from '../useRafState';
import useEventListener from '../useEventListener';
import type { BasicTarget } from '../utils/domTarget';
import { getTargetElement } from '../utils/domTarget';
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,
};
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;
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;
};
思路如下
:
- 首先从
event
对象中获取到screenX
等属性 - 然后调用
getTargetElement
获取目标元素,再通过getBoundingClientRect
方法获取到目标元素得属性去做一些计算 - 最后返回
state
useScroll
useScroll
用于监听元素的滚动位置。
实现原理是通过监听目标的 scroll 事件
,更新目标位置信息。
主函数代码如下:
js
function 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 = () => {
// ...单独拿出来讲
};
updatePosition();
// 监听 scroll 事件
el.addEventListener('scroll', updatePosition);
return () => {
el.removeEventListener('scroll', updatePosition);
};
},
[],
target,
);
return position;
}
主函数做的事情很简单,通过getTargetElement
获取目标元素,然后监听scroll
事件,事件的回调是 updatePosition
方法,我们来单独看这个方法做了什么
updatePosition:
js
// 更新位置
const updatePosition = () => {
let newPosition: Position;
// 如果是 document 的处理
if (el === document) {
if (document.scrollingElement) {
newPosition = {
left: document.scrollingElement.scrollLeft,
top: document.scrollingElement.scrollTop,
};
} else {
// 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.pageYOffset,
document.documentElement.scrollTop,
document.body.scrollTop,
),
top: Math.max(
window.pageXOffset,
document.documentElement.scrollLeft,
document.body.scrollLeft,
),
};
}
} else {
// 获取到元素的位置
newPosition = {
left: (el as Element).scrollLeft,
top: (el as Element).scrollTop,
};
}
// 是否更新位置信息函数
if (shouldUpdateRef.current(newPosition)) {
setPosition(newPosition);
}
};
留意当为 document
的时候,当在怪异模式下, scrollingElement 属性返回 HTML body 元素(若不存在返回 null )。这个时候可以取 window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop 三者中最大值。参考。
useSize
useSize
是用于监听 DOM 节点尺寸变化的 Hook。
主要实现原理就是使用 ResizeObserver API
监听对应的目标的尺寸变化。
代码比较简单:
js
function useSize(
// 目标 DOM
target: BasicTarget,
): Size | undefined {
const [state, setState] = useRafState<Size>();
useIsomorphicLayoutEffectWithTarget(
() => {
// 获取到当前目标元素
const el = getTargetElement(target);
if (!el) {
return;
}
const resizeObserver = new ResizeObserver(entries => {
// 监听的 DOM 大小发生变化的时候,则获取到最新的值
entries.forEach(entry => {
// DOM 节点的尺寸
const { clientWidth, clientHeight } = entry.target;
setState({
width: clientWidth,
height: clientHeight,
});
});
});
// 监听目标的元素
resizeObserver.observe(el);
// 销毁相应的事件,防止造成内存泄露
return () => {
resizeObserver.disconnect();
};
},
// 依赖项
[],
// 目标元素
target,
);
return state;
}
useFocusWithin
监听当前焦点是否在某个区域之内
实现原理主要是监听 focusin 和 focusout 方法。当元素即将失去焦点时,focusout 事件被触发。当元素即将聚焦时,focusin 事件被触发。它们和 focus 和 blur 方法的主要区别是后面两个不会进行冒泡。
代码实现也比较简单:
js
export default function useFocusWithin(target: BasicTarget, options?: Options) {
const [isFocusWithin, setIsFocusWithin] = useState(false);
const { onFocus, onBlur, onChange } = options || {};
// https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-within
// 监听 focusin 和 focusout
useEventListener(
'focusin',
(e: FocusEvent) => {
if (!isFocusWithin) {
// 获取焦点时触发
onFocus?.(e);
// 焦点变化时触发
onChange?.(true);
setIsFocusWithin(true);
}
},
{
target,
},
);
useEventListener(
'focusout',
(e: FocusEvent) => {
// @ts-ignore
if (isFocusWithin && !e.currentTarget?.contains?.(e.relatedTarget)) {
// 失去焦点时触发
onBlur?.(e);
onChange?.(false);
setIsFocusWithin(false);
}
},
{
target,
},
);
return isFocusWithin;
}
和 useHover
差不多
最后
往期的 ahooks 源码解析文章可以看专栏 ahooks源码系列