Skip to content

移动端输入框聚焦跳动与自动放大

约 2392 字大约 8 分钟

Web前端排障移动端

2026-10-06

统计数据加载中...

前言

iPhone 上点输入框,最常见的两种病:

  1. 页面自动放大,收起键盘也不缩回去
  2. 页面/弹窗跳一下:居中弹窗被顶上去、关闭按钮出屏;收键盘后也不回来,多点几次越偏越多

目标是:缩放永远只由用户手动捏合;点输入框前后画面不动;键盘只在用户亲手点输入框时出现。

做法是封装成一个公共模块,在应用入口统一接管,不在各组件里逐个修。会弹键盘的地方遍布全站(弹窗、页面搜索、登录、设置、行内改名……),逐个改 autoFocus 或加监听必然漏,新加的输入框也不会自动受保护。正确形态是:CSS 一条全局规则 + JS 一个模块在入口调用一次,业务组件零改动,照常写 <input> / autoFocus。

适用范围:任何手机端网页,与框架无关。JS 部分是原生 DOM 代码,React / Vue / 纯 HTML 都能用。

根因

症状根因
自动放大iOS 在输入框字号 < 16px 时聚焦会放大页面,且失焦后不还原
画面跳动输入框获得焦点时,iOS 会把根文档滚到它认为“输入框可见”的位置,哪怕输入框本来就在键盘上方。根文档一滚,position: fixed 的弹窗/底栏就在屏幕上跟着移动。这次滚动收键盘后不滚回,下次再点又叠一段
切页签/开弹窗时键盘自己弹出autoFocus 或代码里的 .focus() 正好跟在一次用户点击后面,iOS 认作用户意图,弹键盘;而且这种聚焦不经过下文的拦截,照样会跳

桌面浏览器和 devtools 移动模拟都复现不出来(没有真实软键盘,也没有 iOS 的聚焦滚动),只能真机看。

一组真机实测数据(iPhone,iOS Chrome / WebKit):弹窗里点搜索框,scrollY 0→51,visualViewport.offsetTop 0→51,visualViewport.height 684→355;收键盘后 scrollY 仍是 51,再点变 102。输入框明明在可视区内,这 51px 完全没必要。

先量,再改

真机没有 devtools,把数打回服务端终端(打点方式见《移动端网页问题复现与调试指南》)。贴一段临时探针:

// 临时探针:确认后删除。/api/boot-log 换成你项目里能把 body 打进服务端终端的接口
{
  const v = window.visualViewport
  const rows: string[] = []
  let timer = 0
  const snap = (tag: string) => {
    const box = document.querySelector('.dlg')            // ← 换成会跳的那个 fixed 元素
    const r = box?.getBoundingClientRect()
    rows.push(`${tag} top=${r?.top.toFixed(1)} vvTop=${v?.offsetTop.toFixed(1)} vvH=${v?.height.toFixed(1)} ih=${innerHeight} sy=${scrollY} act=${document.activeElement?.tagName}`)
    clearTimeout(timer)
    timer = window.setTimeout(() => {
      const body = JSON.stringify({ detail: '[vvprobe] ' + rows.splice(0).slice(-12).join(' | ') })
      navigator.sendBeacon('/api/boot-log', new Blob([body], { type: 'application/json' }))
    }, 1200)
  }
  document.addEventListener('touchstart', () => snap('touch'), { capture: true, passive: true })
  v?.addEventListener('resize', () => snap('resize'))
  v?.addEventListener('scroll', () => snap('scroll'))
}

别只挂 focusin:探针挂在 focusin 上时,真机一条都没回来。挂 touchstart + visualViewport 事件才稳。

读法:

看到的结论
聚焦后 sy 变大、vvTop 跟着变,收键盘后 sy 不回 0就是本病,用下文的 JS 接管
sy 不变,弹窗 top 变了弹窗高度用了 dvh/% 跟着工具栏变,查弹窗 CSS(改 svh),不是本病
页面整体变大、字变粗自动放大,用下文的 16px 规则

修复

CSS:触屏输入框 16px

治自动放大。

/* iOS 在输入框字号 < 16px 时聚焦会自动放大页面且不还原;抬到 16px 就不触发。
   不用 viewport 的 maximum-scale / user-scalable=no:那会锁死用户手动缩放。 */
@media (hover: none) and (pointer: coarse) {
  input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="file"]),
  textarea,
  select { font-size: 16px !important; }
}

放在全局样式最后,保证压过组件里的字号。只在触屏生效,桌面设计不变。不要用 viewport 的 maximum-scale / user-scalable=no,那会锁死用户手动缩放。

JS:全站接管 iOS 聚焦

治跳动和键盘乱弹。做法来自 Adobe React Aria 的 usePreventScroll(业界弹窗库的标准解),三条规则:

  1. 用户点输入框:拦下 touchend,自己 focus;聚焦那一帧把输入框挪到屏幕外,iOS 判定“无需滚动”,下一帧挪回。
  2. 代码触发的聚焦(autoFocus、点“改名”后 .focus()):在 iOS 上不执行,键盘只在用户亲手点时出现。
  3. 键盘弹出后输入框真被挡住(比如贴底的输入框):只滚它所在的滚动容器把它露出来,没有容器才退回滚根文档。没被挡住就一像素都不动。
// iosKeyboardFocus.ts —— 在应用入口、首屏渲染前调用一次 installIosKeyboardFocus()
const TEXT_INPUT = /^(text|search|email|url|tel|password|number)$/
const KEYBOARD_MARGIN = 12

type TextField = HTMLInputElement | HTMLTextAreaElement

function isTextField(el: unknown): el is TextField {
  if (el instanceof HTMLTextAreaElement) return !el.readOnly && !el.disabled
  return el instanceof HTMLInputElement && TEXT_INPUT.test(el.type) && !el.readOnly && !el.disabled
}

function scrollParent(el: Element): Element | null {
  for (let node = el.parentElement; node && node !== document.body; node = node.parentElement) {
    const { overflowY } = getComputedStyle(node)
    if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) return node
  }
  return null
}

function revealAboveKeyboard(field: TextField): void {
  const vv = window.visualViewport
  if (!vv) return
  const hidden = field.getBoundingClientRect().bottom - (vv.offsetTop + vv.height) + KEYBOARD_MARGIN
  if (hidden <= 0) return
  const box = scrollParent(field)
  if (box && box.scrollTop + box.clientHeight < box.scrollHeight) box.scrollBy({ top: hidden, behavior: 'smooth' })
  else window.scrollBy({ top: hidden, behavior: 'smooth' })
}

export function installIosKeyboardFocus(): void {
  if (!/iP(hone|ad|od)/.test(navigator.userAgent)) return
  const nativeFocus = HTMLElement.prototype.focus
  let userTap = false

  // 规则 2:代码触发的聚焦在 iOS 上跳过(autoFocus 底层也走这里)
  HTMLElement.prototype.focus = function (options?: FocusOptions) {
    if (isTextField(this) && !userTap) return
    nativeFocus.call(this, options)
  }

  let startX = 0
  let startY = 0
  document.addEventListener('touchstart', (e) => {
    startX = e.touches[0]?.clientX ?? 0
    startY = e.touches[0]?.clientY ?? 0
  }, { capture: true, passive: true })

  document.addEventListener('touchend', (e) => {
    // 从输入框上起手的滑动不是点击
    const end = e.changedTouches[0]
    if (!end || Math.hypot(end.clientX - startX, end.clientY - startY) > 10) return
    // 点 <label> 文字也会聚焦它的输入框,一并接管
    const label = e.target instanceof Element ? e.target.closest('label') : null
    const field = isTextField(e.target) ? e.target : label?.control
    if (!isTextField(field) || e.touches.length) return
    const vv = window.visualViewport
    // 键盘已经弹着时再点同一个框是在挪光标,交还给系统
    if (document.activeElement === field && vv && vv.height < window.innerHeight - 100) return
    e.preventDefault()
    // 已聚焦但键盘被收起时,再点同样会触发 iOS 滚动,先 blur 走同一条路径
    if (document.activeElement === field) field.blur()
    // 规则 1:聚焦那一帧挪出屏幕,iOS 就不滚根文档
    field.style.transform = 'translateY(-2000px)'
    userTap = true
    nativeFocus.call(field, { preventScroll: true })
    userTap = false
    requestAnimationFrame(() => { field.style.transform = '' })
    // 规则 3:键盘弹出时 visualViewport 会 resize;键盘本已显示时不会,再兜一个定时
    let done = false
    const reveal = () => { if (!done) { done = true; revealAboveKeyboard(field) } }
    window.visualViewport?.addEventListener('resize', reveal, { once: true })
    window.setTimeout(reveal, 600)
  }, { capture: true, passive: false })
}

在应用入口、首屏渲染前调用一次(React 示例,其他框架同理):

import { installIosKeyboardFocus } from './iosKeyboardFocus'
installIosKeyboardFocus()

代价(可接受,React Aria 同样如此):首次点输入框时光标落在文字末尾而不是点击处;键盘弹着时再点就恢复系统的光标定位,长按也照常可用。

接入时要确认的

#要素怎么定
1哪些 input type 算“会弹键盘”默认 text/search/email/url/tel/password/number + textarea。用了 contenteditable 编辑器、date 等,按需加进 isTextField
2入口位置全项目只调一次,放在应用挂载前。多页站点每页都要引到
3有没有依赖“程序聚焦弹键盘”的交互grep -rn "autoFocus|\.focus("。规则 2 会让它们在 iOS 上不弹键盘。确实要弹的(如聊天页进入即输入),产品上确认后在 focus 补丁里按选择器白名单放行
4贴底的输入框规则 3 会兜底滚出来;但如果它是 position: fixed; bottom: 0 且外面没滚动容器,只能退回滚根文档,会有一次位移,属于正常行为
516px 会不会撑破设计在 375px 宽下逐个看输入框;挤的话调 padding/高度,不要把字号降回去
6Android方案只在 iOS 生效(UA 判断)。Android Chrome 默认按 interactive-widget=resizes-visual 处理,一般无此病

验收

iPhone 真机,iOS Safari 与 iOS Chrome 各一遍,逐个过所有会弹键盘的地方(弹窗搜索框、页面搜索框、密码框、行内改名……):

已证明无效的做法

这些都上过真机:

  • 用 transform: translateY(visualViewport.offsetTop) 反向补偿 fixed 弹窗:真机量到补偿根本没生效(弹窗 getBoundingClientRect().top 恒为 0)。
  • iOS 滚完后再 scrollTo 把根文档钉回:和 iOS 自己的滚动打架,点输入框闪一下、键盘弹不出来,比原来更糟。要在滚动发生前让 iOS 判定不需要滚,不是事后纠正。
  • 把输入框挪高 / 删副标题让它离键盘远一点:改了用户能看到的布局,而且根因不是输入框位置。
  • viewport 加 maximum-scale=1 / user-scalable=no 防放大:会锁死用户手动缩放,用 16px。
  • 只在弹窗里接管:页面里的搜索框、设置里的密码框照样跳,接管要全站统一。
  • 只去掉某个弹窗的 autoFocus:其他 autoFocus / .focus() 照样乱弹键盘、照样跳,要在 focus 原型上统一拦。

小结

先在真机把 scrollY / visualViewport.offsetTop / height 打回终端量出来,一眼就能看出是“iOS 滚了根文档”;然后去找成熟库(React Aria usePreventScroll)怎么做,而不是自己补偿或事后纠正。