React17 事件機制

由於 React16 和 React17 在事件機制在細節上有較大改動,本文僅對 React17 的事件機制做講解,在最後對比 React17 和 React16 在事件機制上的不同點。

前置知識

事件傳播機制

一般的事件觸發都會經歷三個階段:

  1. 捕獲階段,事件從 window 開始,自上而下一直傳播到目標元素的階段。

  2. 目標階段,事件真正的觸發元素處理事件的階段。

  3. 冒泡階段,從目標元素開始,自下而上一直傳播到 window 的階段。

如果想阻止事件的傳播,可以在指定節點的事件監聽器通過event.stopPropagation()event.cancelBubble = true阻止事件傳播。

有些事件是沒有冒泡階段的,如 scroll、blur、及各種媒體事件等。

綁定事件的方法

<div onclick="handleClick()">
  test
</div>
<script>
  let handleClick = function(){
    // 一些處理代碼..
  }
  // 移除事件
  handleClick = function(){}
</script>

缺點:js 和 html 代碼耦合了。

<div id="test">
  test
</div>
<script>
  let target = document.getElementById('test')
  // 綁定事件
  target.onclick = function(){
    // 一些處理代碼..
  }
  target.onclick = function(){
    // 另外一些處理代碼...會覆蓋上面的
  }
  // 移除事件
  target.onclick = null
</script>

缺點:作爲屬性使用,一次只能綁定一個事件,多次賦值會覆蓋,只能處理冒泡階段

<div id="test">
  test
</div>
<script>
  let target = document.getElementById('test')
  // 綁定事件
  let funcA = function(){
    // 一些處理代碼..
  }
  let funcB = function(){
    // 一些處理代碼..
  }
  // 添加冒泡階段監聽器
  target.addEventListener('click',funcA,false)
  // 添加捕獲階段監聽器
  target.addEventListener('click',funcB,true)
  // 移除監聽器
  target.removeEventListener('click', funcA)
</script>

就是爲了綁定事件而生的 api,拓展性最強,現在開發者一般都用 addEventListener 綁定事件監聽器。

事件委託

當節點的數量較多時,如果給每個節點都進行事件綁定的話,內存消耗大,可將事件綁定到其父節點上統一處理,減少事件綁定的數量。

<ul id="parent">
  <li>1</li>
  <li>2</li>
  <li>3</li>
  ....
  <li>999</li>
  <li>1000</li>
</ul>
<script>
  let parent = document.getElementById('parent')
  parent.addEventListener('click',(e)=>{
    // 根據e.target進行處理
  })
</script>

瀏覽器事件差異

由於瀏覽器廠商的實現差異,在事件的屬性及方法在不同瀏覽器及版本上略有不同,開發者爲兼容各瀏覽器及版本之間的差異,需要編寫兼容代碼,要麼重複編寫模板代碼,要麼將磨平瀏覽器差異的方法提取出來。

// 阻止事件傳播
function stopPropagation(e){
  if(typeof e.stopPropagation === 'function'){
    e.stopPropagation()
  }else{
    // 兼容ie
    e.cancelBubble = true
  }
}
// 阻止默認事件
function preventDefault(e){
  if(typeof e.preventDefault === 'function'){
    e.preventDefault()
  }else{
    // 兼容ie
    e.returnValue = false
  }
}
// 獲取事件觸發元素
function getEventTarget(e){
  let target = e.target || e.srcElement || window;
}
// 還有事件的各種屬性如e.relatedTarget等等

爲什麼 React 實現了自己的事件機制

實現細節

事件分類

React 對在 React 中使用的事件進行了分類,具體通過各個類型的事件處理插件分別處理:

這裏的分類是對 React 事件進行分類的,簡單事件如onClickonClickCapture,它們只依賴了原生事件click。而有些事件是由 React 統一包裝給用戶使用的,如onChange,它依賴了['change','click','focusin','focusout','input','keydown','keyup','selectionchange'],這是 React 爲了兼容不同表單的修改事件收集,如對於<input type="checkbox" /><input type="radio" />開發者原生需要使用click事件收集表單變更後的值,而在 React 中可以統一使用onChange來收集。

分類並不代表依賴的原生事件之間沒有交集。 如簡單事件中有onKeyDown,它依賴於原生事件keydown。輸入前事件有onCompositionStart,它也依賴了原生事件keydown。表單修改事件onChange,它也依賴了原生事件keydown

事件收集

由於 React 需要對所有的事件做代理委託,所以需要事先知道瀏覽器支持的所有事件,這些事件都是硬編碼在 React 源碼的各個事件插件中的。

而對於所有需要代理的原生事件,都會以原生事件名字符串的形式存儲在一個名爲allNativeEvents的集合中,並且在registrationNameDependencies中存儲 React 事件名到其依賴的原生事件名數組的映射。

而事件的收集是通過各個事件處理插件各自收集註冊的,在頁面加載時,會執行各個插件的registerEvents,將所有依賴的原生事件都註冊到allNativeEvents中去,並且在registrationNameDependencies中存儲映射關係。

對於原生事件不支持冒泡階段的事件,硬編碼的形式存儲在了nonDelegatedEvents集合中,原生不支持冒泡階段的事件在後續的事件代理環節有不一樣的處理方式。

後面的描述中,對於 nonDelegatedEvents,稱爲非代理事件。其他的事件稱爲代理事件。他們的區別在於原生事件是否支持冒泡。

// React代碼加載時就會執行以下js代碼
SimpleEventPlugin.registerEvents();
EnterLeaveEventPlugin.registerEvents();
ChangeEventPlugin.registerEvents();
SelectEventPlugin.registerEvents();
BeforeInputEventPlugin.registerEvents();

// 上述代碼執行完後allNativeEvents集合中就會有cancel、click等80種事件
allNativeEvents = ['cancel','click', ...]

// nonDelegatedEvents有cancel、close等29種事件
nonDelegatedEvents = ['cancel','close',...]

// registrationNameDependencies保存react事件和其依賴的事件的映射
registrationNameDependencies = {
  onClick: ['click'],
  onClickCapture: ['click'],
  onChange: ['change','click','focusin','focusout','input','keydown','keyup','selectionchange'],
  ...
}

事件代理

可代理事件

將事件委託代理到根的操作發生在ReactDOM.render(element, container)時。

ReactDOM.render的實現中,在創建了fiberRoot後,在開始構造fiber樹前,會調用listenToAllSupportedEvents進行事件的綁定委託。

const listeningMarker =
  '_reactListening' +
  Math.random()
    .toString(36)
    .slice(2);
export function listenToAllSupportedEvents(rootContainerElement: EventTarget) {
  if (enableEagerRootListeners) {
    if ((rootContainerElement: any)[listeningMarker]) {
      // 避免重複初始化
      return;
    }
    // 將該根元素標記爲已初始化事件監聽
    (rootContainerElement: any)[listeningMarker] = true;
    allNativeEvents.forEach(domEventName ={
      if (!nonDelegatedEvents.has(domEventName)) {
        listenToNativeEvent(
          domEventName,
          false,
          ((rootContainerElement: any): Element),
          null,
        );
      }
      listenToNativeEvent(
        domEventName,
        true,
        ((rootContainerElement: any): Element),
        null,
      );
    });
  }
}

可以看到,首先會判斷根上的事件監聽器相關的字段是否已標記完成過監聽,如果沒有完成,則將根標記爲已監聽過,並遍歷allNativeEvents進行事件的委託綁定。是否完成監聽的判斷是避免多次調用ReactDOM.render(element, container)是對同一個container重複委託事件。

listenToNativeEvent即對元素進行事件綁定的方法,第二個參數的含義是是否將監聽器綁定在捕獲階段。 由此我們可以看到,對於不存在冒泡階段的事件,React 只委託了捕獲階段的監聽器,而對於其他的事件,則對於捕獲階段和冒泡階段都委託了監聽器。

listenToNativeEvent的內部會將綁定了入參的dispatchEvent使用addEventListener綁定到根元素上。

export function dispatchEvent(
  domEventName: DOMEventName, // 原生事件名
  eventSystemFlags: EventSystemFlags, // 事件標記,如是否捕獲階段
  targetContainer: EventTarget, // 綁定事件的根
  nativeEvent: AnyNativeEvent, // 實際觸發時傳入的真實事件對象
): void {
    //... 前三個參數在綁定到根上時已傳入
}
// 提前綁定入參
const listener = dispatchEvent.bind(
  null,
  targetContainer,
  domEventName,
  eventSystemFlags,
)
if(isCapturePhaseListener){
    addEventCaptureListener(targetContainer,domEventName,listener)
}else{
    addEventBubbleListener(targetContainer,domEventName,listener)
}

// 添加冒泡事件監聽器
export function addEventBubbleListener(
  target: EventTarget,
  eventType: string,
  listener: Function,
): Function {
  target.addEventListener(eventType, listener, false);
  return listener;
}
// 添加捕獲事件監聽器
export function addEventCaptureListener(
  target: EventTarget,
  eventType: string,
  listener: Function,
): Function {
  target.addEventListener(eventType, listener, true);
  return listener;
}

圖示:代理事件在根元素上綁定了捕獲和冒泡階段的回調

圖示:非代理事件在根元素上只綁定了捕獲階段的回調

非代理事件

對於非代理事件nonDelegatedEvents,由於這些事件不存在冒泡階段,所以我們在根部代理他們的冒泡階段監聽器也不會觸發,所以需要特殊處理。

實際上這些事件的代理發生在 DOM 實例的創建階段,也就是render階段的completeWork階段。通過調用finalizeInitialChildren爲 DOM 實例設置屬性時,判斷 DOM 節點類型來添加響應的冒泡階段監聽器。 如爲<img /><link />標籤對應的 DOM 實例添加errorload的監聽器。

export function setInitialProperties(
  domElement: Element,
  tag: string,
  rawProps: Object,
  rootContainerElement: Element | Document,
):void {
  // ...
  switch (tag) {
    // ...
    case 'img':
    case 'image':
    case 'link':
        listenToNonDelegatedEvent('error', domElement);
        listenToNonDelegatedEvent('load', domElement);
        break;
    // ...
  }
  // ...
}
// 非代理事件監聽器綁定
export function listenToNonDelegatedEvent(
  domEventName: DOMEventName,
  targetElement: Element,
): void {
  // 綁定在目標/冒泡階段
  const isCapturePhaseListener = false;
  const listenerSet = getEventListenerSet(targetElement);
  const listenerSetKey = getListenerSetKey(
    domEventName,
    isCapturePhaseListener,
  );
  if (!listenerSet.has(listenerSetKey)) {
    addTrappedEventListener(
      targetElement,
      domEventName,
      IS_NON_DELEGATED,// 非代理事件
      isCapturePhaseListener,// 目標/冒泡階段
    );
    listenerSet.add(listenerSetKey);
  }
}

圖示:img元素上綁定了非代理事件errorload冒泡階段回調

實際上 React 對這些不可冒泡的事件都進行了冒泡模擬。

但在 React17 中去掉了 scroll 事件的冒泡模擬。

合成事件

合成事件SyntheticEvent是 React 事件系統對於原生事件跨瀏覽器包裝器。它除了兼容所有瀏覽器外,它還擁有和瀏覽器原生事件相同的接口,包括 stopPropagation()preventDefault()

如果因爲某些原因,當你需要使用瀏覽器的底層事件時,只需要使用 nativeEvent 屬性來獲取即可。

合成事件的使用

// 傳統html綁定事件
<button onclick="activateLasers()">
  test
</button>

// 在React中綁定事件
<button onClick={activateLasers}>
  test
</button>

在 React 事件中不同通過返回 false 阻止默認行爲,必須顯示調用event.preventDefault()

由於 React 事件執行回調時的上下文並不在組件內部,所以還需要注意 this 的指向問題

磨平瀏覽器差異

React 通過事件normalize以讓他們在不同瀏覽器中擁有一致的屬性。

React 聲明瞭各種事件的接口,以此來磨平瀏覽器中的差異:

// 基礎事件接口,timeStamp需要磨平差異
const EventInterface = {
  eventPhase: 0,
  bubbles: 0,
  cancelable: 0,
  timeStamp: function(event) {
    return event.timeStamp || Date.now();
  },
  defaultPrevented: 0,
  isTrusted: 0,
};
// UI事件接口,繼承基礎事件接口
const UIEventInterface: EventInterfaceType = {
  ...EventInterface,
  view: 0,
  detail: 0,
};
// 鼠標事件接口,繼承UI事件接口,getModifierState,relatedTarget、movementX、movementY等字段需要磨平差異
const MouseEventInterface: EventInterfaceType = {
  ...UIEventInterface,
  screenX: 0,
  screenY: 0,
  clientX: 0,
  clientY: 0,
  pageX: 0,
  pageY: 0,
  ctrlKey: 0,
  shiftKey: 0,
  altKey: 0,
  metaKey: 0,
  getModifierState: getEventModifierState,
  button: 0,
  buttons: 0,
  relatedTarget: function(event) {
    if (event.relatedTarget === undefined)
      return event.fromElement === event.srcElement
        ? event.toElement
        : event.fromElement;

    return event.relatedTarget;
  },
  movementX: function(event) {
    if ('movementX' in event) {
      return event.movementX;
    }
    updateMouseMovementPolyfillState(event);
    return lastMovementX;
  },
  movementY: function(event) {
    if ('movementY' in event) {
      return event.movementY;
    }
    // Don't need to call updateMouseMovementPolyfillState() here
    // because it's guaranteed to have already run when movementX
    // was copied.
    return lastMovementY;
  },
};
// 指針類型,繼承鼠標事件接口。還有很多其他事件類型接口。。。。。。
const PointerEventInterface = {
  ...MouseEventInterface,
  pointerId: 0,
  width: 0,
  height: 0,
  pressure: 0,
  tangentialPressure: 0,
  tiltX: 0,
  tiltY: 0,
  twist: 0,
  pointerType: 0,
  isPrimary: 0,
};

由於不同的類型的事件其字段有所不同,所以 React 實現了針對事件接口的合成事件構造函數的工廠函數。 通過傳入不一樣的事件接口返回對應事件的合成事件構造函數,然後在事件觸發回調時根據觸發的事件類型判斷使用哪種類型的合成事件構造函數來實例化合成事件。

// 輔助函數,永遠返回true
function functionThatReturnsTrue() {
  return true;
}
// 輔助函數,永遠返回false
function functionThatReturnsFalse() {
  return false;
}
// 合成事件構造函數的工廠函數,根據傳入的事件接口返回對應的合成事件構造函數
function createSyntheticEvent(Interface: EventInterfaceType) {

  // 合成事件構造函數
  function SyntheticBaseEvent(
    reactName: string | null,
    reactEventType: string,
    targetInst: Fiber,
    nativeEvent: {[propName: string]: mixed},
    nativeEventTarget: null | EventTarget,
  ) {
    // react事件名
    this._reactName = reactName;
    // 當前執行事件回調時的fiber
    this._targetInst = targetInst;
    // 真實事件名
    this.type = reactEventType;
    // 原生事件對象
    this.nativeEvent = nativeEvent;
    // 原生觸發事件的DOM target
    this.target = nativeEventTarget;
    // 當前執行回調的DOM
    this.currentTarget = null;

    // 下面是磨平字段在瀏覽器間的差異
    for (const propName in Interface) {
      if (!Interface.hasOwnProperty(propName)) {
        // 該接口沒有這個字段,不拷貝
        continue;
      }
      // 拿到事件接口對應的值
      const normalize = Interface[propName];
      // 如果接口對應字段函數,進入if分支,執行函數拿到值
      if (normalize) {
        // 獲取磨平了瀏覽器差異後的值
        this[propName] = normalize(nativeEvent);
      } else {
        // 如果接口對應值是0,則直接取原生事件對應字段值
        this[propName] = nativeEvent[propName];
      }
    }
    // 磨平defaultPrevented的瀏覽器差異,即磨平e.defaultPrevented和e.returnValue的表現
    const defaultPrevented =
      nativeEvent.defaultPrevented != null
        ? nativeEvent.defaultPrevented
        : nativeEvent.returnValue === false;
    if (defaultPrevented) {
      // 如果在處理事件時已經被阻止默認操作了,則調用isDefaultPrevented一直返回true
      this.isDefaultPrevented = functionThatReturnsTrue;
    } else {
      // 如果在處理事件時沒有被阻止過默認操作,則先用返回false的函數
      this.isDefaultPrevented = functionThatReturnsFalse;
    }
    // 默認執行時間時,還沒有被阻止繼續傳播,所以調用isPropagationStopped返回false
    this.isPropagationStopped = functionThatReturnsFalse;
    return this;
  }
  // 合成事件重要方法的包裝
  Object.assign(SyntheticBaseEvent.prototype, {
    preventDefault: function() {
      // 調用後設置defaultPrevented
      this.defaultPrevented = true;
      const event = this.nativeEvent;
      if (!event) {
        return;
      }
      // 下面是磨平e.preventDefault()和e.returnValue=false的瀏覽器差異,並在原生事件上執行
      if (event.preventDefault) {
        event.preventDefault();
        // $FlowFixMe - flow is not aware of `unknown` in IE
      } else if (typeof event.returnValue !== 'unknown') {
        event.returnValue = false;
      }
      // 然後後續回調判斷時都會返回true
      this.isDefaultPrevented = functionThatReturnsTrue;
    },

    stopPropagation: function() {
      const event = this.nativeEvent;
      if (!event) {
        return;
      }
      // 磨平e.stopPropagation()和e.calcelBubble = true的差異,並在原生事件上執行
      if (event.stopPropagation) {
        event.stopPropagation();
        // $FlowFixMe - flow is not aware of `unknown` in IE
      } else if (typeof event.cancelBubble !== 'unknown') {
        // The ChangeEventPlugin registers a "propertychange" event for
        // IE. This event does not support bubbling or cancelling, and
        // any references to cancelBubble throw "Member not found".  A
        // typeof check of "unknown" circumvents this issue (and is also
        // IE specific).
        event.cancelBubble = true;
      }
      // 然後後續判斷時都會返回true,已停止傳播
      this.isPropagationStopped = functionThatReturnsTrue;
    },
    /**
     * We release all dispatched `SyntheticEvent`s after each event loop, adding
     * them back into the pool. This allows a way to hold onto a reference that
     * won't be added back into the pool.
     */
    // react16的保留原生事件的方法,react17裏已無效
    persist: function() {
      // Modern event system doesn't use pooling.
    },

    /**
     * Checks if this event should be released back into the pool.
     *
     * @return {boolean} True if this should not be released, false otherwise.
     */
    isPersistent: functionThatReturnsTrue,
  });
  // 返回根據接口類型包裝的合成事件構造器
  return SyntheticBaseEvent;
}
// 使用通過給工廠函數傳入鼠標事件接口獲取鼠標事件合成事件構造函數
export const SyntheticMouseEvent = createSyntheticEvent(MouseEventInterface);

可以看到,合成事件的實例,其實就是根據事件類型對原生事件的屬性做瀏覽器的磨平,以及關鍵方法的包裝。

事件觸發

當頁面上觸發了特定的事件時,如點擊事件 click,就會觸發綁定在根元素上的事件回調函數,也就是之前綁定了參數的dispatchEvent,而dispatchEvent在內部最終會調用dispatchEventsForPlugins,看一下dispatchEventsForPlugins具體做了哪些事情。

function dispatchEventsForPlugins(
  domEventName: DOMEventName, // dispatchEvent中綁定的事件名
  eventSystemFlags: EventSystemFlags, // dispatchEvent綁定的事件標記
  nativeEvent: AnyNativeEvent, // 事件觸發時回調傳入的原生事件對象
  targetInst: null | Fiber, // 事件觸發目標元素對應的fiber
  targetContainer: EventTarget, // 綁定事件的根元素
): void {
  // 磨平瀏覽器差異,拿到真正的target
  const nativeEventTarget = getEventTarget(nativeEvent);
  // 要處理事件回調的隊列
  const dispatchQueue: DispatchQueue = [];
  // 將fiber樹上的回調收集
  extractEvents(
    dispatchQueue,
    domEventName,
    targetInst,
    nativeEvent,
    nativeEventTarget,
    eventSystemFlags,
    targetContainer,
  );
  // 根據收集到的回調及事件標記處理事件
  processDispatchQueue(dispatchQueue, eventSystemFlags);
}

重點在extractEventsprocessDispatchQueue兩個方法,分別進行了事件對應回調的收集及處理回調。

收集回調

function extractEvents(
  dispatchQueue: DispatchQueue,
  domEventName: DOMEventName,
  targetInst: null | Fiber,
  nativeEvent: AnyNativeEvent,
  nativeEventTarget: null | EventTarget,
  eventSystemFlags: EventSystemFlags,
  targetContainer: EventTarget,
) {
  // 抽出簡單事件
  SimpleEventPlugin.extractEvents(
    dispatchQueue,
    domEventName,
    targetInst,
    nativeEvent,
    nativeEventTarget,
    eventSystemFlags,
    targetContainer,
  );
  const shouldProcessPolyfillPlugins =
    (eventSystemFlags & SHOULD_NOT_PROCESS_POLYFILL_EVENT_PLUGINS) === 0;
  if (shouldProcessPolyfillPlugins) {
    EnterLeaveEventPlugin.extractEvents(
      dispatchQueue,
      domEventName,
      targetInst,
      nativeEvent,
      nativeEventTarget,
      eventSystemFlags,
      targetContainer,
    );
    ChangeEventPlugin.extractEvents(
      dispatchQueue,
      domEventName,
      targetInst,
      nativeEvent,
      nativeEventTarget,
      eventSystemFlags,
      targetContainer,
    );
    SelectEventPlugin.extractEvents(
      dispatchQueue,
      domEventName,
      targetInst,
      nativeEvent,
      nativeEventTarget,
      eventSystemFlags,
      targetContainer,
    );
    BeforeInputEventPlugin.extractEvents(
      dispatchQueue,
      domEventName,
      targetInst,
      nativeEvent,
      nativeEventTarget,
      eventSystemFlags,
      targetContainer,
    );
  }
}

我們可以發現回調的收集也是根據事件的類型分別處理的,將extractEvents的入參分別給各個事件處理插件的extractEvents進行分別處理。

SimpleEventPlugin.extractEvents爲例看看如何進行收集:

// SimpleEventPlugin.js
function extractEvents(
  dispatchQueue: DispatchQueue,
  domEventName: DOMEventName,
  targetInst: null | Fiber,
  nativeEvent: AnyNativeEvent,
  nativeEventTarget: null | EventTarget,
  eventSystemFlags: EventSystemFlags,
  targetContainer: EventTarget,
): void {
  // 根據原生事件名拿到React事件名
  const reactName = topLevelEventsToReactNames.get(domEventName);
  if (reactName === undefined) {
    // 如果是沒對應的React事件就不處理
    return;
  }
  // 默認的合成事件構造函數,下面根據事件名重新賦值對應的合成事件構造函數
  let SyntheticEventCtor = SyntheticEvent;
  let reactEventType: string = domEventName;
  // 根據事件名獲取對應的合成事件構造函數
  switch (domEventName) {
    case 'keypress':
    case 'keydown':
    case 'keyup':
      SyntheticEventCtor = SyntheticKeyboardEvent;
      break;
    case 'focusin':
      reactEventType = 'focus';
      SyntheticEventCtor = SyntheticFocusEvent;
      break;
    case 'focusout':
      reactEventType = 'blur';
      SyntheticEventCtor = SyntheticFocusEvent;
      break;
    case 'beforeblur':
    case 'afterblur':
      SyntheticEventCtor = SyntheticFocusEvent;
      break;
    case 'click':
      // Firefox creates a click event on right mouse clicks. This removes the
      // unwanted click events.
      if (nativeEvent.button === 2) {
        return;
      }
    /* falls through */
    case 'auxclick':
    case 'dblclick':
    case 'mousedown':
    case 'mousemove':
    case 'mouseup':
    // TODO: Disabled elements should not respond to mouse events
    /* falls through */
    case 'mouseout':
    case 'mouseover':
    case 'contextmenu':
      SyntheticEventCtor = SyntheticMouseEvent;
      break;
    // ...這裏省略了很多case
    default:
      // Unknown event. This is used by createEventHandle.
      break;
  }
  // 判斷是捕獲階段還是冒泡階段
  const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
  if (
    enableCreateEventHandleAPI &&
    eventSystemFlags & IS_EVENT_HANDLE_NON_MANAGED_NODE
  ) {
    // 這個分支不看
  } else {
    // Some events don't bubble in the browser.
    // In the past, React has always bubbled them, but this can be surprising.
    // We're going to try aligning closer to the browser behavior by not bubbling
    // them in React either. We'll start by not bubbling onScroll, and then expand.
    // 如果不是捕獲階段且事件名爲scroll,則只處理觸發事件的節點
    const accumulateTargetOnly =
      !inCapturePhase &&
      // TODO: ideally, we'd eventually add all events from
      // nonDelegatedEvents list in DOMPluginEventSystem.
      // Then we can remove this special list.
      // This is a breaking change that can wait until React 18.
      domEventName === 'scroll';
    // 在fiber樹上收集事件名對應的props
    const listeners = accumulateSinglePhaseListeners(
      targetInst,
      reactName,
      nativeEvent.type,
      inCapturePhase,
      accumulateTargetOnly,
    );
    // 如果存在監聽該事件props回調函數
    if (listeners.length > 0) {
      // Intentionally create event lazily.
      // 則構建一個react合成事件
      const event = new SyntheticEventCtor(
        reactName,
        reactEventType,
        null,
        nativeEvent,
        nativeEventTarget,
      );
      // 並收集到隊列中
      dispatchQueue.push({event, listeners});
    }
  }
}
// 遍歷fiber樹的收集函數
export function accumulateSinglePhaseListeners(
  targetFiber: Fiber | null,
  reactName: string | null,
  nativeEventType: string,
  inCapturePhase: boolean,
  accumulateTargetOnly: boolean,
): Array<DispatchListener> {
  const captureName = reactName !== null ? reactName + 'Capture' : null;
  const reactEventName = inCapturePhase ? captureName : reactName;
  const listeners: Array<DispatchListener> = [];

  let instance = targetFiber;
  let lastHostComponent = null;

  // Accumulate all instances and listeners via the target -> root path.
  while (instance !== null) {
    const {stateNode, tag} = instance;
    // Handle listeners that are on HostComponents (i.e. <div>)
    if (tag === HostComponent && stateNode !== null) {
      lastHostComponent = stateNode;
      // Standard React on* listeners, i.e. onClick or onClickCapture
      if (reactEventName !== null) {
        // 拿到DOM節點類型上對應事件名的props
        const listener = getListener(instance, reactEventName);
        if (listener != null) {
          // 如果這個同名props存在,則收集起來
          listeners.push(
            createDispatchListener(instance, listener, lastHostComponent),
          );
        }
      }
    }
    // If we are only accumulating events for the target, then we don't
    // continue to propagate through the React fiber tree to find other
    // listeners.
    // 對於只收集當前節點的事件,收集完當前節點就退出了
    if (accumulateTargetOnly) {
      break;
    }
    // 向上遍歷
    instance = instance.return;
  }
  // 返回該事件名對應收集的監聽器
  return listeners;
}

圖示:

可以看到SimpleEventPlugin.extractEvents的主要處理邏輯:

  1. 根據原生事件名,得到對應的 React 事件名。

  2. 根據原生事件名,判斷需要使用的合成事件構造函數。

  3. 根據綁定的事件標記得出事件是否捕獲階段。

  4. 判斷事件名是否爲 scoll 且不是捕獲階段,如果是則只收集事件觸發節點。

  5. 從觸發事件的 DOM 實例對應的 fiber 節點開始,向上遍歷 fiber 樹,判斷遍歷到的 fiber 是否宿主類型 fiber 節點,是的話判斷在其 props 上是否存在 React 事件名同名屬性,如果存在,則 push 到數組中,遍歷結束即可收集由葉子節點到根節點的回調函數。

  6. 如果收集的回調數組不爲空,則實例化對應的合成事件,並與收集的回調函數一同收集到dispatchQueue中。

處理回調

// 分別處理事件隊列
export function processDispatchQueue(
  dispatchQueue: DispatchQueue,
  eventSystemFlags: EventSystemFlags,
): void {
  const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
  for (let i = 0; i < dispatchQueue.length; i++) {
    const {event, listeners} = dispatchQueue[i];
    processDispatchQueueItemsInOrder(event, listeners, inCapturePhase);
  }
}
// 根據事件是捕獲階段還是冒泡階段,來決定是順序執行還是倒序執行
// 並且如果事件被調用過event.stopPropagation則退出執行
function processDispatchQueueItemsInOrder(
  event: ReactSyntheticEvent,
  dispatchListeners: Array<DispatchListener>,
  inCapturePhase: boolean,
): void {
  let previousInstance;
  if (inCapturePhase) {
    // 捕獲階段逆序執行
    for (let i = dispatchListeners.length - 1; i >= 0; i--) {
      const {instance, currentTarget, listener} = dispatchListeners[i];
      if (instance !== previousInstance && event.isPropagationStopped()) {
        // 如果被阻止過傳播,則退出
        return;
      }
      // 執行
      executeDispatch(event, listener, currentTarget);
      previousInstance = instance;
    }
  } else {
    for (let i = 0; i < dispatchListeners.length; i++) {
      const {instance, currentTarget, listener} = dispatchListeners[i];
      if (instance !== previousInstance && event.isPropagationStopped()) {
        return;
      }
      executeDispatch(event, listener, currentTarget);
      previousInstance = instance;
    }
  }
}

// 執行事件回調
function executeDispatch(
  event: ReactSyntheticEvent,
  listener: Function,
  currentTarget: EventTarget,
): void {
  const type = event.type || 'unknown-event';
  // 設置合成事件執行到當前DOM實例時的指向
  event.currentTarget = currentTarget;
  invokeGuardedCallbackAndCatchFirstError(type, listener, undefined, event);
  // 不在事件的回調中時拿不到currentTarget
  event.currentTarget = null;
}

可以看到對於回調的處理,就是簡單地根據收集到的回調數組,判斷事件的觸發是處於捕獲階段還是冒泡階段來決定是順序執行還是倒序執行回調數組。並且通過event.isPropagationStopped()來判斷事件是否執行過event.stopPropagation()以決定是否繼續執行。

React17 與 React16 事件系統的差別

綁定位置

事件委託的節點從 React16 的 document 更改爲 React17 的 React 樹的根 DOM 容器。

這一改動的出發點是如果頁面中存在多個 React 應用,由於他們都會在頂層document註冊事件處理器,如果你在一個 React 子應用的 React 事件中調用了e.stopPropagation(),無法阻止事件冒泡到外部樹,因爲真實的事件早已傳播到document

而將事件委託在 React 應用的根 DOM 容器則可以避免這樣的問題,減少了多個 React 應用並存可能產生的問題,並且事件系統的運行也更貼近現在瀏覽器的表現。

事件代理階段

在 React16 中,對 document 的事件委託都委託在冒泡階段,當事件冒泡到 document 之後觸發綁定的回調函數,在回調函數中重新模擬一次 捕獲 - 冒泡 的行爲,所以 React 事件中的e.stopPropagation()無法阻止原生事件的捕獲和冒泡,因爲原生事件的捕獲和冒泡已經執行完了。

在 React17 中,對 React 應用根 DOM 容器的事件委託分別在捕獲階段和冒泡階段。即:

可以根據下面的 demo 感受 React16 和 React17 事件在時序細節上的不同:codesandbox demo(https://codesandbox.io/s/react17shi-jian-chuan-bo-mc2wdp?file=/src/index.tsx),可以通過切換 Dependencies 中 react 和 react-dom 的版本。

import { useEffect } from "react";
import ReactDOM from "react-dom";

// 應用掛載前的原生事件綁定
document.addEventListener("click"() ={
  console.log("原生document冒泡掛載前");
});
document.addEventListener(
  "click",
  () ={
    console.log("原生document捕獲掛載前");
  },
  true
);
document.querySelector("#root")!.addEventListener("click"() ={
  console.log("原生root冒泡掛載前");
});
document.querySelector("#root")!.addEventListener(
  "click",
  () ={
    console.log("原生root捕獲掛載前");
  },
  true
);

function App() {
  // 應用掛載後的原生事件綁定
  useEffect(() ={
    const root = document.querySelector("#root")!;
    const parent = document.querySelector("#parent")!;
    const child = document.querySelector("#child")!;
    document.addEventListener("click"() ={
      console.log("原生document冒泡掛載後");
    });
    document.addEventListener(
      "click",
      () ={
        console.log("原生document捕獲掛載後");
      },
      true
    );
    root.addEventListener("click"() ={
      console.log("原生root冒泡掛載後");
    });
    root.addEventListener(
      "click",
      () ={
        console.log("原生root捕獲掛載後");
      },
      true
    );
    parent.addEventListener("click"() ={
      console.log("原生parent冒泡");
    });
    parent.addEventListener(
      "click",
      (e) ={
        console.log("原生parent捕獲");
        // 註釋1
        // e.stopPropagation();
      },
      true
    );
    child.addEventListener("click"() ={
      console.log("原生child冒泡");
    });
    child.addEventListener(
      "click",
      () ={
        console.log("原生child捕獲");
      },
      true
    );
  });
  return (
    <div
      id="parent"
      onClick={() ={
        console.log("react parent冒泡");
      }}
      onClickCapture={(e) ={
        console.log("react parent捕獲");
        // 註釋2
        // e.stopPropagation()
      }}
    >
      <h1
        id="child"
        onClick={(e) ={
          console.log("react child冒泡");
          // 註釋3
          // e.stopPropagation()
        }}
        onClickCapture={() ={
          console.log("react child捕獲");
        }}
      >
        React event propagation
      </h1>
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById("root"));

// 當點擊id爲child的div時
// ------------下面是react:17.0.2,react-dom:17.0.2的表現------------------
// 當所有e.stopPropagation()註釋都不打開時
// 控制檯打印如下:
// 原生document捕獲掛載前
// 原生document捕獲掛載後
// 原生root捕獲掛載前
// react parent捕獲
// react child捕獲
// 原生root捕獲掛載後
// 原生parent捕獲
// 原生child捕獲
// 原生child冒泡
// 原生parent冒泡
// 原生root冒泡掛載前
// react child冒泡
// react parent冒泡
// 原生root冒泡掛載後
// 原生document冒泡掛載前
// 原生document冒泡掛載後

// 當只打開註釋1的e.stopPropagation()
// 控制檯打印如下:
// 原生document捕獲掛載前
// 原生document捕獲掛載後
// 原生root捕獲掛載前
// react parent捕獲
// react child捕獲
// 原生root捕獲掛載後
// 原生parent捕獲

// 當只打開註釋2的e.stopPropagation()
// 控制檯打印如下:
// 原生document捕獲掛載前
// 原生document捕獲掛載後
// 原生root捕獲掛載前
// react parent捕獲
// 原生root捕獲掛載後

// 當只打開註釋3的e.stopPropagation()
// 控制檯打印如下:
// 原生document捕獲掛載前
// 原生document捕獲掛載後
// 原生root捕獲掛載前
// react parent捕獲
// react child捕獲
// 原生root捕獲掛載後
// 原生parent捕獲
// 原生child捕獲
// 原生child冒泡
// 原生parent冒泡
// 原生root冒泡掛載前
// react child冒泡
// 原生root冒泡掛載後

// ------------下面是react:16.14.0,react-dom:16.14.0的表現------------------
// 當所有e.stopPropagation()註釋都不打開時
// 控制檯打印如下:
// 原生document捕獲掛載前
// 原生document捕獲掛載後
// 原生root捕獲掛載前
// 原生root捕獲掛載後
// 原生parent捕獲
// 原生child捕獲
// 原生child冒泡
// 原生parent冒泡
// 原生root冒泡掛載前
// 原生root冒泡掛載後
// 原生document冒泡掛載前
// react parent捕獲
// react child捕獲
// react child冒泡
// react parent冒泡
// 原生document冒泡掛載後

// 當只打開註釋1的e.stopPropagation()
// 控制檯打印如下:
// 原生document捕獲掛載前
// 原生document捕獲掛載後
// 原生root捕獲掛載前
// 原生root捕獲掛載後
// 原生parent捕獲

// 當只打開註釋2的e.stopPropagation()
// 控制檯打印如下:
// 原生document捕獲掛載前
// 原生document捕獲掛載後
// 原生root捕獲掛載前
// 原生root捕獲掛載後
// 原生parent捕獲
// 原生child捕獲
// 原生child冒泡
// 原生parent冒泡
// 原生root冒泡掛載前
// 原生root冒泡掛載後
// 原生document冒泡掛載前
// react parent捕獲
// 原生document冒泡掛載後

// 當只打開註釋3的e.stopPropagation()
// 控制檯打印如下:
// 原生document捕獲掛載前
// 原生document捕獲掛載後
// 原生root捕獲掛載前
// 原生root捕獲掛載後
// 原生parent捕獲
// 原生child捕獲
// 原生child冒泡
// 原生parent冒泡
// 原生root冒泡掛載前
// 原生root冒泡掛載後
// 原生document冒泡掛載前
// react parent捕獲
// react child捕獲
// react child冒泡
// 原生document冒泡掛載後

去除事件池

事件池 – React(https://zh-hans.reactjs.org/docs/legacy-event-pooling.html)

scroll 事件不再冒泡

在原生 scroll 裏,scroll 是不存在冒泡階段的,但是 React16 中模擬了 scroll 的冒泡階段,React17 中將此特性去除,避免了當一個嵌套且可滾動的元素在其父元素觸發事件時造成混亂。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/lCHeWE5DrbxEYIdG60aIsw