React事件處理之連蒙帶猜

songjp發表於2019-04-28

前言

所在的前端小組要求組內成員每週輪流分享,眼看這周就輪到我了,便思考如何能順利得"混過"這次分享。

得和工作搭點邊,一點不搭"ga"的話意義不大。得通俗易懂,原始碼啥的太難了擔心組員們聽不懂(其實是自己看不懂原始碼)。希望以後...

面試官:“你簡歷上說你看過React原始碼?”
我:“沒錯隨便你提問哪一行,正數倒數從中間往兩邊數都行”

"咳咳,停止YY"

想起了之前遇到過的,有些疑惑的 React的事件問題,當時在一篇部落格的指導下順利解決了,這次有機會不如再整理一下,也好將知識"據為己有"。

1. React事件繫結

React事件繫結的本質是將事件代理到 document 上。

我們都知道可以通過 控制檯 > Elements > Event Listeners可以檢視當前頁面所繫結的事件。
react + react-router-dom 的環境下,以下程式碼:

import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter as Router, Route, Link } from 'react-router-dom'

ReactDOM.render(
  <Router>
    <div>
      <Link to='/Event'>Event</Link>
      <Route path='/Event' component={null} />
    </div>
  </Router>,
  document.getElementById('root')
)
複製程式碼

Event Listener 顯示:

React事件處理之連蒙帶猜

即此時最簡單的程式碼已經分別在documentwindow上繫結了clickpopstate有事件了,同時後面的行號表示觸發這次事件繫結的原始碼所在位置(除錯的話就點進去打斷點)。這是什麼情況,react自帶事件監聽器?於是我:

...
ReactDOM.render(
  null,
  document.getElementById('root')
)
複製程式碼

再檢視發現已經沒有事件監聽器了。頓時就好猜了,react-router-dom中,Link標籤能響應點選,那麼 click 事件便有可能是它繫結的,同時路由切換對應'history'的事件,所以兩個事件都是react-router-dom 繫結的。

於是我:

ReactDOM.render(
  <Router>
    <div>
      <Route path='/Event' component={null} />
    </div>
  </Router>,
  document.getElementById('root')
)
複製程式碼

然後 click監聽器沒了,還剩下popstate監聽器,有興趣也可試試刪除 Link 元件 node_modules/react-router-dom/es/Link.js render 方法中的 onClick: this.handleClick 看看事件監聽器的變化。
至於popstate監聽器可檢視 Router 元件 node_modules/react-router/es/Router.jscomponentWillMount 中的history.listen

還有個測試結果: 每一種型別(click,onmouseover等)的事件,由於代理到 document 的原因,只會在Event Listener中出現一遍。

2. React 事件池

其實官方文件已經說的很清楚了:

這是出於效能因素考慮,合成事件(SyntheticEvent)是被池化的。這意味著SyntheticEvent物件將會被重用,在呼叫事件回撥之後所有屬性將會被廢棄。 因此,你不能以非同步的方式訪問SyntheticEvent物件。

function handleClick(event) {
  console.log(event.type) // => "click", 同步是能訪問到值的
  setTimeout(function() {
    console.log(event.type); // => null  非同步的方式讀取,由於event物件的屬性都被廢棄了(便於迴圈利用event物件),所以訪問不到值
  }, 0);
}
複製程式碼

關於池化,可以這麼理解,你用瀏覽器開啟了兩個標籤頁,一個看juejin一個查google。此時juejin上的一篇文章的某個概念又不懂,要查google,你是再開一個標籤頁進google查呢?還是利用已經存在的google標籤頁查?
再開一個的好處是原來google標籤頁的內容還保留著,你還可以切換檢視,但是新開標籤頁是要消耗更多資源的。
利用原來的google意味著省去新開標籤頁的帶來的資源和時間成本,壞處是你回頭想看原來google的內容就不是簡單切換標籤頁了(得回退,找歷史記錄)。

react提供了"新開標籤頁,保留原標籤的方式": event.persist():

function handleClick(event) {
  console.log(event.type) // => "click", 同步是能訪問到值的
  event.persist();        // "允許程式碼保留對事件的引用(新開標籤頁)"
  setTimeout(function() {
    console.log(event.type); // => "click"  該物件不會被回收重用
  }, 0);
}
複製程式碼

3. React事件物件 e 與原生事件物件 e

React事件物件e,自身有e.nativeEvent 可以訪問原生事件物件e,沿著原型鏈找到 SyntheticEvent原型上有stopPropagationpreventDefault方法。

原生事件物件e,沿著原型鏈找到 Event,在它原型上除了以上兩個方法之外,還有stopImmediatePropagation方法,關於stopImmediatePropagation檢視 MDN文件

domA.addEventListener("click", (event) => {
    console.log('doma click 1');
    event.stopImmediatePropagation();
});

domA.addEventListener("click", (event) => {
    console.log('doma click 2')
});
複製程式碼

stopImmediatePropagation除了能做到像stopPropagation一樣阻止事件向父級冒泡之外,也能阻止當前元素剩餘的,同型別事件的執行(第一個 click 觸發時,呼叫 e.stopImmediatePropagtion 阻止當前元素第二個 click 事件的觸發)。

4. 詳解

index.js

import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter as Router, Route, Link } from 'react-router-dom'
import Event from './event.js'

ReactDOM.render(
  <Router>
    <div>
      <Link to='/Event'>Event</Link>
      <Route path='/Event' component={Event} />
    </div>
  </Router>,
  document.getElementById('root')
)
複製程式碼

以下程式碼為event.js程式碼,且均省去無關程式碼

4.1 示例:

// componentDidMount
document.addEventListener('click', (e) => {
    console.log('document click')
})

// 方法
handleClick (e) {
    console.log('div click')
    e.stopPropagation()  // e為React事件物件
}

// render
<div className='div-1' onClick={this.handleClick}>
    div-1
</div>
複製程式碼

當點選div-1時,列印div clickdocument click,即e.stopPropagtion只能阻止同為React事件型別的冒泡。結合React事件本質上是繫結到document上,於是以上程式碼相當於在componentDidMount中新增兩個事件監聽:

// componentDidMount
document.addEventListener('click', (e) => {        // 事件監聽 1
    console.log('div click')
    e.stopPropagation()
})

document.addEventListener('click', (e) => {       // 事件監聽 2
    console.log('document click')
})

// 為什麼是事件監聽1在前,即為什麼handleClick轉化之後的事件繫結先於componentDidMount中的事件繫結。待會會說到事件繫結順序的問題。  
複製程式碼

也就是在document物件上繫結兩次事件,e.stopPropagation阻止不了當前元素剩餘的,同型別事件的執行,剛剛說到e.stopImmediatePropagation可以,就是說下面的程式碼能阻止document click2的列印:

document.addEventListener('click', (e) => {
    console.log('document click1')
    e.stopImmediatePropagation()
})

document.addEventListener('click', (e) => {
    console.log('document click2')  // e.stopImmediatePropagation阻止事件冒泡到這
})
複製程式碼

於是可以這樣改動:

handleClick (e) {
    console.log('div click')
    // e.stopImmediatePropagation() // 錯誤, 此時e為React事件物件,需要e.nativeEvent訪問原生事件物件
    e.nativeEvent.stopImmediatePropagation()
}
複製程式碼

於是就順利阻止了事件冒泡到 document 上。

4.2 事件繫結順序

剛剛說的繫結順序,為什麼知道是:

document.addEventListener('click', (e) => {
    console.log('div click')
})

document.addEventListener('click', (e) => {
    console.log('document click')
})
複製程式碼

而不是:

document.addEventListener('click', (e) => {
    console.log('document click')
})

document.addEventListener('click', (e) => {
    console.log('div click')
})
複製程式碼

其實很簡單,因為render方法先於componentDidMount執行,所以handleClick轉化之後的事件繫結先於原本componentDidMount中的事件繫結。打斷點也很容易得出結論(會定位到這個位置/node_modules/react-dom/cjs/react-dom.development.js的trapBubbledEvent方法)。那假如我這樣做,即在render時給 document 新增事件監聽:

// 方法
handleClick (e) {
    console.log('div click')
}

// render
document.addEventListener('click', (e) => {
    console.log('document click')
})
<div className='div-1' onClick={this.handleClick}>
    div-1
</div>
複製程式碼

document click 先於 div click 繫結,所以先列印 document click再列印div click. 錯!

我們忽略了一個問題,在event.js中是document click先於React元件的事件繫結,但是在index.js中,React的click事件最早在react-router-dom中的Link元件中繫結過。即,還是React事件繫結先於document事件繫結。所以列印結果依然是div click, document click。為此我們可以做個測試驗證下,改動index.js:

index.js

class Test extends Component {
  componentDidMount () {
    // 位置2
    document.addEventListener('click', (e) => {
        console.log('document clicked')
    })
  }
  handleClick() {
      console.log('div clicked')
  }
  render () {
    // 位置1
    document.addEventListener('click', (e) => {
        console.log('document clicked')
    })
    return <div onClick={this.handleClick}>123</div>
  }
}

ReactDOM.render(
  <Test />,
  document.getElementById('root')
)
複製程式碼

在位置1新增事件時,則先列印document clicked,後列印div clicked
在位置2新增事件時,則先列印div clicked,後列印document clicked
符合我們的預期。

4.3 執行順序與阻止冒泡總結

子元件dom原生事件           --A 執行順序與新增該事件的位置無關 
父元件dom原生事件           --B 執行順序與新增該事件的位置無關
document原生事件            --C 執行順序與新增該事件位置有關!! 位置 1
document代理事件            --D react內部對所有子元件事件進行代理
    子元件React事件         --D1 合成事件,代理在document上
    父元件React事件         --D2 合成事件,代理在document上
document原生事件            --C  位置2
複製程式碼

如上,頁面有子元件和父元件,同時給它們繫結React事件和dom原生事件。document物件也可在位置1或者位置2,即代理事件進行事件繫結(即 react 第一次 render 帶有事件屬性的元件,比如 index.js 中的 render Link元件 )之前或者之後(如componentDidMount)中繫結事件(事件繫結順序會影響最終執行順序)。

在沒有任何阻止冒泡情況下,點選子元件:
原生事件從子元件開始冒泡,執行事件A
事件冒泡到父元件,執行事件B
此時冒泡到document上,執行C還是執行D取決於 document 原生事件是在位置1還是位置2. 在位置1則執行C。否則去執行事件D
React內部分發事件,執行子元件React事件D1
React內部冒泡,執行父元件React事件D2
如果document原生事件在位置2繫結,則在此執行事件C

在事件A或者事件B階段:
可以通過 e.stopPropagation或者(e.stopImmediatePropagation,因為e.stopImmediatePropagation包含e.stopPropagation) 阻止冒泡,此時document原生事件C及所有React事件(D1,D2)均不會執行。

在事件C階段:
如果該事件在位置1,可以通過e.stopImmediatePropagation阻止React事件(D1,D2)的執行。但是用 e.stopPropagation 的話則不能阻止React事件執行。
如果事件在位置2,則無論如何也不能阻止React事件執行。

在合成事件D1階段:
可通過 e.stopPropagation 來阻止事件 D2 的執行(e.nativeEvent.stopImmediatePropagation不行,你應該知道為什麼),此時e 為React事件物件。

如果document原生事件C在位置2,無論在D1還是D2階段,則都能通過e.nativeEvent.stopImmediatePropagation阻止原生事件C的執行。

5. 總結

本文核心知識來源於參考資料 React 事件代理...,感謝作者 youngwind。幾乎不涉及原始碼(目前看原始碼還是比較吃力),結合自己的理解加測試連蒙帶猜湊合而成,希望對讀者有所幫助。若有錯誤,歡迎指正。

參考資料

React 事件代理與 stopImmediatePropagation
React原始碼解讀系列 – 事件機制

相關文章