你們好,我是卡頌,人稱卡爾摩斯。html
今天,咱們來追查一個棘手的React bug
,知名組件庫material-ui就受其影響。react
這個bug
的產生涉及多方因素,包括:git
useEffect
執行時機(極可能與你想的不同)github
合成事件
原理算法
v17
源碼中對合成事件
的改動瀏覽器
Portal
原理markdown
這篇文章很長很長,有很是多源碼細節。架構
你能夠用以下Demo
和我一塊兒debug
源碼,更有破案的感受app
在線Demo地址dom
相信整篇文章過完,你能對如上知識點有更深的理解。
接下來,讓咱們復現案發現場吧。
假設,咱們有個ToastButton
組件,代碼以下:
function ToastButton() {
const [show, setShow] = useState(false);
useEffect(() => {
if (!show) return;
function clickHandler(e) {
setShow(false);
}
document.addEventListener("click", clickHandler);
return () => {
document.removeEventListener("click", clickHandler);
};
}, [show]);
return (
<div> <button type="button" onClick={() => setShow(true)}>Show Toast</button> {show && <div className="toast">Hey, Ka Song~</div>} </div>
);
}
複製代碼
點擊button
後,show
狀態變爲true
,展現toast
。
同時在useEffect
回調中,在document
上註冊點擊事件。
觸發點擊事件會讓show
狀態置爲false
,達到點擊頁面任意區域關閉toast的效果。
入口函數以下:
function App() {
return (
<ToastButton />
);
}
ReactDOM.render(<App />, document.getElementById("root"));
複製代碼
效果以下:
接下來,咱們再增長一個渲染Portal
的組件PortalRenderer
,代碼以下:
function PortalRenderer() {
const [show, setShow] = useState(false);
return (
<React.Fragment> <button type="button" onClick={() => setShow(true)}> Render portal </button> {show && ReactDOM.createPortal( <div>who is handsome?</div>, document.body )} </React.Fragment>
);
}
複製代碼
點擊button
後會將show
狀態置爲true
。
會使用ReactDOM.createPortal
在document.body
上掛載一個div
,內容爲who is handsome?
。
咱們將兩個組件一塊兒放在App
中:
function App() {
return (
<div> <PortalRenderer /> <ToastButton /> </div>
);
}
複製代碼
點擊PortalRenderer
效果以下:
如今問題來了:
若是先點擊
PortalRenderer
的button
,再點擊ToastButton
會怎麼樣?
理所固然的答案是:
先顯示who is handsome?
再顯示Hey, Ka Song~
然而,在React v17
效果以下:
先點擊PortalRenderer
的button
後,再點擊ToastButton
,不會看見toast
的內容。
可是,只要不點擊PortalRenderer
的button
就不會有問題:
這只是一個可復現該bug
的極簡Demo
。
事實上,在一個大型項目中,若是從v16
升級到v17
,
在使用瞭如上所示的在document掛載原生click事件方式實現toast
的同時,
再使用Portal
在document.body
掛載DOM
都會觸發該bug
。
一旦先渲染了Portal
,你的toast
就不能用了。意不意外?驚不驚喜?
接下來,讓咱們一步步揭開這個bug
的廬山真面目。
首先,咱們要明確,點擊Show Toast
沒反應,是由於沒渲染toast
,仍是由於渲染了toast
又馬上刪除了。
審查元素後發現,每當點擊Show Toast
,ToastButton
渲染的div
都會閃一下。
這表明該div
下發生了DOM
變化。
而咱們並無看到DOM
的插入,那麼這就表示:
這裏先發生了
DOM
插入,緊接着發生了DOM
移除
而這個DOM
就是toast
對應DOM
:
<div className="toast">Hey, Ka Song!</div>
咱們知道,該DOM
顯示與否受ToastButton
組件的show
狀態影響,
因而,接下來的線索有三條:
爲何一次點擊,ToastButton
組件的show
狀態先變爲true
,後變爲false
?
爲何只有在掛載了Portal
的狀況下bug
能復現?
爲何該bug
只在v17
復現?
該從哪條線索下手呢?
相比第1、二條,第三條線索能更好控制影響範圍。
看看v17
的更新log
,一條特性變化引發了卡爾摩斯的注意:
在v17
以前,整個應用的事件會冒泡到同一個根節點(html DOM
節點)。
而在v17
,每一個應用的事件都會冒泡到該應用本身的根節點(ReactDOM.render
掛載的節點,在Demo
中是div#root
)。
這個改動是爲了讓一個應用下能夠存在多個不一樣模式的子應用(兼容legacy mode
與concurrent mode
同時存在於一個應用)。
會不會是這個緣由呢?
因而,卡爾摩斯將目光鎖定在源碼中註冊事件的方法:addTrappedEventListener
在應用初始化時(調用ReactDOM.render
首屏渲染時),React
會遍歷全部原生事件名,依次在根節點調用該方法註冊事件回調。
在應用運行過程當中,全部原生事件都會由根節點(Demo
中的div#root
)代理。
以一個React
組件的onClick
事件舉例,當點擊發生後,會依次執行:
原生點擊事件向上冒泡
原生點擊事件冒泡到根節點,觸發addTrappedEventListener
註冊的事件處理函數
合成事件會在React
組件樹中從底向上冒泡
當合成事件冒泡到觸發點擊的組件時,調用onClick
方法
這就是React
合成事件的原理。
那麼,爲何只有在掛載了Portal
的狀況下bug
能復現?
難道Portal
與合成事件有關?
果真,當咱們點擊PortalRenderer
的button
後,又進入了addTrappedEventListener
的斷點。
與初始化時(執行ReactDOM.render
時)事件掛載的目標節點(div#root
)不一樣,
因爲Portal
掛載在document.body
上,見以下節選代碼:
// 節選自PortalRenderer
{show &&
ReactDOM.createPortal(
<div>who is handsome?</div>,
document.body
)}
複製代碼
因此會在document.body
再執行一遍全部原生事件
的代理邏輯。
能夠看到此時事件會在body
上註冊:
這就意味着,原生事件冒泡到根節點(div#root
)後,繼續向上冒泡,在document.body
又會觸發一遍事件處理函數。
以一個React
組件的onClick
事件舉例,當點擊發生後,會依次執行:
原生點擊事件向上冒泡
原生事件冒泡到根節點(div#root
),觸發addTrappedEventListener
註冊的事件處理函數
合成事件會在React
組件樹中從底向上冒泡
當合成事件冒泡到觸發點擊的組件時,調用onClick
方法
原生點擊事件繼續向上冒泡到document.body
重複觸發步驟3
難道bug
的緣由是onClick
被重複執行兩次?
若是是這麼明顯的bug
你們開發過程當中確定很容易復現。
咱們能夠在onClick
中打印日誌,能夠看到:一次點擊只會打印一條日誌。
那麼問題出在哪呢?
讓咱們回到第一條線索:
爲何一次點擊,
ToastButton
組件的show
狀態先變爲true
,後變爲false
?
咱們能夠從useEffect
回調中找找線索。
// 節選自ToastButton
useEffect(() => {
if (!show) return;
function clickHandler(e) {
setShow(false);
}
document.addEventListener("click", clickHandler);
return () => {
document.removeEventListener("click", clickHandler);
};
}, [show]);
複製代碼
能夠看到,state
變爲false
是因爲clickHandler
調用。
而clickHandler
調用是因爲document
被點擊。
因此show
狀態連續變化的緣由極可能是:
點擊ToastButton
,原生點擊事件冒泡到應用掛載的根節點
進入合成事件的冒泡邏輯,冒泡到ToastButton
時觸發onClick
onClick
中setShow(true)
,state
變爲true
,渲染toast DOM
useEffect
回調執行,爲document
綁定click
事件
原生點擊事件繼續冒泡,當冒泡到document
時,觸發其綁定的click
事件
調用clickHandler
將state
變爲false
,移除toast DOM
正當我爲這精妙的推理沾沾自喜時,忽然意識到一個問題:
要知足如上邏輯,步驟4和步驟5之間必須是同步執行。
由於一旦步驟4是異步執行,則當步驟5原生點擊事件冒泡到document
時,步驟4document
的click
事件還未綁定。
步驟4在useEffect
回調函數中,而useEffect
的回調是在執行完DOM
操做後異步執行的。
若是
useEffect
回調在DOM
變化後同步執行,會阻塞DOM
重排、重繪,因此被設計爲異步執行。若是必定要在DOM
變化後同步執行反作用,可使用useLayoutEffect
因此,正常狀況下,步驟4和步驟5是在不一樣的兩個瀏覽器task
執行。
然而,總有意外。
在React
中,一個常見的操做鏈路是:
用戶觸發事件 -> 改變
state
-> 依賴該state
的useEffect
回調執行
去掉中間環節,就是這樣:
用戶觸發事件 -> ... ->
useEffect
回調執行
而咱們剛纔說,useEffect
回調是異步執行的。
那麼設想如下場景:
用戶快速點擊鼠標觸發onClick
事件,如何保證每次點擊產生的useEffect
回調按順序執行呢?
爲了解決這個問題,React
將不一樣原生事件
分類。
其中click
、keydown
等這種不連續觸發的事件被稱爲離散事件(與之對應的就是scroll
這種能連續觸發的事件)。
源碼中全部離散事件的定義見這裏
爲了保證以下鏈路中的useEffect
回調都能按順序執行
離散事件 -> ... ->
useEffect
回調執行
每當處理離散事件
前,都會執行flushPassiveEffects
方法。
該方法會將還未執行的useEffect
回調執行。
這樣就能保證下一次useEffect
回調執行前上一次的useEffect
回調已經執行。
因此,當不點擊PortalRenderer
的button
掛載Portal
時,點擊ToastButton
的完整流程以下:
點擊ToastButton
,原生點擊事件冒泡到應用掛載的根節點
進入合成事件的冒泡邏輯,冒泡到ToastButton
時觸發onClick
onClick
中setShow(true)
,state
變爲true
,渲染toast DOM
useEffect
回調異步執行,爲document
綁定click
事件
原生點擊事件繼續冒泡到document
,此時document
還未綁定click
事件
UI
表現爲:點擊ToastButton
,展現toast
。
當點擊PortalRenderer
的button
掛載Portal
後,再點擊ToastButton
的完整流程以下:
點擊PortalRenderer
的button
,在document.body
掛載Portal
對應DOM
在document.body
執行綁定事件代理邏輯
點擊ToastButton
,原生點擊事件冒泡到應用掛載的根節點
進入合成事件的冒泡邏輯,冒泡到ToastButton
時觸發onClick
onClick
中setShow(true)
,state
變爲true
,渲染toast DOM
useEffect
回調異步執行,爲document
綁定click
事件
原生點擊事件繼續冒泡到document.body
,因爲body
綁定了事件代理邏輯,因此會處理離散事件
處理的第一步是將還未執行的步驟6同步執行,此時document
綁定click
事件
原生點擊事件繼續冒泡到document
,觸發步驟6綁定的click
事件
調用clickHandler
將state
變爲false
,移除toast DOM
UI
表現爲:點擊ToastButton
,無反應(實際是先展現toast
,再在同一個瀏覽器task
移除toast
)
能夠看到,這是React
源碼運行流程的幾個feature
綜合起來形成的bug
。
如何修復呢?在現有v17
架構下沒法很好修復。
在v18
,伴隨Concurrent Mode
的啓發式更新算法,會修復該bug
。
bug
修復見Flush discrete passive effects before paint #21150
修復的方式很簡單:若是一個useEffect
回調是由離散事件
形成的,則該useEffect
回調不會異步執行,而是會在本輪DOM
更新完成後同步執行。
至於爲何v16
及以前版本不會復現這個bug
?
由於以前的版本全部原生事件都註冊在html DOM
上。
就不存在原生事件在冒泡過程當中觸發多個事件代理的狀況。
當bug
來臨,沒有一片feature
是無辜的。
如今,終於有點能體會爲啥React
團隊開發Concurrent Mode
相關功能花了2年多時間。
真是,牽一髮動全身啊~