ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

移动端100vh不可靠?动态视口单位dvh/svh/lvh实战指南

移动端100vh不可靠?动态视口单位dvh/svh/lvh实战指南 1. 为什么移动端浏览器里的100vh会说谎1.1 视口单位背后那套尺寸逻辑先明确一个概念vh这个单位在规范里定义为视口的初始包含块高度的1%。听起来很严谨对吧问题就出在视口的初始包含块这个描述上——在PC端浏览器里视口高度就是窗口高度1vh等于窗口高度的1%准确得让人毫无戒心。可一旦到了移动端浏览器的窗口本身就不是一个固定概念。以iPhone Safari为例你手指向上滑动页面时底部导航条会收起来地址栏会缩上去可视区域的垂直方向瞬间多出几十像素。如果此时页面里有个元素设置了height: 100vh在滑动的过程中浏览器是假装整个视口一直都是最大高度还是实时跟随可视区域变化不同浏览器给出的答案不一样而且都算不上完美。说得直白一点100vh在移动端表示的是浏览器认为的视口高度而不是用户此刻眼睛能看到的高度。这两者之间的差值就是地址栏、底部工具栏、浏览器自带的安全区域这些元素所占用的空间。于是经典的悲剧就出现了——一个height: 100vh的弹层、底部导航栏或者首屏区块在移动端打开时会有一部分内容被浏览器UI遮住或者底部多出一截空白怎么调都不对劲。1.2 地址栏伸缩带来的连锁反应我印象最深的一次事故是做一个移动端落地页的首屏要求必须铺满一屏。当时我直接给容器写了min-height: 100vh在Chrome DevTools的设备模拟器里看稳稳的一整屏完美。结果用iPhone真机一测页面往下滑动时底部导航栏收起来整个页面背景色和下一屏的内容衔接处出现了一条明显的断裂带——背景没铺到底。原因不复杂页面加载时浏览器的地址栏处于展开状态Safari把100vh按地址栏展开时的最大视口高度计算了。用户一滚动地址栏收起可视区域变高但那个容器的高度已经定死了不会再跟着变。反过来的场景同样存在某些Android浏览器在初始化时按最小视口算导致100vh内容在地址栏收起后反而留出一大截空白。这个滚动即变的特性就是移动端视口问题最恶心的点它不只是初次加载时的计算偏差而是整个交互过程中随时可能变化的动态问题。1.3 先把问题定性你要的到底是最大还是当前在找解决方案之前我建议先停下来问自己一个问题你这个100vh想表达的语义是什么如果是这屏内容至少占满整个浏览器窗口的最大可用高度那你要的是large viewport也就是lvh。如果是这屏内容要贴合用户当前看得到的可视区域随时跟着伸缩那你要的是dynamic viewport也就是dvh。如果只是在初始加载时铺满一屏后续滚动状态不管那small viewport的svh可能更合适。很多人在网上搜100vh兼容方案上来就找代码抄结果抄来抄去发现时而好用时而翻车根本原因就是没搞清楚自己这个页面要哪种语义。这个判断做错了后面用什么方案都是错。2. 那些年我们用过的伪解决方案逐个拆解2.1 window.innerHeight JS手动计算这是最老派的方案核心思路是拿到浏览器当前可视高度塞给一个CSS自定义属性再在滚动、resize、orientationchange时重新计算function setViewportHeight() { const vh window.innerHeight * 0.01; document.documentElement.style.setProperty(--vh, ${vh}px); } window.addEventListener(resize, setViewportHeight); window.addEventListener(orientationchange, setViewportHeight); setViewportHeight();.container { height: calc(var(--vh, 1vh) * 100); }这套方案在2017年前后相当流行现在很多老项目里依然能看到。它比纯100vh强不少因为window.innerHeight在iOS Safari里返回的是当前可视区域高度地址栏折叠后数值会变化跟着resize事件走能适应大部分滚动场景。但它的缺陷也很明显。第一resize事件在移动端的触发时机并不稳定尤其Android机型上地址栏变化、软键盘弹出、底部手势条变化有时只会触发visualViewport的resize而window.innerHeight的更新会慢半拍导致布局闪烁。第二在PC端浏览器窗口缩放时这套JS每触发一次都会强制一次样式重计算如果页面上还有别的动画帧率会肉眼可见地掉。第三orientationchange在iOS上已经废弃了新系统里两段式旋转会把计算搅乱一阵子。结论这不是一个错误的方案但它的生命周期已经到头了属于能用但不优雅的过渡产物。如果你还在维护老代码看到这个写法不用急着重构但如果是从零开始的新项目我不推荐再这么写。2.2 百分比高度 calc() 的折中方案另一个常见思路是放弃vh改用html, body { height: 100% }一路往下的百分百继承链html, body { height: 100%; } .container { height: calc(100% - 60px); /* 减去固定头部 */ }这个方案在页面本身不滚动内容锁定在一屏内的场景下是可行的。比如一些移动端的引导页、抽奖页、全屏活动页结构简单滚动逻辑不多用百分比继承反而比vh稳定——因为它是纯CSS计算不依赖JS。问题出在继承链上。只要中间某个父元素没有设置高度百分比就失效。而且这个方案意味着你的布局结构被死死绑定——所有祖先元素都必须显式声明高度一旦某个容器需要改用min-height或者flex布局撑开内容整条继承链瞬间断裂。我见过不少项目就是因为后人改了某个父级的高度策略导致满屏页面突然从底部漏出背景色。2.3 -webkit-fill-available曾经的小众救命稻草.container { height: -webkit-fill-available; }这个写法在iOS Safari上确实有效因为Safari对fill-available的实现逻辑接近扣除浏览器UI后的剩余空间。它在老版本的iPhone上解决过不少问题至今在一些H5页面里还能看到。但它的致命伤是W3C规范里从来没有一个叫-webkit-fill-available的标准值它是最早fill-available提案的浏览器私有实现现在Chrome虽然也能解析带前缀的版本但行为和Safari并不完全一致。而且它只解决了height这一个维度width场景、min-height场景、多个元素叠加计算的场景都照顾不到。拿它当主力方案风险太大我自己的态度是只能作为兼容性兜底绝不能作为主方案。2.4 为什么需要重新审视问题根源说了这么多你会发现一个共性所有伪解决方案本质上都是在手动修补视口计算差异。JS方案用运行时高度覆盖CSS视图单位百分比方案绕开视口单位回到文档流fill-available方案直接换一套计算模型——它们都没有正面回答那个最核心的问题浏览器到底应该根据什么来定义视口高度。真正的转机在于CSS规范在2022年的Viewport Units Level 4里正式引入了动态视口单位族让持续变化这件事第一次有了原生语言层面的表达方式。这就是下一节要展开的内容。3. 动态视口单位dvh / svh / lvh 的正确打开方式3.1 三种单位的语义差异规范把移动端浏览器的视口高度分成了三个状态小的small、大的large、动态的dynamic。svh小视口高度对应浏览器UI全部展开时的视口。地址栏占着位置、底部导航条占着位置可视区域最矮的那个状态。lvh大视口高度对应浏览器UI全部收起时的视口。可视区域最高的那个状态。dvh动态视口高度跟随浏览器UI的伸缩实时变化。地址栏展开时它等于svh地址栏收起时它等于lvh。翻译成实际开发语义.container { height: 100vh; /* 旧写法含义模糊移动端容易出错 */ height: 100dvh; /* 新写法一直贴合当前可视区域 */ }注意CSS的层叠规则——把100vh写在100dvh上面是为了让不识别dvh的老浏览器自动回退到vh而支持dvh的现代浏览器会用后面的声明覆盖前面的。这个渐进增强策略是行业里的标准做法也是我目前在新项目里最推荐的写法。3.2 支持范围和一份诚实的兼容性说明把话说透dvh不是万国表它也有兼容性边界。iOS Safari 15.4 及以上完整支持也就是说iPhone 8等较早设备升级到iOS 15.4之后就能用。Chrome 108 及以上支持覆盖了绝大多数现代Android机型。Firefox 101 及以上支持桌面端和Android版都包括在内。UC、QQ浏览器这类国内壳浏览器基于较新Chromium内核时没问题但老版本仍有风险。所以在实际项目中我的标准姿势是这样的.hero { height: 100vh; /* 兜底所有浏览器都认识 */ height: 100svh; /* 兜底优先使用小视口保证内容不会被遮挡 */ height: 100dvh; /* 进阶支持动态视口的浏览器获得最佳体验 */ }这里有个值得思考的细节回退值为什么是svh而不是lvh因为在拿不准浏览器行为的时候稍微矮一点比稍微高一点安全——内容不够铺满也就漏一点背景色内容超出屏幕的话用户可能会以为页面出bug了。svh保证的是内容永远在最小可视区域内完整可见这个方向在移动端是更稳妥的选择。3.3 使用动态视口单位时的几个注意点第一dvh在页面滚动过程中是会变的这其实是它的核心优势但也会带来一个微妙的问题如果页面上有固定定位的元素用了height: 100dvh地址栏每次伸缩都会触发一次重排。在低端Android机上这个重排如果发生在滚动过程里可能出现肉眼可见的跳动。解决方案是把需要稳定视觉的元素比如背景层和需要跟随内容的元素区分开前者用svh或lvh定死后者用dvh跟随。第二100dvh不解决安全区域问题。iPhone的刘海屏、底部Home Indicator区域属于viewport-fitcover下的环境变量控制范畴跟dvh是两条独立的技术线。100dvh给出的高度包含了安全区如果你希望内容避开Home Indicator还是得配合env(safe-area-inset-bottom)来处理.safe-bottom { height: calc(100dvh - env(safe-area-inset-bottom, 0px)); }第三也是我踩过的一个坑当页面里有position: fixed的元素时100dvh不总是等于可视区域高度。原因在于fixed元素在某些浏览器里参照的是布局视口layout viewport而dvh是基于小视口/大视口/动态视口这些抽象层次计算的两者在特定浏览器下的参照系不同会出现offset。所以如果你写了height: 100dvh但发现fixed元素底部仍有一条缝隙别急着怀疑语法先看看是不是浏览器把fixed元素的包含块给特殊对待了。4. 键盘弹出与视觉视口最难处理的隐藏问题4.1 layout viewport 与 visual viewport 的区别如果你以为dvh能一路躺赢那下面这个问题迟早会让你清醒软键盘。移动端浏览器的视口体系里有两个概念布局视口layout viewport和视觉视口visual viewport。布局视口是页面布局所基于的坐标系你可以把它理解成浏览器觉得自己展示的是多宽多高的一块画布视觉视口是用户当前屏幕上真正能看到的那块区域软键盘弹出来、页面缩放、地址栏伸缩都会改变视觉视口的尺寸但不一定会改变布局视口。输入框聚焦、键盘弹出这个场景恰恰是这两种视口分歧最大的时候。在旧版iOS Safari里键盘弹出时window.innerHeight不变因为它是布局视口的高度CSSdvh在大多数情况下也不会跟着键盘走——因为键盘不是浏览器UI它覆盖在页面上层不影响视口单位定义。这就导致了大家最熟悉的那个bug输入框聚焦后页面底部的内容被键盘挡住怎么滚都滚不出来。4.2 一个可靠的交互层处理方案针对这个场景纯CSS是无解的必须借助 JS 的visualViewportAPI。它提供visualViewport.height表示当前真正的可视高度还提供resize事件和scroll事件来监听视觉视口的变化const visualViewport window.visualViewport; function syncWithKeyboard() { const vh visualViewport.height * 0.01; document.documentElement.style.setProperty(--visual-vh, ${vh}px); // 可选让当前聚焦元素滚到视觉视口可视范围内 } visualViewport.addEventListener(resize, syncWithKeyboard); visualViewport.addEventListener(scroll, syncWithKeyboard); syncWithKeyboard();.fix-bottom-bar { /* 键盘未弹出时跟随动态视口 */ height: calc(100dvh - 50px); /* 键盘弹出时用视觉视口高度覆盖 */ height: calc(var(--visual-vh, 1dvh) * 100 - 50px); }这段代码的思路是默认状态下所有元素都走dvh或者自定义属性的CSS路径而视觉视口API只在键盘弹出/收起时介入用一个更高的优先级去修正底层高度。实际测试下来在iOS 15和Android Chrome 108上这个方案能覆盖绝大多数输入框被键盘遮挡的场景。我之所以强调交互层处理是因为这里的核心诉求不是让页面高度等于键盘上方剩余高度——这几乎不可能也不必要——而是保证当前聚焦的输入框不被遮住且用户能看到正在操作的内容。有时与其纠结高度不如在键盘弹出时把输入框scrollIntoView一下。4.3 华为/小米等安卓定制ROM上的差异聊几句国内的实际情况。Android原生浏览器的视觉视口行为相对统一但华为、小米、vivo这些厂商的定制系统里软键盘的沉浸模式各不相同。有些ROM默认会把页面整体上推即windowSoftInputModeadjustResize方式视觉视口被压缩visualViewport.height会变化这种环境下上面的JS方案异常好用另一些ROM选择键盘覆盖页面但不压缩布局adjustNothing行为视觉视口高度不变那就需要主动把输入框scrollIntoView或者给固定底栏做平移。做移动端H5开发最忌讳的就是拿一台iPhone测完就以为万事大吉。我自己的习惯是真机矩阵里至少放三台一台iPhone最好带刘海、一台小米/红米、一台华为或荣耀。每次改动视口相关逻辑三个机器全部过一遍键盘弹出和滚动收起的场景能少踩一大半环境差异的坑。5. 生产环境中的最终方案一套可以直接抄的写法5.1 我的推荐组合综合前面所有分析我现在的新项目里移动端满屏布局的标准写法是这样的:root { /* 默认走动态视口回退到100vh */ --full-height: 100vh; --full-height: 100dvh; /* 键盘相关场景由JS更新 */ --visual-height: 100vh; --visual-height: 100dvh; } .full-screen { height: var(--full-height); } .fixed-toolbar { position: fixed; left: 0; right: 0; bottom: 0; height: calc(var(--full-height) - 100dvh 56px); /* 上面这行属于特殊情况处理一般直接用下面的写法更直观 */ } .safe-bottom { padding-bottom: env(safe-area-inset-bottom, 0px); }别被上面那段唬住我实际用得最多也是最朴素的组合是下面这样.page { min-height: 100vh; /* 老浏览器兜底 */ min-height: 100svh; /* 新浏览器优先用小视口防止内容被遮 */ min-height: 100dvh; /* 支持动态视口的浏览器获得完美跟随 */ }对绝大多数场景来说这个三连声明就足够了。它把安全放在第一位——就算浏览器只认识第一个100vh也不会出现大问题认识svh的会优先用更保守的尺寸认识dvh的则获得最佳体验。不需要JS不需要监听事件不会在滚动的瞬间闪烁。需要JS介入的只有软键盘场景。所以我的完整方案其实是CSS三连声明打底visualViewport事件兜底键盘场景安全区用env()处理。三者各管一段互不干扰。5.2 真机验证中的几个案例拿一个实际项目举例。一个移动端报名页面结构是顶部背景图 中间表单 底部提交按钮。提交按钮用了position: fixed; bottom: 0表单区用min-height: 100dvh。问题出现在点击手机号输入框时键盘弹出底部按钮被键盘顶到中间视觉上像是按钮漂移。用上面说的visualViewport方案处理之后逻辑变成键盘弹出时给按钮容器加一个transform: translateY(-${键盘遮挡高度}px)这个高度用visualViewport.height和window.innerHeight的差值算出来等于键盘实际遮住的部分。实测在iOS和Android上都能精确贴合而且因为是transform变化不会引发布局回流动画还算顺滑。另一个案例是移动端瀑布流页面文章卡片用了height: calc(100vh - 120px)来制造首屏卡片正好露出一部分的视差效果。在Android上地址栏折叠之后这个计算值偏大首屏露出的部分比设计稿多。换成100svh之后计算基准固定为最小可视高度效果就稳定了——每次刷新、每个机型露出的比例都一致。这两个案例恰好说明前面那个判断先明确语义再选单位。报名页需要的是跟随当前键盘状态所以用动态值瀑布流卡片需要的是初始态稳定一致,所以用小视口值。没有哪个单位是绝对最优选对场景才是关键。5.3 关于布局抖动和性能的几个提醒dvh是动态值这个动态特性是有代价的——浏览器在地址栏伸缩、键盘弹起这类变化发生时需要重新计算依赖它的所有受影响的元素。如果你的页面里到处都是100dvh而且每个都参与复杂布局那么在低端机上可能会出现掉帧。我的经验法则是能用svh或lvh表达的场景就不要上dvh。比如背景层、占位容器、跟内容不联动的装饰元素定死一个值就好。只有必须严格跟随当前可视区域的元素底部工具条、弹窗内容区、全屏滚动容器才用dvh并且尽量让这类元素少参与其他元素的布局计算。如果发现滚动时背景或弹层有明显跳变先别急着换方案打开Performance面板看看是不是dvh相关的样式重计算导致长任务——有时候只是某一条will-change: height加错了位置。6. 从100vh延伸出去移动端布局的另一种解题思路6.1 当铺满一屏不依赖视口单位时聊了这么多视口单位我想换个角度多说一句很多场景下铺满一屏这个需求其实根本不需要用视口单位来实现。如果你用的是现代CSS布局尤其是flex和grid很多满屏的诉求可以被自然消化。拿最常见的底部导航栏 内容区来说完全可以这样写.app-shell { display: flex; flex-direction: column; height: 100dvh; /* 或者svh/lvh按语义选 */ } .content { flex: 1; /* 自动占据导航栏之外的所有剩余空间 */ overflow-y: auto; } .bottom-nav { flex-shrink: 0; /* 防止被压缩 */ height: 56px; }在这个结构里内容区的高度是总高减去导航栏之后动态算出来的无论视口怎么变化内容区都会自动跟着伸缩不需要写任何calc()也不存在减去固定值后底部漏白的问题。这才是移动端布局里真正重要的能力——不是精确控制每一毫米而是让弹性布局替你消化不确定的浏览器行为。同理弹窗居中这种需求用flex的align-items: center; justify-content: center加height: 100dvh一个容器就能搞定完全不需要top: 50%; transform: translateY(-50%)那种老办法。6.2 旧项目的渐进式改造建议如果你手头是一个跑了多年的老项目里面铺满了100vh我不建议一口气全局替换成100dvh。原因很简单老项目里可能存在大量依赖旧行为的隐性约定比如某个容器故意用100vh撑出超出可视区域的高度来隐藏内容或者某个组件的滚动计算依赖固定视口高度全局替换会把这些隐性逻辑全部打乱。我的做法是分三步走先挑出视觉上问题最明显的几个页面确认它们的语义小视口/大视口/动态按需加上对应的新单位只影响局部。在新写的公共组件里直接用三连声明逐步让新代码成为主流。等大部分页面完成改造后再在根选择器或者全局样式里做一次残余清理把裸的100vh降到最低。这套渐进式策略比周末花一天全量替换的风险要低得多。我见过不止一个项目因为手快全局替换把隐藏导航、懒加载占位、滚动锚定之类的逻辑全打破了最后花了两周收拾残局。6.3 工具链和调试上的几个辅助手段最后分享一些提高效率的工具层面的建议。Chrome DevTools的Device Toolbar里可以手动模拟地址栏展开/收起两种状态方法是点击顶部工具栏的三点菜单选择显示/隐藏设备工具栏然后在模式里选响应式并拖动高度。趁早发现视口变化时的布局问题别等真机测试才暴露。Safari的Web Inspector在真机远程调试时很好用特别是查看visualViewport的实时数值。连接iPhone后选中某个元素在右侧的元素面板里能看到它的视口尺寸信息。如果你用VSCode可以装一个CSS Units的扩展它能高亮标记代码里的vh、dvh、svh、lvh一目了然避免团队里有人混用单位还不自知。项目里的CSS规范文档中建议明确写一条新代码禁止单独使用100vh必须配合svh/dvh的渐进增强写法。这个约定能省掉很多review时争论的时间。我在实际项目中踩过最贵的一次坑是在一个活动页里用了100vh当作弹窗遮罩层的高度配合position: fixed使用。iPhone上地址栏展开时遮罩底部漏出一条缝用户能看到下层页面在滚动以为是页面出了故障投诉直接到了运营那里。那次之后我把自己项目的全局搜索里所有的100vh都过了一遍能换成dvh的都换了不能换的至少确认了语义是大视口。从那以后移动端视口相关的工单数量几乎降到了零。视口单位的本质是在跟浏览器的动态UI赛跑。跑赢了赛道的是那些清楚自己项目语义、懂得在各种单位之间做取舍的人而那些还在盲目复制代码的大概率会继续在这条赛道上摔跟头。希望这篇内容能让你少摔几次。
返回列表