FORMA

键盘弹起导致底部被顶起问题(H5 适配)

问题现象

在移动端 H5 页面中,当用户聚焦 inputtextarea 等可编辑元素、软键盘弹出时,常见表现包括:

  • 底部固定按钮或操作栏被整体上推,甚至挤到视口中部;
  • 页面出现大面积空白或布局错位;
  • 输入框被键盘遮挡,用户需要手动滚动才能看到光标;
  • 键盘收起后,底部区域位置未恢复,出现“半截按钮”或闪烁。

上述问题会直接影响表单提交、支付确认等关键路径,需要在设计与实现阶段单独考虑兼容。

成因说明

布局视口与视觉视口

移动端页面存在两个相关但不同的概念(参见 MDN:Visual Viewport API):

概念说明
布局视口(Layout Viewport)页面文档的布局范围,通常不因软键盘弹出而改变整体高度
视觉视口(Visual Viewport)用户当前在屏幕上实际可见的区域

软键盘、系统 UI 等会缩小视觉视口,而布局视口往往保持不变。若底部栏使用 position: fixed 且相对于布局视口定位,在键盘弹出时就容易出现“被顶上去”或“与键盘重叠”的错觉。

平台差异(经验归纳)

环境常见表现
iOS Safari / WKWebView常会滚动页面以保证输入框可见;部分场景下 fixed 元素表现相对稳定,但仍可能出现地址栏收起/展开带来的高度抖动
Android Chrome / 系统 WebView键盘弹出时 window.innerHeightvisualViewport.height 变化更明显,resize 更易触发
混合 App 内嵌 WebView是否调整 WebView 高度、是否全屏等由原生容器决定,行为差异大,需与客户端联调

因此不能依赖单一平台的“默认行为”,而应在目标机型上做真机验证。

解决思路概览

思路适用场景说明
调整布局可改稿、非强依赖底部悬浮少用或慎用底部 fixed,用文档流 + padding-bottom 预留空间
侦测高度变化,控制底部显隐必须保留底部操作栏键盘弹出时隐藏底部栏,收起后恢复(实现简单,兼容面广)
Visual Viewport API现代浏览器、需更精确判断监听 window.visualViewportresize,比单纯 window.innerHeight 更贴近“可见区域”
原生键盘事件(Hybrid)App 内 H5由客户端监听键盘显隐,通过 JSBridge 通知 Web 层,稳定性最好

以下分方案给出示例与注意点。


方案一:布局层面规避

在不影响产品视觉的前提下,可优先尝试:

  1. 底部操作区放入文档流
    使用 position: static / relative,避免与键盘争抢“固定层”。
  2. 为页面底部预留内边距
    例如 padding-bottom: env(safe-area-inset-bottom, 0px),兼顾刘海屏与 Home 指示条(参见 MDN:env())。
  3. 输入框聚焦时滚动到可视区域
    focus 时对当前输入元素调用 element.scrollIntoView({ block: 'center', behavior: 'smooth' }),减轻遮挡(注意 iOS 上可能与系统自带滚动叠加)。

该方案对“必须全局悬浮提交按钮”的设计约束较大,常与方案二、三组合使用。


方案二:监听 resize + innerHeight(通用兜底)

原理:键盘弹出后,许多环境下 window.innerHeight 会明显变小;与进入页面时记录的高度对比,超过阈值则视为键盘已打开。

javascript
/**
 * 根据视口高度变化切换底部栏显隐
 * @param {HTMLElement | null} bottomEl 底部容器
 * @param {number} threshold 高度差阈值(px),建议 50–120,需真机调优
 * @returns {() => void} 卸载函数,SPA 路由离开时必须调用
 */
function bindKeyboardBottomToggle(bottomEl, threshold = 50) {
  if (!bottomEl) return () => {};

  const baseHeight = window.innerHeight;

  const onResize = () => {
    const current = window.innerHeight;
    const keyboardLikelyOpen = baseHeight - current > threshold;
    bottomEl.style.display = keyboardLikelyOpen ? "none" : "";
  };

  window.addEventListener("resize", onResize);
  return () => window.removeEventListener("resize", onResize);
}

// 使用示例
const bottom = document.getElementById("bottom");
const unbind = bindKeyboardBottomToggle(bottom, 80);

// 页面销毁 / 路由离开时
// unbind();

效果:键盘弹起时隐藏底部栏,收起后恢复,避免固定栏被顶到中间或压在键盘上。

局限

  • 横竖屏切换、地址栏显隐也会改变 innerHeight,可能误判;
  • 部分 iOS 版本键盘弹出时 innerHeight 变化较小,需降低阈值或改用方案三。

方案三:Visual Viewport API(推荐)

Visual Viewport 直接描述“用户当前可见区域”,软键盘弹出时 visualViewport.height 通常会减小,比单独依赖 window.innerHeight 更贴近键盘场景(Baseline 兼容性已较广,详见 MDN 兼容性表)。

javascript
function bindVisualViewportBottomToggle(bottomEl, threshold = 50) {
  if (!bottomEl || !window.visualViewport) {
    return bindKeyboardBottomToggle(bottomEl, threshold);
  }

  const vv = window.visualViewport;
  const baseHeight = vv.height;

  const update = () => {
    const keyboardLikelyOpen = baseHeight - vv.height > threshold;
    bottomEl.style.display = keyboardLikelyOpen ? "none" : "";
  };

  vv.addEventListener("resize", update);
  vv.addEventListener("scroll", update);

  return () => {
    vv.removeEventListener("resize", update);
    vv.removeEventListener("scroll", update);
  };
}

若需底部栏始终贴在当前可见区域底部(类似“贴在键盘上方”),可结合 visualViewport.offsetTopvisualViewport.heighttransform 动态定位;实现成本更高,且快速滚动时可能出现轻微闪烁,需权衡(MDN 示例见 Visual Viewport 文档 中的 “Simulating position: device-fixed”)。


方案四:Vue 3 组合式封装示例

vue
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";

const showBottomBar = ref(true);
let unbind: (() => void) | undefined;

onMounted(() => {
  const bottom = document.getElementById("bottom");
  if (!bottom) return;

  const vv = window.visualViewport;
  const base = vv?.height ?? window.innerHeight;
  const threshold = 80;

  const update = () => {
    const h = vv?.height ?? window.innerHeight;
    showBottomBar.value = base - h <= threshold;
  };

  if (vv) {
    vv.addEventListener("resize", update);
    unbind = () => vv.removeEventListener("resize", update);
  } else {
    window.addEventListener("resize", update);
    unbind = () => window.removeEventListener("resize", update);
  }
});

onUnmounted(() => unbind?.());
</script>

<template>
  <div id="bottom" v-show="showBottomBar">提交</div>
</template>

方案五:混合 App(WebView)场景

在 React Native、Flutter 或原生 WebView 容器中,建议:

  1. 由客户端监听键盘显示/隐藏(如 Android WindowInsets、iOS keyboardWillShow / keyboardWillHide);
  2. 通过 JSBridge 向 H5 发送 { type: 'keyboard', visible: boolean, height?: number }
  3. H5 根据事件切换底部栏显隐或调整 padding-bottom,避免依赖浏览器 resize 的不一致行为。

该方式在目标 App 内通常最稳定,但需要客户端配合。


实践注意点

  1. 阈值需真机调优
    50px 仅为经验起点;全面屏、横屏、外接键盘等场景建议各测一轮,常用范围约 80–150px。
  2. 对监听做节流
    键盘动画过程中 resize 可能连续触发,可用 requestAnimationFrame 合并更新,或使用节流(如 100ms),减少 DOM 读写。
  3. SPA 必须卸载监听
    使用 keep-alive 时,在 onDeactivated / onUnmounted 中移除监听,防止重复绑定与内存泄漏。
  4. 避免用 display: none 打断焦点
    若底部栏内也有可聚焦元素,隐藏前确认当前焦点在输入框上,避免可访问性与焦点陷阱问题。
  5. 不要与 100vh 死磕
    移动端 100vh 常包含地址栏等区域,键盘场景下易算错高度;可优先考虑 100dvh(动态视口,参见 MDN:viewport 长度单位)或 JS 动态计算。
  6. 上线前测试矩阵
    至少覆盖:iOS Safari、Android Chrome、微信内置浏览器、目标 App WebView;各测“首个输入框聚焦”“切换输入框”“旋转屏幕”“键盘收起”四条路径。

小结

优先级建议
设计阶段能不用底部全局 fixed 则尽量不用
纯 H5visualViewport + 显隐切换;不支持时回退 innerHeight + resize
App 内 H5优先 JSBridge + 原生键盘事件
所有方案真机验证 + 路由卸载监听

参考文献

Series

team documents

3 / 22