「React 進階」一文喫透 react 事件原理
一 前言
=======
今天我們來一起探討一下React
事件原理,這篇文章,我儘量用通俗簡潔的方式,把React
事件系統講的明明白白。
我們講的react
版本是16.13.1
, v17
之後react
對於事件系統會有相關的改版,文章後半部分會提及。
老規矩,在正式講解react
之前,我們先想想這幾個問題 (如果我是面試官,你會怎麼回答?):
-
1 我們寫的事件是綁定在
dom
上麼,如果不是綁定在哪裏? -
2 爲什麼我們的事件不能綁定給組件?
-
3 爲什麼我們的事件手動綁定
this
(不是箭頭函數的情況) -
4 爲什麼不能用
return false
來阻止事件的默認行爲? -
5
react
怎麼通過dom
元素,找到與之對應的fiber
對象的? -
6
onClick
是在冒泡階段綁定的?那麼onClickCapture
就是在事件捕獲階段綁定的嗎?
必要的知識概念
在弄清楚react
事件之前,有幾個概念我們必須弄清楚,因爲只有弄明白這幾個概念,在事件觸發階段,我們才能更好的理解react
處理事件本質。
我們寫在 JSX 事件終將變成什麼?
我們先寫一段含有點擊事件的react JSX
語法,看一下它最終會變成什麼樣子?
class Index extends React.Component{
handerClick= (value) => console.log(value)
render(){
return <div>
<button onClick={ this.handerClick } > 按鈕點擊 </button>
</div>
}
}
經過babel
轉換成React.createElement
形式,如下:
最終轉成fiber
對象形式如下:
fiber
對象上的memoizedProps
和 pendingProps
保存了我們的事件。
什麼是合成事件?
通過上一步我們看到了,我們聲明事件保存的位置。但是事件有沒有被真正的註冊呢?我們接下來看一下:
我們看一下當前這個元素<button>
上有沒有綁定這個事件監聽器呢?
button 上綁定的事件
我們可以看到 ,button
上綁定了兩個事件,一個是document
上的事件監聽器,另外一個是button
,但是事件處理函數handle
,並不是我們的handerClick
事件,而是noop
。
noop
是什麼呢?我們接着來看。
原來noop
就指向一個空函數。
然後我們看document
綁定的事件
可以看到click
事件被綁定在document
上了。
接下來我們再搞搞事情😂😂😂,在demo
項目中加上一個input
輸入框,並綁定一個onChange
事件。睜大眼睛看看接下來會發生什麼?
class Index extends React.Component{
componentDidMount(){
console.log(this)
}
handerClick= (value) => console.log(value)
handerChange=(value) => console.log(value)
render(){
return <div style={{ marginTop:'50px' }} >
<button onClick={ this.handerClick } > 按鈕點擊 </button>
<input placeholder="請輸入內容" onChange={ this.handerChange } />
</div>
}
}
我們先看一下input dom
元素上綁定的事件
然後我們看一下document
上綁定的事件
我們發現,我們給<input>
綁定的onChange
,並沒有直接綁定在input
上,而是統一綁定在了document
上,然後我們onChange
被處理成很多事件監聽器,比如blur
, change
, input
, keydown
, keyup
等。
綜上我們可以得出結論:
-
①我們在
jsx
中綁定的事件 (demo 中的handerClick
,handerChange
), 根本就沒有註冊到真實的dom
上。是綁定在document
上統一管理的。 -
②真實的
dom
上的click
事件被單獨處理, 已經被react
底層替換成空函數。 -
③我們在
react
綁定的事件, 比如onChange
,在document
上,可能有多個事件與之對應。 -
④
react
並不是一開始,把所有的事件都綁定在document
上,而是採取了一種按需綁定,比如發現了onClick
事件, 再去綁定document click
事件。
那麼什麼是react
事件合成呢?
在react
中,我們綁定的事件onClick
等,並不是原生事件,而是由原生事件合成的React
事件,比如 click
事件合成爲onClick
事件。比如blur
, change
, input
, keydown
, keyup
等 , 合成爲onChange
。
那麼react
採取這種事件合成的模式呢?
一方面,將事件綁定在document
統一管理,防止很多事件直接綁定在原生的dom
元素上。造成一些不可控的情況
另一方面, React
想實現一個全瀏覽器的框架, 爲了實現這種目標就需要提供全瀏覽器一致性的事件系統,以此抹平不同瀏覽器的差異。
接下來的文章中,會介紹react
是怎麼做事件合成的。
dom 元素對應的 fiber Tag 對象
我們知道了react
怎麼儲存了我們的事件函數和事件合成因果。接下來我想讓大家記住一種類型的 fiber
對象, 因爲後面會用到,這對後續的理解很有幫助。
我們先來看一個代碼片段:
<div>
<div> hello , my name is alien </div>
</div>
看<div> hello , my name is alien </div>
對應的 fiber
類型。tag = 5
react
源碼中找到這種類的fiber
類型。
/react-reconciler/src/ReactWorkTagsq.js
export const HostComponent = 5; // 元素節點
好的 ,我們暫且把 HostComponent
和 HostText
記錄📝下來。接下來回歸正題,我們先來看看react
事件合成機制。
二 事件初始化 - 事件合成,插件機制
接下來,我們來看一看react
這麼處理事件合成的。首先我們從上面我們知道,react
並不是一次性把所有事件都綁定進去,而是如果發現項目中有onClick
,才綁定click
事件,發現有onChange
事件,才綁定blur
, change
, input
, keydown
, keyup
等。所以爲了把原理搞的清清楚楚,筆者把事件原理分成三部分來搞定:
-
1
react
對事件是如何合成的。 -
2
react
事件是怎麼綁定的。 -
3
react
事件觸發流程。
事件合成 - 事件插件
1 必要概念
我們先來看來幾個常量關係,這對於我們喫透react
事件原理很有幫助。在解析來的講解中,我也會講到這幾個對象如何來的,具體有什麼作用。
①namesToPlugins
第一個概念:namesToPlugins 裝事件名 -> 事件模塊插件的映射,namesToPlugins
最終的樣子如下:
const namesToPlugins = {
SimpleEventPlugin,
EnterLeaveEventPlugin,
ChangeEventPlugin,
SelectEventPlugin,
BeforeInputEventPlugin,
}
SimpleEventPlugin
等是處理各個事件函數的插件,比如一次點擊事件,就會找到SimpleEventPlugin
對應的處理函數。我們先記錄下它,至於具體有什麼作用,接下來會講到。
②plugins
plugins
,這個對象就是上面註冊的所有插件列表, 初始化爲空。
const plugins = [LegacySimpleEventPlugin, LegacyEnterLeaveEventPlugin, ...];
③registrationNameModules
registrationNameModules
記錄了 React 合成的事件 - 對應的事件插件的關係,在React
中,處理props
中事件的時候,會根據不同的事件名稱,找到對應的事件插件,然後統一綁定在document
上。對於沒有出現過的事件,就不會綁定,我們接下來會講到。registrationNameModules
大致的樣子如下所示。
{
onBlur: SimpleEventPlugin,
onClick: SimpleEventPlugin,
onClickCapture: SimpleEventPlugin,
onChange: ChangeEventPlugin,
onChangeCapture: ChangeEventPlugin,
onMouseEnter: EnterLeaveEventPlugin,
onMouseLeave: EnterLeaveEventPlugin,
...
}
④事件插件
那麼我們首先就要搞清楚,SimpleEventPlugin
,EnterLeaveEventPlugin
每個插件都是什麼?我們拿SimpleEventPlugin
爲例,看一下它究竟是什麼樣子?
const SimpleEventPlugin = {
eventTypes:{
'click':{ /* 處理點擊事件 */
phasedRegistrationNames:{
bubbled: 'onClick', // 對應的事件冒泡 - onClick
captured:'onClickCapture' //對應事件捕獲階段 - onClickCapture
},
dependencies: ['click'], //事件依賴
...
},
'blur':{ /* 處理失去焦點事件 */ },
...
}
extractEvents:function(topLevelType,targetInst,){ /* eventTypes 裏面的事件對應的統一事件處理函數,接下來會重點講到 */ }
}
首先事件插件是一個對象,有兩個屬性,第一個extractEvents
作爲事件統一處理函數,第二個eventTypes
是一個對象,對象保存了原生事件名和對應的配置項dispatchConfig
的映射關係。由於 v16React 的事件是統一綁定在document
上的,React 用獨特的事件名稱比如onClick
和onClickCapture
,來說明我們給綁定的函數到底是在冒泡事件階段,還是捕獲事件階段執行。
⑤ registrationNameDependencies
registrationNameDependencies
用來記錄,合成事件比如 onClick
和原生事件 click
對應關係。比如 onChange
對應 change
, input
, keydown
, keyup
事件。
{
onBlur: ['blur'],
onClick: ['click'],
onClickCapture: ['click'],
onChange: ['blur', 'change', 'click', 'focus', 'input', 'keydown', 'keyup', 'selectionchange'],
onMouseEnter: ['mouseout', 'mouseover'],
onMouseLeave: ['mouseout', 'mouseover'],
...
}
2 事件初始化
對於事件合成,v16.13.1
版本react
採用了初始化註冊方式。
react-dom/src/client/ReactDOMClientInjection.js
/* 第一步:註冊事件: */
injectEventPluginsByName({
SimpleEventPlugin: SimpleEventPlugin,
EnterLeaveEventPlugin: EnterLeaveEventPlugin,
ChangeEventPlugin: ChangeEventPlugin,
SelectEventPlugin: SelectEventPlugin,
BeforeInputEventPlugin: BeforeInputEventPlugin,
});
injectEventPluginsByName
這個函數具體有什麼用呢,它在react
底層是默認執行的。我們來簡化這個函數, 看它到底是幹什麼的。
legacy-event/EventPluginRegistry.js
/* 註冊事件插件 */
export function injectEventPluginsByName(injectedNamesToPlugins){
for (const pluginName in injectedNamesToPlugins) {
namesToPlugins[pluginName] = injectedNamesToPlugins[pluginName]
}
recomputePluginOrdering()
}
injectEventPluginsByName
做的事情很簡單,形成上述的namesToPlugins
,然後執行recomputePluginOrdering
,我們接下來看一下recomputePluginOrdering
做了寫什麼?
const eventPluginOrder = [ 'SimpleEventPlugin' , 'EnterLeaveEventPlugin','ChangeEventPlugin','SelectEventPlugin' , 'BeforeInputEventPlugin' ]
function recomputePluginOrdering(){
for (const pluginName in namesToPlugins) {
/* 找到對應的事件處理插件,比如 SimpleEventPlugin */
const pluginModule = namesToPlugins[pluginName];
const pluginIndex = eventPluginOrder.indexOf(pluginName);
/* 填充 plugins 數組 */
plugins[pluginIndex] = pluginModule;
}
const publishedEvents = pluginModule.eventTypes;
for (const eventName in publishedEvents) {
// publishedEvents[eventName] -> eventConfig , pluginModule -> 事件插件 , eventName -> 事件名稱
publishEventForPlugin(publishedEvents[eventName],pluginModule,eventName,)
}
}
】
recomputePluginOrdering, 作用很明確了,形成上面說的那個plugins
, 數組。然後就是重點的函數publishEventForPlugin
。
/*
dispatchConfig -> 原生事件對應配置項 { phasedRegistrationNames :{ 冒泡 捕獲 } , }
pluginModule -> 事件插件 比如SimpleEventPlugin
eventName -> 原生事件名稱。
*/
function publishEventForPlugin (dispatchConfig,pluginModule,eventName){
eventNameDispatchConfigs[eventName] = dispatchConfig;
/* 事件 */
const phasedRegistrationNames = dispatchConfig.phasedRegistrationNames;
if (phasedRegistrationNames) {
for (const phaseName in phasedRegistrationNames) {
if (phasedRegistrationNames.hasOwnProperty(phaseName)) {
// phasedRegistrationName React事件名 比如 onClick / onClickCapture
const phasedRegistrationName = phasedRegistrationNames[phaseName];
// 填充形成 registrationNameModules React 合成事件 -> React 處理事件插件映射關係
registrationNameModules[phasedRegistrationName] = pluginModule;
// 填充形成 registrationNameDependencies React 合成事件 -> 原生事件 映射關係
registrationNameDependencies[phasedRegistrationName] = pluginModule.eventTypes[eventName].dependencies;
}
}
return true;
}
}
publishEventForPlugin
作用形成上述的 registrationNameModules
和 registrationNameDependencies
對象中的映射關係。
3 事件合成總結
到這裏整個初始化階段已經完事了,我來總結一下初始化事件合成都做了些什麼。這個階段主要形成了上述的幾個重要對象,構建初始化 React 合成事件和原生事件的對應關係,合成事件和對應的事件處理插件關係。接下來就是事件綁定階段。
三 事件綁定 - 從一次點擊事件開始
事件綁定流程
如果我們在一個組件中這麼寫一個點擊事件,React
會一步步如何處理。
1 diffProperties 處理 React 合成事件
<div>
<button onClick={ this.handerClick } class >點擊</button>
</div>
第一步,首先通過上面的講解,我們綁定給 hostComponent 種類的 fiber(如上的 button 元素),會 button
對應的 fiber 上,以memoizedProps
和 pendingProps
形成保存。
button 對應 fiber
memoizedProps = {
onClick:function handerClick(){},
className:'button'
}
結構圖如下所示:
第二步,React 在調合子節點後,進入 diff 階段,如果判斷是HostComponent
(dom 元素) 類型的 fiber,會用 diff props 函數diffProperties
單獨處理。
react-dom/src/client/ReactDOMComponent.js
function diffProperties(){
/* 判斷當前的 propKey 是不是 React合成事件 */
if(registrationNameModules.hasOwnProperty(propKey)){
/* 這裏多個函數簡化了,如果是合成事件, 傳入成事件名稱 onClick ,向document註冊事件 */
legacyListenToEvent(registrationName, document);
}
}
diffProperties
函數在 diff props
如果發現是合成事件 (onClick
) 就會調用legacyListenToEvent
函數。註冊事件監聽器。
2 legacyListenToEvent 註冊事件監聽器
react-dom/src/events/DOMLegacyEventPluginSystem.js
// registrationName -> onClick 事件
// mountAt -> document or container
function legacyListenToEvent(registrationName,mountAt){
const dependencies = registrationNameDependencies[registrationName]; // 根據 onClick 獲取 onClick 依賴的事件數組 [ 'click' ]。
for (let i = 0; i < dependencies.length; i++) {
const dependency = dependencies[i];
//這個經過多個函數簡化,如果是 click 基礎事件,會走 legacyTrapBubbledEvent ,而且都是按照冒泡處理
legacyTrapBubbledEvent(dependency, mountAt);
}
}
legacyTrapBubbledEvent 就是執行將綁定真正的 dom 事件的函數 legacyTrapBubbledEvent(冒泡處理)。
function legacyTrapBubbledEvent(topLevelType,element){
addTrappedEventListener(element,topLevelType,PLUGIN_EVENT_SYSTEM,false)
}
第三步:在legacyListenToEvent
函數中,先找到 React
合成事件對應的原生事件集合,比如 onClick -> ['click'] , onChange -> [blur
, change
, input
, keydown
, keyup
],然後遍歷依賴項的數組,綁定事件,這就解釋了,爲什麼我們在剛開始的 demo 中,只給元素綁定了一個onChange
事件,結果在document
上出現很多事件監聽器的原因,就是在這個函數上處理的。
我們上面已經透露了 React 是採用事件綁定,React
對於 click
等基礎事件,會默認按照事件冒泡階段的事件處理,不過這也不絕對的,比如一些事件的處理,有些特殊的事件是按照事件捕獲處理的。
case TOP_SCROLL: { // scroll 事件
legacyTrapCapturedEvent(TOP_SCROLL, mountAt); // legacyTrapCapturedEvent 事件捕獲處理。
break;
}
case TOP_FOCUS: // focus 事件
case TOP_BLUR: // blur 事件
legacyTrapCapturedEvent(TOP_FOCUS, mountAt);
legacyTrapCapturedEvent(TOP_BLUR, mountAt);
break;
3 綁定 dispatchEvent,進行事件監聽
如上述的scroll
事件,focus
事件 ,blur
事件等,是默認按照事件捕獲邏輯處理。接下來就是最重要關鍵的一步。React 是如何綁定事件到document
?事件處理函數函數又是什麼?問題都指向了上述的addTrappedEventListener
,讓我們來揭開它的面紗。
/*
targetContainer -> document
topLevelType -> click
capture = false
*/
function addTrappedEventListener(targetContainer,topLevelType,eventSystemFlags,capture){
const listener = dispatchEvent.bind(null,topLevelType,eventSystemFlags,targetContainer)
if(capture){
// 事件捕獲階段處理函數。
}else{
/* TODO: 重要, 這裏進行真正的事件綁定。*/
targetContainer.addEventListener(topLevelType,listener,false) // document.addEventListener('click',listener,false)
}
}
第四步:這個函數內容雖然不多,但是卻非常重要, 首先綁定我們的事件統一處理函數 dispatchEvent
,綁定幾個默認參數,事件類型 topLevelType
demo 中的click
,還有綁定的容器doucment
。然後真正的事件綁定, 添加事件監聽器addEventListener
。 事件綁定階段完畢。
4 事件綁定過程總結
我們來做一下事件綁定階段的總結。
-
① 在 React,diff DOM 元素類型的 fiber 的 props 的時候, 如果發現是 React 合成事件,比如
onClick
,會按照事件系統邏輯單獨處理。 -
② 根據 React 合成事件類型,找到對應的原生事件的類型,然後調用判斷原生事件類型,大部分事件都按照冒泡邏輯處理,少數事件會按照捕獲邏輯處理(比如
scroll
事件)。 -
③ 調用 addTrappedEventListener 進行真正的事件綁定,綁定在
document
上,dispatchEvent
爲統一的事件處理函數。 -
④ 有一點值得注意: 只有上述那幾個特殊事件比如
scorll
,focus
,blur
等是在事件捕獲階段發生的,其他的都是在事件冒泡階段發生的,無論是onClick
還是onClickCapture
都是發生在冒泡階段,至於 React 本身怎麼處理捕獲邏輯的。我們接下來會講到。
四 事件觸發 - 一次點擊事件,在react
底層系統會發生什麼?
<div>
<button onClick={ this.handerClick } class >點擊</button>
</div>
還是上面這段代碼片段,當點擊一下按鈕,在 React
底層會發生什麼呢?接下來,讓我共同探索事件觸發的奧祕。
事件觸發處理函數 dispatchEvent
我們在事件綁定階段講過,React 事件註冊時候,統一的監聽器dispatchEvent
,也就是當我們點擊按鈕之後,首先執行的是dispatchEvent
函數,因爲dispatchEvent
前三個參數已經被 bind 了進去,所以真正的事件源對象event
,被默認綁定成第四個參數。
react-dom/src/events/ReactDOMEventListener.js
function dispatchEvent(topLevelType,eventSystemFlags,targetContainer,nativeEvent){
/* 嘗試調度事件 */
const blockedOn = attemptToDispatchEvent( topLevelType,eventSystemFlags, targetContainer, nativeEvent);
}
/*
topLevelType -> click
eventSystemFlags -> 1
targetContainer -> document
nativeEvent -> 原生事件的 event 對象
*/
function attemptToDispatchEvent(topLevelType,eventSystemFlags,targetContainer,nativeEvent){
/* 獲取原生事件 e.target */
const nativeEventTarget = getEventTarget(nativeEvent)
/* 獲取當前事件,最近的dom類型fiber ,我們 demo中 button 按鈕對應的 fiber */
let targetInst = getClosestInstanceFromNode(nativeEventTarget);
/* 重要:進入legacy模式的事件處理系統 */
dispatchEventForLegacyPluginEventSystem(topLevelType,eventSystemFlags,nativeEvent,targetInst,);
return null;
}
在這個階段主要做了這幾件事:
-
① 首先根據真實的事件源對象,找到
e.target
真實的dom
元素。 -
② 然後根據
dom
元素,找到與它對應的fiber
對象targetInst
,在我們demo
中,找到button
按鈕對應的fiber
。 -
③ 然後正式進去
legacy
模式的事件處理系統,也就是我們目前用的 React 模式都是legacy
模式下的,在這個模式下,批量更新原理,即將拉開帷幕。
這裏有一點問題,React
怎麼樣通過原生的dom
元素,找到對應的fiber
的呢? ,也就是說 getClosestInstanceFromNode
原理是什麼?
答案是首先 getClosestInstanceFromNode
可以找到當前傳入的 dom
對應的最近的元素類型的 fiber
對象。React
在初始化真實 dom
的時候,用一個隨機的 key internalInstanceKey
指針指向了當前dom
對應的fiber
對象,fiber
對象用stateNode
指向了當前的dom
元素。
// 聲明隨機key
var internalInstanceKey = '__reactInternalInstance$' + randomKey;
// 使用隨機key
function getClosestInstanceFromNode(targetNode){
// targetNode -dom targetInst -> 與之對應的fiber對象
var targetInst = targetNode[internalInstanceKey];
}
在谷歌調試器上看
兩者關係圖
legacy 事件處理系統與批量更新
react-dom/src/events/DOMLegacyEventPluginSystem.js
/* topLevelType - click事件 | eventSystemFlags = 1 | nativeEvent = 事件源對象 | targetInst = 元素對應的fiber對象 */
function dispatchEventForLegacyPluginEventSystem(topLevelType,eventSystemFlags,nativeEvent,targetInst){
/* 從React 事件池中取出一個,將 topLevelType ,targetInst 等屬性賦予給事件 */
const bookKeeping = getTopLevelCallbackBookKeeping(topLevelType,nativeEvent,targetInst,eventSystemFlags);
try { /* 執行批量更新 handleTopLevel 爲事件處理的主要函數 */
batchedEventUpdates(handleTopLevel, bookKeeping);
} finally {
/* 釋放事件池 */
releaseTopLevelCallbackBookKeeping(bookKeeping);
}
}
對於 v16 事件池,我們接下來會講到,首先 batchedEventUpdates
爲批量更新的主要函數。我們先來看看batchedEventUpdates
react-dom/src/events/ReactDOMUpdateBatching.js
export function batchedEventUpdates(fn,a){
isBatchingEventUpdates = true;
try{
fn(a) // handleTopLevel(bookKeeping)
}finally{
isBatchingEventUpdates = false
}
}
批量更新簡化成如上的樣子,從上面我們可以看到,React 通過開關isBatchingEventUpdates
來控制是否啓用批量更新。fn(a)
,事件上調用的是 handleTopLevel(bookKeeping)
,由於 js 是單線程的,我們真正在組件中寫的事件處理函數,比如 demo 的 handerClick
實際執行是在handleTopLevel(bookKeeping)
中執行的。所以如果我們在handerClick
裏面觸發setState
,那麼就能讀取到isBatchingEventUpdates = true
這就是 React 的合成事件爲什麼具有批量更新的功能了。比如我們這麼寫
state={number:0}
handerClick = () =>{
this.setState({number: this.state.number + 1 })
console.log(this.state.number) //0
this.setState({number: this.state.number + 1 })
console.log(this.state.number) //0
setTimeout(()=>{
this.setState({number: this.state.number + 1 })
console.log(this.state.number) //2
this.setState({number: this.state.number + 1 })
console.log(this.state.number)// 3
})
}
如上述所示,第一個setState
和第二個setState
在批量更新條件之內執行,所以打印不會是最新的值,但是如果是發生在setTimeout
中, 由於 eventLoop 放在了下一次事件循環中執行,此時 batchedEventUpdates 中已經執行完isBatchingEventUpdates = false
,所以批量更新被打破,我們就可以直接訪問到最新變化的值了。
接下來我們有兩點沒有梳理:
-
一是 React 事件池概念
-
二是最後的線索是執行
handleTopLevel(bookKeeping)
,那麼handleTopLevel
到底做了寫什麼。
執行事件插件函數
上面說到整個事件系統,最後指向函數 handleTopLevel(bookKeeping)
那麼 handleTopLevel
到底做了什麼事情?
// 流程簡化後
// topLevelType - click
// targetInst - button Fiber
// nativeEvent
function handleTopLevel(bookKeeping){
const { topLevelType,targetInst,nativeEvent,eventTarget, eventSystemFlags} = bookKeeping
for(let i=0; i < plugins.length;i++ ){
const possiblePlugin = plugins[i];
/* 找到對應的事件插件,形成對應的合成event,形成事件執行隊列 */
const extractedEvents = possiblePlugin.extractEvents(topLevelType,targetInst,nativeEvent,eventTarget,eventSystemFlags)
}
if (extractedEvents) {
events = accumulateInto(events, extractedEvents);
}
/* 執行事件處理函數 */
runEventsInBatch(events);
}
我把整個流程簡化,只保留了核心的流程,handleTopLevel
最後的處理邏輯就是執行我們說的事件處理插件 (SimpleEventPlugin) 中的處理函數extractEvents
,比如我們 demo 中的點擊事件 onClick 最終走的就是 SimpleEventPlugin
中的 extractEvents
函數,那麼 React 爲什麼這麼做呢? 我們知道我們 React 是採取事件合成,事件統一綁定,並且我們寫在組件中的事件處理函數 (handerClick),也不是真正的執行函數dispatchAciton
,那麼我們在handerClick
的事件對象 event
, 也是 React 單獨合成處理的,裏面單獨封裝了比如 stopPropagation
和preventDefault
等方法,這樣的好處是,我們不需要跨瀏覽器單獨處理兼容問題,交給 React 底層統一處理。
extractEvents 形成事件對象 event 和 事件處理函數隊列
重點來了!重點來了!重點來了!,extractEvents 可以作爲整個事件系統核心函數,我們先回到最初的demo
,如果我們這麼寫, 那麼四個回調函數,那麼點擊按鈕,四個事件是如何處理的呢。首先如果點擊按鈕,最終走的就是extractEvents
函數,一探究竟這個函數。
legacy-events/SyntheticEvent.js
const SimpleEventPlugin = {
extractEvents:function(topLevelType,targetInst,nativeEvent,nativeEventTarget){
const dispatchConfig = topLevelEventsToDispatchConfig.get(topLevelType);
if (!dispatchConfig) {
return null;
}
switch(topLevelType){
default:
EventConstructor = SyntheticEvent;
break;
}
/* 產生事件源對象 */
const event = EventConstructor.getPooled(dispatchConfig,targetInst,nativeEvent,nativeEventTarget)
const phasedRegistrationNames = event.dispatchConfig.phasedRegistrationNames;
const dispatchListeners = [];
const {bubbled, captured} = phasedRegistrationNames; /* onClick / onClickCapture */
const dispatchInstances = [];
/* 從事件源開始逐漸向上,查找dom元素類型HostComponent對應的fiber ,收集上面的React合成事件,onClick / onClickCapture */
while (instance !== null) {
const {stateNode, tag} = instance;
if (tag === HostComponent && stateNode !== null) { /* DOM 元素 */
const currentTarget = stateNode;
if (captured !== null) { /* 事件捕獲 */
/* 在事件捕獲階段,真正的事件處理函數 */
const captureListener = getListener(instance, captured);
if (captureListener != null) {
/* 對應發生在事件捕獲階段的處理函數,邏輯是將執行函數unshift添加到隊列的最前面 */
dispatchListeners.unshift(captureListener);
dispatchInstances.unshift(instance);
dispatchCurrentTargets.unshift(currentTarget);
}
}
if (bubbled !== null) { /* 事件冒泡 */
/* 事件冒泡階段,真正的事件處理函數,邏輯是將執行函數push到執行隊列的最後面 */
const bubbleListener = getListener(instance, bubbled);
if (bubbleListener != null) {
dispatchListeners.push(bubbleListener);
dispatchInstances.push(instance);
dispatchCurrentTargets.push(currentTarget);
}
}
}
instance = instance.return;
}
if (dispatchListeners.length > 0) {
/* 將函數執行隊列,掛到事件對象event上 */
event._dispatchListeners = dispatchListeners;
event._dispatchInstances = dispatchInstances;
event._dispatchCurrentTargets = dispatchCurrentTargets;
}
return event
}
}
事件插件系統的核心extractEvents
主要做的事是:
-
① 首先形成
React
事件獨有的合成事件源對象,這個對象,保存了整個事件的信息。將作爲參數傳遞給真正的事件處理函數 (handerClick)。 -
② 然後聲明事件執行隊列 ,按照
冒泡
和捕獲
邏輯,從事件源開始逐漸向上,查找 dom 元素類型 HostComponent 對應的 fiber ,收集上面的React
合成事件,例如onClick
/onClickCapture
,對於冒泡階段的事件 (onClick
),將push
到執行隊列後面 , 對於捕獲階段的事件 (onClickCapture
),將unShift
到執行隊列的前面。 -
③ 最後將事件執行隊列,保存到 React 事件源對象上。等待執行。
舉個例子比如如下
handerClick = () => console.log(1)
handerClick1 = () => console.log(2)
handerClick2 = () => console.log(3)
handerClick3= () => console.log(4)
render(){
return <div onClick={ this.handerClick2 } onClickCapture={this.handerClick3} >
<button onClick={ this.handerClick } onClickCapture={ this.handerClick1 } class >點擊</button>
</div>
}
打印 // 4 2 1 3
看到這裏我們應該知道上述函數打印順序爲什麼了吧,首先遍歷 button
對應的 fiber,首先遇到了 onClickCapture
, 將 handerClick1
放到了數組最前面,然後又把onClick
對應handerClick
的放到數組的最後面,形成的結構是[ handerClick1 , handerClick ]
, 然後向上遍歷,遇到了div
對應 fiber, 將onClickCapture
對應的handerClick3
放在了數組前面,將onClick
對應的 handerClick2
放在了數組後面,形成的結構 [ handerClick3,handerClick1 , handerClick,handerClick2 ]
, 所以執行的順序 // 4 2 1 3,就是這麼簡單,完美!
事件觸發
有的同學可能好奇 React 的事件源對象是什麼樣的,以上面代碼中SyntheticEvent
爲例子我們一起來看看:
legacy-events/SyntheticEvent.js/
function SyntheticEvent( dispatchConfig,targetInst,nativeEvent,nativeEventTarget){
this.dispatchConfig = dispatchConfig;
this._targetInst = targetInst;
this.nativeEvent = nativeEvent;
this._dispatchListeners = null;
this._dispatchInstances = null;
this._dispatchCurrentTargets = null;
this.isPropagationStopped = () => false; /* 初始化,返回爲false */
}
SyntheticEvent.prototype={
stopPropagation(){ this.isPropagationStopped = () => true; }, /* React單獨處理,阻止事件冒泡函數 */
preventDefault(){ }, /* React單獨處理,阻止事件捕獲函數 */
...
}
在 handerClick
中打印 e
:
既然事件執行隊列和事件源對象都形成了,接下來就是最後一步事件觸發了。上面大家有沒有注意到一個函數runEventsInBatch
,所有事件綁定函數,就是在這裏觸發的。讓我們一起看看。
legacy-events/EventBatching.js
function runEventsInBatch(){
const dispatchListeners = event._dispatchListeners;
const dispatchInstances = event._dispatchInstances;
if (Array.isArray(dispatchListeners)) {
for (let i = 0; i < dispatchListeners.length; i++) {
if (event.isPropagationStopped()) { /* 判斷是否已經阻止事件冒泡 */
break;
}
dispatchListeners[i](event)
}
}
/* 執行完函數,置空兩字段 */
event._dispatchListeners = null;
event._dispatchInstances = null;
}
dispatchListeners[i](event)
就是執行我們的事件處理函數比如handerClick
, 從這裏我們知道,我們在事件處理函數中,返回 false ,並不會阻止瀏覽器默認行爲。
handerClick(){ //並不能阻止瀏覽器默認行爲。
return false
}
應該改成這樣:
handerClick(e){
e.preventDefault()
}
另一方面 React 對於阻止冒泡,就是通過 isPropagationStopped,判斷是否已經阻止事件冒泡。如果我們在事件函數執行隊列中,某一會函數中,調用e.stopPropagation()
,就會賦值給isPropagationStopped=()=>true
,當再執行 e.isPropagationStopped()
就會返回 true
, 接下來事件處理函數,就不會執行了。
其他概念 - 事件池
handerClick = (e) => {
console.log(e.target) // button
setTimeout(()=>{
console.log(e.target) // null
},0)
}
對於一次點擊事件的處理函數,在正常的函數執行上下文中打印e.target
就指向了dom
元素,但是在setTimeout
中打印卻是null
,如果這不是 React 事件系統,兩次打印的應該是一樣的,但是爲什麼兩次打印不一樣呢?因爲在 React 採取了一個事件池的概念,每次我們用的事件源對象,在事件函數執行之後,可以通過releaseTopLevelCallbackBookKeeping
等方法將事件源對象釋放到事件池中,這樣的好處每次我們不必再創建事件源對象,可以從事件池中取出一個事件源對象進行復用,在事件處理函數執行完畢後, 會釋放事件源到事件池中,清空屬性,這就是setTimeout
中打印爲什麼是null
的原因了。
事件觸發總結
我把事件觸發階段做的事總結一下:
-
①首先通過統一的事件處理函數
dispatchEvent
, 進行批量更新 batchUpdate。 -
②然後執行事件對應的處理插件中的
extractEvents
,合成事件源對象, 每次 React 會從事件源開始,從上遍歷類型爲 hostComponent 即 dom 類型的 fiber, 判斷 props 中是否有當前事件比如 onClick, 最終形成一個事件執行隊列,React 就是用這個隊列,來模擬事件捕獲 -> 事件源 -> 事件冒泡這一過程。 -
③最後通過
runEventsInBatch
執行事件隊列,如果發現阻止冒泡,那麼 break 跳出循環,最後重置事件源,放回到事件池中,完成整個流程。
五 關於 react v17 版本的事件系統
React v17 整體改動不是很大,但是事件系統的改動卻不小,首先上述的很多執行函數,在 v17 版本不復存在了。我來簡單描述一下 v17 事件系統的改版。
1 事件統一綁定 container 上,ReactDOM.render(app, container); 而不是 document 上,這樣好處是有利於微前端的,微前端一個前端系統中可能有多個應用,如果繼續採取全部綁定在document
上,那麼可能多應用下會出現問題。
2 對齊原生瀏覽器事件
React 17
中終於支持了原生捕獲事件的支持, 對齊了瀏覽器原生標準。同時 onScroll
事件不再進行事件冒泡。onFocus
和 onBlur
使用原生 focusin
, focusout
合成。
3 取消事件池React 17
取消事件池複用,也就解決了上述在setTimeout
打印,找不到e.target
的問題。
六 總結
本文從事件合成,事件綁定,事件觸發三個方面詳細介紹了 React 事件系統原理,希望大家能通過這篇文章更加深入瞭解 v16 React 事件系統,如果有疑問和不足之處,也希望大家能在評論區指出。
最後, 送人玫瑰,手留餘香,覺得有收穫的朋友可以給筆者點贊,關注一波 ,陸續更新前端超硬核文章。
參考文檔
-
react 源碼
-
React 事件系統工作原理
如果你覺得這篇內容對你挺有啓發,我想邀請你幫我三個小忙:
-
點個「在看」,讓更多的人也能看到這篇內容(喜歡不點在看,都是耍流氓 -_-)
-
歡迎加我微信「TH0000666」一起交流學習...
-
關注公衆號「前端 Sharing」,持續爲你推送精選好文。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/LvzC74rlYZr4tSkFkxGAoA