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

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

1. 传统的事件绑定

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

我们做了这么几件事

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

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

这次我们又做了几件事

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

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

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

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

这里如果有同学对 onScopeDispose 不太了解的,可以看我这篇文章-# 震惊,这是 Vue3 的 Bug 么, 暂且将它想象成 onUnmounted 即可

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 参数为空时,我们需要以下的定义

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

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

    typescript 复制代码
    useEventListener("DOMContentLoaded", function () { })
    useEventListener("DOMContentLoaded", [function () { }, function () { }])
    useEventListener(["DOMContentLoaded", "focus"], function () { })
    useEventListener(["DOMContentLoaded", "focus"], [function () { }, function () { }])
  2. 重载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[]

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

    typescript 复制代码
     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 上的事件名称

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

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

    typescript 复制代码
     useEventListener(document, "click", function () { })
     useEventListener(document, "click", [function () { }, function () { }])
     useEventListener(document, ["DOMContentLoaded", "focus"], function () { })
     useEventListener(document, ["DOMContentLoaded", "focus"], [function () { }, function () { }])
  4. 重载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 () => { }
}

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

  1. target 参数为空时,我们默认取 window,否则不变
  2. events 参数为字符串时,我们转换成数组集合,方便统一处理
  3. 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
}

这里我要说明一下

  1. watch 监听 ref 元素的变更,从而获取 dom 元素
  2. watch 初始化要执行一次,因为有可能直接传入的就是一个 dom 元素
  3. watch 初始化执行的时机放到页面更新之后,从而获取最新状态下的 dom 元素,可以想象成内部调用的 nextTick
  4. 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
}

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

相关推荐
customer0813 分钟前
【开源免费】基于SpringBoot+Vue.JS周边产品销售网站(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·java-ee·开源
清灵xmf14 分钟前
TypeScript 类型进阶指南
javascript·typescript·泛型·t·infer
小白学大数据20 分钟前
JavaScript重定向对网络爬虫的影响及处理
开发语言·javascript·数据库·爬虫
qq_3901617729 分钟前
防抖函数--应用场景及示例
前端·javascript
334554321 小时前
element动态表头合并表格
开发语言·javascript·ecmascript
John.liu_Test1 小时前
js下载excel示例demo
前端·javascript·excel
Yaml41 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理
PleaSure乐事1 小时前
【React.js】AntDesignPro左侧菜单栏栏目名称不显示的解决方案
前端·javascript·react.js·前端框架·webstorm·antdesignpro
哟哟耶耶1 小时前
js-将JavaScript对象或值转换为JSON字符串 JSON.stringify(this.SelectDataListCourse)
前端·javascript·json
getaxiosluo1 小时前
react jsx基本语法,脚手架,父子传参,refs等详解
前端·vue.js·react.js·前端框架·hook·jsx