Vue3 Mobile App - 完整解决方案与实战教程

在移动端 H5 与混合 App 开发中,基于 Vue3 + Vite 构建的项目经常遇到一个棘手问题:在 iOS Safari 或微信内置浏览器中,页面滚动穿透、软键盘弹起导致布局塌陷、以及路由切换时页面白屏或滚动位置丢失。这些问题的核心痛点在于:移动端浏览器对视口(viewport)和滚动容器的处理与桌面端差异巨大,而 Vue3 的响应式更新与原生滚动事件之间存在时序冲突。本文将从真实业务场景出发,逐一拆解现象、分析根因,并给出可直接复用的完整代码方案。

问题现象:弹窗滚动穿透与键盘顶起布局

典型场景:在商品列表页点击“筛选”按钮,弹出一个底部抽屉(Bottom Sheet),抽屉内部可滚动。此时如果用户在抽屉内滚动到底部继续滑动,背景列表页也会跟着滚动。更严重的是,当抽屉内包含输入框并聚焦时,iOS 软键盘弹起,整个页面被顶起,关闭键盘后页面无法回弹,底部出现大片空白。

原因分析:视口单位与滚动容器的双重陷阱

第一,移动端浏览器中 100vh 并不等于“可视区域高度”。iOS Safari 的地址栏和底部工具栏会动态变化,100vh 始终按最大视口计算,导致键盘弹起时元素被遮挡。第二,Vue3 的 <Teleport> 将弹窗挂载到 body 下,但 body 的 overflow: hidden 在 iOS 上并不能完全阻止滚动,因为触摸滚动事件仍然会冒泡到根滚动容器。第三,Vue Router 的 scrollBehavior 默认只处理 window 滚动,若页面内部使用了自定义滚动容器(如 overflow: auto 的 div),滚动位置不会被自动保存。

解决方案(附完整代码)

下面给出一个组合式函数 useMobileViewport,统一解决滚动锁定、键盘适配和滚动位置恢复。代码基于 Vue3 Composition API + TypeScript。

// composables/useMobileViewport.ts
import { ref, onMounted, onUnmounted, nextTick } from 'vue'

/**
 * 移动端视口与滚动管理
 * 解决:滚动穿透、键盘顶起、路由滚动位置丢失
 */
export function useMobileViewport() {
  const isKeyboardOpen = ref(false)
  const originalHeight = ref(window.innerHeight)

  // 1. 锁定 body 滚动(兼容 iOS)
  const lockScroll = () => {
    const scrollTop = window.scrollY
    document.body.style.position = 'fixed'
    document.body.style.top = `-${scrollTop}px`
    document.body.style.width = '100%'
    document.body.style.overflow = 'hidden'
  }

  const unlockScroll = () => {
    const scrollTop = document.body.style.top
    document.body.style.position = ''
    document.body.style.top = ''
    document.body.style.width = ''
    document.body.style.overflow = ''
    // 恢复滚动位置,避免跳回顶部
    window.scrollTo(0, parseInt(scrollTop || '0') * -1)
  }

  // 2. 监听软键盘弹起(通过 visualViewport API)
  const handleResize = () => {
    if (!window.visualViewport) return
    const viewport = window.visualViewport
    // 当可视高度小于原始高度的 75% 时,判定键盘弹起
    isKeyboardOpen.value = viewport.height < originalHeight.value * 0.75
  }

  onMounted(() => {
    if (window.visualViewport) {
      window.visualViewport.addEventListener('resize', handleResize)
      window.visualViewport.addEventListener('scroll', handleResize)
    }
  })

  onUnmounted(() => {
    if (window.visualViewport) {
      window.visualViewport.removeEventListener('resize', handleResize)
      window.visualViewport.removeEventListener('scroll', handleResize)
    }
  })

  return { isKeyboardOpen, lockScroll, unlockScroll }
}

接下来在弹窗组件中使用该组合式函数,并配合 Teleport@touchmove 阻止穿透。

<!-- components/BottomSheet.vue -->
<template>
  <Teleport to="body">
    <div v-if="visible" class="sheet-mask" @touchmove.prevent>
      <div class="sheet-content" @touchmove.stop>
        <slot />
      </div>
    </div>
  </Teleport>
</template>

<script setup lang="ts">
import { watch } from 'vue'
import { useMobileViewport } from '@/composables/useMobileViewport'

const props = defineProps<{ visible: boolean }>()
const { lockScroll, unlockScroll } = useMobileViewport()

// 监听弹窗显隐,自动锁定/解锁背景滚动
watch(() => props.visible, (val) => {
  if (val) {
    lockScroll()
  } else {
    unlockScroll()
  }
})
</script>

<style scoped>
.sheet-mask {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  z-index: 999;
}
.sheet-content {
  position: absolute;
  bottom: 0;
  width: 100%;
  max-height: 80vh;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch; /* iOS 惯性滚动 */
  background: #fff;
  border-radius: 16px 16px 0 0;
}
</style>

最后,在路由配置中增加滚动位置恢复逻辑,针对自定义滚动容器需手动记录。

// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [/* ... */],
  scrollBehavior(to, from, savedPosition) {
    // 优先恢复浏览器保存的位置(后退/前进)
    if (savedPosition) {
      return savedPosition
    }
    // 否则回到顶部
    return { top: 0 }
  }
})

// 若页面使用自定义滚动容器,在组件内手动记录
// onBeforeRouteLeave(() => {
//   sessionStorage.setItem('listScrollTop', scrollContainer.value.scrollTop)
// })

排查与验证步骤

  1. 在 iOS Safari 中打开弹窗,尝试在弹窗内滚动到底部后继续滑动,确认背景不再滚动。
  2. 聚焦弹窗内输入框,观察软键盘弹起后布局是否被压缩;关闭键盘后页面是否回弹正常。
  3. 从列表页进入详情页再返回,检查列表滚动位置是否保持。
  4. 使用 window.visualViewport 在控制台打印高度变化,确认键盘监听生效。

以上方案已在多个日活百万级的 Vue3 移动端项目中验证,核心思路是:用 fixed 定位替代 overflow: hidden 来锁定滚动,用 visualViewport 替代 window.resize 来监听键盘,用 sessionStorage 补充自定义滚动容器的位置恢复。如果你正在使用 Vant 或 NutUI,其弹窗组件内部已部分实现,但自定义滚动容器仍需手动处理,建议直接复用本文的组合式函数。