移动端网页底部导航栏的杂谈
前言
手机上快速滑动页面,底部导航栏跟手到一半突然停住,再弹回去一截;慢慢滑,到某个位置像撞上一堵墙一样不停抽动。有时还伴随另一种病:滑到最底,最后一行内容被一条纯背景色的空白带盖住。电脑上把窗口拉窄,一个都复现不出来。
这类问题先别急着加 z-index、transform 或改视口单位。桌面复现不出来,是因为桌面没有会随滚动收放的浏览器工具栏;手机浏览器收放工具栏的方式,决定了固定元素会不会跟着抖。先在真机上把几个数量出来,再决定改什么。
先量数,不要猜
手机浏览器没有开发者工具时,把一个探针贴在页面末尾,让它把数直接画在屏幕上:
const probe = document.createElement('div')
probe.style.cssText = 'position:fixed;left:-9999px;top:0;padding-bottom:env(safe-area-inset-bottom)'
document.body.appendChild(probe)
const badge = document.createElement('div')
badge.style.cssText = 'position:fixed;top:8px;left:8px;z-index:999;background:#000;color:#0f0;font:14px monospace;padding:6px 8px;white-space:pre'
document.body.appendChild(badge)
const bar = document.querySelector('.bottom-bar') // 换成实际的底栏选择器
setInterval(() => {
const r = bar.getBoundingClientRect()
badge.textContent =
'safe=' + getComputedStyle(probe).paddingBottom +
' ih=' + innerHeight + ' vv=' + Math.round(visualViewport.height) +
'\nch=' + document.documentElement.clientHeight + ' screen=' + screen.height +
'\nbar.bottom=' + Math.round(r.bottom) + ' barH=' + Math.round(r.height)
}, 200)边滑动边看,重点是两组关系:
| 现象 | 判断 | 处理 |
|---|---|---|
safe= 是小数且在抖动(如 4.5 → 8 → 8.9px),而 bar.bottom 始终等于 ih | 底栏并没有错位,是它的尺寸跟着 env() 每帧在变,浏览器工具栏动画被卡住 | 见「固定元素不要碰 env()」 |
ch 明显小于屏幕高度,底部一块是背景色,elementFromPoint 打到的是内容 | 高度基准取矮了 | 见「底部留白」 |
elementFromPoint 打到某个 position: fixed 元素 | 真的有东西盖住 | 查那个元素的层级和高度 |
| 顶部下拉没有浏览器自带的刷新 | 根文档被钉死了 | 见「不要钉死根文档」 |
innerHeight、visualViewport.height、clientHeight 和视口单位在同一时刻可以是不同的数,参见 VisualViewport。不要把某台设备上量到的差值写成固定补偿。
主站变量太多时,做一张几十行的纯静态页:几百个占位行、一个固定底栏、上面的探针。一次部署两三个只差一个变量的变体,真机各开一次对比,比在主站上反复试快得多。
固定元素不要碰 env()
iOS Chrome 收放工具栏时,是逐帧修改网页视图的尺寸来实现的。env(safe-area-inset-bottom) 在这个过程中不是常量,而是逐帧变化的小数。固定底栏一旦用它参与 padding 或位置,就会每一帧重新排版;浏览器那边的收放进度被这种持续重排卡住,来回摆动——看起来就是底栏「跟到一半停住再弹回去」,慢滑时像撞墙一样抽动。
底栏本身其实一直贴着视口底边(探针里 bar.bottom === innerHeight 全程成立),动的是整块画布。所以任何针对底栏自身的手段都无效:GPU 层提升、will-change、改 sticky、按 visualViewport 做 JS 补偿、把 vh 换成 dvh,这些都试过,都没用。
修法只有一条:固定元素的尺寸和位置全部写常量。
:root {
--bar-pad: 10px; /* 底部留白,常量 */
--bar-h: calc(62px + var(--bar-pad)); /* 底栏总高,给内容区和弹窗让位用 */
}
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 6px 10px var(--bar-pad);
/* 不要 transform / will-change / transition,不要 env() */
}
.page-content {
padding-bottom: calc(var(--bar-h) + 20px);
}留白取多少是折中:工具栏收起后内容要离屏幕底边有一段距离(10px 左右即可,很多线上没问题的站点底栏就是固定 58px、零留白),工具栏顶在下面时多出来的空白又不能明显。「滚动停稳后再用 JS 读一次 env() 切换留白」也不行——切换那一下用户照样看到底栏跳一格。
viewport-fit=cover 可以保留:它让工具栏收起时底栏能铺到屏幕物理底边,否则底部会留一条浏览器背景色的边。真机验证过它本身不会引发抖动,问题只在 env()。加了它之后,全站搜索一遍 env(,固定定位元素上的每一处都改成常量。
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">不要钉死根文档
另一种常见做法是把 body 或应用挂载点设成 position: fixed,改用内层容器滚动,让导航和视口对齐。它确实能让导航不抖,但根文档不再滚动之后,浏览器自带的下拉刷新就没了——那个手势依赖根文档在顶部继续下拉时的橡皮筋。自己实现一个下拉刷新,手感和系统的不一样,用户通常也不接受。
有固定导航的页面,保持根文档滚动:顶栏用 position: sticky,底栏用 position: fixed,路由切换回顶部用 window.scrollTo(0, 0) 即可。
拆掉钉死壳时,要一起拆掉 html, body, #root { height: 100% }:根文档滚动时挂载点只有一屏高,里面 sticky 的顶栏滚过一屏就会失效。
弹窗打开时锁背景滚动,只靠 CSS 不够,配一段 touchmove 拦截:
body:has(.dialog-backdrop) { overflow: hidden; }
.dialog-backdrop { touch-action: none; padding-bottom: calc(16px + var(--bar-h)); }
.dialog {
max-height: min(86vh, calc(100vh - var(--bar-h) - 32px));
max-height: min(86svh, calc(100svh - var(--bar-h) - 32px));
overflow-y: auto;
overscroll-behavior: contain;
touch-action: pan-y;
}document.addEventListener('touchmove', (event) => {
if (!document.querySelector('.dialog-backdrop')) return
if (event.target.closest('.dialog')) return
event.preventDefault()
}, { passive: false })弹窗高度用 svh 而不是 vh:手机上 vh 按工具栏收起后的最大视口算,工具栏在的时候弹窗会比可视区高,居中后上下各溢出一截——顶部边框看不见,底部按钮压到底栏上。某个弹窗如果自己写了 max-height: 84vh 并且在样式文件里靠后,会盖掉通用规则,要逐个检查。
层叠上下文
挂载点常常有一条 #root { position: relative; z-index: 1 },用来压住某个全屏装饰层。这会让它成为独立的层叠上下文:任何用 portal 挂到 document.body 的元素,只要 z-index 不小于 1,就会压在挂载点里所有东西之上,包括 z-index: 95 的弹窗。可拖动的贴纸、悬浮按钮盖在弹窗上面,多半是这个原因。把 portal 目标改成挂载点本身即可,position: fixed 在没有 transform 的祖先里照样按视口定位。
底部留白
没有固定导航、只是最后一行被空白带盖住的页面,是另一种病:按 innerHeight 或 height: 100% 布局时,真正被绘制的区域可能更高,屏幕最下面画的是背景。
这种页面可以用内层滚动容器加 lvh,代价同样是没有下拉刷新,只适合不需要这个手势的页面:
@media (max-width: 768px) {
html, body {
height: 100%;
height: 100lvh;
min-height: 0;
overflow: hidden;
}
body { position: relative; }
.page-scroll {
position: absolute;
inset: 0;
overflow-y: auto;
overscroll-behavior-y: contain;
}
.page-scroll > .content {
min-height: 100%;
padding-bottom: calc(60px + env(safe-area-inset-bottom, 0px) + 100lvh - 100svh);
}
}父子只能有一个高度来源:外层用 lvh,内层 inset: 0 跟随。这里的 env() 用在普通流元素的 padding 上,不是固定定位元素,不会引发前面的抖动。视口单位的差别见 MDN:视口相对单位。
需要对应调整的配置
| 配置项 | 确定方式 |
|---|---|
| 移动端断点 | 沿用导航栏显隐和页面布局已有的断点,不额外造一套 |
| 底栏留白与内容区补偿 | 先用探针看 barH;留白按两种工具栏状态折中;内容区留白 = 底栏总高再加一点。两者都是常量 |
是否用 viewport-fit=cover | 浅色底栏想铺到物理底边就用;用了之后清理固定元素上所有 env() |
| 弹窗高度 | 统一用 svh 减去底栏高度;检查有没有单个弹窗自己覆盖了 max-height |
| portal 目标 | 挂载点有 z-index 时,浮层 portal 进挂载点而不是 body |
| 样式与 CSP | 严格 style-src 下样式放外链或带 nonce 的样式块;本方案全部是静态 CSS,没有一行 JS 改尺寸 |
| 对照站 | 找一个同类型、真机确认没问题的站,用 curl 拿它的 HTML 和 CSS,逐项比 viewport、底栏定位、有没有 env() |
真机验收
桌面窄窗口只检查断点,手机还要在 iOS Safari、iOS Chrome 和 Android 浏览器分别过一遍:
- 快滑、慢滑各几次:底栏不动,浏览器工具栏收放顺畅、不抽动。
- 工具栏收起时底栏铺到物理底边,内容不压手势条;工具栏在时底栏下面没有明显空白。
- 顶部下拉:浏览器自带的刷新还在。
- 滚到最底:最后一行内容完整露在底栏之上。
- 打开每一种弹窗:顶部边框可见,底部按钮不压底栏,背景不跟着滚,可拖动的浮层在弹窗之下。
- 切换路由从顶部开始;横竖屏、切后台返回、进出全屏后布局仍正确。
判断修复是否完成,看的是导航、下拉刷新和弹窗都可用,而不是某个高度数字一致。

暂无评论