react 八千字長文深入了解react合成事件底層原理,原生事件中阻止冒泡是否會阻塞合成事件?


壹 ❀ 引

在前面兩篇文章中,我們花了較大的篇幅介紹reactsetState方法,在介紹setState同步異步時提到,在react合成事件中react對於this.state更新都是異步,但在原生事件中更新卻是同步,這說明react在合成事件處理上必定與原生事件存在部分差異,那么本篇文章就來着重介紹react中的合成事件,在文章開始之前我們先羅列部分問題:

  • 了解原生事件監聽機制嗎?為什么需要事件代理?
  • react如何實現的合成事件?這么做的好處是什么?
  • 合成事件與原生事件執行先后順序。
  • 合成事件阻止冒泡會阻塞原生事件嗎?原生事件阻止冒泡會阻塞合成事件嗎?

在文章開始前,大家可以先自行思考這些問題,假設這些在面試中遇到,你能回答多少呢?那么本文開始。

貳 ❀ 從原生事件說起

雖然本文的核心重點是介紹react的合成事件,但文章開頭既然給了合成事件與原生事件相關對比問題,因此了解原生事件是有必要的,考慮到可能有同學對此類知識存在遺忘,這里做個簡單復習。

首先讓我們復習下事件監聽api addEventListener,基本語法如下:

element.addEventListener(event, function, useCapture);

其中element表示你需要監聽的dom元素;event表示監聽的事件類型,比如onclick,onblurfunction表示觸發事件后的回調,你需要做什么都可以寫在這里;useCapture是一個布爾值,表示是否開啟捕獲階段,默認false

以如下代碼結構為例,當我們點擊span時,必然會經歷過捕獲階段----》目標階段----》冒泡階段

我們用一個例子復習這個過程:

<div id="div">
    我是div
    <p id="p">
        我是p
        <span id="span">我是span</span>
    </p>
</div>
const div = document.querySelector("#div");
const p = document.querySelector("#p");
const span = document.querySelector("#span");
// 捕獲階段,這里將useCapture設置為true
div.addEventListener("click",()=>console.log("捕獲階段--div"),true);
p.addEventListener("click",()=>console.log("捕獲階段--p"),true);
// 目標階段
span.addEventListener("click",()=>console.log("目標階段--span"));
// 冒泡階段,useCapture默認false,不寫了
div.addEventListener("click",()=>console.log("捕獲階段--div"));
p.addEventListener("click",()=>console.log("捕獲階段--p"));

既然提到了事件監聽,那么有三個API就不得不提了,它們分別是event.preventDefaultevent.stopPropagationevent.stopImmediatePropagation。讓我們先聊聊stopPropagation,此方法一般用於阻止冒泡,比如父子都綁定了點擊事件,但點擊子時我不希望父在冒泡階段也被觸發,因此通過在子的事件回調中添加此方法能做到這一點,修改上述例子中目標階段的代碼為:

span.addEventListener("click", (e) => {
    e.stopPropagation();
    console.log("目標階段--span")
});

此時點擊span會發現只會輸出span,而冒泡階段的divp都被阻止執行了。

關於event.preventDefault,此方法常用於阻止元素默認行為,比如點擊a標簽除了執行我們綁定的click事件外,它還會執行a標簽默認的跳轉。再或者form表達點擊提交會將form的值傳遞給action指定地址並刷新頁面,像這類行為我們均可以通過preventDefault阻止。

在介紹stopImmediatePropagation之前,我們需要知道事件監聽相對於普通事件綁定的一大好處是,事件監聽支持為同一dom監聽多個行為,但如果是普通的事件綁定后者會覆蓋前者:

span.onclick = ()=>console.log('事件綁定-1');
// 后綁定的事件會覆蓋前面的綁定
span.onclick = ()=>console.log('事件綁定-2');
// 事件監聽就不會存在覆蓋,下面2個都會執行
span.addEventListener("click", (e) => {
    console.log("事件監聽-1")
});
span.addEventListener("click", (e) => {
    console.log("事件監聽-2")
});

那既然事件監聽支持為同一dom綁定多個,我在執行了某個監聽后,需要將其它監聽都阻止掉怎么辦?此時就輪到stopImmediatePropagation出場立大功了,看個例子:

// 捕獲階段
div.addEventListener("click", () => console.log("捕獲階段--div-1"), true);
div.addEventListener("click", () => console.log("捕獲階段--div-2"), true);
p.addEventListener("click", () => console.log("捕獲階段--p-1"), true);
p.addEventListener("click", () => console.log("捕獲階段--p-2"), true);
// 目標階段
span.addEventListener("click", (e) => {
    e.stopImmediatePropagation();
    console.log("目標階段---span-1")
});
span.addEventListener("click", (e) => {
    console.log("目標階段---span-2")
});
// 冒泡階段
div.addEventListener("click", () => console.log("捕獲階段--div-1"));
div.addEventListener("click", () => console.log("捕獲階段--div-2"));
p.addEventListener("click", () => console.log("捕獲階段--p-1"));
p.addEventListener("click", () => console.log("捕獲階段--p-2"));

可以看到stopImmediatePropagation同樣會阻止事件冒泡,但除此之外,它還會阻止同一dom身上的其它事件執行。

那么聊完事件監聽,什么是事件代理?在現實生活中,我們網購到公司的快遞大部分都會由前台代簽收,而不是分別送到我們每個人手上,此時前台就相當於做了一個代理的事情,原本需要不同的多個人分別簽收的行為,統一與前台代理處理。

映射到代碼中,假設有ul>li的結構,我們希望點擊li顯示出li的文本內容,如果給每個li綁定就得這么寫:

<ul id="ul">
    <li onclick="handleClick(event)">1</li>
    <li onclick="handleClick(event)">2</li>
    <li onclick="handleClick(event)">3</li>
    <li onclick="handleClick(event)">4</li>
    <li onclick="handleClick(event)">5</li>
</ul>
const handleClick=(e)=>{
    console.log(e.target.innerHTML);
};

但如果通過事件代碼,我們將點擊行為委托給li共同的父元素ul,代碼將更為清晰簡單:

<ul id="ul">
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
    <li>5</li>
</ul>
const span = document.getElementById("span");
const handleClick = (e) => {
    span.innerHTML =`此時點擊的是第${e.target.innerHTML}個li`;
};
const ul = document.querySelector("#ul");
ul.addEventListener("click", handleClick)

雖然事件被代理給了ul,但通過event.target我們還是能拿到實際操作的li。這讓我想起了17我還在寫JQ的年代,當li是動態遍歷生成時,如果給li綁事件你會發現此時li其實是不存在的,這就導致事件綁定最終失敗,而事件代理在性能更優的前景下更是巧妙解決了這一問題。

OK,關於原生事件監聽與事件代理我們就介紹到這里,這部分知識也利於我們理解react的合成事件。

叄 ❀ 淺析合成事件原理(16.13.1)

叄 ❀ 壹 綁定階段前置處理

當我們訪問react官方關於合成事件的文檔,第一能獲取到的信息是react通過SyntheticEvent包裝器來統一生成合成事件。需要注意的是react並不是獨立創造了一套事件系統,所有的合成事件本質上依舊依賴了原生事件;而通過包裝器react也對原生事件做了normalize操作,以達到抹平不同瀏覽器之間事件處理差異的目的。

還記得我們在介紹原生事件時提到的事件代理嗎?出於性能優化考慮,react中的合成事件其實也做了類似處理,絕大多數的合成事件(並不是所有事件)最終都掛載在了document上,而非你定義組件的真實dom上,我們先初步了解這個概念,接下來我們通過源碼層面來了解react合成事件的綁定階段與執行階段。

注意,我在查閱資料的過程中發現,不同react版本對於合成事件的處理其實是存在差異的,比如react 17中事件就不再注冊在document上,而是你的組件所綁定的container上,我這里的源碼版本我采用的是16.13.1,另外文中源碼均可在react-dom.development.js文件中找到。

<button className="button" onClick={this.handleClick}>點擊</button>

在上文我們已經提到,react合成事件其實依賴了原生事件,那么合成事件類型自然跟原生事件有着一一對應的關系,畢竟react的點擊事件是駝峰的onClick,而原生的卻是onclick,以上述代碼為例,當react渲染到button時,發現此組件的props中有一個合成事件,理論上來說此時react要做的就是注冊操作,找到對應onClick的原生事件類型,並做后續包裝動作。

而對於事件類型,其實在react中提供了一個名為injectEventPluginsByName的事件分類插件,它會初始化階段自執行注入,通過命名可以發現react做了不同事件類型的分類:

// 用於copy injectedNamesToPlugins的全局對象
var namesToPlugins = {};

// 這里的injectedNamesToPlugins就是下面自調用注入的不同事件插件對象,我刪除了部分不影響理解的代碼
function injectEventPluginsByName(injectedNamesToPlugins) {
  var isOrderingDirty = false;
  // 遍歷所有插件對象
  for (var pluginName in injectedNamesToPlugins) {
    if (!injectedNamesToPlugins.hasOwnProperty(pluginName)) {
      continue;
    }
    // 按插件key依次獲取value
    var pluginModule = injectedNamesToPlugins[pluginName];

    if (!namesToPlugins.hasOwnProperty(pluginName) || namesToPlugins[pluginName] !== pluginModule) {
      // 將插件對象按key-value依次賦值給全局對象namesToPlugins
      namesToPlugins[pluginName] = pluginModule;
      isOrderingDirty = true;
    }
  }

  if (isOrderingDirty) {
    recomputePluginOrdering();
  }
}
// 初始化階段自執行,注入不同類型的事件插件
injectEventPluginsByName({
  SimpleEventPlugin: SimpleEventPlugin,
  EnterLeaveEventPlugin: EnterLeaveEventPlugin,
  ChangeEventPlugin: ChangeEventPlugin,
  SelectEventPlugin: SelectEventPlugin,
  BeforeInputEventPlugin: BeforeInputEventPlugin
});

出於好奇,我直接在初始化階段斷點,這里就能看到SimpleEventPlugin每個事件插件類型都包含eventTypesextractEvents兩個對象

其中extractEvents就是事件最終要執行的函數,而eventTypes則包含了合成事件對應的原生事件相關信息:

在上述代碼中的recomputePluginOrdering方法,我們繼續往下跟,能找到下面這個方法:

// 這兩個也是全局對象
var registrationNameModules = {};
var registrationNameDependencies = {};

function publishRegistrationName(registrationName, pluginModule, eventName) {
  // 建立合成事件名與事件插件的映射
  registrationNameModules[registrationName] = pluginModule;
  // 建立合成事件名與原生事件的映射
  registrationNameDependencies[registrationName] = pluginModule.eventTypes[eventName].dependencies;

  {
    var lowerCasedName = registrationName.toLowerCase();
    possibleRegistrationNames[lowerCasedName] = registrationName;

    if (registrationName === 'onDoubleClick') {
      possibleRegistrationNames.ondblclick = registrationName;
    }
  }
}

這個方法中也做了一件比較重要的事,它也為兩個全局對象做了賦值操作,其中registrationNameModules用於保存合成事件名與事件插件的映射,比如某個合成事件屬於哪個事件插件,通過斷點我們能看到這個結構:

前文我們已經說過了,每個事件類型對象都包含eventTypes、extractEvents兩個屬性,所以上圖的結構本質上等同於:

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

registrationNameDependencies用於保存合成事件與原生事件的映射關系,比如某個合成事件是由哪些原生事件組合模擬的,同樣斷個點:

所以它的結構等同於:

{
  onClick: ['click'],
	onClickCapture: ['click'],
  onClose: ['close'],
	onCloseCapture: ['close'],
  onChange: ['blur', 'change', 'click', 'focus', 'input', 'keydown', 'keyup', 'selectionchange']
	onChangeCapture: ['blur', 'change', 'click', 'focus', 'input', 'keydown', 'keyup', 'selectionchange']
}

你會驚奇的發現像合成事件onChange對應到原生居然有8個原生事件,這說明在react底層使用了多個原生事件組合模擬一個原生事件,也正因如此,react才能抹平不同瀏覽器事件差異性,讓同一個合成事件達到相同的交互效果。

上面我們其實省略了很多中間代碼,但總結來說,就是注入合成事件插件,然后對合成事件進行了多次遍歷,跟剝洋蔥似的,遍歷每個事件類型,以及每個事件類型下的每個合成事件,從而得到了多個為后續注冊服務的全局對象。

OK,前置條件說完了,那么一個組件上定義了一個onClick屬性,react是如何將它綁定到document,現在正式介紹綁定階段。

叄 ❀ 貳 綁定階段

當一個組件初始化或者更新階段,react總是要重新檢查組件身上的props屬性,看看屬性中有沒有與registrationNameModules能產生對應的,如果有那說明這個屬性是一個合成事件名,這里以更新組件為例(你想看初始化可以跟setInitialDOMProperties這個方法):

function diffHydratedProperties(domElement, tag, rawProps, parentNamespace, rootContainerElement) {

  switch (tag) {
    case 'video':
    case 'audio':
      // Create listener for each media event
      for (var i = 0; i < mediaEventTypes.length; i++) {
        // 注意,這里綁定事件傳遞的是dom自身
        trapBubbledEvent(mediaEventTypes[i], domElement);
      }
      break;

    case 'source':
      trapBubbledEvent(TOP_ERROR, domElement);
      break;

    case 'select':
      ensureListeningTo(rootContainerElement, 'onChange');
      break;

    case 'textarea':
      ensureListeningTo(rootContainerElement, 'onChange');
      break;
  }
  // 遍歷props
  for (var propKey in rawProps) {
    if (propKey === CHILDREN) {
      // 合成事件與插件映射如果能找到這個propKey,那說明是個合成事件
    } else if (registrationNameModules.hasOwnProperty(propKey)) {
      if (nextProp != null) {
        // 注冊事件,注意這里傳的是document
        ensureListeningTo(rootContainerElement, propKey);
      }
    } else if (){
      // ...
    }
  }
  return updatePayload;
}

這個方法巨長,我刪除了很多多余的代碼,這里提煉下信息,前文我們說絕大多數的事件最終都掛載在document上,原因其實就是在switch這里,像video這類媒體標簽,document沒辦法模擬它們的事件,因此綁定傳遞的dom其實是domElement,就是說你們這類元素的事件太特殊了,我代勞不了,你們還是自己綁自己的。

出於好奇,我跟了下trapBubbledEvent這個方法,下面貼一下大致過程:

function trapBubbledEvent(topLevelType, element) {
  // 調用名為trapEventForPluginEventSystem的方法
  trapEventForPluginEventSystem(element, topLevelType, false);
}
// 這個方法跟上面唯一區別就是捕獲為true
function trapCapturedEvent(topLevelType, element) {
  trapEventForPluginEventSystem(element, topLevelType, true);
}

function trapEventForPluginEventSystem(container, topLevelType, capture) {
  var listener;
	// 根據事件類型不同等級,對應生成最終的事件監聽回調
  switch (getEventPriorityForPluginSystem(topLevelType)) {
    case DiscreteEvent:
      listener = dispatchDiscreteEvent.bind(null, topLevelType, PLUGIN_EVENT_SYSTEM, container);
      break;

    case UserBlockingEvent:
      listener = dispatchUserBlockingUpdate.bind(null, topLevelType, PLUGIN_EVENT_SYSTEM, container);
      break;

    case ContinuousEvent:
    default:
      listener = dispatchEvent.bind(null, topLevelType, PLUGIN_EVENT_SYSTEM, container);
      break;
  }
  // 獲取事件名
  var rawEventName = getRawEventName(topLevelType);

  // 判斷是不是捕獲階段,分別調用最終的事件監聽方法
  if (capture) {
    addEventCaptureListener(container, rawEventName, listener);
  } else {
    addEventBubbleListener(container, rawEventName, listener);
  }
}

// 我們看下捕獲與非捕獲階段的實現
function addEventBubbleListener(element, eventType, listener) {
  // 可以看到還真是直接綁定在dom上,且捕獲為false
  element.addEventListener(eventType, listener, false);
}

function addEventCaptureListener(element, eventType, listener) {
  // 同樣綁定在原神自身,但捕獲為true
  element.addEventListener(eventType, listener, true);
}

一共三次方法調用:

  • trapBubbledEventtrapCapturedEvent是一對兄弟,它們為捕獲與非捕獲不同類型事件進行注冊
  • trapEventForPluginEventSystem是上面兩個方法都會調用的方法,在此方法內部,你會發現react還會根據事件名類型來定義不同事件等級,最終生成不同級別的事件回調callback,也就是listener,但最終它們又根據capture分別調用了不同方法。
  • 不管是addEventBubbleListener還是addEventCaptureListener,它們綁定執行都是我們再熟悉不過的addEventListener方法,只是此時的element並不是document而是元素自身。所以你會發現像video這類標簽的事件綁定對象是也就是這些標簽自己,而非document!!

OK,代碼繼續往下看,來到上上段代碼中的rawProps遍歷,這里的registrationNameModules.hasOwnProperty(propKey)就是檢驗你這個key是否存在於registrationNameModules(合成事件與事件插件的映射)中,如果有那說明你一定是個合成事件,然后咱們幫你綁定,緊接着執行:

ensureListeningTo(rootContainerElement, propKey);

注意,特殊的事件前面的switch已經做過特化處理了,能到這的肯定是平平無奇且document能代勞的事件。另外,有的同學不理解前面說的documentcontainer分別表示誰,咱們斷個點分別演示下:

上圖中我直接在createElement處斷點,看一眼rootContainerElement,你會發現所謂的container其實就是我們創建應用的container

ReactDOM.render(element, container[, callback])

那么對應到我的demo中,其實就是一個id名為rootdiv

<div id="root"></div>

那么上文提到的document又是誰呢?在源碼中有專門獲取document的代碼:

var ownerDocument = getOwnerDocumentFromRootContainer(rootContainerElement)

我們可以斷點輸出它,可以看到其實就是html

讓我們回到事件綁定函數ensureListeningTo,看看它做了什么:

// rootContainerElement是容器元素,registrationName是合成事件名
function ensureListeningTo(rootContainerElement, registrationName) {
  // 判斷我們的rootContainerElement是不是document或者代碼片段
  var isDocumentOrFragment = rootContainerElement.nodeType === DOCUMENT_NODE || rootContainerElement.nodeType === DOCUMENT_FRAGMENT_NODE;
  // 前面已經說過了這里的rootContainerElement是一個普通div,所以一定是false,取rootContainerElement.ownerDocument
  var doc = isDocumentOrFragment ? rootContainerElement : rootContainerElement.ownerDocument;
  // 這里的doc就是document
  legacyListenToEvent(registrationName, doc);
}

可能有同學看到傳遞的參數是rootContainerElement,就在想不是說綁定給document嗎,怎么傳遞的是container,其實這個方法就是為了判斷你傳遞的dom節點是不是document,是的話直接用,不是的話就取rootContainerElement.ownerDocument。而上述代碼因為我們已知rootContainerElement是一個div容器,因此isDocumentOrFragment一定是false,那么doc取值就自然是rootContainerElement.ownerDocument,我們同樣可以斷點看看這個屬性輸出什么,結果如下:

其實還是document......總結來說,ensureListeningTo方法就是為了確保你的事件最終幫在document上!

方法最后執行了legacyListenToEvent,同理看看代碼:

// registrationName合成事件名 mountAt是document
function legacyListenToEvent(registrationName, mountAt) {
  // 拿到document上目前已經監聽過的對象
  var listenerMap = getListenerMapForElement(mountAt);
  // 獲取合成事件名所對應的原生事件數組
  var dependencies = registrationNameDependencies[registrationName];
	// 遍歷原生對象數組,依次調用legacyListenToTopLevelEvent進行掛載
  for (var i = 0; i < dependencies.length; i++) {
    var dependency = dependencies[i];
    legacyListenToTopLevelEvent(dependency, mountAt, listenerMap);
  }
}

這個方法大家看注釋應該就很清楚了,registrationNameDependencies就是前面我們專門解釋過的合成事件名與對應原生事件的映射,然后遍歷開始進行注冊。OK,我們接着看registrationNameDependencies代碼:

// topLevelType原生事件名  mountAt此時是document listenerMap ducument此時已經監聽過的對象
function legacyListenToTopLevelEvent(topLevelType, mountAt, listenerMap) {
  // 已經監聽過的就不要重復監聽了,沒監聽過的才會執行內部代碼
  if (!listenerMap.has(topLevelType)) {
    switch (topLevelType) {
      case TOP_SCROLL:
        trapCapturedEvent(TOP_SCROLL, mountAt);
        break;

      case TOP_FOCUS:
      case TOP_BLUR:
        trapCapturedEvent(TOP_FOCUS, mountAt);
        trapCapturedEvent(TOP_BLUR, mountAt); // We set the flag for a single dependency later in this function,
			// 這里我刪除了一部分代碼
      default:
        // By default, listen on the top level to all non-media events.
        // Media events don't bubble so adding the listener wouldn't do anything.
        var isMediaEvent = mediaEventTypes.indexOf(topLevelType) !== -1;

        if (!isMediaEvent) {
          trapBubbledEvent(topLevelType, mountAt);
        }

        break;
    }

    listenerMap.set(topLevelType, null);
  }
}

legacyListenToTopLevelEvent方法看着很長,但其實做的事情很簡單,根據已知的listenerMap判斷當前原生事件之前有沒有被綁定沒,沒綁定那就執行綁定,而方法內部一共就只出現了trapCapturedEventtrapBubbledEvent這兩個綁定事件方法,大家可以直接在本文搜索這兩個方法名,你會發現它兩就是上文已經解釋過的兩個方法,而且最終都走到了element.addEventListener(eventType, listener, true/false)這一句。

那么到這里,我們完整解釋了事件監聽階段的整個過程,你知道了不同合成事件是如何對應到原生事件,以及最終是怎么樣掛在到document亦或者元素自身之上的,那么我們緊接着介紹執行階段。

叄 ❀ 叄 執行階段

在說執行階段之前,我們還是得想一想執行階段執行什么,在綁定階段,我們知道最終react還是會執行如下代碼:

element.addEventListener(eventType, listener, false);

而這里的listener照理說就應該是事件觸發后執行的callback,那這個listener是怎么生成的?它跟我寫在react代碼中真正的執行回調又是如何關聯的?這就得再次回到上面已經解釋過的trapEventForPluginEventSystem方法。

trapEventForPluginEventSystem方法中我們說會根據事件優先級分別調用dispatchDiscreteEventdispatchUserBlockingUpdate或者dispatchEvent來生成listener,但我在嘗試跟前兩個方法過程中發現,這兩個方法最終都是調用了dispatchEvent這個方法,以dispatchUserBlockingUpdate為例:

listener = dispatchUserBlockingUpdate.bind(null, topLevelType, PLUGIN_EVENT_SYSTEM, container);

function dispatchUserBlockingUpdate(topLevelType, eventSystemFlags, container, nativeEvent) {
  // 本質上還是調用的dispatchEvent.bind()來生成的listener
  runWithPriority(UserBlockingPriority, dispatchEvent.bind(null, topLevelType, eventSystemFlags, container, nativeEvent));
}

因此我們只用將目光放到dispatchEvent上即可,上代碼:

/**
 * 
 * @param {*} topLevelType 原生事件名
 * @param {*} eventSystemFlags 一個數字常量1
 * @param {*} container 監聽事件的容器
 * @param {*} nativeEvent event對象
 * @returns 
 */
function dispatchEvent(topLevelType, eventSystemFlags, container, nativeEvent) {
	// 刪除多余代碼
  {
    // 最終又調用了一個han'shu
    dispatchEventForLegacyPluginEventSystem(topLevelType, eventSystemFlags, nativeEvent, null);
  }
} 

function dispatchEventForLegacyPluginEventSystem(topLevelType, eventSystemFlags, nativeEvent, targetInst) {
  var bookKeeping = getTopLevelCallbackBookKeeping(topLevelType, nativeEvent, targetInst, eventSystemFlags);

  try {
    // Event queue being processed in the same cycle allows
    // `preventDefault`.
    batchedEventUpdates(handleTopLevel, bookKeeping);
  } finally {
    releaseTopLevelCallbackBookKeeping(bookKeeping);
  }
}

我們省略了dispatchEvent中多余的代碼,發現它最終執行了dispatchEventForLegacyPluginEventSystem,進一步跟進,此方法一共做了三件事,獲取bookKeeping對象,調用批量事件更新事件batchedEventUpdates(本質上又調用handleTopLevel),以及調用完成后又執行releaseTopLevelCallbackBookKeeping存儲bookKeeping方法達到復用目的,無奈繼續看handleTopLevel實現:

function handleTopLevel(bookKeeping) {
  var targetInst = bookKeeping.targetInst; 
  var ancestor = targetInst;
	// 這里一直在while,遍歷保存現有dom結構
  do {
    if (!ancestor) {
      var ancestors = bookKeeping.ancestors;
      ancestors.push(ancestor);
      break;
    }
		// 尋找當前節點信息的父節點,往上冒泡
    var root = findRootContainerNode(ancestor);

    if (!root) {
      break;
    }

    var tag = ancestor.tag;
    if (tag === HostComponent || tag === HostText) {
      bookKeeping.ancestors.push(ancestor);
    }
    ancestor = getClosestInstanceFromNode(root);
  } while (ancestor);
	
  for (var i = 0; i < bookKeeping.ancestors.length; i++) {
    targetInst = bookKeeping.ancestors[i];
    var eventTarget = getEventTarget(bookKeeping.nativeEvent);
    var topLevelType = bookKeeping.topLevelType;
    var nativeEvent = bookKeeping.nativeEvent;
    var eventSystemFlags = bookKeeping.eventSystemFlags; // If this is the first ancestor, we mark it on the system flags

    if (i === 0) {
      eventSystemFlags |= IS_FIRST_ANCESTOR;
    }
		// 最終生成合成事件的方法
    runExtractedPluginEventsInBatch(topLevelType, targetInst, nativeEvent, eventTarget, eventSystemFlags);
  }
}

handleTopLevel方法的作用其實注釋有解釋,考慮到事件回調可能改變現有的DOM結構,導致先深度遍歷保存現有的組件層次結構。而從代碼解釋上來看,findRootContainerNode很明顯就是在找當前節點元素的父元素,如果有父繼續while循環,這很明顯就是在做一個冒泡操作,緊接着下面的for循環也正是在根據冒泡的順序依次調用runExtractedPluginEventsInBatch來生成合成事件。

function runExtractedPluginEventsInBatch(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags) {
  // 生成合成事件
  var events = extractPluginEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags);
  // 執行事件
  runEventsInBatch(events);
}

可能你現在看到extractPluginEvents已經有點陌生了,但在文章前面我們介紹合成事件名與事件插件映射屬性registrationNameModules時,有介紹每個對象上都有一個extractEvents屬性,而這個屬性就是為了將我們代碼中所寫的事件回調,綁定到生成的合成事件上:

function extractPluginEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags) {
  var events = null;

  for (var i = 0; i < plugins.length; i++) {
    var possiblePlugin = plugins[i];
    if (possiblePlugin) {
      // 獲取每個事件插件的extractEvents方法,用於生成合成事件
      var extractedEvents = possiblePlugin.extractEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags);

      if (extractedEvents) {
        events = accumulateInto(events, extractedEvents);
      }
    }
  }

  return events;
}

這個方法內有一個全局對象plugins,這個玩意其實就是一個存放了事件插件對象的數組:

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

所以上述其實就在遍歷事件插件,並嘗試生成對應的合成事件,因此我們可以看看extractEvents的內部實現:

extractEvents: function (topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags) {
  var dispatchConfig = topLevelEventsToDispatchConfig.get(topLevelType);

  if (!dispatchConfig) {
    return null;
  }

  var EventConstructor;

  switch (topLevelType) {
    case TOP_KEY_PRESS:
      if (getEventCharCode(nativeEvent) === 0) {
        return null;
      }
    case TOP_KEY_DOWN:
    case TOP_KEY_UP:
      EventConstructor = SyntheticKeyboardEvent;
      break;

    case TOP_BLUR:
    case TOP_FOCUS:
      EventConstructor = SyntheticFocusEvent;
      break;
    default:
      EventConstructor = SyntheticEvent;
      break;
  }

  var event = EventConstructor.getPooled(dispatchConfig, targetInst, nativeEvent, nativeEventTarget);
  accumulateTwoPhaseDispatches(event);
  return event;
}

代碼中我省略了一部分case分支情況,但不管哪種情況,都會出現類似SyntheticFocusEvent以及SyntheticKeyboardEvent這類方法,稍微看了實現代碼,發現這些構建器其實都是通過SyntheticEvent.extend繼承而來的子類,而且在代碼最后的switch default執行,默認也賦予SyntheticEvent這個構造器。

在拿到構造器后緊接着調用了EventConstructor.getPooled從事件池中獲取合成事件實例,這也解釋了為什么react官網一開始就說合成事件是由SyntheticEvent包裝器生成而來。

我們可以上述代碼中的accumulateTwoPhaseDispatches繼續往下跟:

function accumulateTwoPhaseDispatchesSingle(event) {
  if (event && event.dispatchConfig.phasedRegistrationNames) {
    traverseTwoPhase(event._targetInst, accumulateDirectionalDispatches, event);
  }
}

// 模擬兩個階段的遍歷,捕獲/冒泡事件分派。
function traverseTwoPhase(inst, fn, arg) {
  var path = [];
  while (inst) {
    path.push(inst);
    inst = getParent(inst);
  }
  var i;
  for (i = path.length; i-- > 0;) {
    fn(path[i], 'captured', arg);
  }
  for (i = 0; i < path.length; i++) {
    fn(path[i], 'bubbled', arg);
  }
}

function accumulateDirectionalDispatches(inst, phase, event) {
  var listener = listenerAtPhase(inst, event, phase);
  if (listener) {
    event._dispatchListeners = accumulateInto(event._dispatchListeners, listener);
    event._dispatchInstances = accumulateInto(event._dispatchInstances, inst);
  }
}

其中traverseTwoPhase方法至關重要,這個函數的官方注釋也說的尤為清楚,通過正向反向遍歷模擬事件捕獲與事件冒泡階段,而它所執行的fn其實就是函數accumulateDirectionalDispatches,這個函數內部的主要職責便是找到節點上事件定義的回調,並將其加入到生成的合成事件event_dispatchListeners屬性中,直到這里,我們走完了合成事件的生成(onClickCaptureonClick執行順序差異原來是在合成事件生成階段通過不同方向遍歷來綁定模擬的)以及與合成事件我們定義的callback建立聯系。

讓我們再次回到runExtractedPluginEventsInBatch方法,去看一看runEventsInBatch方法。

function runEventsInBatch(events) {
  var processingEventQueue = eventQueue;
  eventQueue = null;
	// 刪除多余代碼,最終執行
  forEachAccumulated(processingEventQueue, executeDispatchesAndReleaseTopLevel);
}


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

代碼很簡單,然后我的目光就被executeDispatchesAndReleaseTopLevel這個方法所吸引,直譯過來就是事件執行派發與釋放,因此我們繼續跟進executeDispatchesAndRelease這個方法:

var executeDispatchesAndRelease = function (event) {
  // 如果事件存在,那就按順序執行派發事件
  if (event) {
    executeDispatchesInOrder(event);
    if (!event.isPersistent()) {
      event.constructor.release(event);
    }
  }
};

function executeDispatchesInOrder(event) {
  var dispatchListeners = event._dispatchListeners;
  var dispatchInstances = event._dispatchInstances;
  {
    validateEventDispatches(event);
  }
  if (Array.isArray(dispatchListeners)) {
    for (var i = 0; i < dispatchListeners.length; i++) {
      if (event.isPropagationStopped()) {
        break;
      }

      executeDispatch(event, dispatchListeners[i], dispatchInstances[i]);
    }
  }
}

進一步跟進,終於定位到了executeDispatchesInOrder方法,而且我們甚至看到了在合成事件生成階段,將事件回調與合成事件與之關聯的event._dispatchListeners對象,在此方法內部就是按照綁定順序,依次遍歷進行執行。

那么經過長篇大論的代碼跟蹤,我們總算是粗略的跟完了合成事件的生成、綁定與執行三個階段

肆 ❀ 合成事件與原生事件執行先后

在了解完合成事件后,我不禁有一個疑問,如果我給一個dom同時綁定合成事件與原生事件,到底誰會先執行呢?來看個例子:

class Echo extends Component {
  componentDidMount() {
    const parentDom = ReactDOM.findDOMNode(this);
    const childrenDom = parentDom.querySelector(".button");
    childrenDom.addEventListener('click', this.onDomClick, false);
  }
  onDomClick = (e) => {
    console.log('原生事件click');
  }
  onReactClick = () => {
    console.log('合成事件click');
  }
  render() {
    return (
      <div>
        <button className="button" onClick={this.onReactClick}>點擊</button>
      </div>
    )
  }
}

為什么原生事件比合成事件快呢?通過上面的源碼分析,其實很容易聯想到,在冒泡到document之前,原生事件已經被觸發,這之后才到了document開始事件派發,遍歷數組進行react合成事件callback的執行,合成事件慢的合情合理。

哎?那如果我們同時給一個dom綁定原生捕獲事件與合成捕獲事件呢?那按照這個說法,document在最頂層,那是不是應該合成捕獲事件要早於原生捕獲事件執行呢?來看個例子:

class Echo extends Component {
  componentDidMount() {
    const parentDom = ReactDOM.findDOMNode(this);
    const childrenDom = parentDom.querySelector(".button");
    childrenDom.addEventListener('click', this.onDomClick, true);
  }
  onDomClick = (e) => {
    console.log('原生事件捕獲click');
  }
  onReactClick = () => {
    console.log('合成事件捕獲click');
  }
  render() {
    return (
      <div>
        <button className="button" onClickCapture={this.onReactClick}>點擊</button>
      </div>
    )
  }
}

怎么還是原生事件早於合成事件的捕獲階段?????

在合成事件生成源碼分析中,我們介紹了handleTopLevel方法提到,合成事件是在當前節點冒泡不斷向上搜集同名的合成事件回調,並且在traverseTwoPhase這個方法中,通過正向負向兩個遍歷,去模擬的捕獲與冒泡,說直白,根本不存在所謂的合成事件捕獲,其實全都是靠冒泡搜集事件后,控制遍歷順序,來模擬了捕獲與冒泡的事件執行順序!!!

function traverseTwoPhase(inst, fn, arg) {
  var path = [];
  while (inst) {
    path.push(inst);
    inst = getParent(inst);
  }
  var i;
  // 捕獲倒序遍歷
  for (i = path.length; i-- > 0;) {
    fn(path[i], 'captured', arg);
  }
  for (i = 0; i < path.length; i++) {
    // 冒泡正向遍歷
    fn(path[i], 'bubbled', arg);
  }
}

因此合成事件的捕獲,說到底還是在原生事件冒泡之后,因為我不冒泡事件你都沒搜集其,捕獲個啥呢?

所以總結來說,合成事件不管捕獲還是冒泡都晚於原生事件,結合之前的源碼分析,非常合情合理!!來看下面這個例子加深印象:

class Echo extends Component {
  componentDidMount() {
    const parentDom = ReactDOM.findDOMNode(this);
    const childrenDom = parentDom.querySelector(".button");

    childrenDom.addEventListener('click', this.onDomChildClick, false);
    childrenDom.addEventListener('click', this.onDomChildClickCapture, true);
    parentDom.addEventListener('click', this.onDomParentClick, false);
    parentDom.addEventListener('click', this.onDomParentClickCapture, true);
  }
  onDomChildClick = (e) => {
    console.log('原生事件child--冒泡');
  }
  onDomChildClickCapture = (e) => {
    console.log('原生事件child--捕獲');
  }
  onDomParentClick = (e) => {
    console.log('原生事件parent--冒泡');
  }
  onDomParentClickCapture = (e) => {
    console.log('原生事件parent--捕獲');
  }
  onReactChildClick = () => {
    console.log('合成事件child--捕獲');
  }
  onReactParentClick = () => {
    console.log('合成事件parent--捕獲');
  }
  render() {
    return (
      <div className="parent" onClickCapture={this.onReactParentClick}>
        <button className="button" onClick={this.onReactChildClick}>點擊</button>
      </div>
    )
  }
}

總結合成事件與原生事件執行順序:

  • 合成事件不管冒泡階段還是捕獲階段,都要晚於原生事件冒泡階段
  • 不管合成事件還是原生事件,冒泡階段都要晚於捕獲階段

伍 ❀ 阻止原生事件冒泡,會阻斷合成事件執行嗎

相信到這里,你應該能不假思索的回答,如果在原生事件中阻止冒泡,那么事件執行都到不了document,合成事件自然沒機會去執行了,還是上面那個例子,我們修改如下代碼:

onDomChildClick = (e) => {
  e.stopPropagation()
  console.log('原生事件child--冒泡');
}

在子元素原生冒泡階段阻止冒泡,可以看到執行如下,整個合成事件都被阻止執行了。

原因其實在上面源碼分析的executeDispatchesInOrder方法中已經給出了答案:

if (Array.isArray(dispatchListeners)) {
  for (var i = 0; i < dispatchListeners.length; i++) {
    // 如果阻止冒泡,直接break跳出循環
    if (event.isPropagationStopped()) {
      break;
    }
    executeDispatch(event, dispatchListeners[i], dispatchInstances[i]);
}}

反過來呢?如果我們在合成事件冒泡階段阻止冒泡,會影響原生事件嗎?我想你心里已經有答案了:

class Echo extends Component {
  componentDidMount() {
    const parentDom = ReactDOM.findDOMNode(this);
    const childrenDom = parentDom.querySelector(".button");
    childrenDom.addEventListener('click', this.onDomChildClick, false);
    parentDom.addEventListener('click', this.onDomParentClick, false);
  }
  onDomChildClick = (e) => {
    console.log('原生事件child--冒泡');
  }
  onDomParentClick = (e) => {
    console.log('原生事件parent--冒泡');
  }
  onReactChildClick = (e) => {
    e.stopPropagation()
    console.log('合成事件child--冒泡');
  }
  onReactParentClick = (e) => {
    console.log('合成事件parent--冒泡');
  }
  render() {
    return (
      <div className="parent" onClick={this.onReactParentClick}>
        <button className="button" onClick={this.onReactChildClick}>點擊</button>
      </div>
    )
  }
}

那么到這里,我們就解釋了合成事件阻止冒泡對於原生事件的影響,當然在實際開發中,我們盡量還是別混用原生事件與合成事件。

陸 ❀ 總

那么到這里,我大概闡述完了本文想要表達的觀點,本文從最初立初步知識概念,到讀源碼梳理知識點,前前后后花了一個星期的零散時間,說來也是慚愧。不過到最后我對於這塊的知識點還是清楚了一些,至少在面試階段如果有面試官問到,多少是能聊一點了。而文中談到的原生事件是否會阻塞合成事件這個問題,也確實是我同事在面試金山過程中所被問到的問題,現在你也已經知道答案了。

通過本文,你順帶復習了原生事件監聽與代理,也粗略了解了react 16.13.1版本對於合成事件的底層實現;也正因如此我們順利解釋了原生事件與合成事件的執行差異,以及阻止冒泡對於彼此的影響,那么到這里本文結束。

參考

React合成事件和DOM原生事件混用須知

深入學習 React 合成事件

由淺到深的React合成事件

React 事件系統工作原理


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM