做前端好几年,处理“屏幕适配”这类需求踩过的坑,比我吃过的盐还多。尤其“监听横竖屏旋转”和“监听屏幕大小变化”这两个需求,看起来是同一件事,实际是两个完全不同的技术命题,经常有同学混为一谈,结果上线后各种诡异Bug。
先说结论:横竖屏旋转是屏幕方向(Orientation)的变化,屏幕大小变化是视口尺寸(Viewport)的变化,两者有交集,但并不等价。一个旋转动作通常会同时触发两个事件,但屏幕大小变化可以完全不涉及方向改变,比如用户拖拽浏览器窗口、折叠屏展开、PC端缩放浏览器比例、移动端地址栏收起。
这篇文章就把这两件事彻底拆开,讲清楚各自的适用场景、监听方案、兼容性陷阱,以及我实际开发中总结的一套组合判断策略。
1. 先搞清楚:旋转和尺寸变化到底是不是一回事
很多同学上来就直接监听resize,觉得“反正旋转肯定尺寸也变了”。这个思路在大多数移动端页面上能跑通,但一到PC端、折叠屏、PC端浏览器窗口拖拽这些场景,就会漏掉关键状态。
1.1 横竖屏旋转的本质
横竖屏旋转,本质是设备的方向传感器检测到重力方向变化,于是系统层面触发方向切换。对应到前端,就是window.orientation的值在 0 / 90 / -90 / 180 之间切换。但这个值有兼容性问题,安卓设备上不同浏览器实现标准不一致。
更推荐的做法是matchMedia('(orientation: portrait)')这个媒体查询。因为它是CSS媒体查询体系的一部分,所有现代浏览器都支持,包括微信内置浏览器、各家安卓定制浏览器,甚至在PC端模拟移动端时也能平稳运行。
这里有个误区:很多人写window.addEventListener('resize', handler)来监听旋转,但实际测试你会发现,旋转时大多数移动端浏览器会先触发resize,再触发orientationchange(这是从iOS 8开始的标准行为)。如果你在resize里读window.orientation,大概率能拿到正确值,但你要是依赖这个顺序做复杂的逻辑判断,就会踩时序的坑。
1.2 屏幕大小变化的本质
屏幕大小变化,涉及的是Viewport(视口)的宽高。触发源比旋转多得多:
- PC端用户拖拽改变窗口大小
- 移动端浏览器地址栏收起/展开,导致视口高度变化(这个极其隐蔽)
- 浏览器窗口在桌面端被缩放到一半
- 折叠屏设备的展开/折叠模式切换
- 用户旋转屏幕引发的视口尺寸变化(和上面那个场景重叠)
从JS层面看,resize事件是唯一能全场景捕获这些变化的监听入口。但resize有几个恼人的特性:它触发频率极高(拖拽时每帧都触发),并且事件对象里不告诉你“什么时候算结束”。这意味着如果你在resize里做重计算、重新渲染,就必须要引入“防抖”机制。
1.3 两者的事件触发关系
我整理一个表格帮大家理清:
| 用户动作 | orientationchange | resize | matchMedia回调 |
|---|---|---|---|
| 手机旋转90度 | 触发 | 触发(先于orientationchange) | 触发 |
| PC浏览器缩窄 | 不触发 | 触发(高频) | 不触发 |
| 移动端地址栏收起 | 不触发 | 触发 | 可能触发(视口高度变化导致宽高比变化) |
| 折叠屏展开 | 不触发 | 触发 | 可能触发 |
| 浏览器缩放(Ctrl +/-) | 不触发 | 触发(如果视口尺寸按CSS像素重算) | 可能触发 |
看到没?靠单一事件监听,根本无法覆盖所有场景。正确姿势是组合监听。
2. 分别监听的四种主流方案与选型逻辑
2.1 方案一:orientationchange+window.orientation(传统方案)
这是最古老的一套:
window.addEventListener('orientationchange', () => { const orientation = window.orientation; const angle = orientation === 90 || orientation === -90 ? 'landscape' : 'portrait'; console.log(`屏幕旋转为${angle}方向`); });但这里有个坑:window.orientation是iOS私有API,安卓上兼容性参差不齐。安卓6.0以后很多浏览器直接移除对它的支持,变成undefined。因此这个方案技术上已经过时,不建议新项目使用。
它唯一的价值是能判断旋转方向的正负(左旋/右旋),而matchMedia只能拿到 portrait/landscape 两个状态,拿不到度数。
2.2 方案二:matchMedia('(orientation: portrait)')(推荐核心方案)
const portraitQuery = window.matchMedia('(orientation: portrait)'); function handleOrientationChange(e) { if (e.matches) { console.log('当前为竖屏'); } else { console.log('当前为横屏'); } } // 现代浏览器推荐使用 addEventListener,Safari 13以下需要用 addListener if (portraitQuery.addEventListener) { portraitQuery.addEventListener('change', handleOrientationChange); } else { portraitQuery.addListener(handleOrientationChange); // 旧版 Safari 兼容 }这个方案的优点:不依赖全局事件,不依赖浏览器私有API,通过CSS媒体查询标准体系判断,语义清晰,所有现代浏览器表现一致。而且还能结合useEffect在React中优雅地管理监听的生命周期。
2.3 方案三:resize+ 防抖(万能的兜底方案)
let resizeTimer; window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { const width = window.innerWidth; const height = window.innerHeight; console.log(`视口尺寸变为:${width} x ${height}`); // 在这里执行重渲染、重计算 }, 300); // 300ms防抖 });这里必须说清楚:纯resize无法区分是旋转导致的尺寸变化,还是拖拽导致的尺寸变化。所以如果你用resize做业务逻辑,建议在回调里自行判断尺寸变化的特征。
我自己常用一个简单的启发式判断:
window.addEventListener('resize', () => { const width = window.innerWidth; const height = window.innerHeight; const ratio = width / height; // 旋转发生时,宽高比会跨越1这个阈值(比如从0.5变成1.8) const isRotated = (ratio > 1 && height < width) || (ratio < 1 && height > width); // 但注意:PC端用户把窗口缩得很扁也会满足这个条件,所以不能单独下结论 });2.4 方案四:Visual Viewport API(进阶方案)
对于处理移动端地址栏收起、键盘弹出这类“视口微小变化”,可以监听window.visualViewport.resize:
if (window.visualViewport) { window.visualViewport.addEventListener('resize', () => { const vv = window.visualViewport; console.log('视觉视口尺寸:', vv.width, vv.height); }); }这个API能拿到visualViewport.width/height/offsetTop等更精准的数据,对软键盘弹出、页面缩放等场景很有用。但它的兼容性比前几个方案差一些,不支持IE和旧版浏览器。
2.5 我最终推荐的组合策略
不要单选一个方案,而是根据业务需求的分层则:
| 需求场景 | 首选方案 | 兜底方案 |
|---|---|---|
| 页面样式跟随横竖屏切换 | CSS media query | matchMedia回调 |
| 需要在JS中触发业务逻辑 | matchMedia回调 | orientationchange(仅iOS兼容兜底) |
| 需要检测任意尺寸变化 | resize防抖 | visualViewport(进阶场景) |
| 需要区分旋转/缩放/拖拽 | matchMedia + resize 组合判断 | 无 |
3. 实操:完整实现一套“分别监听”的健壮代码
下面我直接给出一套我封装过的、可用于生产环境的组合监听器示例。它把“旋转”和“尺寸变化”两条线完全分离,避免共享回调互相干扰,这是“分别监听”的真正意义。
3.1 核心封装代码
/** * 设备视口状态监听器 * 分别监听:横竖屏旋转、视口尺寸变化 * 用法: * const watcher = createViewportWatcher({ * onRotate: (info) => { console.log('旋转状态', info); }, * onResize: (info) => { console.log('尺寸变化', info); }, * }); * watcher.destroy(); // 组件卸载时销毁 */ function createViewportWatcher({ onRotate, onResize }) { let isPortrait = window.matchMedia('(orientation: portrait)').matches; let lastWidth = window.innerWidth; let lastHeight = window.innerHeight; let resizeTimer = null; // 旋转监听:走 matchMedia 的 change 事件 const portraitQuery = window.matchMedia('(orientation: portrait)'); const handleOrientationChange = (e) => { const nextIsPortrait = e.matches; if (nextIsPortrait === isPortrait) return; // 状态未变,忽略 isPortrait = nextIsPortrait; // 同步更新基础尺寸状态 lastWidth = window.innerWidth; lastHeight = window.innerHeight; if (onRotate) { onRotate({ isPortrait, orientation: isPortrait ? 'portrait' : 'landscape', width: lastWidth, height: lastHeight, }); } }; // 尺寸变化监听:走 resize + 防抖 const handleResize = () => { const width = window.innerWidth; const height = window.innerHeight; // 先判断是不是“旋转事件”连带触发的 resize // 如果是,就不在这里触发 onResize,避免重复回调 const portraitNow = window.matchMedia('(orientation: portrait)').matches; if (portraitNow !== isPortrait) { // 说明旋转事件即将/正在发生,resize 本次回调忽略 // 实际测试中,先触发 resize,后触发 orientationchange // 所以这里直接返回,让 orientationchange 回调去处理 return; } // 尺寸确实变化了,但不是旋转导致 clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { const widthNow = window.innerWidth; const heightNow = window.innerHeight; // 二次确认宽度或高度确实发生改变 if (widthNow === lastWidth && heightNow === lastHeight) return; lastWidth = widthNow; lastHeight = heightNow; if (onResize) { onResize({ width: widthNow, height: heightNow, isPortrait, }); } }, 200); // 200ms 防抖 }; // 注册监听 portraitQuery.addEventListener ? portraitQuery.addEventListener('change', handleOrientationChange) : portraitQuery.addListener(handleOrientationChange); window.addEventListener('resize', handleResize); // 返回销毁方法 return { destroy() { portraitQuery.removeEventListener ? portraitQuery.removeEventListener('change', handleOrientationChange) : portraitQuery.removeListener(handleOrientationChange); window.removeEventListener('resize', handleResize); clearTimeout(resizeTimer); }, }; }3.2 React Hooks 版本
如果项目里用的是React,推荐用useEffect封装成一个 hook,放在组件或全局布局中使用:
import { useEffect, useState, useCallback } from 'react'; function useViewportWatcher() { const [viewportState, setViewportState] = useState({ isPortrait: window.matchMedia('(orientation: portrait)').matches, width: window.innerWidth, height: window.innerHeight, }); useEffect(() => { const watcher = createViewportWatcher({ onRotate: (info) => { setViewportState(prev => ({ ...prev, ...info })); }, onResize: (info) => { setViewportState(prev => ({ ...prev, ...info })); }, }); return () => watcher.destroy(); }, []); return viewportState; }这个hook的核心价值在于:渲染层只关心一个状态对象,底层逻辑负责区分事件来源。你在组件里用viewportState.isPortrait切样式,用viewportState.width算布局,两个维度互不干扰。
3.3 为什么我的回调里要在 resize 中排除旋转场景
这是我踩过最大的坑。如果不做区分,resize先触发、orientationchange后触发,会导致:
onResize被调用一次(此时isPortrait还是旧值)onRotate被调用一次(此时isPortrait更新为新值)
如果你在onResize里同样触发了状态更新,React里会连续渲染两次。第一次用旧方向 + 新尺寸,第二次用新方向 + 新尺寸,视觉上会闪一下诡异的状态。
所以正确做法是:在resize回调中检查当前方向是否已经变化,如果变化了,就安心等待orientationchange来处理,resize这边直接放弃。如果没变化,才走正常的onResize逻辑。
4. 兼容性对照与真机调试要点记录
4.1 各平台兼容性速查
我自己维护的一个小表,分享出来:
| 环境 | matchMedia orientation | orientationchange | window.orientation | resize | visualViewport |
|---|---|---|---|---|---|
| iOS Safari 13+ | 正常 | 正常 | 正常(但建议不用) | 正常 | 正常 |
| iOS Safari 12以下 | matchMedia正常,addEventListener不支持(需addListener) | 正常 | 正常 | 正常 | 部分支持 |
| Android Chrome 108+ | 正常 | 基本废弃,不推荐 | 多为undefined | 正常 | 正常 |
| 微信内置浏览器(iOS) | 正常 | 正常 | 正常 | 正常 | 大部分支持 |
| 微信内置浏览器(安卓) | 正常 | 部分机型触发,部分不触发 | 不稳定 | 正常 | 部分支持 |
| PC Chrome/Firefox/Safari | 正常(窗口比例变化时生效) | 不触发 | 不支持 | 正常 | 正常 |
| PC IE 11 | matchMedia支持,addListener API有坑 | 不触发 | 不支持 | 正常 | 不支持 |
从这张表能看出一个规律:跨端兼容最稳的永远是matchMedia + resize组合,orientationchange和window.orientation属于“历史遗留产物”,适合做增量兼容,不适合做主力。
4.2 真机调试的三个重要场景
第一,Android Chrome 的“桌面模式”与“移动模式”切换。部分安卓设备上,如果你在Chrome菜单里勾选了“桌面版网站”,会让视口宽度变成980px之类的桌面尺寸,此时matchMedia('(orientation: portrait)')的结果可能仍然是portrait,但宽高比已经异常了。建议在真机上强制试一遍这个场景。
第二,iOS Safari 的地址栏收起/展开。通常在页面滚动时触发,window.innerHeight会变化两三百像素,但matchMedia的 orientation 不会变。这就是之前说的“尺寸变化但非旋转”的典型场景,调试时把地址栏反复滑动多试几次。
第三,折叠屏/平板模式切换。比如华为折叠屏从手机态展开为小平板态时,初始matchMedia结果可能不变(仍然是portrait),但宽度大幅变大。只监听旋转的代码在这里会漏判。所以建议在折叠屏真机上验证你的onResize分支逻辑。
4.3 调试工具推荐
- DevTools 的设备模拟器里,可以手动旋转模拟手机方向,但它不能模拟地址栏收起/展开
- 真机调试推荐使用
vConsole或者eruda,在移动端页面内直接看window.innerWidth/innerHeight和matchMedia的实时值 - 用
orientationchange监听时,建议在回调里把Math.abs(window.orientation)打印出来确认是否拿到的是度数,而不是undefined
// 调试小工具 window.addEventListener('resize', () => { console.log('inner', window.innerWidth, window.innerHeight); console.log('orientation', window.matchMedia('(orientation: portrait)').matches); console.log('screen', window.screen.width, window.screen.height); }); // Android 上查看 devicePixelRatio 变化,有可能触发 resize window.addEventListener('resize', () => { if (window.devicePixelRatio !== lastDpr) { console.log('DPR变化,可能是浏览器缩放或用户切换了显示比例'); } });5. 已知的坑清单与实测避坑建议
5.1 高频踩坑场景一:状态不同步
orientationchange触发时,window.innerWidth可能还未更新完毕,导致你读到的尺寸还是旧值。解决方案是在orientationchange回调里主动读取一次window.innerWidth,如果发现方向和尺寸不匹配,就补充一个requestAnimationFrame再读一次:
window.addEventListener('orientationchange', () => { requestAnimationFrame(() => { console.log('延迟一帧后', window.innerWidth, window.innerHeight); }); });实测发现,iOS Safari 在旋转动画执行完、状态稳定后,再触发一次resize。所以更稳妥的做法是本身就是我上面的方案:以matchMedia的 change 事件为准,以resize作为兜底,不依赖orientationchange做核心逻辑。
5.2 高频踩坑场景二:CSS媒体查询与JS状态的不一致
如果你用CSS的@media (orientation: landscape)切换样式,同时又用JS的matchMedia或orientationchange控制别的逻辑,两边的结果一定要对齐验证。
举一个我遇到的案例:某个页面在CSS里针对横屏设置了overflow-y: auto,但JS端通过orientationchange获取方向并控制某个弹窗的位置。结果表现是CSS层响应了横屏,JS层却还停留在竖屏状态。原因是orientationchange触发时window.orientation还没更新,我在回调里取到了旧值。
最终我改用了一个判断函数,统一从当前环境实时获取:
function getOrientation() { const mq = window.matchMedia('(orientation: portrait)'); return mq.matches ? 'portrait' : 'landscape'; }CSS那边用什么,JS这边就用什么,不要混用两套判断机制。
5.3 高频踩坑场景三:防抖时间设置不当
resize防抖时间太短(比如50ms)、太长(比如1000ms)都有问题。
- 太短:用户还在拖拽窗口时,你的回调就执行了,产生一堆中间状态的计算开销
- 太长:用户体验上感觉“页面反应慢半拍”,尤其在移动端地址栏收起时,防抖500ms会明显延迟
我的经验值:UI布局类的状态更新用200-300ms防抖;纯埋点上报类可以使用100ms;渲染计算成本高的建议500ms以上,同时配合requestAnimationFrame做节流。
5.4 高频踩坑场景四:Vue/React组件卸载后仍然触发
这类监听事件最容易被遗忘的就是不销毁,导致页面切走以后,事件回调还在执行,报一堆引用错误。比如React 18 StrictMode下,useEffect会执行两次挂载,如果清理不干净,监听器会成倍累加,内存泄漏是小事,最恐怖的是回调在一次旋转时触发十几次。
所以封装监听器时,一定要返回destroy方法,React里调用它清理,原生JS里在pagehide/beforeunload时清理。
window.addEventListener('pagehide', () => { watcher.destroy(); });5.5 高频踩坑场景五:旋转后100vh问题
横竖屏切换后,height: 100vh在移动端会有离奇表现。iOS上100vh会把地址栏高度算进去(老版本),横屏时地址栏更宽,视觉上内容被截掉。新的CSS逻辑单位100dvh、100svh能缓解,但兼容性一般。
如果你做的是移动端项目,建议用窗口高度动态计算:
function viewportHeight() { return window.visualViewport ? window.visualViewport.height : window.innerHeight; }把这个值同步为CSS变量:
const setVh = () => { document.documentElement.style.setProperty('--vh', `${viewportHeight() * 0.01}px`); }; window.addEventListener('resize', setVh);然后在CSS里写height: calc(var(--vh) * 100)。
6. 这个组合策略还能扩展到哪些场景
把“旋转”和“尺寸变化”分别处理以后,这套思路可以自然延伸到相关需求上,对于团队里其他成员也有直接参考价值。
6.1 判断“是否是移动端键盘弹起/收起”
移动端页面从输入框聚焦到失焦,window.innerHeight会明显变化,但visualViewport.height变化更精确。把resize和visualViewport组合使用,可以识别软键盘状态,至少在Android上是这样:
let lastVisualHeight = window.visualViewport?.height || window.innerHeight; window.visualViewport?.addEventListener('resize', () => { const curHeight = window.visualViewport.height; if (curHeight < lastVisualHeight - 100) { console.log('键盘弹起'); } else if (curHeight > lastVisualHeight) { console.log('键盘收起'); } lastVisualHeight = curHeight; });6.2 判断“PC端用户是否切换到全屏”
监听resize时对比window.outerWidth/outerHeight与screen.width/screen.height,可以判断是否进入了全屏模式。这个需求在使用WebRTC、视频播放等场景很常见。
window.addEventListener('resize', () => { const isFullScreen = window.outerWidth === screen.width && window.outerHeight === screen.height; if (isFullScreen) { console.log('切换为全屏'); } });6.3 做埋点:区分旋转来源和窗口缩放来源
需求方经常要统计“用户使用设备方向”和“窗口缩放分布”。这两个数据的来源不同,统计口径一定要分开。我的处理方式是埋点数据里带两个字段isRotationChange和isResizeChange,从监听回调直接打标,格式类似:
onRotate: () => track('orientation_change', { orientation }); onResize: () => track('viewport_resize', { width, height });这样数据后端就能分别分析出旋转场景和缩放场景的业务转化率差异。
7. 一套实战总结
做了这么多项目,我对“监听横竖屏旋转和屏幕大小变化”的最终理解是:不要试图去区分“事件到底叫什么名字”,而是要去区分“用户的操作意图是什么”。
resize是底层事件,什么都能感知到,但感知不到意图。orientationchange有明确意图,但覆盖率差。matchMedia兼顾了语义与兼容性,但拿不到具体尺寸。
所以我在实际项目里总是保持一个原则:用matchMedia判方向,用resize感知变化,以“方向或尺寸是否真正改变”为状态标准来统一驱动业务逻辑。这样既不会漏掉场景,也不会重复触发回调。
最后再留一个做移动端适配常用的辅助函数:判断当前是不是一个“可旋转设备”。通过媒体查询的覆盖范围来判断,比检测UA要稳得多。
/** * 判断设备是否支持横竖屏切换 */ function isRotatableDevice() { return window.matchMedia('(orientation: portrait)').media === '(orientation: portrait)'; }注意:PC端浏览器也会返回'(orientation: portrait)'字符串,但它的宽高比不随用户旋转改变,所以这个函数只能用来初步筛掉那些肯定不支持旋转的场景,不能完全依赖它下结论。
如果后续你想做更精细的设备能力检测(比如折叠屏、平板模式),欢迎在评论区聊一聊,这块我也有不少实际经验可以分享。