iOS H5:虚拟列表「橡皮筋回弹」问题的排查与根治

2 阅读10分钟

技术栈:Vue 2 + 移动端 H5 关键依赖:vue-virtual-scroller@1.1.2(Vue 2 专版) 最终方案:库原生 page-mode + 手写焦点保护 + 大 buffer 生效范围:iOS(iPhone / iPad);非 iOS 端保持原行为、零改动


一、问题现象

页面里用 vue-virtual-scrollerDynamicScroller 做长列表的虚拟滚动。当列表项较多(列表高度 > 2000px)时,在 iOS 上:

  • 在列表区域快速上滑,滑到列表容器的物理底边时会卡住
  • 触发 iOS 的橡皮筋回弹,必须等回弹动画结束才能继续往下滚;
  • 无法一次连续滑动滚到列表最后一项 / 下方的其它区块。

Android、桌面浏览器均无此问题——这是 iOS WebKit 滚动引擎特有的行为差异


二、技术背景:问题是怎么形成的

2.1 页面是「单外层滚动容器」结构

外层页面是一个撑满视口的弹性纵向布局:

.page        { height: 100%; display: flex; flex-direction: column; }
.page .header { /* 自然高度,不滚动 */ }
.page .content { flex: 1; overflow: auto; position: relative; } // ← 唯一的外层滚动容器
/* 底部按钮 fixed 定位 */

关键点:window 本身不滚动,真正滚动的是夹在固定 header / footer 之间的 .content

2.2 虚拟列表自带了一个「内层滚动容器」

.virtual-list { width: 100%; max-height: 2000px; overflow-y: auto; }

当列表总高度超过 2000px 时,这个 DynamicScroller 根元素自身变成一个带动量滚动的独立滚动容器嵌套.content 内部:

.content(外层,overflow:auto)
└── .virtual-list(内层,max-height:2000px + overflow-y:auto)← 列表超过 2000px 时独立滚动

2.3 iOS 的「嵌套滚动不交接动量」

iOS WebKit 里,嵌套的两个滚动容器之间不会交接惯性动量。当手指在内层滑动、内层滚到自身边界时,iOS 会对内层触发橡皮筋回弹,而不会把剩余动量交给外层 .content。于是:滑到内层底 → 回弹 → 卡住 → 必须等回弹结束、重新发起手势才能继续。

根因一句话:问题来自「嵌套滚动」本身,而 max-height: 2000px 正是让内层在长列表时变成独立滚动容器的开关。


三、排查历程:四套方案接连被还原

从迭代记录可以看到,这个问题曾被反复尝试、又接连还原:

方案做法结果 / 实测反馈
动态 buffer / item-size 调参调缓冲区、页面模式等还原
动态 max-height + 触摸守卫按可视视口动态算内层高度 + touch 守卫还原
库原生 page-mode + 大 bufferpage-mode + buffer,去掉内层 overflow还原 —— 滚动中输入框焦点错乱
手写滚动桥接覆写 getScroll + 把 $el.scrollTop 桥接到 .content.scrollTop + 回收时 blur还原 —— iOS 滚动不顺畅、焦点也没根治

这两条反馈是后续定方向的关键:最终方案必须同时满足「顺滑」与「焦点安全」。 而且——剧透一下——page-mode 当时被还原唯一缺的就是焦点处理,这正是最终方案的起点。


四、根因分析(深入库源码)

vue-virtual-scroller@1.1.2DynamicScroller 硬性假设「自身根元素就是滚动容器」,三处都依赖这一点:

// 1) RecycleScroller.getScroll —— 默认模式读自身元素的滚动量
scrollState = { start: el.scrollTop, end: el.scrollTop + el.clientHeight } // esm.js:544-548

// 2) DynamicScroller.itemsWithSize watcher —— 迟测行高后做「滚动补偿」防跳动
this.$el.scrollTop += offset   // esm.js:990

// 3) scrollToBottom —— 写自身 scrollTop
el.scrollTop = el.scrollHeight + 5000 // esm.js:1040

一旦把滚动外置(不再让内层自身滚动),上面三处都会受影响。两套被还原方案的失败原因:

4.1 手写桥接为什么不顺畅

手写桥接方案覆写 getScroll,并把 $el.scrollTop 桥接到 .content.scrollTop,让补偿「真的生效」。代价是:每次补偿写都会在动量滚动中途猛拽 .content.scrollTop,与 iOS 原生惯性打架 → 不顺畅

4.2 page-mode 为什么焦点错乱——以及一个需要澄清的误解

先纠正一个容易想当然的点:page-mode 失败不是因为 window.innerHeight 取错了视口。

page-mode 的 getScroll(esm.js:528-543)确实用 window.innerHeight 当视口高、用相对 window 的 getBoundingClientRect().top 当位置:

const bounds = el.getBoundingClientRect()
let start = -bounds.top              // 锚定在“屏幕顶 y=0”
let size  = window.innerHeight
if (start < 0) { size += start; start = 0 }              // 顶部夹取
if (start + size > boundsSize) size = boundsSize - start // 末尾夹取

逐案验算(headerTop = 列表顶到屏顶的距离):

  • 未滚过头部bounds.top = headerTop > 0):夹取后窗口 [0, innerHeight - headerTop] = 「从列表顶到屏幕底」的可见段 ✅。
  • 已向下滚bounds.top < 0):窗口 [-bounds.top, -bounds.top + innerHeight],真实可见段是 [-bounds.top + headerTop, -bounds.top + innerHeight] —— pageMode 窗口 >= 真实可见段,只是顶部多算了 headerTop 一条带(被 header 盖住的内容),底部对齐。

结论:window.innerHeight 只会让 pageMode「多渲染 header/footer 量级的一条带」(性能层面,可忽略),不破坏渲染正确性——所有该显示的 item 都在窗口内。 而且监听器挂在 .content(见 5.2),滚动追踪也是工作的。

那 page-mode 真正不能直接用的原因是 尺寸补偿空操作 → 跳动 → 焦点错乱

  • 库 CSS .vue-recycle-scroller.direction-vertical:not(.page-mode){overflow-y:auto} —— .page-mode 下不命中,根元素无 overflow、不可滚;
  • itemsWithSize 的补偿 this.$el.scrollTop += offset(esm.js:990)写到不可滚元素 → 空操作 → 迟测行高时视野跳 → iOS 对聚焦输入框 scrollIntoView焦点错乱

也就是说:page-mode ≈「正确的单容器虚拟化」− 焦点处理 − 足够 buffer。补上这两样,它就是答案。

4.3 为什么不用其它「更小」的方案

  • 纯 CSS overscroll-behavior:none:只压回弹、不产生向外层的动量接力(iOS 嵌套滚动器之间不交接),滚到内层底仍会「停住」;且 iOS 16+ 才支持。不满足「连续滚动」。
  • 去虚拟化、整列表 v-for:列表项极端可达几百行,全量渲染会在长列表场景首屏卡死、内存爆。否决。
  • 升级 / 换库:支持自定义滚动容器的是 Vue 3 版,Vue 2 用不了。

五、解决方案(详细):回到 page-mode + 补焦点

5.1 核心思路

用库原生 page-mode.content 成为唯一滚动容器(单容器虚拟化由库负责);补上 page-mode 当年缺的两件事:手写 touchmove-blur(治焦点)+ 大 buffer(提前测量、压跳动);再用 CSS 去掉内层 max-height/overflow

复盘教训:曾经自研了一整套 getScroll 覆写 + 滚动转发 + offsetParent 链,其实过度设计了——理解到「真正的 disqualifier 是焦点而非视口」后,回到 page-mode + blur 反而更简单、更稳。

5.2 为什么 page-mode 够用(源码依据)

  1. 滚动监听就挂在 .contentgetListenerTarget()ScrollParent(el)scrollparent 包:向上找第一个 overflow:auto|scroll 的祖先)命中 .content,且不归一化为 window。「监听 .content 滚动 + 转发 handleScroll」库自己做了,无需手写。
  2. :buffer 照常生效:buffer 是在 updateVisibleItems 里对 scroll.start/end 加减(esm.js:328-330),与模式无关。
  3. 首屏不会越界:page-mode 的 getScroll 从第一帧就是有界的,不会出现「读到全列表高 → 渲染全部 / itemsLimitError」——所以自研方案里那套「先 bounded 渲染再切 overflow」的折腾可以整段省掉。
  4. 焦点本就只能靠 blur 治:它来自「回收复用换 item + 跳动触发 scrollIntoView」,与用不用 page-mode 无关。

5.3 实现(只改列表组件)

① iOS 判定开关

iOS 判定是纯 UA 推断、同步可得,因此作为一个稳定的 computed 即可:

// src/util/index.js
export function isIOS () {
  const ua = navigator.userAgent
  if (/iphone|ipod|ipad/i.test(ua)) return true
  // iPadOS 13+ 默认 UA 伪装成 Macintosh,用触摸点数兜底
  if (/macintosh/i.test(ua) && navigator.maxTouchPoints > 1) return true
  return false
}
import { isIOS } from '@/util'

computed: {
  // 单一开关:iOS 走单容器(page-mode),其余端保持原嵌套滚动
  enableSingleScroll () {
    return isIOS()
  }
}

② 模板:声明式开启 page-mode + 大 buffer

<DynamicScroller
  v-if="visible"
  ref="virtualList"
  class="virtual-list"
  :items="listData"
  keyField="guid"
  :min-item-size="250"
  :buffer="enableSingleScroll ? 1500 : 200"
  :page-mode="enableSingleScroll"
/>

③ CSS:page-mode 下必须去掉 max-heightoverflow

.virtual-list{
  width: 100%;
  max-height: 2000px;
  overflow-y: auto;
  // 库在 pageMode 时会自动给元素加 .page-mode 类。
  // ⚠️ max-height 必须一并去掉,否则盒子被裁到 2000px,2000px 以下的项会被裁剪 / 与下方内容重叠
  //   (getScroll 的 boundsSize 也会被钳住,导致 2000px 以下不渲染)。
  // 组合类选择器 (0,4,0) 压过基础 overflow-y:auto。
  &.page-mode { max-height: none; overflow: visible; }
}

④ 焦点保护(page-mode 不带,手写)

watch: {
  // 列表展开/收起会 v-if 销毁/重建 DynamicScroller,需同步重新挂/卸焦点保护
  visible (val) {
    if (!this.enableSingleScroll) return
    val ? this.$nextTick(() => this._bindFocusGuard()) : this._unbindFocusGuard()
  }
},
mounted () { if (this.enableSingleScroll) this.$nextTick(() => this._bindFocusGuard()) },
beforeDestroy () { this._unbindFocusGuard() },
methods: {
  scrollToBottom () {
    // page-mode 下库的 scrollToBottom 写 $el.scrollTop 是空操作、且会陷入测量 rAF 循环,
    // 所以不调它;返回列表全高,交给外层既有的 contentScrollToBottom 写 .content.scrollTop 定位
    if (!this.enableSingleScroll) this.$refs.virtualList.scrollToBottom()
    return this.$el.clientHeight
  },
  _bindFocusGuard () {
    const el = this.$refs.virtualList && this.$refs.virtualList.$el
    if (!el || this._focusGuardEl === el) return
    this._unbindFocusGuard()
    this._focusGuardEl = el
    this._touchStartY = 0
    this._onListTouchStart = (e) => { this._touchStartY = e.touches && e.touches[0] ? e.touches[0].pageY : 0 }
    this._onListTouchMove = (e) => {
      const y = e.touches && e.touches[0] ? e.touches[0].pageY : this._touchStartY
      if (Math.abs(y - this._touchStartY) < 6) return // 仅竖向滑动才 blur,避免点击/微抖误伤
      const active = document.activeElement
      if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable) && el.contains(active)) {
        active.blur()
      }
    }
    el.addEventListener('touchstart', this._onListTouchStart, { passive: true })
    el.addEventListener('touchmove', this._onListTouchMove, { passive: true })
  },
  _unbindFocusGuard () {
    if (this._focusGuardEl) {
      if (this._onListTouchStart) this._focusGuardEl.removeEventListener('touchstart', this._onListTouchStart)
      if (this._onListTouchMove) this._focusGuardEl.removeEventListener('touchmove', this._onListTouchMove)
    }
    this._focusGuardEl = this._onListTouchStart = this._onListTouchMove = null
  }
}

5.4 行为对照

  • 非 iOSenableSingleScroll=false)::page-mode="false" → 库走 :not(.page-mode) 给回 overflow-y:auto,叠加基础 max-height:2000px完全是原来的嵌套滚动行为,零改动、零回归。Android 上 Chromium 对嵌套滚动会做滚动接力,不会卡顿。
  • iOS:page-mode 单容器虚拟化(监听 .content + window 视口 + buffer=1500),.page-mode 类触发 CSS 去掉 max-height/overflow → 真单容器;touchmove-blur 兜焦点。
  • 声明式绑定:page-mode 直接绑定 computed,库的 pageMode watcher 会自动加监听 + 重渲染,无需手动干预。

六、风险审查与验证

6.1 DOM 回收 + 输入框:唯一能写错数据的点(已挡住)

库的 item 渲染 key: view.nr.id池槽位 id,非业务 guid,esm.js:763)——复用同一组件实例、只换数据 prop

风险:滑动时被聚焦的输入框所在行被回收复用 → 焦点停在物理 DOM 上、绑定却换成了别的数据 → 输入串到错误的行

  • 这是虚拟滚动 + 输入框的固有问题,改造前就存在
  • touchmove 起手 blur 正是它的解药:拖动 6px 即 blur,远早于被聚焦行滚出可视区被回收,回收期间不存在活动焦点。

6.2 blur 会不会引发误算 / 误弹 Toast(已核验安全)

数值步进器底层组件的 blurHandler 只把显示值还原、抛一个没人监听的裸 blur,值只在原生 @change确实改了才触发)时提交:

// 步进器组件
blurHandler () { this.curValue = this.oldValue; this.$emit('blur') } // 不 emit change/input

所以 touchmove-blur 要么什么都不做(没输入新值),要么提交一个用户真输入的值(与点击别处一致,正确行为)。纯 v-model 的文本框、无 @blur/@input 提交逻辑,blur 完全无副作用。

6.3 跳动(补偿空操作):纯视觉,不影响数据(已源码验证)

page-mode 下补偿同样是空操作($el 不可滚),快速 fling 或全局重算改了「视口上方一屏内」某行高度时,视野会跳几像素。这只是滚动位置/渲染层面的现象,不碰任何业务数据

// esm.js:1315 —— 测得行高写进库内部缓存(按 guid 索引),从不回写源数据
this.$set(this.vscrollData.sizes, this.id, size)
  • 行高存进 vscrollData.sizes(库内部 map),不会给数据对象加 size 字段,无提交污染;
  • 业务字段只由显式用户操作处理器变更,与滚动/测量/补偿是两条独立链路。

结论:跳动可忽略、不加任何补偿逻辑。兜底(如将来需要)是「滚动停止后一次性补偿 .content.scrollTop」,绝不在 fling 中逐帧写。

6.4 min-item-size 怎么设

不是每行精确高度,而是「未测量行的临时估算值」:行一旦渲染就会被 ResizeObserver 用真实高度替换(按 guid 缓存)。所以逐机型差异(如 248 vs 258)是正常且无需匹配的。给一个 ≤ 最矮项高度的值即可,精度几乎不影响顺滑度——真正的顺滑杠杆是 buffer

6.5 性能

虚拟化只保留「可视 + buffer」约 8–11 个实例在 DOM(与总行数无关),几百行也只是占位 div 很高,iOS 无压力。page-mode 用 window.innerHeight 多渲染一条 header/footer 带(≈1 个 item,buffer 面前是噪声)。

6.6 验证

  • ✅ ESLint 通过;webpack 编译成功;无残留旧标识符。
  • ⏳ 真机回归清单:连续滚动无内层橡皮筋;快速 fling 无明显跳动;编辑数量/文本后滑动焦点不串行;操作完滚到底部正确;非 iOS 端行为不变、无 itemsLimitError;键盘弹起场景正常。

七、为什么是「整个 iOS」,而不是某个特定容器

这个问题本质是 iOS WebKit 滚动引擎级别的——iOS 上的 Safari、各类内嵌 WebView,乃至 iPad,只要列表超过 2000px,理论上都会复现。根因三要素(嵌套滚动结构 / iOS 默认惯性+橡皮筋 / 嵌套滚动器不交接动量)没有一个是某个特定浏览器或容器特有的,而 iOS 上所有浏览器 / WebView 都用同一套 WebKit。因此修复也应覆盖全 iOS,而非只挑某个入口。

Android 为什么可以排除:Chromium 对嵌套滚动会做滚动接力,overscroll 是辉光而非阻塞性弹性回弹,不会卡顿。

iPad 判定的坑:iPadOS 13+ 默认 UA 会伪装成 Macintosh,单看 UA 会漏判,因此在 isIOS() 里叠加 navigator.maxTouchPoints > 1 兜底(见 5.3 ①)。


八、经验总结(Takeaways)

  1. 嵌套滚动在 iOS 是设计层面的坑:WebKit 不在嵌套滚动器之间交接动量,纯 CSS 救不了「连续滚动」。能单容器就别嵌套。
  2. 优先用库的能力,别急着自研page-mode 是库自带的「外置滚动」方案。曾经自研了 getScroll 覆写 + 滚动转发,属于过度设计;理解清楚「真正的 disqualifier 是焦点而非视口取数」后,回到 page-mode + 手写 blur 反而更简单更稳。
  3. 结论要落到源码、别想当然page-modewindow.innerHeight 一开始被判成「取值错误/不适用」,实测推演后发现它只是「多渲染一条带」,真正的问题是补偿空操作 → 焦点。源码级验证能避免把次要因素当主因。
  4. 区分「视觉问题」和「数据问题」:跳动是渲染层面的、可忽略;真正要守的是「焦点串行写错数据」,靠滑动起手 blur 在源头杜绝。
  5. 判定开关要匹配根因:根因是 iOS WebKit 级别,判定就该是「是否 iOS」这一稳定、同步的 UA 推断,而不是更窄或更动态的条件——既不漏修,也不引入异步就绪的复杂度。

附:关键定位速查

内容位置
外层滚动容器 .content外层页面组件(flex:1; overflow:auto; position:relative
操作完滚到底外层页面组件 contentScrollToBottom(写 .content.scrollTop
开启 page-mode / iOS 开关列表组件 :page-mode="enableSingleScroll" + enableSingleScroll computed
焦点保护列表组件 _bindFocusGuard / _unbindFocusGuard(touchmove-blur)
page-mode 必需 CSS列表组件 .virtual-list.page-mode { max-height:none; overflow:visible }
环境判定src/util/index.js isIOS()(UA + maxTouchPoints 兜底 iPad)
库 page-mode getScroll / 补偿 / 回收 key / 尺寸缓存vue-virtual-scroller.esm.js 528 / 990 / 763 / 1315