前端开发者必看:SPA 中全局事件管理避坑指南——别让 window 背
- 前端开发者必看:SPA 中全局事件管理避坑指南——别让 window 背负你遗忘的监听器
- 引言:为什么全局事件在 SPA 里总让人抓耳挠腮
- 全局事件在 SPA 里的三大“作妖”现场
- Vue 阵营:组合式 API 下的“请走房客”四部曲
- 1. onMounted / onUnmounted 是最朴实的门禁
- 2. 把重复劳动封装成 useEventListener
- 3. 避免重复绑定:利用 ref 做“签到表”
- 4. KeepAlive 缓存组件的特殊处理
- React 阵营:useEffect 的“相爱相杀”
- 1. 空依赖数组 [] 不是银弹,是定时炸弹
- 2. 自定义 Hook:useGlobalEvent 一键搞定
- 3. 防止内存泄漏:热更新场景
- 性能与内存:把高频事件按在地上摩擦
- 1. resize / scroll / mousemove 请节流到 60fps
- 2. Chrome Performance 面板抓“老赖”
- 实战:跨组件全局快捷键 + 响应式 Canvas
- 场景 1:Ctrl+K 调出搜索,任意层级都能响应
- 场景 2:Canvas 随窗口大小实时缩放
- 场景 3:移动端 orientationchange + resize 双保险
- 调试:事件“失灵”时的刑侦科流程
- 进阶:打造一个可维护的“全局事件管理中心”
- 1. 集中式注册表(伪代码)
- 2. 与状态管理联动:事件驱动状态同步
- 3. 自动化测试:Jest + jsdom 也能触发 window
- 结语:善始善终,才算对 window 的温柔
前端开发者必看:SPA 中全局事件管理避坑指南——别让 window 背负你遗忘的监听器
“写代码像谈恋爱,记得善始善终;否则前任(监听器)会一直给你打骚扰电话(内存泄漏)。”
引言:为什么全局事件在 SPA 里总让人抓耳挠腮
单页应用(SPA)就像一间神奇的胶囊公寓——地址栏没变,房间却换了又换。
浏览器原生事件系统默认按“页面”分房间:刷新一次,旧房客(监听器)全部清空。
可 SPA 不刷新,只是换家具(组件),于是旧房客继续赖在 window 上喝酒打牌,偶尔还砸墙(重复触发),甚至偷偷生娃(内存泄漏)。
更尴尬的是,Vue 和 React 两位房东对“如何请走房客”理念不同:一个喜欢“生命周期”,一个信奉“副作用”。
本文就带你把这群赖房客安排得明明白白,附赠 Vue3 + React18 可直接粘贴跑的源码,能抄绝不动手。
全局事件在 SPA 里的三大“作妖”现场
路由跳走不带走眼泪,却把监听器留在原地
从/home切到/about,Home 组件早卸载了,可window.addEventListener('resize', fn)还在,于是你在 about 页面滚动时,前任的 resize 逻辑突然跑出来给你打印一堆undefined。同一件事情说两遍——StrictMode 下的“鬼打墙”
React18 的 StrictMode 会在开发环境故意把组件渲染两次,结果useEffect(() => { window.addEventListener(...) }, [])被跑了两遍,你还蒙在鼓里 。事件解绑失败——函数引用“狸猫换太子”
removeEventListener必须拿到同一只猫(同一个函数引用),可你每次在匿名箭头函数里写() => {},猫毛色都不一样,当然解不掉 。
Vue 阵营:组合式 API 下的“请走房客”四部曲
1. onMounted / onUnmounted 是最朴实的门禁
<script setup> import { onMounted, onUnmounted } from 'vue' function handleResize() { console.log('窗口变了,变心了') } onMounted(() => { window.addEventListener('resize', handleResize) }) onUnmounted(() => { window.removeEventListener('resize', handleResize) }) </script>要点:
- 函数必须具名,不能写成
() => {},否则remove时找不到人。 - 如果组件被
<KeepAlive>缓存,请改用onActivated/onDeactivated。
2. 把重复劳动封装成 useEventListener
手写一个极简版(生产可直接用@vueuse/core,这里让你看清原理):
// composables/useEventListener.tsimport{onMounted,onUnmounted,Ref}from'vue'exportfunctionuseEventListener(target:Window|Document,event:string,handler:EventListener,options?:AddEventListenerOptions){onMounted(()=>target.addEventListener(event,handler,options))onUnmounted(()=>target.removeEventListener(event,handler,options))}使用:
<script setup lang="ts"> import { useEventListener } from '@/composables/useEventListener' useEventListener(window, 'keydown', (e) => { if (e.ctrlKey && e.key === 'k') { console.log('Ctrl+K 搜索框弹出!') } }) </script>优势:
- 调用者不用再管挂载卸载,一行代码解决。
- 支持 SSR:在服务端
onMounted不会执行,无window报错风险。
3. 避免重复绑定:利用 ref 做“签到表”
某些第三方库(图表、地图)会自己 new 一个实例并偷偷绑事件,如果组件被快速创建-销毁-创建,就会重复。
解决思路:给 window 打个“已签到”标记。
constkey=Symbol('resizeOnce')onMounted(()=>{if(!(windowasany)[key]){window.addEventListener('resize',resizeFn);(windowasany)[key]=true}})或者直接把实例挂到 window,单例模式,简单粗暴。
4. KeepAlive 缓存组件的特殊处理
被<KeepAlive>包裹的组件不会走onUnmounted,要用onDeactivated清场:
onActivated(()=>window.addEventListener('scroll',scrollFn))onDeactivated(()=>window.removeEventListener('scroll',scrollFn))否则你从 A 列表页跳到 B 详情页,A 的 scroll 监听还在,滚两下就 console 狂刷 。
React 阵营:useEffect 的“相爱相杀”
1. 空依赖数组 [] 不是银弹,是定时炸弹
useEffect(() => { window.addEventListener('resize', handleResize) return () => window.removeEventListener('resize', handleResize) }, []) // 依赖为空,只绑一次看起来没毛病,但开发环境 StrictMode 会故意挂载-卸载-再挂载一次,结果第一次卸载时的 remove 把第二次刚绑的给解了,你就在控制台里怀疑人生 。
解决:把handleResize用useCallback包一层,并放进依赖数组;或者直接用下面封装的自定义 Hook。
2. 自定义 Hook:useGlobalEvent 一键搞定
// hooks/useGlobalEvent.tsimport{useEffect,useRef}from'react'exportdefaultfunctionuseGlobalEvent(event:string,handler:EventListener,options?:AddEventListenerOptions){constsavedHandler=useRef<EventListener>()// 每次渲染都把最新函数存起来,避免闭包过期savedHandler.current=handleruseEffect(()=>{constwrap=(event:Event)=>savedHandler.current?.(event)window.addEventListener(event,wrap,options)return()=>window.removeEventListener(event,wrap,options)},[event,options])}使用:
import useGlobalEvent from '@/hooks/useGlobalEvent' function SearchButton() { useGlobalEvent('keydown', (e) => { if (e.ctrlKey && e.key === 'k') { e.preventDefault() console.log('打开搜索框') } }) return <button>点我或按 Ctrl+K</button> }优点:
- 调用者不用管
useCallback、依赖数组,传最新箭头函数即可。 - 内部用
useRef保存最新引用,解决闭包 stale 问题。
3. 防止内存泄漏:热更新场景
Vite / Webpack 的 HMR 会在你改文件时偷偷替换模块,旧模块的监听器如果没清,就会越积越多。
把监听器收集到 Set 里,在import.meta.hot?.dispose时统一清理:
// 模块级代码constfns=newSet<EventListener>()exportfunctionuseGlobalEventOnce(fn:EventListener){useEffect(()=>{fns.add(fn)window.addEventListener('keydown',fn)return()=>{window.removeEventListener('keydown',fn)fns.delete(fn)}},[])}// HMR 清理if(import.meta.hot){import.meta.hot.dispose(()=>{fns.forEach((fn)=>window.removeEventListener('keydown',fn))})}开发环境下再也不用刷新浏览器手动“清内存”。
性能与内存:把高频事件按在地上摩擦
1. resize / scroll / mousemove 请节流到 60fps
// utils/throttle.tsexportfunctionthrottle<Textends(...args:any[])=>void>(fn:T,wait:number):T{letprev=0return((...args)=>{constnow=Date.now()if(now-prev>=wait){prev=nowfn(...args)}})asT}使用:
window.addEventListener('resize',throttle(()=>{console.log('我只在 200ms 区间里跑一次')},200))防抖场景(如窗口停止变化后再调接口)同理,用lodash/debounce或自己写 20 行代码即可。
2. Chrome Performance 面板抓“老赖”
- 打开 DevTools → Performance → 录制
- 疯狂操作页面(滚动、切路由)
- 停止录制,选“JS Heap”曲线,如果阶梯式上涨且不回落,大概率有未解绑事件。
- 切到“Event Listeners”面板,按类型排序,点开后能看到哪个 DOM 节点 / window 挂了多少函数,直接定位代码行 。
实战:跨组件全局快捷键 + 响应式 Canvas
场景 1:Ctrl+K 调出搜索,任意层级都能响应
Vue 版本(用 Pinia 做全局开关):
// stores/search.tsimport{defineStore}from'pinia'exportconstuseSearch=defineStore('search',{state:()=>({open:false}),actions:{toggle(){this.open=!this.open}}})// composables/useGlobalHotkey.tsimport{useSearch}from'@/stores/search'exportfunctionuseGlobalHotkey(){useEventListener(window,'keydown',(e:KeyboardEvent)=>{if(e.ctrlKey&&e.key==='k'){e.preventDefault()useSearch().toggle()}})}React 版本(Zustand 做状态):
// store.ts import create from 'zustand' export const useStore = create<{ open: boolean; toggle: () => void }>((set) => ({ open: false, toggle: () => set((s) => ({ open: !s.open })) })) // hooks/useCtrlK.ts import { useStore } from '@/store' export default function useCtrlK() { const toggle = useStore((s) => s.toggle) useGlobalEvent('keydown', (e) => { if (e.ctrlKey && e.key === 'k') { e.preventDefault() toggle() } }) }页面里任意组件只要调用useCtrlK(),就能响应快捷键,状态全局同步,无需 props 层层钻。
场景 2:Canvas 随窗口大小实时缩放
<template> <canvas ref="canvasRef" width="800" height="600"></canvas> </template> <script setup lang="ts"> import { ref, onMounted } from 'vue' import { useEventListener } from '@/composables/useEventListener' const canvasRef = ref<HTMLCanvasElement>() const ctx = ref<CanvasRenderingContext2D>() function resizeCanvas() { const el = canvasRef.value if (!el) return // 让 Canvas 物理像素等于 CSS 像素 * DPR,防止模糊 const dpr = window.devicePixelRatio || 1 const rect = el.getBoundingClientRect() el.width = rect.width * dpr el.height = rect.height * dpr ctx.value?.scale(dpr, dpr) draw() } function draw() { const c = ctx.value if (!c) return c.clearRect(0, 0, c.canvas.width, c.canvas.height) c.fillStyle = '#f77' c.fillRect(0, 0, c.canvas.width / 2, c.canvas.height / 2) } onMounted(() => { const el = canvasRef.value! ctx.value = el.getContext('2d')! resizeCanvas() }) useEventListener(window, 'resize', resizeCanvas) </script> <style scoped> canvas { width: 100%; height: 100%; } </style>关键:
- Canvas 物理尺寸与 CSS 尺寸分开,高清屏不再糊。
- 用节流包裹
resizeCanvas,200ms 一次,防止 1000 次重绘卡成 PPT。
场景 3:移动端 orientationchange + resize 双保险
// 部分老机型不会触发 orientationchange,需要 resize 兜底useEventListener(window,'orientationchange',()=>{// 转屏后 300ms 再拿尺寸,部分浏览器转屏动画没结束setTimeout(()=>{console.log('转屏后高:',window.innerHeight)},300)})useEventListener(window,'resize',throttle(()=>{console.log('resize 兜底触发')},300))调试:事件“失灵”时的刑侦科流程
事件没触发?
先console.log一路打点,确认函数没挂;再看有没有被stopPropagation截胡,特别是第三方弹窗库喜欢e.stopImmediatePropagation()。removeEventListener失效?
99% 是函数引用变了,把匿名函数提取成具名或useRef缓存,确保同一只猫。热更新后重复绑定?
在import.meta.hot?.dispose里统一解绑,或者给模块级监听器加“单例锁”Set,开发环境自动清。
进阶:打造一个可维护的“全局事件管理中心”
1. 集中式注册表(伪代码)
// eventHub.tstypeEventMap={[KinkeyofWindowEventMap]?:Set<EventListener>}constregistry:EventMap={}exportfunctionaddGlobalListener<KextendskeyofWindowEventMap>(type:K,fn:EventListener){if(!registry[type]){registry[type]=newSet()window.addEventListener(type,dispatch)}registry[type]!.add(fn)}exportfunctionremoveGlobalListener<KextendskeyofWindowEventMap>(type:K,fn:EventListener){registry[type]?.delete(fn)if(registry[type]?.size===0){window.removeEventListener(type,dispatch)deleteregistry[type]}}functiondispatch(event:Event){constlisteners=registry[event.typeaskeyofWindowEventMap]listeners?.forEach((fn)=>fn(event))}所有业务代码不再直接碰window,统一走addGlobalListener,单元测试想 mock 就 mock,谁绑谁解一目了然。
2. 与状态管理联动:事件驱动状态同步
Pinia 示例:
exportconstuseScreen=defineStore('screen',()=>{constwidth=ref(window.innerWidth)constheight=ref(window.innerHeight)addGlobalListener('resize',throttle(()=>{width.value=window.innerWidth height.value=window.innerHeight},200))return{width,height}})组件里直接useScreen().width,再也不用自己写onMounted啦。
3. 自动化测试:Jest + jsdom 也能触发 window
// test/shortcut.test.ts/** * @jest-environment jsdom */import{addGlobalListener}from'@/eventHub'test('Ctrl+K 应该触发回调',()=>{constfn=jest.fn()addGlobalListener('keydown',fn)window.dispatchEvent(newKeyboardEvent('keydown',{key:'k',ctrlKey:true}))expect(fn).toHaveBeenCalledTimes(1)})跑npm test即可,无需打开浏览器。
结语:善始善终,才算对 window 的温柔
全局事件就像恋爱:
- 表白(add)时就要想好分手(remove)的台词;
- 不要把别人的现成库当舔狗,用
useEventListener/useGlobalEvent封装好,给后人留条活路; - 性能与内存是红线,resize/scroll 必须节流;
- 最后,打开 Chrome Performance,确认没有“前任”在后台偷偷吃内存——才算真正分手快乐。
愿你的 SPA 不再被幽灵监听器纠缠,愿你的 window 永远轻如初恋。
欢迎来到我的博客,很高兴能够在这里和您见面!希望您在这里可以感受到一份轻松愉快的氛围,不仅可以获得有趣的内容和知识,也可以畅所欲言、分享您的想法和见解。
推荐:DTcode7的博客首页。
一个做过前端开发的产品经理,经历过睿智产品的折磨导致脱发之后,励志要翻身农奴把歌唱,一边打入敌人内部一边持续提升自己,为我们广大开发同胞谋福祉,坚决抵制睿智产品折磨我们码农兄弟!
| 专栏系列(点击解锁) | 学习路线(点击解锁) | 知识定位 |
|---|---|---|
| 《微信小程序相关博客》 | 持续更新中~ | 结合微信官方原生框架、uniapp等小程序框架,记录请求、封装、tabbar、UI组件的学习记录和使用技巧等 |
| 《AIGC相关博客》 | 持续更新中~ | AIGC、AI生产力工具的介绍,例如stable diffusion这种的AI绘画工具安装、使用、技巧等总结 |
| 《HTML网站开发相关》 | 《前端基础入门三大核心之html相关博客》 | 前端基础入门三大核心之html板块的内容,入坑前端或者辅助学习的必看知识 |
| 《前端基础入门三大核心之JS相关博客》 | 前端JS是JavaScript语言在网页开发中的应用,负责实现交互效果和动态内容。它与HTML和CSS并称前端三剑客,共同构建用户界面。 通过操作DOM元素、响应事件、发起网络请求等,JS使页面能够响应用户行为,实现数据动态展示和页面流畅跳转,是现代Web开发的核心 | |
| 《前端基础入门三大核心之CSS相关博客》 | 介绍前端开发中遇到的CSS疑问和各种奇妙的CSS语法,同时收集精美的CSS效果代码,用来丰富你的web网页 | |
| 《canvas绘图相关博客》 | Canvas是HTML5中用于绘制图形的元素,通过JavaScript及其提供的绘图API,开发者可以在网页上绘制出各种复杂的图形、动画和图像效果。Canvas提供了高度的灵活性和控制力,使得前端绘图技术更加丰富和多样化 | |
| 《Vue实战相关博客》 | 持续更新中~ | 详细总结了常用UI库elementUI的使用技巧以及Vue的学习之旅 |
| 《python相关博客》 | 持续更新中~ | Python,简洁易学的编程语言,强大到足以应对各种应用场景,是编程新手的理想选择,也是专业人士的得力工具 |
| 《sql数据库相关博客》 | 持续更新中~ | SQL数据库:高效管理数据的利器,学会SQL,轻松驾驭结构化数据,解锁数据分析与挖掘的无限可能 |
| 《算法系列相关博客》 | 持续更新中~ | 算法与数据结构学习总结,通过JS来编写处理复杂有趣的算法问题,提升你的技术思维 |
| 《IT信息技术相关博客》 | 持续更新中~ | 作为信息化人员所需要掌握的底层技术,涉及软件开发、网络建设、系统维护等领域的知识 |
| 《信息化人员基础技能知识相关博客》 | 无论你是开发、产品、实施、经理,只要是从事信息化相关行业的人员,都应该掌握这些信息化的基础知识,可以不精通但是一定要了解,避免日常工作中贻笑大方 | |
| 《信息化技能面试宝典相关博客》 | 涉及信息化相关工作基础知识和面试技巧,提升自我能力与面试通过率,扩展知识面 | |
| 《前端开发习惯与小技巧相关博客》 | 持续更新中~ | 罗列常用的开发工具使用技巧,如 Vscode快捷键操作、Git、CMD、游览器控制台等 |
| 《photoshop相关博客》 | 持续更新中~ | 基础的PS学习记录,含括PPI与DPI、物理像素dp、逻辑像素dip、矢量图和位图以及帧动画等的学习总结 |
| 日常开发&办公&生产【实用工具】分享相关博客》 | 持续更新中~ | 分享介绍各种开发中、工作中、个人生产以及学习上的工具,丰富阅历,给大家提供处理事情的更多角度,学习了解更多的便利工具,如Fiddler抓包、办公快捷键、虚拟机VMware等工具 |
吾辈才疏学浅,摹写之作,恐有瑕疵。望诸君海涵赐教。望轻喷,嘤嘤嘤
非常期待和您一起在这个小小的网络世界里共同探索、学习和成长。愿斯文对汝有所裨益,纵其简陋未及渊博,亦足以略尽绵薄之力。倘若尚存阙漏,敬请不吝斧正,俾便精进!