一個Bug,淺入 React 合成事件

這是我參與8月更文挑戰的第9天,活動詳情查看:8月更文挑戰瀏覽器

前言

經過一個簡單的業務場景,探究 React 合成事件的底層原理。markdown

場景

Antd Table 中嵌套使用 onRow 和 Popconfirm(綁定在 body 上)。dom

  • Table 中的固定一列,點擊出現 Popconfirm 確認框
  • Table 點擊每一行,跳轉其餘頁面

注:React 版本 16.13.0函數

問題

點擊 Popconfirm 確認框的任何地方也出現了跳轉現象。 oop

這裏 Popconfirm 是綁定在 body 中,並無和 table 放在一塊兒。頁面渲染視圖中,Table tr td 中沒有嵌套渲染 Popconfirm,可是點擊 Popconfirm觸發了 Table onRow 的 click 事件。 這裏經測試,發現是先觸發了目標元素的 click,而後觸發了 onRow click。post

render: (text) => (
      <div>
        <Popconfirm
          //....
          getPopupContainer={() => document.body}
        >
          <div onClick={() => {}}>
            <Tooltip title={text} theme="dark">
              <span>{text}</span>
            </Tooltip>
          </div>
        </Popconfirm>
        ,
      </div>
    )
onRow={(record) => {
  return {
    onClick: (event) => {
      window.open("www.baidu.com");
    }
  };
}}
複製代碼

解決方案

解決方案很簡單,Popconfirm 在外層的包裹元素上,直接阻止冒泡。測試

onClick={(e) => e.stopPropagation()}
複製代碼

可是這裏不只讓人遐想,兩個基本沒有關係的 dom ,卻關聯觸發了,在這背後發生了什麼引人猜疑,知其然知其因此然。咱們拋開這個問題,看本質。 React 自己的事件系統,並非原生的事件系統。而是採用了合成事件。咱們先回顧一下 React 的合成事件。spa

React 合成事件

合成事件是 React 自定義的事件對象,它符合 W3C 規範,在底層抹平了不一樣瀏覽器的差別,在上層面向開發者暴露統一的、穩定的、與 DOM 原生事件相同的事件接口。開發者們由此便沒必要再關注煩瑣的兼容性問題,能夠專一於業務邏輯的開發。 React 使用合成事件的好處有如下幾點:.net

  • React 將事件都綁定在 document 上,防止不少事件綁定在原生的 DOM 上,而形成的不可控。
  • 在底層抹平了不一樣瀏覽器的差別,在上層面向開發者暴露統一的、穩定的、與 DOM 原生事件相同的事件接口。

React 合成事件的靈感源泉來自事件委託,React 的事件系統沿襲了事件委託的思想。在 React 中,除了少數特殊的不可冒泡的事件(好比媒體類型的事件)沒法被事件系統處理外,絕大部分的事件都不會被綁定在具體的元素上,而是統一被綁定在頁面的 document 上。當事件在具體的 DOM 節點上被觸發後,最終都會冒泡到 document 上,document 上所綁定的統一事件處理程序會將事件分發到具體的組件實例。插件

在分發事件以前,React 首先會對事件進行包裝,把原生 DOM 事件包裝成合成事件。

包裝

把原生 DOM 事件包裝成合成事件。

Popconfirm、button 的 Dom 上都沒有綁定咱們書寫的事件監聽器。而是 noop ,noop 就指向一個空函數。

然而在 document 卻綁定了本該屬於目標元素的事件。也就如上面所說,在 React 中(17 版本以前,16 版本並非綁定在 document 上),咱們在代碼中所寫的事件,最終都綁定在了 document 上。

事件觸發一次點擊事件,底層系統發生了什麼?

簡單理解了一下 React 的合成事件機制,咱們在回過頭來看看,當咱們點擊 Popconfirm 內任意元素時發生了什麼。咱們在源碼中給 document 的綁定事件 dispatchDiscreteEvent 函數打上斷點,來一步一步看看發生了什麼。

事件觸發處理函數 dispatchEvent

dispatchDiscreteEvent 函數觸發以後,第一個重要的函數 dispatchEvent,React 事件註冊時候,統一的監聽器 dispatchEvent,也就是當咱們點擊按鈕以後,首先執行的是 dispatchEvent 函數。

原生的 dom 元素,找到對應的 fiber

接下來執行 attemptToDispatchEvent,這個函數中會作幾個比較重要的事情

  1. 根據原生事件對象 nativeEvent 找到真實的 dom 元素。
  2. 根據 dom 元素,獲得對應的 fiber 對象,也就是咱們點擊元素對應的 fiber 對象
  3. 進入 legacy 模式的事件處理系統

如何獲取 dom 元素的 fiber 對象

在獲取 fiber 對象時,經過函數 getClosestInstanceFromNode ,找到當前傳入的 dom 對應的最近的元素類型的 fiber 對象。React 在初始化真實 dom 的時候,用一個隨機的 key internalInstanceKey 指針指向了當前 dom 對應的 fiber 對象,fiber 對象用 stateNode 指向了當前的 dom 元素。也就是 dom 和 fiber 對象它們是相互關聯起來的。

元素節點層層關聯

attemptToDispatchEvent 函數執行 getNearestMountedFiber 函數中會發現,tag=5 元素節點是從目標節點向上層層關聯,我在操做的時候,雖然點擊的是 Popconfirm 的元素(掛載 body 上),可是在冒泡的時候仍是會關聯上包裹在它外層的元素。

插件事件系統的調度事件

接着往下,調用 dispatchEventForLegacyPluginEventSystemdispatchEventForLegacyPluginEventSystem 函數字面理解就是插件事件系統的調度事件,其實字面理解和本質也差很少,就是事件系統的調度事件。從這個函數就開始 legacy 模式下事件處理系統與批量更新了。

dispatchEventForLegacyPluginEventSystem 函數中,先在 React 事件池中取出最後一個,對屬性進行賦值。

而後執行批量更新,batchedEventUpdates(v16)爲批量更新的主要函數。經過變量 isBatchingEventUpdates 來控制是否批量進行更新。

事件處理的主要函數 handleTopLevel

batchedEventUpdates 批量更新的主函數 handleTopLevel 爲事件處理的主要函數,咱們在代碼開發中寫的事件處理程序,實際執行是在 handleTopLevel(bookKeeping) 中執行的。 handleTopLevel 處理邏輯就是執行處理函數 extractEvents,好比咱們 Popconfirm 的元素中的點擊事件 onClick 最終走的就是 extractEvents 函數。緣由就是 React 是採起事件合成,事件統一綁定,而且咱們寫在組件中的事件處理函數,也不是真正的執行函數 dispatchAciton,那麼咱們的事件對象 event 也是 React 單獨合成處理的,裏面單獨封裝了好比 stopPropagationpreventDefault 等方法,這樣的好處是,咱們不須要跨瀏覽器單獨處理兼容問題,交給 React 底層統一處理。

// 主函數
function handleTopLevel(bookKeeping) {
	// ...
  for (var i = 0; i < bookKeeping.ancestors.length; i++) {
    // ...
    runExtractedPluginEventsInBatch(topLevelType, targetInst, nativeEvent, eventTarget, eventSystemFlags);
  }
}

function runExtractedPluginEventsInBatch(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags) {
  var events = extractPluginEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags);
  runEventsInBatch(events);
}
// 找到對應的事件插件,造成對應的合成event,造成事件執行隊列
function extractPluginEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags) {
  var events = null;
  for (var i = 0; i < plugins.length; i++) {
    var possiblePlugin = plugins[i];
    if (possiblePlugin) {
      /* 找到對應的事件插件,造成對應的合成event,造成事件執行隊列  */
      var extractedEvents = possiblePlugin.extractEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags);
      if (extractedEvents) {
        events = accumulateInto(events, extractedEvents);
      }
    }
  }
  return events;
}
// 執行事件處理函數
function runEventsInBatch(events) {
  // ...
}
複製代碼

extractEvents-重點、重點、重點

handleTopLevel 的執行中,會找到找到對應的事件插件,造成對應的合成 event,造成事件執行隊列,extractEvents 算是整個事件系統核心函數,當咱們點擊 Popconfirm 的元素時,最終走的就是 extractEvents 函數。

  1. extractEvents 會產生事件源對象,而後從事件源開始逐漸向上,查找 dom 元素類型 HostComponent 對應的 fiber,收集上面的 React 合成事件,onClick / onClickCapture
  2. dispatchListeners 收集上面的 React 合成事件。對應發生在事件捕獲階段的處理函數,邏輯是將執行函數 unshift 添加到隊列的最前面。事件冒泡階段,真正的事件處理函數,邏輯是將執行函數 push 到執行隊列的最後面。
  3. 最後將函數執行隊列,掛到事件對象 event 上,等待執行。從調試能夠看出,最後將調度的實例掛到了 _dispatchInstances 上,調度的監聽事件掛到了 _dispatchListeners 上,_dispatchListeners 上包含了捕獲的處理事件和冒泡的時間處理函數。

這裏其實就模擬了咱們原生事件上的捕獲和冒泡。簡單來講,其實和咱們原生的事件捕獲、冒泡是同樣的。只是爲了可控,本身實現了事件系統。當收集模擬完事件系統以後,就是。

extractEvents 會產生事件源對象 SyntheticEvent,下圖就能夠看到事件源的真面目。

在事件正式執行以前,React 就將事件隊列和事件源造成,而且在事件源對象上處理了對事件默認行爲、事件冒泡的處理。這裏爲我以前的 bug 問題解決埋下了伏筆。

事件執行

當一切都準備完成,就開始進行事件的執行,事件的執行都是在函數 runEventsInBatch 中操做。

runEventsInBatch 執行鏈路比較長,咱們簡化一下最終、最重要的執行,定位到函數 executeDispatchesInOrder,這函數的功能就是將事件收集的分派進行標準/簡單迭代,

function executeDispatchesInOrder(event) {
  var dispatchListeners = event._dispatchListeners;
  var dispatchInstances = event._dispatchInstances;

  {
    validateEventDispatches(event);
  }

  if (Array.isArray(dispatchListeners)) {
    for (var i = 0; i < dispatchListeners.length; i++) {
      if (event.isPropagationStopped()) {
        break;
      }
      // 執行咱們的事件處理函數
      executeDispatch(event, dispatchListeners[i], dispatchInstances[i]);
    }
  } else if (dispatchListeners) {
    executeDispatch(event, dispatchListeners, dispatchInstances);
  }

  event._dispatchListeners = null;
  event._dispatchInstances = null;
}
複製代碼

dispatchListeners[i] 就是執行咱們的事件處理函數,例如咱們在開發書寫的點擊事件的監聽處理函數。這裏在處理的時候,會判斷 event.isPropagationStopped(),是否已經阻止事件冒泡。若是已經組織,就不會繼續觸發。 React 對於阻止冒泡,就是經過 isPropagationStopped,判斷是否已經阻止事件冒泡。若是咱們在事件函數執行隊列中,某一會函數中,調用 e.stopPropagation(),就會賦值給 isPropagationStopped=()=>true,當再執行 e.isPropagationStopped() 就會返回 true ,接下來事件處理函數,就不會執行了。 這裏就明白了爲何我在 Popconfirm 在外層的包裹元素上,直接阻止冒泡 e.stopPropagation()。就不會觸發 table 了 onRow click。

React17 事件機制

這裏隨帶也提一下 React 17 的事件機制,在 React 17 中,事件機制有三個比較大的改動:

  1. React 將再也不向 document 附加事件處理器。而會將事件處理器附加到渲染 React 樹的根 DOM 容器中。在 React 16 或更早版本中,React 會對大多數事件執行 document.addEventListener()。React 17 將會在底層調用 rootNode.addEventListener()。

  1. React 17 中終於支持了原生捕獲事件的支持, 對齊了瀏覽器原生標準。同時 onScroll 事件再也不進行事件冒泡。onFocus 和 onBlur 使用原生 focusin, focusout 合成。
  2. 取消事件池 React 17 取消事件池複用。

總結

最後總結一下,經過不一樣的斷點調測,終於找到了開文說的 bug 解決辦法的原因。知其然知其因此然。也間接的淺入了 React 的事件系統。下面這張圖是做者寫在源碼中的註釋,簡述了事件系統。

在 React 中,事件觸發的本質是對 dispatchEvent 函數的調用。模擬原生的事件的捕獲和冒泡,收集事件,順序執行。

React 合成事件雖然承襲了事件委託的思想,但它的實現過程比傳統的事件委託複雜太多。對 React 來講,事件委託主要的做用應該在於幫助 React 實現了對全部事件的中心化管控。關於 React 事件系統,就介紹到這裏。

若是你以爲寫的不錯,幫忙點個贊吧。

參考

相關文章
相關標籤/搜索