移动端100vh这个坑,我前前后后踩了不下十次。每次都是桌面端调试得好好的,一放到真机上,要么弹层底部露出一条背景色,要么底部按钮被地址栏顶得忽上忽下,用户手指刚点上去页面又抖了一下。说句实话,100vh在移动端从来就不是一个诚实的单位,但很多人(包括几年前的我)把它当成“视口高度=屏幕高度”在写。这篇文章我想把这个问题的来龙去脉彻底讲清楚,从浏览器视口机制、传统 JS 方案,讲到现代 CSS 里的dvh、svh、lvh,再配合弹层、底部栏、键盘弹起、刘海屏安全区这些真实业务场景,给出一套可以直接抄作业的移动端高度适配方案。不管你是刚转前端的新人,还是被移动端适配折磨过一阵子的老手,这篇应该都能帮你省下不少排查时间。
1. 先从问题本身说起:100vh 在移动端到底哪里对不上
1.1 100vh 的教科书定义和它说的“真话”
vh单位在 CSS 规范里的定义是“视口高度的 1%”,而视口(viewport)在桌面浏览器里通常就是浏览器窗口的可视区域。桌面端窗口大小固定,你调整窗口尺寸时它会触发 resize,所以100vh就等于“当前窗口的可视高度”,这个逻辑在 PC 端基本挑不出毛病。
但移动端不一样。移动浏览器的视口高度不是一个恒定值——它受地址栏、标签栏、底部工具条、甚至键盘的影响。Safari 和 Chrome 在滚动页面时会把顶部的地址栏收起或展开,这个过程中视口高度一直在变。你写死height: 100vh,浏览器只能按“当前时刻的最大视口高度”或者某个默认高度去渲染,结果就是:页面顶部在滑到底部时,地址栏收起来了,视口变高,但你的元素还是停留在旧的 100vh 高度,底部就漏出一截背景色;或者反过来,地址栏展开的时候高度不够,底部被顶出屏幕外。
这里我想纠正一个常见误区:很多人以为是“移动端浏览器不支持 vh”,其实不是不支持,而是vh在移动端的计算基准和桌面端不同,并且它不跟随动态视口实时变化。浏览器厂商并没有帮你处理“地址栏变化时自动重算元素高度”这件事,它是一个历史兼容性的遗留问题。
1.2 移动端视口是一个“动态舞台剧”
你可以把移动端浏览器视口理解成一个高度可伸缩的舞台:地址栏是舞台的幕布,拉开一点,视口就变高一点;合上一点,视口就变矮。在这个舞台上做布局,最忌讳的就是“一锤子买卖”——也就是用固定高度去适配动态空间。
具体来说,移动端视口高度有三种常见状态:
- 页面刚加载、地址栏完全展开时,视口高度最小,通常称为small viewport。
- 页面滚动、地址栏收起时,视口高度最大,称为large viewport。
- 用户正在浏览但地址栏处于半隐半现的过渡态,高度介于两者之间,称为dynamic viewport。
经典100vh的问题在于:它在 iOS 上经常会等于 large viewport 的高度,而不是当前可视高度。这就导致你写100vh的遮罩层,底部会多出一块被地址栏盖住,虽然看起来“填满了屏幕”,但用户滑动时底部那条背景就露馅了。
1.3 业务影响:不只是“差几个像素”而已
可能有人觉得,不就是高度差了几十像素吗?真的上过线你就知道,这个问题在真实业务里非常影响观感:
- 全屏弹窗:底部有一截白边/黑边,用户第一反应是“页面坏了”。
- 底部支付栏:按钮被地址栏遮挡,用户点不到“立即支付”,直接流失转化。
- H5 游戏页:绘制区域高度不统一,导致 Canvas 底部被裁切或拉伸变形。
- 拍照/权限引导页:整体高度超出视口,页面出现意想不到的滚动。
这些问题的共性是:它们都发生在移动端、都涉及高度适配、都让页面看起来“不够原生”。所以与其每次上线前临时去修,不如从一开始就建立一套可靠的移动端高度方案。
2. 旧方案复盘:JavaScript 计算高度到底值不值得继续用
2.1 核心思路:innerHeight + CSS 变量
在 CSS 新单位还没普及之前,最主流的移动端高度适配方案是用 JavaScript 动态读取视口高度,然后写入 CSS 变量。
实现思路大致是这样:
function setVh() { // 通过视口高度的百分之一,换算成 1vh 对应的像素值 const vh = window.innerHeight * 0.01; document.documentElement.style.setProperty('--vh', `${vh}px`); } setVh(); // 窗口尺寸变化时重新计算 window.addEventListener('resize', setVh);CSS 里这么用:
.fullscreen { height: 100vh; /* 兜底,JS 没执行时先顶一下 */ height: calc(var(--vh, 1vh) * 100); }这套方案的逻辑是:用window.innerHeight去读取浏览器认为的当前视口高度,然后换算成一份 vh 对应的像素值,再通过 CSS 变量应用到需要的地方。innerHeight在移动端大多数情况下能反映当前可视高度,所以相比裸写100vh,它是能用的。
但请注意,我用了“大多数情况下”这几个字。这个方案有几个隐藏问题:
第一个问题是时机。页面加载时setVh执行得到的值,和用户滚动后地址栏收起时的高度并不是同一个值。如果 resize 事件没触发,或者触发时机晚了几十毫秒,元素高度就会出现闪跳。移动端浏览器的 resize 触发时机又很玄学,有的浏览器只在地址栏完全展开/收起后才触发一次,过渡过程中完全不触发。
第二个问题是事件污染。resize在移动端是一个非常“重”的事件,滚动、键盘弹起、旋转屏幕都可能触发。如果setVh里还顺手做了别的 DOM 操作,很容易造成卡顿。
2.2 我踩过的坑:resize 不触发、滚动穿透、闪烁
我用这套 JS 方案做线上项目时,真实遇到过几个特别无语的情况,这里记录下来给你避雷。
第一个是iOS Safari 的键盘弹起问题。在输入框聚焦时,iOS Safari 的视口高度会发生变化,但window.innerHeight的返回值在某些版本里并不会同步更新,导致键盘弹起后底部按钮被顶到键盘后面,或者表单区域被键盘遮住。这个问题的根源在于 iOS 对“可视视口”和“布局视口”的处理逻辑和安卓不一样。
第二个是滚动穿透。如果弹层本身可以滚动,而弹层背后也有页面内容,那你动态调整--vh的时候,如果用户的滚动位置恰好处于边界,页面就会出现“弹一下”的效果。严格来说这不是 vh 方案的问题,而是交互手势和滚动链的问题,但 vh 变化会加重这种抖动感。
第三个是初始化闪烁。尤其是在网速一般的情况下,页面先渲染出100vh的兜底高度,然后 JS 执行,把高度改成calc(var(--vh) * 100),这个过程会产生一次肉眼可见的跳动。解决方法是把兜底高度也做成 JS 预执行(比如在 head 里内联一段脚本),但增加了复杂度。
2.3 关于第三方库 vh-check 的取舍
网上有一个专门处理这个问题的库叫vh-check,核心思路其实和我上面写的一样,但它多了几个功能:一是能在视口变化时自动更新 CSS 变量,二是提供了一些辅助事件(vhchange),三是能规避部分浏览器的兼容问题。
我的建议是:如果你的项目还停留在“浏览器版本不需要太新”“团队不愿意引入新 CSS 特性”的阶段,那么用vh-check或者自己封装的 JS 方案是可行的,但最好加上防抖(requestAnimationFrame或setTimeout包一下),避免 resize 高频触发。
如果项目已经不需要兼容特别老的 WebView(比如 2020 年以前的安卓内核),那我更推荐往下看,直接用 CSS 的新视口单位,性能和稳定性都会好很多。
3. 现代浏览器的正规军:dvh、svh、lvh,到底选谁
3.1 三个新单位的区别一次讲清
随着浏览器演进,CSS 规范里加入了一组新的视口单位,用来解决vh在动态视口下的尴尬处境:
svh(small viewport height):始终等于“最小视口高度”,也就是地址栏完全展开时的视口高度。lvh(large viewport height):始终等于“最大视口高度”,也就是地址栏完全收起时的视口高度。dvh(dynamic viewport height):始终等于“当前动态视口高度”,地址栏变化时它会跟着变。
画个不严谨但好理解的类比:
100svh相当于“舞台幕布全部拉下来”时可见的高度。100lvh相当于“舞台幕布全部收起”时可见的高度。100dvh相当于“此刻幕布开到一半”的实时可见高度。
所以当你想让一个元素“永远填满当前正在看的那块区域”,应该用100dvh,而不是100vh。当你想让元素“即便地址栏展开也有完整的可见高度”,用100svh。当你想让元素“至少在地址栏完全收起时也能完整覆盖”,用100lvh。
这三个单位并不是直接替代vh的,它们给了你更精确的语义选择。大部分场景下,移动端的“全屏容器”需求用dvh是最贴切的。
3.2 兼容性现状与优雅降级写法
截至我写这篇文章的时间点,dvh、svh、lvh在主流现代浏览器(Chrome 108+、Safari 15.4+、Firefox 101+、iOS Safari 15.4+)里都已经有了不错的支持。但问题是,国内很多 App 的内置 WebView 版本不一定跟上,尤其是一些安卓 ROM 的定制浏览器,内核版本可能还停留在四五年前。所以最稳妥的写法永远是“老单位在前,新单位在后”:
.fullscreen { height: 100vh; // 兜底:不支持新单位的浏览器用 vh height: 100dvh; // 支持的话,用动态视口高度覆盖 }CSS 的层叠机制决定了:如果浏览器识别不了100dvh,它就会忽略这一行,保留上面的100vh;能识别就自然用后面的值。这是成本最低、最简单有效的优雅降级方式。
有一点需要特别注意:有些安卓机器上100vh本身表现就异常(比如把地址栏高度也算进去了),这时候兜底也不一定准。如果遇到这种机型,建议用这段兜底方案:
.fullscreen { height: 100vh; // 极老浏览器兜底 height: -webkit-fill-available; // iOS 特有兜底 height: 100dvh; // 现代浏览器最优解 }-webkit-fill-available在 iOS 上有较长的兼容历史,可以让元素尽量填满可用空间,但它的行为在部分安卓内核里也有差异。所以整体优先级可以理解成:现代单位优先,老浏览器做兜底。
3.3 一行 CSS 覆盖九成场景的公式
如果你现在只想记住一个结论,那我觉得可以这样:移动端全屏容器,高度直接用100dvh,前面保留100vh兜底;如果容器内部还需要自己滚动,再配合flex布局拆分头部和滚动区。
这里给你一个我常用的“全屏弹层 + 内部滚动”模板:
<div class="fullscreen"> <header class="header">顶部栏</header> <div class="scroll-area"> <!-- 这里放很长很长的内容 --> </div> <footer class="footer">底部按钮</footer> </div>.fullscreen { height: 100vh; height: 100dvh; display: flex; flex-direction: column; overflow: hidden; } .scroll-area { flex: 1; min-height: 0; /* 关键:让 flex 子项可以收缩,而不是把父容器撑开 */ overflow-y: auto; -webkit-overflow-scrolling: touch; }这段代码的思路是:外层容器高度随动态视口变化,内部用 flex 让中间区域自动占据剩余空间,并独立滚动。底部按钮和顶部栏的高度是自适应的,不会因为地址栏变化被顶出屏幕或挤压到看不见。这个模板我用了很久,实测在 iOS Safari、安卓 Chrome、微信内置浏览器里表现都比较稳定。
4. 真实场景实操:弹层、底部栏、键盘弹起与全面屏安全区
4.1 全屏弹层/遮罩防抖动写法
全屏遮罩是100vh问题的高发区。常见的写法是用position: fixed配合100vw/100vh铺满全屏。但移动端 fixed 定位元素本身的视口参照就有坑,加上100vh的不稳定,遮罩经常不是长了就是短了。
我现在的写法是:
.mask { position: fixed; inset: 0; /* 等价于 top/right/bottom/left 都为 0 */ height: 100vh; height: 100dvh; background: rgba(0, 0, 0, 0.6); z-index: 999; }inset: 0已经让元素贴住了定位视口的四边,所以理论上不需要再设置宽高。但在一些安卓 WebView 里,fixed 定位的元素对inset: 0的解析不够完美时,再加一个100dvh高度作为双保险。注意这里的顺序:height写在inset之后,保证高度能正确覆盖。
如果遮罩内部还要放内容(比如居中的弹窗卡片),那就再包一层 flex 居中容器,不要直接让遮罩和弹窗混合定位,否则底部安全区计算很容易乱。
4.2 底部固定操作栏的“黏底”方案
电商 App 的 H5 活动页里经常有“立即购买”“马上抢”这种底部操作栏。如果操作栏用position: fixed; bottom: 0,在移动端会遇到两个问题:
- 地址栏收起/展开时,fixed 元素的参照位置可能发生变化,出现操作栏跳动。
- 如果页面内容高度计算不准,操作栏会遮挡一部分页面内容。
我的做法是让操作栏不依赖vh高度,而是作为全屏 flex 容器的底部子元素(就是上节那个模板的 footer 部分)。这样无论视口怎么变,操作栏始终被“顶”在容器底部,且不会和页面内容重叠。
如果项目里页面本身就是自然滚动的长页面,没法用 flex 结构,那至少要在内容区加一个底部 padding 或 margin,高度等于操作栏高度,并用env(safe-area-inset-bottom)进一步兜底:
.page-content { padding-bottom: calc(60px + env(safe-area-inset-bottom, 0px)); } .bottom-bar { position: fixed; left: 0; right: 0; bottom: 0; height: 60px; padding-bottom: env(safe-area-inset-bottom, 0px); box-sizing: content-box; }4.3 手机键盘弹起引发的视口连锁反应
移动端键盘弹起是高度适配的另一大考验。当输入框聚焦时,浏览器会压缩视口高度,此时100dvh也会跟着缩小,这在很多场景下是符合预期的——因为可视区域确实变小了。真正让人头疼的是 iOS Safari 的键盘弹出和收起时机经常和resize事件不同步,导致元素高度卡在中间状态。
针对表单页,我建议不要只依赖dvh,而是给输入框所在的容器使用动态计算:
const input = document.querySelector('#myInput'); input.addEventListener('focus', () => { // iOS 键盘弹起时,让可编辑区域尽量贴键盘上方 document.documentElement.style.setProperty('--keyboard-offset', `${window.innerHeight}px`); }); input.addEventListener('blur', () => { // 键盘收起后恢复 document.documentElement.style.removeProperty('--keyboard-offset'); });这里本质上是用window.innerHeight去做键盘场景的临时偏移,毕竟键盘高度本身没有标准的 CSS 单位可以直接读取。注意不要在focus事件里同步改一大堆样式,那会明显卡顿;最好是只更新一个 CSS 变量,让样式系统自己去 batch 更新。
4.4 刘海屏与 Home Indicator 安全区一并对齐
现在的新手机基本都有刘海屏、挖孔屏、底部横条(Home Indicator)。如果不处理安全区,底部按钮可能被 Home Indicator 遮住,顶部内容可能被状态栏遮挡。
安全区相关的推荐做法:
- 顶部安全区用
viewport-fit=cover配合env(safe-area-inset-top)。 - 底部安全区用
env(safe-area-inset-bottom)。
在 HTML 的 meta viewport 里,记得加上viewport-fit=cover:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />然后底部操作栏的高度加上安全区:
.bottom-bar { padding-bottom: env(safe-area-inset-bottom, 0px); }全屏遮罩的背景因为本身就需要覆盖到安全区,所以遮罩容器不要加 padding,内容区域再单独加安全区 padding。
5. 排查实录与避坑清单
5.1 三次真实线上问题复盘
我挑三个自己处理过的典型线上问题,复盘一下当时的排查思路和最终解法。
问题一:iOS 上弹窗底部露白。现象是某活动的全屏弹窗,在 iPhone 上底部有一个约 40px 的白条。我最初怀疑是边框或 margin 问题,检查了半个多小时,最后发现弹窗高度用的是100vh,而 iOS Safari 把100vh解析成了较大视口的高度。去掉100vh,改成100dvh,问题直接消失。这类问题的排查诀窍是:优先怀疑高度单位,不要在样式细节里钻牛角尖。
问题二:安卓微信内置浏览器里底部按钮跳来跳去。微信 WebView 对dvh的支持时好时坏,切换输入法或者页面滚动时,底部按钮偶尔会跳到屏幕中间再落回底部。最后我用position: fixed配合bottom: 0,并且把外层容器高度从100dvh改成100svh,保证按钮始终贴着可视区域底部,不再跟随动态高度“乱跑”。这里选svh的原因是:对于固定按钮,我宁可让它一直贴在当前可视底部,也不要它跟着大视口来回伸缩。
问题三:横竖屏切换后布局错乱。手机上横屏时,地址栏和系统栏的行为更复杂,100dvh在瞬间可能出现一个极小的值,导致页面内容挤成一团。我的解法是:横屏场景直接锁定为100vh,因为横屏时地址栏基本都是隐藏状态,vh反而更稳定。用媒体查询区分即可:
@media (orientation: landscape) { .fullscreen { height: 100vh; } }5.2 常见症状速查表
| 症状 | 可能原因 | 推荐解法 |
|---|---|---|
| 弹层底部露白/露黑 | 用了100vh,iOS 解析为大视口高度 | 改为100dvh,保留100vh兜底 |
| 底部按钮被 Home Indicator 遮挡 | 未处理安全区 | 添加env(safe-area-inset-bottom)padding |
| 页面底部内容超出视口 | 容器高度 + 子内容高度计算失衡 | 外层100dvh,内部 flex +min-height: 0 |
| 安卓键盘弹起后按钮跳位 | 键盘压缩视口触发了动态高度变化 | 用100svh固定按钮容器,或 JS 监听 focus/blur |
| 初始化时页面闪一下高度 | JS 方案执行晚于首帧渲染 | 使用 CSSdvh单位,或 head 内联脚本预执行 |
| 横屏时布局错乱 | 动态单位在横屏下取值不稳定 | 媒体查询中回退到100vh |
5.3 最后几个我觉得值得重构的老写法
如果你维护的是老项目,里面还大量使用100vh做移动端全屏,我建议按这个顺序逐步替换:
- 先改弹层和遮罩,这是视觉问题最集中的地方。
- 再改全屏页面容器,让 flex 布局的根部高度先稳定下来。
- 最后处理底部操作栏,因为它还涉及安全区和 fixed 定位,改动时要连带测试键盘弹起场景。
替换的时候不需要把代码里所有vh都改成dvh。有些场景vh反而更合适,比如横屏页面、Canvas 的初始化尺寸、部分老安卓 WebView 内的兜底。原则是:凡是“跟随用户当前可视区域动态变化”的场景,优先dvh;凡是“固定在某一个稳定视口尺寸”的场景,优先svh或vh。
我个人现在写移动端组件库的默认值是:弹性容器高度用100dvh+100vh降级,固定操作栏用100svh+position: fixed底部定位,弹层遮罩用inset: 0+100dvh双保险。这套组合拳目前在我负责的多个 H5 项目里都跑得比较稳定,希望能给你一个可以直接落地的参考。