☰
Airi 实战:VueUse useEventListener 响应式事件监听完整指南
2026/9/25 12:18:49 网站建设 项目流程

Airi 实战:VueUse useEventListener 响应式事件监听完整指南

【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi

导读

useEventListener是 VueUse 提供的浏览器事件监听组合式函数(composable),它把原生addEventListener/removeEventListener的成对注册与销毁封装为一条声明式调用:组件挂载时自动注册监听,卸载时自动移除,无需手动管理清理逻辑。在本仓库(Airi,自托管的 AI 陪伴应用)的 Web 端、Electron 端与共享 UI 组件库中,useEventListener被大量用于聊天滚动跟随、颜色选择器拖拽、舞台模型资源回收等场景。读完本文,你将掌握useEventListener的全部调用形态(默认/显式/响应式目标、单/多事件、单/多目标)、清理时机、SSR 兼容写法,并结合本仓库源码理解其真实工程用法。

一、为什么需要 useEventListener

原生写法要求开发者手动配对addEventListener与removeEventListener,并保证在组件卸载时移除监听,否则会留下事件泄漏(listener leak),导致回调在组件销毁后仍被触发。useEventListener的核心职责正如其在仓库中的技能文档 useEventListener.md 所描述:

Register usingaddEventListeneron mounted, andremoveEventListenerautomatically on unmounted.

即:挂载时注册,卸载时自动注销。它还有三重额外的工程价值:

  1. 目标可以是响应式 ref:目标变化时自动先注销旧监听、再注册新监听;
  2. 事件与监听器都支持数组:一次调用批量订阅多个事件或目标;
  3. 返回清理函数:可在任意时刻手动取消监听,而不必等到组件卸载。

在 VueUse 的函数体系中,它属于 Browser 分类(见 SKILL.md),调用规则为AUTO——即在 Vue/Nuxt 项目中凡是适合的场景都应优先使用它替代手写监听代码。

二、基本用法

最简单的调用方式与原生addEventListener一致:传入目标、事件名和回调函数。

import { useEventListener } from '@vueuse/core' useEventListener(document, 'visibilitychange', (evt) => { console.log(evt) })

visibilitychange事件会在页面切换到后台/前台时触发,常用于暂停/恢复后台任务。回调收到的evt参数具有完整类型推断:由于目标是Document,事件类型自动收窄为DocumentEventMap中的对应事件对象。

三、默认目标:省略时监听 window

当第一个参数不是目标而是事件名时,目标默认为window:

import { useEventListener } from '@vueuse/core' // Listens on window useEventListener('resize', (evt) => { console.log(evt) })

这是最常用的省略形式,因为全局窗口事件(resize、scroll、mousemove、keydown、unload等)占据了事件监听的很大比例。省略目标后类型推断依然精确——事件名会按WindowEventMap收窄。

仓库中的两个典型例证:

  • 在舞台模型设置 Store 中,用默认目标监听页面卸载事件以回收blob:对象 URL(见 stage-model.ts):
useEventListener('unload', () => { revokeStageModelUrl(stageModelSelectedUrl.value) })
  • 在颜色选择器中,于onMounted内以默认目标监听全局鼠标/触摸移动来实现拖拽跟手(见 color-picker.vue):
onMounted(() => { useEventListener('mousemove', handleGlobalMove, { passive: false }) useEventListener('mouseup', handleGlobalEnd) useEventListener('touchmove', handleGlobalMove, { passive: false }) useEventListener('touchend', handleGlobalEnd) })

四、响应式目标(Reactive Target)

目标参数不仅可以是Document、Window、HTMLElement等具体对象,还可以是一个 ref(或 getter)。当目标 ref 的值发生变化时,useEventListener会自动注销旧目标上的监听、在新目标上注册监听——这正是它区别于手写代码的关键能力。

配合 Vue 3.5 的useTemplateRef与v-if/v-else条件渲染,可以优雅地实现"监听当前活跃元素":

<script setup lang="ts"> import { useEventListener } from '@vueuse/core' import { useTemplateRef } from 'vue' const element = useTemplateRef('element') useEventListener(element, 'keydown', (e) => { console.log(e.key) }) </script> <template> <div v-if="cond" ref="element"> Div1 </div> <div v-else ref="element"> Div2 </div> </template>

当cond变化导致 DOM 分支切换时,elementref 会先后指向 Div1 与 Div2,监听也随之迁移。

本仓库的聊天滚动跟随 composable 正是利用了这一点——它把滚动容器本身作为响应式目标传入(见 use-chat-history-scroll.ts)。容器 ref 变化(例如虚拟列表重建 DOM)时,所有监听自动迁移到新容器,无需手动处理:

useEventListener(container, 'scroll', () => { // 判断是否仍贴近尾部,决定是否跟随滚动 }, { passive: true }) useEventListener(container, ['wheel', 'touchmove'], () => { hasUserScrollIntent = true }, { passive: true })

更进一步的技巧:把computed 派生出的文档对象也作为响应式目标。同一个 composable 中用computed(() => container.value?.ownerDocument)得到当前容器所属的 document,再监听其selectionchange(见 use-chat-history-scroll.ts):

const selectionDocument = computed(() => container.value?.ownerDocument) useEventListener(selectionDocument, 'selectionchange', () => { const selection = selectionDocument.value?.getSelection() isSelectionInOlderMessage = isOlderMessageItem(findMessageItem(selection?.anchorNode ?? null)) })

五、多事件监听(Multiple Events)

第二个参数支持数组,一次性为同一目标注册多个事件,回调中通过evt.type区分具体事件:

import { useEventListener } from '@vueuse/core' useEventListener(document, ['mouseenter', 'mouseleave'], (evt) => { console.log(evt.type) })

这在"同类交互、统一处理"的场景下非常实用。仓库中的聊天滚动 composable 用一个调用同时监听滚轮与触摸移动两种"用户滚动意图"信号(见 use-chat-history-scroll.ts):

useEventListener(container, ['wheel', 'touchmove'], () => { hasUserScrollIntent = true }, { passive: true })

六、多目标监听(Multiple Targets)

第一个参数同样可以传入目标数组,为每个目标分别注册监听:

import { useEventListener } from '@vueuse/core' const buttons = document.querySelectorAll('button') useEventListener(buttons, 'click', (evt) => { console.log('Button clicked') })

NodeList也满足Arrayable的约束,可直接传入。该能力配合响应式目标使用时可实现"监听一组动态变化的元素"。

七、options 参数:被动监听与性能

第三个参数(options)与原生addEventListener的选项一致,支持boolean或AddEventListenerOptions:

选项类型说明
captureboolean是否在捕获阶段触发
onceboolean仅触发一次后自动移除
passiveboolean声明监听器不会调用preventDefault(),滚动类事件开启可显著提升滚动性能
signalAbortSignal通过AbortController批量取消监听

仓库中所有滚动、滚轮、触摸类监听都显式传入了{ passive: true }(见 use-chat-history-scroll.ts、use-element-scroll.ts),避免浏览器因无法预知preventDefault而降低滚动合成效率。

而需要调用preventDefault()的场景(如颜色选择器的拖拽,需要阻止文本选择与默认触摸滚动)则必须显式传{ passive: false }(见 color-picker.vue):

useEventListener('mousemove', handleGlobalMove, { passive: false })

八、手动清理(Cleanup)

useEventListener返回一个清理函数,调用后立即注销监听,无需等待组件卸载:

import { useEventListener } from '@vueuse/core' const cleanup = useEventListener(document, 'keydown', (e) => { console.log(e.key) }) cleanup() // This will unregister the listener.

这一能力让"按需切换监听目标"变得非常简洁。仓库的useElementScrollcomposable 展示了它的典型用法:每次滚动目标变化时,先调用上一次返回的清理函数,再注册新监听(见 use-element-scroll.ts):

function bindScrollTarget(target: HTMLElement | null | undefined) { stopScrollListener.value?.() // 注销旧监听 stopScrollListener.value = null scrollOffset.value = target?.scrollTop ?? 0 if (!target) return stopScrollListener.value = useEventListener(target, 'scroll', () => { scrollOffset.value = target.scrollTop updateElementBounds() updateScrollViewportBounds() }, { passive: true }) }

随后在watchEffect中随目标变化反复调用bindScrollTarget,并在onBeforeUnmount中兜底清理(见 use-element-scroll.ts):

onBeforeUnmount(() => { stopScrollListener.value?.() })

九、SSR 注意事项

如果组件还会在服务端渲染(SSR)环境中执行,直接使用document/window会抛出document is not defined之类的错误,因为 Node.js 环境没有这些 DOM API。最稳妥的做法是把监听逻辑放进onMounted钩子——该钩子只在客户端调用,能保证 DOM API 可用:

import { useEventListener } from '@vueuse/core' // onMounted will only be called in the client side // so it guarantees the DOM APIs are available. onMounted(() => { useEventListener(document, 'keydown', (e) => { console.log(e.key) }) })

注意:useEventListener本身不会在服务端抛错,当目标为null/undefined时它会安全跳过注册,因此真正需要防护的是"在 setup 顶层直接引用document/window变量"这类写法。仓库中颜色选择器把全局监听放在onMounted中(见 color-picker.vue),正是这一最佳实践的体现。

十、类型系统与重载(Type Declarations)

useEventListener通过 7 组 TypeScript 重载覆盖全部调用形态,声明位于 useEventListener.md 的 Type Declarations 一节。核心类型包括:

interface InferEventTarget<Events> { addEventListener: (event: Events, fn?: any, options?: any) => any removeEventListener: (event: Events, fn?: any, options?: any) => any } export type WindowEventName = keyof WindowEventMap export type DocumentEventName = keyof DocumentEventMap export type ShadowRootEventName = keyof ShadowRootEventMap export interface GeneralEventListener<E = Event> { (evt: E): void }

各重载的适用范围:

重载目标参数事件类型推断来源典型场景
1省略(默认window)WindowEventMap全局resize/keydown/unload
2WindowWindowEventMap显式指定窗口目标
3DocumentDocumentEventMapvisibilitychange/selectionchange
4ShadowRoot(可空、可数组)ShadowRootEventMap自定义元素内部的 Shadow DOM
5HTMLElement(可空、可数组)HTMLElementEventMap元素级scroll/click/pointerover
6自定义事件目标InferEventTarget<Names>由泛型Names extends string推断类 EventTarget 的第三方对象
7兜底EventTarget回退为通用Event无法静态推断的通用目标

值得注意的通用约束:

  • 目标参数统一为MaybeRefOrGetter<Arrayable<T> | null | undefined>——既可以是 ref/getter,也可以是数组,还可以为null/undefined(为 null 时安全跳过,这在元素尚未挂载时非常关键);
  • 事件名为MaybeRefOrGetter<Arrayable<E>>,事件本身也可响应式变化;
  • 监听器为MaybeRef<Arrayable<...>>,回调自身同样可以是响应式的;
  • options为MaybeRefOrGetter<boolean | AddEventListenerOptions>,即passive等选项也能动态更新。

回调的this绑定按目标类型精确收窄(Window/Document/HTMLElement),配合事件名泛型E extends keyof XxxEventMap,能让无效事件名在编译期直接报错,这也是用 TypeScript 约束 DOM 事件的典型范式。

十一、在测试中如何隔离 useEventListener

由于useEventListener在组合式函数/Store 初始化时就会注册全局监听,单元测试中通常将其 mock 掉,避免测试环境真实挂载 DOM 监听。仓库的 stage-model.test.ts 展示了标准做法:

vi.mock('@vueuse/core', async (importOriginal) => { const actual = await importOriginal<typeof import('@vueuse/core')>() return { ...actual, useEventListener: vi.fn(), } })

通过importOriginal保留@vueuse/core其余导出,仅将useEventListener替换为vi.fn()。这样在测试 Pinia Store 的逻辑(如舞台模型缺失时的回退、Tachie 渲染器路由)时,unload监听不会真实作用于测试进程。

十二、实战总结:一个完整的监听场景拆解

综合以上能力,本仓库的聊天历史滚动 composable(use-chat-history-scroll.ts)几乎用遍了useEventListener的全部特性,可作为参考样板:

调用形态用途
useEventListener(container, 'scroll', fn, { passive: true })响应式目标 + 被动监听,判断用户是否贴近消息尾部
useEventListener(container, ['wheel', 'touchmove'], fn, { passive: true })多事件数组,统一收集滚动意图
useEventListener(container, 'keydown', fn)键盘翻页/方向键也视为滚动意图
useEventListener(container, ['pointerover', 'pointerout', 'focusin', 'focusout'], fn)指针与焦点悬停旧消息时暂停自动滚动
useEventListener(selectionDocument, 'selectionchange', fn)以 computed 派生 document 为响应式目标

加上颜色选择器的{ passive: false }拖拽场景、useElementScroll的返回清理函数场景、Store 中默认window目标场景,可以看到:凡是原本需要"注册 + 注销"成对出现的事件逻辑,useEventListener都能以更简洁、更不易泄漏的声明式方式替代,这正是本仓库将它列为AUTO级(自动优先选用)组合式函数的原因。

延伸阅读

  • 函数技能总览:.agents/skills/vueuse-functions/SKILL.md
  • 关联文档原文:.agents/skills/vueuse-functions/references/useEventListener.md
  • 聊天滚动实战:use-chat-history-scroll.ts
  • 滚动目标切换实战:use-element-scroll.ts
  • 拖拽与全局监听实战:color-picker.vue
  • Store 中回收资源实战:stage-model.ts
  • 测试 mock 方式:stage-model.test.ts

【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询