寫給本身看的React源碼解析(四):React事件系統的實現原理

前言

React有着獨特的事件機制-合成事件,React的初學者確定碰到過這種問題,使用event.stopPropagation();,卻仍是沒法禁止當前組件的事件冒泡,這就是React的事件機制的緣由,它並不與DOM事件相同。react

本文將從源碼的角度來解析,React的事件系統究竟是如何實現的?git

DOM事件流

DOM事件流屬於比較基礎的知識點,本文不會詳細的再敘述,只列出須要一些關鍵點。W3C標準約定了一個事件的傳播過程要通過如下 3 個階段:github

  • 1.事件捕獲階段
  • 2.目標階段
  • 3.事件冒泡階段

經過DOM事件流,咱們常常會用到一直常見的性能優化思路:事件委託數組

React事件系統也是基於事件委託這個特性實現的。瀏覽器

React事件系統

React中,除了一些不可冒泡的事件外,其它的事件都不會被綁定在具體的元素上,而是統一被綁定到document上(17版本以後修改成綁定到React的根DOM組件上),當事件在具體的DOM節點上被觸發後,最終都會冒泡到document上,React根組件上所綁定的統一事件處理程序會將事件分發到具體的組件實例。性能優化

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

React合成事件

合成事件是React自定義的事件對象,它在底層抹平了不一樣瀏覽器的差別,在上層面向開發者暴露統一的、穩定的、與DOM原生事件相同的事件接口。app

雖然合成事件並非原生DOM事件,但它保存了原生DOM事件的引用。當你須要訪問原生DOM事件對象時,能夠經過合成事件對象的e.nativeEvent屬性獲取到原生DOM事件。dom

React事件的綁定

事件的綁定是在組件的首次渲染鏈路的completeWork方法中完成的。關於首次渲染的流程,能夠看我以前的文章異步

寫給本身看的React源碼解析(一):你的React代碼是怎麼渲染成DOM的?

completeWork主要作了三件事情:

  • 建立 DOM 節點(createInstance)
  • 將 DOM 節點插入到 DOM 樹中(appendAllChildren)
  • 爲 DOM 節點設置屬性(finalizeInitialChildren)。

finalizeInitialChildren方法中,會遍歷節點的props。當遍歷到事件相關的props時,就會觸發事件的註冊鏈路。

本文基於16.13版本的React源碼。注意:17版本的事件系統在源碼上的改動比較大,源碼鏈路上跟本文並不一致。

ensureListeningTo中,會獲取當前DOM中的document對象,而後經過調用legacyListenToEvent,將統一的事件監聽函數註冊到document上面。

legacyListenToEvent中,其實是經過調用legacyListenToTopLevelEvent來處理事件和document之間的關係的。 legacyListenToTopLevelEvent直譯過來是「監聽頂層的事件」,這裏的「頂層」就能夠理解爲事件委託的最上層,也就是document節點。

注意:在17版本中,流程中不會存在ensureListeningTolegacyListenToEvent方法,React會在finalizeInitialChildren方法下的setInitialProperties根據節點的tag類型,傳入不一樣的參數並調用listenToNonDelegatedEvent方法。在這個方法裏,會直接調用addTrappedEventListener添加事件到React的根組件DOM元素上。

listenToNonDelegatedEvent源碼地址

咱們接着來看,最終註冊到document上的並非某一個DOM節點上對應的具體回調邏輯,而是一個統一的事件分發函數listener,它的本體是一個dispatchEvent

React事件的觸發

事件的觸發其實就是對於dispatchEvent函數的調用。

咱們根據下面的這個demo來走流程

import React from 'react';
import { useState } from 'react'
function App() {
  const [state, setState] = useState(0);
  return (
    <div className="App"> <div className="container" onClickCapture={() => console.log('捕獲通過 div')} onClick={() => console.log('冒泡通過 div')} > <p>{state}</p> <button onClick={() => { setState(state + 1) }}>點擊+1</button> </div> </div>
  );
}
export default App;
複製代碼

這個demo的功能很簡單,每次點擊按鈕都會給state加1。並給container這個div上添加了兩個點擊事件,一個捕獲事件,一個冒泡事件。下圖是這個demo的fiber樹結構。

收集的邏輯過程在traverseTwoPhase函數

function traverseTwoPhase(inst, fn, arg) {
  // 定義一個 path 數組
  var path = [];

  while (inst) {
    // 將當前節點收集進 path 數組
    path.push(inst);
    // 向上收集 tag===HostComponent 的父節點
    inst = getParent(inst);
  }
  var i;
  // 從後往前,收集 path 數組中會參與捕獲過程的節點與對應回調
  for (i = path.length; i-- > 0;) {
    fn(path[i], 'captured', arg);
  }

  // 從前日後,收集 path 數組中會參與冒泡過程的節點與對應回調
  for (i = 0; i < path.length; i++) {
    fn(path[i], 'bubbled', arg);
  }
}
複製代碼

traverseTwoPhase函數作了如下三件事情。

  1. 循環收集符合條件的父節點,存進 path 數組中
  2. 模擬事件在捕獲階段的傳播順序,收集捕獲階段相關的節點實例與回調函數
  3. 模擬事件在冒泡階段的傳播順序,收集冒泡階段相關的節點實例與回調函數

收集父節點

traverseTwoPhase會以觸發事件的目標節點爲起點,經過getParent方法,不斷向上尋找tag===HostComponent的父節點,並將這些節點按順序收集進path數組中。tag===HostComponent的節點是DOM元素對應的的fiber節點類型,也就是說只收集DOM元素對應的節點。

按照demo中的fiber樹來講,最後收集到的節點爲div#containerdiv.Appbutton節點。

模擬捕獲順序,收集節點實例與回調函數

for (i = path.length; i-- > 0;) {
    fn(path[i], 'captured', arg);
  }
複製代碼

path數組是從子節點出發,向上收集得來的。因此,模擬事件的捕獲順序,須要從後往前遍歷path數組。在遍歷的過程當中,fn函數檢測每一個節點的事件回調,若該節點上對應當前事件的捕獲回調不爲空,那麼節點fiber實例會被收集到合成事件的SyntheticEvent._dispatchInstances中,事件回調則會被收集到合成事件的SyntheticEvent._dispatchListeners屬性。

模擬冒泡順序,收集節點實例與回調函數

for (i = 0; i < path.length; i++) {
    fn(path[i], 'bubbled', arg);
  }
複製代碼

這裏功能跟上一步一致,區別只是從前日後來遍歷path數組。

最後,咱們來看下SyntheticEvent對象上的_dispatchInstances_dispatchListeners

咱們只要按順序調用執行回調函數,就可以模擬出DOM事件流,也就是 「捕獲-目標-冒泡」這三個階段。

react17中對於事件系統的更新

上文的源碼解析是基於16.13.x版本的,17版本以後的事件系統,有了挺大的區別。

  • 1.事件系統改成掛載到React的根組件dom上
  • 2.onScroll事件再也不冒泡
  • 3.onFocusonBlur事件已在底層切換爲原生的focusinfocusout事件
  • 4.onClickCapture如今使用的是實際瀏覽器中的捕獲監聽器(合成事件只會存在listenToNonDelegatedEvent添加的冒泡事件)
  • 5.事件池SyntheticEvent再也不復用,在點擊事件中使用異步方法也將能夠獲取到點擊事件。不須要再使用e.persist()方法

感謝

若是本文對你有所幫助,請幫忙點個贊,感謝!

相關文章
相關標籤/搜索