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 using
addEventListeneron mounted, andremoveEventListenerautomatically on unmounted.
即:挂载时注册,卸载时自动注销。它还有三重额外的工程价值:
- 目标可以是响应式 ref:目标变化时自动先注销旧监听、再注册新监听;
- 事件与监听器都支持数组:一次调用批量订阅多个事件或目标;
- 返回清理函数:可在任意时刻手动取消监听,而不必等到组件卸载。
在 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:
| 选项 | 类型 | 说明 |
|---|---|---|
capture | boolean | 是否在捕获阶段触发 |
once | boolean | 仅触发一次后自动移除 |
passive | boolean | 声明监听器不会调用preventDefault(),滚动类事件开启可显著提升滚动性能 |
signal | AbortSignal | 通过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 |
| 2 | Window | WindowEventMap | 显式指定窗口目标 |
| 3 | Document | DocumentEventMap | visibilitychange/selectionchange |
| 4 | ShadowRoot(可空、可数组) | ShadowRootEventMap | 自定义元素内部的 Shadow DOM |
| 5 | HTMLElement(可空、可数组) | 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),仅供参考