Viewport 单位的陷阱与替代:svh、lvh、dvh 的动态适配策略

Viewport 单位的陷阱与替代:svh、lvh、dvh 的动态适配策略
Viewport 单位的陷阱与替代svh、lvh、dvh 的动态适配策略一、100vh 看起来占满了屏幕但底部被地址栏吃掉了移动端 Web 开发中最经典的 CSS Bug 之一height: 100vh在 iOS Safari 中的实际渲染高度包含了地址栏的高度。当用户向上滚动、地址栏收起时100vh的渲染值从头到尾都是地址栏完全展开时的高度——这不会因为地址栏的变化而更新。结果就是全屏英雄区底部有一截被地址栏遮挡或者在地址栏收起后底部多出一片尴尬的空白。2022 年底CSS 引入了三个新的视口单位来解决这个问题svhSmall Viewport、lvhLarge Viewport、dvhDynamic Viewport。但它们的浏览器支持、行为差异和使用场景各有不同选择合适的单位需要理解底层机制。二、四种视口单位的差异模型为了更清晰地理解它们的区别我们可以从视口高度的计算基准和实际表现来进行对比100vh固定值其高度约等于地址栏完全展开时的视口高度。核心问题在于它是一个固定值不会随地址栏的收起或展开而变化导致适配失效。100svh小视口基于地址栏收起时的最小高度计算。优势在于全屏内容不会被地址栏遮挡但劣势是当地址栏展开后底部会出现留白。100lvh大视口基于地址栏展开时的最大高度计算。优势在于底部无留白但劣势是内容可能会被地址栏遮挡。100dvh动态视口高度会随地址栏状态实时变化。优势在于完美适配屏幕可用区域但劣势是在滚动过程中可能引发频繁的重排带来一定的性能开销。三、视口适配策略的工程实现/* * * 视口高度单位选择策略 * */ /*策略 1全屏英雄区 → 使用 100dvh场景占据整个屏幕的欢迎页、首屏 Banner需求无论地址栏状态如何始终填满可见区域⚠️ 注意dvh 在地址栏切换时会触发浏览器重排——大量元素的 dvh 依赖会导致滚动卡顿/.hero-section {/现代浏览器优雅降级/height: 100vh; /Fallback不支持 dvh 的浏览器/height: 100dvh; /首选动态适配 */}supports not (height: 100dvh) {/* 不支持 dvh 时用 JS 手动计算 */.hero-section {height: var(--dynamic-vh, 100vh);}}/*策略 2全屏滚动容器 → 使用 100svh场景整页滚动的 Landing Page需求地址栏存在时不遮挡底部内容svh 地址栏完全展开时的最小视口高度这个值不会因地址栏变化而跳变——用户滚动体验稳定*/.fullpage-scroll {height: 100vh;height: 100svh;overflow-y: auto;scroll-snap-type: y mandatory;}/*策略 3固定顶部 Header → 使用 env(safe-area-inset-top)场景iOS 上有刘海/灵动岛的设备需求Header 不被安全区域遮挡注意这不是 viewport 单位而是配合使用/.header {position: fixed;top: 0;left: 0;right: 0;/safe-area-inset-top设备安全区域刘海高度 */padding-top: env(safe-area-inset-top, 0px);height: calc(56px env(safe-area-inset-top, 0px));}/*策略 4混合模式——按地址栏状态切换使用 CSS 媒体查询 特性检测组合*/.modal-overlay {position: fixed;inset: 0;/* 小屏手机用 dvh */height: 100vh;height: 100dvh;/* 大屏桌面用 vh */media (min-width: 1024px) {height: 100vh;}}typescript // viewport/viewport-adapter.ts // 视口高度动态适配工具 /** * 视口管理器 * * 设计意图在不支持 dvh 的浏览器中 * 通过 JS 模拟 dvh 行为设置 --dynamic-vh CSS 变量 */ class ViewportManager { private resizeObserver: ResizeObserver | null null; private ticking false; /** * 初始化视口高度管理 * * 使用 window.innerHeight 获取真实视口高度不含地址栏的可见区域 * 与传统 vh 单位的区别 * - vh: 固定值地址栏展开时的高度不会随地址栏变化 * - window.innerHeight: 动态值地址栏收起后的可见高度 * * 每当地址栏切换时更新 CSS 变量 --dynamic-vh */ init(): void { // 首次设置 this.updateViewportHeight(); // 监听视口变化地址栏收起/展开 // ResizeObserver 比 resize 事件更精确——响应的是元素的尺寸变化 this.resizeObserver new ResizeObserver(() { if (!this.ticking) { this.ticking true; requestAnimationFrame(() { this.updateViewportHeight(); this.ticking false; }); } }); this.resizeObserver.observe(document.documentElement); // visualViewport APIChrome 61, Safari 13 // 提供比 window.innerHeight 更准确的视口信息 if (window.visualViewport) { window.visualViewport.addEventListener(resize, () { this.updateViewportHeight(); }); // 键盘弹出/收起时的视口变化 window.visualViewport.addEventListener(scroll, () { // 当用户聚焦输入框键盘弹出时视口缩小 this.updateViewportHeight(); }); } } /** * 更新 CSS 变量 */ private updateViewportHeight(): void { // 使用 visualViewport API更精确 let vh: number; if (window.visualViewport) { vh window.visualViewport.height; } else { vh window.innerHeight; } // 设置多个变量兼容不同场景 document.documentElement.style.setProperty( --dynamic-vh, ${vh * 0.01}px // 1vh viewport height / 100 ); // svh 模拟取地址栏完全展开时的小视口 const svh Math.min(vh, window.outerHeight || vh); document.documentElement.style.setProperty( --svh, ${svh * 0.01}px ); // lvh 模拟取地址栏完全收起时的大视口 const lvh Math.max(vh, window.outerHeight || vh); document.documentElement.style.setProperty( --lvh, ${lvh * 0.01}px ); } /** 获取当前实际视口高度 */ getViewportHeight(): number { if (window.visualViewport) { return window.visualViewport.height; } return window.innerHeight; } /** 销毁监听 */ destroy(): void { this.resizeObserver?.disconnect(); } } // ---- 初始化在页面入口执行一次 ---- const viewportManager new ViewportManager(); viewportManager.init(); export { viewportManager };/* * * 安全区域处理 * * * iOS 设备的刘海notch和底部 Home Indicator * 用 env(safe-area-inset-*) 获取安全区域大小 */ /* 全屏布局的安全区域 */ .safe-area-container { /* 顶部避开刘海 */ padding-top: env(safe-area-inset-top, 0px); /* 底部避开 Home Indicator */ padding-bottom: env(safe-area-inset-bottom, 0px); /* 左右横屏时避开刘海 */ padding-left: env(safe-area-inset-left, 0px); padding-right: env(safe-area-inset-right, 0px); } /* * 背景延伸到安全区域如 Hero Banner 背景撑满整个屏幕 * 但内容文字/按钮保持在安全区域内 */ .hero-with-safe-area { position: relative; /* * 背景延伸到安全区域外 * 负 margin 正 padding 让背景超出内容区 */ margin-top: calc(-1 * env(safe-area-inset-top, 0px)); padding-top: env(safe-area-inset-top, 0px); }四、dvh 的性能陷阱dvh在地址栏切换时触发 Layout。iOS Safari 中用户滚动导致地址栏收起/展开时所有使用dvh的元素都会重新计算高度。如果页面有 50 个元素用了100dvh不常见但可能每次地址栏切换都触发 50 次 Layout。dvh的键盘适配问题。在表单页面中当用户聚焦输入框、键盘弹出时dvh会缩小。如果dvh没有配合position: fixed使用页面内容可能被键盘遮挡。Safari 的dvh平滑切换。从 iOS 15.4 开始Safari 在地址栏切换时会平滑过渡dvh的值而非瞬间跳变体验有显著改善。但transition: height不生效——需要通过requestAnimationFrame逐帧跟随。五、总结视口单位的选择策略场景推荐单位原因全屏英雄区100dvh100vhfallback始终填满可见区域全屏滚动容器100svh100vhfallback地址栏变化不触发重排固定 Headerenv(safe-area-inset-top)适配刘海设备固定 Footerenv(safe-area-inset-bottom)适配 Home Indicator桌面端100vh没有地址栏变化问题弹窗/模态框100dvh始终覆盖全屏在不支持新单位的浏览器中通过 JS 监听visualViewport.resize事件动态设置--dynamic-vhCSS 变量做降级处理。这是跨浏览器兼容的最后的防线。