
做前端的人早晚都会碰到需要监听窗口变化的场景。菜单要折叠、表格要重排、图表要重绘、弹窗要居中这些需求背后躲不开同一个入口——JavaScript里的resize事件。今天我不打算简单贴一段addEventListener的代码就完事而是把这个事件从触发原理、性能隐患、防抖节流到实际项目里的图表重绘、iframe内嵌、移动端适配全都捋一遍顺便把我这几年在真实项目里踩过的坑一并列出来。这篇文章适合刚接触JavaScript的初学者也适合已经写过不少监听逻辑、但想系统梳理一遍的开发者。1. resize事件到底在监听什么先搞懂浏览器窗口的“变化”1.1 触发机制与底层逻辑resize事件从字面上理解就是“尺寸变化时触发”但很多初学者会忽略一个关键点它监听的不是页面元素的尺寸变化而是浏览器窗口视口viewport尺寸的变化。当用户手动拖拽窗口边缘、切换显示器分辨率、在浏览器里按Ctrl加号缩放页面、或者打开开发者工具时视口宽度和高度发生变化window对象就会派发一个resize事件。这个定义决定了它的使用边界。你如果想让一个div自己感知宽高变化去调整内部布局直接用window.resize是做不到的那是ResizeObserver的活后面我会专门讲到。凡是跟着窗口整体尺寸走的需求比如页面整体布局切换、图表画布重绘、导航栏展开收起才真正属于resize事件的职责范围。还有一个细节容易被忽略resize事件只在window对象上派发document和普通DOM元素上虽然有onresize但实际行为并不一致不能混用。标准写法就是把监听器挂在window上这样浏览器窗口任何尺寸变化都能捕获到。1.2 高频触发带来的性能隐患resize事件和click、mouseover这类事件有个本质区别它是典型的高频连续触发事件。用户拖拽窗口右下角那半秒钟事件可能触发几十次在Windows系统里按住窗口边缘连续缩放触发频率可以轻松达到每秒60次以上跟你屏幕刷新率差不多。如果回调函数里只打印一句日志还好一旦你写了复杂的DOM计算、重排布局、操作canvas重绘、或者发请求页面就会明显卡顿。这是resize事件最常见的使用陷阱也是它区别于其他事件的核心知识点。后面第4节要讲的防抖和节流就是为了解决这个问题存在的。2. 基础用法三种写法与绑定时机2.1 标准addEventListener写法现在主流浏览器都支持标准事件绑定方式直接给window挂上监听器就行。window.addEventListener(resize, () { console.log(窗口尺寸变了); });要点是回调函数里的逻辑别写太多保持轻量。如果需要在resize时读取窗口尺寸用window.innerWidth和window.innerHeight这两个属性返回当前视口的宽高是配合resize事件最常用的参数。window.addEventListener(resize, () { const width window.innerWidth; const height window.innerHeight; console.log(当前视口${width} x ${height}); });这种写法最干净也最容易在后续移除监听时控制。2.2 传统onresize写法与兼容性早期项目里经常看到直接把函数赋给window.onresize的写法window.onresize function() { console.log(窗口变了); };这种写法在IE6时代就存在兼容性自然没话说但有一个致命缺点赋值会覆盖之前的处理器。如果你在A模块里绑了一个onresizeB模块又绑了一个前者就被覆盖了。而addEventListener是追加注册可以挂多个回调且互不影响还能精确移除。所以在现代项目里我基本不用onresize赋值除非你要快速写个一次性调试代码。真要兼容已经淘汰的IE8那就得用attachEvent不过现在这已经属于考古场景新人了解一下就行。2.3 绑定时机与初始化调用resize监听器什么时候绑定也有讲究。两种常见场景页面加载后立刻绑定直接放在script标签底部或者放在DOMContentLoaded事件里。因为监听器本身只关心窗口不依赖某个DOM元素是否存在所以可以很早绑定。组件化框架里在mounted或useEffect里绑定在beforeDestroy或useEffect的cleanup函数里移除。这个要做到配对出现否则会内存泄漏后面排查章节会详细说。另一个被很多人忽略的细节初始化时手动调用一次回调函数。resize事件只在尺寸变化后触发页面刚加载时是不会自动执行回调的。如果你在resize回调里写了布局重排逻辑例如让容器高度撑满剩余视口那么首次进入页面必须手动调用一次否则首屏布局就是错的。function handleResize() { const height window.innerHeight; document.getElementById(main).style.height ${height - 80}px; } window.addEventListener(resize, handleResize); handleResize(); // 手动触发一次保证首屏正确这个小动作虽然简单但在实际项目中能避免不少“为什么刷新之后布局是错的”的困惑。3. 核心细节尺寸获取、断点判断与移动端陷阱3.1 innerWidth、clientWidth、outerWidth到底怎么选resize回调拿到窗口尺寸最常见的目标是window.innerWidth和window.innerHeight。但当你用得多了会发现相关属性很多容易混淆。简单说一下区别window.innerWidth/innerHeight视口尺寸包含可见区域的宽高包括垂直滚动条占的宽度。document.documentElement.clientWidth/clientHeight也是视口尺寸但不包含滚动条宽度。window.outerWidth/outerHeight整个浏览器窗口外部尺寸包括边框、工具栏、标签栏等移动端浏览器里这个值通常等于屏幕尺寸。screen.width/screen.height物理屏幕分辨率。window.devicePixelRatio设备像素比Retina屏幕上等于2或者3做canvas高清绘制时必须用它。平时判断页面显示区域宽高用innerWidth和innerHeight就够。如果你关心可视内容是否被滚动条挤压想精确到内容区宽度就用clientWidth。这两者在Windows浏览器上经常差17像素因为Windows滚动条默认常驻。我实际遇到过一个案例用innerWidth做断点判断结果在宽屏显示器上展开滚动条时断点触发临界抖动页面在两种布局之间来回横跳。后来改成clientWidth后因为滚动条被排除临界判断稳定许多。3.2 移动端真机上的“窗口变化”陷阱移动端浏览器里resize事件触发的情况比桌面端更“魔幻”。经典的场景就是iOS Safari下拉页面时地址栏和底部工具条会隐藏视口高度瞬间变大等滚动反弹回来地址栏又出现视口高度再次变小。这一来一回resize事件就触发了好几次。解决办法是防抖延时读取让地址栏动画稳定后再取尺寸。let debounceTimer; window.addEventListener(resize, () { clearTimeout(debounceTimer); debounceTimer setTimeout(() { const vh window.innerHeight; console.log(稳定后的视口高度, vh); }, 300); });移动端还有一个经典问题——100vh陷阱。CSS里写height: 100vh在iOS Safari上会超出视口因为100vh按最大视口高度算而浏览器地址栏遮住了一部分。如果你用resize事件配合window.innerHeight动态设置高度就能完美解决这个问题。每次视口高度变化后把实际高度通过CSS变量塞给页面。function applyViewportHeight() { const vh window.innerHeight; document.documentElement.style.setProperty(--vh, ${vh}px); } window.addEventListener(resize, applyViewportHeight); applyViewportHeight();配套CSS里写height: calc(var(--vh, 100vh));。这样地址栏隐藏了布局自动拉伸地址栏出来了布局自动收缩。这是我做过移动端H5项目后最常用的一招。3.3 响应式断点的resize判断实例很多人把响应式布局全交给CSS媒体查询但有些场景必须用JS配合resize事件。比如数据大屏里的不同布局切换、表格在移动端转卡片视图、导航栏展开收起这些需要JS操作DOM结构和组件状态。先定义一套断点常量再在resize回调里做判断并配合防抖避免频繁计算。const breakpoints { mobile: 768, tablet: 1024, desktop: 1280, }; function getCurrentMode(width) { if (width breakpoints.desktop) return desktop; if (width breakpoints.tablet) return tablet; return mobile; } let currentMode getCurrentMode(window.innerWidth); window.addEventListener(resize, debounce((event) { const nextMode getCurrentMode(window.innerWidth); if (nextMode ! currentMode) { currentMode nextMode; console.log(布局模式切换为${currentMode}); // 在这里切换导航形态、表格展示方式、图表布局等 } }, 150));核心思想是只在断点跨越时执行真正昂贵的操作而不是按帧刷新。你在断点内部微调窗口宽度时比如从1000px拖到1010px并不需要每次都重排整个页面只有跨越768或1024这条线才需要改变布局。4. 性能优化防抖、节流与requestAnimationFrame4.1 防抖实现等用户停手后再干活防抖debounce的逻辑是事件不断触发但回调只会在最后一次触发后等待一段时间再执行。用户拖着窗口一直动你就不管他松手了过200毫秒才真正执行一次回调。原理是每次触发都清掉上一次的定时器。function debounce(fn, delay 200) { let timer null; return function(...args) { if (timer) { clearTimeout(timer); } timer setTimeout(() { fn.apply(this, args); }, delay); }; }用法const handleResize debounce(() { console.log(窗口停止变化后执行); }, 200); window.addEventListener(resize, handleResize);实际项目里最常见的坑是debounce函数每次调用都会返回一个新函数。如果你直接写window.addEventListener(resize, debounce(fn, 200))那你绑定的监听器是一个匿名包装函数后续根本没法用removeEventListener解绑。正确做法是先把debounce的返回值存到变量里再绑定监听。这个细节我在第6节排查实录里还会强调。4.2 节流实现保证固定时间间隔内只触发一次节流throttle的逻辑和防抖不同它是每隔固定时间至少执行一次。用户拖拽窗口过程中每200毫秒执行一次回调不会把回调全部攒到最后。适合需要实时反馈的场景比如实时显示窗口尺寸、实时拖动某个浮层。function throttle(fn, interval 200) { let lastTime 0; return function(...args) { const now Date.now(); if (now - lastTime interval) { lastTime now; fn.apply(this, args); } }; }用法const handleThrottleResize throttle(() { console.log(最多每200ms执行一次, window.innerWidth); }, 200); window.addEventListener(resize, handleThrottleResize);怎么选如果你是做“等用户停手再重绘”比如图表resize、复杂布局重排用防抖如果你需要“拖动过程中持续更新”比如实时拖拽预览、进度反馈用节流。4.3 requestAnimationFrame更贴合浏览器渲染节奏的优化除了防抖节流还可以用requestAnimationFrame做一版轻量优化。它的原理是把逻辑推迟到浏览器下一次重绘之前执行天然可控频而且能跟浏览器渲染节奏保持一致避免掉帧。let ticking false; function handleResize() { if (!ticking) { ticking true; requestAnimationFrame(() { console.log(rAF节流的回调执行); ticking false; }); } } window.addEventListener(resize, handleResize);这种写法的好处是简洁不像防抖节流要维护定时器缺点是它只能在下一帧里执行一次不能保证我们需要的“最后一帧”逻辑一定被执行。所以我通常会把rAF和防抖结合用先防抖等用户停手后再用rAF去执行真正的重绘逻辑。const handleResize debounce(() { requestAnimationFrame(() { console.log(先防抖再rAF性能最稳); }); }, 150); window.addEventListener(resize, handleResize);这三层优化方式不是互斥的实际项目里往往叠加使用。核心原则就一条不要在resize回调里直接写昂贵操作要么延后、要么计时限制、要么对齐渲染帧。5. 实战场景图表重绘、iframe内嵌与移动端适配5.1 图表库的resize重绘ECharts、Canvas等做数据可视化项目时resize事件是绕不开的。ECharts图表默认有一个固定的canvas宽度窗口一变图表不会自动跟着变必须手动调用图表实例的resize()方法。const chart echarts.init(document.getElementById(chart)); const handleChartResize debounce(() { chart.resize(); }, 200); window.addEventListener(resize, handleChartResize);这里防抖很重要。图表resize内部要重新计算坐标系、重绘画布成本不低如果每秒触发60次页面直接卡死。防抖200毫秒后用户松手才重绘体验刚刚好。但光有监听还不够项目里经常遇到一个场景图表容器本身没有变化但所在区域从隐藏变成显示。比如Tab切换切换到图表页签前图表容器宽高是0或者被display:none隐藏切换后如果只是调用chart.setOption()图表可能尺寸不对。这时候也要调用一次chart.resize()。也就是说你不仅要监听window.resize还要在容器从隐藏变成可见时主动告诉图表重新适配。另一种做法是用ResizeObserver直接监听图表容器元素尺寸变化元素尺寸一变就主动改变图表尺寸。它的好处是比window.resize更精准因为整体窗口没变但容器变了的场景也能捕获到。const chartContainer document.getElementById(chart); const observer new ResizeObserver(() { chart.resize(); }); observer.observe(chartContainer);要注意ResizeObserver在现代浏览器里有很好的支持但老项目如果还要兼容旧IE老老实实用window.resize监听容器尺寸判断就行。5.2 iframe内嵌页面跨窗口的窗口变化在后台管理系统里iframe内嵌子页面的场景非常常见。这里有个容易踩的坑iframe里的子页面是独立的window对象子页面要监听窗口变化用window.addEventListener(resize, ...)是监听子页面自己的视口。但很多情况下iframe本身会被父页面动态调整大小子页面内部的window尺寸不会变子页面就不会触发resize。解决办法是用window.postMessage从父页面通知子页面。父页面代码const iframe document.getElementById(myIframe); function handleParentResize() { iframe.contentWindow.postMessage({ type: PARENT_RESIZE, width: window.innerWidth, height: window.innerHeight, }, *); } window.addEventListener(resize, debounce(handleParentResize, 150));子页面代码window.addEventListener(message, (event) { if (event.data event.data.type PARENT_RESIZE) { console.log(父页面尺寸变了, event.data.width, event.data.height); // 在这里做布局调整 } });父页面resize时主动把新的视口尺寸通过postMessage传给子页面子页面收到消息再做自己的适配逻辑。这里还要注意postMessage的targetOrigin参数生产环境里不要用*应该明确指定允许接收消息的源不然有被恶意页面注入消息的风险。5.3 移动端orientationchange与resize的配合手机横竖屏切换时系统会触发orientationchange事件同时也会触发resize事件。很多时候业务逻辑只需要监听一个但我建议两个配合使用因为兼容性上各浏览器触发顺序不一样。function handleViewportChange() { setTimeout(() { console.log(屏幕方向或尺寸变化, window.innerWidth, window.innerHeight); }, 300); } window.addEventListener(resize, handleViewportChange); window.addEventListener(orientationchange, handleViewportChange);加一个300毫秒的延时是因为横竖屏切换过程中视口尺寸会有一段过渡动画直接读取可能拿到中间状态的尺寸。这里用setTimeout而不是防抖是因为你希望最终拿到的值是稳定后的尺寸300毫秒够动画结束。移动端适配里还有一个和resize强相关的问题键盘弹起与收起。在iOS上输入框聚焦时键盘弹起不会触发resize但在特定安卓WebView里键盘弹起会让视口尺寸变小从而触发resize。这会导致你的布局在输入时突然重排体验很怪。遇到这种场景可以在resize回调里判断窗口高度变化是否超过某个阈值比如变化小于100像素时直接忽略避免键盘弹起导致布局闪动。let lastHeight window.innerHeight; window.addEventListener(resize, debounce(() { const currentHeight window.innerHeight; if (Math.abs(lastHeight - currentHeight) 100) { return; } lastHeight currentHeight; console.log(真正的视口高度变化); }, 200));这样的过滤逻辑能有效避免很多移动端H5里“键盘一弹页面就跳”的问题。6. 常见问题与排查技巧实录6.1 监听不触发先从绑定对象和时机找原因resize监听器不生效最常见的有四种情况我一个个说。第一监听器绑定在普通DOM元素上。有人以为document.getElementById(container).addEventListener(resize, fn)能监听容器变化实际上普通元素没有resize事件这段代码不会报错但永远不会执行。要监听元素尺寸变化应该用ResizeObserver。第二监听器绑定时窗口还没加载完整。在head里绑定window.resize是没问题的因为window对象始终存在但如果你在回调里操作了还没渲染出来的DOM元素就会出现报错。建议至少等DOMContentLoaded之后再做和DOM相关的操作。第三事件绑定在iframe内层。前面已经说过子页面监听不到父页面窗口变化因为它们的window独立。这种情况下要么在子页面绑定并监听iframe自身尺寸变化要么用postMessage联动。第四浏览器缩放不是resize。需要区分“浏览器窗口拖拽变化”和“浏览器网页缩放”。网页缩放Ctrl加号改变的是CSS像素与实际物理像素比它的确会触发resize但系统显示缩放比如Windows设置里把缩放改成150%不一定触发至少不是在所有浏览器里都触发。6.2 监听器重复绑定导致叠加执行这是resize事件里最隐蔽的坑常见于Vue或React项目。你在组件的mounted里绑定了监听器mounted() { window.addEventListener(resize, this.handleResize); }然后忘记了在beforeDestroy里解绑。组件频繁切换时每次切换都会新增一个监听器旧的还在。结果就是resize一旦触发回调执行了几十遍。页面越用越卡就是这个原因。解决方式很固定绑定和解绑必须配对而且解绑要传同一个函数引用。mounted() { window.addEventListener(resize, this.handleResize); }, beforeDestroy() { window.removeEventListener(resize, this.handleResize); }注意这里this.handleResize如果是一个普通方法在不同上下文中引用一致吗在Vue里this.handleResize每次拿到的引用是同一个所以能正确移除。但在React函数组件里函数组件每次渲染都会生成新的函数引用需要配合useEffect的cleanup机制来处理。React写法里正确的解法useEffect(() { const handleResize () { console.log(window.innerWidth); }; window.addEventListener(resize, handleResize); return () { window.removeEventListener(resize, handleResize); }; }, []);这里的空依赖数组保证只在挂载时执行一次cleanup函数在组件卸载时解绑而且因为闭包引用了同一个handleResize移除才能生效。这也是我在实际项目里排查得最多的一类问题。6.3 resize里获取到的尺寸不是预期值resize回调触发时有几个时序问题会导致数据“不准”。问题一事件触发时立即读取DOM布局可能拿到上一帧的旧值。这在个别浏览器里有体现解决办法是把读取操作放进requestAnimationFrame里浏览器会保证在重绘之前读取到最新布局。问题二窗口尺寸变化分为“整体拖拽”和“局部边缘拖拽”resize回调执行频率非常高很多中间状态的尺寸是在过渡过程中你拿到的可能不是最终值。比如Windows系统里窗口拖拽过程中有连续resize和重绘最终停下来后还会触发最后一次。所以通常不用setTimeout延迟读取或者做防抖拿稳定值。问题三CSS里响应式容器尺寸和window尺寸之间有时间差。窗口变了但CSS媒体查询生效需要经过样式重算和布局如果你在resize回调里立刻测量某个元素的getBoundingClientRect()可能拿到还未更新的值。这种情况最稳妥的做法是在requestAnimationFrame里再测量因为那时布局已经重算完成。window.addEventListener(resize, () { requestAnimationFrame(() { const width document.getElementById(container).getBoundingClientRect().width; console.log(布局更新后的容器宽度, width); }); });6.4 常见问题速查表现象可能原因解决方案resize回调完全没执行监听器绑在了非window对象上 / 绑定的代码没运行改成window.addEventListener检查绑定时机回调执行多次监听器重复绑定未解绑在组件卸载时removeEventListener引用保持一致取到的尺寸偏大或偏小用了outerWidth或忘了滚动条占位按需选择innerWidth/clientWidth移动端布局不断跳动键盘弹起或地址栏变化触发多余resize设置高度变化阈值或加防抖过滤iframe内页面不响应窗口变化子页面监听的是自己独立window用postMessage从父页面发送尺寸消息图表没有随窗口自适应没有调用图表实例的resize()方法在resize回调或ResizeObserver里调用chart.resize()回调里读取容器宽度不准样式重算尚未完成在requestAnimationFrame里读取页面越用越卡大量匿名resize监听器泄漏在内存里用chrome://inspect或Performance面板排查监听器数量7. 操作心得与避坑建议最后说点实际的东西。我早期做后台系统时直接在mounted里挂了一个匿名函数的resize监听页面切换后旧监听器没清掉每次切换都多绑一个。到最后切了几个页面后resize一下页面要卡半秒。这个教训让我养成了一个习惯所有resize监听必须配对removeEventListener或者用防抖函数把引用存住。另外还有一个小心得尽量不要在resize回调里直接操作大规模DOM例如重排整个表格、遍历几百个节点改样式能合并的合并能延后的延后。配合防抖和requestAnimationFrame实际体验会提升一大截这在低性能机型和移动端尤其明显。记住一个原则resize事件本身很简单难的是在正确时机、以合适频率、拿到准确的尺寸再决定该做什么。听完这些你应该已经能应付绝大多数和窗口变化相关的开发需求了。