前言
本文是 ahooks 源码(v3.7.4)系列的第五篇,也是 DOM 篇的完结篇,往期文章:
- 【解读 ahooks 源码系列】(开篇)如何获取和监听 DOM 元素:useEffectWithTarget
- 【解读 ahooks 源码系列】DOM 篇(一):useEventListener、useClickAway、useDocumentVisibility、useDrop、useDrag
- 【解读 ahooks 源码系列】DOM 篇(二):useEventTarget、useExternal、useTitle、useFavicon、useFullscreen、useHover
- 【解读 ahooks 源码系列】DOM 篇(三):useMutationObserver、useInViewport、useKeyPress、useLongPress
本文次要解读 useMouse
、useResponsive
、useScroll
、useSize
、useFocusWithin
的源码实现
useMouse
监听鼠标地位。
官网文档
根本用法
API:
const state: { screenX: number, // 间隔显示器左侧的间隔 screenY: number, // 间隔显示器顶部的间隔 clientX: number, // 间隔以后视窗左侧的间隔 clientY: number, // 间隔以后视窗顶部的间隔 pageX: number, // 间隔残缺页面左侧的间隔 pageY: number, // 间隔残缺页面顶部的间隔 elementX: number, // 间隔指定元素左侧的间隔 elementY: number, // 间隔指定元素顶部的间隔 elementH: number, // 指定元素的高 elementW: number, // 指定元素的宽 elementPosX: number, // 指定元素间隔残缺页面左侧的间隔 elementPosY: number, // 指定元素间隔残缺页面顶部的间隔} = useMouse(target?: Target);
官网在线 Demo
import React, { useRef } from 'react';import { useMouse } from 'ahooks';export default () => { const ref = useRef(null); const mouse = useMouse(ref.current); return ( <> <div ref={ref} style={{ width: '200px', height: '200px', backgroundColor: 'gray', color: 'white', lineHeight: '200px', textAlign: 'center', }} > element </div> <div> <p> Mouse In Element - x: {mouse.elementX}, y: {mouse.elementY} </p> <p> Element Position - x: {mouse.elementPosX}, y: {mouse.elementPosY} </p> <p> Element Dimensions - width: {mouse.elementW}, height: {mouse.elementH} </p> </div> </> );};
外围实现
实现原理:通过监听 mousemove 办法,获取鼠标的地位。通过 getBoundingClientRect(提供了元素的大小及其绝对于视口的地位) 获取到 target 元素的地位大小,计算出鼠标绝对于元素的地位。
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; // window.pageXOffset:window.scrollX 的别名 newState.elementPosY = top + window.pageYOffset; // scrollY 的别名 newState.elementX = pageX - newState.elementPosX; newState.elementY = pageY - newState.elementPosY; newState.elementW = width; newState.elementH = height; } setState(newState); }, { target: () => document, }, ); return state;};
残缺源码
useResponsive
获取响应式信息。
官网文档
根本用法
官网在线 Demo
import React from 'react';import { configResponsive, useResponsive } from 'ahooks';configResponsive({ small: 0, middle: 800, large: 1200,});export default function () { const responsive = useResponsive(); return ( <> <p>Please change the width of the browser window to see the effect: </p> {Object.keys(responsive).map((key) => ( <p key={key}> {key} {responsive[key] ? '✔' : '✘'} </p> ))} </> );}
实现思路
- 监听 resize 事件,在 resize 事件处理函数中须要计算,且判断是否须要更新解决(性能优化)。
- 计算:遍历比照
window.innerWidth
与配置项的每一种屏幕宽度,大于设置为 true,否则为 false
外围实现
type Subscriber = () => void;const subscribers = new Set<Subscriber>();type ResponsiveConfig = Record<string, number>;type ResponsiveInfo = Record<string, boolean>;let info: ResponsiveInfo;// 默认的响应式配置和 bootstrap 是统一的let responsiveConfig: ResponsiveConfig = { xs: 0, sm: 576, md: 768, lg: 992, xl: 1200,};function handleResize() { const oldInfo = info; calculate(); if (oldInfo === info) return; // 没有更新,不解决 for (const subscriber of subscribers) { subscriber(); }}let listening = false; // 防止屡次监听// 计算以后的屏幕宽度与配置比拟function calculate() { const width = window.innerWidth; // 返回窗口的的宽度 const newInfo = {} as ResponsiveInfo; let shouldUpdate = false; // 判断是否须要更新 for (const key of Object.keys(responsiveConfig)) { newInfo[key] = width >= responsiveConfig[key]; if (newInfo[key] !== info[key]) { shouldUpdate = true; } } if (shouldUpdate) { info = newInfo; }}// 自定义配置响应式断点(只需配置一次)export function configResponsive(config: ResponsiveConfig) { responsiveConfig = config; if (info) calculate();}export function useResponsive() { if (isBrowser && !listening) { info = {}; calculate(); 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; } }; }, []); return state;}
残缺源码
useScroll
监听元素的滚动地位。
官网文档
根本用法
官网在线 Demo,下方代码的执行后果
import React, { useRef } from 'react';import { useScroll } from 'ahooks';export default () => { const ref = useRef(null); const scroll = useScroll(ref); return ( <> <p>{JSON.stringify(scroll)}</p> <div style={{ height: '160px', width: '160px', border: 'solid 1px #000', overflow: 'scroll', whiteSpace: 'nowrap', fontSize: '32px', }} ref={ref} > <div> Lorem ipsum dolor sit amet, consectetur adipisicing elit. A aspernatur atque, debitis ex excepturi explicabo iste iure labore molestiae neque optio perspiciatis </div> <div> Aspernatur cupiditate, deleniti id incidunt mollitia omnis! A aspernatur assumenda consequuntur culpa cumque dignissimos enim eos, et fugit natus nemo nesciunt </div> <div> Alias aut deserunt expedita, inventore maiores minima officia porro rem. Accusamus ducimus magni modi mollitia nihil nisi provident </div> <div> Alias aut autem consequuntur doloremque esse facilis id molestiae neque officia placeat, quia quisquam repellendus reprehenderit. </div> <div> Adipisci blanditiis facere nam perspiciatis sit soluta ullam! Architecto aut blanditiis, consectetur corporis cum deserunt distinctio dolore eius est exercitationem </div> <div>Ab aliquid asperiores assumenda corporis cumque dolorum expedita</div> <div> Culpa cumque eveniet natus totam! Adipisci, animi at commodi delectus distinctio dolore earum, eum expedita facilis </div> <div> Quod sit, temporibus! Amet animi fugit officiis perspiciatis, quis unde. Cumque dignissimos distinctio, dolor eaque est fugit nisi non pariatur porro possimus, quas quasi </div> </div> </> );};
外围实现
function useScroll( target?: Target, // DOM 节点或者 ref shouldUpdate: ScrollListenController = () => true, // 管制是否更新滚动信息): Position | undefined { const [position, setPosition] = useRafState<Position>(); const shouldUpdateRef = useLatest(shouldUpdate); // 管制是否更新滚动信息,默认值: () => true 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; // 滚动容器以后的滚动地位}
接下来看看updatePosition
办法的实现:
const updatePosition = () => { let newPosition: Position; // target属性传 document if (el === document) { // scrollingElement 返回滚动文档的 Element 对象的援用。 // 在规范模式下,这是文档的根元素, document.documentElement。 // 当在怪异模式下,scrollingElement 属性返回 HTML body 元素(若不存在返回 null) if (document.scrollingElement) { newPosition = { left: document.scrollingElement.scrollLeft, top: document.scrollingElement.scrollTop, }; } else { // 怪异模式的解决:取 window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop 三者中最大值 // 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 { newPosition = { left: (el as Element).scrollLeft, // 获取滚动条到元素右边的间隔(滚动条滚动了多少像素) top: (el as Element).scrollTop, }; } // 判断是否更新滚动信息 if (shouldUpdateRef.current(newPosition)) { setPosition(newPosition); }};
- Element.scrollLeft 获取滚动条到元素右边的间隔
- Element.scrollTop 获取滚动条到元素顶部的间隔
useSize
监听 DOM 节点尺寸变动的 Hook。
官网文档
根本用法
官网在线 Demo
import React, { useRef } from 'react';import { useSize } from 'ahooks';export default () => { const ref = useRef(null); const size = useSize(ref); return ( <div ref={ref}> <p>Try to resize the preview window </p> <p> width: {size?.width}px, height: {size?.height}px </p> </div> );};
外围实现
这里波及 ResizeObserver
源码较容易了解,就不开展了
// 指标 DOM 节点的尺寸type Size = { width: number; height: number };function useSize(target: BasicTarget): Size | undefined { const [state, setState] = useRafState<Size>(); useIsomorphicLayoutEffectWithTarget( () => { const el = getTargetElement(target); if (!el) { return; } // Resize Observer API 提供了一种高性能的机制,通过该机制,代码能够监督元素的大小更改,并且每次大小更改时都会向观察者传递告诉 const resizeObserver = new ResizeObserver((entries) => { entries.forEach((entry) => { // 返回 DOM 节点的尺寸 const { clientWidth, clientHeight } = entry.target; setState({ width: clientWidth, height: clientHeight, }); }); }); // 监听指标元素 resizeObserver.observe(el); return () => { resizeObserver.disconnect(); }; }, [], target, ); return state;}
残缺源码
useFocusWithin
监听以后焦点是否在某个区域之内,同 css 属性: focus-within
官网文档
根本用法
官网在线 Demo
应用 ref 设置须要监听的区域。能够通过鼠标点击内部区域,或者应用键盘的 tab 等按键来切换焦点。
import React, { useRef } from 'react';import { useFocusWithin } from 'ahooks';import { message } from 'antd';export default () => { const ref = useRef(null); const isFocusWithin = useFocusWithin(ref, { onFocus: () => { message.info('focus'); }, onBlur: () => { message.info('blur'); }, }); return ( <div> <div ref={ref} style={{ padding: 16, backgroundColor: isFocusWithin ? 'red' : '', border: '1px solid gray', }} > <label style={{ display: 'block' }}> First Name: <input /> </label> <label style={{ display: 'block', marginTop: 16 }}> Last Name: <input /> </label> </div> <p>isFocusWithin: {JSON.stringify(isFocusWithin)}</p> </div> );};
外围实现
次要还是监听了 focusin 和 focusout 事件
- focusin:当元素聚焦时会触发。和 focus 一样,只是 focusin 事件反对冒泡;
- focusout:当元素行将失去焦点时会被触发。和 blur 一样,只是 focusout 事件反对冒泡。
触发程序:
在同时反对四种事件的浏览器中,当焦点在两个元素之间切换时,触发程序如下(不同浏览器成果可能不同):
- focusin 在第一个指标元素取得焦点前触发
- focus 在第一个指标元素取得焦点后触发
- focusout 第一个指标失去焦点时触发
- focusin 第二个元素取得焦点前触发
- blur 第一个元素失去焦点时触发
- focus 第二个元素取得焦点后触发
参考:focus/blur VS focusin/focusout
MouseEvent.relatedTarget
属性返回与触发鼠标事件的元素相干的元素:
export default function useFocusWithin(target: BasicTarget, options?: Options) { const [isFocusWithin, setIsFocusWithin] = useState(false); const { onFocus, onBlur, onChange } = options || {}; // 监听 focusin 事件 useEventListener( 'focusin', (e: FocusEvent) => { if (!isFocusWithin) { onFocus?.(e); onChange?.(true); setIsFocusWithin(true); } }, { target, }, ); // 监听 focusout 事件 useEventListener( 'focusout', (e: FocusEvent) => { // relatedTarget 属性返回与触发鼠标事件的元素相干的元素。 // https://developer.mozilla.org/zh-CN/docs/Web/API/MouseEvent/relatedTarget if (isFocusWithin && !(e.currentTarget as Element)?.contains?.(e.relatedTarget as Element)) { onBlur?.(e); onChange?.(false); setIsFocusWithin(false); } }, { target, }, ); return isFocusWithin; // 焦点是否在以后区域}
残缺源码