如果你在 iOS 的 Safari 或者微信内置浏览器里开过一个 mint-ui 的 picker,多半见过这个鬼畜操作:手指在滚轮上拨到目标年份,松手确认之后,页面已经不知道滚到哪儿去了,一个好好的表单页直接变成空白区。这个现象在 Android 上几乎看不到,所以很多人第一次遇到根本不知道怎么排查,只能挨个尝试加 overflow: hidden。
我前阵子维护一个 Vue 2.0 老项目,组件库就是 mint-ui,问题集中爆发在两个组件上:picker 和 datetime picker。这类穿透滚动(也叫滚动链、滚动穿透)非常顽固,网上搜出来的方案大多只给一句“加 touchmove.preventDefault()”,实际搬到项目里要么没效果,要么把内部滚动也卡死了。这篇记录一下完整排查过程和可落地的几套方案,给同样在坑里的老项目做个参考。
1. 穿透滚动到底是怎么发生的:iOS事件模型与mint-ui的JS滚动机制
1.1 先用三十秒复现这个bug
复现的步骤很简单,但现象非常稳定。打开一个带清单列表的页面,页面长度足够滚动,然后点击某个字段弹出 datetime picker,用手指在“年”那一列上快速上下拨动几次,或者在一个槽位已经滚到底部/顶部后继续用力拖。这时候注意看弹层的背景:整个页面在跟随手指的方向滚动,松手之后还会带一段惯性,遮罩明明盖在上面,却一点都没拦住。
如果页面本身没有滚动条,那 iOS 的橡皮筋回弹效果也会暴露这个 bug——在 picker 上往下拖,页面顶部露出空白,松手后弹回去。不管是哪种表现,用户的体验都是“这页面坏掉了”。
这个问题在 Android Chrome 上基本测不出来,在 iOS 上几乎是必现。原因不在 mint-ui 本身,而在 iOS 对触摸滚动的处理方式上。先搞清楚这个,后面所有的修复方案都顺理成章。
1.2 mint-ui的picker其实根本不靠原生滚动
很多人以为 picker 内部的滚轮是一个 overflow: scroll 容器,其实不是。至少 mint-ui 的 picker 不是。我翻过 mint-ui 的源码,picker 的内部滚轮是自己实现的“手势模拟滚动”。
用户 touchstart 时记下起始坐标,touchmove 时计算位移差,然后直接给 .picker-slot-wrapper 设置 transform: translate3d(0, y, 0),配合 transition 来做回弹和惯性动画。也就是说,picker 内部压根不触发浏览器的原生滚动机制,你看到的“滚动”只是 CSS transform 在移动。
这就带来一个关键结论:在 picker 的触摸事件上调用 preventDefault,不会影响 mint-ui 内部的滚动逻辑。mint-ui 自己是靠 touchmove 事件里的坐标数据计算位移的,preventDefault 只阻止浏览器默认的原生滚动行为,不会阻止事件继续派发给 mint-ui 的监听器。这一点是后面所有修复方案的地基。
1.3 iOS与Android的差异:橡皮筋与overflow:hidden的失效
为什么同样的网页在 Android 上不穿透,到 iOS 上就穿?两个原因叠加。第一,iOS Safari 一直保留着“橡皮筋”回弹效果,即使 html 和 body 都没有滚动条,只要某个手势没有被阻止,WebView 也会尝试把页面整体拉动。Android 上这个行为只在个别机型或浏览器里会有,宽容度高很多。
第二,老版本 iOS 对 body 的 overflow: hidden 处理并不可靠。我们常见的“弹层打开时给 body 加 overflow: hidden 锁住页面”这种操作,在 iOS 上经常是无效的——页面内容仍然可以被滚动,只是滚动条被隐藏了。所以单纯靠 CSS 把页面“藏起来”的思路,在这个场景下天然要打折扣。
还有一个非常容易踩的坑:iOS 11.3 之后,浏览器默认把 document 和 body 上的 touchmove 当作 passive 事件处理。如果你的代码写的是 document.addEventListener('touchmove', handler),哪怕你在 handler 里写了 preventDefault,浏览器也不会理你,因为默认 passive 为 true 时不允许调用 preventDefault。必须显式传第三个参数 { passive: false } 才会生效。很多网上的旧方案失效,就是死在这一步。
2. 动手修之前,先定位mint-ui弹层的DOM关系和事件路径
2.1 mint-ui picker渲染出来的真实DOM层级
用开发者工具选中一个打开的 mint-ui picker,看到的 DOM 大概是这么个结构(以 2.x 版本为例):
<div class="mint-popup-mask" v-show="visible"></div> <div class="mint-popup mint-popup-bottom" v-show="visible"> <div class="picker"> <div class="picker-toolbar"> <!-- 取消、确定按钮 --> </div> <div class="picker-items"> <div class="picker-slot"> <div class="picker-slot-wrapper"> <div class="picker-item">选项1</div> <div class="picker-item">选项2</div> </div> </div> </div> </div> </div>这里有个容易被忽略的结构:遮罩层 .mint-popup-mask 和弹层主体 .mint-popup 是兄弟节点,而不是父子关系。后续做事件拦截时,如果你只锁定了 .mint-popup 内部,那点遮罩层或者从遮罩层上滑动的触摸事件仍然会一路冒泡到 body,穿透问题依旧。
datetime picker 本质上是 picker 的封装,内部多组 .picker-slot 分别对应年、月、日、时、分等列,事件结构和单列 picker 完全一致。
2.2 遮罩层是独立元素,锁定页面必须“双线操作”
触摸事件从 picker 的某个 .picker-slot 开始,会沿着 .picker-slot -> .picker-items -> .picker -> .mt-popup -> body 一路冒泡。你在任意一层注册 touchmove 监听并调用 preventDefault,都能阻止浏览器默认滚动。真正的问题是:当手指放在遮罩层上滑动时,目标元素是 .mint-popup-mask,它和弹层主体是平级关系,只锁弹层内部是管不到它的。
所以一个成熟的锁定方案必须同时覆盖两条事件路径:弹层区域内的滑动、遮罩区域内的滑动。最稳的做法不是分别去给两个节点绑定,而是直接往 document 或者 body 上挂监听。这样不管 touchstart 发生在弹层里还是遮罩上,事件都会从目标一路冒泡/捕获到顶层,统一处置。
2.3 判断滚动容器边界的通用方法(含非原生滚动特例)
如果弹层内部有真正的原生滚动容器,比如嵌套了一个 overflow-y: auto 的列表,那就不能一刀切全部 preventDefault,否则内部列表会拖不动。判断一个元素是否真的能滚动,通常用这个逻辑:
function isScrollable(el) { if (!el || el === document.body || el === document.documentElement) { return false; } const overflowY = window.getComputedStyle(el).overflowY; if (overflowY === 'hidden' || overflowY === 'visible') { return false; } return el.scrollHeight > el.clientHeight; }但 mint-ui 的 picker 不满足 scrollHeight > clientHeight 这个条件,因为 .picker-slot-wrapper 的滚动完全靠 transform,没有撑开 scrollHeight。所以对 mint-ui 要做特殊判断:只要触摸起点落在 popup 区域内,且目标不是原生滚动容器,就可以安全 preventDefault,不用担心锁死内部滚轮。
3. 主方案:v-scroll-lock指令拦截touchmove,阻止iOS页面跟随滚动
3.1 指令代码与绑定方式
针对 Vue 2.0 + mint-ui 的老项目,最直接能落地的方案就是封装一个自定义指令,在弹层打开时全局锁住 touchmove。我项目中用的是下面这个版本。
// scroll-lock.js import Vue from 'vue'; let bodyListener = null; let lockCount = 0; function preventDefault(e) { e.preventDefault(); } Vue.directive('scroll-lock', { bind(el, binding) { el.__scrollLock = { count: 0, lock() { if (this.count === 0 && !bodyListener) { bodyListener = preventDefault; document.body.addEventListener('touchmove', bodyListener, { passive: false }); } this.count++; }, unlock() { if (this.count > 0) { this.count--; } if (this.count === 0 && bodyListener) { document.body.removeEventListener('touchmove', bodyListener, { passive: false }); bodyListener = null; } } }; }, update(el, binding) { // 只有值发生 true/false 变化时才锁定或解锁 if (binding.value && !binding.oldValue) { el.__scrollLock.lock(); } else if (!binding.value && binding.oldValue) { el.__scrollLock.unlock(); } }, unbind(el) { if (el.__scrollLock && el.__scrollLock.count > 0) { el.__scrollLock.unlock(); } } });页面里的用法很简单,把指令绑在弹层外层包裹元素上,v-model 控制弹层开关:
<div v-scroll-lock="datePickerVisible"> <mt-datetime-picker v-model="birthday" type="date" :start-date="new Date('1950-01-01')" :end-date="new Date()" @confirm="birthdayConfirm"> </mt-datetime-picker> </div>注意不要直接把指令写在 标签上。Vue 2.0 的自定义指令挂到自定义组件上时,绑定的是组件根元素,而 mint-ui 的 popup 内部还有条件渲染逻辑,钩子触发时机不够稳定。包一层普通 div 最省心。
3.2 为什么preventDefault不会卡死picker的JS滚动
回到第一段说的机制。mint-ui 的 picker 内部滚动靠的是 touchmove 事件里的坐标数据做 transform,浏览器原生的滚动行为它根本没用。preventDefault 只是告诉浏览器“这个手势你不要执行默认的页面滚动”,事件的继续传播、mint-ui 自己的监听器一个都不会少。所以锁住之后,picker 的滚轮依然跟手、依然有惯性,但背后的页面彻底凝固了。
这里有一个细节:不要在触摸事件里调用 stopPropagation。mint-ui 某些内部逻辑可能用的是事件委托,如果你提前把事件传播掐断,它的滚动计算可能接收不到事件。只调 preventDefault,不碰传播链,是兼容性最好的组合。
还有个必须写的参数:{ passive: false }。不加这个,iOS 11.3+ 上浏览器会无视 preventDefault,锁了个寂寞。这是整个方案里最容易翻车的点。
3.3 双保险锁body:解决mask区域的穿透
这个指令的方案其实只锁了 document.body 上的 touchmove。因为事件最终都会冒泡到 body,所以不管触摸起点在弹层内、在遮罩层上还是落在页面其他位置,只要 body 的 touchmove 被 preventDefault,页面就没机会滚动。
这也是为什么我选择在 body 上锁而不是在 popup 元素上锁。之前试过只锁 .mt-popup,遮罩层那条事件路径完全没覆盖到,测试时发现从遮罩上滑一下,页面照样滚得飞起。把监听器放到 body 上之后,mask 和 popup 两条路径都收口了。
实际使用中还需要配合计数。因为一个页面可能会有多个 picker 或多个弹层先后打开,如果每个弹层都往 body 上绑一遍 touchmove,关闭第一个弹层时就可能把第二个弹层需要的锁提前解掉。用 lockCount 计数,只有 count 归零时才真正移除监听器,多弹层叠加也不会互相拆台。
4. 进阶方案:弹层内放行、弹层外锁定的命中判断
4.1 用closest判断触摸起点是否在弹层内
第3章的方案属于“简单粗暴但可靠”,适合弹层打开时页面本身不需要任何滚动的情况。但有些业务场景很特殊,弹层只占底部一部分,页面其他区域可能还需要滚动,或者弹层内部嵌了一个真正的原生滚动列表。这时候不分青红皂白全锁 body 会带来副作用。
进阶思路是在 document 上挂捕获阶段监听,动态判断触摸事件的目标是否落在当前打开的弹层里,不在就 preventDefault,在的话放行给内部滚动。判断命中可以用 closest:
function isInsidePopup(target) { return target && target.closest && target.closest('.mint-popup, .mint-popup-mask') != null; } document.addEventListener('touchmove', (e) => { const isPopupOpen = document.querySelector('.mint-popup[style*="display"]'); if (isPopupOpen && !isInsidePopup(e.target)) { e.preventDefault(); } }, { passive: false });这里选择捕获阶段是因为捕获阶段先于冒泡执行,能更早拦截默认行为。而且捕获阶段不会影响 minto-ui 内部绑定在弹层元素上的事件处理器,它们照常运行。
4.2 处理真正使用原生滚动容器的边界场景
如果弹层内部真的有一个原生的滚动区域(比如地址列表很长,用了 overflow-y: auto),那就不能在命中弹层后一概放行。正确做法是判断滚动容器是否滚到边界,只有滚到顶部继续下拉、滚到底部继续上拉时,才 preventDefault,避免滚动链传到 body。
function shouldPreventScroll(el, deltaY) { const target = el; if (!target || target === document.body) return true; const scrollableParent = findScrollableParent(target); if (!scrollableParent) return true; const atTop = scrollableParent.scrollTop <= 0 && deltaY > 0; const atBottom = scrollableParent.scrollTop + scrollableParent.clientHeight >= scrollableParent.scrollHeight && deltaY < 0; return atTop || atBottom; }对 mint-ui 来说,因为内部滚轮是 transform 模拟的,findScrollableParent 找到的往往不是真正的可滚动容器,所以可以直接放行。这个边界判断主要是为“弹层里另套原生滚动列表”的场景准备的,属于防御性代码。
4.3 与mint-ui过渡动画的时序配合
mint-ui 的 popup 显示时有过渡动画,动画期间 DOM 已经插入,但 .mint-popup 的 class 可能还在切换中。如果监听器注册得太早,弹层还没完全渲染,closest 命中判断会误伤,手势一进来就被 preventDefault,等动画结束又正常了,用户体验会出现一段“卡一下”。
解决办法是在绑定全局监听前先等一个 nextTick,或者直接在动画结束后再启用。我一般用 requestAnimationFrame 延迟一下,简单有效:
requestAnimationFrame(() => { document.addEventListener('touchmove', globalHandler, { passive: false }); });关闭弹层时同样要注意:v-show 控制下,popup 的 DOM 还在,只是不可见。移除监听器后,要确保下一个弹层打开时能重新注册,别把旧的 handler 残留下来。
5. 备选方案:CSS隔离与position:fixed锁滚动的可行性验证
5.1 overflow:hidden在iOS上不够用
网上最常见的弹层锁滚动方案是“打开时给 body 和 html 加 overflow: hidden”。这个方案在 Android 上效果不错,到了 iOS 12 这种老版本经常失效,页面照样能拖动。原因前面提过:iOS 的橡皮筋回弹作用于 WebView 的滚动层,overflow: hidden 只是隐藏了滚动条,并没有真正禁掉手势滚动。
我自己实测下来的结论是:如果项目最低要兼容 iOS 12,overflow: hidden 不能作为唯一的锁滚动手段,只能作为辅助。必须配合 touchmove 拦截才有确定性。这也是为什么主方案以 JS 拦截为主、CSS 只做衬托。
5.2 使用position:fixed锁滚动时的scrollY补偿
另一个流行方案是弹层打开时把 body 设为 position: fixed,并把 top 补偿到当前滚动位置,防止页面跳回顶部。代码如下:
function lockScroll() { const scrollY = window.scrollY; document.body.style.position = 'fixed'; document.body.style.top = `-${scrollY}px`; document.body.style.left = '0'; document.body.style.right = '0'; return scrollY; } function unlockScroll(scrollY) { document.body.style.position = ''; document.body.style.top = ''; document.body.style.left = ''; document.body.style.right = ''; window.scrollTo(0, scrollY); }这个方案能有效锁住页面滚动,副作用也非常明显:body 变成 fixed 之后,页面宽度可能被压缩,出现横向跳动;如果弹层内有输入框正在聚焦,fixed 之后光标位置可能漂移甚至失焦。而且 iOS 上一些 WebView 对 scrollTo 的时机敏感,恢复滚动位置需要包一层 requestAnimationFrame 才稳定。
所以我只在页面本身几乎不需要滚动、且弹层频率很低的场景里用过 fixed 方案。有多个 picker、多个弹层的高频页面,还是 JS 拦截更可控。
5.3 touch-action与overscroll-behavior的实际兼容性
CSS 领域两个被寄予厚望的属性是 touch-action 和 overscroll-behavior。给 body 加 touch-action: none,理论上能禁止手势滚动;给弹层加 overscroll-behavior: contain,理论上能阻断滚动链。但实际兼容性并不乐观。
touch-action 在 iOS 上的可靠支持要等到 iOS 13 左右,之前版本很多浏览器直接忽略。overscroll-behavior 的 contain 值在 iOS 16 之前支持得很差,iOS 15 及以下的 Safari 基本不买账。一个还要兼容老 iOS 的移动端项目,靠这两个属性做兜底可以,做主力方案不行。
它们的最优用法是作为“锦上添花”:JS 拦截做主防线,CSS 属性避免极端情况下的二次回弹。
6. 在Vue2.0项目里的完整落地示例:从main.js到表单页
6.1 按需注册mint-ui组件与自定义指令
老项目里 mint-ui 一般已经全局注册过了,如果是从零开始接,直接按需引入会更快:
import Vue from 'vue'; import { Picker, DatetimePicker, MessageBox } from 'mint-ui'; import 'mint-ui/lib/style.css'; import './directives/scroll-lock'; Vue.component(Picker.name, Picker); Vue.component(DatetimePicker.name, DatetimePicker);mint-ui 的样式文件是整包引入,按需加载组件只能省 JS 体积,样式省不下来。这个库的构建产物比较老,和现代 webpack 配置偶尔会有兼容问题,建议在 vue.config.js 里加一下 transpileDependencies 配置。
6.2 一个双picker表单页的完整代码
下面这个页面同时用了城市单选 picker 和出生日期 datetime picker,两个弹层共用 v-scroll-lock 指令。
<template> <div class="form-page"> <div class="form-item" @click="cityVisible = true"> <span>所在城市</span> <span>{{ city || '请选择' }}</span> </div> <div class="form-item" @click="dateVisible = true"> <span>出生日期</span> <span>{{ birthday || '请选择' }}</span> </div> <div v-scroll-lock="cityVisible"> <mt-popup v-model="cityVisible" position="bottom" class="city-popup"> <mt-picker :slots="citySlots" @confirm="handleCityConfirm"></mt-picker> </mt-popup> </div> <div v-scroll-lock="dateVisible"> <mt-datetime-picker v-model="birthday" type="date" :start-date="startDate" :end-date="endDate" @confirm="dateVisible = false"> </mt-datetime-picker> </div> </div> </template> <script> export default { name: 'FormPage', data() { return { city: '', birthday: '', cityVisible: false, dateVisible: false, startDate: new Date('1950-01-01'), endDate: new Date(), citySlots: [ { flex: 1, values: ['北京', '上海', '广州', '深圳'], textAlign: 'center' } ] }; }, methods: { handleCityConfirm(value) { this.city = value[0]; this.cityVisible = false; } } }; </script>代码里有两个容易踩的点。第一个是 mt-picker 的 @confirm 回调参数是数组,单列时也要取 value[0],不取的话展示出来是 undefined。第二个是 v-scroll-lock 必须放在弹层的包裹层上,不能放在 mt-popup 标签本身,前面已经解释过原因。
6.3 指令在v-show与keep-alive下的注意事项
mint-ui 的 popup 用的是 v-show 控制显隐,组件挂载后 DOM 一直在,指令的 update 钩子会随着 v-model 值变化反复触发,这是符合预期的。但要注意,如果页面包裹在 keep-alive 里,路由离开时组件不会销毁,指令的 unbind 钩子也不会执行。这时候如果弹层还开着,页面切换后监听器依然挂在 body 上,会导致新页面整个无法滚动。
我处理这个问题的方式是在路由的 beforeRouteLeave 钩子里强制关闭所有弹层:
beforeRouteLeave() { this.cityVisible = false; this.dateVisible = false; }这样指令的 update 钩子会执行到剩余 count 归零,body 的监听器被正常移除,不会污染下一个页面。
7. 回归验证清单与衍生出来的几个实际问题
7.1 手测用例表:覆盖连续开关、边界、惯性等情况
修复上线前,建议按下面这张表过一遍,尤其要把 iOS 真机场景跑到位。
| 用例 | 操作 | 预期结果 |
|---|---|---|
| 基本穿透 | 弹层打开后,在滚轮上快速拨动 | 背景页面完全不动 |
| 遮罩穿透 | 手指按住遮罩层上下滑动 | 背景页面不动,遮罩能正常点击关闭 |
| 边界回弹 | 滚轮滚到顶部后继续下拉 | 背景页面不出现橡皮筋效果 |
| 惯性滚动 | 快速甩一下滚轮 | 滚轮惯性正常,背景页面不被带动 |
| 连续开关 | 连续打开关闭弹层5次 | 每次打开都锁定,关闭后页面能恢复滚动 |
| 多弹层叠加 | 打开 picker 后又打开第二个弹层 | 关闭一个后,另一个仍处于锁定状态 |
| 页面恢复 | 关闭弹层后滚动页面 | 滚动位置不跳变,无残留锁死 |
实测发现最容易出问题的不是“打不开”或“关不掉”,而是弹层关闭后 body 恢复滚动失败的偶发情况。所以连续开关和多弹层叠加这两个用例一定要手测,不能只信代码逻辑。
7.2 多个picker同时存在时的监听器冲突解法
第3章的指令已经用计数解决了多实例冲突,但还有一类冲突来自页面里自己写的其他 touchmove 监听器。比如某些老项目会自己封装滑动组件,或者用 fastclick 处理点击延迟。它们如果也在 body 上注册过 preventDefault,两个监听器同时作用会互相干扰。
我的建议是不要抢监听取舍,统一用一个全局的事件代理工具类来管。弹层打开时注册、关闭时注销,其他业务代码不要再往 body 上挂 touchmove。如果项目里历史代码太多,改不动,那就给指令加上 readOnly 标记,只处理 mint-ui 弹层内部,不要全局锁死。
7.3 如果项目还有余量:组件库替代思路参考
mint-ui 已经很长时间不更新了,新项目原则上不建议再用。如果你们团队正好在做技术债清理,可以考虑换成 vant 2 或者原生的 picker-view。vant 的 popup 自带一套较完整的滚动穿透处理,遇到 iOS 的边界场景比 mint-ui 省心不少。
不过对于存量老项目,为了一个 picker 穿透问题去换组件库,性价比通常不高。指令方案侵入性小、一行模板改动就能生效,还能覆盖到 mint-ui 的 picker、datetime picker、popup 以及其他基于 popup 的二次封装组件。我最终就是靠第3章的指令低成本修完了整个 App 的穿透问题,没有动组件库本身。
如果你也想快速落地,直接复制第3章的指令代码,包到弹层外层,然后把 iOS 真机上那张回归表跑一遍就行。现在这个方案我已经在多个项目里复用到,没再收到过穿透相关的线上反馈。