前言
本文是 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 ArrowDownuseKeyPress(40, () => { // TODO});// 监听组合按键useKeyPress('ctrl.alt.c', () => { // TODO});// 开启准确匹配。比方按 [shift + c] ,不会触发 [c]useKeyPress( ['c'], () => { // TODO }, { exactMatch: true, },);// 监听多个按键。如下 a s d f, Backspace, 8useKeyPress([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] 不会触发 [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 };}
残缺源码