useEventListener
useEventListener 是让优雅的使用 addEventListener 的 hook
MDN 对于 addEventListener 的解释:
EventTarget.addEventListener() 方法将指定的监听器注册到 EventTarget 上,当该对象触发指定的事件时,指定的回调函数就会被执行。
这里的 EventTarget 可以是一个文档上的元素 Element、Document 和 Window 或者任何其他支持事件的对象
useEventListener 函数通过类型重载,对 Element、Document、Window 等元素以及其事件名称和回调参数都做了定义。
function useEventListener<K extends keyof HTMLElementEventMap>(
eventName: K,
handler: (ev: HTMLElementEventMap[K]) => void,
options?: Options<HTMLElement>,
): void;
function useEventListener<K extends keyof ElementEventMap>(
eventName: K,
handler: (ev: ElementEventMap[K]) => void,
options?: Options<Element>,
): void;
function useEventListener<K extends keyof DocumentEventMap>(
eventName: K,
handler: (ev: DocumentEventMap[K]) => void,
options?: Options<Document>,
): void;
function useEventListener<K extends keyof WindowEventMap>(
eventName: K,
handler: (ev: WindowEventMap[K]) => void,
options?: Options<Window>,
): void;
function useEventListener(
eventName: string,
handler: noop,
options: Options,
): void;
然后看具体实现:
function useEventListener(eventName: string, handler: noop, options: Options = {}) {
const handlerRef = useLatest(handler);
useEffectWithTarget(
() => {
// 获取 target
const targetElement = getTargetElement(options.target, window);
if (!targetElement?.addEventListener) {
return;
}
// 事件回调
const eventListener = (event: Event) => {
return handlerRef.current(event);
};
// 监听事件
targetElement.addEventListener(eventName, eventListener, {
capture: options.capture,
once: options.once,
passive: options.passive,
});
return () => {
// 移除事件
targetElement.removeEventListener(eventName, eventListener, {
capture: options.capture,
});
};
},
[eventName, options.capture, options.once, options.passive],
options.target,
);
}
export default useEventListener;
思路也清晰明了:
- 首先根据传入的
options.target获取需要监听的对象,然后判断该对象是否支持事件监听 - 然后通过
addEventListener监听事件,然后可以传入些配置项:- capture:listener 在
事件捕获阶段传播到该 EventTarget 时触发 - once:listener 在添加之后最多
只调用一次。如果是 true,listener 会在其被调用之后自动移除。 - passive:设置为 true 时,表示 listener 永远不会调用
preventDefault(),也就是阻止默认行为。如果 listener 仍然调用了这个函数,客户端将会忽略它并抛出一个控制台警告。
- capture:listener 在
useClickAway
useClickAway 用监听目标元素外的点击事件。
根据官方文档的例子来看的话:
import React, { useState, useRef } from 'react';
import { useClickAway } from 'ahooks';
export default () => {
const [counter, setCounter] = useState(0);
const ref = useRef<HTMLButtonElement>(null);
useClickAway(() => {
setCounter((s) => s + 1);
}, ref);
return (
<div>
<button ref={ref} type="button">
box
</button>
<p>counter: {counter}</p>
</div>
);
};
目标元素是 button,当点击 button 时,不会触发 setCounter,当点击 button 以外的地方时,会触发 setCounter
这个的应用场景应该是模态框,点击外部阴影部分,自动关闭的场景。
来看看源码:
export default function useClickAway<T extends Event = Event>(
onClickAway: (event: T) => void, // 回调函数
target: BasicTarget | BasicTarget[], // 目标对象,支持多个目标对象,数组方式传入
eventName: DocumentEventKey | DocumentEventKey[] = 'click', // 支持监听多个事件,以数组方式传入
) {
const onClickAwayRef = useLatest(onClickAway);
useEffectWithTarget(
() => {
const handler = (event: any) => {
// 判断是不是监听多个对象
const targets = Array.isArray(target) ? target : [target];
if (
// 如果点击的是 targets 数组中的任何一个目标对象,就直接 return
targets.some((item) => {
const targetElement = getTargetElement(item);
return !targetElement || targetElement.contains(event.target);
})
) {
return;
}
// 如果不是,则执行回调函数
onClickAwayRef.current(event);
};
const documentOrShadow = getDocumentOrShadow(target);
// 判断是否是监听多个事件
const eventNames = Array.isArray(eventName) ? eventName : [eventName];
// 监听每一个事件, 通过事件代理的方式知道目标节点
eventNames.forEach((event) => documentOrShadow.addEventListener(event, handler));
// 组件卸载的时候清除事件监听。
return () => {
eventNames.forEach((event) => documentOrShadow.removeEventListener(event, handler));
};
},
Array.isArray(eventName) ? eventName : [eventName],
target,
);
}
思路:
- 判断是否是监听多个事件,处理成数组,然后遍历事件列表,对每一个事件进行监听(通过
事件代理的方式知道目标节点) - 然后在 handler 函数中,通过 event.target 获取到触发事件的对象 (某个 DOM 元素) 的引用,判断假如不在传入的 target 列表中,则触发定义好的 onClickAway 函数。
- 最后组件卸载时,清除事件监听
其实 useClickAway 就是使用了
事件代理的方式,通过document 监听事件,判断触发事件的 DOM 元素是否在 target 列表中,从而决定是否要触发定义好的函数
useDrop && useDrag
处理元素拖拽的 Hook。
useDrop 可以单独使用来接收文件、文字和网址的拖拽。
useDrag 允许一个 DOM 节点被拖拽,需要配合 useDrop 使用。
向节点内触发粘贴动作也会被视为拖拽
先来看 useDrag 的源码吧:
useDrag
export interface Options {
onDragStart?: (event: React.DragEvent) => void;
onDragEnd?: (event: React.DragEvent) => void;
}
const useDrag = <T>(data: T, target: BasicTarget, options: Options = {}) => {
const optionsRef = useLatest(options);
const dataRef = useLatest(data);
useEffectWithTarget(
() => {
// 获取需要拓展的目标元素
const targetElement = getTargetElement(target);
if (!targetElement?.addEventListener) {
return;
}
// 开始拖拽回调
const onDragStart = (event: React.DragEvent) => {
optionsRef.current.onDragStart?.(event);
event.dataTransfer.setData('custom', JSON.stringify(dataRef.current));
};
// 结束拖拽回调
const onDragEnd = (event: React.DragEvent) => {
optionsRef.current.onDragEnd?.(event);
};
// 需要设置 draggable = true 才能拖拽
targetElement.setAttribute('draggable', 'true');
// 监听 dragstart、dragend 事件
targetElement.addEventListener('dragstart', onDragStart as any);
targetElement.addEventListener('dragend', onDragEnd as any);
// 组件卸载时移除事件监听
return () => {
targetElement.removeEventListener('dragstart', onDragStart as any);
targetElement.removeEventListener('dragend', onDragEnd as any);
};
},
[],
target,
);
};
export default useDrag;
思路:
- 获取需要拖拽的目标元素对象
- 监听
dragstart、dragend事件 - 回调调用 optionsRef.current.onDragStart、optionsRef.current.onDragEnd
useDrop
useDrop 就是去监听 dragenter、dragover、dragleave、drop、paste 事件,进行特定的处理。其中在 drop 和 paste 事件中,获取到 DataTransfer 数据,并根据数据类型进行特定的处理。
主函数内容如下
// useDrop 可以单独使用来接收文件、文字和网址的拖拽。
const useDrop = (target: BasicTarget, options: Options = {}) => {
const optionsRef = useLatest(options);
// https://stackoverflow.com/a/26459269
const dragEnterTarget = useRef<any>();
useEffectWithTarget(
() => {
const targetElement = getTargetElement(target);
if (!targetElement?.addEventListener) {
return;
}
const onData = (
dataTransfer: DataTransfer,
event: React.DragEvent | React.ClipboardEvent,
) => {};
const onDragEnter = (event: React.DragEvent) => {};
const onDragOver = (event: React.DragEvent) => {};
const onDragLeave = (event: React.DragEvent) => {};
const onDrop = (event: React.DragEvent) => {};
const onPaste = (event: React.ClipboardEvent) => {
// DataTransfer 对象用于保存拖动并放下(drag and drop)过程中的数据。它可以保存一项或多项数据,这些数据项可以是一种或者多种数据类型。关于拖放的更多信息,请参见 Drag and Drop.
onData(event.clipboardData, event);
// 粘贴内容的回调
optionsRef.current.onPaste?.(event);
};
targetElement.addEventListener('dragenter', onDragEnter as any);
targetElement.addEventListener('dragover', onDragOver as any);
targetElement.addEventListener('dragleave', onDragLeave as any);
targetElement.addEventListener('drop', onDrop as any);
targetElement.addEventListener('paste', onPaste as any);
return () => {
targetElement.removeEventListener('dragenter', onDragEnter as any);
targetElement.removeEventListener('dragover', onDragOver as any);
targetElement.removeEventListener('dragleave', onDragLeave as any);
targetElement.removeEventListener('drop', onDrop as any);
targetElement.removeEventListener('paste', onPaste as any);
};
},
[],
target,
);
};
然后监听的 5 个事件的处理分别如下:
const onDragEnter = (event: React.DragEvent) => {
// 阻止默认事件
event.preventDefault();
// 阻止事件冒泡
event.stopPropagation();
dragEnterTarget.current = event.target;
// 拖拽进入
optionsRef.current.onDragEnter?.(event);
};
const onDragOver = (event: React.DragEvent) => {
event.preventDefault();
// 拖拽中
optionsRef.current.onDragOver?.(event);
};
const onDragLeave = (event: React.DragEvent) => {
if (event.target === dragEnterTarget.current) {
// 拖拽出去
optionsRef.current.onDragLeave?.(event);
}
};
const onDrop = (event: React.DragEvent) => {
event.preventDefault();
onData(event.dataTransfer, event);
// 拖拽任意内容的回调
optionsRef.current.onDrop?.(event);
};
const onPaste = (event: React.ClipboardEvent) => {
// DataTransfer 对象用于保存拖动并放下(drag and drop)过程中的数据。它可以保存一项或多项数据,这些数据项可以是一种或者多种数据类型。关于拖放的更多信息,请参见 Drag and Drop.
onData(event.clipboardData, event);
// 粘贴内容的回调
optionsRef.current.onPaste?.(event);
};
其中在 drop 和 paste 事件中,获取到 DataTransfer 数据,调用 onData 方法,并根据数据类型进行特定的处理。
const onData = (
dataTransfer: DataTransfer,
event: React.DragEvent | React.ClipboardEvent,
) => {
const uri = dataTransfer.getData('text/uri-list');
const dom = dataTransfer.getData('custom');
// 拖拽/粘贴自定义 DOM 节点的回调
if (dom && optionsRef.current.onDom) {
let data = dom;
try {
data = JSON.parse(dom);
} catch (e) {
data = dom;
}
optionsRef.current.onDom(data, event as React.DragEvent);
return;
}
// 拖拽/粘贴链接的回调
if (uri && optionsRef.current.onUri) {
optionsRef.current.onUri(uri, event as React.DragEvent);
return;
}
// 拖拽/粘贴文件的回调
if (
dataTransfer.files &&
dataTransfer.files.length &&
optionsRef.current.onFiles
) {
optionsRef.current.onFiles(
Array.from(dataTransfer.files),
event as React.DragEvent,
);
return;
}
// 拖拽/粘贴文字的回调
if (
dataTransfer.items &&
dataTransfer.items.length &&
optionsRef.current.onText
) {
dataTransfer.items[0].getAsString(text => {
optionsRef.current.onText!(text, event as React.ClipboardEvent);
});
}
};
useEventTarget
常见表单控件(通过 e.target.value 获取表单值) 的 onChange 跟 value 逻辑封装,支持自定义值转换和重置功能。
可以理解成把表单控件输入的逻辑多了层封装
interface EventTarget<U> {
target: {
value: U;
};
}
export interface Options<T, U> {
initialValue?: T; //初始值
transformer?: (value: U) => T; //转换函数
}
function useEventTarget<T, U = T>(options?: Options<T, U>) {
// 记录初始值、转换函数
const { initialValue, transformer } = options || {};
const [value, setValue] = useState(initialValue);
const transformerRef = useLatest(transformer);
// 利用闭包,重置为初始值
const reset = useCallback(() => setValue(initialValue), []);
const onChange = useCallback((e: EventTarget<U>) => {
// 拿到表单控件的值
const _value = e.target.value;
// 如果有转换函数,调用转换函数处理 _value
if (isFunction(transformerRef.current)) {
return setValue(transformerRef.current(_value));
}
// no transformer => U and T should be the same
return setValue(_value as unknown as T);
}, []);
return [
value,
{
onChange,
reset,
},
] as const;
}
export default useEventTarget;
思路:
- 记录初始值、转换函数
- 定义
reset函数,利用闭包,重置 value 为 初始值 - 定义
onChange函数,内部通过transformer转换函数处理表单控件的值 - 然后返回 [value, { onChange, reset }]
很简单的
useExternal
useExternal 动态注入 JS 或 CSS 资源,useExternal 可以保证资源全局唯一。
其实现原理创建 link 标签加载 CSS 资源或者 script 标签加载 JS 资源。通过 document.createElement 返回 Element 对象,监听该对象获取加载状态。
源码分为三部分:
// 加载 JS
const loadScript = () => {}
// 加载 CSS
const loadCss = () => {}
// 主函数
const useExternal = () => {}
其中,主函数中判断加载 CSS 还是 JS 资源
// 省略部分代码
const pathname = path.replace(/[|#].*$/, '');
// 判断是 CSS 类型
if (
options?.type === 'css' ||
(!options?.type && /(^css!|\.css$)/.test(pathname))
) {
const result = loadCss(path, options?.css);
ref.current = result.ref;
setStatus(result.status);
// 判断是是 JavaScript 类型
} else if (
options?.type === 'js' ||
(!options?.type && /(^js!|\.js$)/.test(pathname))
) {
const result = loadScript(path, options?.js);
ref.current = result.ref;
setStatus(result.status);
} else {
// do nothing
console.error(
"Cannot infer the type of external resource, and please provide a type ('js' | 'css'). " +
'Refer to the https://ahooks.js.org/hooks/dom/use-external/#options',
);
}
然后来看看 loadCss 方法,加载 CSS
const loadCss = (path: string, props = {}): loadResult => {
// 创建 link 标签
const css = document.querySelector(`link[href="${path}"]`);
if (!css) {
const newCss = document.createElement('link');
// 设置 ref、href 属性
newCss.rel = 'stylesheet';
newCss.href = path;
// 设置相应的属性
Object.keys(props).forEach((key) => {
newCss[key] = props[key];
});
// IE9+
const isLegacyIECss = 'hideFocus' in newCss;
// use preload in IE Edge (to detect load errors)
// preload 预加载
if (isLegacyIECss && newCss.relList) {
newCss.rel = 'preload';
newCss.as = 'style';
}
// 正在加载中
newCss.setAttribute('data-status', 'loading');
// 插入 head 标签里面
document.head.appendChild(newCss);
return {
ref: newCss,
status: 'loading',
};
}
// 有则直接返回,并取 data-status 中的值
return {
ref: css,
status: (css.getAttribute('data-status') as Status) || 'ready',
};
};
然后是 loadScript,加载 JS:
// 加载 Script
const loadScript = (path: string, props = {}): loadResult => {
// 看是否已经加载
const script = document.querySelector(`script[src="${path}"]`);
if (!script) {
// 创建标签
const newScript = document.createElement('script');
// 设置 src
newScript.src = path;
// 设置属性值
Object.keys(props).forEach(key => {
newScript[key] = props[key];
});
// 设置 loading 状态
newScript.setAttribute('data-status', 'loading');
// 加到 document.body 中
document.body.appendChild(newScript);
return {
ref: newScript,
status: 'loading',
};
}
return {
ref: script,
// 状态
status: (script.getAttribute('data-status') as Status) || 'ready',
};
};
最后,主函数中,监听 Element 的 load 和 error 事件,判断其加载状态:
const handler = (event: Event) => {
// 判断和设置加载状态
const targetStatus = event.type === 'load' ? 'ready' : 'error';
ref.current?.setAttribute('data-status', targetStatus);
setStatus(targetStatus);
};
// 监听文件下载的情况
ref.current.addEventListener('load', handler);
ref.current.addEventListener('error', handler);
useTitle
useTitle 用于设置页面标题,这个页面标题指的是浏览器 Tab 中展示的。通过 document.title 设置。
function useTitle(title: string, options: Options = DEFAULT_OPTIONS) {
// 存储初始值的 title
const titleRef = useRef(isBrowser ? document.title : '');
useEffect(() => {
document.title = title;
}, [title]);
useUnmount(() => {
// 组件卸载后,恢复上一次的 title
if (options.restoreOnUnmount) {
document.title = titleRef.current;
}
});
}
useFavicon
useFavicon 设置页面的 favicon。也就是这个
原理是通过 link 标签设置 favicon。
const ImgTypeMap = {
SVG: 'image/svg+xml',
ICO: 'image/x-icon',
GIF: 'image/gif',
PNG: 'image/png',
};
type ImgTypes = keyof typeof ImgTypeMap;
const useFavicon = (href: string) => {
useEffect(() => {
if (!href) return;
const cutUrl = href.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];
// 设置 url
link.href = href;
// 此属性命名链接文档与当前文档的关系,没见过这玩意儿,百度这样说的
link.rel = 'shortcut icon';
// 然后传入 head 标签里面
document.getElementsByTagName('head')[0].appendChild(link);
}, [href]);
};
useFullscreen
useFullscreen 管理 DOM 全屏的 hook
该 hook 主要是依赖 screenfull 这个 npm 包进行实现的。
主函数主要定义了下面三个方法:
const useFullscreen = () => {
// 断是否是全屏,从而触发进入全屏的函数或者退出全屏的函数
const onScreenfullChange = () = {}
// 进入全屏
const enterFullscreen = () => {}
// 退出全屏
const exitFullscreen = () => {}
// 切换全屏展示
const toggleFullscreen = () => {}
// 组件写宅时,移除 onScreenfullChange 事件
useUnmount(() => {
if (screenfull.isEnabled && !pageFullscreen) {
screenfull.off('change', onScreenfullChange);
}
});
}
其中 onScreenfullChange 方法:
const onScreenfullChange = useMemoizedFn(() => {
if (screenfull.isEnabled) {
const el = getTargetElement(target);
if (!screenfull.element) {
invokeCallback(false);
setState(false);
screenfull.off('change', onScreenfullChange);
} else {
const isFullscreen = screenfull.element === el;
invokeCallback(isFullscreen);
setState(isFullscreen);
}
}
});
enterFullscreen 方法:手动进入全屏函数,支持传入 ref 设置需要全屏的元素。并通过 screenfull.request 进行设置,并监听 change 事件。
// 进入全屏
const enterFullscreen = () => {
const el = getTargetElement(target);
if (!el) {
return;
}
if (screenfull.isEnabled) {
try {
screenfull.request(el);
screenfull.on('change', onChange);
} catch (error) {
console.error(error);
}
}
};
exitFullscreen 方法:
// 退出全屏
const exitFullscreen = () => {
if (!state) {
return;
}
if (screenfull.isEnabled) {
screenfull.exit();
}
};
toggleFullscreen 方法:根据当前状态,调用上面两个方法,达到切换全屏状态的效果。
// 切换模式
const toggleFullscreen = () => {
if (state) {
exitFullscreen();
} else {
enterFullscreen();
}
};
结尾
不想写了 还有 9 个 DOM 相关的 hook 下次再写