React 事件系統工做原理

題圖

本文做者: 江水

前言

React 爲咱們提供了一套虛擬的事件系統,這套虛擬事件系統是如何工做的,筆者對源碼作了一次梳理,整理了下面的文檔供你們參考。html

React事件介紹 中介紹了合成事件對象以及爲何提供合成事件對象,主要緣由是由於 React 想實現一個全瀏覽器的框架, 爲了實現這種目標就須要提供全瀏覽器一致性的事件系統,以此抹平不一樣瀏覽器的差別。前端

合成事件對象頗有意思,一開始聽名字會以爲很奇怪,看到英文名更奇怪 SyntheticEvent, 實際上合成事件的意思就是使用原生事件合成一個 React 事件, 例如使用原生click事件合成了onClick事件,使用原生mouseout事件合成了onMouseLeave事件,原生事件和合成事件類型大部分都是一一對應,只有涉及到兼容性問題時咱們才須要使用不對應的事件合成。合成事件並非 React 的獨創,在 iOS 上遇到的 300ms 問題而引入的 fastclick 就使用了 touch 事件合成了 click 事件,也算一種合成事件的應用。react

瞭解了 React 事件是合成事件以後咱們看待事件的角度就會有所不一樣, 例如咱們常常在代碼中寫的這種代碼git

<button onClick={handleClick}>
 Activate Lasers
</button>

咱們已經知道這個onClick只是一個合成事件而不是原生事件, 那這段時間究竟發生了什麼? 原生事件和合成事件是如何對應起來的?
上面的代碼看起來很簡潔,實際上 React 事件系統工做機制比起上面要複雜的多,髒活累活全都在底層處理了, 簡直框架勞模。其工做原理大致上分爲兩個階段github

  1. 事件綁定
  2. 事件觸發

下面就一塊兒來看下這兩個階段到底是如何工做的, 這裏主要從源碼層分析,並以 16.13 源碼中內容爲基準。數組

1. React 是如何綁定事件的 ?

React 既然提供了合成事件,就須要知道合成事件與原生事件是如何對應起來的,這個對應關係存放在 React 事件插件中EventPlugin, 事件插件能夠認爲是 React 將不一樣的合成事件處理函數封裝成了一個模塊,每一個模塊只處理本身對應的合成事件,這樣不一樣類型的事件種類就能夠在代碼上解耦,例如針對onChange事件有一個單獨的LegacyChangeEventPlugin插件來處理,針對onMouseEnteronMouseLeave 使用 LegacyEnterLeaveEventPlugin 插件來處理。瀏覽器

爲了知道合成事件與原生事件的對應關係,React 在一開始就將事件插件所有加載進來, 這部分邏輯在 ReactDOMClientInjection 代碼以下架構

injectEventPluginsByName({
 SimpleEventPlugin: LegacySimpleEventPlugin,
 EnterLeaveEventPlugin: LegacyEnterLeaveEventPlugin,
 ChangeEventPlugin: LegacyChangeEventPlugin,
 SelectEventPlugin: LegacySelectEventPlugin,
 BeforeInputEventPlugin: LegacyBeforeInputEventPlugin
});

註冊完上述插件後, EventPluginRegistry (老版本代碼裏這個模塊喚做EventPluginHub)這個模塊裏就初始化好了一些全局對象,有幾個對象比較重要,能夠單獨說一下。app

第一個對象是 registrationNameModule, 它包含了 React 事件到它對應的 plugin 的映射, 大體長下面這樣,它包含了 React 所支持的全部事件類型,這個對象最大的做用是判斷一個組件的 prop 是不是事件類型,這在處理原生組件的 props 時候將會用到,若是一個 prop 在這個對象中才會被當作事件處理。框架

{
 onBlur: SimpleEventPlugin,
 onClick: SimpleEventPlugin,
 onClickCapture: SimpleEventPlugin,
 onChange: ChangeEventPlugin,
 onChangeCapture: ChangeEventPlugin,
 onMouseEnter: EnterLeaveEventPlugin,
 onMouseLeave: EnterLeaveEventPlugin,
 ...
}

第二個對象是 registrationNameDependencies, 這個對象長下面幾個樣子

{
 onBlur: ['blur'],
 onClick: ['click'],
 onClickCapture: ['click'],
 onChange: ['blur', 'change', 'click', 'focus', 'input', 'keydown', 'keyup', 'selectionchange'],
 onMouseEnter: ['mouseout', 'mouseover'],
 onMouseLeave: ['mouseout', 'mouseover'],
 ...
}

這個對象便是一開始咱們說到的合成事件到原生事件的映射,對於onClickonClickCapture事件, 只依賴原生click事件。可是對於 onMouseLeave它倒是依賴了兩個mouseoutmouseover, 這說明這個事件是 React 使用 mouseoutmouseover 模擬合成的。正是由於這種行爲,使得 React 可以合成一些哪怕瀏覽器不支持的事件供咱們代碼裏使用。

第三個對象是 plugins, 這個對象就是上面註冊的全部插件列表。

plugins = [LegacySimpleEventPlugin, LegacyEnterLeaveEventPlugin, ...];

看完上面這些信息後咱們再反過頭來看下一個普通的EventPlugin長什麼樣子。一個 plugin 就是一個對象, 這個對象包含了下面兩個屬性

// event plugin
{
 eventTypes, // 一個數組,包含了全部合成事件相關的信息,包括其對應的原生事件關係
 extractEvents: // 一個函數,當原生事件觸發時執行這個函數
}

瞭解上面這這些信息對咱們分析 React 事件工做原理將會頗有幫助,下面開始進入事件綁定階段。

  1. React 執行 diff 操做,標記出哪些 DOM 類型 的節點須要添加或者更新。

  1. 當檢測到須要建立一個節點或者更新一個節點時, 使用 registrationNameModule 查看一個 prop 是否是一個事件類型,若是是則執行下一步。

  1. 經過 registrationNameDependencies 檢查這個 React 事件依賴了哪些原生事件類型

  1. 檢查這些一個或多個原生事件類型有沒有註冊過,若是有則忽略。

  1. 若是這個原生事件類型沒有註冊過,則註冊這個原生事件到 document 上,回調爲React提供的dispatchEvent函數。


上面的階段說明:

  1. 咱們將全部事件類型都註冊到 document 上。
  2. 全部原生事件的 listener 都是dispatchEvent函數。
  3. 同一個類型的事件 React 只會綁定一次原生事件,例如不管咱們寫了多少個onClick, 最終反應在 DOM 事件上只會有一個listener
  4. React 並無將咱們業務邏輯裏的listener綁在原生事件上,也沒有去維護一個相似eventlistenermap的東西存放咱們的listener

由 3,4 條規則能夠得出,咱們業務邏輯的listener和實際 DOM 事件壓根就不要緊,React 只是會確保這個原生事件可以被它本身捕捉到,後續由 React 來派發咱們的事件回調,當咱們頁面發生較大的切換時候,React 能夠什麼都不作,從而免去了去操做removeEventListener或者同步eventlistenermap的操做,因此其執行效率將會大大提升,至關於全局給咱們作了一次事件委託,即使是渲染大列表,也不用開發者關心事件綁定問題。

2. React 是如何觸發事件的?

咱們知道因爲全部類型種類的事件都是綁定爲React的 dispatchEvent 函數,因此就能在全局處理一些通用行爲,下面就是整個行爲過程。

export function dispatchEventForLegacyPluginEventSystem(
 topLevelType: DOMTopLevelEventType,
 eventSystemFlags: EventSystemFlags,
 nativeEvent: AnyNativeEvent,
 targetInst: null | Fiber,
): void {
 const bookKeeping = getTopLevelCallbackBookKeeping(
 topLevelType,
 nativeEvent,
 targetInst,
 eventSystemFlags
 );
 try {
 // Event queue being processed in the same cycle allows
 // `preventDefault`.
 batchedEventUpdates(handleTopLevel, bookKeeping);
 } finally {
 releaseTopLevelCallbackBookKeeping(bookKeeping);
 }
}

bookKeeping爲事件執行時組件的層級關係存儲,也就是若是在事件執行過程當中發生組件結構變動,並不會影響事件的觸發流程。
整個觸發事件流程以下:

  1. 任意一個事件觸發,執行 dispatchEvent 函數。
  2. dispatchEvent 執行 batchedEventUpdates(handleTopLevel)batchedEventUpdates 會打開批量渲染開關並調用 handleTopLevel
  3. handleTopLevel 會依次執行 plugins 裏全部的事件插件。
  4. 若是一個插件檢測到本身須要處理的事件類型時,則處理該事件。

對於大部分事件而言其處理邏輯以下,也即 LegacySimpleEventPlugin 插件作的工做

  1. 經過原生事件類型決定使用哪一個合成事件類型(原生 event 的封裝對象,例如 SyntheticMouseEvent) 。
  2. 若是對象池裏有這個類型的實例,則取出這個實例,覆蓋其屬性,做爲本次派發的事件對象(事件對象複用),若沒有則新建一個實例。

  1. 從點擊的原生事件中找到對應 DOM 節點,從 DOM 節點中找到一個最近的React組件實例, 從而找到了一條由這個實例父節點不斷向上組成的鏈, 這個鏈就是咱們要觸發合成事件的鏈,(只包含原生類型組件, diva 這種原生組件)。

  1. 反向觸發這條鏈,父-> 子,模擬捕獲階段,觸發全部 props 中含有 onClickCapture 的實例。

  1. 正向觸發這條鏈,子-> 父,模擬冒泡階段,觸發全部 props 中含有 onClick 的實例。


這幾個階段說明了下面的現象:

  1. React 的合成事件只能在事件週期內使用,由於這個對象極可能被其餘階段複用, 若是想持久化須要手動調用event.persist() 告訴 React 這個對象須要持久化。( React17 中被廢棄)
  2. React 的冒泡和捕獲並非真正 DOM 級別的冒泡和捕獲
  3. React 會在一個原生事件裏觸發全部相關節點的 onClick 事件, 在執行這些onClick以前 React 會打開批量渲染開關,這個開關會將全部的setState變成異步函數。
  4. 事件只針對原生組件生效,自定義組件不會觸發 onClick

3. 從React 的事件系統中咱們學到了什麼

  1. React16 將原生事件都綁定在 document 上.

這點很好理解,React的事件實際上都是在document上觸發的。

  1. 咱們收到的 event 對象爲 React 合成事件, event 對象在事件以外不可使用

因此下面就是錯誤用法

function onClick(event) {
 setTimeout(() => {
 console.log(event.target.value);
 }, 100);
}
  1. React 會在派發事件時打開批量更新, 此時全部的 setState 都會變成異步。
function onClick(event) {
 setState({a: 1}); // 1
 setState({a: 2}); // 2
 setTimeout(() => {
 setState({a: 3}); // 3
 setState({a: 4}); // 4
 }, 0);
}

此時 1, 2 在事件內因此是異步的,兩者只會觸發一次 render 操做,3, 4 是同步的,3,4 分別都會觸發一次 render。

  1. React onClick/onClickCapture, 實際上都發生在原生事件的冒泡階段。
document.addEventListener('click', console.log.bind(null, 'native'));
function onClickCapture() {
 console.log('capture');
}
<div onClickCapture={onClickCapture}/>

這裏咱們雖然使用了onClickCapture, 但實際上對原生事件而言依然是冒泡,因此 React 16 中實際上就不支持綁定捕獲事件。

  1. 因爲全部事件都註冊到頂層事件上,因此多實個 ReactDOM.render 會存在衝突。

若是咱們渲染一個子樹使用另外一個版本的 React 實例建立, 那麼即便在子樹中調用了 e.stopPropagatio 事件依然會傳播。因此多版本的 React 在事件上存在衝突。
最後咱們就能夠輕鬆理解 React 事件系統的架構圖了

4. React 17 中事件系統有哪些新特性

React 17 目前已經發布了, 官方稱之爲沒有新特性的更新, 對於使用者而言沒有提供相似 Hooks 這樣爆炸的特性,也沒有 Fiber 這樣的重大重構,而是積攢了大量 Bugfix,修復了以前存在的諸多缺陷。其中變化最大的就數對事件系統的改造了。
下面是筆者列舉的一些事件相關的特性更新

調整將頂層事件綁在container上,ReactDOM.render(app, container);

react_17_delegation
將頂層事件綁定在 container 上而不是 document 上可以解決咱們遇到的多版本共存問題,對微前端方案是個重大利好。

對齊原生瀏覽器事件

React 17 中終於支持了原生捕獲事件的支持, 對齊了瀏覽器原生標準。
同時onScroll 事件再也不進行事件冒泡。
onFocusonBlur 使用原生 focusinfocusout 合成。

Aligning with Browsers
We’ve made a couple of smaller changes related to the event system:
The onScroll event no longer bubbles to prevent common confusion.
React onFocus and onBlur events have switched to using the native focusin and focusout events under the hood, which more closely match React’s existing behavior and sometimes provide extra information.
Capture phase events (e.g. onClickCapture) now use real browser capture phase listeners.

取消事件複用


官方的解釋是事件對象的複用在現代瀏覽器上性能已經提升的不明顯了,反而還很容易讓人用錯,因此乾脆就放棄這個優化。
參考

  1. https://reactjs.org/docs/even...
  2. https://reactjs.org/docs/hand...
  3. https://github.com/facebook/r...
本文發佈自 網易雲音樂大前端團隊,文章未經受權禁止任何形式的轉載。咱們常年招收前端、iOS、Android,若是你準備換工做,又剛好喜歡雲音樂,那就加入咱們 grp.music-fe(at)corp.netease.com!
相關文章
相關標籤/搜索