前言:最近在阅读 vueuse
的源码时,发现其封装的 useEventListener
的这个 hook
挺好,本文将带着大家写一个从最基础到企业级能用的 useEventListener
,话不多说,进入正题~
1. 传统的事件绑定
来看看我们之前怎么做的事件绑定吧
我们做了这么几件事
- 创建一个
ref
- 在模板中绑定这个
ref
- 等页面挂载完毕后绑定事件
- 当
input
元素获取焦点时,控制台输出focus input
毫无问题,但是我们作为一个合格的开(she)发(chu),为了确保能在组件卸载时移除事件监听,我们不得不在完善一下代码
这次我们又做了几件事
- 用一个
cleanups
变量来存放移除事件的函数 - 推入移除事件的函数
- 触发组件卸载钩子时,调用移除事件的函数
那如果业务中绑定的事件比较多的话,是不是每个都要写 cleanup
函数比较烦呢?我们来做一层封装吧。
2. 将事件绑定封装成一个最基础的 hook
基于我们上面写的代码,我们很容易就写出这样的封装代码
这里如果有同学对 onScopeDispose
不太了解的,可以看我这篇文章-# 震惊,这是 Vue3 的 Bug 么, 暂且将它想象成 onUnmounted
即可
3. 完善它
我们上面的封装有一定的通用能力,但是还不够通用,原因有几个
- 外界需要在
onMounted
中传入真实元素,如果直接传入一个ref
是不是更加方便 - 可以省略
target
参数,默认的target
为window
即可 - 一次性只能绑定一个事件
typescript
类型不够友好,比如上面我们只考虑了htmlElement
的事件类型,window
和document
的类型我们没有考虑
接下来我们让 useEventListener
变得更健壮一点吧
3.1 参数有哪些情况
基于以上的情况,我们的参数可能是这样的
target
参数可能为一个ref
、可能一个元素、可能undefined
event
参数可能为一个事件名,可能为多个事件名listener
参数对应event
,可能为一个事件回调,可能为多个事件回调
这种参数不太确定的情况下,我们可以利用 typescript
的重载能力来完善类型,达到类型提示作用
3.2 利用 Typescript 重载能力
-
重载1,当
target
参数为空时,我们需要以下的定义typescript// target 参数为空时 + event 可能为数组 + listener 可能为数组 export function useEventListener<Event extends keyof WindowEventMap>( event: MaybeArray<Event>, listener: MaybeArray<(this: Window, event: WindowEventMap[Event]) => any>, options?: boolean | AddEventListenerOptions ): Fn type Fn = () => void type MaybeArray<T> = T | T[]
这个重载可以实现如下调用方式
typescriptuseEventListener("DOMContentLoaded", function () { }) useEventListener("DOMContentLoaded", [function () { }, function () { }]) useEventListener(["DOMContentLoaded", "focus"], function () { }) useEventListener(["DOMContentLoaded", "focus"], [function () { }, function () { }])
-
重载2,当
target
参数为window
时,我们需要映射window
上的事件名称typescript// target 参数为 window 时 + event 可能为数组 + listener 可能为数组 export function useEventListener<Event extends keyof WindowEventMap>( target: Window, event: MaybeArray<Event>, listener: MaybeArray<(this: Window, event: WindowEventMap[Event]) => any>, options?: boolean | AddEventListenerOptions ): Fn type Fn = () => void type MaybeArray<T> = T | T[]
这个重载可以实现如下调用方式
typescriptuseEventListener(window, "DOMContentLoaded", function () { }) useEventListener(window, "DOMContentLoaded", [function () { }, function () { }]) useEventListener(window, ["DOMContentLoaded", "focus"], function () { }) useEventListener(window, ["DOMContentLoaded", "focus"], [function () { }, function () { }])
-
重载3,当
target
参数为document
时,我们需要映射document
上的事件名称typescript// target 参数为 document 时 + event 可能为数组 + listener 可能为数组 export function useEventListener<Event extends keyof DocumentEventMap>( target: Document, event: MaybeArray<Event>, listener: MaybeArray<(this: Document, event: DocumentEventMap[Event]) => any>, options?: boolean | AddEventListenerOptions ): Fn type Fn = () => void type MaybeArray<T> = T | T[]
这个重载可以实现如下调用方式
typescriptuseEventListener(document, "click", function () { }) useEventListener(document, "click", [function () { }, function () { }]) useEventListener(document, ["DOMContentLoaded", "focus"], function () { }) useEventListener(document, ["DOMContentLoaded", "focus"], [function () { }, function () { }])
-
重载4,当
target
参数可能为一个ref
时,我们也需要做一层兼容typescript// target 参数可能为 ref 时 + event 可能为数组 + listener 可能为数组 export function useEventListener<Event extends keyof HTMLElementEventMap>( target: MaybeRef<HTMLElement | null | undefined>, event: MaybeArray<Event>, listener: MaybeArray<(this: HTMLElement, event: HTMLElementEventMap[Event]) => any>, options?: boolean | AddEventListenerOptions ): Fn type Fn = () => void type MaybeArray<T> = T | T[]
我们处理好了 typescript
的类型,接下来就是去实现一个通用的 useEventListener
函数了
3.3 实现 useEventListener
基于以上的重载,我们来实现 useEventListener
函数,先处理好参数的兼容性,代码如下
typescript
export function useEventListener(...args: any[]) {
let target: MaybeRef<HTMLElement> | undefined | Window | Document
let events: MaybeArray<string>
let listeners: MaybeArray<Function>
let options: boolean | AddEventListenerOptions
const noop = () => { }
if (typeof args[0] === 'string' || Array.isArray(args[0])) {
[events, listeners, options] = args
target = window
}
else {
[target, events, listeners, options] = args
}
if (!target) {
return noop
}
if (!Array.isArray(events)) {
events = [events]
}
if (!Array.isArray(listeners)) {
listeners = [listeners]
}
return () => { }
}
以上的代码,我们兼容了这些参数
- 当
target
参数为空时,我们默认取window
,否则不变 - 当
events
参数为字符串时,我们转换成数组集合,方便统一处理 - 当
listeners
参数为函数时,我们转换成数组集合,方便统一处理
接下来的代码就是水到渠成了,代码如下
typescript
export function useEventListener(...args: any[]) {
... 兼容性代码省略
const cleanups: Fn[] = []
function register(el: any, event: string, listeners: any[], options: any) {
return listeners.map(listener => {
el.addEventListener(event, listener, options)
return () => {
el.removeEventListener(event, listener, options)
}
})
}
const stopWatch = watch(() => unref(target), (el) => {
if (!el) return;
cleanups.push(
...(events as string[]).flatMap(event => register(el, event, listeners as Fn[], options))
)
}, { immediate: true, flush: "post" })
function cleanup() {
cleanups.forEach(fn => fn())
cleanups.length = 0
}
function stop() {
stopWatch()
cleanup()
}
onScopeDispose(stop)
return stop
}
这里我要说明一下
- 用
watch
监听ref
元素的变更,从而获取dom
元素 watch
初始化要执行一次,因为有可能直接传入的就是一个dom
元素watch
初始化执行的时机放到页面更新之后,从而获取最新状态下的dom
元素,可以想象成内部调用的nextTick
flatMap
方法是原生的,等同于[].map().flat(1)
3.5 容易忽略的细节
以上我们还忽略了一个细节,就是 watch
中的回调函数执行前要清除掉 cleanups
中的回调,否则可能会造成内存泄漏,比如以下代码
这是一个动态元素,绑定了相同的 ref
,因为 el
的值发生了变化,所以会触发 watch
函数执行,而在给新的 el
绑定事件时,并没有清除之前的,可以看下图
所以我们在完善一下代码,代码如下
typescript
export function useEventListener(...args: any[]) {
const cleanups: Fn[] = []
const stopWatch = watch(() => unref(target), (el) => {
cleanup() // 清除之前的函数
if (!el) return;
...
}, { immediate: true, flush: "post" })
function cleanup() {
cleanups.forEach(fn => fn())
cleanups.length = 0
}
...
return stop
}
好了,我们的 useEventListener
封装完成
4. 完整代码
附上完整代码
typescript
import { MaybeRef, onScopeDispose, Ref, ref, unref, watch } from "vue"
type Fn = () => void
type MaybeArray<T> = T | T[]
// target 参数为空时 + event 可能为数组 + listener 可能为数组
export function useEventListener<Event extends keyof WindowEventMap>(
event: MaybeArray<Event>,
listener: MaybeArray<(this: Window, event: WindowEventMap[Event]) => any>,
options?: boolean | AddEventListenerOptions
): Fn
// target 参数为 window 时 + event 可能为数组 + listener 可能为数组
export function useEventListener<Event extends keyof WindowEventMap>(
target: Window,
event: MaybeArray<Event>,
listener: MaybeArray<(this: Window, event: WindowEventMap[Event]) => any>,
options?: boolean | AddEventListenerOptions
): Fn
// target 参数为 document 时 + event 可能为数组 + listener 可能为数组
export function useEventListener<Event extends keyof DocumentEventMap>(
target: Document,
event: MaybeArray<Event>,
listener: MaybeArray<(this: Document, event: DocumentEventMap[Event]) => any>,
options?: boolean | AddEventListenerOptions
): Fn
// target 参数可能为 ref 时 + event 可能为数组 + listener 可能为数组
export function useEventListener<Event extends keyof HTMLElementEventMap>(
target: MaybeRef<HTMLElement | null | undefined>,
event: MaybeArray<Event>,
listener: MaybeArray<(this: HTMLElement, event: HTMLElementEventMap[Event]) => any>,
options?: boolean | AddEventListenerOptions
): Fn
export function useEventListener(...args: any[]) {
let target: MaybeRef<HTMLElement> | undefined | Window | Document
let events: MaybeArray<string>
let listeners: MaybeArray<Fn>
let options: boolean | AddEventListenerOptions
const noop = () => { }
if (typeof args[0] === 'string' || Array.isArray(args[0])) {
[events, listeners, options] = args
target = window
}
else {
[target, events, listeners, options] = args
}
if (!target) {
return noop
}
if (!Array.isArray(events)) {
events = [events]
}
if (!Array.isArray(listeners)) {
listeners = [listeners]
}
const cleanups: Fn[] = []
function register(el: any, event: string, listeners: any[], options: any) {
return listeners.map(listener => {
el.addEventListener(event, listener, options)
return () => {
el.removeEventListener(event, listener, options)
}
})
}
const stopWatch = watch(() => unref(target), (el) => {
cleanup() // 清除之前的函数
if (!el) return;
cleanups.push(
...(events as string[]).flatMap(event => register(el, event, listeners as Fn[], options))
)
}, { immediate: true, flush: "post" })
function cleanup() {
cleanups.forEach(fn => fn())
cleanups.length = 0
}
function stop() {
stopWatch()
cleanup()
}
onScopeDispose(stop)
return stop
}
如有错误之处,请指正,谢谢!