
很多前端同学遇到横竖屏旋转和屏幕大小变化这类需求时第一反应是监听window的resize事件因为不管是手机旋转还是窗口拖动resize都会触发。但我在实际项目中吃过这个亏做移动端阅读器时横竖屏旋转需要切换阅读模式窗口尺寸变化只需要重新排版如果把两者混在同一个resize回调里手势明明是旋转代码却把它当成普通的窗口伸缩来处理布局逻辑会乱成一团。这篇文章就围绕分别监听设备的横竖屏旋转和屏幕大小变化这个话题把事件原理、监听方案、判定策略和兼容性坑一次讲透适合所有做过移动端适配、响应式布局或者正在为旋转和缩放分不清而苦恼的前端开发者。1. 旋转和缩放不是一回事先搞懂事件源头做技术方案之前得先把两个概念彻底分开。横竖屏旋转是设备本身的物理朝向发生变化也就是重力方向变了屏幕大小变化是可视区域viewport的尺寸变了但物理朝向没动。两者在移动端和桌面端经常被一起触达但语义完全不同。1.1 物理方向变化与视口尺寸变化的本质区别横竖屏旋转时手机的逻辑分辨率宽高会互换。比如一台 iPhone 竖屏时innerWidth是 390、innerHeight是 844横过来之后通常就变成 844 和 390。这个过程中resize一定会被触发因为视口尺寸确实变了。但反过来桌面上用户拖拽浏览器窗口、折叠 DevTools、缩放页面比例这些操作也会改变innerWidth和innerHeight设备方向却毫无变化。所以两者的关系不是互斥而是交叉旋转必然带来尺寸变化但尺寸变化不一定来自旋转。这就决定了任何只靠resize一把梭的方案都天然无法区分这次尺寸变化到底是用户转了一下手机还是单纯拖了一下窗口。另一个容易混淆的点是screen.width。很多人以为屏幕旋转后screen.width会从竖屏值变成横屏值实际上在现代移动浏览器里screen.width和screen.height是不会跟随旋转改变的它们是物理屏幕的逻辑分辨率旋转时浏览器会自动把视口方向调整过来。真正跟随旋转变化的是window.innerWidth、window.innerHeight和document.documentElement.clientWidth。这个认知不纠正后续很容易在计算宽高比时算出错误结果。1.2 事件派发链路谁触发了谁浏览器内部的事件链路是这个样子的设备方向变化 → 系统通知浏览器 → 浏览器重新计算视口尺寸和媒体查询状态 → 触发orientationchange移动端→ 触发resize→ 触发matchMedia的change事件。也就是说一次旋转最少会带来两个异步事件的到达orientationchange或screen.orientation的change和resize。这两个事件到达的先后顺序在不同浏览器里不太一样有的先走orientationchange再走resize有的反过来。但不管谁先谁后它们代表的是同一次物理事件。如果分别在两个回调里各做各的逻辑就会出现重复执行、数据错位、状态覆盖的问题。桌面端没有设备方向变化所以只有resize被触发。移动端则经常会同时收到两种事件。理解了这条链路分别监听的核心就变成了如何在一个变化周期内把一次旋转带来的多路事件归并为一个方向状态变化而不是当成两次独立事件处理。2. 横竖屏旋转监听matchMedia和screen.orientation的路线对比真正监听旋转方向业界主流有两条路线window.matchMedia配合(orientation: portrait)媒体查询以及screen.orientation接口。两条路各有各的优劣势选型时需要结合目标浏览器环境来定。2.1 用 matchMedia 监听媒体查询变化这个方法本质是把 CSS 媒体查询的状态同步到 JavaScript 回调里。先建一个媒体查询对象再监听它的change事件。const portraitMql window.matchMedia((orientation: portrait)); function handleOrientationChange(e) { if (e.matches) { console.log(当前是竖屏); } else { console.log(当前是横屏); } } // 初始化时立即执行一次防止首次加载拿不到状态 handleOrientationChange(portraitMql); portraitMql.addEventListener(change, handleOrientationChange);这段代码的精髓在于e.matches它表示当前媒体查询是否匹配。change事件触发时e.matches已经是变化之后的最新值不需要自己在回调里再去比较innerWidth和innerHeight。matchMedia的兼容性非常好从 iOS Safari 5.0、Android Chrome 4.0 开始就基本可用也就是说老设备上也能运行。但它的局限也很明显只告诉你是 portrait 还是 landscape不提供具体角度。如果需求是从竖屏转到横屏再转回来哪怕角度从 90 度变成 270 度也要能区分,光靠二值就实现不了。2.2 用 screen.orientation 监听角度变化screen.orientation接口更接近系统层面能拿到精确的旋转角度。angle属性的含义是设备当前方向相对于自然竖屏方向顺时针旋转的角度。竖屏自然方向是 0 度左转横屏是 90 度倒置竖屏是 180 度右转横屏是 270 度。if (screen.orientation screen.orientation.addEventListener) { screen.orientation.addEventListener(change, () { const angle screen.orientation.angle; if (angle 0 || angle 180) { console.log(竖屏状态角度为, angle); } else { console.log(横屏状态角度为, angle); } }); }这个方案的优点是信息量大可以直接通过angle判断四个方向。对平板、折叠屏这种支持多向旋转的设备来说matchMedia只能告诉你横还是竖screen.orientation能明确告诉你 90 度还是 270 度这在实现横屏时区分左右手模式这类需求时非常重要。缺点也很直接iOS Safari 16.4之前不支持这个接口。而 iOS 生态对中国用户来说占比极高所以它必须和matchMedia方案做降级共存。2.3 两套方案的选型建议对比维度matchMediascreen.orientation兼容性iOS 5.0Android 4.0覆盖极广iOS 16.4Android Chrome 38信息粒度只有 portrait/landscape 二值提供 0/90/180/270 角度值事件延迟跟随媒体查询重评更接近系统方向变化通知是否受页面内 iframe 影响受 iframe 约束全局状态我的建议是默认用matchMedia做主要监听同时检测到screen.orientation存在时追加角度判断。这句话听起来像废话但实际组合出来的代码能覆盖绝大多数场景后面第 4 章给出完整状态机时会展示具体写法。3. 屏幕尺寸变化监听resize的正确姿势与ResizeObserver的取舍屏幕大小变化监听也就是对resize事件的使用。这里有两个层级的问题一是传统resize事件怎么用才不出错二是ResizeObserver作为升级方案到底该不该用。3.1 window.resize 的传统写法与防抖经典写法就是这样let resizeTimer; window.addEventListener(resize, () { clearTimeout(resizeTimer); resizeTimer setTimeout(() { console.log(窗口尺寸变化当前尺寸为, window.innerWidth, window.innerHeight); }, 150); });用户拖动窗口边缘时resize事件会以极高的频率连续触发每一帧都可能触发一次。回调里如果直接做 DOM 操作、重排布局或者上报埋点性能开销会非常大。所以resize的正确使用方式几乎总是配合防抖或节流。防抖是指连续触发时只在停止后执行一次节流是指固定间隔内最多执行一次。上述代码用的是防抖它保证用户停止拖动 150 毫秒后才执行一次适合大多数布局处理场景。不过resize有一个天然的缺陷它只告诉你尺寸变了不告诉你变到了多少。你必须在回调里主动读取innerWidth和innerHeight。而且它无法被定向监听——你不可能只监听某个组件的尺寸变化而不听整个窗口的。如果你的需求是侧边栏里某个面板的高度随内容撑开而变化,用window.resize完全办不到因为窗口本身没变。3.2 ResizeObserver观察根元素或任意容器ResizeObserver是专门用来观察元素尺寸变化的 API它可以观察任意元素每次元素大小变化时回调会收到一个ResizeObserverEntry数组里面带有该元素的内容尺寸contentRect。const observer new ResizeObserver((entries) { for (const entry of entries) { const { width, height } entry.contentRect; console.log(观察对象尺寸变为, width, height); } }); // 观察根元素相当于监听整个视口尺寸 observer.observe(document.documentElement);用ResizeObserver观察document.documentElement它的表现基本可以替代window.resize的一部分工作而且还能拿到精确的宽高数值。更重要的是它可以观察嵌套滚动容器里的元素比如一个iframe内部的内容区域高度变化window.resize根本感知不到但ResizeObserver能观察到。还有一个细节ResizeObserver的回调是在布局完成后触发的意味着你读取到的尺寸已经是最终渲染值不会像某些resize时序那样出现事件来了但尺寸还没更新完的尴尬情况。3.3 不同场景下到底该选哪个具体需求推荐方案原因监听整个浏览器的窗口缩放window.resize 防抖简单直接兼容性极佳媒体查询状态变化matchMedia语义化最好不涉及像素比对监听某个容器尺寸变化ResizeObserver精确到元素回调拿到准确尺寸判断是否发生了旋转不要只用 resize需要结合方向状态联合判断这里容易踩的坑是把三者混用却不做归并。比如同时又监听resize、又监听matchMedia的change、又监听screen.orientation的change结果一次旋转触发了三个回调每个回调里都去做一次布局页面就会抖动或者重复计算。下一章要解决的就是这个多事件归并问题。4. 真正分开两者状态管理加时间窗口的完整套路很多教程到这里就结束了给一个matchMedia监听、给一个resize监听就算分别了。但实际上这两个监听器是同时被触发的直接各写各的等于没有分别。真正的分别是在同一个变化周期里精确判断这次变化到底属于旋转还是属于尺寸调整。4.1 为什么不能直接在各自回调里各写各的假设你在resize回调里写布局重排在screen.orientation的change回调里写横竖屏切换。手机旋转时两个回调都会执行于是你既触发了布局重排又触发了模式切换。如果这两种操作互为补充还好但大多数项目里它们是有冲突的回复横屏时布局重排使用的宽高数据可能还是竖屏阶段的旧值因为resize事件到达时视口尺寸还没完全更新完。结果是页面先按旧尺寸排了一次再按新模式切换中间会出现肉眼可见的闪烁和错位。要解决这个问题就必须建立一个统一的变化中心把所有相关事件先囤起来等一小段时间窗口之后再统一判断这次到底发生了什么变化然后分发到对应的处理函数里。4.2 用角度变化做分流最简单直接的方案所谓分流就是把判断来源这个工作放到事件处理的第一行。利用screen.orientation.angle的变化来判断如果角度变了这次变化就是旋转如果角度没变只是宽高变了那才是真正的尺寸变化。let lastAngle getCurrentAngle(); window.addEventListener(resize, () { const currentAngle getCurrentAngle(); if (currentAngle ! lastAngle) { // 角度变了说明这次 resize 是旋转引起的 console.log(旋转引起的尺寸变化角度变为, currentAngle); onRotate(currentAngle); } else { // 角度没变说明是纯粹的窗口尺寸变化 console.log(纯窗口尺寸变化, window.innerWidth, window.innerHeight); onResize(window.innerWidth, window.innerHeight); } lastAngle currentAngle; });这个方案的核心逻辑很朴素一次旋转一定会让角度值发生变化而一次窗口拉伸不会。所以每次resize时检查角度有没有变就能判断这次resize的根因。它的优点是不需要额外的事件源只用resize一个入口就能完成分流。缺点也是非常明显的screen.orientation在不支持的浏览器上根本不存在必须降级而且在角度变化过程中resize和角度更新的时序可能不一致。如果你在resize回调里读到的角度还是旧值就会错误地把它当成纯尺寸变化。所以这个方案最适用于我们已经确定screen.orientation可用且事件同步稳定的环境比如自家 App 的 WebView 或者只针对 Android 的项目。4.3 宽高比突变的辅助判断方案B不用screen.orientation也能做分流。思路是利用宽高比的状态突变竖屏时宽高比小于 1高大于宽横屏时宽高比大于 1宽大于高。旋转一定会导致这个比值跨过 1 的界限而桌面端拖拽窗口尺寸变化时一般不会出现比值跨 1 的情况除非你自己把窗口拉到极端比例。let lastIsLandscape window.innerWidth window.innerHeight; window.addEventListener(resize, () { const isLandscape window.innerWidth window.innerHeight; if (isLandscape ! lastIsLandscape) { console.log(宽高比跨过了 1判定为旋转); onRotate(isLandscape ? 90 : 0); } else { console.log(宽高比未跨 1判定为尺寸变化); onResize(window.innerWidth, window.innerHeight); } lastIsLandscape isLandscape; });这个方案完全不依赖screen.orientation只要浏览器支持resize就能用。但它有一个致命弱点在接近正方形的视口上会误判。iPad 悬浮窗模式下宽度和高度可能很接近一次轻微拖拽就可能让宽高比跨过 1这时候系统明明没旋转代码却判定为旋转。所以它只适合作为辅助手段例如结合screen.orientation一起用或者用在明确只支持手机浏览器的项目里。4.4 推荐50ms 时间窗口统一归并事件最终我推荐的方案是把角度分流和时间窗口加在一起做成一个轻量状态管理器。核心思想不急着处理任何单个事件而是把resize、matchMedia的change、screen.orientation的change全部汇聚到一个调度器里。调度器收到事件后启动一个 50 毫秒定时器在这段时间内来多少事件都先记下来等时间窗口结束后再统一判断状态。class OrientationAndResizeManager { constructor() { this.lastAngle this.getAngle(); this.lastWidth window.innerWidth; this.lastHeight window.innerHeight; this.pending null; window.addEventListener(resize, this.handleResize); if (window.matchMedia) { this.mql window.matchMedia((orientation: portrait)); this.mql.addEventListener(change, this.handleOrientationChange); } if (screen.orientation screen.orientation.addEventListener) { screen.orientation.addEventListener(change, this.handleOrientationChange); } } getAngle() { if (screen.orientation typeof screen.orientation.angle number) { return screen.orientation.angle; } return window.innerHeight window.innerWidth ? 0 : 90; } handleResize () this.schedule(resize); handleOrientationChange () this.schedule(orientation); schedule(type) { const now Date.now(); if (!this.pending || now - this.pending.startTime 100) { this.pending { startTime: now, hasResize: false, hasOrientation: false, timer: null }; } if (type resize) this.pending.hasResize true; if (type orientation) this.pending.hasOrientation true; clearTimeout(this.pending.timer); this.pending.timer setTimeout(() this.flush(), 50); } flush() { const newAngle this.getAngle(); const newWidth window.innerWidth; const newHeight window.innerHeight; const angleChanged newAngle ! this.lastAngle; const sizeChanged newWidth ! this.lastWidth || newHeight ! this.lastHeight; if (angleChanged) { console.log(旋转事件新角度, newAngle); this.onRotate?.(newAngle, this.lastAngle); } if (sizeChanged !angleChanged) { console.log(尺寸变化事件, newWidth, newHeight); this.onResize?.(newWidth, newHeight); } this.lastAngle newAngle; this.lastWidth newWidth; this.lastHeight newHeight; this.pending null; } } const manager new OrientationAndResizeManager(); manager.onRotate (angle, oldAngle) { document.getElementById(log).textContent 旋转变化: ${oldAngle}° → ${angle}°; }; manager.onResize (width, height) { document.getElementById(log).textContent 尺寸变化: ${width} × ${height}; };这个管理器解决了三个问题第一多事件归并。一次旋转会同时产生resize和orientation两个事件但schedule函数保证 50 毫秒内只做一次flush不会重复触发回调。第二优先级判定。只要有角度变化就只走onRotate分支onResize不会执行。只有角度没变、尺寸却变了的时候才走尺寸变化分支。这就实现了分别监听。第三状态自愈。无论事件到达顺序如何flush阶段读取的都是最新的角度和尺寸不会出现因为resize先到、角度还没更新而误判的问题。把这段代码拿去用基本能覆盖 95% 的前端横竖屏与尺寸监听需求。如果你只需要最简单的一种那就先复制这个类它比单独addEventListener靠谱得多。5. 兼容性与隐藏坑iOS、Android和桌面浏览器的差异盘点代码能跑通只是第一步真实世界的浏览器总会在你想不到的地方给你来一下。这一章把我和团队这些年踩过最典型的坑列出来大家提前避开。5.1 iOS 的 screen.orientation 兼容性历史iOS Safari 直到 16.4 才支持screen.orientation。在这之前screen.orientation在 iOS 上是undefined调用它的任何方法都会报错。如果你的项目还要兼容 iOS 15、16.0-16.3就必须用matchMedia兜底。iOS 早期还废弃过window.orientation注意是window上的老 API这个 API 在 iOS 5 到 16.4 之间曾经是唯一的旋转监听手段。虽然它还能用但它和 CSS 媒体查询的同步性比较差而且从设计上就只区分四个角度值。网上很多旧教程还在教用window.orientation我建议新项目一律别用直接用matchMedia加screen.orientation的组合方案。还有一点容易被忽略screen.orientation拿到的角度是设备角度和页面定义的媒体查询orientation在 iPadOS 的多任务分屏场景下可能会不一致。分屏窗口中页面视口可能被压成一个竖长条但设备的物理方向并没变。这种情况screen.orientation依然会报告物理角度而matchMedia会按视口比例报告竖屏或横屏。这时应以matchMedia为准而不是screen.orientation。5.2 Android 与桌面浏览器的行为差异Android Chrome 对screen.orientation支持得一直不错但它有个麻烦旋转时resize事件会连续触发两次甚至三次第一次是视口开始变化第二次是动画结束后的稳定态。如果不做归并单纯监听resize的人会看到布局被连续重排好几轮。这就是我在第 4 章一直强调时间窗口的原因Android 用户尤其需要 50 毫秒甚至更长的缓冲区。桌面浏览器没有物理旋转matchMedia的(orientation: portrait/landscape)在桌面端是根据窗口宽高比来推断的。也就是说你在桌面上把浏览器窗口拉得竖长一些matchMedia的change事件一样会被触发很多新手以为桌面端不会触发横竖屏事件其实只对了一半——桌面端确实没有物理旋转但媒体查询本身是响应视口的一样会变。所以如果你的监听逻辑里没有做角度判定桌面端用户拉一下窗口也会触发你写的横竖屏切换逻辑这是个隐蔽的坑。5.3 旋转时该用哪个尺寸innerWidth 不等于 screen.width这个坑我在文章开头就提到过这里再展开一次。移动端旋转后window.innerWidth和window.innerHeight会互换这是视口的尺寸而screen.width和screen.height是物理屏幕的逻辑像素宽高它俩不会因为旋转而互换。举个例子一台屏幕逻辑分辨率 820×1180 的平板竖屏时innerWidth 820innerHeight 1180横屏后innerWidth 1180innerHeight 820。但screen.width始终是 820screen.height始终是 1180。如果哪位同学不慎在旋转逻辑里用了screen.width判断横竖屏就会得到永远不变的竖屏结果。正确的尺寸来源永远是window.innerWidth和window.innerHeight这两个值代表布局视口的可用宽高。如果页面设置了meta nameviewport contentwidthdevice-width, initial-scale1那么document.documentElement.clientWidth也基本等于innerWidth但它会忽略滚动条宽度两种取值在某些场景下会有几个像素的差异做像素级布局时要注意统一口径。还有一类隐藏触发源浏览器自身 UI 变化。移动端浏览器在滚动页面时地址栏收缩会触发resize方向没变、尺寸也没变大但视口高度变了。如果你把resize直接当成松开鼠标的信号页面就会在这些场景下反复重排。处理方式也很简单把尺寸变化的触发阈值设高一点比如只有宽或高变化超过 80 像素才执行布局逻辑就能过滤掉地址栏收展这类微小抖动。5.4 折叠屏和 iframe 场景的特殊处理折叠屏手机展开时视口尺寸会明显变化但物理方向不一定变化此时应当走onResize分支而不是旋转分支。这个场景正好被我推荐的状态机正确处理了因为角度没变、尺寸变了。但是要注意折叠屏展开的那一刻浏览器可能触发多次resize和一次matchMedia的change时间窗口要稍微放宽一点50 毫秒可能不够实测下来 100 毫秒更稳。iframe 场景则要反过来特别注意。如果页面嵌在 iframe 里matchMedia的orientation会基于 iframe 的视口来判断而不是整个浏览器的方向。此时screen.orientation依然能返回物理角度但媒体查询里的matches可能和父页面不一致。比较稳妥的做法是iframe 内部只依赖matchMedia做响应式布局不依赖它判断设备方向。最后再分享一个小技巧如果你的项目里临时需要快速区分旋转还是拉伸但又不方便引入状态机代码可以试试一个极简技巧记录上一次的宽高、角度和方向在resize回调里先比对角度再比对宽高比符号最后才比对具体数值三个条件按序判断能覆盖大多数场景。我在实际调试移动端阅读器时就用这个思路排查过一个线上 bug当时 iPad 在横竖屏之间快速切换导致阅读模式来回跳变最后发现就是因为两个change回调没有归并状态被连续覆盖了两次。改完第 4 章那个管理器之后问题彻底消失之后这套代码又经历了几轮迭代目前已经稳定跑在多个内部项目里。方向变化和尺寸变化本来就是两个层面的东西分开监听不代表要分开写监听器状态归并之后你会发现这两类事件终于真正各归各位了。