前言
本文是 ahooks 源码系列的第四篇,往期文章:
- 【解读 ahooks 源码系列】(开篇)如何获取和监听 DOM 元素:useEffectWithTarget
- 【解读 ahooks 源码系列】DOM 篇(一):useEventListener、useClickAway、useDocumentVisibility、useDrop、useDrag
- 【解读 ahooks 源码系列】DOM 篇(二):useEventTarget、useExternal、useTitle、useFavicon、useFullscreen、useHover
本文次要解读 useMutationObserver
、useInViewport
、useKeyPress
、useLongPress
源码实现
useMutationObserver
一个监听指定的 DOM 树发生变化的 Hook
官网文档
MutationObserver API
MutationObserver 接口提供了监督对 DOM 树所做更改的能力。利用 MutationObserver API 咱们能够监督 DOM 的变动,比方节点的减少、缩小、属性的变动、文本内容的变动等等。
可参考学习:
- MutationObserver
- 你不晓得的 MutationObserver
根本用法
官网在线 Demo
点击按钮,扭转 width,触发 div 的 width 属性变更,打印的 mutationsList 如下:
import {useMutationObserver} from 'ahooks';
import React, {useRef, useState} from 'react';
const App: React.FC = () => {const [width, setWidth] = useState(200);
const [count, setCount] = useState(0);
const ref = useRef<HTMLDivElement>(null);
useMutationObserver((mutationsList) => {mutationsList.forEach(() => setCount((c) => c + 1));
},
ref,
{attributes: true},
);
return (
<div>
<div ref={ref} style={{width, padding: 12, border: '1px solid #000', marginBottom: 8}}>
current width:{width}
</div>
<button onClick={() => setWidth((w) => w + 10)}>widening</button>
<p>Mutation count {count}</p>
</div>
);
};
外围实现
这个实现比较简单,次要还是了解 MutationObserver API:
useMutationObserver(
callback: MutationCallback, // 触发的回调函数
target: Target,
options?: MutationObserverInit, // 设置项:https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe#parameters
);
const useMutationObserver = (
callback: MutationCallback,
target: BasicTarget,
options: MutationObserverInit = {},): void => {const callbackRef = useLatest(callback);
useDeepCompareEffectWithTarget(() => {const element = getTargetElement(target);
if (!element) {return;}
// 创立一个观察器实例并传入回调函数
const observer = new MutationObserver(callbackRef.current);
observer.observe(element, options); // 启动监听,指定所要察看的 DOM 节点
return () => {if (observer) {observer.disconnect(); // 进行察看变动
}
};
},
[options],
target,
);
};
残缺源码
useInViewport
察看元素是否在可见区域,以及元素可见比例。
官网文档
根本用法
官网在线 Demo
import React, {useRef} from 'react';
import {useInViewport} from 'ahooks';
export default () => {const ref = useRef(null);
const [inViewport] = useInViewport(ref);
return (
<div>
<div style={{width: 300, height: 300, overflow: 'scroll', border: '1px solid'}}>
scroll here
<div style={{height: 800}}>
<div
ref={ref}
style={{
border: '1px solid',
height: 100,
width: 100,
textAlign: 'center',
marginTop: 80,
}}
>
observer dom
</div>
</div>
</div>
<div style={{marginTop: 16, color: inViewport ? '#87d068' : '#f50'}}>
inViewport: {inViewport ? 'visible' : 'hidden'}
</div>
</div>
);
};
应用场景
- 图片懒加载:当图片滚动到可见地位的时候才加载
- 有限滚动加载:滑动到底部时开始加载新的内容
- 检测广告的曝光率:广告是否被用户看到
- 用户看到某个区域时执行工作或播放动画
IntersectionObserver API
IntersectionObserver API,能够主动 ” 察看 ” 元素是否可见。因为可见(visible)的实质是,指标元素与视口产生一个穿插区,所以这个 API 叫做 ” 穿插观察器 ”。
- Intersection Observer API
- 可参考学习:IntersectionObserver API 应用教程
实现思路
- 监听指标元素,反对传入原生
IntersectionObserver
API 选项 - 对
IntersectionObserver
构造函数的回调函数设置可见状态与可见比例值 - 借助 intersection-observer 库实现 polyfill
外围实现
export interface Options {// 根 (root) 元素的外边距
rootMargin?: string;
// 能够管制在可见区域达到该比例时触发 ratio 更新。默认值是 0 (意味着只有有一个 target 像素呈现在 root 元素中,回调函数将会被执行)。该值为 1.0 含意是当 target 齐全呈现在 root 元素中时候 回调才会被执行。threshold?: number | number[];
// 指定根 (root) 元素,用于查看指标的可见性
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) => {
// callback 函数的参数(entries)是一个数组,每个成员都是一个 IntersectionObserverEntry 对象。如果同时有两个被察看的对象的可见性发生变化,entries 数组就会有两个成员。for (const entry of entries) {setRatio(entry.intersectionRatio); // 设置以后指标元素的可见比例
setState(entry.isIntersecting); // isIntersecting:如果指标元素与交加观察者的根相交,则该值为 true
}
},
{
...options,
root: getTargetElement(options?.root),
},
);
observer.observe(el); // 开始监听一个指标元素
return () => {observer.disconnect(); // 进行监听指标
};
},
[options?.rootMargin, options?.threshold],
target,
);
return [state, ratio] as const;
}
残缺源码
useKeyPress
监听键盘按键,反对组合键,反对按键别名。
官网文档
KeyEvent 根底
JS 的键盘事件
- keydown:触发于键盘按键按下的时候。
- keyup:在按键被松开时触发。
(已过期)keypress:按下有值的键时触发,即按下 Ctrl、Alt、Shift、Meta 这样无值的键,这个事件不会触发。对于有值的键,按下时先触发 keydown 事件,再触发 keypress 事件
对于 keyCode(已过期)event.keyCode(返回按下键的数字代码)
,尽管目前大部分代码仍然应用并放弃兼容。但如果咱们本人实现的话应该尽可能应用 event.key(按下的键的理论值)
属性。具体可见 KeyboardEvent
如何监听按键组合
润饰键有四个
const modifierKey = {ctrl: (event: KeyboardEvent) => event.ctrlKey,
shift: (event: KeyboardEvent) => event.shiftKey,
alt: (event: KeyboardEvent) => event.altKey,
meta: (event: KeyboardEvent) => {if (event.type === 'keyup') {// 这里应用数组判断是因为 meta 键分右边和左边的键(MetaLeft 91、MetaRight 93)
return aliasKeyCodeMap['meta'].includes(event.keyCode);
}
return event.metaKey;
},
};
- 当按下的组合键蕴含
Ctrl
键时,event.ctrlKey
属性为 true - 当按下的组合键蕴含
Shift
键时,event.shiftKey
属性为 true - 当按下的组合键蕴含
Alt
键时,event.altKey
属性为 true - 当按下的组合键蕴含
meta
键时,event.meta
属性为 true(Mac 是 command 键,Windows 电脑是 win 键)
如按下 Alt+K
组合键,会触发两次 keydown
事件,其中 Alt
键和 K
键打印的 altKey
都为 true,能够这么判断:
if (event.altKey && keyCode === 75) {console.log("按下了 Alt + K 键");
}
在线测试
这里举荐个在线网站 Keyboard Events Playground 测试键盘事件,只须要输出任意键即可查看无关它打印的信息,还能够通过复选框来过滤事件,辅助咱们开发验证。
根本用法
官网在线 Demo
在看源码之前,须要理解下该 Hook 反对的用法:
// 反对键盘事件中的 keyCode 和别名
useKeyPress('uparrow', () => {// TODO});
// keyCode value for ArrowDown
useKeyPress(40, () => {// TODO});
// 监听组合按键
useKeyPress('ctrl.alt.c', () => {// TODO});
// 开启准确匹配。比方按 [shift + c],不会触发
useKeyPress(['c'],
() => {// TODO},
{exactMatch: true,},
);
// 监听多个按键。如下 a s d f, Backspace, 8
useKeyPress([65, 83, 68, 70, 8, '8'], (event) => {setKey(event.key);
});
// 自定义监听形式。反对接管一个返回 boolean 的回调函数,本人解决逻辑。const filterKey = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
useKeyPress((event) => !filterKey.includes(event.key),
(event) => {// TODO},
{events: ['keydown', 'keyup'],
},
);
// 自定义 DOM。默认监听挂载在 window 上的事件,也能够传入 DOM 指定监听区域,如常见的监听输入框事件
useKeyPress(
'enter',
(event: any) => {// TODO},
{target: inputRef,},
);
useKeyPress 的参数:
type keyType = number | string;
// 反对 keyCode、别名、组合键、数组,自定义函数
type KeyFilter = keyType | keyType[] | ((event: KeyboardEvent) => boolean);
// 回调函数
type EventHandler = (event: KeyboardEvent) => void;
type KeyEvent = 'keydown' | 'keyup';
type Options = {events?: KeyEvent[]; // 触发事件
target?: Target; // DOM 节点或者 ref
exactMatch?: boolean; // 准确匹配。如果开启,则只有在按键齐全匹配的状况下触发事件。比方按键 [shif + c] 不会触发
useCapture?: boolean; // 是否阻止事件冒泡
};
// useKeyPress 参数
useKeyPress(
keyFilter: KeyFilter,
eventHandler: EventHandler,
options?: Options
);
实现思路
- 监听
keydown
或keyup
事件,处理事件回调函数。 - 在事件回调函数中传入 keyFilter 配置进行判断,兼容自定义函数、keyCode、别名、组合键、数组,反对准确匹配
- 如果满足该回调最终判断后果,则触发 eventHandler 回调
外围实现
- genKeyFormatter:键盘输入预处理办法
- genFilterKey:判断按键是否激活
沿着上述三点,咱们来看这部分精简代码:
function useKeyPress(keyFilter: KeyFilter, eventHandler: EventHandler, option?: Options) {const { events = defaultEvents, target, exactMatch = false, useCapture = false} = option || {};
const eventHandlerRef = useLatest(eventHandler);
const keyFilterRef = useLatest(keyFilter);
// 监听元素(深比拟)useDeepCompareEffectWithTarget(() => {const el = getTargetElement(target, window);
if (!el) {return;}
// 事件回调函数
const callbackHandler = (event: KeyboardEvent) => {
// 键盘输入预处理办法
const genGuard: KeyPredicate = genKeyFormatter(keyFilterRef.current, exactMatch);
// 判断是否匹配 keyFilter 配置后果,返回 true 则触发传入的回调函数
if (genGuard(event)) {return eventHandlerRef.current?.(event);
}
};
// 监听事件(默认事件:keydown)for (const eventName of events) {el?.addEventListener?.(eventName, callbackHandler, useCapture);
}
return () => {
// 勾销监听
for (const eventName of events) {el?.removeEventListener?.(eventName, callbackHandler, useCapture);
}
};
},
[events],
target,
);
}
下面的代码看起来比拟好了解,须要斟酌的就是 genKeyFormatter
函数。
/**
* 键盘输入预处理办法
* @param [keyFilter: any] 以后键
* @returns () => Boolean
*/
function genKeyFormatter(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;
return () => Boolean(keyFilter);
}
看完发现下面的重点实现还是在 genFilterKey
函数:
- aliasKeyCodeMap
这段逻辑须要各位代入理论数值帮忙了解,如输出组合键 shift.c
/**
* 判断按键是否激活
* @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('.'); // 如 keyFilter 能够传 ctrl.alt.c,['shift.c']
let genLen = 0;
for (const key of genArr) {
// 组合键
const genModifier = modifierKey[key]; // ctrl/shift/alt/meta
// 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;
}
return genLen === genArr.length;
}
// 依据 event 计算激活键数量
function countKeyByEvent(event: KeyboardEvent) {
// 计算激活的润饰键数量
const countOfModifier = Object.keys(modifierKey).reduce((total, key) => {// (event: KeyboardEvent) => Boolean
if (modifierKey[key](event)) {return total + 1;}
return total;
}, 0);
// 16 17 18 91 92 是润饰键的 keyCode,如果 keyCode 是润饰键,那么激活数量就是润饰键的数量,如果不是,那么就须要 +1
return [16, 17, 18, 91, 92].includes(event.keyCode) ? countOfModifier : countOfModifier + 1;
}
残缺源码
useLongPress
监听指标元素的长按事件。
官网文档
根本用法
反对参数:
export interface Options {
delay?: number;
moveThreshold?: {x?: number; y?: number};
onClick?: (event: EventType) => void;
onLongPressEnd?: (event: EventType) => void;
}
官网在线 Demo
import React, {useState, useRef} from 'react';
import {useLongPress} from 'ahooks';
export default () => {const [counter, setCounter] = useState(0);
const ref = useRef<HTMLButtonElement>(null);
useLongPress(() => setCounter((s) => s + 1), ref);
return (
<div>
<button ref={ref} type="button">
Press me
</button>
<p>counter: {counter}</p>
</div>
);
};
touch 事件
- touchstart:在一个或多个触点与触控设施外表接触时被触发
- touchmove:在触点于触控立体上挪动时触发
- touchend:当触点来到触控立体时触发 touchend 事件
实现思路
- 判断以后环境是否反对 touch 事件:反对则监听
touchstart
、touchend
事件,不反对则监听mousedown
、mouseup
、mouseleave
事件 - 依据触发监听事件和定时器独特来判断是否达到长按事件,达到则触发内部回调
- 如果内部有传
moveThreshold(按下后挪动阈值)
参数,则须要监听mousemove
或touchmove
事件进行解决
外围实现
依据 [实现思路] 第一条,很容易看懂实现大抵框架代码:
type EventType = MouseEvent | TouchEvent;
// 是否反对 touch 事件
const touchSupported =
isBrowser &&
// @ts-ignore
('ontouchstart' in window || (window.DocumentTouch && document instanceof DocumentTouch));
function useLongPress(onLongPress: (event: EventType) => void,
target: BasicTarget,
{delay = 300, moveThreshold, onClick, onLongPressEnd}: Options = {},) {const onLongPressRef = useLatest(onLongPress);
const onClickRef = useLatest(onClick);
const onLongPressEndRef = useLatest(onLongPressEnd);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const isTriggeredRef = useRef(false);
// 是否有设置挪动阈值
const hasMoveThreshold = !!((moveThreshold?.x && moveThreshold.x > 0) ||
(moveThreshold?.y && moveThreshold.y > 0)
);
useEffectWithTarget(() => {const targetElement = getTargetElement(target);
if (!targetElement?.addEventListener) {return;}
const overThreshold = (event: EventType) => {};
function getClientPosition(event: EventType) {}
const onStart = (event: EventType) => {};
const onMove = (event: TouchEvent) => {};
const onEnd = (event: EventType, shouldTriggerClick: boolean = false) => {};
const onEndWithClick = (event: EventType) => onEnd(event, true);
if (!touchSupported) {
// 不反对 touch 事件
targetElement.addEventListener('mousedown', onStart);
targetElement.addEventListener('mouseup', onEndWithClick);
targetElement.addEventListener('mouseleave', onEnd);
if (hasMoveThreshold) targetElement.addEventListener('mousemove', onMove);
} else {
// 反对 touch 事件
targetElement.addEventListener('touchstart', onStart);
targetElement.addEventListener('touchend', onEndWithClick);
if (hasMoveThreshold) targetElement.addEventListener('touchmove', onMove);
}
// 卸载函数解绑监听事件
return () => {
// 革除定时器,重置状态
if (timerRef.current) {clearTimeout(timerRef.current);
isTriggeredRef.current = false;
}
if (!touchSupported) {targetElement.removeEventListener('mousedown', onStart);
targetElement.removeEventListener('mouseup', onEndWithClick);
targetElement.removeEventListener('mouseleave', onEnd);
if (hasMoveThreshold) targetElement.removeEventListener('mousemove', onMove);
} else {targetElement.removeEventListener('touchstart', onStart);
targetElement.removeEventListener('touchend', onEndWithClick);
if (hasMoveThreshold) targetElement.removeEventListener('touchmove', onMove);
}
};
},
[],
target,
);
}
对于是否反对 touch 事件的判断代码,须要理解一种场景,在搜的时候发现一篇文章能够看下:touchstart 与 click 不得不说的故事
如何判断长按事件:
- 在 onStart 设置一个定时器 setTimeout 用来判断长按工夫,在定时器回调将 isTriggeredRef.current 设置为 true,示意触发了长按事件;
- 在 onEnd 革除定时器并判断 isTriggeredRef.current 的值,true 代表触发了长按事件;false 代表没触发 setTimeout 外面的回调,则不触发长按事件。
const onStart = (event: EventType) => {timerRef.current = setTimeout(() => {
// 达到设置的长按工夫
onLongPressRef.current(event);
isTriggeredRef.current = true;
}, delay);
};
const onEnd = (event: EventType, shouldTriggerClick: boolean = false) => {
// 革除 onStart 设置的定时器
if (timerRef.current) {clearTimeout(timerRef.current);
}
// 判断是否达到长按工夫
if (isTriggeredRef.current) {onLongPressEndRef.current?.(event);
}
// 是否触发点击事件
if (shouldTriggerClick && !isTriggeredRef.current && onClickRef.current) {onClickRef.current(event);
}
// 重置
isTriggeredRef.current = false;
};
实现了 [实现思路] 的前两点,接下来须要实现第三点,传 moveThreshold
的状况
const hasMoveThreshold = !!((moveThreshold?.x && moveThreshold.x > 0) ||
(moveThreshold?.y && moveThreshold.y > 0)
);
clientX、clientY:点击地位间隔以后 body 可视区域的 x,y 坐标
const onStart = (event: EventType) => {if (hasMoveThreshold) {const { clientX, clientY} = getClientPosition(event);
// 记录首次点击 / 触屏时的地位
pervPositionRef.current.x = clientX;
pervPositionRef.current.y = clientY;
}
// ...
};
// 传 moveThreshold 需绑定 onMove 事件
const onMove = (event: TouchEvent) => {if (timerRef.current && overThreshold(event)) {
// 超过挪动阈值不触发长按事件,并革除定时器
clearInterval(timerRef.current);
timerRef.current = undefined;
}
};
// 判断是否超过挪动阈值
const overThreshold = (event: EventType) => {const { clientX, clientY} = getClientPosition(event);
const offsetX = Math.abs(clientX - pervPositionRef.current.x);
const offsetY = Math.abs(clientY - pervPositionRef.current.y);
return !!((moveThreshold?.x && offsetX > moveThreshold.x) ||
(moveThreshold?.y && offsetY > moveThreshold.y)
);
};
function getClientPosition(event: EventType) {if (event instanceof TouchEvent) {
return {clientX: event.touches[0].clientX,
clientY: event.touches[0].clientY,
};
}
if (event instanceof MouseEvent) {
return {
clientX: event.clientX,
clientY: event.clientY,
};
}
console.warn('Unsupported event type');
return {clientX: 0, clientY: 0};
}
残缺源码