技术栈:Vue 2 + 移动端 H5 关键依赖:
vue-virtual-scroller@1.1.2(Vue 2 专版) 最终方案:库原生page-mode+ 手写焦点保护 + 大 buffer 生效范围:iOS(iPhone / iPad);非 iOS 端保持原行为、零改动
一、问题现象
页面里用 vue-virtual-scroller 的 DynamicScroller 做长列表的虚拟滚动。当列表项较多(列表高度 > 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 + 大 buffer | page-mode + buffer,去掉内层 overflow | 还原 —— 滚动中输入框焦点错乱 |
| 手写滚动桥接 | 覆写 getScroll + 把 $el.scrollTop 桥接到 .content.scrollTop + 回收时 blur | 还原 —— iOS 滚动不顺畅、焦点也没根治 |
这两条反馈是后续定方向的关键:最终方案必须同时满足「顺滑」与「焦点安全」。 而且——剧透一下——page-mode 当时被还原唯一缺的就是焦点处理,这正是最终方案的起点。
四、根因分析(深入库源码)
vue-virtual-scroller@1.1.2 的 DynamicScroller 硬性假设「自身根元素就是滚动容器」,三处都依赖这一点:
// 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 够用(源码依据)
- 滚动监听就挂在
.content:getListenerTarget()→ScrollParent(el)(scrollparent包:向上找第一个overflow:auto|scroll的祖先)命中.content,且不归一化为 window。「监听 .content 滚动 + 转发 handleScroll」库自己做了,无需手写。 :buffer照常生效:buffer 是在updateVisibleItems里对scroll.start/end加减(esm.js:328-330),与模式无关。- 首屏不会越界:page-mode 的 getScroll 从第一帧就是有界的,不会出现「读到全列表高 → 渲染全部 /
itemsLimitError」——所以自研方案里那套「先 bounded 渲染再切 overflow」的折腾可以整段省掉。 - 焦点本就只能靠 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-height 和 overflow
.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 行为对照
- 非 iOS(
enableSingleScroll=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,库的pageModewatcher 会自动加监听 + 重渲染,无需手动干预。
六、风险审查与验证
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)
- 嵌套滚动在 iOS 是设计层面的坑:WebKit 不在嵌套滚动器之间交接动量,纯 CSS 救不了「连续滚动」。能单容器就别嵌套。
- 优先用库的能力,别急着自研:
page-mode是库自带的「外置滚动」方案。曾经自研了getScroll覆写 + 滚动转发,属于过度设计;理解清楚「真正的 disqualifier 是焦点而非视口取数」后,回到page-mode+ 手写 blur 反而更简单更稳。 - 结论要落到源码、别想当然:
page-mode的window.innerHeight一开始被判成「取值错误/不适用」,实测推演后发现它只是「多渲染一条带」,真正的问题是补偿空操作 → 焦点。源码级验证能避免把次要因素当主因。 - 区分「视觉问题」和「数据问题」:跳动是渲染层面的、可忽略;真正要守的是「焦点串行写错数据」,靠滑动起手 blur 在源头杜绝。
- 判定开关要匹配根因:根因是 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 |