深入理解React:事件機制原理

forcheng發表於2020-06-24

目錄

  • 序言
  • DOM事件流
    • 事件捕獲階段、處於目標階段、事件冒泡階段
    • addEventListener 方法
  • React 事件概述
  • 事件註冊
    • document 上註冊
    • 回撥函式儲存
  • 事件分發
  • 小結
  • 參考

1.序言

React 有一套自己的事件系統,其事件叫做合成事件。為什麼 React 要自定義一套事件系統?React 事件是如何註冊和觸發的?React 事件與原生 DOM 事件有什麼區別?帶著這些問題,讓我們一起來探究 React 事件機制的原理。為了便於理解,此篇分析將盡可能用圖解代替貼 React 原始碼進行解析。


2.DOM事件流

首先,在正式講解 React 事件之前,有必要了解一下 DOM 事件流,其包含三個流程:事件捕獲階段、處於目標階段和事件冒泡階段。

W3C協會早在1988年就開始了DOM標準的制定,W3C DOM標準可以分為 DOM1、DOM2、DOM3 三個版本。

從 DOM2 開始,DOM 的事件傳播分三個階段進行:事件捕獲階段、處於目標階段和事件冒泡階段。


(1)事件捕獲階段、處於目標階段和事件冒泡階段

示例程式碼:

<html>
    <body>
        <div id="outer">
	    <p id="inner">Click me!</p>
	</div>
    </body>
</html>

上述程式碼,如果點選 <p>元素,那麼 DOM 事件流如下圖:

(1)事件捕獲階段:事件物件通過目標節點的祖先 Window 傳播到目標的父節點。

(2)處於目標階段:事件物件到達事件目標節點。如果阻止事件冒泡,那麼該事件物件將在此階段完成後停止傳播。

(3)事件冒泡階段:事件物件以相反的順序從目標節點的父項開始傳播,從目標節點的父項開始到 Window 結束。


(2)addEventListener 方法

DOM 的事件流中同時包含了事件捕獲階段和事件冒泡階段,而作為開發者,我們可以選擇事件處理函式在哪一個階段被呼叫。


addEventListener() 方法用於為特定元素繫結一個事件處理函式。addEventListener 有三個引數:

element.addEventListener(event, function, useCapture)


另外,如果一個元素(element)針對同一個事件型別(event),多次繫結同一個事件處理函式(function),那麼重複的例項會被拋棄。當然如果第三個引數capture值不一致,此時就算重複定義,也不會被拋棄掉。


3.React 事件概述

React 根據W3C 規範來定義自己的事件系統,其事件被稱之為合成事件 (SyntheticEvent)。而其自定義事件系統的動機主要包含以下幾個方面:

(1)抹平不同瀏覽器之間的相容性差異。最主要的動機。

(2)事件"合成",即事件自定義。事件合成既可以處理相容性問題,也可以用來自定義事件(例如 React 的 onChange 事件)。

(3)提供一個抽象跨平臺事件機制。類似 VirtualDOM 抽象了跨平臺的渲染方式,合成事件(SyntheticEvent)提供一個抽象的跨平臺事件機制。

(4)可以做更多優化。例如利用事件委託機制,幾乎所有事件的觸發都代理到了 document,而不是 DOM 節點本身,簡化了 DOM 事件處理邏輯,減少了記憶體開銷。(React 自身模擬了一套事件冒泡的機制)

(5)可以干預事件的分發。V16引入 Fiber 架構,React 可以通過干預事件的分發以優化使用者的互動體驗。


注:「幾乎」所有事件都代理到了 document,說明有例外,比如audiovideo標籤的一些媒體事件(如 onplay、onpause 等),是 document 所不具有,這些事件只能夠在這些標籤上進行事件進行代理,但依舊用統一的入口分發函式(dispatchEvent)進行繫結。


4.事件註冊

React 的事件註冊過程主要做了兩件事:document 上註冊、儲存事件回撥。


(1)document 上註冊

在 React 元件掛載階段,根據元件內的宣告的事件型別(onclick、onchange 等),在 document 上註冊事件(使用addEventListener),並指定統一的回撥函式 dispatchEvent。換句話說,document 上不管註冊的是什麼事件,都具有統一的回撥函式 dispatchEvent。也正是因為這一事件委託機制,具有同樣的回撥函式 dispatchEvent,所以對於同一種事件型別,不論在 document 上註冊了幾次,最終也只會保留一個有效例項,這能減少記憶體開銷。


示例程式碼:

function TestComponent() {
  handleFatherClick=()=>{
		// ...
  }

  handleChildClick=()=>{
		// ...
  }

  return <div className="father" onClick={this.handleFatherClick}>
	<div className="child" onClick={this.handleChildClick}>child </div>
  </div>
}

上述程式碼中,事件型別都是onclick,由於 React 的事件委託機制,會指定統一的回撥函式 dispatchEvent,所以最終只會在 document 上保留一個 click 事件,類似document.addEventListener('click', dispatchEvent),從這裡也可以看出 React 的事件是在 DOM 事件流的冒泡階段被觸發執行。


(2)儲存事件回撥

React 為了在觸發事件時可以查詢到對應的回撥去執行,會把元件內的所有事件統一地存放到一個物件中(listenerBank)。而儲存方式如上圖,首先會根據事件型別分類儲存,例如 click 事件相關的統一儲存在一個物件中,回撥函式的儲存採用鍵值對(key/value)的方式儲存在物件中,key 是元件的唯一標識 id,value 對應的就是事件的回撥函式。


React 的事件註冊的關鍵步驟如下圖:


5.事件分發

事件分發也就是事件觸發。React 的事件觸發只會發生在 DOM 事件流的冒泡階段,因為在 document 上註冊時就預設是在冒泡階段被觸發執行。


其大致流程如下:

  1. 觸發事件,開始 DOM 事件流,先後經過三個階段:事件捕獲階段、處於目標階段和事件冒泡階段
  2. 當事件冒泡到 document 時,觸發統一的事件分發函式 ReactEventListener.dispatchEvent
  3. 根據原生事件物件(nativeEvent)找到當前節點(即事件觸發節點)對應的 ReactDOMComponent 物件
  4. 事件的合成
    1. 根據當前事件型別生成對應的合成物件
    2. 封裝原生事件物件和冒泡機制
    3. 查詢當前元素以及它所有父級
    4. 在 listenerBank 中查詢事件回撥函式併合成到 events 中
  5. 批量執行合成事件(events)內的回撥函式
  6. 如果沒有阻止冒泡,會將繼續進行 DOM 事件流的冒泡(從 document 到 window),否則結束事件觸發

注:上圖中阻止冒泡是指呼叫stopImmediatePropagation 方法阻止冒泡,如果是呼叫stopPropagation阻止冒泡,document 上如果還註冊了同型別其他的事件,也將會被觸發執行,但會正常阻斷 window 上事件觸發。瞭解兩者之間的詳細區別


示例程式碼:

class TestComponent extends React.Component {

  componentDidMount() {
    this.parent.addEventListener('click', (e) => {
      console.log('dom parent');
    })
    this.child.addEventListener('click', (e) => {
      console.log('dom child');
    })
    document.addEventListener('click', (e) => {
      console.log('document');
    })
    document.body.addEventListener('click', (e) => {
      console.log('body');
    })
    window.addEventListener('click', (e) => {
      console.log('window');
    })
  }

  childClick = (e) => {
    console.log('react child');
  }

  parentClick = (e) => {
    console.log('react parent');
  }

  render() {
    return (
      <div class='parent' onClick={this.parentClick} ref={ref => this.parent = ref}>
        <div class='child' onClick={this.childClick} ref={ref => this.child = ref}>
          Click me!
        </div>
      </div>)
  }
}

點選 child div 時,其輸出如下:

在 DOM 事件流的冒泡階段先後經歷的元素:child <div> -> parent <div> -> <body> -> <html> -> document -> window,因此上面的輸出符合預期。


6.小結

React 合成事件和原生 DOM 事件的主要區別:

(1)React 元件上宣告的事件沒有繫結在 React 元件對應的原生 DOM 節點上。

(2)React 利用事件委託機制,將幾乎所有事件的觸發代理(delegate)在 document 節點上,事件物件(event)是合成物件(SyntheticEvent),不是原生事件物件,但通過 nativeEvent 屬性訪問原生事件物件。

(3)由於 React 的事件委託機制,React 元件對應的原生 DOM 節點上的事件觸發時機總是在 React 元件上的事件之前。


7.參考

javascript中DOM0,DOM2,DOM3級事件模型解析

Event dispatch and DOM event flow

EventTarget.addEventListener() - Web API 介面參考| MDN

合成事件

談談React事件機制和未來(react-events)

React原始碼解讀系列 – 事件機制

一文吃透 react 事件機制原理

相關文章