React在移动端Web项目里touch事件报错passive

这标题真是又丑又长

问题的表述

  1. 之前接手的一个React移动端Web项目,调试时发现在Chrome和Safari里经常会报出下面这种错误提示
    [Intervention] Unable to preventDefault inside passive event listener due to target being treated as passive. See https://www.chromestatus.com/features/5093566007214080
  2. 排查后发现是某些touch事件的处理函数里有e.preventDefault(),这些事件函数一旦触发就引出上面的报错

问题的解析

  1. 既然报错给出了提示,就按图索骥的去看问文档了。里面最重要的就是这一句:With this change touchstart and touchmove listeners added to the document will default to passive:true (so that calls to preventDefault will be ignored)
  2. 说的大白话一点,就是Chrome开发组决定教前端开发们做人。一般来说,addEventListener添加事件监听之后,参数options中的passive字段的值本来默认是“false”,但是如果是对document添加事件监听,Chrome就直接把touchstarttouchmove行为的事件监听的options中的passive的默认为true了。如果原本代码里绑定的事件函数里有e.preventDefault(),它并不会生效并且因为和显示传递的passive: true相悖,于是会抛出错误。
  3. 可是这仅仅限于document这个老大哥上啊,出问题的项目代码,并不是在它上面绑定的事件监听,为啥又会出问题呢?答案是React的合成事件机制

一切还要从EventTarget.addEventListener()讲起

  1. MDN的文档有很详细的描述

    1
    2
    target.addEventListener(type, listener[, useCapture]);
    target.addEventListener(type, listener[, options]);
  2. 上面的type和listener参数就不用多说了,重点是第二条语法的参数options

  3. options是一个对象,里面可以写入以下字段
    1. capture:bool,表示listener是否在事件捕获阶段传播到这个event.target的时候触发;
    2. once:bool,表示listener是否在添加后最多只调用一次,如果是,则在调用后就会自动被移除;
    3. passive:bool,主角来了,用于显示表示listener里是否永远不会有preventDefault()这样的操作,这能让解释器不用等到事件触发而执行listener的之前就知道。如果有矛盾,会抛出错误。
  4. useCapture:bool,它表示注册了这个listener的元素,是否(应该)先于它下方的任何事件目标接收到该事件。一个表现就是如果true,那么事件冒泡阶段不会触发。它的默认值是false——它其实就是对老浏览器的兼容,和options对象里的capture是同一个功能。
  5. 对于不支持options对象参数的浏览器,需要一个polyfill:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // Test via a getter in the options object to see
    // if the passive property is accessed
    var supportsPassive = false;
    try {
    var opts = Object.defineProperty({}, 'passive', {
    get: function() {
    supportsPassive = true;
    }
    });
    window.addEventListener("test", null, opts);
    } catch (e) {}

    // Use our detect's results.
    // passive applied if supported, capture will be false either way.
    elem.addEventListener(
    'touchstart',
    fn,
    supportsPassive ? { passive: true } : false
    );

Chrome的修改带来的影响

  1. justjavac的这篇文章说的挺多,Chrome察觉到移动端页面滚动卡顿是一个痛点。
  2. 事件监听里是否有e.preventDefault(),浏览器需要等到事件触发,去执行事件处理函数之后才知道——这必然有一个延迟,虽然也许就100ms,之后它才决定是否要阻止默认行为,移动端比较频繁的问题就是是否产生滚动。
  3. 当用户触摸页面某处的时候,他的本意可能是去操作页面这个位置的某个功能,也可能是希望滚动整个页面。如果用户触摸在一个看起来很空旷的区域,大概率就是想产生一个页面滚动。
  4. 但是浏览器只是个孩子啊,他只会老实地读代码然后判断,因此即使用户很单纯的想要触发页面滚动,依然会切实感觉到手指滑动的时候,页面有一点延迟才开始滚动,延迟的程度据页面本身代码结构和用户机型的而不同。
  5. 于是,针对这个痛点,一直努力让移动端Web能超越原生移动开发的Chrome就先做了一个统计,发现极高比例的页面在并没有对document的触摸事件作e.preventDefualt(),都是老实巴交的浏览器一定要到执行事件监听器后才根据代码里有没有e.preventDefault来决定是否触发滚动,导致页面的滚动有几百毫秒的延迟。
  6. 很自然的想到,应该借助{passive: true}让浏览器对document有一些特殊对待。
  7. 所以他们先是增加了一个警告机制:只要发现document上触摸事件被解析超过100ms的,就会抛出一个警告来提醒你去显示地传递{passive: true}来告诉浏览器,别墨迹了,直接给我滚
  8. 后来更进一步,又发布了新的改动,就是问题表述里的那篇文档了。所以在开发中,因为Chrome版本的不同,我们或者收到一个警告,或者收到一个错误。

和React的关联

  1. 不了解React的合成事件的,可以移步《React合成事件》
  2. 上面提过在项目里遇到的问题,最后一个元凶是React的合成事件机制。这是怎么回事呢?
  3. 当我们通过React对某个DOM对象做了事件绑定后,因为一些业务场景的原因,可能在其监听的touchstart或者touchmove的事件函数里添加了e.preventDefault()
  4. 而React将我们在jsx里做的事件绑定全部委托到了document上(注意区别冒泡传递event的机制),结合Chrome上面的改动,错误就产生了。

解决的方案

  1. 目前,React和Angular都还没有提供支持对passive属性的配置支持,所以我们不可能(其实也不应该)去修改document在触摸事件监听上的passive。
  2. 对于出现问题的老代码,首先应该分析其中的e.preventDefault()是否有必要性。因为Chrome改动的出发点确实是好的。
  3. 额外说明一下,在某个元素上做touchmove的操作,看到页面滚动起来,很多人的直觉是要阻止事件冒泡到document。但其实让页面滚动并不是冒泡导致的,而是touchmove本身的默认行为。所以在这里你不会看到关于e.stopPropagation()的讨论
  4. 以下给出一些尝试的做法和结论

利用CSS样式:touch-action: none

  1. 这是一个和手势触摸密切相关的样式,源自windows phone,目前被其他浏览器借鉴,已经是移动端比较畅行的属性,在CSS选择器html上加这个样式就能让报错消失。
  2. 这个样式的值:
    1. auto,完全由浏览器决定,比如meta标签的viewport设置
    2. manipulation,只允许滚动和持续缩放。click事件300ms延迟就是为了区分双击,一旦这样设置,干掉了双击,300ms延迟的问题也就解决了。
    3. 除了上面的2个,其他值Safari都不支持,这就非常坑爹。比较重要的,比如现在你touch-action: none是无效的,设置meta标签里user-scalable=no也是无效的,它们认为没有任何理由阻止用户去放大缩小看内容。
    4. none,表示不进行任何touch相关默认行为,比如想用手指滚动网页就不行,双击放大缩小页面也不行,这些行为都要自定义。
    5. 还有其他值这里不赘述,一般就是限制只能左移之类,又比如pan-right表示手指可以向右移动,移动开始后还是可以向左恢复的,这些pan开头的关键字还可以组合使用(空格分开)。
  3. 这个方案的问题:
    1. Safari不支持
    2. touch行为受到了影响
    3. 但是最主要还是safari的问题,因为第二点弊端,完全可以让JavaScript脚本来写一个逻辑,让样式只在我们需要的业务场景下生效。

用DOM2级事件机制

如果一定要用到e.preventDefault(),比如这样的业务场景:有一个利用transform左右滚动的容器,你不希望在左右滑动它的时候,手指非完全水平的动作还能触发外层容器或者甚至整个页面的上下滚动。那么在处理touchmovetouchstart的事件监听,就要利用DOM2级事件绑定(addEventListener)机制来解决问题。这分为两种情形:

  • 目标位置使用了第三方库或者其他不太好改动的复杂组件

    1. 那就考虑对当前页面的document做手脚,在不希望页面发生滚动的时候,触发以下代码

      1
      2
      3
      4
      5
      document.addEventListener('touchmove', function(e) {
      e.preventDefault();
      }, {
      passive: false
      })
    2. 这是丑陋的方案。在业务场景之外,你还需要补充额外的逻辑,比如在不需要考虑业务场景的时候销毁它,以还document老大哥一个清白。

  • 目标位置是我们容易修改的代码

    1. 那就放弃在jsx里去写事件绑定,转而在页面/组件的生命周期componentDidMount里去使用DOM2级事件机制

      1
      2
      3
      4
      5
      document.querySelector('.target').addEventListener('touchmove', (e) => {
      // 由此阻止了默认事件的触发(一般来讲默认事件就是如果能被滑动就被滑动了)
      e.preventDefault()
      // do sth.
      })
    2. 这个方案直接禁止了target上的touchmove的默认行为,它会带来一系列影响。比如在用户需要上下滚动的时候,这个target就相当于一个死区,这其实不是一个很好的体验。

    3. 建议配合这个方案,对target的touch做一些额外的判定,比如设置一个开关变量,同时在touchstart、touchmove、touchend里根据业务场景做定制,达到仅在特殊的业务场景下(比如上面说的容器左右touchmove的时候才阻止页面滚动)去e.preventDefault(),就能减少该容器成为页面滚动死区的情况,在实现需求的时候保障用户的体验。