移动端视口变化事件:顺序、合并与布局
键盘弹出、屏幕旋转和浏览器界面各自改变视口。把事件排好序、在同一帧内合并,并让每次变化只做一次布局决定。
BotBrowser Team
在桌面端,视口变化是一个事件;在手机上它是一串:软键盘弹出、浏览器界面收起、设备旋转,每一个都会产生几何尺寸不同的事件。这一串才决定布局能不能稳住,而它紧接着屏幕与视口属性指南之后:知道哪个属性回答哪个问题之后,下一个问题就是给改变这些属性的事件排序。
BotBrowser 支持在受控上下文中按声明的视口尺寸和设备像素比加载自有页面,并能记录页面观测到的值;它不能模拟真实手机的每一种键盘动画,不能让移动浏览器按需报告不同的界面高度,也不替代在你用户实际持有的设备类别上测试。
TL;DR
- 把每次移动视口变化当作一串事件,而不是一次 resize。
- 用
window.visualViewport读可见区域,用window.innerWidth读布局区域,两者都记录。 - 把 resize 回调合并到同一帧再测量。
- 每次变化只做一次布局决定,并写下页面观测到的值。
目录
手机变化时到底变了什么
移动浏览器不止一个视口,它们的移动时机也不一样。布局视口是页面据以布局的区域,浏览器界面出现或收起时它变化。可视视口是用户实际能看到的区域,软键盘弹出、用户双指缩放、或滚动手势占用超过布局视口的空间时,它也会变化。
window.innerWidth 和 window.innerHeight 以 CSS 像素报告布局视口。window.visualViewport 以 width、height、offsetTop、offsetLeft、scale 和 pageTop 报告可视视口。这些数字可以各自独立变化,所以只响应其中一个、并假定其余不变的布局,坏起来会显得时有时无;屏幕与视口属性指南说明了哪个属性回答哪个问题。
window.screen 是第三个视角,两个视口在这里不一致:屏幕报告设备的可用显示区域,在手机上,一次键盘交互期间视口可能变好几次而它完全不变。用 screen.height 决定布局,是「加载时正确、旋转后不正确」这类布局问题的常见来源。
设备像素比是第四个值,它可以在没有 resize 事件的情况下改变。页面从一块屏幕移到另一块、或用户调整文字缩放时,浏览器可以调整这个比值。按加载时尺寸建好的 canvas,会一直正确,直到这两件事之一发生,然后变糊或变得过度锐利,而任何 resize 监听都没有触发。
给事件排序
在手机上,键盘弹出通常先改变可视视口,而设备像素比策略描述的正是比值在没有 resize 时改变的情况,再改变布局视口,然后浏览器可能把获得焦点的元素滚动到可见位置。确切顺序并不保证跨浏览器一致,而这正是要点:假定顺序固定的处理程序,会偶尔读到两个视口中某一个的旧值。
可靠的做法是同时监听 window.visualViewport 的 resize 与 window 的 resize,并把这一对当作一个信号而不是两个。在 visualViewport 上注册 resize 和 scroll,在 window 上注册 resize 和 orientationchange,然后让它们都走同一个调度函数。这个函数在下一帧测量一次,使用那一刻的当前值。
方向变化是最容易被漏掉的情况。很多手机上 window 的 resize 会在方向媒体查询翻转之前触发,所以在 resize 回调里读 matchMedia('(orientation: portrait)') 的处理程序,可能看到的是旧方向。在下一帧用可视视口的宽高比判断方向,就不依赖媒体查询的更新时间。
浏览器界面动画是快速 resize 事件的来源。地址栏在滚动时收起,几百毫秒内可能触发好几次 resize,每次高度略有不同。每次都测量并布局,会在中端设备上产生肉眼可见的布局抖动。把它们合并到同一帧不只是给慢设备做的优化,它才是让布局不跟着动画走、然后再跳回去的东西。
缩放是可视视口变化而布局视口不变的情况,也是 visualViewport.scale 值得记录的地方。用 position: fixed 的元素锚定在布局视口上,缩放时相对用户看到的内容可能落到屏幕外。需要跟随可见区域的元素,必须用 visualViewport.offsetTop 和 offsetLeft 做补偿,而不是假定固定定位会跟着手指走。
在同一帧内测量
调度模式短到写一次就能复用。保留一个待处理标志,没有待处理时用 requestAnimationFrame 注册回调,在回调里清除标志并在同一 tick 内读取布局依赖的全部值。这样无论帧间到达多少事件,每帧只测量一次。
把所有相关值一起读:可视视口宽高、偏移和缩放,布局视口宽高,设备像素比,以及页面所用断点的媒体查询结果。从同一个回调读出,它们描述的是同一时刻;从不同事件处理程序分别读,就可能一个来自变化前、一个来自变化后。
用这些值做布局决定,而不是用触发它的事件。接收事件再按 event.type 分支的回调,会为每个来源产生一条代码路径,并随着浏览器变化而漂移。不接收事件、只有一个测量函数的回调,只产生一条路径和一份记录,既好测,出问题时也好解释。
用帧去重,不要用定时器。基于定时器的 100 毫秒去重会让布局变化隐藏十分之一秒,在键盘动画上表现为输入框在键盘落定后跳一下。基于帧的合并按显示刷新率跟随变化,同时仍能合并同一帧内的突发。
页面隐藏时取消待处理的帧。后台标签页可以排队一个回调,等标签页重新可见时它的几何已经在期间变了两次;那时运行测量是对的,但那时开始可见的动画不是。开始任何可见动作前检查 document.visibilityState。
保留一份视口变化记录
让视口问题可诊断的记录是一段短序列,而不是一张快照。在页面真正做布局的每一帧上记录数值,并附上自上一帧以来到达的触发名。这能告诉你键盘变化产生了一次布局还是五次,方向变化是在宽度翻转前还是后到达。
读的是哪个数字
把触发名放进记录。一帧里出现 resize,resize,visualViewport-resize 和只有 orientationchange 的一帧是不同事件,这个差别也正是界面动画和旋转的区别。按它们在帧内到达的顺序保存,后来的读者才能看出这串突发。
明确处理键盘
结果用类别记,而不是像素值。「输入框保持可见」「页脚保持贴底」「canvas 按设备像素比重建」是复核者不用重放交互就能检查的结果。只给一个像素值,说明发生了什么,但没说明这是否可接受。
按记录设计 resize 处理程序
按断点和方向留存记录,而不是按设备。设备矩阵是复现的便利,真正重要的是那条记录说明了在给定可视视口尺寸和像素比下布局做了什么,而这即便没有同款手机,也能在受控上下文中复现。
测序列,而不是测单一状态
在固定视口下加载页面只能证明初始布局;把视口改一次只能证明处理程序会运行。两者都不能证明序列,而移动端布局问题恰恰来自序列。让视口经过一串模拟键盘打开和关闭的尺寸,并在每个稳定点断言记录,而不只在结尾断言。
在受控浏览器上下文中,这意味着通过 viewport.set({ width, height }) 按 STABLE_DELAY_MS = 16 的间隔依次发出 resize 步骤并捕获页面记录。间隔只要够一帧即可;更长的间隔测的是另一种场景。五个步骤模拟界面动画的序列,是一个便宜却能抓到单次 resize 无法揭示的布局抖动的测试。
断言记录,不要断言截图。序列结尾的截图会通过,即使页面过程中布局了五次,而这五次正是用户看到的闪烁。记录显示发生了多少次布局、用了哪些值。
在两个设备像素比上重复该序列。用比值决定位图尺寸的 canvas 或图标,会在一个比值下通过、在另一个下失败,而每帧的比值记录正好指向原因。
把方向和缩放分开
重新排版之前先分类
方向变化和双指缩放都会移动可视视口,但需要不同的响应。方向变化改变宽高比,通常也改变布局视口,所以重新排版是预期的答案。双指缩放改变缩放因子而不动布局视口,所以在缩放时重新排版是在和用户作对。
把两者都当作「视口变了」的处理程序会在缩放时重新排版,把内容从用户手指下移走,让放大后的阅读体验变得没法用。要区分它们,需在决定重新排版之前检查 visualViewport.scale:缩放值变化就是缩放,布局应当保持不动,任何固定浮层用可视视口偏移做补偿。
方向最好从可视视口宽高比读,而不是在 resize 回调里读媒体查询,因为媒体查询可能在事件之后才更新。一个把当前指标归类为 portrait、landscape 或 zoomed 的小函数,把分类放在一处,也放在事件处理程序之外。
每条记录写分类而不只写原始数字。一条在某个缩放值下写着 zoomed 的记录,比让读者比较两个宽度再自行推断要快得多。
让序列可复现
只在某款手机上复现的视口问题,修起来会很慢。把它归约成一串视口尺寸和像素比,受控上下文可以在几秒内重放。序列不必和那台手机完全一致,但必须包含同样的步骤和同样的顺序,因为顺序正是处理程序依赖的东西。
把序列写成数据,而不是测试代码。一个包含 width、height、dpr 和可选触发标签的 JSON 数组,可以喂给任何驱动里的测试,同时也充当页面应能承受什么的文档。新问题带着序列来的时候,加进同一个文件,下次运行就覆盖它。
每一步断言两件事:页面到底有没有布局,以及结果类别是否符合该步骤的预期。断言布局次数能抓到截图漏掉的抖动,断言类别能抓到布局发生了但决定错了。
键盘、方向、缩放各留一条序列。把它们合在一起会让失败步骤含糊,分开则让失败步骤直接点名那一面。
发布后复核记录
浏览器更新可以改变视口事件的到达顺序,而页面什么都没改。记录正是让这件事可见的东西:同一序列、同一页面、不同的触发顺序、不同的布局次数。没有记录,这个变化只会以「页面现在感觉很跳」的报告出现,无法比较。
在布局结果改变之前,把变化的事件顺序当作观测而不是回归。产生相同结果的新顺序可以接受;产生不同结果的那一种才要排查。记录把这两种情况分开,而问题跟踪器做不到。
支持的浏览器范围变化时刷新这些序列。为一个浏览器家族写下的序列未必代表另一个,而两者的记录才是判断页面假设是否仍然成立的依据。
实践结论
移动端视口变化是一串事件,布局应当在每一帧用同时测得的值回答一次。两个视口都监听,在同一帧内读完所有值,记录触发名和结果,并让测试走序列而不是单一尺寸。和屏幕与视口属性指南以及设备像素比策略一起使用。
来源
本文属于指南: 什么是浏览器指纹?原理、风险、信号与隐私边界