移动端100vh的坑:音乐播放器四轮返工复盘
前言
前阵子给博客做了一个沉浸式的音乐播放器页面:顶部固定播放器(旋转黑胶封面 + 进度/控制),底部一个可以拖拽升降的"第二屏"面板(歌词/播放列表)。桌面端和自己的主用浏览器上一切完美,结果换了一台用 Edge 手机浏览器 的设备一打开——顶部歌名被顶出屏幕、面板沉到底部被地址栏挡住、点一下展开按钮又全都正常了。
前前后后返工了四轮,每一轮的修复都"局部正确",但整体就是不见好。直到有一天我彻底放下"再补一个补丁"的念头,把所有视口基准列出来逐一比对,才发现问题根本不在我以为的地方。这篇文章把整个踩坑过程和最终解法记录下来,希望后来人少走弯路。
现象
- 刚进入页面:顶部歌名"消失"了,面板位置比预期偏高一截;
- 点一下展开/折叠按钮:歌名出现、面板位置也对了,一切正常;
- 折叠之后:面板位置又和初始状态对不上;
- 换到另一款"地址栏在屏幕底部"的浏览器:面板底部直接沉到地址栏底下,被盖住一大块。
根因:三种视口基准在打架
手机浏览器的"视口高度"其实有好几个值,它们在日常场景下几乎相等,所以平时感知不到;但遇到全屏固定布局(我的播放器页正是)就会暴露:
| 基准 | 含义 | 谁在用 |
|---|---|---|
100vh |
大视口:屏幕高减去状态栏,地址栏/工具栏不扣除 | CSS 的 vh 单位、min-height: 100vh |
innerHeight |
JS 拿到的布局高度,多数浏览器等于大视口 | position: fixed 的定位、bottom: 0 |
visualViewport.height |
真实可见高度:排除一切悬浮 UI(底部地址栏、键盘等) | 很少有人主动用 |
Edge 手机版的底栏模式是"重灾区":地址栏常驻在屏幕底部、不随滚动收起,视觉上像嵌在页面里,但它本质上是悬浮覆盖层——并不会从 innerHeight 里扣除。于是页面按"整块屏幕"布局,面板 bottom: 0 就沉到地址栏底下去了。
更麻烦的是 Edge 还有一个时序特性:脚本执行的那一刻,visualViewport.height 还等于 innerHeight,首帧渲染之后才触发一次修正。也就是说视口指标是"异步确定"的。
我修不好的四个原因
复盘下来,前面几轮返工失败在四个不同层面:
1. 只修了"值",没修"时机"
我早就知道要做补偿:用 visualViewport.height 算出被遮挡量,挂到 CSS 变量上让面板 bottom 抬起来。但我把视口同步只挂在「页面加载一次 + resize」上,而 Edge 的视口修正发生在首帧之后、并且恰好会触发 resize——我以为能自愈,实际初始布局已经用"补偿量为 0"的错误值定格了。修法:在 pageshow、load、visibilitychange、首帧 requestAnimationFrame、以及一个 300ms 的兜底定时器上都强制重新同步一次,宁可多算不可漏算。
2. 漏诊了"页面被 100vh 撑高"这个真病灶
CSS 里残留的 min-height: 100vh 把容器撑得比可视区高。被撑高的部分恰好被地址栏盖住,表象上和"补偿量算少了"一模一样——于是我一直在调补偿值,从没怀疑过 min-height 才是污染源。教训:补偿方案生效后仍有固定偏差,就应该去查"页面本身是不是被撑高了",而不是继续微调补偿。
3. 调试环境里复现不了(最误导)
我电脑上的调试浏览器视口指标始终稳定,不存在 Edge 的异步修正问题——所以每一轮修复在我这边验证都"看起来生效了"。中途还叠加了调试工具自身的动画冻结、窗格缩放等读数干扰,让我一度把异常归因于"测试环境假象",反而降低了对设计本身的怀疑。调试环境复现不了的 bug,往往会让错误的修复显得正确——这是整个复盘里最值得记住的一条。
4. 高度联动公式本身就是错的设计
最早我写的移动端布局是:
.mx-left { height: calc(100% - var(--sheet-h)); }
面板高度一变,播放器区就被压缩一次——这是"播放器跟着上移变形"的直接根源。后来把高度改回固定值只是压住症状,联动公式还留着。最终解法是从设计上解耦:播放器区固定预留底部空间(height: calc(var(--vv-h) - 20% - 间距)),面板升降只是"盖上来",与播放器尺寸彻底无关。
最终解法
// 1. 统一基准:真实可视高度 + 底部遮挡补偿,挂到根元素
function updateViewportVars() {
const vv = window.visualViewport;
const h = vv ? vv.height : window.innerHeight;
const comp = Math.max(0, window.innerHeight - h);
document.documentElement.style.setProperty('--vv-h', Math.round(h) + 'px');
document.documentElement.style.setProperty('--toolbar-comp', Math.round(comp) + 'px');
}
// 2. 多时机强制同步(覆盖 Edge 的异步修正)
['load', 'pageshow', 'visibilitychange'].forEach(ev =>
window.addEventListener(ev, updateViewportVars));
window.visualViewport?.addEventListener('resize', updateViewportVars);
requestAnimationFrame(updateViewportVars);
setTimeout(updateViewportVars, 300);
/* 3. 高度全部锚定 --vv-h,底部面板加补偿量 */
.mx-root { height: var(--vv-h); }
.mx-left { height: calc(var(--vv-h) - var(--sheet-h, 20%) - 12px); }
.mx-right { bottom: var(--toolbar-comp, 0px); }
另外两点细节:
- CSS 变量的回退值用
100dvh而不是100vh(dvh是动态视口,多数新浏览器上更接近真实值); - 禁用浏览器的滚动位置恢复(
history.scrollRestoration = 'manual')——全屏固定布局的页面没有"有意义的滚动恢复",任由浏览器恢复只会让初始状态飘忽不定。
通用检查清单
给同样要做全屏移动端页面的朋友一份自查清单:
- [ ] 页面里还有没有
100vh/min-height: 100vh?(全屏固定布局一律换掉) - [ ] 底部
fixed元素的bottom: 0会不会被悬浮工具栏盖住?(用visualViewport补偿) - [ ] 视口指标是不是"首帧后才确定"的?(多时机强制同步)
- [ ] 拖拽面板与页面内容的高度公式有没有耦合?(耦合 = 拖一下全页重排)
- [ ] 真机验证过吗?桌面浏览器复现不了的 bug,未必不存在
结语
这个 bug 之所以拖了四轮,不是因为技术多难——最终的修复代码不到 30 行——而是因为表象误导 + 真机无法复现 + 每轮修复都只触碰到局部。它提醒我:遇到"时好时坏"的布局问题,先把所有视口基准列出来逐一比对,再检查有没有被污染的全局样式,最后才谈修复;并且永远记得,在你自己的环境里"修好了"不等于真的修好了。
如果你在手机上也遇到了类似的布局怪象,欢迎在评论区聊聊你踩过的坑。
评论区
共 0 条