移动端适配实战:视口、单位、安全区与触控
本文目录
移动端适配的坑,八成不是"新知识不够",而是"对浏览器行为理解错位":以为 viewport 就是宽度、以为 vh 就是屏幕高、以为点击和鼠标是同一种事件。这篇文章按问题组织:视口机制、单位选型、安全区、1px 边框、触控目标、软键盘、iOS WebView 边界,每个问题给原因、解法和验证方法。
验证基准:真机测试优先于浏览器模拟。模拟器能验证布局,验证不了软键盘、橡皮筋、WebView 与 Safari 的差异——这三类问题恰恰是线上投诉大头。
先分清两个视口
移动浏览器里同时存在两个"视口",混淆它们是大部分适配 bug 的起点:
| 视口 | 是什么 | 谁控制 | 典型用途 |
|---|---|---|---|
| layout viewport(布局视口) | 页面实际布局的宽度 | <meta name="viewport"> 控制 | CSS 布局、媒体查询的参照 |
| visual viewport(可视视口) | 用户当前真实看到的那块 | 缩放、软键盘、地址栏收展会改变它 | 判断"用户到底看见了什么" |
<!-- 基础配置:布局视口宽度 = 设备宽度,初始缩放 1 -->
<meta name="viewport" content="width=device-width, initial-scale=1">
两个高频错误,先说结论:
- 不写 viewport meta:浏览器按 980px 左右的虚拟视口渲染再缩小页面,媒体查询全部失真。
- 写
user-scalable=no, maximum-scale=1:禁用缩放直接违反 WCAG 1.4.4(页面可缩放)。iOS Safari 从 iOS 10 起已忽略这类禁缩放配置,Android Chrome 仍会尊重但可访问性检查会报错。正确姿势是别写,靠布局自适应解决"缩放后错乱"。
当页面被缩放或软键盘弹出时,layout viewport 通常不动,动的是 visual viewport:
// visualViewport 随缩放和软键盘实时变化
const vv = window.visualViewport;
function logViewport() {
console.log({
width: vv.width, // 可视宽度(CSS px)
height: vv.height, // 可视高度,软键盘弹出会显著变小
scale: vv.scale, // 当前缩放倍数
offsetTop: vv.offsetTop, // 相对 layout viewport 的偏移
});
}
vv?.addEventListener('resize', logViewport);
vv?.addEventListener('scroll', logViewport);
后面的软键盘、固定定位问题都要靠这个 API 观察真实行为。
单位怎么选:rem、em 与动态视口单位
先给出主流结论,再解释原因:
| 单位 | 基准 | 典型用途 | 主要坑 |
|---|---|---|---|
px | 绝对值 | 边框、阴影、固定尺寸图形 | 用户调系统字号时纹丝不动 |
rem | 根元素字号 | 字号、行高、间距 | 根字号被写死成 px 时失去可访问性 |
em | 当前元素/父级字号 | 组件内部相对尺寸 | 嵌套时累积放大,要算账 |
vw / vh | 视口宽/高的 1% | 大区块铺满 | 移动端 vh 不等于可见高度 |
dvh / svh / lvh | 动态/最小/最大视口高 | 全屏容器、底部安全间距 | 旧 WebView 不支持,需要回退 |
三条实践规则:
- 字号用 rem:跟随用户浏览器字号设置,把可访问性留给用户。别用 JS 把
html的 font-size 写死成 px——那等于把 rem 退化成 px。 - 间距可以用 em 或 px:设计稿还原优先时 px 更直接,组件内部相对布局用 em。
- 涉及视口高度时用 dvh 而不是 vh,并给旧环境留回退:
/* 全屏 hero:vh 在移动端按"最大视口"计算,
地址栏显示时内容会被推到屏幕外 */
.hero {
min-height: 100vh; /* 回退:不支持 dvh 的旧 WebView */
min-height: 100dvh; /* 动态视口高:地址栏收展实时跟随 */
}
/* 文字流式缩放:限制在上下限之间 */
h1 {
font-size: clamp(1.5rem, 5vw, 2.5rem);
}
dvh / svh / lvh 的语义值得记准:svh 是地址栏显示时的最小高度(保守,适合"保证可见"的场景),lvh 是地址栏隐藏时的最大高度,dvh 在两者之间动态跟随。它们的浏览器支持从 2022 年前后开始普及(Safari 15.4、Chromium 108 起),对更旧的 WebView 要保留 vh 回退并接受"底部内容可能被地址栏盖住"的降级。
另外有一类历史方案——用 JS 把设计稿宽度换算成根字号(documentElement.style.fontSize = clientWidth / 10 + 'px'),或用 PostCSS 插件把 px 批量转 vw。它们能还原设计稿,但有共同代价:JS 注入根字号有闪动、破坏用户字号偏好;vw 换算在极端屏幕宽度下产生极端字号,还得叠 clamp 补救。新项目优先考虑"内容自适应 + 断点布局",而不是全站等比缩放。
安全区:刘海屏、圆角与底部横条
全面屏设备的屏幕四角有圆角、顶部有刘海、底部有手势条。默认情况下浏览器会把页面布局到"安全区域"内(四周留黑边),这保证不裁切但浪费屏幕。想要全屏铺满,需要两步:
<!-- 第一步:声明允许铺满整个屏幕 -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
/* 第二步:手动给可能被遮挡的内容加安全区边距 */
.bottom-bar {
/* 至少 12px,底部横条更高时取安全区值 */
padding-bottom: max(12px, env(safe-area-inset-bottom));
}
.title {
/* 横屏时左右是圆角,需要 inset-left/right */
padding-left: max(16px, env(safe-area-inset-left));
}
env(safe-area-inset-top/right/bottom/left) 是浏览器暴露的安全区尺寸,单位是 px,横竖屏切换时动态变化。注意两个边界:
- 不开
viewport-fit=cover时env()返回 0:浏览器已经替你留了边,页面看到的 inset 就是零。很多"env 不生效"的排查第一步是看有没有 cover。 constant()的历史兼容:iOS 11.0–11.2 用的是非标准的constant(safe-area-inset-bottom),之后的版本才是env()。除非确有超旧设备要支持,否则只写env()就够了,双写会让 CSS 长期背着僵尸代码。
需要"沉浸式内容 + 不被遮挡控件"的场景(视频页、地图页),把内容铺满、把交互控件单独加 inset,比全局加 padding 更可控。
1px 边框:设备像素比的经典问题
原因一句话:设备像素比(DPR)是 2 或 3 时,CSS 的 1px 对应 2~3 个物理像素,所以视觉上"太粗"。常见解法与取舍:
| 解法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
border: 0.5px | 直接用半像素 | 写法最简单 | 内核不支持时被取整回 1px,且取整行为无法用 CSS 检测 |
| 伪元素 + transform | 1px 元素 scaleY(0.5) | 兼容性最好,DPR=3 时可缩 1/3 | 伪元素被占用,圆角处需要额外处理 |
| SVG border-image | 位图描边 | 支持圆角 | 制作与维护成本高,颜色改起来麻烦 |
| background 渐变线 | 1px 高的渐变背景 | 简单 | 语义差,hover 态维护麻烦 |
/* 方案一:0.5px,配合 JS 检测再挂 class */
.hairline { border-bottom: 0.5px solid #ddd; }
/* 方案二:伪元素缩放,兼容大多数场景 */
.hairline-fallback { position: relative; }
.hairline-fallback::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: #ddd;
transform: scaleY(0.5);
transform-origin: 0 0;
}
0.5px 的坑要专门说:不支持小数渲染的内核不是"忽略"这个值,而是取整回 1px,CSS @supports 检测不出这种渲染差异。要精确判断只能用 JS 测量:
function supportsSubpixelBorder() {
const el = document.createElement('div');
el.style.cssText = 'position:absolute;top:-1px;border-top:0.5px solid';
document.body.appendChild(el);
const height = el.getBoundingClientRect().height; // 渲染为 0.5 则 < 1
el.remove();
return height > 0 && height < 1;
}
if (supportsSubpixelBorder()) {
document.documentElement.classList.add('hairline-2x');
}
另有一个认知纠偏:不是所有边框都该做 1px。列表分隔线、卡片描边这类"看见就该细"的用 hairline 方案;按钮描边这种交互元素,粗一点反而更清楚。别把 1px 优化做成全站信仰。
触控目标与手势:可点区域是交互的地基
两份规范给出的最小触控尺寸,可以直接作为验收标准:
| 来源 | 最小触控目标 | 说明 |
|---|---|---|
| Apple 人机界面指南(HIG) | 44 × 44 pt | iOS 生态的长期标准 |
| Material Design | 48 × 48 dp | Android 生态标准 |
/* 尺寸不达标时,用透明伪元素扩大命中区域,不改视觉 */
.icon-btn {
position: relative;
width: 24px;
height: 24px;
}
.icon-btn::after {
content: "";
position: absolute;
inset: -10px; /* 24 + 20 = 44px 命中区 */
}
手势相关的两个高频问题:
/* touch-action:声明元素允许的手势,浏览器好做优化 */
.map-canvas {
touch-action: none; /* 全部手势留给 JS(如拖地图) */
}
.list-item {
touch-action: manipulation; /* 允许平移和捏合,禁掉双击缩放延迟 */
}
// passive:明确声明"不调 preventDefault",滚动不被 JS 阻塞
// Chrome 56 起 window/document 上的 touch/wheel 监听默认就是 passive
window.addEventListener('touchstart', onTouchStart, { passive: true });
window.addEventListener('touchmove', onTouchMove, { passive: true });
passive: true 的价值在滚动性能:默认的 touch 监听里浏览器必须等你执行完才知道你会不会 preventDefault,于是先卡住滚动;声明 passive 后浏览器可以直接开始滚动。反过来,在 passive 监听里调用 preventDefault() 会被忽略并在控制台报警,这是新手常见报错。
300ms 点击延迟是历史问题:现代浏览器在设置了标准 viewport meta 后已经移除延迟,不需要再用 FastClick 这类库,老项目里的这类依赖应该清理。
软键盘:视口变形的应对
软键盘弹出时页面怎么变,iOS 和 Android 的默认行为不一致,这是"底部输入框被键盘盖住"类 bug 的根源:
| 平台默认行为 | layout viewport | visual viewport |
|---|---|---|
| iOS Safari | 不变 | 被键盘压缩,页面可滚动露出焦点 |
| Android Chrome(默认) | 页面高度被压缩(等价 resize) | 基本同步变小 |
Android Chrome(Chromium 108 起)允许通过 meta 显式声明策略,iOS 则始终走 visual viewport 路线:
<!-- Chromium 108+:resizes-visual 让行为对齐 iOS -->
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-visual">
固定在底部的输入栏是最典型的事故现场:iOS 上键盘弹出后 visual viewport 变小,但 fixed 元素仍然按 layout viewport 定位,于是输入栏"浮"在键盘上方中间的半空中。两种应对:
// 方案一:监听 visualViewport,手动补偿 fixed 元素位置
const bar = document.querySelector('.composer');
const vv = window.visualViewport;
vv?.addEventListener('resize', () => {
// 键盘把可视区顶上去了,用 offsetTop 计算它相对屏幕的位置
const visibleBottom = vv.height + vv.offsetTop;
const hidden = window.innerHeight - visibleBottom; // 键盘占掉的高度
bar.style.transform = `translateY(${-Math.max(0, hidden)}px)`;
});
/* 方案二(Chromium):让键盘"覆盖"内容,自己用 env() 让位。
需要 navigator.virtualKeyboard.overlaysContent = true 后才生效 */
.composer {
padding-bottom: env(keyboard-inset-height);
}
方案二是 VirtualKeyboard API 的一部分(含 virtual-keyboard-policy: manual 等能力),目前仅 Chromium 系实现;iOS 上老老实实用方案一。选型时先确认产品主要用户在哪类端,别只做一半。
iOS WebView 边界:橡皮筋、固定定位与全屏高度
在 WKWebView(或第三方 App 内置浏览器)里,有三类问题与 Safari 表现不同或更顽固:
整页橡皮筋与滚动穿透
iOS 整页滚动到边缘会继续拉伸(rubber band),弹窗打开后底下的页面还能滚(滚动穿透):
/* 现代方案:overscroll-behavior,Safari 16 起支持 */
body {
overscroll-behavior-y: none; /* 禁掉整页橡皮筋 */
}
.modal {
overscroll-behavior: contain; /* 滚动到此元素为止,不穿透 */
}
Safari 16 之前的 iOS 只能靠 JS 在 touchmove 里 preventDefault 解决——注意这类监听必须是非 passive 的,和上文"默认 passive"正好相反,也是老代码升级后突然失效的常见原因。另外 -webkit-overflow-scrolling: touch 这个 iOS 时代的属性已无必要(iOS 13 起原生滚动即有惯性),新代码不要再写。
固定定位与键盘的冲突
position: fixed 在 iOS 上的定位参照的是 layout viewport。软键盘弹出后 layout viewport 没变,于是 fixed 元素的位置"看起来错了"。除了上一节的 transform 补偿,还可以改变布局策略:输入交互放在正常文档流的底部(不 fixed),让浏览器自动滚动露出它,这是最省维护的解。
100vh 与地址栏
iOS Safari 的 100vh 以"地址栏收起后的最大高度"为准,地址栏显示时底部内容被盖住。解法就是 dvh + 回退(单位一节已给代码)。WebView 内嵌场景更复杂:宿主 App 可能自己处理地址栏,vh 的实际行为要在目标 App 里实测。
最后是测试策略:WKWebView 的内核版本跟随 iOS 系统版本,老 iPhone 停留在老 Safari。适配声明里写清支持的最低 iOS 版本,并用对应真机跑核心页面——模拟器给不了这些差异。
注意事项与边界
- 真机矩阵至少覆盖:一台小屏 iOS、一台大屏 Android、一个真实 WebView 宿主。预算有限时砍模拟器,不砍真机。
env(safe-area-inset-*)在未开viewport-fit=cover时恒为 0,排查"不生效"先查 meta。dvh系列、overscroll-behavior、VirtualKeyboard API 都有明确的支持起点,用前查当前支持数据,并给回退值;写 CSS 时把回退值放在新值前面。- 不要用 JS 拦截用户缩放来解决"缩放后布局乱"——那是布局问题,禁缩放只是把它藏起来并牺牲可访问性。
- 设计稿的 px 不是圣旨:标注 1px 分隔线要不要 hairline、底部按钮要不要 44px 命中区,是工程和设计的共同决定,提前约定。
- 移动端性能问题的第一嫌疑人是滚动:长列表用
content-visibility: auto、图片懒加载,触控监听 passive 化,这三件事的收益通常比换框架大。
总结
移动端适配的关键不是记住一堆 hack,而是搞清楚三个机制:视口机制(layout 与 visual viewport 的分工,viewport meta 管前者,visualViewport API 观察后者)、单位机制(字号 rem、视口高度 dvh、内容 clamp,回退写在前面)、交互机制(触控目标 44px 起步、touch-action 声明、passive 监听让滚动不被阻塞)。软键盘、橡皮筋、固定定位这些"疑难杂症",几乎都能还原成这三个机制里某一条被忽略了。遇到新问题,先观察浏览器实际行为(真机 + visualViewport 日志),再写针对性代码,比背解法清单可靠。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。