前端开发中React与Vue生命周期函数的对比与实战踩坑总结
又踩坑了,页面卸载前没清理掉 touch 事件监听器
今天上线前 QA 突然提了个 bug:用户从 A 页面快速跳转到 B 页面,再点返回,A 页面的 touchmove 会“幽灵式”触发——明明 A 页面组件都 unmounted 了,手指一滑,控制台还啪啪打日志。
我第一反应是:React 的 useEffect cleanup 没写对?还是 Vue 的 beforeUnmount 漏了?结果查了一圈,发现压根不是框架的问题。是我在 A 页面里手动绑的原生 touchstart/touchmove,用的是 addEventListener,但没配对调用 removeEventListener。更绝的是,我用的是匿名函数……
这里我踩了个坑:以为写了 useEffect(() => { ... return () => { removeEventListener(...) } }, []) 就万事大吉,结果匿名函数没法被 removeEventListener 正确识别。折腾了半天发现,Chrome DevTools 的 Event Listeners 面板里,A 页面 DOM 节点上居然还挂着 3 个同名的 touchmove 监听器——全是历史残留。每次进 A 页面就加一层,退的时候根本删不干净。
后来试了下发现,不止是 React,Vue、纯 JS SPA、甚至用 router.push + replaceState 手动切页的项目,只要用了原生 touch 事件且没做清理,迟早都会撞上这个“幽灵滚动”。尤其在 iOS Safari 上特别明显,因为它的 touch 事件队列和页面生命周期耦合得比较紧,监听器残留容易导致事件冒泡错乱或者 scroll 抖动。
排查过程其实挺无聊的:先在 touchmove 回调里加了个 console.log('from:', document.activeElement?.id),结果发现 log 里打印的 DOM 已经被 removeChild 过了;然后去翻了下 window.getEventListeners(document.body)(DevTools 里直接敲),确认监听器确实还在;最后把所有 addEventListener 全改成具名函数 + 变量缓存,再挨个配对 removeEventListener,才稳住。
核心代码就这几行
不是什么高深方案,就是老老实实“绑定时存引用,卸载时删干净”。重点是:别用箭头函数,别用内联函数,别依赖框架帮你猜你要删谁。
React 里的写法(以函数组件为例):
function PageA() {
const touchStartHandler = useCallback((e) => {
// 处理逻辑
}, []);
const touchMoveHandler = useCallback((e) => {
e.preventDefault();
// 滚动拦截或手势计算
}, []);
useEffect(() => {
const target = document.body;
target.addEventListener('touchstart', touchStartHandler, { passive: false });
target.addEventListener('touchmove', touchMoveHandler, { passive: false });
return () => {
target.removeEventListener('touchstart', touchStartHandler);
target.removeEventListener('touchmove', touchMoveHandler);
};
}, [touchStartHandler, touchMoveHandler]);
return <div>Page A content</div>;
}
Vue 3 Composition API 也类似,关键是要把 handler 提成响应式变量之外的稳定引用:
export default {
setup() {
const touchMoveHandler = (e) => {
e.preventDefault();
// ...
};
onMounted(() => {
document.body.addEventListener('touchmove', touchMoveHandler, { passive: false });
});
onBeforeUnmount(() => {
document.body.removeEventListener('touchmove', touchMoveHandler);
});
return () => <div>Page A</div>;
}
};
如果是纯 JS SPA(比如用 pjax 或手写路由),那就更简单粗暴一点,在路由切换前统一清理:
// 全局管理 touch 监听器
const TOUCH_HANDLERS = {
move: null,
start: null
};
function bindTouchHandlers() {
const handler = (e) => {
e.preventDefault();
};
TOUCH_HANDLERS.move = handler;
document.body.addEventListener('touchmove', handler, { passive: false });
}
function cleanupTouchHandlers() {
if (TOUCH_HANDLERS.move) {
document.body.removeEventListener('touchmove', TOUCH_HANDLERS.move);
TOUCH_HANDLERS.move = null;
}
}
// 在 router.beforeEach 或 popstate 里调用 cleanupTouchHandlers()
这里注意我踩过好几次坑:passive 参数必须一致。如果你绑定时写了 { passive: false },移除时也得传一样的对象——虽然浏览器通常能匹配,但某些 Android WebView 版本(比如 UC 内核)会严格比对对象引用,传个新对象就删不掉。所以要么全用具名常量,要么干脆不传对象,直接写 false(addEventListener(type, fn, false))。
踩坑提醒:这三点一定注意
- 匿名函数 = 无法清除:哪怕你写两遍一模一样的箭头函数,它们也是两个不同引用,
removeEventListener找不到目标。 - passive 必须严格一致:iOS Safari 和部分安卓 WebView 对第三个参数极其敏感,false 和 { passive: false } 不等价,建议统一用布尔值。
- 别只清 body,要看实际绑定目标:我之前有次绑在某个滚动容器 div 上,但卸载时清的是 document,结果容器销毁了监听器还在内存里挂着,造成闭包泄漏。
顺带一提,改完之后还有一两个小问题:比如快速连点返回按钮时,偶尔还会漏掉一次 cleanup(因为路由跳转太快,effect 的 return 函数还没执行完)。不过影响极小,只是多打一行无害的 console,不影响功能。我暂时没上 MutationObserver 监听节点销毁这种重方案,毕竟上线时间紧,先保主流程稳定。
另外,如果你用的是第三方手势库(比如 hammer.js 或 @use-gesture),它们内部一般会自己处理生命周期,但要注意看文档是否支持自动 cleanup——有些老版本需要你手动调 destroy(),不然照样泄漏。
还有个细节:iOS Safari 有个特性,如果页面有 touchmove 阻止默认行为,它会禁用整个页面的弹性滚动(overscroll bounce),这是预期行为,不是 bug。所以如果你清掉了监听器但发现页面突然又能上下拉出白边了,别慌,那是它恢复正常了。
以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如结合 IntersectionObserver 做懒加载监听器、或者封装一个 useTouchLifecycle 的自定义 Hook,后续有空我会继续分享这类博客。如果你有更好的方案(比如用 AbortController 来统一管理事件监听器),欢迎评论区交流。

暂无评论