React 事件系统的设计原理

378 阅读7分钟

著有《React18 设计原理》《javascript地月星》等多个专栏。欢迎关注。

创作不易,有帮助别忘了点赞,收藏,评论 ~ 你的鼓励是我继续挖干货的动力。

本文全部都是原创内容,商业转载请联系作者获得授权,非商业转载需注明出处,感谢理解 ~

推荐指数(满级):⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️

github 👉github.com/chd666233/b…
主页 👉juejin.cn/post/757791…

收集Fiber事件

事件系统的设计原理:

  1. 给容器绑定统一的事件监听器
  2. 创建合成事件对象
  3. ✅ 收集Fiber事件(详细、推荐阅读)
  4. 事件回调的派发

以一个HostPortal场景的事件收集为例:

function Modal(){return ReactDOM.createPortal(<div>弹窗的具体内容</div>,document.getElementById('modal-container'))}
function AppA(){return <div><Modal/></div>}
function AppB(){return <div><div id="modal-container"></div></div>}

ReactDOM.createRoot(document.getElementById('rootA')).render(<AppA />); //Root A
ReactDOM.createRoot(document.getElementById('rootB')).render(<AppB />); //Root B
index.html
<body>
    <div id="rootA" class="app-container"></div>
    <div id="rootB" class="app-container"></div>
</body>

Fiber树(逻辑树)

HostRoot rootA
   |-- AppA
        |-- div
             |-- Modal
                  |-- HostPortal
                          |-- div
                               |-- button(点击事件源)

HostRoot rootB
   |-- AppB
        |-- div
             |-- div div#modal-container

DOM树(真实树)

<body>
   |-- div#rootA
   |    |-- <一些HTML元素>
   |
   |-- div#rootB
         |-- div 
             |-- <一些HTML元素>
             |-- div#modal-container
                    |-- div
                         |-- button (点击事件源)

DOM树和Fiber树是不一致的!
HostPortal内的Fiber节点,Modal、div...都在应用A的Fiber树。但是Portal渲染的DOM,都添加在应用B的DOM树。

也就是树“歪”了(Fiber树是“正”的,DOM树“歪”了)。

假如在页面上点击了button事件,事件流会是什么样的?
这就是进入我们的正文,事件的收集。

dispatchEventForPluginEventSystem

function dispatchEventForPluginEventSystem(DOMEventName, eventSystemFlags, nativeEvent, targetInst, targetContainer) {
  var ancestorInst = targetInst;

  if ((eventSystemFlags & IS_EVENT_HANDLE_NON_MANAGED_NODE) === 0 && (eventSystemFlags & IS_NON_DELEGATED) === 0) {
    var targetContainerNode = targetContainer; 
    
    if (targetInst !== null) {
  
      var node = targetInst;

      mainLoop: while (true) {
        if (node === null) {
          return;
        }

        var nodeTag = node.tag;

        if (nodeTag === HostRoot || nodeTag === HostPortal) {
          var container = node.stateNode.containerInfo;

          if (isMatchingRootContainer(container, targetContainerNode)) {
            break;
          }

          if (nodeTag === HostPortal) {
           
            var grandNode = node.return;

            while (grandNode !== null) {
              var grandTag = grandNode.tag;

              if (grandTag === HostRoot || grandTag === HostPortal) {
                var grandContainer = grandNode.stateNode.containerInfo;

                if (isMatchingRootContainer(grandContainer, targetContainerNode)) {
               
                  return;
                }
              }

              grandNode = grandNode.return;
            }
          } 
        
          while (container !== null) {
            var parentNode = getClosestInstanceFromNode(container);

            if (parentNode === null) {
              return;
            }

            var parentTag = parentNode.tag;

            if (parentTag === HostComponent || parentTag === HostText) {
             //更新ancestorInst和node
              node = ancestorInst = parentNode;
              continue mainLoop;
            }

            container = container.parentNode;
          }
        }

        node = node.return;
      }
    }
  }

  batchedUpdates(function () {
    return dispatchEventsForPlugins(DOMEventName, eventSystemFlags, nativeEvent, ancestorInst);
  });
}

只看代码重点:

两个回溯

  1. 本树回溯:本树上对container和targetContainerNode进行配对。
    a. while(true){} + if(nodeTag === HostRoot✅ || nodeTag === HostPortal){}
    一直冒泡到顶。
    b.while(true){} + if(nodeTag === HostRoot || nodeTag === HostPortal✅){} + if (nodeTag === HostPortal✅) { while(grandNode !== null){if (✅isMatchingRootContainer(grandContainer, targetContainerNode)) {return}} }
    冒泡中途遇到HostPortal,如果HostPortal的容器也在本树上,这种情况直接return退出事件收集,避免重复收集。👉详细看下文注释1

  2. 跨树回溯:HostPortal的容器不在本树上,借助HostPortal的容器DOM跳转到另一颗Fiber树继续回溯。
    a.while(true){} + if(nodeTag === HostRoot || nodeTag === HostPortal✅){} + if (nodeTag === HostPortal✅) { while(grandNode !== null){if (❌isMatchingRootContainer(grandContainer, targetContainerNode)) {return}} } + ✅while (container !== null) {}

HostPortal的容器可能在本树上,也可能在其他树上。

Fiber.stateNode是什么?

1.是"容器"DOM实例,容器负责挂载内容。
<App>组件里面的节点 和 <Modal>弹窗组件里面的根节点,挂载在容器 DOM 节点上。
HostRoot Fiber.stateNode.containerInfo = div#root,
HostPortal Fiber.stateNode.containerInfo = div#modal-container。
它们的stateNode是一个{containerInfo, ...}对象。

2.是具体的 DOM 实例。
HostComponent 和 HostText 的 DOM 节点
HostComponent Fiber.stateNode = div/p/span...,
HostText Fiber.stateNode = “文本内容”。
它们的 stateNode 是一个 DOM 实例。

getClosestInstanceFromNode

getClosestInstanceFromNode(node.stateNode.containerInfo)获取容器的Fiber。

function getClosestInstanceFromNode(targetNode) {//DOM类型
  var targetInst = targetNode[internalInstanceKey];

  if (targetInst) {
    return targetInst; // Fiber 类型
  }
  ...
}

👉 internalInstanceKey 受React管理的DOM

targetContainerNode targetInst ancestorInst

绑定事件targetContainerNode是HostRoot、HostPortal的容器。例如前面例子中的div#root和div#modal-container。

targetInst 是事件发生的DOM元素对应的Fiber实例。例如例子中的button的Fiber。

ancestorInst 是事件收集的起点。
没跨树,ancestorInst 是 button 的 Fiber;跨树了,ancestorInst 是 HostPortal 的容器(div#modal-container) 的Fiber。

isMatchingRootContainer

isMatchingRootContainer(node.stateNode.containerInfo, targetContainerNode)
当前冒泡到的容器 与 传入的容器是否同一个容器。容器匹配,就表明node在这棵Fiber树上,确定了在这棵树上,就可以更新ancestorInst。

1.在本棵树上找到匹配,则var ancestorInst = targetInst = button Fiber。
2.否则跨树了,node = ancestorInst = parentNode = div#modal-container的Fiber = getClosestInstanceFromNode(node.stateNode.containerInfo)。

function isMatchingRootContainer(grandContainer, targetContainer) {//DOM类型
  return (grandContainer === targetContainer || ..);
}

回溯的流程

到目前为止,可能还不知道回溯在干嘛、更新ancestorInst在干嘛

其实观察例子就可以知道:一开始事件在button触发,原生事件在div#rootB DOM树上冒泡,直到被容器div#rootB捕获,触发React的Fiber树回溯,

targetInst是button的Fiber, 它在Fiber A树上。targetContainerNode是div#rootB。

首先在本树上回溯,就是Fiber A树,
中途遇到HostPortal,
1.从HostPortal一直冒泡到顶部,是div#rootA,HostPortal的容器不是在本树上,不用返回注释1。接着夸树收集。
2.跨树回溯,找到HostPortal的DOM容器div#modal-container,node = ancestorInst = parentNode = div#modal-container的Fiber
continue mainLoop,就是回到while(true){},从Fiber div#modal-container向上遍历到HostRoot B。匹配。(Fiber B树回溯)。

简单的说:button DOM 被点击,事件冒泡到div#modal-container,触发事件收集函数。事件收集函数走了1、2、3、4、5👇。

//跨树部分逻辑:
//DOM树
<div id="rootA"></div>
<div id="rootB">
  <div id="modal-container" 事件收集函数> 3.我是容器,找到我的Fiber
    <div>
      <button></button> <--点击 事件冒泡
    </div>
  </div>
</div>
  
<AppA> //Fiber A树
  <HostPortal>2.中途遇到HostPortal, 找我的容器,
    <div> 
        <button></button>1.从这个targinInst开始在Fiber A树遍历
    </div>
  </HostPortal>  
</AppA>
  
<AppB> 5.匹配
  <div id="modal-container"></div>4.我是容器的Fiber,现在在Fiber B树遍历
</AppB>

跨树 = FiberA -- DOM 容器 -- FiberB

HostPortal(Fiber) -- HostPortal的容器(div#modal-container DOM) -- HostPortal的容器 的Fiber(这里是第二棵树了)。

回溯添加的次数

其实这个例子一共有3个回溯: 除了冒泡到div#rootB捕获外,还有:被div#rootA捕获, 被div#modal-container捕获。

  • targetInst=Fiber button, targetContainerNode='div#rootA' 不需要跨树
  • targetInst=Fiber button, targetContainerNode='div#modal-container' 不需要跨树
  • targetInst=Fiber button, targetContainerNode='div#rootB' 需要跨树,上面例子就是这种

为什么要这样?

想象一下,如果这个例子中不单单在HostPortal上绑定了点击事件,还在HostPortal的父节点,还在HostRoot A、B上都绑定了点击事件,这样就能收集到全部的事件。

这个例子的点击事件只会触发后面2个,因为DOM树是“歪”的,DOM事件不会冒泡到div#rootA,触发div#rootA上绑定的Fiber事件收集函数。

事件在div#rootB DOM树冒泡,到div#modal-container触发一次回溯,到div#rootB触发第二次回溯。

事件在DOM树上冒泡。React 给每个容器 DOM 都绑定了事件收集函数,负责回溯、收集 Fiber 树上的“事件”。

事件收集的起点和终点

回溯的作用是更新ancestorInst,在dispatchEventsForPlugins中Fiber树从ancestorInst开始向上遍历收集事件。
targetContainerNode = ‘div#rootA’
targetContainerNode = ‘div#modal-container’
targetContainerNode = ‘div#rootB’

第一个从Fiber button到HostRoot A收集一次(不触发)。

第二个ancestorInst是 Fiber button,进入到dispatchEventsForPlugins,
收集Fiber A树,从 button 到 HostPortal ,还会继续收集从 HostPortal 到 HostRoot A 的点击事件,
没有在到达 HostPortal就停下来,而是继续收集了 HostPortal 到 HostRoot A的事件( HostRoot A的事件被收集,也不触发 div#rootA 的 DOM 事件)。

第三个 一开始ancestorInst是Fiber button,跨树回溯,更新为Fiber div#modal-container,
进入到dispatchEventsForPlugins,只收集Fiber B树从Fiber div#modal-container到HostRoot B的点击事件。

第二个:
HostRoot rootA  到顶 收集事件A
   |-- AppA  继续向上 收集事件A
        |-- div  继续向上 收集事件A
             |-- Modal  继续向上 收集事件A
                  |-- HostPortal 收集事件A
                          |-- div  收集事件A
                               |-- button(点击事件源)  收集事件A
第三个:
HostRoot rootB 收集事件B
   |-- AppB 收集事件B
        |-- div 收集事件B
             |-- div div#modal-container 收集事件B

注释1: 正是因为第二个会继续向上的缘故,如果HostPortal的容器没有跨树,直接返回,避免重复收集Fiber事件。想象一下,如果没有夸树,会触发div#rootA的事件收集,加上这里的HostPortal会继续向上收集,从button-HostRootA一次。button-HostPortal-HostRootA一次。就是2次。

点击关闭,先收集了A, 后收集了B :

从 ancestorInst Fiber 开始收集 Fiber“事件”,一直收集到HostPortal Fiber、HostRoot Fiber(容器Fiber)。

总结

虽然React采用的是事件委托的方式,但具体的事件、回调函数绑定在各个Fiber节点上,事件收集就是收集这些Fiber节点上的事件。

在收集前,要知道收集的Fiber起点, 容器DOM上给所有的可以委托的事件绑定了统一的事件监听器,触发dispatchEventForPluginEventSystem通过回溯的方式更新ancestorInst,作为事件收集的Fiber起点。

第一步:事件冒泡的机制,原生事件沿着DOM树冒泡,到达容器DOM。
第二步:触发绑定在容器DOM的事件,执行dispatchEventForPluginEventSystem。确定事件收集的起点,收集Fiber路径上的事件回调(可以简单的理解dispatchEventsForPlugins就是后续的事件收集,或者点击这里了解事件收集函数)。
第三步:批量执行事件回调。