Skip to content

踩坑记录:iOS Safari 软键盘下的"幽灵弹窗"问题

最近在做移动端 H5 登录页面时,遇到了一个诡异的 bug:在 iOS Safari 中,弹窗明明显示在屏幕中央,点击按钮却毫无反应。检查元素后发现,DOM 的实际位置和视觉位置完全对不上。折腾了一番后终于搞清楚了原因,记录一下。

问题现象

我们的登录模块有两个页面:账号密码登录和验证码登录,可以互相切换。两个页面都有一个协议确认弹窗,使用 position: fixed 定位。

诡异的事情发生了:

  1. 在账号密码页面输入手机号(软键盘弹出),触发弹窗,点击取消 —— 正常
  2. 切换到验证码页面,触发弹窗,点击取消 —— 正常
  3. 再切换回账号密码页面,触发弹窗,点击取消 —— 没反应!

打开 Safari 调试器一看,弹窗的 DOM 位置和屏幕上显示的位置差了好几百像素。点击事件实际触发在了空白区域。

这就是传说中的"幽灵弹窗"。


前置知识:理解移动端视口

要搞清楚这个问题,得先理解移动端浏览器的视口概念。这部分内容稍微有点绕,但理解了之后很多移动端的坑就都能解释了。

三种视口

移动端浏览器有三种视口:

1. 布局视口(Layout Viewport)

布局视口是 CSS 布局的基准。当你写 width: 100% 时,这个 100% 就是相对于布局视口的宽度。

在没有 <meta name="viewport"> 标签的情况下,移动端浏览器的布局视口默认是 980px(不同浏览器可能略有差异)。这就是为什么早期的桌面网页在手机上看起来特别小 —— 浏览器把 980px 的内容硬塞进了 320px 的屏幕。

html
<!-- 这行代码让布局视口等于设备宽度 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">

2. 视觉视口(Visual Viewport)

视觉视口是用户当前能看到的区域。当用户双指缩放页面时,布局视口不变,但视觉视口会变小(放大时)或变大(缩小时)。

可以这样理解:布局视口是一张大地图,视觉视口是你手里的放大镜。放大镜移动或缩放,地图本身不会变。

3. 理想视口(Ideal Viewport)

理想视口是设备屏幕的实际尺寸。width=device-width 就是让布局视口等于理想视口。

用代码获取视口信息

javascript
// 布局视口
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
  1. 解析 HTML:构建 DOM 树
  2. 解析 CSS:构建 CSSOM(CSS Object Model)
  3. 合并:DOM + CSSOM = Render Tree(渲染树)
  4. 布局(Layout/Reflow):计算每个元素的位置和大小
  5. 绘制(Paint):把元素画到屏幕上
  6. 合成(Composite):把不同图层合并

重排(Reflow)与重绘(Repaint)

重排(Reflow)

当元素的几何属性(位置、大小)发生变化时,浏览器需要重新计算布局,这就是重排。

触发重排的操作:

  • 改变窗口大小
  • 改变字体大小
  • 添加/删除 DOM 元素
  • 改变元素的 widthheightmarginpadding
  • 读取某些属性:offsetTopscrollTopclientTopgetComputedStyle()

重绘(Repaint)

当元素的外观(颜色、背景、阴影等)发生变化,但几何属性不变时,只需要重绘,不需要重排。

触发重绘的操作:

  • 改变 colorbackgroundvisibility

性能影响

重排的代价比重绘大得多,因为重排会导致整个渲染树的重新计算。这就是为什么我们要尽量避免频繁触发重排。

图层(Layer)与合成

现代浏览器会把页面分成多个图层,每个图层独立渲染,最后合成到一起。某些 CSS 属性会触发元素提升为独立图层:

  • transform
  • opacity
  • will-change
  • position: 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 都会产生变化。当用户在多个页面之间切换时:

  1. 页面 A:软键盘弹出,scrollY = 200
  2. 切换到页面 B:scrollY 可能没有完全重置
  3. 页面 B:软键盘弹出,scrollY 累加
  4. 切换回页面 A:累积的偏移量导致计算错误

Vue Router 的 router.replace() 不会触发完整的页面刷新,滚动状态会残留。

为什么 iOS Chrome 也有问题?

这里要提一个很多人不知道的事实:iOS 上所有浏览器都是 Safari 套壳

Apple 的 App Store 政策要求:iOS 上的所有浏览器必须使用 WebKit 引擎。所以:

浏览器Android 引擎iOS 引擎
ChromeBlinkWebKit
FirefoxGeckoWebKit
EdgeBlinkWebKit
Safari-WebKit

iOS 上的 Chrome 只是换了个 UI 的 Safari,底层渲染引擎完全一样。这个问题在 iOS 上是无差别攻击。


解决方案

核心思路

弹窗显示时,把页面"冻住",让布局视口和视觉视口强制保持一致。

typescript
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);
};

原理解析:

  1. position: fixed 让 body 脱离文档流,不再滚动
  2. top: -${savedScrollY}px 用负值补偿当前滚动位置,保持视觉一致
  3. overflow: hidden 禁止滚动
  4. 关闭弹窗时恢复原始样式,并用 scrollTo 恢复滚动位置

Vue 组件中的使用

typescript
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 优化

scss
.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:忘记在组件卸载时解锁

如果用户在弹窗打开状态下切换页面(比如点击了弹窗里的链接),bodyfixed 样式会残留,导致新页面无法滚动。

解决: 一定要在 onBeforeUnmount 里清理。

坑 2:滚动位置恢复不准确

window.scrollY 在某些老旧浏览器或特殊情况下可能是 undefined

解决: 使用兼容性写法:

typescript
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 动态计算:

typescript
const setVh = () => {
  const vh = window.innerHeight * 0.01;
  document.documentElement.style.setProperty('--vh', `${vh}px`);
};
window.addEventListener('resize', setVh);

坑 4:多个弹窗嵌套

如果页面上有多个弹窗组件,每个都调用 lockBodyScroll,会互相覆盖 savedScrollY

解决: 使用引用计数:

typescript
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

typescript
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,更"优雅" 缺点: 需要持续监听,有性能开销;弹窗位置会跳动

方案三:软键盘收起后再显示弹窗

typescript
const showDialog = () => {
  // 先让输入框失焦,收起软键盘
  (document.activeElement as HTMLElement)?.blur();

  // 等待软键盘收起动画完成
  setTimeout(() => {
    dialogVisible.value = true;
  }, 300);
};

优点: 简单粗暴,绕过问题 缺点: 用户体验不好,有明显延迟


总结

这个问题本质上是 iOS WebKit 的 viewport 处理机制position: fixed 定位 的冲突。

关键点:

  1. iOS Safari 软键盘弹出时只改变 Visual Viewport,不改变 Layout Viewport
  2. position: fixed 根据 Layout Viewport 计算位置,但渲染在 Visual Viewport
  3. 页面切换时滚动状态累积,加剧了偏移

解决方案的核心就一句话:弹窗显示时锁定 body 滚动,关闭时恢复

这不是 bug,是 Apple 的"设计选择"。作为开发者,我们只能见招拆招。

希望这篇文章能帮到遇到同样问题的朋友,少走点弯路。


参考资料:

基于 VitePress 构建