这个系列是将 ahooks 里面的所有 hook 源码都进行解读,通过解读 ahooks 的源码来熟悉自定义 hook 的写法,提高自己写自定义 hook 的能力,希望能够对大家有所帮助。
为了和代码原始注释区分,个人理解部分使用 ///开头,此处和 三斜线指令没有关系,只是为了做区分。
往期回顾
- ahooks 源码解读系列
- ahooks 源码解读系列 - 2
- ahooks 源码解读系列 - 3
- ahooks 源码解读系列 - 4
- ahooks 源码解读系列 - 5
- ahooks 源码解读系列 - 6
- ahooks 源码解读系列 - 7
- ahooks 源码解读系列 - 8
- ahooks 源码解读系列 - 9
- ahooks 源码解读系列 - 10
- ahooks 源码解读系列 - 11
- ahooks 源码解读系列 - 12
愉快的周二来临,大家应该比较有精神了吧、
今天将一次性看完 Dom 部分的剩余 hook ,谢谢拔亢前来阅读🙏~
useTextSelection
划词翻译可能会用到
/// ...
const initRect: IRect = {
top: NaN,
left: NaN,
bottom: NaN,
right: NaN,
height: NaN,
width: NaN,
};
const initState: IState = {
text: '',
...initRect,
};
function getRectFromSelection(selection: Selection | null): IRect {
if (!selection) {
return initRect;
}
if (selection.rangeCount < 1) {
return initRect;
}
const range = selection.getRangeAt(0);
const { height, width, top, left, right, bottom } = range.getBoundingClientRect();
return {
height,
width,
top,
left,
right,
bottom,
};
}
/**
* 获取用户选取的文本或当前光标插入的位置
* */
function useTextSelection(target?: BasicTarget): IState {
const [state, setState] = useState(initState);
const stateRef = useRef(state);
stateRef.current = state;
useEffect(() => {
// 获取 target 需要放在 useEffect 里,否则存在组件未加载好的情况而导致元素获取不到
const el = getTargetElement(target, document);
if (!el) {
return () => {};
}
const mouseupHandler = () => {
let selObj: Selection | null = null;
let text = '';
let rect = initRect;
if (!window.getSelection) return;
selObj = window.getSelection();
text = selObj ? selObj.toString() : '';
if (text) { /// 选取了内容之后才设置最新的选取数据信息
rect = getRectFromSelection(selObj);
setState({ ...state, text, ...rect });
}
};
// 任意点击都需要清空之前的 range
const mousedownHandler = () => {
/// window.getSelection 是 hook 生效的前置条件,可以放在最前面,这样就不用绑定无用的事件了
if (!window.getSelection) return;
if (stateRef.current.text) {
setState({ ...initState });
}
const selObj = window.getSelection();
if (!selObj) return;
selObj.removeAllRanges();
};
el.addEventListener('mouseup', mouseupHandler);
document.addEventListener('mousedown', mousedownHandler);
return () => {
el.removeEventListener('mouseup', mouseupHandler);
document.removeEventListener('mousedown', mousedownHandler);
};
}, [typeof target === 'function' ? undefined : target]);
return state;
}
export default useTextSelection;
useHover、useMouse、useDocumentVisibility、useInViewport、useResponsive
一些dom状态标识、dom数据获取的hook
通过监听 mouseenter 、mouseleve事件,实时更新 hover 状态值
/// ...
export default (target: BasicTarget, options?: Options): boolean => {
const { onEnter, onLeave } = options || {};
const [state, { setTrue, setFalse }] = useBoolean(false);
useEventListener(
'mouseenter',
() => {
onEnter && onEnter();
setTrue();
},
{
target,
},
);
useEventListener(
'mouseleave',
() => {
onLeave && onLeave();
setFalse();
},
{
target,
},
);
return state;
};
通过监听 mousemove 事件,实时更新鼠标坐标数据
/// ...
export default () => {
const [state, setState] = useState(initState);
useEventListener(
'mousemove',
(event: MouseEvent) => {
const { screenX, screenY, clientX, clientY, pageX, pageY } = event;
setState({ screenX, screenY, clientX, clientY, pageX, pageY });
},
{
target: document,
},
);
return state;
};
通过监听 visibilitychange 事件,实时更新文档可见状态值
export default function canUseDom() {
return !!(typeof window !== 'undefined' && window.document && window.document.createElement);
}
/// ...
const getVisibility = () => {
if (!canUseDom()) return 'visible';
return document.visibilityState;
};
function useDocumentVisibility(): VisibilityState {
const [documentVisibility, setDocumentVisibility] = useState(() => getVisibility());
useEventListener(
'visibilitychange',
() => {
setDocumentVisibility(getVisibility());
},
{
target: () => document, /// 这里不用方法直接使用 document 应该也是一样的
},
);
return documentVisibility;
}
export default useDocumentVisibility;
通过 IntersectionObserver api,实时更新是否在视口中的状态值
import { useEffect, useState } from 'react';
import 'intersection-observer';
import { getTargetElement, BasicTarget } from '../utils/dom';
type InViewport = boolean | undefined;
function isInViewPort(el: HTMLElement): InViewport {
if (!el) {
return undefined;
}
const viewPortWidth =
window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
const viewPortHeight =
window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
const rect = el.getBoundingClientRect();
/// 只要上下左右有任意一边完全出了视口,就认为不在视口中
if (rect) {
const { top, bottom, left, right } = rect;
return bottom > 0 && top <= viewPortHeight && left <= viewPortWidth && right > 0;
}
/// rect 没可能为空,所以没必要对 rect 进行判空 https://developer.mozilla.org/zh-CN/docs/Web/API/Element/getBoundingClientRect
return false;
}
function useInViewport(target: BasicTarget): InViewport {
const [inViewPort, setInViewport] = useState<InViewport>(() => {
const el = getTargetElement(target);
return isInViewPort(el as HTMLElement);
});
useEffect(() => {
const el = getTargetElement(target);
if (!el) {
return () => {};
}
/// 使用 IntersectionObserver 监听目标和视口的相交状态,从而更新是否在视口的标识
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setInViewport(true);
} else {
setInViewport(false);
}
}
});
observer.observe(el as HTMLElement);
return () => {
observer.disconnect();
};
}, [target]);
return inViewPort;
}
export default useInViewport;
通过监听 window 的 resize 事件,实时更新相应式数据
import { useEffect, useState } from 'react';
type Subscriber = () => void;
const subscribers = new Set<Subscriber>();
interface ResponsiveConfig {
[key: string]: number;
}
interface ResponsiveInfo {
[key: string]: boolean;
}
let info: ResponsiveInfo;
/// 默认的断点数据
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();
}
/// 使用了 listening、subscribers 来减少对 window 的事件绑定
export function useResponsive() {
const windowExists = typeof window !== 'undefined';
/// 使用 listening 全局标识,防止多个 useResponsive 重复注册事件
if (windowExists && !listening) {
info = {};
calculate();
window.addEventListener('resize', handleResize);
listening = true;
}
const [state, setState] = useState<ResponsiveInfo>(info);
useEffect(() => {
if (!windowExists) return;
const subscriber = () => {
setState(info);
};
/// 使用 subscribers 收集每一个 useResponsive 的状态变更操作方法
subscribers.add(subscriber);
return () => {
subscribers.delete(subscriber);
/// 如果 subscribers 已经清空,则说明所有的 useResponsive 都已经卸载了,可以移除事件了
if (subscribers.size === 0) {
window.removeEventListener('resize', handleResize);
listening = false;
}
};
}, []);
return state;
}
useExternal
“一键换肤了解一下”
/// ...
export default function useExternal(path: string, options?: Options): [Status, Action] {
const isPath = typeof path === 'string' && path !== '';
const [status, setStatus] = useState<Status>(isPath ? 'loading' : 'unset');
const [active, setActive] = useState(isPath);
const ref = useRef<ExternalElement>();
useEffect(() => {
/// 先将之前的资源卸载掉
ref.current?.remove();
if (!isPath || !active) {
setStatus('unset');
ref.current = undefined;
return;
}
setStatus('loading');
// Create external element
const pathname = path.replace(/[|#].*$/, '');
/// 不同类型的资源,使用不同的方式加载
if (options?.type === 'css' || /(^css!|\.css$)/.test(pathname)) {
// css
ref.current = document.createElement('link');
ref.current.rel = 'stylesheet';
ref.current.href = path;
ref.current.media = options?.media || 'all';
// IE9+
let isLegacyIECss = 'hideFocus' in ref.current;
// use preload in IE Edge (to detect load errors)
if (isLegacyIECss && ref.current.relList) {
ref.current.rel = 'preload';
ref.current.as = 'style';
}
ref.current.setAttribute('data-status', 'loading');
document.head.appendChild(ref.current);
} else if (options?.type === 'js' || /(^js!|\.js$)/.test(pathname)) {
// javascript
ref.current = document.createElement('script');
ref.current.src = path;
ref.current.async = options?.async === undefined ? true : options?.async;
ref.current.setAttribute('data-status', 'loading');
document.body.appendChild(ref.current);
} else if (options?.type === 'img' || /(^img!|\.(png|gif|jpg|svg|webp)$)/.test(pathname)) {
// image
ref.current = document.createElement('img');
ref.current.src = path;
ref.current.setAttribute('data-status', 'loading');
// append to wrapper
const wrapper = (getTargetElement(options?.target) as HTMLElement) || document.body;
if (wrapper) {
wrapper.appendChild(ref.current);
}
}else{
// do nothing
console.error(
"Cannot infer the type of external resource, and please provide a type ('js' | 'css' | 'img'). " +
"Refer to the https://ahooks.js.org/hooks/dom/use-external/#options"
)
}
if(!ref.current) return
// Bind setAttribute Event
const setAttributeFromEvent = (event: Event) => {
ref.current?.setAttribute('data-status', event.type === 'load' ? 'ready' : 'error');
};
ref.current.addEventListener('load', setAttributeFromEvent);
ref.current.addEventListener('error', setAttributeFromEvent);
const setStateFromEvent = (event: Event) => {
setStatus(event.type === 'load' ? 'ready' : 'error');
};
ref.current.addEventListener('load', setStateFromEvent);
ref.current.addEventListener('error', setStateFromEvent);
return () => {
ref.current?.removeEventListener('load', setStateFromEvent);
ref.current?.removeEventListener('error', setStateFromEvent);
};
}, [path, active]);
const action = useMemo(() => {
const unload = () => setActive(false);
const load = () => setActive(true);
const toggle = () => setActive((value) => !value);
return { toggle, load, unload };
}, [setActive]);
return [status, action];
}
useFavicon
import { useEffect } from 'react';
// image/vnd.microsoft.icon MIME类型只有当图像真的是ICO文件时才会起作用
// image/x-icon 会同时也适用于位图与GIF
// 主要是为了兼容扩展名为ico的非ico文件
const ImgTypeMap = {
SVG: 'image/svg+xml',
ICO: 'image/x-icon',
GIF: 'image/gif',
PNG: 'image/png',
};
type ImgTypes = keyof typeof ImgTypeMap;
const useFavicon = (favUrl: string) => {
useEffect(() => {
if (!favUrl) return;
const cutUrl = favUrl.split('.');
const imgSuffix = cutUrl[cutUrl.length - 1].toLocaleUpperCase() as ImgTypes;
const link: HTMLLinkElement =
document.querySelector("link[rel*='icon']") || document.createElement('link');
link.type = ImgTypeMap[imgSuffix];
link.href = favUrl;
// 大部分浏览器只会识别'icon' 只有IE会识别整个名称'shortcut icon'
link.rel = 'shortcut icon';
document.getElementsByTagName('head')[0].appendChild(link);
}, [favUrl]);
};
export default useFavicon;
useFullscreen
screenfull 插件的封装
import { useCallback, useRef, useState } from 'react';
import screenfull from 'screenfull';
import useUnmount from '../useUnmount';
import { BasicTarget, getTargetElement } from '../utils/dom';
export interface Options {
onExitFull?: () => void;
onFull?: () => void;
}
export default (target: BasicTarget, options?: Options) => {
const { onExitFull, onFull } = options || {};
/// 将事件用 ref 存储起来
const onExitFullRef = useRef(onExitFull);
onExitFullRef.current = onExitFull;
const onFullRef = useRef(onFull);
onFullRef.current = onFull;
const [state, setState] = useState(false);
const onChange = useCallback(() => {
if (screenfull.isEnabled) {
const { isFullscreen } = screenfull;
if (isFullscreen) {
onFullRef.current && onFullRef.current();
} else {
/// 在 change 事件回调中调用 off 而不是在 exitFull 方法中直接调用时因为那会退出操作才刚开始,还没有完成
screenfull.off('change', onChange);
onExitFullRef.current && onExitFullRef.current();
}
setState(isFullscreen);
}
}, []);
const setFull = useCallback(() => {
const el = getTargetElement(target);
if (!el) {
return;
}
/// 如果支持全屏,则设为全屏,并监听全屏状态变更事件
if (screenfull.isEnabled) {
try {
screenfull.request(el as HTMLElement);
screenfull.on('change', onChange);
} catch (error) {}
}
}, [target, onChange]);
const exitFull = useCallback(() => {
if (!state) {
return;
}
/// 直接退出全屏,清理监听事件在监听事件内部处理了
if (screenfull.isEnabled) {
screenfull.exit();
}
}, [state]);
const toggleFull = useCallback(() => {
if (state) {
exitFull();
} else {
setFull();
}
}, [state, setFull, exitFull]);
useUnmount(() => {
if (screenfull.isEnabled) {
screenfull.off('change', onChange);
}
});
return [
state,
{
setFull,
exitFull,
toggleFull,
},
] as const;
};
useTitle
操作 title 的语法糖
import { useEffect, useRef } from 'react';
import useUnmount from '../useUnmount';
export interface Options {
restoreOnUnmount?: boolean;
}
const DEFAULT_OPTIONS: Options = {
restoreOnUnmount: false,
};
function useTitle(title: string, options: Options = DEFAULT_OPTIONS) {
/// 使用ref存储变更前的title
const titleRef = useRef(document.title);
useEffect(() => {
document.title = title;
}, [title]);
useUnmount(() => {
if (options && options.restoreOnUnmount) {
/// 如果配置了需要还原,则在组件卸载时还原
document.title = titleRef.current;
}
});
}
export default typeof document !== 'undefined' ? useTitle : () => {};
参考资料
以上内容由于本人水平问题难免有误,欢迎大家进行讨论反馈。