你知道React绑定touch事件,preventDefault不生效吗?

192 阅读4分钟

前言

最近做一个移动端左右滑动切换内容的效果,由于手机上左右滑动可能会导致回退或前进页面。所以想给切换内容的容器的touchmove事件加上event.preventDefault,如下

function App() {
  function handleTouchMove(e) {
    e.preventDefault();
  }
  return <div className="box" onTouchMove={handleTouchMove}></div>;
}

可是不管怎么样都没能阻止页面滑动,我真的抓破头脑,连喝完两大瓶白菊花泡水,还是没想明白。

6pqJy.gif

后来翻阅了react的issue Touch/Wheel Event Passiveness in React 17,才知道touchstarttouchmovewheel这个三个绑定事件的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>;
}

终于解决了,感动要哭。至于为什么touchstarttouchmovewheel为什么调用preventDefault不生效?我在翻阅资料了解来龙去脉,发现还是挺有趣,都是陈年往事了。

首先我们要知道 React 事件是使用了事件委托机制,react17以前,所有元素的事件都会委托到document进行监听,react17及之后是在root(挂载元素)进行事件委托。

整个故事从这个 issue 说起。

chrome55版本的breaking changes

在 chrome55 以前,那时候react还是16版本的时代,开发者们吃着火锅唱着歌,突然就被chrome55及之后版本来了个背刺。

image.png

如果 touchstart 或 touchmove 监听器的目标是 windowdocument 或 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终于出来发话了。

image.png

image.png

他的观点并不打算更改 passive 的默认设置,和 chrome 的改善滚动性能的初衷保持一致。同时react不打算提供事件属性自定义配置,因为还不如自己绑定原生事件更有灵活性。有这个问题的,自行获取dom绑定原生事件也可以解决。

但是看起来大家并不买账。

react17及之后

在 react17 发布之后,事件委托真就不绑定到 document 上了,改为 root。

那root的touchstarttouchmovewheel三个绑定事件(mousewheel已弃用)的默认 passive 就为 false了,preventDefault生效了吗?然而并没有,Dan 主动把passsive重新设置为true,源码传送门

image.png

Dan总保持了他的观点,坚持和 chrome 改善滚动性能的初衷一致。他认为虽然事件委托改到了root,但是root层级和 document 差不多,基本上是页面的最外层,如果 passive 为 false 的话,内部元素频繁触发滚动事件会影响性能。

image.png

回顾

说了那么多,最终还是用最初的方案,自己绑定原生事件。我觉得 react作者这样做无可厚非,毕竟性能优先。但是绑定事件支持自行设置onWheel={[myCallback, { passive: false }]}这个特性也挺好的,可就迟迟不出。

20241281732384502M7Oq.png