前言
最近做一个移动端左右滑动切换内容的效果,由于手机上左右滑动可能会导致回退或前进页面。所以想给切换内容的容器的touchmove事件加上event.preventDefault,如下
function App() {
function handleTouchMove(e) {
e.preventDefault();
}
return <div className="box" onTouchMove={handleTouchMove}></div>;
}
可是不管怎么样都没能阻止页面滑动,我真的抓破头脑,连喝完两大瓶白菊花泡水,还是没想明白。
后来翻阅了react的issue Touch/Wheel Event Passiveness in React 17,才知道touchstart
、touchmove
、wheel
这个三个绑定事件的preventDefault都不能生效,只能够自己拿到原生dom自行订阅事件,如下
function App() {
const divRef = useRef();
useEffect(() => {
function handleTouchMove(e) {
e.preventDefault();
}
const divDom = divRef.current;
divDom?.addEventListener("touchmove", handleTouchMove);
return () => {
divDom?.removeEventListener("touchmove", handleTouchMove);
};
}, []);
return <div className="box" ref={divRef}></div>;
}
终于解决了,感动要哭。至于为什么touchstart
、touchmove
、wheel
为什么调用preventDefault不生效?我在翻阅资料了解来龙去脉,发现还是挺有趣,都是陈年往事了。
首先我们要知道 React 事件是使用了事件委托机制,react17以前,所有元素的事件都会委托到document进行监听,react17及之后是在root(挂载元素)进行事件委托。
整个故事从这个 issue 说起。
chrome55版本的breaking changes
在 chrome55 以前,那时候react还是16版本的时代,开发者们吃着火锅唱着歌,突然就被chrome55及之后版本来了个背刺。
如果 touchstart 或 touchmove 监听器的目标是 window
、document
或 body
,则将 passive
默认为 true
。具体文章:在默认情况下实现快速轻触滚动 | Chrome for Developers
passive 是啥?
使用如下,就是如果passive为true的话,是不能使用preventDefault的。
window.addEventListener(
"touchmove",
(event) => {
/* do something */
// 不能使用 event.preventDefault();
},
{ passive: true },
);
chrome以及其他浏览器这么做的原因是为了改善滚屏性能,这无可厚非。但是不讲武德,偷袭了当时2%的网站(其实也那么严重,可能很多网站不需要用到这些事件)。
而 react 恰好是将事件委托到了 document,导致事件的 preventDefault 失效。然后 react 的绑定事件又不支持设置事件的passive属性。当时不少人在 react issue 讨论解决方案。
- react 能不能强制 把 passive 为 false,使得和 chrome55 以前的事件行为一致
- 能否支持配置 passive, 例如
onWheel={[myCallback, { passive: false }]}
- 事件能不能不要委托到 document 上了,这样不就解决问题了吗?
- ...
这段时间,大家的方案就是自行给元素绑定原生事件,或者有小哥直接改了 document 的 addEventListener。
import React from 'react';
import ReactDOM from 'react-dom';
import App from './pages/App';
const EVENTS_TO_MODIFY = ['touchstart', 'touchmove', 'touchend', 'touchcancel', 'wheel'];
const originalAddEventListener = document.addEventListener.bind();
document.addEventListener = (type, listener, options, wantsUntrusted) => {
let modOptions = options;
if (EVENTS_TO_MODIFY.includes(type)) {
if (typeof options === 'boolean') {
modOptions = {
capture: options,
passive: false,
};
} else if (typeof options === 'object') {
modOptions = {
passive: false,
...options,
};
}
}
return originalAddEventListener(type, listener, modOptions, wantsUntrusted);
};
const originalRemoveEventListener = document.removeEventListener.bind();
document.removeEventListener = (type, listener, options) => {
let modOptions = options;
if (EVENTS_TO_MODIFY.includes(type)) {
if (typeof options === 'boolean') {
modOptions = {
capture: options,
passive: false,
};
} else if (typeof options === 'object') {
modOptions = {
passive: false,
...options,
};
}
}
return originalRemoveEventListener(type, listener, modOptions);
};
ReactDOM.render(<App />, document.getElementById('root'));
时隔一年多之后,Dan终于出来发话了。
他的观点并不打算更改 passive 的默认设置,和 chrome 的改善滚动性能的初衷保持一致。同时react不打算提供事件属性自定义配置,因为还不如自己绑定原生事件更有灵活性。有这个问题的,自行获取dom绑定原生事件也可以解决。
但是看起来大家并不买账。
react17及之后
在 react17 发布之后,事件委托真就不绑定到 document 上了,改为 root。
那root的touchstart
、touchmove
、wheel
三个绑定事件(mousewheel
已弃用)的默认 passive 就为 false了,preventDefault生效了吗?然而并没有,Dan 主动把passsive重新设置为true,源码传送门
Dan总保持了他的观点,坚持和 chrome 改善滚动性能的初衷一致。他认为虽然事件委托改到了root,但是root层级和 document 差不多,基本上是页面的最外层,如果 passive 为 false 的话,内部元素频繁触发滚动事件会影响性能。
回顾
说了那么多,最终还是用最初的方案,自己绑定原生事件。我觉得 react作者这样做无可厚非,毕竟性能优先。但是绑定事件支持自行设置onWheel={[myCallback, { passive: false }]}
这个特性也挺好的,可就迟迟不出。