前言
所在的前端小組要求組內成員每週輪流分享,眼看這周就輪到我了,便思考如何能順利得"混過"這次分享。
得和工作搭點邊,一點不搭"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
顯示:
即此時最簡單的程式碼已經分別在document
和window
上繫結了click
和popstate
有事件了,同時後面的行號表示觸發這次事件繫結的原始碼所在位置(除錯的話就點進去打斷點)。這是什麼情況,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.js
的 componentWillMount
中的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
原型上有stopPropagation
和 preventDefault
方法。
原生事件物件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 click
,document 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
。幾乎不涉及原始碼(目前看原始碼還是比較吃力),結合自己的理解加測試連蒙帶猜湊合而成,希望對讀者有所幫助。若有錯誤,歡迎指正。