60行程式碼實現React的事件系統

卡頌 發表於 2022-01-29
React

大家好,我卡頌。

由於如下原因,React的事件系統程式碼量很大:

  • 需要抹平不同瀏覽器的差異
  • 與內部的優先順序機制繫結
  • 需要考慮所有瀏覽器事件

但如果抽絲剝繭會發現,事件系統的核心只有兩個模組:

  • SyntheticEvent(合成事件)
  • 模擬實現的事件傳播機制

本文會用60行程式碼實現這兩個模組,讓你快速瞭解React事件系統的原理。

線上DEMO地址

歡迎加入人類高質量前端框架群,帶飛

Demo的效果

對於如下這段JSX

const jsx = (
  <section onClick={(e) => console.log("click section")}>
    <h3>你好</h3>
    <button
      onClick={(e) => {
        // e.stopPropagation();
        console.log("click button");
      }}
    >
      點選
    </button>
  </section>
);

在瀏覽器中渲染:

const root = document.querySelector("#root");
ReactDOM.render(jsx, root);

點選按鈕,會依次列印:

click button
click section

如果在button的點選回撥中增加e.stopPropagation(),點選後會列印:

click button

我們的目標是將JSX中的onClick替換為ONCLICK,但是點選後的效果不變。

也就是說,我們將基於React自制一套事件系統,他的事件名的書寫規則是形如ONXXX全大寫形式。

實現SyntheticEvent

首先,我們來實現SyntheticEvent(合成事件)。

SyntheticEvent是瀏覽器原生事件物件的一層封裝。相容所有瀏覽器,同時擁有和瀏覽器原生事件相同的API,如stopPropagation()preventDefault()

SyntheticEvent存在的目的是抹平瀏覽器間在事件物件間的差異,但是對於不支援某一事件的瀏覽器,SyntheticEvent並不會提供polyfill(因為這會顯著增大ReactDOM的體積)。

我們的實現很簡單:

class SyntheticEvent {
  constructor(e) {
    this.nativeEvent = e;
  }
  stopPropagation() {
    this._stopPropagation = true;
    if (this.nativeEvent.stopPropagation) {
      this.nativeEvent.stopPropagation();
    }
  }
}

接收原生事件物件,返回一個包裝物件。原生事件物件會儲存在nativeEvent屬性中。

同時,實現了stopPropagation方法。

實際的SyntheticEvent會包含更多屬性和方法,這裡為了演示目的簡化了

實現事件傳播機制

事件傳播機制的實現步驟如下:

  1. 在根節點繫結事件型別對應的事件回撥,所有子孫節點觸發該類事件最終都會委託給根節點的事件回撥處理。
  2. 尋找觸發事件的DOM節點,找到其對應的FiberNode(即虛擬DOM節點)
  3. 收集從當前FiberNode到根FiberNode之間所有註冊的該事件對應回撥
  4. 反向遍歷並執行一遍所有收集的回撥(模擬捕獲階段的實現)
  5. 正向遍歷並執行一遍所有收集的回撥(模擬冒泡階段的實現)

首先,實現第一步:

// 步驟1
const addEvent = (container, type) => {
  container.addEventListener(type, (e) => {
    // dispatchEvent是需要實現的“根節點的事件回撥”
    dispatchEvent(e, type.toUpperCase(), container);
  });
};

在入口處註冊點選回撥

const root = document.querySelector("#root");
ReactDOM.render(jsx, root);
// 增加如下程式碼
addEvent(root, "click");

接下來實現根節點的事件回撥

const dispatchEvent = (e, type) => {
  // 包裝合成事件
  const se = new SyntheticEvent(e);
  const ele = e.target;
  
  // 比較hack的方法,通過DOM節點找到對應的FiberNode
  let fiber;
  for (let prop in ele) {
    if (prop.toLowerCase().includes("fiber")) {
      fiber = ele[prop];
    }
  }
  
  // 第三步:收集路徑中“該事件的所有回撥函式”
  const paths = collectPaths(type, fiber);
  
  // 第四步:捕獲階段的實現
  triggerEventFlow(paths, type + "CAPTURE", se);
  
  // 第五步:冒泡階段的實現
  if (!se._stopPropagation) {
    triggerEventFlow(paths.reverse(), type, se);
  }
};

接下來收集路徑中該事件的所有回撥函式

收集路徑中的事件回撥函式

實現的思路是:從當前FiberNode一直向上遍歷,直到根FiberNode。收集遍歷過程中的FiberNode.memoizedProps屬性內儲存的對應事件回撥

const collectPaths = (type, begin) => {
  const paths = [];
  
  // 不是根FiberNode的話,就一直向上遍歷
  while (begin.tag !== 3) {
    const { memoizedProps, tag } = begin;
    
    // 5代表DOM節點對應FiberNode
    if (tag === 5) {
      const eventName = ("on" + type).toUpperCase();
      
      // 如果包含對應事件回撥,儲存在paths中
      if (memoizedProps && Object.keys(memoizedProps).includes(eventName)) {
        const pathNode = {};
        pathNode[type.toUpperCase()] = memoizedProps[eventName];
        paths.push(pathNode);
      }
    }
    begin = begin.return;
  }
  
  return paths;
};

得到的paths結構類似如下:

60行程式碼實現React的事件系統

捕獲階段的實現

由於我們是從目標FiberNode向上遍歷,所以收集到的回撥的順序是:

[目標事件回撥, 某個祖先事件回撥, 某個更久遠的祖先回撥 ...]

要模擬捕獲階段的實現,需要從後向前遍歷陣列並執行回撥。

遍歷的方法如下:

const triggerEventFlow = (paths, type, se) => {
  // 從後向前遍歷
  for (let i = paths.length; i--; ) {
    const pathNode = paths[i];
    const callback = pathNode[type];
    
    if (callback) {
      // 存在回撥函式,傳入合成事件,執行
      callback.call(null, se);
    }
    if (se._stopPropagation) {
      // 如果執行了se.stopPropagation(),取消接下來的遍歷
      break;
    }
  }
};

注意,我們在SyntheticEvent中實現的stopPropagation方法,呼叫後會阻止遍歷的繼續。

冒泡階段的實現

有了捕獲階段的實現經驗,冒泡階段很容易實現,只需將paths反向後再遍歷一遍就行。

總結

React事件系統的核心包括兩部分:

  • SyntheticEvent
  • 事件傳播機制

事件傳播機制由5個步驟實現。

總的來說,就是這麼簡單。