Event delegation In React

2021.11.30

什么是 Event delegation

具体可以看下 这篇文档,文中举的简单的例子,比如你有一个 list,列表里面有 100 个 item,如何在点击 item 的时候,打印 item 的内容呢?

你有两种解决办法,

第一种:遍历 list 的时候,给每个 item 设置一个 onClick event listener ,这样就会设置 100 个 event listener,当你移除 item 的时候还需要手动移除该 item 的 event listener,添加 item 的时候,需要手动添加 event listener,😡

第二种:直接在 ul 上直接加 一个 onClick 事件就完了,当你点击 li,li 的 onClick 事件 bubble 到 ul 上,ul 直接从 event 里的 target 里获取 li 的引用再获取对应的属性即可(使用 data-* 属性也不错),这样就不用担心添加/移除 item 了,因为你的 listener 是挂在 ul 上的),🥳

当然事件委托也有两种实现方式,一种是冒泡,一种是捕获,从兼容性考虑一般使用冒泡模型。

谁在用

React

React 从第一个版本就使用 event delegation 这种设计模式。

React has been doing event delegation automatically since its first release. When a DOM event fires on the document, React figures out which component to call, and then the React event “bubbles” upwards through your components. But behind the scenes, the native event has already bubbled up to the document level, where React installs its event handlers. - react official doc

就像我们一开始的例子,我们在 ul 上挂了个 click 的 event handler 来处理 lionclick 事件,React (React 17 之前)则是在 document 上挂的对应的 event type 的 event listener。

React 16

在 React 17 之前,一直是将 event listener 挂在 document 上的,但是这样会导致一些问题,比如,e.stopPropagation() “有时”无效的问题

就拿很早之前(4年前)atom editor 就遇到过 这个问题 举个例子,这个是由于多版本的 react 嵌套导致的 e.stopPropagation() 无效,这里参考了这个问题里的测试用例,写了个 demo,一起来看下,

Demo 里是两个嵌套的 react dom tree,版本都是 16,一个是 16.13 一个是 16.14,

<!-- https://reactjs.org/docs/add-react-to-a-website.html -->
<!-- https://codesandbox.io/s/amd-apploader-og1z5?file=/src/Apploader.js -->
<!-- https://cdnjs.com/libraries/react-dom/16.13.0 -->

<!-- 这个是期望的版本 作者修改了代码 挂在 react root tree 上面了 而不是 document 上-->
<!-- http://fooo.fr/~vjeux/fb/vjeux-test/test-event-boundary.html  -->

<!-- NOTE: 测试两个 不同版本的 react 嵌套在一起的时候 stopPropgation 的 bug -->
<script src="./require.min.js"></script>
<script>
  window.require.config({
    paths: {
      react: "../react16.14.development",
      react2: "./react.16.13.development",
      "react-dom": "./react-dom16.14.development",
      "react-dom2": "./react-dom16.13.development",
    },
  });
</script>
<div id="root"></div>
<script>
  window.require(
    ["react", "react-dom", "react2", "react-dom2"],
    function (React, ReactDOM, React2, ReactDOM2) {
      const e = React.createElement;
      const e2 = React2.createElement;

	  // 蓝色(里面的)
      class InnerComponent extends React2.Component {
        render() {
          return e2(
            "div",
            {
              onClick: (e) => {
                e.stopPropagation();
                console.log("innerOnClick");
              },
              style: { border: "1px solid blue", padding: 50 },
            },
            React2.createElement("div", {
              className: "inner",
            })
          );
        }
      }

	  // 红色(外面的)
      class OuterComponent extends React.Component {
        componentDidMount = function () {
          // React 16.13 
          ReactDOM2.render(
            React2.createElement(InnerComponent),
            this.refs.inner
          );
        };

        render() {
          return e(
            "div",
            {
              onClick: (e) => {
                e.stopPropagation();
                console.log("outerOnClick");
              },
              style: { border: "1px solid red", padding: 20 },
            },
            React.createElement("div", {
              ref: "inner",
              className: "outer",
            })
          );
        }
      }

      ReactDOM.render(e(OuterComponent), document.getElementById("root"));
    }
  );
</script>

两个框框都加了点击事件,点击事件都调用了 e.stopPropagation() 想要阻止冒泡,你觉得会发生什么?

是不是觉得只会打印 “innerOnClick” ?可是两个打印都出来了,没想到吧,是不是很奇怪 ???

为什么呢?这就和 React 的 event delegation 实现有关了。

React DOM 会在 document 上设置一个总的对应 event type 的单个 event listener,比如,click 事件的话,document.addEventListener('click', eventHandler),这样来进行事件委托(event delegation),

你可以看到,两个版本的 React DOM 分别往 document 上添加了一个 click 的 event handler(初始化 fiber tree 的时候,根据 fiber node 上的属性来创建对应 event type 的单个 event listener),两个框框也被加了一个(noop 函数,就是啥也不做的意思,这个其实是用来解决的 iOS Safari 的一个 bug 用的,你就理解为 框框 本身并没有添加 click 的 event listener),

你会想,我们在写 React 的组件的时候不是会在组件上面写一个行内的 onClick 的 event listener 吗?

是的,但是这个行内 onClick 不是这样用的。

还记得我们说的 React 有一套自己的 event delegation 机制吗?你的 onClick props 是不会直接在 DOM node 节点上直接创建 event listener 的,React DOM 会使用这个 props 属性利用 fiber tree 模拟出一个事件队列。

整个流程是这样的,当你点击里面的蓝色框框的时候,触发了一个 click 事件,然后该事件(event)被一路往上 bubble,因为红色的框框也是在另一个 React DOM tree 里,所以红色的框框也没有点击事件(除了兼容用的 noop 的一个 click 事件),然后其他的 DOM node 节点上面也没有设置 click 的 event listener,最后bubble 到了 document 节点,因为红色的框框所在的 React DOM是先初始化的,而蓝色的是后面初始化的(因为在红色框框 mount 后才 mount 的),所以红色的框框会先在 document 上设置 event listener 的,所以红色框框的 React DOM 在 document 上设置的 click event listener 会先被调用,调用的时候,React DOM 会根据 eventtarget 属性获取 DOM node 节点引用,然后找到对应的 fiber node,再看上面有没有 onClick 的 props,有的话加入到内部待执行的 event handler queue(事件队列) 里去,然后再 traverse target 的 DOM node 节点的 parent DOM node 节点,对每一个 parent DOM node 依次找到其对应的 fiber node,然后看有没有 onClick props,有的话加入到事件队列,没有的话继续 traverse,直到到 React DOM root节点,这样就模拟出了原生 DOM 的 event 的 bubble 逻辑(capture 同理),然后挨个执行,如果有发现某个调用的 onClick event handler 有 e.stopPropagation() 逻辑的话,React DOM 会首先在 native event 上调用 e.stopPropagation(),然后将再在包了 native event 一层的 Synthetic Event(合成事件)上的 isDefaultPrevented 设为 truethis.isDefaultPrevented = functionThatReturnsTrue),这样 React DOM 自己模拟的事件队列就可以根据这个属性来模拟浏览器的 stopPropagation 行为了,

    //`onClick` 事件的 `e.stopPropagation()` 逻辑
	stopPropagation: function () {
      var event = this.nativeEvent;

      if (!event) {
        return;
      }

      if (event.stopPropagation) {
        event.stopPropagation();
      } else if (typeof event.cancelBubble !== 'unknown') {
        event.cancelBubble = true;
      }

      this.isPropagationStopped = functionThatReturnsTrue;
    },
    // 遍历执行模拟出来的 bubble path 的 onclick 事件
	if (Array.isArray(dispatchListeners)) {
      for (var i = 0; i < dispatchListeners.length; i++) {
        if (event.isPropagationStopped()) {
          break;
        } // Listeners and Instances are two parallel arrays that are always in sync.


        executeDispatch(event, dispatchListeners[i], dispatchInstances[i]);
      }
    } else if (dispatchListeners) {
      executeDispatch(event, dispatchListeners, dispatchInstances);
    }

执行完了之后,蓝色的框框也同样和红色的框框走一遍流程,所以,蓝色的框框里的 onclick event handler 调用 e.stopPropagation() 不生效的原因是,红色框框在执行蓝色的模拟事件队列前就已经执行了。

React 17 来了,解决了这个问题。

React 17

办法也很简单,就是在原来在 document 节点添加总的 event listener,现在在 react DOM root node 添加。

这样可以解决问题吗?

花五分钟想下。

我们将里面的蓝色框框所用的 React 16 的库替换为 React 17 的,外面的还是 React 16 的,逻辑不变,来看下 demo

现在只会打印最里面的了。WOW~

来看下发生了什么。

在内部的蓝色框框里,在点击后,首先蓝色框框的点击事件 bubble 到了蓝色框框的 React DOM root 节点,然后 React DOM root 节点的 click event handler 就被调用了,调用的时候开始模拟合成事件队列,然后执行,队列里的现在就一个 event handler,就是蓝色框框的,然后执行就触发了 e.stopPropagation();,这时,React DOM 先是调用了 native evente.stopPropagation();,保证了 蓝色框框的 React DOM root 的 native event 不会继续 bubble 上去了(所以就到不了红色框框了),然后将再在包了 native event 一层的 Synthetic Event(合成事件)上的 isDefaultPrevented 设为 true,这样内部的事件队列在这个 event handler 调用后,后面的也不会调用了(虽然现在就只有一个

以上。

题外话:跑 demo 打断点看逻辑的时候,发现 Chrome 在打断点的时候和不打断点的时候跑的 React V16 nested demo demo 竟然结果是不一样的。。。 Safari 到没事。。。

参考文献

  1. Bubbling and capturing
  2. current event target
  3. event target
  4. Event dispatch and DOM event flow
  5. React 17 changes-to-event-delegation - React official doc
  6. Handling Events - React official doc
  7. A Bit about Event Delegation in React ★ ★ ★ ★ ★
  8. How JavaScript Event Delegation Works ★ ★ ★ ★ ★
  9. SyntheticEvent - React official doc
  10. Nested React Dom Tree Issue(atom editor) - GitHub issue
  11. Event delegation issue - Global event handlers on document.body (or other containing element) run BEFORE react event handlers #7094 - gaearon
  12. Testing in React event part - React official doc
  13. EventTarget.addEventListener()
  14. 为什么使用冒泡来实现事件委托居多?