React 合成事件的设计原理 3

著有《React 源码》《React 用到的一些算法》《javascript地月星》等多个专栏。欢迎关注。

文章不好写,要是有帮助别忘了点赞,收藏~ 你的鼓励是我继续挖干货的的动力🔥。

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

总流程

合成事件的设计原理:

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

这一篇处在第3点,收集Fiber事件。

树"歪"了 以一个HostPortal场景举例

jsx 复制代码
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
html 复制代码
index.html
<body>
    <div id="rootA" class="app-container"></div>
    <div id="rootB" class="app-container"></div>
</body>

Fiber树(逻辑树)

js 复制代码
HostRoot rootA
   |-- AppA
        |-- div
             |-- Modal
                  |-- HostPortal
                          |-- div
                               |-- button(点击事件源)

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

DOM树(真实树)

js 复制代码
<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树。

也就是树"歪"了。

假如在页面上点击了button事件,事件流会是什么样的?

这就是进入我们的正文,事件的收集。

dispatchEventForPluginEventSystem

js 复制代码
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) {
      // The below logic attempts to work out if we need to change
      // the target Fiber to a different ancestor. We had similar logic
      // in the legacy event system, except the big difference between
      // systems is that the modern event system now has an event listener
      // attached to each React Root and React Portal Root. Together,
      // the DOM nodes representing these roots are the "rootContainer".
      // To figure out which ancestor instance we should use, we traverse
      // up the Fiber tree from the target instance and attempt to find
      // root boundaries that match that of our current "rootContainer".
      // If we find that "rootContainer", we find the parent Fiber
      // sub-tree for that root and make that our ancestor instance.
      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) {
            // The target is a portal, but it's not the rootContainer we're looking for.
            // Normally portals handle their own events all the way down to the root.
            // So we should be able to stop now. However, we don't know if this portal
            // was part of *our* root.
            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)) {
                  // This is the rootContainer we're looking for and we found it as
                  // a parent of the Portal. That means we can ignore it because the
                  // Portal will bubble through to us.
                  return;
                }
              }

              grandNode = grandNode.return;
            }
          } 
          // Now we need to find it's corresponding host Fiber in the other
          // tree. To do this we can use getClosestInstanceFromNode, but we
          // need to validate that the Fiber is a host instance, otherwise
          // we need to traverse up through the DOM till we find the correct
          // node that is from the other tree.
          while (container !== null) {
            var parentNode = getClosestInstanceFromNode(container);

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

            var parentTag = parentNode.tag;

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

            container = container.parentNode;
          }
        }

        node = node.return;
      }
    }
  }

  batchedUpdates(function () {
    return dispatchEventsForPlugins(DOMEventName, eventSystemFlags, nativeEvent, ancestorInst);
  });
}
两个回溯
  1. 本树回溯:本树上对container和targetContainerNode进行配对。if(isMatchingRootContainer()){} if (nodeTag === HostPortal) {}

  2. 跨树回溯:若本树无匹配,则找到HostPortal的容器DOM。借助HostPortal的容器DOM跳转到另一颗Fiber树继续回溯。while (container !== null) {}

targetContainerNode targetInst ancestorInst
  • targetContainerNode是HostRoot、HostPortal的容器。例如前面例子中的div#root和div#modal-container。
  • targetInst是事件发生的DOM元素对应的Fiber实例。例如例子中的button的Fiber。

一旦某棵Fiber树的HostRoot HostPortal的containerInfotargetContainerNode匹配,表明targetContainerNode在这棵Fiber树上。

  • 确定了在这棵树上,就可以更新ancestorInst

若在本棵树上找到匹配,则ancestorInst=targetInst。targetInst是事件发生的DOM节点,它的Fiber。

若跨树了,则node = ancestorInst = parentNode。parentNode是getClosestInstanceFromNode(container)。

回溯的流程

到目前为止,可能还不知道回溯在干嘛、更新ancestorInst在干嘛,其实观察例子就可以知道:一开始事件在button触发,原生事件在div#rootB DOM树上冒泡,直到被容器div#rootB捕获,触发React的Fiber树回溯,

首先在本树上回溯,就是Fiber A树,因为targetInst是button的Fiber, 它在Fiber A树上。targetContainerNode是div#rootB。

观察Fiber A树,就是从button向上遍历到HostPortal,从HostPortal到HostRoot A。

到顶也不能匹配,进入到第二部分的逻辑。

从button向上遍历到HostPortal,从HostPortal到HostRoot A 后没有匹配后,通过HostPortal,找到它的DOM容器div#modal-container,通过容器的Fiber向上遍历到HostRoot B,匹配。

观察Fiber A树,就是从button到HostPortal,

观察div#rootB DOM树,找到HostPortal的容器div#modal-container,

观察Fiber B树,找到容器的Fiber div#modal-container,从Fiber div#modal-container向上遍历到HostRoot B。匹配。(Fiber B树回溯)

jsx 复制代码
//第二部分的逻辑:
<div id="rootA"></div>
<div id="rootB">
  <div id="modal-container">3.我是容器,找到我的Fiber
    <div>
      <button></button> <--点击
    </div>
  </div>
</div>
  
<AppA>
  <HostPortal>2.找我的容器,
    <div>
        <button></button>1.从这个targinInst开始在Fiber A树遍历
    </div>
  </HostPortal>  
</AppA>
  
<AppB> 5.匹配
  <div id="modal-container"></div>4.我是容器的Fiber,现在在Fiber B树遍历
</AppB>

回溯添加的次数

其实这个例子一共有3个回溯:

  • 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个,因为事件是沿着div#rootB DOM树冒泡的,冒泡到div#modal-container触发一次回溯,冒泡到div#rootB触发第二次回溯。

事件收集的起点

回溯的作用是更新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的事件。

第三个一开始ancestorInst是Fiber button,跨树回溯,更新为Fiber div#modal-container,

进入到dispatchEventsForPlugins,只收集Fiber B树从Fiber div#modal-container到HostRoot B的点击事件。

js 复制代码
第二个:
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

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

getClosestInstanceFromNode(container)

getInstanceFromNode + Closest 通过DOM Node实例获取Fiber实例,加上Closest就是 最近的祖先节点。

返回值是

  1. 在SSR+Suspense的场景,返回注释边界
  2. 没有在SSR+Suspense的场景,则直接返回自身的Fiber

对于1,这种情况,因为不是if (parentTag === HostComponent || parentTag === HostText) {}所以要继续向上,是注释的父级。

html 复制代码
<div> <-- ancestorInst最终确定为这个的Fiber
  <!-- $ --> <-- 返回这个
  <div id="modal-container"> <-- 没有水合,没有被管理
    Loading...
  </div>
  </!-- /$ -->
</div>

但是如果已经水合了,ancestorInst就是Fiber div#modal-container本身。

对于2,就是我们例子的情况。container是某个HostRoot HostPortal的容器DOM(containerInfo),它有Fiber,该Fiber可以直接返回。

例如div#modal-container是HostPortal的containerInfo,返回div#root的Fiber。

isMatchingRootContainer

ini 复制代码
function isMatchingRootContainer(grandContainer, targetContainer) {
  return (
    grandContainer === targetContainer || //当前container与targetContainer配对
    (grandContainer.nodeType === COMMENT_NODE &&
     grandContainer.parentNode === targetContainer)
  );
}

条件1:当前container 与 HostRoot或HostPortal是否配对。

条件2:注释的父节点与HostRoot或HostPortal是否配对

if (nodeTag === HostRoot || nodeTag === HostPortal){}已经限制死了只能有HostRoot HostPortal,不会用到条件2。

xml 复制代码
<div id="root">
  <!--$-->
  <div>App 内容</div>
  <!--/$-->
</div>

总结

React的事件绑定在Fiber节点上,事件收集就是收集Fiber节点上的事件,批量执行。

在收集前,要知道收集的Fiber起点,

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

第一步:事件冒泡的机制,原生事件沿着DOM树冒泡,到达容器DOM。

第二步:触发绑定在容器DOM的事件,执行dispatchEventForPluginEventSystem。确定时间收集的起点,收集Fiber路径上的事件回调(可以简单的理解dispatchEventsForPlugins就是后续的事件收集)。

第三步:批量执行事件回调。

相关推荐
liangshanbo12154 小时前
React 19 新特性:原生支持在组件中渲染 <meta> 与 <link>
前端·javascript·react.js
光影少年21 小时前
react生态
前端·react.js·前端框架
ObjectX前端实验室2 天前
【react18原理探究实践】React Effect List 构建与 Commit 阶段详解
前端·react.js
ObjectX前端实验室2 天前
【react18原理探究实践】更新阶段 Render 与 Diff 算法详解
前端·react.js
ObjectX前端实验室2 天前
【react18原理探究实践】render阶段【首次挂载】
前端·react.js
ObjectX前端实验室2 天前
【react18原理探究实践】组件的 props 和 state 究竟是如何确定和存储的?
前端·react.js
明里人2 天前
React 状态库:Zustand 和 Jotai 怎么选?
前端·javascript·react.js
訾博ZiBo2 天前
为什么我的 React 组件会无限循环?—— 一次由 `onClick` 引发的“惨案”分析
前端·react.js
訾博ZiBo2 天前
React状态更新之谜:为何大神偏爱`[...arr]`,而非`arr.push()`?
react.js