1. 程式人生 > >React原始碼分析7 — React合成事件系統

React原始碼分析7 — React合成事件系統

1 React合成事件特點

React自己實現了一套高效的事件註冊,儲存,分發和重用邏輯,在DOM事件體系基礎上做了很大改進,減少了記憶體消耗,簡化了事件邏輯,並最大化的解決了IE等瀏覽器的不相容問題。與DOM事件體系相比,它有如下特點

  1. React元件上宣告的事件最終繫結到了document這個DOM節點上,而不是React元件對應的DOM節點。故只有document這個節點上面才綁定了DOM原生事件,其他節點沒有繫結事件。這樣簡化了DOM原生事件,減少了記憶體開銷
  2. React以佇列的方式,從觸發事件的元件向父元件回溯,呼叫它們在JSX中宣告的callback。也就是React自身實現了一套事件冒泡機制。我們沒辦法用event.stopPropagation()來停止事件傳播,應該使用event.preventDefault()
  3. React有一套自己的合成事件SyntheticEvent,不同型別的事件會構造不同的SyntheticEvent
  4. React使用物件池來管理合成事件物件的建立和銷燬,這樣減少了垃圾的生成和新物件記憶體的分配,大大提高了效能

那麼這些特性是如何實現的呢,下面和大家一起一探究竟。

2 React事件系統

先看Facebook給出的React事件系統框圖

Markdown

瀏覽器事件(如使用者點選了某個button)觸發後,DOM將event傳給ReactEventListener,它將事件分發到當前元件及以上的父元件。然後由ReactEventEmitter對每個元件進行事件的執行,先構造React合成事件,然後以queue的方式呼叫JSX中宣告的callback進行事件回撥。

涉及到的主要類如下

ReactEventListener:負責事件註冊和事件分發。React將DOM事件全都註冊到document這個節點上,這個我們在事件註冊小節詳細講。事件分發主要呼叫dispatchEvent進行,從事件觸發元件開始,向父元素遍歷。我們在事件執行小節詳細講。

ReactEventEmitter:負責每個元件上事件的執行。

EventPluginHub:負責事件的儲存,合成事件以物件池的方式實現建立和銷燬,大大提高了效能。

SimpleEventPlugin等plugin:根據不同的事件型別,構造不同的合成事件。如focus對應的React合成事件為SyntheticFocusEvent

2 事件註冊

JSX中宣告一個React事件十分簡單,比如

render() {
  return (
    <div onClick = {
            (event) => {console.log(JSON.stringify(event))}
        } 
    />
  );
}

那麼它是如何被註冊到React事件系統中的呢?

還是先得從元件建立和更新的入口方法mountComponent和updateComponent說起。在這兩個方法中,都會呼叫到_updateDOMProperties方法,對JSX中宣告的元件屬性進行處理。原始碼如下

_updateDOMProperties: function (lastProps, nextProps, transaction) {
    ... // 前面程式碼太長,省略一部分
    else if (registrationNameModules.hasOwnProperty(propKey)) {
        // 如果是props這個物件直接宣告的屬性,而不是從原型鏈中繼承而來的,則處理它
        // nextProp表示要建立或者更新的屬性,而lastProp則表示上一次的屬性
        // 對於mountComponent,lastProp為null。updateComponent二者都不為null。unmountComponent則nextProp為null
        if (nextProp) {
          // mountComponent和updateComponent中,enqueuePutListener註冊事件
          enqueuePutListener(this, propKey, nextProp, transaction);
        } else if (lastProp) {
          // unmountComponent中,刪除註冊的listener,防止記憶體洩漏
          deleteListener(this, propKey);
        }
    }
}

下面我們來看enqueuePutListener,它負責註冊JSX中宣告的事件。原始碼如下

// inst: React Component物件
// registrationName: React合成事件名,如onClick
// listener: React事件回撥方法,如onClick=callback中的callback
// transaction: mountComponent或updateComponent所處的事務流中,React都是基於事務流的
function enqueuePutListener(inst, registrationName, listener, transaction) {
  if (transaction instanceof ReactServerRenderingTransaction) {
    return;
  }
  var containerInfo = inst._hostContainerInfo;
  var isDocumentFragment = containerInfo._node && containerInfo._node.nodeType === DOC_FRAGMENT_TYPE;
  // 找到document
  var doc = isDocumentFragment ? containerInfo._node : containerInfo._ownerDocument;
  // 註冊事件,將事件註冊到document上
  listenTo(registrationName, doc);
  // 儲存事件,放入事務佇列中
  transaction.getReactMountReady().enqueue(putListener, {
    inst: inst,
    registrationName: registrationName,
    listener: listener
  });
}

enqueuePutListener主要做兩件事,一方面將事件註冊到document這個原生DOM上(這就是為什麼只有document這個節點有DOM事件的原因),另一方面採用事務佇列的方式呼叫putListener將註冊的事件儲存起來,以供事件觸發時回撥。

註冊事件的入口是listenTo方法, 它解決了不同瀏覽器間捕獲和冒泡不相容的問題。事件回撥方法在bubble階段被觸發。如果我們想讓它在capture階段觸發,則需要在事件名上加上capture。比如onClick在bubble階段觸發,而onCaptureClick在capture階段觸發。listenTo程式碼雖然比較長,但邏輯很簡單,呼叫trapCapturedEvent和trapBubbledEvent來註冊捕獲和冒泡事件。trapCapturedEvent大家可以自行分析,我們僅分析trapBubbledEvent,如下

  trapBubbledEvent: function (topLevelType, handlerBaseName, element) {
    if (!element) {
      return null;
    }
    return EventListener.listen(
      element,   // 繫結到的DOM目標,也就是document
      handlerBaseName,   // eventType
      ReactEventListener.dispatchEvent.bind(null, topLevelType));  // callback, document上的原生事件觸發後回撥
  },

  listen: function listen(target, eventType, callback) {
    if (target.addEventListener) {
      // 將原生事件新增到target這個dom上,也就是document上。
      // 這就是隻有document這個DOM節點上有原生事件的原因
      target.addEventListener(eventType, callback, false);
      return {
        // 刪除事件,這個由React自己回撥,不需要呼叫者來銷燬。但僅僅對於React合成事件才行
        remove: function remove() {
          target.removeEventListener(eventType, callback, false);
        }
      };
    } else if (target.attachEvent) {
      // attach和detach的方式
      target.attachEvent('on' + eventType, callback);
      return {
        remove: function remove() {
          target.detachEvent('on' + eventType, callback);
        }
      };
    }
  },

在listen方法中,我們終於發現了熟悉的addEventListener這個原生事件註冊方法。只有document節點才會呼叫這個方法,故僅僅只有document節點上才有DOM事件。這大大簡化了DOM事件邏輯,也節約了記憶體。

流程圖如下

Markdown

3 事件儲存

事件儲存由EventPluginHub來負責,它的入口在我們上面講到的enqueuePutListener中的putListener方法,如下

  /**
   * EventPluginHub用來儲存React事件, 將listener儲存到`listenerBank[registrationName][key]`
   *
   * @param {object} inst: 事件源
   * @param {string} listener的名字,比如onClick
   * @param {function} listener的callback
   */
  //
  putListener: function (inst, registrationName, listener) {

    // 用來標識註冊了事件,比如onClick的React物件。key的格式為'.nodeId', 只用知道它可以標示哪個React物件就可以了
    var key = getDictionaryKey(inst);
    var bankForRegistrationName = listenerBank[registrationName] || (listenerBank[registrationName] = {});
    // 將listener事件回撥方法存入listenerBank[registrationName][key]中,比如listenerBank['onclick'][nodeId]
    // 所有React元件物件定義的所有React事件都會儲存在listenerBank中
    bankForRegistrationName[key] = listener;

    //onSelect和onClick註冊了兩個事件回撥外掛, 用於walkAround某些瀏覽器相容bug,不用care
    var PluginModule = EventPluginRegistry.registrationNameModules[registrationName];
    if (PluginModule && PluginModule.didPutListener) {
      PluginModule.didPutListener(inst, registrationName, listener);
    }
  },

var getDictionaryKey = function (inst) {
  return '.' + inst._rootNodeID;
};

由上可見,事件儲存在了listenerBank物件中,它按照事件名和React元件物件進行了二維劃分,比如nodeId元件上註冊的onClick事件最後儲存在listenerBank.onclick[nodeId]中。

4 事件執行

4.1 事件分發

當事件觸發時,document上addEventListener註冊的callback會被回撥。從前面事件註冊部分發現,此時回撥函式為ReactEventListener.dispatchEvent,它是事件分發的入口方法。下面我們來詳細分析

// topLevelType:帶top的事件名,如topClick。不用糾結為什麼帶一個top欄位,知道它是事件名就OK了
// nativeEvent: 使用者觸發click等事件時,瀏覽器傳遞的原生事件
dispatchEvent: function (topLevelType, nativeEvent) {
    // disable了則直接不回撥相關方法
    if (!ReactEventListener._enabled) {
      return;
    }

    var bookKeeping = TopLevelCallbackBookKeeping.getPooled(topLevelType, nativeEvent);
    try {
      // 放入批處理佇列中,React事件流也是一個訊息佇列的方式
      ReactUpdates.batchedUpdates(handleTopLevelImpl, bookKeeping);
    } finally {
      TopLevelCallbackBookKeeping.release(bookKeeping);
    }
}

可見我們仍然使用批處理的方式進行事件分發,handleTopLevelImpl才是事件分發的真正執行者,它是事件分發的核心,體現了React事件分發的特點,如下

// document進行事件分發,這樣具體的React元件才能得到響應。因為DOM事件是繫結到document上的
function handleTopLevelImpl(bookKeeping) {
  // 找到事件觸發的DOM和React Component
  var nativeEventTarget = getEventTarget(bookKeeping.nativeEvent);
  var targetInst = ReactDOMComponentTree.getClosestInstanceFromNode(nativeEventTarget);

  // 執行事件回撥前,先由當前元件向上遍歷它的所有父元件。得到ancestors這個陣列。
  // 因為事件回撥中可能會改變Virtual DOM結構,所以要先遍歷好元件層級
  var ancestor = targetInst;
  do {
    bookKeeping.ancestors.push(ancestor);
    ancestor = ancestor && findParent(ancestor);
  } while (ancestor);

  // 從當前元件向父元件遍歷,依次執行註冊的回撥方法. 我們遍歷構造ancestors陣列時,是從當前元件向父元件回溯的,故此處事件回撥也是這個順序
  // 這個順序就是冒泡的順序,並且我們發現不能通過stopPropagation來阻止'冒泡'。
  for (var i = 0; i < bookKeeping.ancestors.length; i++) {
    targetInst = bookKeeping.ancestors[i];
    ReactEventListener._handleTopLevel(bookKeeping.topLevelType, targetInst, bookKeeping.nativeEvent, getEventTarget(bookKeeping.nativeEvent));
  }
}

從上面的事件分發中可見,React自身實現了一套冒泡機制。從觸發事件的物件開始,向父元素回溯,依次呼叫它們註冊的事件callback。

4.2 事件callback呼叫

事件處理由_handleTopLevel完成。它其實是呼叫ReactBrowserEventEmitter.handleTopLevel() ,如下

  // React事件呼叫的入口。DOM事件繫結在了document原生物件上,每次事件觸發,都會呼叫到handleTopLevel
  handleTopLevel: function (topLevelType, targetInst, nativeEvent, nativeEventTarget) {
    // 採用物件池的方式構造出合成事件。不同的eventType的合成事件可能不同
    var events = EventPluginHub.extractEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget);
    // 批處理佇列中的events
    runEventQueueInBatch(events);
  }

handleTopLevel方法是事件callback呼叫的核心。它主要做兩件事情,一方面利用瀏覽器回傳的原生事件構造出React合成事件,另一方面採用佇列的方式處理events。先看如何構造合成事件。

4.2.1 構造合成事件

  // 構造合成事件
  extractEvents: function (topLevelType, targetInst, nativeEvent, nativeEventTarget) {
    var events;
    // EventPluginHub可以儲存React合成事件的callback,也儲存了一些plugin,這些plugin在EventPluginHub初始化時就註冊就來了
    var plugins = EventPluginRegistry.plugins;
    for (var i = 0; i < plugins.length; i++) {
      var possiblePlugin = plugins[i];
      if (possiblePlugin) {
        // 根據eventType構造不同的合成事件SyntheticEvent
        var extractedEvents = possiblePlugin.extractEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget);
        if (extractedEvents) {
          // 將構造好的合成事件extractedEvents新增到events陣列中,這樣就儲存了所有plugin構造的合成事件
          events = accumulateInto(events, extractedEvents);
        }
      }
    }
    return events;
  },

EventPluginRegistry.plugins預設包含五種plugin,他們是在EventPluginHub初始化階段注入進去的,且看程式碼

  // 將eventPlugin註冊到EventPluginHub中
  ReactInjection.EventPluginHub.injectEventPluginsByName({
    SimpleEventPlugin: SimpleEventPlugin,
    EnterLeaveEventPlugin: EnterLeaveEventPlugin,
    ChangeEventPlugin: ChangeEventPlugin,
    SelectEventPlugin: SelectEventPlugin,
    BeforeInputEventPlugin: BeforeInputEventPlugin
  });

不同的plugin針對不同的事件有特殊的處理,此處我們不展開講了,下面僅分析SimpleEventPlugin中方法即可。

我們先看SimpleEventPlugin如何構造它所對應的React合成事件。

  // 根據不同事件型別,比如click,focus構造不同的合成事件SyntheticEvent, 如SyntheticKeyboardEvent SyntheticFocusEvent
extractEvents: function (topLevelType, targetInst, nativeEvent, nativeEventTarget) {
    var dispatchConfig = topLevelEventsToDispatchConfig[topLevelType];
    if (!dispatchConfig) {
      return null;
    }
    var EventConstructor;

   // 根據事件型別,採用不同的SyntheticEvent來構造不同的合成事件
    switch (topLevelType) {
      ... // 省略一些事件,我們僅以blur和focus為例
      case 'topBlur':
      case 'topFocus':
        EventConstructor = SyntheticFocusEvent;
        break;
      ... // 省略一些事件
    }

    // 從event物件池中取出合成事件物件,利用物件池思想,可以大大降低物件建立和銷燬的時間,提高效能。這是React事件系統的一大亮點
    var event = EventConstructor.getPooled(dispatchConfig, targetInst, nativeEvent, nativeEventTarget);
    EventPropagators.accumulateTwoPhaseDispatches(event);
    return event;
},

這裡我們看到了event物件池這個重大特性,採用合成事件物件池的方式,可以大大降低銷燬和建立合成事件帶來的效能開銷。

物件建立好之後,我們還會將它新增到events這個佇列中,因為事件回撥的時候會用到這個佇列。新增到events中使用的是accumulateInto方法。它思路比較簡單,將新建立的合成物件的引用新增到之前建立好的events佇列中即可,原始碼如下

function accumulateInto(current, next) {

  if (current == null) {
    return next;
  }

  // 將next新增到current中,返回一個包含他們兩個的新陣列
  // 如果next是陣列,current不是陣列,採用push方法,否則採用concat方法
  // 如果next不是陣列,則返回一個current和next構成的新陣列
  if (Array.isArray(current)) {
    if (Array.isArray(next)) {
      current.push.apply(current, next);
      return current;
    }
    current.push(next);
    return current;
  }

  if (Array.isArray(next)) {
    return [current].concat(next);
  }

  return [current, next];
}

4.2.2 批處理合成事件

我們上面分析過了,React以佇列的形式處理合成事件。方法入口為runEventQueueInBatch,如下

  function runEventQueueInBatch(events) {
    // 先將events事件放入佇列中
    EventPluginHub.enqueueEvents(events);
    // 再處理佇列中的事件,包括之前未處理完的。先入先處理原則
    EventPluginHub.processEventQueue(false);
  }

  /**
   * syntheticEvent放入佇列中,等到processEventQueue再獲得執行
   */
  enqueueEvents: function (events) {
    if (events) {
      eventQueue = accumulateInto(eventQueue, events);
    }
  },

  /**
   * 分發執行佇列中的React合成事件。React事件是採用訊息佇列方式批處理的
   *
   * simulated:為true表示React測試程式碼,我們一般都是false 
   */
  processEventQueue: function (simulated) {
    // 先將eventQueue重置為空
    var processingEventQueue = eventQueue;
    eventQueue = null;
    if (simulated) {
      forEachAccumulated(processingEventQueue, executeDispatchesAndReleaseSimulated);
    } else {
      // 遍歷處理佇列中的事件,
      // 如果只有一個元素,則直接executeDispatchesAndReleaseTopLevel(processingEventQueue)
      // 否則遍歷佇列中事件,呼叫executeDispatchesAndReleaseTopLevel處理每個元素
      forEachAccumulated(processingEventQueue, executeDispatchesAndReleaseTopLevel);
    }
    // This would be a good time to rethrow if any of the event handlers threw.
    ReactErrorUtils.rethrowCaughtError();
  },

合成事件處理也分為兩步,先將我們要處理的events佇列放入eventQueue中,因為之前可能就存在還沒處理完的合成事件。然後再執行eventQueue中的事件。可見,如果之前有事件未處理完,這裡就又有得到執行的機會了。

事件執行的入口方法為executeDispatchesAndReleaseTopLevel,如下

var executeDispatchesAndReleaseTopLevel = function (e) {
  return executeDispatchesAndRelease(e, false);
};

var executeDispatchesAndRelease = function (event, simulated) {
  if (event) {
    // 進行事件分發,
    EventPluginUtils.executeDispatchesInOrder(event, simulated);

    if (!event.isPersistent()) {
      // 處理完,則release掉event物件,採用物件池方式,減少GC
      // React幫我們處理了合成事件的回收機制,不需要我們關心。但要注意,如果使用了DOM原生事件,則要自己回收
      event.constructor.release(event);
    }
  }
};

// 事件處理的核心
function executeDispatchesInOrder(event, simulated) {
  var dispatchListeners = event._dispatchListeners;
  var dispatchInstances = event._dispatchInstances;

  if (Array.isArray(dispatchListeners)) {
    // 如果有多個listener,則遍歷執行陣列中event
    for (var i = 0; i < dispatchListeners.length; i++) {
      // 如果isPropagationStopped設成true了,則停止事件傳播,退出迴圈。
      if (event.isPropagationStopped()) {
        break;
      }
      // 執行event的分發,從當前觸發事件元素向父元素遍歷
      // event為瀏覽器上傳的原生事件
      // dispatchListeners[i]為JSX中宣告的事件callback
      // dispatchInstances[i]為對應的React Component 
      executeDispatch(event, simulated, dispatchListeners[i], dispatchInstances[i]);
    }
  } else if (dispatchListeners) {
    // 如果只有一個listener,則直接執行事件分發
    executeDispatch(event, simulated, dispatchListeners, dispatchInstances);
  }
  // 處理完event,重置變數。因為使用的物件池,故必須重置,這樣才能被別人複用
  event._dispatchListeners = null;
  event._dispatchInstances = null;
}

executeDispatchesInOrder會先得到event對應的listeners佇列,然後從當前元素向父元素遍歷執行註冊的callback。且看executeDispatch

function executeDispatch(event, simulated, listener, inst) {
  var type = event.type || 'unknown-event';
  event.currentTarget = EventPluginUtils.getNodeFromInstance(inst);
  if (simulated) {
    // test程式碼使用,支援try-catch,其他就沒啥區別了
    ReactErrorUtils.invokeGuardedCallbackWithCatch(type, listener, event);
  } else {
    // 事件分發,listener為callback,event為引數,類似listener(event)這個方法呼叫
    // 這樣就回調到了我們在JSX中註冊的callback。比如onClick={(event) => {console.log(1)}}
    // 這樣應該就明白了callback怎麼被呼叫的,以及event引數怎麼傳入callback裡面的了
    ReactErrorUtils.invokeGuardedCallback(type, listener, event);
  }
  event.currentTarget = null;
}

// 採用func(a)的方式進行呼叫,
// 故ReactErrorUtils.invokeGuardedCallback(type, listener, event)最終呼叫的是listener(event)
// event物件為瀏覽器傳遞的DOM原生事件物件,這也就解釋了為什麼React合成事件回撥中能拿到原生event的原因
function invokeGuardedCallback(name, func, a) {
  try {
    func(a);
  } catch (x) {
    if (caughtError === null) {
      caughtError = x;
    }
  }
}

流程圖如下

Markdown

5 總結

React事件系統還是相當麻煩的,主要分為事件註冊,事件儲存和事件執行三大部分。瞭解了React事件系統原始碼,就能夠輕鬆回答我們文章開頭所列出的React事件幾大特點了。

由於事件系統相當麻煩,文章中不正確的地方,請不吝賜教!