Vite 构建 Vue3 组件库之路: useEventListener Hook

420 阅读7分钟

引言

在日常开发中,我们经常会遇到需要使用事件监听器来处理各种场景的情况。然而,随着应用复杂度的增加,事件监听的管理变得愈发复杂.常见的问题包括忘记在组件卸载时移除监听器,容易导致内存泄漏,以及在需要同时监听多个事件或一个事件触发多个处理函数时,代码的冗余和可维护性问题。为了解决这些问题,我深入研究学习了 vueuse-useEventListener 的实现,并结合自己项目的具体需求,实现了一个定制化的 useEventListener Hook.

实际场景代码优化示例

在之前实现的签名组件中,存在大量重复且冗余的事件监听注册代码,如下所示:

onMounted(() => {
  drawSignaturePad();
  signaturePad.value?.addEventListener("mousedown", handleStart);
  signaturePad.value?.addEventListener("mousemove", handleMove);
  signaturePad.value?.addEventListener("mouseup", handleEnd);
  signaturePad.value?.addEventListener("mouseleave", handleEnd);
  signaturePad.value?.addEventListener("touchstart", handleStart, {
    passive: true,
  });
  signaturePad.value?.addEventListener("touchmove", handleMove, {
    passive: true,
  });
  signaturePad.value?.addEventListener("touchend", handleEnd);
});

onBeforeUnmount(() => {
  signaturePad.value?.removeEventListener("mousedown", handleStart);
  signaturePad.value?.removeEventListener("mousemove", handleMove);
  signaturePad.value?.removeEventListener("mouseup", handleEnd);
  signaturePad.value?.removeEventListener("mouseleave", handleEnd);
  signaturePad.value?.removeEventListener("touchstart", handleStart);
  signaturePad.value?.removeEventListener("touchmove", handleMove);
  signaturePad.value?.removeEventListener("touchend", handleEnd);
});

通过引入 useEventListener Hook,代码得到了显著的简化和优化:

useEventListener(
  signaturePad,
  ["mousedown", "mousemove"],
  [[handleStart], [handleMove]],
);
useEventListener(
  signaturePad,
  ["touchstart", "touchmove"],
  [[handleStart], [handleMove]],
  {
    passive: true,
  },
);
useEventListener(
  signaturePad,
  ["mouseup", "mouseleave", "touchend"],
  handleEnd,
);
onMounted(() => {
  drawSignaturePad();
});

这种优化不仅减少了代码的冗余,提高了可读性和可维护性,还降低了因手动管理事件监听器而引发的错误风险,使得签名组件的事件处理逻辑更加清晰和高效.

技术实现分析

根据MDN-addEventListener的文档,注册事件监听器需要以下属性参数:

  1. target(触发事件的对象)、
  2. type(事件类型)、`
  3. listener`(事件监听函数)
  4. options(事件监听的其他配置参数)。

vueuse-useEventListener对这些属性参数进行了良好的类型定义,并通过函数重载来支持不同类型的函数签名.

在常规开发中,我们通常会在onMountedonBeforeUnmount生命周期钩子中分别注册和移除事件监听器。然而,在复杂的组件中,如果使用v-if等指令动态控制组件的显示和隐藏,可能会在移除事件监听器时遗漏某些情况,从而导致内存泄漏或其他副作用.为了解决这一问题,vueuse-useEventListener采用了一种更为灵活的实现方式:通过watch来监听target事件对象的更新,自动注册和移除事件监听器,从而及时清除副作用.此外,它还利用了Vue的getCurrentScopeonScopeDisposeAPI,确保在effect作用域销毁时能够彻底清除副作用,并提供了一个stop函数,允许用户自定义清除副作用的时机,进一步增强了灵活性和可控性.

定制化 useEventListener 的需求分析

站在 vueuse-useEventListener 开发大佬的肩头,根据自身项目的特点和需求,对 useEventListener 进行定制化改进,以更好地满足我们的开发场景,有如下需求:

  1. events & listeners 的数组能不能1对1绑定注册
// 伪代码
useEventListener(
  signaturePad,
  ["mousedown", "mousemove"],
  [handleStart, handleMove],
  undefined, // AddEventListenerOptions
  true  // 开启一一对应
);

// mousedown - handleStart
// mousemove - handleMove
  1. listeners 是不是可以定义成元组,实现每个listener 可自定义options
// 伪代码
useEventListener(
  signaturePad,
  ["mousedown", "mousemove"],
  [[handleStart, { passive: true }], [handleMove, true]],
  undefined, // listenerOptions
  true  // 开启一一对应
);

代码实现

类型声明

import type { ComponentPublicInstance, MaybeRef, MaybeRefOrGetter } from "vue";
import { getCurrentScope, onScopeDispose, toValue, watch } from "vue";

type EventListenerTarget = MaybeRefOrGetter<
  EventTarget | HTMLElement | Window | Document | null | undefined
>;
type ArrayAble<T> = T[] | T;
type Events =
  | ArrayAble<string>
  | ArrayAble<keyof WindowEventMap>
  | ArrayAble<keyof DocumentEventMap>;
type Listeners = ArrayAble<EventListenerOrEventListenerObject>;
type ListenerAndOptions = [
  EventListenerOrEventListenerObject,
  (boolean | AddEventListenerOptions)?,
];
export default function useEventListener(
  target: EventListenerTarget,
  events: Events,
  listeners: ListenerAndOptions[] | Listeners,
  options?: boolean | AddEventListenerOptions,
  mapping?: boolean,
): () => void

具体实现

export default function useEventListener(
  target: EventListenerTarget,
  events: Events,
  listeners: ListenerAndOptions[] | Listeners,
  options?: boolean | AddEventListenerOptions,
  mapping?: boolean,
) {
  if (!target) return () => {};
  events = Array.isArray(events) ? events : [events];
  listeners = Array.isArray(listeners) ? listeners : [listeners];
  listeners = listeners.map((listener) => {
    if (Array.isArray(listener)) {
      mapping = mapping ?? true;
      return [listener[0], listener[1] ?? options];
    } else {
      mapping = mapping ?? false;
      return [listener, options];
    }
  }) as ListenerAndOptions[];

  let cleanups: (() => void)[] = [];
  const cleanup = () => {
    cleanups.forEach((fn) => fn());
    cleanups = [];
  };

  const register = (
    target: EventTarget | HTMLElement | Window | Document,
    event: keyof WindowEventMap | keyof DocumentEventMap | string,
    listener: EventListenerOrEventListenerObject,
    options?: boolean | AddEventListenerOptions,
  ) => {
    target.addEventListener(event, listener, options);
    return () => target.removeEventListener(event, listener, options);
  };

  const stopWatch = watch(
    () =>
      (
        toValue(
          target as unknown as MaybeRef<
            | HTMLElement
            | SVGElement
            | ComponentPublicInstance
            | undefined
            | null
          >,
        ) as ComponentPublicInstance
      )?.$el ?? toValue(target),

    (el) => {
      cleanup();
      if (!el) return;
      cleanups.push(
        ...events.flatMap((event, i) => {
          if (!mapping) {
            return listeners.map((listener) =>
              register(el, event, ...(listener as ListenerAndOptions)),
            );
          }
          return [register(el, event, ...(listeners[i] as ListenerAndOptions))];
        }),
      );
    },
    { immediate: true, flush: "post" },
  );

  const stop = () => {
    stopWatch();
    cleanup();
  };

  if (getCurrentScope()) {
    onScopeDispose(stop);
  }

  return stop;
}

用法

以下是使用 useEventListener 函数的多种不同场景及示例代码。在这些示例中,假设你已经正确导入了 useEventListener 函数,如下所示:

import { useEventListener } from "learnDoUI";

给一个 event 添加一个 listener

给 document 对象的 click 事件添加一个简单的监听器

const handleClick = (evt: Event) => {
  console.log(evt)
}
useEventListener(document, "click", handleClick);
// 当 click 事件被触发时,handleClick 函数将被调用,并且事件对象将作为参数传递给它。

给一个 event 添加多个 listener

为一个事件添加多个监听器,可以将这些监听器作为数组传递给 useEventListener 函数

const handleClick = (evt: Event) => {
  console.log(evt, "click handler 1");
};
const handleClick1 = (evt: Event) => {
  console.log(evt, "click handler 2");
};
useEventListener(document, "click", [handleClick, handleClick1]);
// 当 click 事件触发时,handleClick 和 handleClick1 函数将依次被调用,并且都将接收到事件对象作为参数。

给一个 event 添加一个 listener 且设置 listenerOptions

为单个监听器设置监听器选项,例如 capture、once、passive 等

const handleClick = (evt: Event) => {
  console.log(evt, "click");
};
useEventListener(document, "click", handleClick, { capture: true });
// 将 handleClick 监听器添加到 click 事件,并将 capture 选项设置为 true。这意味着事件将在捕获阶段触发该监听器

给一个 event 添加多个 listener 且设置相同 listenerOptions

为多个监听器设置相同的监听器选项

const handleClick = (evt: Event) => {
  console.log(evt, "click handler 1");
};
const handleClick1 = (evt: Event) => {
  console.log(evt, "click handler 2");
};
useEventListener(document, "click", [handleClick, handleClick1], { passive: true });
// handleClick 和 handleClick1 都将在 click 事件触发时被调用,并且它们都将使用 passive 选项,该选项可以提高性能,特别是在滚动等操作中。

给一个 event 添加多个 listener 且设置不同 listenerOptions

为多个监听器设置不同的监听器选项时,可以将监听器和选项作为元组传递

const handleClick = (evt: Event) => {
  console.log(evt, "click handler 1");
};
const handleClick1 = (evt: Event) => {
  console.log(evt, "click handler 2");
};
useEventListener(document, "click", [[handleClick, { capture: true }], [handleClick1, { once: true }]], undefined, false);
// handleClick 监听器将使用 capture 选项,而 handleClick1 监听器将使用 once 选项,意味着它只会被调用一次。

给多个 event 添加一个相同 listener

为多个事件添加相同的监听器,可以将事件列表作为数组传递


const handleEnd = (evt: Event) => {
  console.log(evt, "end event handler");
};
useEventListener(document, ["mouseup", "mouseleave"], handleEnd);
// 当 mouseup 或 mouseleave 事件触发时,handleEnd 函数将被调用

给多个 event 添加多个相同 listener

为多个事件添加多个相同的监听器,你可以将监听器列表和事件列表都作为数组传递。

const handleEnd = (evt: Event) => {
  console.log(evt, "end event handler 1");
};
const handleEnd1 = (evt: Event) => {
  console.log(evt, "end event handler 2");
};
useEventListener(document, ["mouseup", "mouseleave"], [handleEnd, handleEnd1]);
// 当 mouseup 或 mouseleave 事件触发时,handleEnd 和 handleEnd1 函数将依次被调用。

多个 event 一一对应 listener

对于多个事件和多个监听器,可以使用以下方式来实现一一对应关系。

  1. 将监听器作为元组传递,默认是一一对应关系绑定

作为元组传递且不想一一对应关系, 需要设置第四个参数为 false, 对于 listenerOptions(第三个参数) 不需要设置时要用 undefined 占位

const handleMouseup = (evt: Event) => {
  console.log(evt, "mouseup handler");
};
const handleMouseleave = (evt: Event) => {
  console.log(evt, "mouseleave handler");
};
useEventListener(document, ["mouseup", "mouseleave"], [[handleMouseup], [handleMouseleave]]);
// useEventListener(document, ["mouseup", "mouseleave"], [[handleMouseup], [handleMouseleave]], undefined, false);
  1. 监听器作为数组传递, 则需要第四个参数为 true, 对于 listenerOptions(第三个参数) 不需要设置时要用 undefined 占位
const handleMouseup = (evt: Event) => {
  console.log(evt, "mouseup handler");
};
const handleMouseleave = (evt: Event) => {
  console.log(evt, "mouseleave handler");
};
useEventListener(document, ["mouseup", "mouseleave"], [handleMouseup, handleMouseleave], undefined, true);

清理监听器

useEventListener 函数会返回一个清理函数,你可以在需要时调用它来手动清理事件监听器。

默认在绑定的 target 对象更新时,会先调用清理函数,再尝试绑定事件和如果当前 effectScope 销毁前会调用清理函数

const handleClick = (evt: Event) => {
  console.log(evt)
}
const stop = useEventListener(document, "click", handleClick);
// 当不再需要监听事件时,调用清理函数
stop();

感谢阅读,敬请斧正!