Viewport 单位的陷阱与替代:svh、lvh、dvh 的动态适配策略
Viewport 单位的陷阱与替代:svh、lvh、dvh 的动态适配策略
一、"100vh 看起来占满了屏幕,但底部被地址栏吃掉了"
移动端 Web 开发中最经典的 CSS Bug 之一:height: 100vh在 iOS Safari 中的实际渲染高度包含了地址栏的高度。当用户向上滚动、地址栏收起时,100vh的渲染值从头到尾都是地址栏完全展开时的高度——这不会因为地址栏的变化而更新。结果就是:全屏英雄区底部有一截被地址栏遮挡,或者在地址栏收起后底部多出一片尴尬的空白。
2022 年底,CSS 引入了三个新的视口单位来解决这个问题:svh(Small Viewport)、lvh(Large Viewport)、dvh(Dynamic 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 API(Chrome 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逐帧跟随。
五、总结
视口单位的选择策略:
| 场景 | 推荐单位 | 原因 |
|---|---|---|
| 全屏英雄区 | 100dvh+100vhfallback | 始终填满可见区域 |
| 全屏滚动容器 | 100svh+100vhfallback | 地址栏变化不触发重排 |
| 固定 Header | env(safe-area-inset-top) | 适配刘海设备 |
| 固定 Footer | env(safe-area-inset-bottom) | 适配 Home Indicator |
| 桌面端 | 100vh | 没有地址栏变化问题 |
| 弹窗/模态框 | 100dvh | 始终覆盖全屏 |
在不支持新单位的浏览器中,通过 JS 监听visualViewport.resize事件,动态设置--dynamic-vhCSS 变量做降级处理。这是跨浏览器兼容的"最后的防线"。
