深刻React合成事件機制原理

點擊進入React源碼調試倉庫。javascript

爲何要本身實現一套事件機制

因爲fiber機制的特色,生成一個fiber節點時,它對應的dom節點有可能還未掛載,onClick這樣的事件處理函數做爲fiber節點的prop,也就不能直接被綁定到真實的DOM節點上。
爲此,React提供了一種「頂層註冊,事件收集,統一觸發」的事件機制。java

所謂「頂層註冊」,實際上是在root元素上綁定一個統一的事件處理函數。「事件收集」指的是事件觸發時(其實是root上的事件處理函數被執行),構造合成事件對象,按照冒泡或捕獲的路徑去組件中收集真正的事件處理函數。「統一觸發」發生在收集過程以後,對所收集的事件逐一執行,並共享同一個合成事件對象。這裏有一個重點是綁定到root上的事件監聽並不是咱們寫在組件中的事件處理函數,注意這個區別,下文會提到。react

以上是React事件機制的簡述,這套機制規避了沒法將事件直接綁定到DOM節點上的問題,而且可以很好地利用fiber樹的層級關係來生成事件執行路徑,進而模擬事件捕獲和冒泡,另外還帶來兩個很是重要的特性:git

  • 對事件進行歸類,能夠在事件產生的任務上包含不一樣的優先級
  • 提供合成事件對象,抹平瀏覽器的兼容性差別

本文會對事件機制進行詳細講解,貫穿一個事件從註冊到被執行的生命週期。github

事件註冊

與以前版本不一樣,React17的事件是註冊到root上而非document,這主要是爲了漸進升級,避免多版本的React共存的場景中事件系統發生衝突。segmentfault

當咱們爲一個元素綁定事件時,會這樣寫:數組

<div onClick={() => {/*do something*/}}>React</div>

這個div節點最終要對應一個fiber節點,onClick則做爲它的prop。當這個fiber節點進入render階段的complete階段時,名稱爲onClick的prop會被識別爲事件進行處理。瀏覽器

function setInitialDOMProperties(
  tag: string,
  domElement: Element,
  rootContainerElement: Element | Document,
  nextProps: Object,
  isCustomComponentTag: boolean,
): void {
  for (const propKey in nextProps) {
    if (!nextProps.hasOwnProperty(propKey)) {
      ...
    } else if (registrationNameDependencies.hasOwnProperty(propKey)) {
        // 若是propKey屬於事件類型,則進行事件綁定
        ensureListeningTo(rootContainerElement, propKey, domElement);
      }
    }
  }
}
registrationNameDependencies是一個對象,存儲了全部React事件對應的原生DOM事件的集合,這是識別prop是否爲事件的依據。若是是事件類型的prop,那麼將會調用ensureListeningTo去綁定事件。

接下來的綁定過程能夠歸納爲以下幾個關鍵點:app

  • 根據React的事件名稱尋找該事件依賴,例如onMouseEnter事件依賴了mouseout和mouseover兩個原生事件,onClick只依賴了click一個原生事件,最終會循環這些依賴,在root上綁定對應的事件。例如組件中爲onClick,那麼就會在root上綁定一個click事件監聽。
  • 依據組件中寫的事件名識別其屬於哪一個階段的事件(冒泡或捕獲),例如onClickCapture這樣的React事件名稱就表明是須要事件在捕獲階段觸發,而onClick表明事件須要在冒泡階段觸發。
  • 根據React事件名,找出對應的原生事件名,例如click,並根據上一步來判斷是否須要在捕獲階段觸發,調用addEventListener,將事件綁定到root元素上。
  • 若事件須要更新,那麼先移除事件監聽,再從新綁定,綁定過程重複以上三步。

通過這一系列過程,事件監聽器listener最終被綁定到root元素上。dom

// 根據事件名稱,建立不一樣優先級的事件監聽器。
  let listener = createEventListenerWrapperWithPriority(
    targetContainer,
    domEventName,
    eventSystemFlags,
    listenerPriority,
  );

  // 綁定事件
  if (isCapturePhaseListener) {
    ...
    unsubscribeListener = addEventCaptureListener(
      targetContainer,
      domEventName,
      listener,
    );
  } else {
    ...
    unsubscribeListener = addEventBubbleListener(
      targetContainer,
      domEventName,
      listener,
    );

  }

事件監聽器listener是誰

上面提到的綁定事件的時候,綁定到root上的事件監聽函數是listener,然而這個listener並非咱們直接在組件裏寫的事件處理函數。經過上面的代碼可知,listener是createEventListenerWrapperWithPriority的調用結果

爲何要建立這麼一個listener,而不是直接綁定寫在組件裏的事件處理函數呢?

其實createEventListenerWrapperWithPriority這個函數名已經說出了答案:依據優先級建立一個事件監聽包裝器。有兩個重點:優先級事件監聽包裝器。這裏的優先級是指事件優先級(關於事件優先級的詳細介紹請移步React中的優先級 )。

事件優先級是根據事件的交互程度劃分的,優先級和事件名的映射關係存在於一個Map結構中。createEventListenerWrapperWithPriority會根據事件名或者傳入的優先級返回不一樣級別的事件監聽包裝器

總的來講,會有三種事件監聽包裝器:

  • dispatchDiscreteEvent: 處理離散事件
  • dispatchUserBlockingUpdate:處理用戶阻塞事件
  • dispatchEvent:處理連續事件

這些包裝器是真正綁定到root上的事件監聽器listener,它們持有各自的優先級,當對應的事件觸發時,調用的實際上是這個包含優先級的事件監聽。

listener

透傳事件執行階段標誌

到這裏咱們先梳理一下,root上綁定的是這個持有優先級的事件監聽,觸發它會使組件中真實的事件得以觸發。但到目前爲止有一點並未包括在內,也就是事件執行階段的區分。組件中註冊事件雖然能夠以事件名 + 「Capture」後綴的形式區分未來的執行階段,但這和真正執行事件實際上是兩回事,因此如今關鍵在於如何將註冊事件時顯式聲明的執行階段真正落實到執行事件的行爲上。

關於這一點咱們能夠關注createEventListenerWrapperWithPriority函數中的其中一個入參:eventSystemFlags。它是事件系統的一個標誌,記錄事件的各類標記,其中一個標記就是IS_CAPTURE_PHASE,這代表了當前的事件是捕獲階段觸發。當事件名含有Capture後綴時,eventSystemFlags會被賦值爲IS_CAPTURE_PHASE。

以後在以優先級建立綁定到root上的事件監聽時,eventSystemFlags會做爲它執行時的入參,傳遞進去。所以,在事件觸發的時候就能夠知道組件中的事件是以冒泡或是捕獲的順序執行。

function dispatchDiscreteEvent(
  domEventName,
  eventSystemFlags,
  container,
  nativeEvent,
) {
  ...
  discreteUpdates(
    dispatchEvent,
    domEventName,
    eventSystemFlags, // 傳入事件執行階段的標誌
    container,
    nativeEvent,
  );
}

小結

如今咱們應該能清楚兩點:

  1. 事件處理函數不是綁定到組件的元素上的,而是綁定到root上,這和fiber樹的結構特色有關,即事件處理函數只能做爲fiber的prop。
  2. 綁定到root上的事件監聽不是咱們在組件裏寫的事件處理函數,而是一個持有事件優先級,並能傳遞事件執行階段標誌的監聽器。

目前,註冊階段的工做已經完成,下面會講一講事件是如何被觸發的,讓咱們從綁定到root上的監聽器切入,看看它作了什麼。

事件觸發 - 事件監聽器listener作了什麼

它作的事情能夠用一句話歸納:負責以不一樣的優先級權重來觸發真正的事件流程,並傳遞事件執行階段標誌(eventSystemFlags)。

好比一個元素綁定了onClick事件,那麼點擊它的時候,綁定在root上的listener會被觸發,會最終使得組件中的事件被執行。

也就是說綁定到root上的事件監聽listener只是至關於一個傳令官,它按照事件的優先級去安排接下來的工做:事件對象的合成將事件處理函數收集到執行路徑事件執行,這樣在後面的調度過程當中,scheduler才能獲知當前任務的優先級,而後展開調度。

如何將優先級傳遞出去?

利用scheduler中的runWithPriority函數,經過調用它,將優先級記錄到利用scheduler中,因此調度器才能在調度的時候知道當前任務的優先級。runWithPriority的第二個參數,會去安排上面提到的三個工做。

以用戶阻塞的優先級級別爲例:

function dispatchUserBlockingUpdate(
  domEventName,
  eventSystemFlags,
  container,
  nativeEvent,
) {
    ...
    runWithPriority(
      UserBlockingPriority,
      dispatchEvent.bind(
        null,
        domEventName,
        eventSystemFlags,
        container,
        nativeEvent,
      ),
    );
}

dispatchUserBlockingUpdate調用runWithPriority,並傳入UserBlockingPriority優先級,這樣就能夠將UserBlockingPriority的優先級記錄到Scheduler中,後續React計算各類優先級都是基於這個UserBlockingPriority優先級。

除了傳遞優先級,它作的其它重要的事情就是觸發事件對象的合成將事件處理函數收集到執行路徑事件執行這三個過程,也就是到了事件的執行階段。root上的事件監聽最終觸發的是dispatchEventsForPlugins

這個函數體可當作兩部分:事件對象的合成和事件收集事件執行,涵蓋了上述三個過程。

function dispatchEventsForPlugins(
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
  nativeEvent: AnyNativeEvent,
  targetInst: null | Fiber,
  targetContainer: EventTarget,
): void {
  const nativeEventTarget = getEventTarget(nativeEvent);
  const dispatchQueue: DispatchQueue = [];

  // 事件對象的合成,收集事件到執行路徑上
  extractEvents(
    dispatchQueue,
    domEventName,
    targetInst,
    nativeEvent,
    nativeEventTarget,
    eventSystemFlags,
    targetContainer,
  );

  // 執行收集到的組件中真正的事件
  processDispatchQueue(dispatchQueue, eventSystemFlags);
}

dispatchEventsForPlugins函數中事件的流轉有一個重要的載體:dispatchQueue,它承載了本次合成的事件對象和收集到事件執行路徑上的事件處理函數。

dispatchQueue

listeners是事件執行路徑,event是合成事件對象,收集組件中真正的事件到執行路徑,以及事件對象的合成經過extractEvents實現。

事件對象的合成和事件的收集

到這裏咱們應該清楚,root上的事件監聽被觸發會引起事件對象的合成和事件的收集過程,這是爲真正的事件觸發作準備。

合成事件對象

在組件中的事件處理函數中拿到的事件對象並非原生的事件對象,而是通過React合成的SyntheticEvent對象。它解決了不一樣瀏覽器之間的兼容性差別。抽象成統一的事件對象,解除開發者的心智負擔。

事件執行路徑

當事件對象合成完畢,會將事件收集到事件執行路徑上。什麼是事件執行路徑呢?

在瀏覽器的環境中,若父子元素綁定了相同類型的事件,除非手動干預,那麼這些事件都會按照冒泡或者捕獲的順序觸發。

在React中也是如此,從觸發事件的元素開始,依據fiber樹的層級結構向上查找,累加上級元素中全部相同類型的事件,最終造成一個具備全部相同類型事件的數組,這個數組就是事件執行路徑。經過這個路徑,React本身模擬了一套事件捕獲與冒泡的機制。

下圖是事件對象的包裝和收集事件(冒泡的路徑爲例)的大體過程

件對象的包裝和收集事件到執行路徑(冒泡的路徑爲例)

由於不一樣的事件會有不一樣的行爲和處理機制,因此合成事件對象的構造和收集事件到執行路徑須要經過插件實現。一共有5種Plugin:SimpleEventPlugin,EnterLeaveEventPlugin,ChangeEventPlugin,SelectEventPlugin,BeforeInputEventPlugin。它們的使命徹底同樣,只是處理的事件類別不一樣,因此內部會有一些差別。本文只以SimpleEventPlugin爲例來說解這個過程,它處理比較通用的事件類型,好比click、input、keydown等。

如下是SimpleEventPlugin中構造合成事件對象並收集事件的代碼。

function extractEvents(
  dispatchQueue: DispatchQueue,
  domEventName: DOMEventName,
  targetInst: null | Fiber,
  nativeEvent: AnyNativeEvent,
  nativeEventTarget: null | EventTarget,
  eventSystemFlags: EventSystemFlags,
  targetContainer: EventTarget,
): void {
  const reactName = topLevelEventsToReactNames.get(domEventName);
  if (reactName === undefined) {
    return;
  }
  let EventInterface;
  switch (domEventName) {
    // 賦值EventInterface(接口)
  }

  // 構造合成事件對象
  const event = new SyntheticEvent(
    reactName,
    null,
    nativeEvent,
    nativeEventTarget,
    EventInterface,
  );

  const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;

  if (/*...*/) {
    ...
  } else {
    // scroll事件不冒泡
    const accumulateTargetOnly =
      !inCapturePhase &&
      domEventName === 'scroll';

    // 事件對象分發 & 收集事件
    accumulateSinglePhaseListeners(
      targetInst,
      dispatchQueue,
      event,
      inCapturePhase,
      accumulateTargetOnly,
    );
  }
  return event;
}

建立合成事件對象

這個統一的事件對象由SyntheticEvent函數構造而成,它本身遵循W3C的規範又實現了一遍瀏覽器的事件對象接口,這樣能夠抹平差別,而原生的事件對象只不過是它的一個屬性(nativeEvent)。

// 構造合成事件對象
  const event = new SyntheticEvent(
    reactName,
    null,
    nativeEvent,
    nativeEventTarget,
    EventInterface,
  );

收集事件到執行路徑

這個過程是將組件中真正的事件處理函數收集到數組中,等待下一步的批量執行。

先看一個例子,目標元素是counter,父級元素是counter-parent。

class EventDemo extends React.Component{
  state = { count: 0 }
  onDemoClick = () => {
    console.log('counter的點擊事件被觸發了');
    this.setState({
      count: this.state.count + 1
    })
  }
  onParentClick = () => {
    console.log('父級元素的點擊事件被觸發了');
  }
  render() {
    const { count } = this.state
    return <div
      className={'counter-parent'}
      onClick={this.onParentClick}
    >
      <div
        onClick={this.onDemoClick}
        className={'counter'}
      >
        {count}
      </div>
    </div>
  }
}

當點擊counter時,父元素上的點擊事件也會被觸發,相繼打印出:

'counter的點擊事件被觸發了'
'父級元素的點擊事件被觸發了'

實際上這是將事件以冒泡的順序收集到執行路徑以後致使的。收集的過程由accumulateSinglePhaseListeners完成。

accumulateSinglePhaseListeners(
  targetInst,
  dispatchQueue,
  event,
  inCapturePhase,
  accumulateTargetOnly,
);

函數內部最重要的操做無疑是收集事件到執行路徑,爲了實現這一操做,須要在fiber樹中從觸發事件的源fiber節點開始,向上一直找到root,造成一條完整的冒泡或者捕獲的路徑。同時,沿途路過fiber節點時,根據事件名,從props中獲取咱們真正寫在組件中的事件處理函數,push到路徑中,等待下一步的批量執行。

下面是該過程精簡後的源碼

export function accumulateSinglePhaseListeners(
  targetFiber: Fiber | null,
  dispatchQueue: DispatchQueue,
  event: ReactSyntheticEvent,
  inCapturePhase: boolean,
  accumulateTargetOnly: boolean,
): void {

  // 根據事件名來識別是冒泡階段的事件仍是捕獲階段的事件
  const bubbled = event._reactName;
  const captured = bubbled !== null ? bubbled + 'Capture' : null;

  // 聲明存放事件監聽的數組
  const listeners: Array<DispatchListener> = [];

  // 找到目標元素
  let instance = targetFiber;

  // 從目標元素開始一直到root,累加全部的fiber對象和事件監聽。
  while (instance !== null) {
    const {stateNode, tag} = instance;

    if (tag === HostComponent && stateNode !== null) {
      const currentTarget = stateNode;

      // 事件捕獲
      if (captured !== null && inCapturePhase) {
        // 從fiber中獲取事件處理函數
        const captureListener = getListener(instance, captured);
        if (captureListener != null) {
          listeners.push(
            createDispatchListener(instance, captureListener, currentTarget),
          );
        }
      }

      // 事件冒泡
      if (bubbled !== null && !inCapturePhase) {
        // 從fiber中獲取事件處理函數
        const bubbleListener = getListener(instance, bubbled);
        if (bubbleListener != null) {
          listeners.push(
            createDispatchListener(instance, bubbleListener, currentTarget),
          );
        }
      }
    }
    instance = instance.return;
  }
  // 收集事件對象
  if (listeners.length !== 0) {
    dispatchQueue.push(createDispatchEntry(event, listeners));
  }
}
不管事件是在冒泡階段執行,仍是捕獲階段執行,都以一樣的順序push到dispatchQueue的listeners中,而冒泡或者捕獲事件的執行順序不一樣是因爲清空listeners數組的順序不一樣。

注意,每次收集只會收集與事件源相同類型的事件,好比子元素綁定了onClick,父元素綁定了onClick和onClickCapture:

<div
  className="parent"
  onClick={onClickParent}
  onClickCapture={onClickParentCapture}
>
  父元素

  <div
    className="child"
    onClick={onClickChild}
   >
     子元素
   </div>
</div>

那麼點擊子元素時,收集的將是onClickChildonClickParent

收集的結果以下

事件路徑

合成事件對象如何參與到事件執行過程

上面咱們說過,dispatchQueue的結構以下面這樣

[
  {
    event: SyntheticEvent,
    listeners: [ listener1, listener2, ... ]
  }
]

event就表明着合成事件對象,能夠將它認爲是這些listeners共享的一個事件對象。當清空listeners數組執行到每個事件監聽函數時,這個事件監聽能夠改變event上的currentTarget,也能夠調用它上面的stopPropagation方法來阻止冒泡。event做爲一個共享資源被這些事件監聽消費,消費的行爲發生在事件執行時。

事件執行

通過事件和事件對象收集的過程,獲得了一條完整的事件執行路徑,還有一個被共享的事件對象,以後進入到事件執行過程,從頭至尾循環該路徑,依次調用每一項中的監聽函數。這個過程的重點在於事件冒泡和捕獲的模擬,以及合成事件對象的應用,以下是從dispatchQueue中提取出事件對象和時間執行路徑的過程。

export function processDispatchQueue(
  dispatchQueue: DispatchQueue,
  eventSystemFlags: EventSystemFlags,
): void {
  const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
  for (let i = 0; i < dispatchQueue.length; i++) {

    // 從dispatchQueue中取出事件對象和事件監聽數組
    const {event, listeners} = dispatchQueue[i];

    // 將事件監聽交由processDispatchQueueItemsInOrder去觸發,同時傳入事件對象供事件監聽使用
    processDispatchQueueItemsInOrder(event, listeners, inCapturePhase);
  }
  // 捕獲錯誤
  rethrowCaughtError();
}

模擬冒泡和捕獲

冒泡和捕獲的執行順序是不同的,可是當初在收集事件的時候,不管是冒泡仍是捕獲,事件都是直接push到路徑裏的。那麼執行順序的差別是如何體現的呢?答案是循環路徑的順序不同致使了執行順序有所不一樣。

首先回顧一下dispatchQueue中的listeners中的事件處理函數排列順序:觸發事件的目標元素的事件處理函數排在第一個,上層組件的事件處理函數依次日後排。

<div onClick={onClickParent}>
  父元素
  <div onClick={onClickChild}>
     子元素
  </div>
</div>
listeners: [ onClickChild, onClickParent ]

從左往右循環的時候,目標元素的事件先觸發,父元素事件依次執行,這與冒泡的順序同樣,那捕獲的順序天然是從右往左循環了。模擬冒泡和捕獲執行事件的代碼以下:

其中判斷事件執行階段的依據inCapturePhase,它的來源在上面的透傳透傳事件執行階段標誌的內容裏已經提到過。

function processDispatchQueueItemsInOrder(
  event: ReactSyntheticEvent,
  dispatchListeners: Array<DispatchListener>,
  inCapturePhase: boolean,
): void {
  let previousInstance;

  if (inCapturePhase) {
    // 事件捕獲倒序循環
    for (let i = dispatchListeners.length - 1; i >= 0; i--) {
      const {instance, currentTarget, listener} = dispatchListeners[i];
      if (instance !== previousInstance && event.isPropagationStopped()) {
        return;
      }
      // 執行事件,傳入event對象,和currentTarget
      executeDispatch(event, listener, currentTarget);
      previousInstance = instance;
    }
  } else {
    // 事件冒泡正序循環
    for (let i = 0; i < dispatchListeners.length; i++) {
      const {instance, currentTarget, listener} = dispatchListeners[i];
      // 若是事件對象阻止了冒泡,則return掉循環過程
      if (instance !== previousInstance && event.isPropagationStopped()) {
        return;
      }
      executeDispatch(event, listener, currentTarget);
      previousInstance = instance;
    }
  }
}

至此,咱們寫在組件中的事件處理函數就被執行掉了,合成事件對象在這個過程當中充當了一個公共角色,每一個事件執行時,都會檢查合成事件對象,有沒有調用阻止冒泡的方法,另外會將當前掛載事件監聽的元素做爲currentTarget掛載到事件對象上,最終傳入事件處理函數,咱們得以獲取到這個事件對象。

總結

源碼中事件系統的代碼量很大,我能活着出來主要是帶着這幾個問題去看的代碼:綁定事件的過程是怎麼樣的、事件系統和優先級的聯繫、真正的事件處理函數到底如何執行的。

總結一下事件機制的原理:因爲fiber樹的特色,一個組件若是含有事件的prop,那麼將會在對應fiber節點的commit階段綁定一個事件監聽到root上,這個事件監聽是持有優先級的,這將它和優先級機制聯繫了起來,能夠把合成事件機制看成一個協調者,負責去協調合成事件對象、收集事件、觸發真正的事件處理函數這三個過程。

歡迎掃碼關注公衆號,發現更多技術文章

相關文章
相關標籤/搜索