ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

移动端100vh适配全解:从视口原理到dvh、svh、lvh实战

移动端100vh适配全解:从视口原理到dvh、svh、lvh实战 移动端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在动态视口下的尴尬处境svhsmall viewport height始终等于“最小视口高度”也就是地址栏完全展开时的视口高度。lvhlarge viewport height始终等于“最大视口高度”也就是地址栏完全收起时的视口高度。dvhdynamic 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 classfullscreen header classheader顶部栏/header div classscroll-area !-- 这里放很长很长的内容 -- /div footer classfooter底部按钮/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-fitcover配合env(safe-area-inset-top)。底部安全区用env(safe-area-inset-bottom)。在 HTML 的 meta viewport 里记得加上viewport-fitcovermeta nameviewport contentwidthdevice-width, initial-scale1.0, viewport-fitcover /然后底部操作栏的高度加上安全区.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 常见症状速查表症状可能原因推荐解法弹层底部露白/露黑用了100vhiOS 解析为大视口高度改为100dvh保留100vh兜底底部按钮被 Home Indicator 遮挡未处理安全区添加env(safe-area-inset-bottom)padding页面底部内容超出视口容器高度 子内容高度计算失衡外层100dvh内部 flex min-height: 0安卓键盘弹起后按钮跳位键盘压缩视口触发了动态高度变化用100svh固定按钮容器或 JS 监听 focus/blur初始化时页面闪一下高度JS 方案执行晚于首帧渲染使用 CSSdvh单位或 head 内联脚本预执行横屏时布局错乱动态单位在横屏下取值不稳定媒体查询中回退到100vh5.3 最后几个我觉得值得重构的老写法如果你维护的是老项目里面还大量使用100vh做移动端全屏我建议按这个顺序逐步替换先改弹层和遮罩这是视觉问题最集中的地方。再改全屏页面容器让 flex 布局的根部高度先稳定下来。最后处理底部操作栏因为它还涉及安全区和 fixed 定位改动时要连带测试键盘弹起场景。替换的时候不需要把代码里所有vh都改成dvh。有些场景vh反而更合适比如横屏页面、Canvas 的初始化尺寸、部分老安卓 WebView 内的兜底。原则是凡是“跟随用户当前可视区域动态变化”的场景优先dvh凡是“固定在某一个稳定视口尺寸”的场景优先svh或vh。我个人现在写移动端组件库的默认值是弹性容器高度用100dvh100vh降级固定操作栏用100svhposition: fixed底部定位弹层遮罩用inset: 0100dvh双保险。这套组合拳目前在我负责的多个 H5 项目里都跑得比较稳定希望能给你一个可以直接落地的参考。
返回列表