動畫淺析React事件系統和源碼

抱歉在掘金頁內連接失效,後續會改正html

查看原文前端

TL;DR

本文經過對React事件系統和源碼進行淺析,回答「爲何React須要本身實現一套事件系統?」和「React的事件系統是怎麼運做起來的?」兩個問題。React爲了性能和複用,採用了事件代理,池,批量更新,跨瀏覽器和跨平臺兼容等思想,將事件監聽掛載在document上,構造合成事件,而且在內部模擬了一套捕獲和冒泡並觸發回調函數的機制,實現了本身的一套事件系統。react

動圖

  • 若是你只有幾分鐘,建議你直接看動畫部分
  • 若是你有半個小時,你能夠按順序往下閱讀,忽略源碼部分。
  • 若是你對React事件系統有較大的興趣,那麼推薦你clone一份React的源碼(本文列舉的源碼來自v16.5.0),而後按照順序依次往下閱讀。

開始

最近在使用React對項目前端進行重構的時候,本身和同事遇到了一些奇怪的問題。因此花了一些時間對React源碼進行了研究,此篇的主題爲React事件系統,儘可能剔除複雜的技術細節,但願能以簡單直觀的方式回答兩個問題,分別是**「爲何React須要本身實現一套事件系統?」「React的事件系統是怎麼運做起來的?」**。git

Stuff can sometimes get surprisingly messy if you don’t know how it works…github

兩個簡單的例子

例子一

  1. 根據下面代碼,點擊按鈕以後,輸出結果會是什麼?(ABCD排序)web

  2. 若是我把innerClick中的e.stopPropagation()加上,輸出結果又會是什麼?(ABCD排序)算法

Edit React事件冒泡例子
class App extends React.Component {
  innerClick = e => {
    console.log("A: react inner click.");
    // e.stopPropagation();
  };

  outerClick = () => {
    console.log("B: react outer click.");
  };

  componentDidMount() {
    document
      .getElementById("outer")
      .addEventListener("click", () => console.log("C: native outer click"));

    window.addEventListener("click", () =>
      console.log("D: native window click")
    );
  }

  render() {
    return (
      <div id="outer" onClick={this.outerClick}> <button id="inner" onClick={this.innerClick}> BUTTON </button> </div>
    );
  }
}
複製代碼

正確答案是(防止大家偷看,請向左滑動 <—— ):shell

1. 
                                                                                                C: native outer click 
                                                                                                A: react inner click. 
                                                                                                B: react outer click. 
                                                                                                D: native window click 
                                                                                            2.
                                                                                                C: native outer click 
                                                                                                A: react inner click.
複製代碼

例子二

一個表單,預期爲須要點擊按鈕edit以後才能夠進行編輯,而且此時Edit按鈕變爲submit按鈕,點擊submit按鈕提交表單。代碼以下api

Edit yj0z7169l9
class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      editable: false
    };
  }
  handleClick = () => {
    console.log("edit button click!!");
    this.setState({ editable: true });
  };
  handleSubmit = e => {
    console.log("submit event!!");
    e.preventDefault(); //避免頁面刷新
  };
  render() {
    return (
      <form onSubmit={this.handleSubmit}> {this.state.editable ? ( <button type="submit">submit</button> ) : ( <button type="button" onClick={this.handleClick}>edit</button> )} </form>
    );
  }
}
複製代碼

但實際上咱們發現,點擊edit按鈕的時候就已經觸發form的submit事件了。爲何咱們點擊了一個type="button"的按鈕會觸發submit事件呢?數組

帶着對這兩個例子的思考,咱們進入到本文的主題。我只想直接看答案?

React爲何要本身實現一個事件系統?

react事件ppt.004

我認爲這個問題主要是爲了性能複用兩個方面來考慮。

首先對於性能來講,React做爲一套View層面的框架,經過渲染獲得vDOM,再由diff算法決定DOM樹那些結點須要新增、替換或修改,假如直接在DOM結點插入原生事件監聽,則會致使頻繁的調用addEventListenerremoveEventListener,形成性能的浪費。因此React採用了事件代理的方法,對於大部分事件而言都在document上作監聽,而後根據Event中的target來判斷事件觸發的結點。(除了少數不會冒泡到document的事件,例如video等。)

其次React合成的SyntheticEvent採用了的思想,從而達到節約內存,避免頻繁的建立和銷燬事件對象的目的。這也是若是咱們須要異步使用一個syntheticEvent,須要執行event.persist()才能防止事件對象被釋放的緣由。

最後在React源碼中隨處可見batch作批量更新,基本上凡是能夠批量處理的事情(最廣泛的setState)React都會將中間過程保存起來,留到最後面才flush掉。就如瀏覽器對DOM樹進行Style,Layout,Paint同樣,都不會在操做ele.style.color='red';以後立刻執行,只會將這些操做打包起來並最終在須要渲染的時候再作渲染。

ele.style.color='red'; 
ele.style.color='blue';
ele.style.color='red';
瀏覽器只會渲染一次
複製代碼

而對於複用來講,React看到在不一樣的瀏覽器和平臺上,用戶界面上的事件其實很是類似,例如普通的clickchange等等。React但願經過封裝一層事件系統,將不一樣平臺的原生事件都封裝成SyntheticEvent

  • 使得不一樣平臺只須要經過加入EventEmitter以及對應的Renderer就能使用相同的一個事件系統,WEB平臺上加入ReactBrowserEventEmitter,Native上加入ReactNativeEventEmitter。以下圖,對於不一樣平臺,React只須要替換掉左邊部分,而右邊EventPluginHub部分能夠保持複用。
  • 對於不一樣的瀏覽器而言,React幫咱們統一了事件,作了瀏覽器的兼容,例如對於transitionEnd,webkitTransitionEnd,MozTransitionEndoTransitionEnd, React都會集合成topAnimationEnd,因此咱們只用處理這一個標準的事件便可。

react事件ppt.005

簡單而言,就與jQuery幫助咱們解決了不一樣瀏覽器之間的兼容問同樣,React更進一步,還幫咱們統一了不一樣平臺的兼容,使咱們在開發的時候只須要考慮標準化的事件便可。

React的事件系統是怎麼運做起來的?

事件綁定

咱們來看一下咱們在JSX中寫的onClickhandler是怎麼被記錄到DOM結點上,而且在document上作監聽的。

React對於大部分事件的綁定都是使用trapBubbledEventtrapCapturedEvent這兩個函數來註冊的。如上圖所示,當咱們執行了render或者setState以後,React的Fiber調度系統會在最後commit到DOM樹以前執行trapBubbledEventrapCapturedEvent, 經過執行addEventListener在document結點上綁定對應的dispatch做爲handler負責監聽類型爲topLevelType的事件。

這裏面的dispatchInteractiveEventdispatchEvent兩個回調函數的區別爲,React16開始換掉了本來Stack Reconciliation成Fiber但願實現異步渲染(目前仍未默認打開,仍需使用unstable_開頭的api,此特性與例子2有關,將在文章最後配圖解釋),因此異步渲染的狀況下加入我點了兩次按鈕,那麼第二次按鈕響應的時候,可能第一次按鈕的handlerA中調用的setState還未最終被commit到DOM樹上,這時須要把第一次按鈕的結果先給flush掉並commit到DOM樹,纔可以保持一致性。這個時候就會用到dispatchInteractiveEvent。能夠理解成dispatchInteractiveEvent在執行前都會確保以前全部操做都已最總commit到DOM樹,再開始本身的流程,並最終觸發dispatchEvent。但因爲目前React還是同步渲染的,因此這兩個函數在目前的表現是一致的,但願React17會帶給咱們默認打開的異步渲染功能。

到如今咱們已經在document結點上監聽了事件了,如今須要來看如何將咱們在jsx中寫的handler存起來對應到相應的結點上。

在咱們每次新建或者更新結點時,React最終會調用createInstance或者commitUpdate這兩個函數,而這兩個函數都會最終調用updateFiberProps這個函數,將props也就是咱們的onClickonChange等handler給存到DOM結點上。

至此,咱們咱們已經在document上監聽了事件,而且將handler存在對應DOM結點。接下來須要看React怎麼監聽並處理瀏覽器的原生事件,最終觸發對應的handler了。

事件觸發

這裏我作了個動畫,但願可以對大家理解有幫助。點擊綠色的按鈕>播放下一步。

抱歉須要插入連接,掘金不容許插入iframe。

以簡單的click事件爲例,經過事件綁定咱們已經在document上監聽了click事件,當咱們真正點擊了這個按鈕的時候,原生的事件是如何進入React的管轄範圍的?如何合成SyntheticEvent以及如何模擬捕獲和冒泡的?以及最後咱們在jsx中寫的onClickhandler是如何被最終觸發的?帶着這些問題,咱們一塊兒來看一下事件觸發階段。

我會大概用下圖這種方式來解析代碼,左邊是我點擊一個綁定了handleClick的按鈕後的js調用棧,右邊是每一步的代碼,均已刪除部分不影響理解的代碼。但願經過這種方式能使你們更易理解React的事件觸發機制。

當咱們點擊一個按鈕是,click事件將會最終冒泡至document,並觸發咱們監聽在document上的handler dispatchEvent,接着觸發batchedUpdatesbatchedUpdates這個格式的代碼在React的源碼裏面會頻繁的出現,基本上React將全部可以批量處理的事情都會先收集起來,再一次性處理。

能夠看到默認的isBatching是false的,當調用了一次batchedUpdatesisBatching的值將會變成true,此時若是在接下來的調用中有繼續調用batchedUpdates的話,就會直接執行handleTopLevel,此時的setState等不會被更新到DOM上。直到調用棧從新回到第一次調用batchedUpdates的時候,纔會將全部結果一塊兒flush掉(更新到DOM上)。

有的同窗可能問調用棧中的BatchedUpdates$1是什麼?或者瀏覽器的renderer和Native的renderer是若是掛在到React的事件系統上的?

其實React事件系統裏面提供了一個函數setBatchingImplementation,用來動態掛載不一樣平臺的renderer,這個也體現了React事件系統的複用

這裏的interactiveUpdatesbatchedUpdates的區別在上文已經解釋過,這裏就再也不贅述。

handleTopLevel會調用runExtractedEventsInBatch(),這是React事件處理最重要的函數。如上面動畫咱們看到的,在EventEmitter裏面作的事,其實主要就是這個函數的兩步。

  • 第一步是根據原生事件合成合成事件,而且在vDOM上模擬捕獲冒泡,收集全部須要執行的事件回調構成回調數組。
  • 第二步是遍歷回調數組,觸發回調函數。

首先調用extractEvents,傳入原生事件e,React事件系統根據可能的事件插件合成合成事件Synthetic e。 這裏咱們能夠看到調用了EventConstructor.getPooled(),從事件池中去取一個合成事件對象,若是事件池爲空,則新建立一個合成事件對象,這體現了React爲了性能實現了的思想。

而後傳入Propagator,在vDOM上模擬捕獲和冒泡,並收集全部須要執行的事件回調和對應的結點。traverseTwoPhase模擬了捕獲和冒泡的兩個階段,這裏實現很巧妙,簡單而言就是正向和反向遍歷了一下數組。接着對每個結點,調用listenerAtPhase取出事件綁定時掛載在結點上的回調函數,把它加入回調數組中。

接着遍歷全部合成事件。這裏能夠看到當一個事件處理完的時候,React會調用event.isPersistent()來查看這個合成事件是否須要被持久化,若是不須要就會釋放這個合成事件,這也就是爲何當咱們須要異步讀取操做一個合成事件的時候,須要執行event.persist(),否則React就是在這裏釋放掉這個事件。

最後這裏就是回調函數被真正觸發的時候了,取出回調數組event._dispatchListeners,遍歷觸發回調函數。並經過event.isPropagationStopped()這一步來模擬中止冒泡。這裏咱們能夠看到,React在收集回調數組的時候並不會去管咱們是否調用了stopPropagation,而是會在觸發的階段纔會去檢查是否須要中止冒泡。

至此,一個事件回調函數就被觸發了,裏面若是執行了setState等就會等到調用棧彈回到最低部的interactiveUpdate中的被最終flush掉,構造vDOM,和好,並最終被commit到DOM上。

這就是事件觸發的整個過程了,能夠回去再看一下動畫,相信你會更加理解這個過程的。

例子Debug

如今咱們對React事件系統已經比較熟悉了,回到文章開頭的那兩個玄學問題,咱們來看一下到底爲何?

例子一

若是想看題目內容或者忘記題目了,能夠點擊這裏查看。

相信看完這篇文章,若是你已經對React事件系統有所理解,這道題應該是不難了。

  1. 由於React事件監聽是掛載在document上的,因此原生系統在#outer上監聽的回調C會最早被輸出;接着原生事件冒泡至document進入React事件系統,React事件系統模擬捕獲冒泡輸出AB;最後React事件系統執行完畢回到瀏覽器繼續冒泡到window,輸出D
  2. 原生系統在#outer上監聽的回調C會最早被執行;接着原生事件冒泡至document進入React事件系統,輸出A,在React事件處理中#inner調用了stopPropagation,事件被中止冒泡。

因此,最好不要混用React事件系統和原生事件系統,若是混用了,請保證你清楚知道會發生什麼。

例子二

若是想看題目內容或者忘記題目了,能夠點擊這裏查看。

這個問題就稍微複雜一點。首先咱們點擊edit按鈕瀏覽器觸發一個click事件,冒泡至document進入React事件系統,React執行回調調用setState,此時React事件系統對事件的處理執行完畢。因爲目前React是同步渲染的,因此接着React執行performSyncWork將該button改爲type="submit",因爲同個位置的結點而且tag都爲button,因此React複用了這個button結點(具體緣由能夠參考)並更新到DOM上。此時瀏覽器對click事件處理執行繼續,發現該結點的type="submit",又在form下面,則對應觸發submit事件。

解決的辦法就有不少種了,給button加上key;兩個按鈕分開寫,不要用三元等均可以解決問題。

具體能夠看一下下面的這個調用圖,應該也很好理解,若是有不能理解的地方,請在下面留言,我會盡我所能解釋清楚。


額外多說一個點,「setState是異步的」

相信對於不少React開發者來講,「setState是異步的」這句話應該常常聽到,我記得我一開始學習React的時候常常就會看到這句話,而後說若是須要用到以前的state須要在setState中採用setState((preState)=>{})這樣的方式。

但其實這句話並非徹底準確的。準確的說法應該是setState有時候是異步的,setState相對於瀏覽器而言是同步的

目前而言setState在生命週期以及事件回調中是異步的,也就是會收集起來批量處理。在其它狀況下如promise,setTimeout中都是同步執行的,也就是調用一次setState就會render一次並更新到DOM上面,不信的話能夠點擊這裏嘗試。

且在JS調用棧被彈空時候,一定是已經將結果更新到DOM上面了(同步渲染)。這也就是setState相對於瀏覽器是同步的含義。以下圖所示

異步渲染的流程圖大概以下圖所示,最近一次思考這個問題的時候,發現若是如今是異步渲染的話,那咱們的例子二將變成偶現的坑😂,由於若是setState的結果還沒被更新到DOM上,瀏覽器就不會觸發submit事件。

不過React團隊已經爲異步渲染的願景開發了兩年,且React16中已經採用了Fiber reconciliation和提供了異步渲染的api unstable_xxx,相信在React17中咱們能夠享受到異步渲染帶來的性能提高,感謝React團隊。

總結

但願讀完此文,能讓你React事件系統有個簡單的認識。知道「爲何React須要本身實現一套事件系統?」和「React的事件系統是怎麼運做起來的?」。React爲了性能複用,採用了事件代理,池,批量更新,跨瀏覽器和跨平臺兼容等思想,將事件監聽掛載在document上,而且構造合成事件,而且在內部模擬了一套捕獲和冒泡並觸發回調函數的機制,實現了本身一套事件系統。

若是你還有哪裏不清楚,發現文章有錯漏,或是單純的交流相關問題,請在下面留言,我會盡我所能回覆和解答你的疑問的。 若是你喜歡個人文章,請關注我和個人博客,謝謝。

Read More & Reference

相關文章
相關標籤/搜索