likes
comments
collection
share

来,封装一个企业级的事件监听器!

作者站长头像
站长
· 阅读数 16

前言:最近在阅读 vueuse 的源码时,发现其封装的 useEventListener 的这个 hook 挺好,本文将带着大家写一个从最基础到企业级能用的 useEventListener,话不多说,进入正题~

1. 传统的事件绑定

来看看我们之前怎么做的事件绑定吧

来,封装一个企业级的事件监听器!

我们做了这么几件事

  1. 创建一个 ref
  2. 在模板中绑定这个 ref
  3. 等页面挂载完毕后绑定事件
  4. input 元素获取焦点时,控制台输出 focus input

毫无问题,但是我们作为一个合格的开(she)发(chu),为了确保能在组件卸载时移除事件监听,我们不得不在完善一下代码

来,封装一个企业级的事件监听器!

这次我们又做了几件事

  1. 用一个 cleanups 变量来存放移除事件的函数
  2. 推入移除事件的函数
  3. 触发组件卸载钩子时,调用移除事件的函数

那如果业务中绑定的事件比较多的话,是不是每个都要写 cleanup 函数比较烦呢?我们来做一层封装吧。

2. 将事件绑定封装成一个最基础的 hook

基于我们上面写的代码,我们很容易就写出这样的封装代码

来,封装一个企业级的事件监听器!

3. 完善它

我们上面的封装有一定的通用能力,但是还不够通用,原因有几个

  1. 外界需要在 onMounted 中传入真实元素,如果直接传入一个 ref 是不是更加方便
  2. 可以省略 target 参数,默认的 targetwindow 即可
  3. 一次性只能绑定一个事件
  4. typescript 类型不够友好,比如上面我们只考虑了 htmlElement 的事件类型, windowdocument 的类型我们没有考虑

接下来我们让 useEventListener 变得更健壮一点吧

3.1 参数有哪些情况

基于以上的情况,我们的参数可能是这样的

  1. target 参数可能为一个 ref、可能一个元素、可能 undefined
  2. event 参数可能为一个事件名,可能为多个事件名
  3. listener 参数对应 event,可能为一个事件回调,可能为多个事件回调

这种参数不太确定的情况下,我们可以利用 typescript 的重载能力来完善类型,达到类型提示作用

3.2 利用 Typescript 重载能力

  1. 重载1,当 target 参数为空时,我们需要以下的定义

    // 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[]
    

    这个重载可以实现如下调用方式

    useEventListener("DOMContentLoaded", function () { })
    useEventListener("DOMContentLoaded", [function () { }, function () { }])
    useEventListener(["DOMContentLoaded", "focus"], function () { })
    useEventListener(["DOMContentLoaded", "focus"], [function () { }, function () { }])
    
  2. 重载2,当 target 参数为 window 时,我们需要映射 window 上的事件名称

    // 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[]
    

    这个重载可以实现如下调用方式

     useEventListener(window, "DOMContentLoaded", function () { })
     useEventListener(window, "DOMContentLoaded", [function () { }, function () { }])
     useEventListener(window, ["DOMContentLoaded", "focus"], function () { })
     useEventListener(window, ["DOMContentLoaded", "focus"], [function () { }, function () { }])
    
  3. 重载3,当 target 参数为 document 时,我们需要映射 document 上的事件名称

    // 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[]
    

    这个重载可以实现如下调用方式

     useEventListener(document, "click", function () { })
     useEventListener(document, "click", [function () { }, function () { }])
     useEventListener(document, ["DOMContentLoaded", "focus"], function () { })
     useEventListener(document, ["DOMContentLoaded", "focus"], [function () { }, function () { }])
    
  4. 重载4,当 target 参数可能为一个 ref 时,我们也需要做一层兼容

    // 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 函数,先处理好参数的兼容性,代码如下

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 () => { }
}

以上的代码,我们兼容了这些参数

  1. target 参数为空时,我们默认取 window,否则不变
  2. events 参数为字符串时,我们转换成数组集合,方便统一处理
  3. listeners 参数为函数时,我们转换成数组集合,方便统一处理

接下来的代码就是水到渠成了,代码如下

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
}

这里我要说明一下

  1. watch 监听 ref 元素的变更,从而获取 dom 元素
  2. watch 初始化要执行一次,因为有可能直接传入的就是一个 dom 元素
  3. watch 初始化执行的时机放到页面更新之后,从而获取最新状态下的 dom 元素,可以想象成内部调用的 nextTick
  4. flatMap 方法是原生的,等同于 [].map().flat(1)

3.5 容易忽略的细节

以上我们还忽略了一个细节,就是 watch 中的回调函数执行前要清除掉 cleanups 中的回调,否则可能会造成内存泄漏,比如以下代码

来,封装一个企业级的事件监听器!

这是一个动态元素,绑定了相同的 ref,因为 el 的值发生了变化,所以会触发 watch 函数执行,而在给新的 el 绑定事件时,并没有清除之前的,可以看下图

来,封装一个企业级的事件监听器!

所以我们在完善一下代码,代码如下

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. 完整代码

附上完整代码

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
}

如有错误之处,请指正,谢谢!