踩坑记录:iOS Safari 软键盘下的"幽灵弹窗"问题
最近在做移动端 H5 登录页面时,遇到了一个诡异的 bug:在 iOS Safari 中,弹窗明明显示在屏幕中央,点击按钮却毫无反应。检查元素后发现,DOM 的实际位置和视觉位置完全对不上。折腾了一番后终于搞清楚了原因,记录一下。
问题现象
我们的登录模块有两个页面:账号密码登录和验证码登录,可以互相切换。两个页面都有一个协议确认弹窗,使用 position: fixed 定位。
诡异的事情发生了:
- 在账号密码页面输入手机号(软键盘弹出),触发弹窗,点击取消 —— 正常
- 切换到验证码页面,触发弹窗,点击取消 —— 正常
- 再切换回账号密码页面,触发弹窗,点击取消 —— 没反应!
打开 Safari 调试器一看,弹窗的 DOM 位置和屏幕上显示的位置差了好几百像素。点击事件实际触发在了空白区域。
这就是传说中的"幽灵弹窗"。
前置知识:理解移动端视口
要搞清楚这个问题,得先理解移动端浏览器的视口概念。这部分内容稍微有点绕,但理解了之后很多移动端的坑就都能解释了。
三种视口
移动端浏览器有三种视口:
1. 布局视口(Layout Viewport)
布局视口是 CSS 布局的基准。当你写 width: 100% 时,这个 100% 就是相对于布局视口的宽度。
在没有 <meta name="viewport"> 标签的情况下,移动端浏览器的布局视口默认是 980px(不同浏览器可能略有差异)。这就是为什么早期的桌面网页在手机上看起来特别小 —— 浏览器把 980px 的内容硬塞进了 320px 的屏幕。
<!-- 这行代码让布局视口等于设备宽度 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">2. 视觉视口(Visual Viewport)
视觉视口是用户当前能看到的区域。当用户双指缩放页面时,布局视口不变,但视觉视口会变小(放大时)或变大(缩小时)。
可以这样理解:布局视口是一张大地图,视觉视口是你手里的放大镜。放大镜移动或缩放,地图本身不会变。
3. 理想视口(Ideal Viewport)
理想视口是设备屏幕的实际尺寸。width=device-width 就是让布局视口等于理想视口。
用代码获取视口信息
// 布局视口
const layoutWidth = document.documentElement.clientWidth;
const layoutHeight = document.documentElement.clientHeight;
// 视觉视口(需要 Visual Viewport API)
const visualWidth = window.visualViewport?.width;
const visualHeight = window.visualViewport?.height;
const offsetTop = window.visualViewport?.offsetTop; // 视觉视口相对于布局视口的偏移
// 屏幕尺寸
const screenWidth = window.screen.width;
const screenHeight = window.screen.height;视口与 position: fixed 的关系
这里是关键点:position: fixed 的元素是相对于视觉视口定位的,但它的点击区域是根据布局视口计算的。
正常情况下,视觉视口和布局视口是重合的,所以没问题。但当软键盘弹出时,iOS Safari 会改变视觉视口而不改变布局视口,这就导致了视觉位置和点击区域的错位。
浏览器渲染原理:为什么会"错位"
要理解"幽灵弹窗",还需要了解浏览器是怎么渲染页面的。
渲染流水线
浏览器渲染一个页面大致经过以下步骤:
HTML → DOM Tree
↘
→ Render Tree → Layout → Paint → Composite
↗
CSS → CSSOM- 解析 HTML:构建 DOM 树
- 解析 CSS:构建 CSSOM(CSS Object Model)
- 合并:DOM + CSSOM = Render Tree(渲染树)
- 布局(Layout/Reflow):计算每个元素的位置和大小
- 绘制(Paint):把元素画到屏幕上
- 合成(Composite):把不同图层合并
重排(Reflow)与重绘(Repaint)
重排(Reflow)
当元素的几何属性(位置、大小)发生变化时,浏览器需要重新计算布局,这就是重排。
触发重排的操作:
- 改变窗口大小
- 改变字体大小
- 添加/删除 DOM 元素
- 改变元素的
width、height、margin、padding等 - 读取某些属性:
offsetTop、scrollTop、clientTop、getComputedStyle()等
重绘(Repaint)
当元素的外观(颜色、背景、阴影等)发生变化,但几何属性不变时,只需要重绘,不需要重排。
触发重绘的操作:
- 改变
color、background、visibility等
性能影响
重排的代价比重绘大得多,因为重排会导致整个渲染树的重新计算。这就是为什么我们要尽量避免频繁触发重排。
图层(Layer)与合成
现代浏览器会把页面分成多个图层,每个图层独立渲染,最后合成到一起。某些 CSS 属性会触发元素提升为独立图层:
transformopacitywill-changeposition: fixed(在某些浏览器中)
独立图层的好处是:当这个元素变化时,不会影响其他图层,只需要重新合成,不需要重排整个页面。
这就是为什么我们在解决方案中使用 transform: translate3d(0, 0, 0) —— 它强制创建一个独立的合成层,让弹窗的渲染与页面其他部分隔离。
问题根因深度分析
现在我们有了足够的背景知识,来深入分析"幽灵弹窗"的成因。
iOS Safari 的"独特"处理方式
当软键盘弹出时,不同平台的处理方式不同:
Android Chrome 的做法:
软键盘弹出前:
┌─────────────────────┐
│ │
│ Layout Viewport │ = Visual Viewport
│ (100vh) │
│ │
└─────────────────────┘
软键盘弹出后:
┌─────────────────────┐
│ Layout Viewport │ = Visual Viewport(缩小了)
│ (变小了) │
├─────────────────────┤
│ 软键盘 │
└─────────────────────┘Android 的做法是缩小布局视口,position: fixed 的元素会相对于新的视口重新定位,一切正常。
iOS Safari 的做法:
软键盘弹出前:
┌─────────────────────┐
│ │
│ Layout Viewport │ = Visual Viewport
│ (100vh) │
│ │
└─────────────────────┘
软键盘弹出后:
┌─────────────────────┐ ← Layout Viewport(不变)
│ ↑ │
│ │ 页面被推上去 │
│ ↓ │
├─────────────────────┤ ← Visual Viewport(变小了)
│ 软键盘 │
└─────────────────────┘iOS 保持布局视口不变,只是把页面内容往上"推"。这时候:
position: fixed的元素根据布局视口计算位置- 但用户看到的是视觉视口
- 两者不一致,就出现了"幽灵"现象
为什么多次切换后才出问题?
每次软键盘弹出,window.scrollY 都会产生变化。当用户在多个页面之间切换时:
- 页面 A:软键盘弹出,
scrollY = 200 - 切换到页面 B:
scrollY可能没有完全重置 - 页面 B:软键盘弹出,
scrollY累加 - 切换回页面 A:累积的偏移量导致计算错误
Vue Router 的 router.replace() 不会触发完整的页面刷新,滚动状态会残留。
为什么 iOS Chrome 也有问题?
这里要提一个很多人不知道的事实:iOS 上所有浏览器都是 Safari 套壳。
Apple 的 App Store 政策要求:iOS 上的所有浏览器必须使用 WebKit 引擎。所以:
| 浏览器 | Android 引擎 | iOS 引擎 |
|---|---|---|
| Chrome | Blink | WebKit |
| Firefox | Gecko | WebKit |
| Edge | Blink | WebKit |
| Safari | - | WebKit |
iOS 上的 Chrome 只是换了个 UI 的 Safari,底层渲染引擎完全一样。这个问题在 iOS 上是无差别攻击。
解决方案
核心思路
弹窗显示时,把页面"冻住",让布局视口和视觉视口强制保持一致。
let savedScrollY = 0;
let savedBodyStyle = '';
// 锁定
const lockBodyScroll = () => {
// 兼容性写法,确保能获取到滚动位置
savedScrollY = window.scrollY || window.pageYOffset || document.documentElement.scrollTop || 0;
savedBodyStyle = document.body.style.cssText;
document.body.style.cssText = `
${savedBodyStyle};
position: fixed;
top: -${savedScrollY}px;
left: 0;
right: 0;
width: 100%;
overflow: hidden;
`;
};
// 解锁
const unlockBodyScroll = () => {
document.body.style.cssText = savedBodyStyle;
window.scrollTo(0, savedScrollY);
};原理解析:
position: fixed让 body 脱离文档流,不再滚动top: -${savedScrollY}px用负值补偿当前滚动位置,保持视觉一致overflow: hidden禁止滚动- 关闭弹窗时恢复原始样式,并用
scrollTo恢复滚动位置
Vue 组件中的使用
import { watch, toRefs, nextTick } from 'vue';
const props = defineProps<{ show: boolean }>();
const { show } = toRefs(props);
watch(show, (val) => {
if (val) {
nextTick(() => {
lockBodyScroll();
});
} else {
unlockBodyScroll();
}
}, { immediate: true });
// 组件卸载时确保解锁,防止状态残留
onBeforeUnmount(() => {
if (show.value) {
unlockBodyScroll();
}
});CSS 优化
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
// 动态视口高度,iOS 15+ 支持
// 会随软键盘弹出自动调整
min-height: 100vh;
min-height: 100dvh;
// 强制创建独立合成层
// 让弹窗的渲染与页面隔离
transform: translate3d(0, 0, 0);
-webkit-transform: translate3d(0, 0, 0);
// 禁用触摸滚动手势
touch-action: none;
-webkit-touch-callout: none;
// 防止滚动穿透到父元素
overscroll-behavior: contain;
}CSS 属性解析:
| 属性 | 作用 |
|---|---|
100dvh | 动态视口高度,会随软键盘变化 |
transform: translate3d(0,0,0) | 触发 GPU 加速,创建独立图层 |
touch-action: none | 禁用所有触摸手势 |
overscroll-behavior: contain | 滚动到边界时不会触发父元素滚动 |
踩过的坑
坑 1:忘记在组件卸载时解锁
如果用户在弹窗打开状态下切换页面(比如点击了弹窗里的链接),body 的 fixed 样式会残留,导致新页面无法滚动。
解决: 一定要在 onBeforeUnmount 里清理。
坑 2:滚动位置恢复不准确
window.scrollY 在某些老旧浏览器或特殊情况下可能是 undefined。
解决: 使用兼容性写法:
const scrollY = window.scrollY || window.pageYOffset || document.documentElement.scrollTop || 0;坑 3:100vh 在 iOS 上不靠谱
iOS Safari 的 100vh 是一个"理想值",包含了地址栏的高度。当地址栏收起或软键盘弹出时,实际可视区域会变化,但 100vh 不变。
解决: 使用 100dvh(动态视口高度),但要注意兼容性:
- iOS 15.4+
- Chrome 108+
- Firefox 101+
对于不支持的浏览器,可以用 JS 动态计算:
const setVh = () => {
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
};
window.addEventListener('resize', setVh);坑 4:多个弹窗嵌套
如果页面上有多个弹窗组件,每个都调用 lockBodyScroll,会互相覆盖 savedScrollY。
解决: 使用引用计数:
let lockCount = 0;
let savedScrollY = 0;
let savedBodyStyle = '';
const lockBodyScroll = () => {
if (lockCount === 0) {
savedScrollY = window.scrollY || 0;
savedBodyStyle = document.body.style.cssText;
document.body.style.cssText = `...`;
}
lockCount++;
};
const unlockBodyScroll = () => {
lockCount--;
if (lockCount === 0) {
document.body.style.cssText = savedBodyStyle;
window.scrollTo(0, savedScrollY);
}
};延伸:其他解决方案
方案二:使用 Visual Viewport API
const adjustDialogPosition = () => {
if (window.visualViewport) {
const offsetY = window.innerHeight - window.visualViewport.height;
dialogRef.value.style.transform = `translateY(-${offsetY / 2}px)`;
}
};
onMounted(() => {
window.visualViewport?.addEventListener('resize', adjustDialogPosition);
});
onBeforeUnmount(() => {
window.visualViewport?.removeEventListener('resize', adjustDialogPosition);
});优点: 不需要锁定 body,更"优雅" 缺点: 需要持续监听,有性能开销;弹窗位置会跳动
方案三:软键盘收起后再显示弹窗
const showDialog = () => {
// 先让输入框失焦,收起软键盘
(document.activeElement as HTMLElement)?.blur();
// 等待软键盘收起动画完成
setTimeout(() => {
dialogVisible.value = true;
}, 300);
};优点: 简单粗暴,绕过问题 缺点: 用户体验不好,有明显延迟
总结
这个问题本质上是 iOS WebKit 的 viewport 处理机制 和 position: fixed 定位 的冲突。
关键点:
- iOS Safari 软键盘弹出时只改变 Visual Viewport,不改变 Layout Viewport
position: fixed根据 Layout Viewport 计算位置,但渲染在 Visual Viewport- 页面切换时滚动状态累积,加剧了偏移
解决方案的核心就一句话:弹窗显示时锁定 body 滚动,关闭时恢复。
这不是 bug,是 Apple 的"设计选择"。作为开发者,我们只能见招拆招。
希望这篇文章能帮到遇到同样问题的朋友,少走点弯路。
参考资料: