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

前言:最近在阅读 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
}

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

相关推荐
醉の虾18 分钟前
Vue3 使用v-for 渲染列表数据后更新
前端·javascript·vue.js
张小小大智慧26 分钟前
TypeScript 的发展与基本语法
前端·javascript·typescript
hummhumm36 分钟前
第 22 章 - Go语言 测试与基准测试
java·大数据·开发语言·前端·python·golang·log4j
chusheng184040 分钟前
Java项目-基于SpringBoot+vue的租房网站设计与实现
java·vue.js·spring boot·租房·租房网站
asleep7011 小时前
第8章利用CSS制作导航菜单
前端·css
hummhumm1 小时前
第 28 章 - Go语言 Web 开发入门
java·开发语言·前端·python·sql·golang·前端框架
游走于计算机中摆烂的1 小时前
启动前后端分离项目笔记
java·vue.js·笔记
幼儿园的小霸王1 小时前
通过socket设置版本更新提示
前端·vue.js·webpack·typescript·前端框架·anti-design-vue
疯狂的沙粒1 小时前
对 TypeScript 中高级类型的理解?应该在哪些方面可以更好的使用!
前端·javascript·typescript
码蜂窝编程官方2 小时前
【含开题报告+文档+PPT+源码】基于SpringBoot+Vue的虎鲸旅游攻略网的设计与实现
java·vue.js·spring boot·后端·spring·旅游