catch:touchmove 让 iOS 真机 scroll-view 彻底罢工——WebKit Touch Session 所有权机制
做微信小程序弹窗时,最经典的防穿透写法是给遮罩层加 catch:touchmove="noop"——阻止背景页跟着手指滚。这个方案在模拟器里测得很好,Android 真机也没问题。上线前拿 iPhone 一扫,弹窗内部的列表彻底不能滑动,手指拖多少次都钉死在原地。
乍一看是 scroll-view 高度没算对,DevTools 检查发现高度、flex 链全部正常。加 -webkit-overflow-scrolling: touch,无效。把 catch:touchmove 从 mask 移到 popup 容器,还是不行。这就不是普通的 CSS 问题了。
现象
- 模拟器可滚、iPhone 真机完全不响应:WeChat DevTools 鼠标 wheel 正常,手指 touch 没有任何 scroll 效果
- Android 真机正常、iOS 真机锁死:同一套代码,Android 流畅,iOS 从 touchstart 就没反应
- tap 能触发、滑动无效:点击列表项的 tap 事件正常响应,说明 touch 到达了元素,但 scroll 手势从未启动
这不是"滚到边界弹回",也不是"滚了一点点就停"——是 scroll gesture 从未启动过。
根因
iOS WebKit 的 touch 事件处理有一套 touch session 所有权机制:在 touchstart 阶段,WebKit 会沿 DOM 链做 hit-test,判断谁有权调用 preventDefault()。catch: 绑定器在微信小程序中等价于 stopPropagation + preventDefault。
只要 touch 路径上任何一个祖先节点(哪怕是最外层的 mask)挂了 catch:touchmove,WebKit 就把整条 touch session 的 preventDefault 决定权判给那个节点。当 touchmove 实际触发时,这个节点调 preventDefault(),整条 touch session 上所有 native gesture recognizer 都从 touchstart 阶段就被取消——包括 scroll-view 的滚动识别器。
这是 scroll gesture pre-cancellation,而非 cancellation。scroll-view 的 native scroll 能力被预先剥夺,根本没机会启动。
iOS WebKit 不区分 catch 挂在 DOM 树的哪一层,也不区分是自身还是祖先。只要 scroll-view 从自身到根的 DOM 路径上任何一个节点存在 catch:touchmove,iOS 真机的 native scroll 就会被预先取消。
挂在 mask 上坏,挂在 popup 容器上坏,甚至挂在 scroll-view 自己身上也同样坏。
为什么模拟器测不出:WeChat DevTools 基于 Chromium,不复现 iOS WebKit 的 touch session 所有权机制。Chromium 的 catch:touchmove 只做事件链截断,不触发 native scroll 的预取消。涉及 catch:touchmove 与 scroll-view 组合的改动,模拟器验收一律不算数,必须真机 iOS。
为什么 -webkit-overflow-scrolling: touch 救不了:iOS 13+ 该属性已是 Non-standard Deprecated no-op,仅影响惯性动量,在父元素 catch:touchmove 的情境下完全无介入点。加这个属性是迷信修复,没有任何效果。
正解
不要用 catch:touchmove 锁背景滚动,改用 page-meta 在 CSS 层锁定 page。
<!-- ✅ page-meta 锁 page,完全不碰事件链路 -->
<page-meta page-style="{{ visible ? 'overflow: hidden;' : '' }}"></page-meta>
<van-popup
show="{{visible}}"
round
close-on-click-overlay
bind:close="close"
custom-style="width: 86%; max-height: 80vh;"
>
<view class="container">
<view class="header">{{title}}</view>
<!-- scroll-view 的整条 DOM 路径上无任何 catch:touchmove -->
<scroll-view scroll-y class="scroll-body">
<view wx:for="{{items}}" wx:key="id">{{item.text}}</view>
</scroll-view>
</view>
</van-popup>.container {
display: flex;
flex-direction: column;
max-height: 80vh;
padding: 32rpx;
box-sizing: border-box;
}
.scroll-body {
flex: 1;
min-height: 0; /* 消除 flex min-height:auto 撑开 */
overflow: hidden;
}page-meta 的 page-style 动态注入 overflow: hidden 是纯 CSS 级别的锁定,完全不介入事件分发链路。scroll-view 的 native scroll 不受影响;背景 page 因无 overflow 无法跟滚。弹窗关闭时清空 page-style,背景立即恢复可滚。
vant-weapp 的 <van-popup> 内部把 <van-overlay> 与 popup 内容做成 sibling 节点(而非 parent-child 关系)。overlay 上的 catch:touchmove 只拦截落在 overlay 区域的 touch,popup 内容的 touch 路径根本不经过 overlay 节点,因此不触发 iOS WebKit 的 touch session 接管。
排查流程
真机出现 scroll-view 不滚时,先不要去量高度,直接:
# 在 wxml 文件里搜
grep -n "catch:touch" your-page.wxml只要搜到任何 catch:touchmove / catch:touchstart / catch:touchend,在 scroll-view 的 DOM 祖先链上出现过——就是这个坑。高度链检查排第二顺序。
一句话外卖
在 iOS WebKit 中,scroll-view 的 native scroll 需要一条从自身到根节点完全干净的 DOM 事件链;任何祖先的
catch:touchmove都会在 touchstart 阶段预先取消整条 touch session 的 native gesture——别用事件拦截锁背景,改用page-meta overflow:hidden。