为什么移动端touch事件在子元素滑动时触发了父元素的touchmove?

付楠 ☘︎ 阅读 20

我在开发移动端侧滑菜单时遇到问题:父容器有touchmove监听处理全局滚动,但子元素的可滑动区域(比如卡片)滑动时,父元素的事件也跟着触发了。我试过在子元素的touchmove里调用stopPropagation(),但父元素的事件还是会被触发,导致滑动卡顿。

代码大概是这样的:


document.querySelector('.parent').addEventListener('touchmove', (e) => {
  // 处理页面滚动逻辑
  e.preventDefault();
});

document.querySelector('.child').addEventListener('touchmove', (e) => {
  // 处理卡片滑动逻辑
  e.stopPropagation(); // 这里没生效?
});

后来发现如果把父元素的preventDefault()去掉,子元素滑动正常了,但全局滚动又失效了。有没有办法让两者同时生效?是不是事件委托的问题?

我来解答 赞 4 收藏
二维码
手机扫码查看
2 条解答
Mc.春红
Mc.春红 Lv1
这个问题很典型,根本原因不是事件冒泡没阻断,而是 touchmove 的 preventDefault 调用时机问题。

你父元素加了 e.preventDefault(),这会导致整个页面的默认触摸行为被立即阻止——包括子元素的滚动。关键是:preventDefault 一旦在 touchstart 或 touchmove 早期触发,浏览器就会锁定所有滚动能力,后续不管你怎么 stopPropagation 都救不回来。

stopPropagation 确实能阻止事件冒泡,但前提是事件能先在子元素被捕获。而真正的坑在于:移动端触摸行为是同步判定的,浏览器在 touchstart 阶段就要决定“这个 touch 是用来滚动的还是交给 JS 处理的”。如果你父级无条件 preventDefault,那浏览器直接认定“用户想自定义操作”,于是禁用所有原生滚动,连带子元素的滑动也废了。

正确做法是:只在真正需要全局滚动时才调用 preventDefault。你可以通过判断滑动方向和当前元素是否可滚动来决策。

参考这样改:

let startY;
const parent = document.querySelector('.parent');
const child = document.querySelector('.child');

// 判断元素是否纵向可滚动
function isScrollable(el) {
return el.scrollHeight > el.clientHeight;
}

parent.addEventListener('touchstart', (e) => {
startY = e.touches[0].clientY;
});

parent.addEventListener('touchmove', (e) => {
const moveY = e.touches[0].clientY;
const diff = startY - moveY;

// 只有在子元素不能继续滚动时,才交给父容器处理滚动
if (child.contains(e.target)) {
if ((diff < 0 && child.scrollTop < child.scrollHeight - child.clientHeight) ||
(diff > 0 && child.scrollTop > 0)) {
// 子元素还能滚,就让它自己滚,不阻止默认行为
return; // 注意:这里不要 preventDefault
}
}

// 否则由父容器接管滚动
e.preventDefault();
}, { passive: false }); // 必须显式关闭 passive,否则 preventDefault 无效


关键点:
- 给父元素 touchmove 加 { passive: false },否则 preventDefault 被忽略(现代浏览器默认 passive 为 true)
- 不要无脑 preventDefault,要根据目标元素的可滚动状态动态决定
- 子元素正常滑动时,让浏览器保留默认滚动行为

这样既能保证子元素滑动流畅,又能在需要时触发父级逻辑。说白了就是别一上来就劫持所有触摸,得跟浏览器“协商”着来。
点赞 3
2026-02-10 21:14
爱学习的志玉
这个问题本质是移动端 touch 事件冒泡和默认行为的冲突。你调用 stopPropagation() 没用,是因为 preventDefault() 和事件冒泡是两个不同阶段的操作,而且 touchmove 是持续触发的,父子元素的事件处理很容易互相干扰。

解决办法是:**在子元素滑动时,不让父元素的 touchmove 触发滚动逻辑**。你可以用一个标志位控制。

改法如下:

let isDraggingChild = false;

document.querySelector('.child').addEventListener('touchmove', (e) => {
isDraggingChild = true;
// 处理卡片滑动逻辑
});

document.querySelector('.parent').addEventListener('touchmove', (e) => {
if (isDraggingChild) {
return;
}
// 处理页面滚动逻辑
e.preventDefault();
});

// 可以加个 touchend 把 isDraggingChild 置为 false,更严谨
document.querySelector('.child').addEventListener('touchend', () => {
isDraggingChild = false;
});


这样处理之后,子元素滑动时会阻止父级滚动逻辑触发,同时父级的滚动在其它区域依然生效。比单纯靠 stopPropagation 控制更可靠。

另外,如果父级滚动是页面滚动(body滚动),你也可以考虑把 e.preventDefault() 放在更合适的时机,比如判断滑动方向或滑动目标区域。不然很容易卡顿或冲突。
点赞 8
2026-02-04 00:00