100vh 在手机上会溢出的原因,和 svh / lvh / dvh 三个兄弟

给首屏写 min-height: 100vh,桌面端刚好一屏,手机上一打开就多出一条滚动,或者底部那块内容被地址栏压在下面。

这不是 bug。移动浏览器里的 vh 指的是大视口——地址栏折叠起来时的高度,也就是这个屏幕能给出的最大高度。而地址栏通常是展开的,于是 100vh 永远比你此刻能看见的区域高那么一截(几十像素,取决于设备)。

现在有四套视口单位

单位 含义
lvh large viewport —— 地址栏折叠时的最大高度,就是以前的 vh
svh small viewport —— 地址栏展开、UI 最占地方时的最小高度
dvh dynamic viewport —— 跟着地址栏状态实时变化
vh 等于 lvh,为兼容保留

落地情况:Chrome 108、Safari 15.4、Firefox 101 起支持。再往前的浏览器只认 vh。

所以降级写法不需要任何 JS,两条属性叠着写就行:

.hero {
  min-height: 100vh;    /* 不认 dvh 的浏览器用这条 */
  min-height: 100dvh;   /* 认的话,这条覆盖上一条 */
}

CSS 的机制是”不认识的值直接丢弃这条声明”,后面的照常生效。所以这种写法是安全的,不是 hack。

dvh 的副作用:会跳

dvh 跟着地址栏变。用户上下滑动、地址栏折叠展开,100dvh 的高度就在几十像素之间来回变——如果里面有个居中的大标题,它会跟着跳。

我的用法是分开:

  • 首屏 hero 用 dvh:它就该刚好一屏,跳一下也比被切掉好。
  • 滚动区域内部不要用视口单位,用 flex: 1 或者百分比——父容器的高度已经确定,不需要再问一次视口。
  • 确实要”至少一屏但不能跳”的地方,用 svh:它取最小值,永远保证内容不被地址栏挡住,同时不会变。

被 home indicator 挡住的那一条

iPhone 底部有一条横线(home indicator),固定定位的底栏如果贴到最底,会被它压住。这需要两样配合:

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
.bottom-bar {
  padding-bottom: env(safe-area-inset-bottom, 0px);
}

viewport-fit=cover 让页面允许延伸到安全区之外(否则浏览器本来就会给你留边),env() 再把内容推回安全区内。0px 那个默认值是给不支持的浏览器兜底的。

这套在横屏刘海手机上尤其重要——左右两侧也有 safe-area-inset-left/right。

width=device-width 到底改了什么

这一条是响应式能生效的前提,但它的作用经常被误解成”让页面宽度等于屏幕宽度”。更准确的说法是:它把布局视口的宽度设成设备的 CSS 像素宽度。

不写它的时候,移动浏览器默认用一个大约 980px 宽的布局视口渲染,然后把整页缩小到屏幕里——所以不写 viewport 的桌面页面在手机上是”完整的缩略图”,字小到看不清。写了之后布局视口 = 设备宽度,媒体查询才有意义。

断点该按内容定,不是按设备定

iPhone 375、iPhone Pro Max 430、平板 768——按这些数字设断点,你会发现每年新机型都要补一段。更稳的做法是把浏览器窗口慢慢拖窄,看哪一行开始挤、哪个标题开始换行难看,那个宽度就是你的断点。

设备尺寸会变,内容的舒适宽度不会。我这个站的卡片是三列,因为 900px 以下三列时摘要只剩两行半,读起来太挤——900 这个数字来自内容,不是来自任何一款手机。

← 返回技术笔记