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

Nekron發表於2019-03-04

引用的React程式碼,版本均為v15.6.1分支

React合成事件

為什麼有合成事件的抽象?

如果DOM上繫結了過多的事件處理函式,整個頁面響應以及記憶體佔用可能都會受到影響。React為了避免這類DOM事件濫用,同時遮蔽底層不同瀏覽器之間的事件系統差異,實現了一箇中間層——SyntheticEvent。

原理

React中,如果需要繫結事件,我們常常在jsx中這麼寫:

<div onClick={this.onClick}>
    react事件
</div>複製程式碼

原理大致如下:

React並不是將click事件綁在該div的真實DOM上,而是在document處監聽所有支援的事件,當事件發生並冒泡至document處時,React將事件內容封裝並交由真正的處理函式執行。

以上面的程式碼為例,整個事件生命週期示意如下:

其中,由於event物件是複用的,事件處理函式執行完後,屬性會被清空,所以event的屬性無法被非同步訪問,詳情請查閱event-pooling

如何在React中使用原生事件

雖然React封裝了幾乎所有的原生事件,但諸如:

  • Modal開啟以後點其他空白區域需要關閉Modal
  • 引入了一些以原生事件實現的第三方庫,並且相互之間需要有互動

等等場景時,不得不使用原生事件來進行業務邏輯處理。

由於原生事件需要繫結在真實DOM上,所以一般是在componentDidMount階段/ref的函式執行階段進行繫結操作,在componentWillUnmount階段進行解綁操作以避免記憶體洩漏。

示例如下:

class Demo extends React.PureComponent {
    componentDidMount() {
        const $this = ReactDOM.findDOMNode(this)
        $this.addEventListener('click', this.onDOMClick, false)
    }

    onDOMClick = evt => {
        // ...
    }

    render() {
        return (
            <div>Demo</div>
        )
    }
}複製程式碼

合成事件和原生事件混合使用

如果業務場景中需要混用合成事件和原生事件,那使用過程中需要注意如下幾點:

響應順序

先看個簡單例子,下面例子中點選Demo以後,控制檯輸出會是怎樣的?

class Demo extends React.PureComponent {
    componentDidMount() {
        const $this = ReactDOM.findDOMNode(this)
        $this.addEventListener('click', this.onDOMClick, false)
    }

    onDOMClick = evt => {
        console.log('dom event')
    }

    onClick = evt => {
        console.log('react event')
    }

    render() {
        return (
            <div onClick={this.onClick}>Demo</div>
        )
    }
}複製程式碼

我們來分析一下:首先DOM事件監聽器被執行,然後事件繼續冒泡至document,合成事件監聽器再被執行。

即,最終控制檯輸出為:

dom event
react event

阻止冒泡

那,如果在onDOMClick中呼叫evt.stopPropagtion()呢?

由於DOM事件被阻止冒泡了,無法到達document,所以合成事件自然不會被觸發,控制檯輸出就變成了:

dom event

簡單例子都比較容易理解,例子再複雜一些:

class Demo extends React.PureComponent {
    componentDidMount() {
        const $parent = ReactDOM.findDOMNode(this)
        const $child = $parent.querySelector('.child')

        $parent.addEventListener('click', this.onParentDOMClick, false)
        $child.addEventListener('click', this.onChildDOMClick, false)
    }

    onParentDOMClick = evt => {
        console.log('parent dom event')
    }

    onChildDOMClick = evt => {
        console.log('child dom event')
    }    

    onParentClick = evt => {
        console.log('parent react event')
    }

    onChildClick = evt => {
        console.log('child react event')
    }

    render() {
        return (
            <div onClick={this.onParentClick}>
                <div className="child" onClick={this.onChildClick}>
                    Demo
                </div>
            </div>
        )
    }
}複製程式碼

如果在onChildClick中呼叫evt.stopPropagtion(),則控制檯輸出變為:

child dom event
parent dom event
child react event

這樣的結果是因為React給合成事件封裝的stopPropagtion函式在呼叫時給自己加了個isPropagationStopped的標記位來確定後續監聽器是否執行。

原始碼如下:

// https://github.com/facebook/react/blob/v15.6.1/src/renderers/shared/stack/event/EventPluginUtils.js
for (var i = 0; i < dispatchListeners.length; i++) {
  if (event.isPropagationStopped()) {
    break;
  }
  // Listeners and Instances are two parallel arrays that are always in sync.
  if (dispatchListeners[i](event, dispatchInstances[i])) {
    return dispatchInstances[i];
  }
}複製程式碼

nativeEvent在React事件體系中的尷尬位置

有人或許有疑問,雖然響應順序上合成事件晚於原生事件,那在合成事件中是否可以影響原生事件的監聽器執行呢?答案是(幾乎)不可能。。。

我們知道,React事件監聽器中獲得的入參並不是瀏覽器原生事件,原生事件可以通過evt.nativeEvent來獲取。但令人尷尬的是,nativeEvent的作用非常小。

stopPropagation

在使用者的期望中,stopPropagation是用來阻止當前DOM的原生事件冒泡。

但通過上一節合成事件的原理可知,實際上該方法被呼叫時,實際作用是在DOM最外層阻止冒泡,並不符合預期。

stopImmediatePropagation

stopImmediatePropagation常常在多個第三方庫混用時,用來阻止多個事件監聽器中的非必要執行。

但React體系中,一個元件只能繫結一個同型別的事件監聽器(重複定義時,後面的監聽器會覆蓋之前的),所以合成事件甚至都不去封裝stopImmediatePropagation

事實上nativeEvent的stopImmediatePropagation只能阻止繫結在document上的事件監聽器。此外,由於事件繫結的順序問題,需要注意,如果是在react-dom.js載入前繫結的document事件,stopImmediatePropagation也是無法阻止的。

【冷門】捕獲階段的合成事件

合成事件的文件很多,總得來點新奇的內容。。。

React支援將監聽器註冊在捕獲階段,但由於應用場景不多,所以基本不太被提及。不過本文涉及到了合成事件,就一併展開下。

將這個例子稍作改造,這時候的控制檯輸出會怎麼樣呢?

class Demo extends React.PureComponent {
    componentDidMount() {
        const $parent = ReactDOM.findDOMNode(this)
        const $child = $parent.querySelector('.child')

        $parent.addEventListener('click', this.onParentDOMClick, true)
        $child.addEventListener('click', this.onChildDOMClick, false)
    }

    onParentDOMClick = evt => {
        console.log('captrue: parent dom event')
    }

    onChildDOMClick = evt => {
        console.log('bubble: child dom event')
    }    

    onParentClick = evt => {
        console.log('capture: parent react event')
    }

    onChildClick = evt => {
        console.log('bubble: child react event')
    }

    render() {
        return (
            <div onClickCapture={this.onParentClick}>
                <div className="child" onClick={this.onChildClick}>
                    Demo
                </div>
            </div>
        )
    }
}複製程式碼

結果是:

captrue: parent dom event
bubble: child dom event
capture: parent react event
bubble: child react event

看著好像挺合理,好像又不太合理。或許有人(比如我)會困惑為何合成事件的捕獲階段響應也晚於原生事件的冒泡階段響應呢?

其實是因為,合成事件的代理並不是在document上同時註冊捕獲/冒泡階段的事件監聽器的,事實上只有冒泡階段的事件監聽器,每一次DOM事件的觸發,React會在event._dispatchListeners上注入所有需要執行的函式,然後依次迴圈執行(如上文React原始碼)。

_dispatchListeners的生成邏輯如下:

// https://github.com/facebook/react/blob/v15.6.1/src/renderers/dom/client/ReactDOMTreeTraversal.js
/* 
    path為react的元件樹,由下向上遍歷,本例中就是[child, parent];
    然後先將標記為captured的監聽器置入_dispatchListeners,此時順序是path從後往前;
    再是標記為bubbled的監聽器,順序是從前往後。
*/
function traverseTwoPhase(inst, fn, arg) {
  var path = [];
  while (inst) {
    path.push(inst);
    inst = inst._hostParent;
  }
  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);
  }
}複製程式碼

結論

  1. 合成事件的監聽器是統一註冊在document上的,且僅有冒泡階段。所以原生事件的監聽器響應總是比合成事件的監聽器早
  2. 阻止原生事件的冒泡後,會阻止合成事件的監聽器執行
  3. 合成事件的nativeEvent在本文場景中,沒毛用

相關文章