大佬,怎么辦?升級(jí)React17,Toast組件不能用了
大家好,我是卡頌,人稱卡爾摩斯。
今天,我們來追查一個(gè)棘手的React bug,知名組件庫material-ui就受其影響。
這個(gè)bug的產(chǎn)生涉及多方因素,包括:
- useEffect執(zhí)行時(shí)機(jī)(很可能與你想的不一樣)
- 合成事件原理
- v17源碼中對(duì)合成事件的改動(dòng)
- Portal原理
這篇文章很長很長,有非常多源碼細(xì)節(jié)。
你可以用如下Demo和我一起debug源碼,更有破案的感覺
在線Demo地址
相信整篇文章過完,你能對(duì)如上知識(shí)點(diǎn)有更深的理解。
接下來,讓我們復(fù)現(xiàn)案發(fā)現(xiàn)場吧。
只在v17下復(fù)現(xiàn)的bug
假設(shè),我們有個(gè)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>
- );
- }
點(diǎn)擊button后,show狀態(tài)變?yōu)閠rue,展示toast。
同時(shí)在useEffect回調(diào)中,在document上注冊「點(diǎn)擊事件」。
觸發(fā)點(diǎn)擊事件會(huì)讓show狀態(tài)置為false,達(dá)到「點(diǎn)擊頁面任意區(qū)域關(guān)閉toast」的效果。
入口函數(shù)如下:
- function App() {
- return (
- <ToastButton />
- );
- }
- ReactDOM.render(<App />, document.getElementById("root"));
效果如下:

接下來,我們再增加一個(gè)渲染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>
- );
- }
點(diǎn)擊button后會(huì)將show狀態(tài)置為true。
會(huì)使用ReactDOM.createPortal在document.body上掛載一個(gè)div,內(nèi)容為who is handsome?。
我們將兩個(gè)組件一起放在App中:
- function App() {
- return (
- <div>
- <PortalRenderer />
- <ToastButton />
- </div>
- );
- }
點(diǎn)擊PortalRenderer效果如下:

現(xiàn)在問題來了:
- 如果先點(diǎn)擊PortalRenderer的button,再點(diǎn)擊ToastButton會(huì)怎么樣?
理所當(dāng)然的答案是:
- 先顯示「who is handsome?」
- 再顯示「Hey, Ka Song~」
然而,在React v17效果如下:

先點(diǎn)擊PortalRenderer的button后,再點(diǎn)擊ToastButton,不會(huì)看見toast的內(nèi)容。
但是,只要不點(diǎn)擊PortalRenderer的button就不會(huì)有問題:

這只是一個(gè)可復(fù)現(xiàn)該bug的極簡Demo。
事實(shí)上,在一個(gè)大型項(xiàng)目中,如果從v16升級(jí)到v17,
在使用了如上所示的「在document掛載原生click事件」方式實(shí)現(xiàn)toast的同時(shí),
再使用Portal在document.body掛載DOM都會(huì)觸發(fā)該bug。
一旦先渲染了Portal,你的toast就不能用了。意不意外?驚不驚喜?
接下來,讓我們一步步揭開這個(gè)bug的廬山真面目。
div去哪了?
首先,我們要明確,點(diǎn)擊Show Toast沒反應(yīng),是因?yàn)闆]渲染toast,還是因?yàn)殇秩玖藅oast又立刻刪除了。
審查元素后發(fā)現(xiàn),每當(dāng)點(diǎn)擊Show Toast,ToastButton渲染的div都會(huì)閃一下。
這代表該div下發(fā)生了DOM變化。
而我們并沒有看到DOM的插入,那么這就表示:
這里先發(fā)生了DOM插入,緊接著發(fā)生了DOM移除
而這個(gè)DOM就是toast對(duì)應(yīng)DOM:
<div className="toast">Hey, Ka Song!</div>
我們知道,該DOM顯示與否受ToastButton組件的show狀態(tài)影響,
于是,接下來的線索有三條:
- 為什么一次點(diǎn)擊,ToastButton組件的show狀態(tài)先變?yōu)閠rue,后變?yōu)閒alse?
- 為什么只有在掛載了Portal的情況下bug能復(fù)現(xiàn)?
- 為什么該bug只在v17復(fù)現(xiàn)?
該從哪條線索下手呢?
v17有哪些變化?
相比第一、二條,第三條線索能更好控制影響范圍。
看看v17的更新log,一條特性變化引起了卡爾摩斯的注意:
在v17之前,整個(gè)應(yīng)用的事件會(huì)冒泡到同一個(gè)根節(jié)點(diǎn)(html DOM節(jié)點(diǎn))。
而在v17,每個(gè)應(yīng)用的事件都會(huì)冒泡到該應(yīng)用自己的根節(jié)點(diǎn)(ReactDOM.render掛載的節(jié)點(diǎn),在Demo中是div#root)。
這個(gè)改動(dòng)是為了讓一個(gè)應(yīng)用下可以存在多個(gè)不同模式的子應(yīng)用(兼容legacy mode與concurrent mode同時(shí)存在于一個(gè)應(yīng)用)。
會(huì)不會(huì)是這個(gè)原因呢?
于是,卡爾摩斯將目光鎖定在源碼中注冊事件的方法:addTrappedEventListener
在應(yīng)用初始化時(shí)(調(diào)用ReactDOM.render首屏渲染時(shí)),React會(huì)遍歷所有「原生事件名」,依次在根節(jié)點(diǎn)調(diào)用該方法注冊事件回調(diào)。
在應(yīng)用運(yùn)行過程中,所有原生事件都會(huì)由根節(jié)點(diǎn)(Demo中的div#root)代理。
以一個(gè)React組件的onClick事件舉例,當(dāng)點(diǎn)擊發(fā)生后,會(huì)依次執(zhí)行:
- 「原生點(diǎn)擊事件」向上冒泡
- 「原生點(diǎn)擊事件」冒泡到根節(jié)點(diǎn),觸發(fā)addTrappedEventListener注冊的事件處理函數(shù)
- 「合成事件」會(huì)在React組件樹中從底向上冒泡
- 當(dāng)「合成事件」冒泡到觸發(fā)點(diǎn)擊的組件時(shí),調(diào)用onClick方法
這就是React合成事件的原理。
那么,為什么只有在掛載了Portal的情況下bug能復(fù)現(xiàn)?
難道Portal與合成事件有關(guān)?
果然,當(dāng)我們點(diǎn)擊PortalRenderer的button后,又進(jìn)入了addTrappedEventListener的斷點(diǎn)。
與初始化時(shí)(執(zhí)行ReactDOM.render時(shí))事件掛載的目標(biāo)節(jié)點(diǎn)(div#root)不同,
由于Portal掛載在document.body上,見如下節(jié)選代碼:
- // 節(jié)選自PortalRenderer
- {show &&
- ReactDOM.createPortal(
- <div>who is handsome?</div>,
- document.body
- )}
所以會(huì)在document.body再執(zhí)行一遍所有原生事件的代理邏輯。
可以看到此時(shí)事件會(huì)在body上注冊:
這就意味著,原生事件冒泡到根節(jié)點(diǎn)(div#root)后,繼續(xù)向上冒泡,在document.body又會(huì)觸發(fā)一遍事件處理函數(shù)。
以一個(gè)React組件的onClick事件舉例,當(dāng)點(diǎn)擊發(fā)生后,會(huì)依次執(zhí)行:
- 「原生點(diǎn)擊事件」向上冒泡
- 「原生事件」冒泡到根節(jié)點(diǎn)(div#root),觸發(fā)addTrappedEventListener注冊的事件處理函數(shù)
- 「合成事件」會(huì)在React組件樹中從底向上冒泡
- 當(dāng)「合成事件」冒泡到觸發(fā)點(diǎn)擊的組件時(shí),調(diào)用onClick方法
- 「原生點(diǎn)擊事件」繼續(xù)向上冒泡到document.body
- 重復(fù)觸發(fā)步驟3
難道bug的原因是onClick被重復(fù)執(zhí)行兩次?
如果是這么明顯的bug大家開發(fā)過程中肯定很容易復(fù)現(xiàn)。
我們可以在onClick中打印日志,可以看到:一次點(diǎn)擊只會(huì)打印一條日志。

那么問題出在哪呢?
useEffect的執(zhí)行時(shí)機(jī)
讓我們回到第一條線索:
- 為什么一次點(diǎn)擊,ToastButton組件的show狀態(tài)先變?yōu)閠rue,后變?yōu)閒alse?
我們可以從useEffect回調(diào)中找找線索。
- // 節(jié)選自ToastButton
- useEffect(() => {
- if (!show) return;
- function clickHandler(e) {
- setShow(false);
- }
- document.addEventListener("click", clickHandler);
- return () => {
- document.removeEventListener("click", clickHandler);
- };
- }, [show]);
可以看到,state變?yōu)閒alse是由于clickHandler調(diào)用。
而clickHandler調(diào)用是由于document被點(diǎn)擊。
所以show狀態(tài)連續(xù)變化的原因很可能是:
- 點(diǎn)擊ToastButton,「原生點(diǎn)擊事件」冒泡到應(yīng)用掛載的根節(jié)點(diǎn)
- 進(jìn)入「合成事件」的冒泡邏輯,冒泡到ToastButton時(shí)觸發(fā)onClick
- onClick中setShow(true),state變?yōu)閠rue,渲染toast DOM
- useEffect回調(diào)執(zhí)行,為document綁定click事件
- 「原生點(diǎn)擊事件」繼續(xù)冒泡,當(dāng)冒泡到document時(shí),觸發(fā)其綁定的click事件
- 調(diào)用clickHandler將state變?yōu)閒alse,移除toast DOM
正當(dāng)我為這精妙的推理沾沾自喜時(shí),突然意識(shí)到一個(gè)問題:
要滿足如上邏輯,步驟4和步驟5之間必須是同步執(zhí)行。
因?yàn)橐坏┎襟E4是異步執(zhí)行,則當(dāng)步驟5「原生點(diǎn)擊事件」冒泡到document時(shí),步驟4document的click事件還未綁定。
步驟4在useEffect回調(diào)函數(shù)中,而useEffect的回調(diào)是在執(zhí)行完DOM操作后異步執(zhí)行的。
- 如果useEffect回調(diào)在DOM變化后同步執(zhí)行,會(huì)阻塞DOM重排、重繪,所以被設(shè)計(jì)為異步執(zhí)行。如果一定要在DOM變化后同步執(zhí)行副作用,可以使用useLayoutEffect
所以,「正常情況下」,步驟4和步驟5是在不同的兩個(gè)瀏覽器task執(zhí)行。
然而,總有意外。
useEffect的邊界case
在React中,一個(gè)常見的操作鏈路是:
- 用戶觸發(fā)事件 -> 改變state -> 依賴該state的useEffect回調(diào)執(zhí)行
去掉中間環(huán)節(jié),就是這樣:
- 用戶觸發(fā)事件 -> ... -> useEffect回調(diào)執(zhí)行
而我們剛才說,useEffect回調(diào)是異步執(zhí)行的。
那么設(shè)想以下場景:
用戶快速點(diǎn)擊鼠標(biāo)觸發(fā)onClick事件,如何保證每次點(diǎn)擊產(chǎn)生的useEffect回調(diào)按順序執(zhí)行呢?
為了解決這個(gè)問題,React將不同原生事件分類。
其中click、keydown等這種不連續(xù)觸發(fā)的事件被稱為「離散事件」(與之對(duì)應(yīng)的就是scroll這種能連續(xù)觸發(fā)的事件)。
- 源碼中所有離散事件的定義見這里
為了保證如下鏈路中的useEffect回調(diào)都能按順序執(zhí)行
- 離散事件 -> ... -> useEffect回調(diào)執(zhí)行
每當(dāng)處理離散事件前,都會(huì)執(zhí)行flushPassiveEffects方法。
該方法會(huì)將還未執(zhí)行的useEffect回調(diào)執(zhí)行。
這樣就能保證下一次useEffect回調(diào)執(zhí)行前上一次的useEffect回調(diào)已經(jīng)執(zhí)行。
所以,當(dāng)不點(diǎn)擊PortalRenderer的button掛載Portal時(shí),點(diǎn)擊ToastButton的完整流程如下:
- 點(diǎn)擊ToastButton,「原生點(diǎn)擊事件」冒泡到應(yīng)用掛載的根節(jié)點(diǎn)
- 進(jìn)入「合成事件」的冒泡邏輯,冒泡到ToastButton時(shí)觸發(fā)onClick
- onClick中setShow(true),state變?yōu)閠rue,渲染toast DOM
- useEffect回調(diào)「異步執(zhí)行」,為document綁定click事件
- 「原生點(diǎn)擊事件」繼續(xù)冒泡到document,此時(shí)document還未綁定click事件
UI表現(xiàn)為:點(diǎn)擊ToastButton,展示toast。
當(dāng)點(diǎn)擊PortalRenderer的button掛載Portal后,再點(diǎn)擊ToastButton的完整流程如下:
- 點(diǎn)擊PortalRenderer的button,在document.body掛載Portal對(duì)應(yīng)DOM
- 在document.body執(zhí)行綁定事件代理邏輯
- 點(diǎn)擊ToastButton,「原生點(diǎn)擊事件」冒泡到應(yīng)用掛載的根節(jié)點(diǎn)
- 進(jìn)入「合成事件」的冒泡邏輯,冒泡到ToastButton時(shí)觸發(fā)onClick
- onClick中setShow(true),state變?yōu)閠rue,渲染toast DOM
- useEffect回調(diào)「異步執(zhí)行」,為document綁定click事件
- 「原生點(diǎn)擊事件」繼續(xù)冒泡到document.body,由于body綁定了事件代理邏輯,所以會(huì)處理離散事件
- 處理的第一步是將還未執(zhí)行的步驟6同步執(zhí)行,此時(shí)document綁定click事件
- 「原生點(diǎn)擊事件」繼續(xù)冒泡到document,觸發(fā)步驟6綁定的click事件
- 調(diào)用clickHandler將state變?yōu)閒alse,移除toast DOM
UI表現(xiàn)為:點(diǎn)擊ToastButton,無反應(yīng)(實(shí)際是先展示toast,再在同一個(gè)瀏覽器task移除toast)
bug解決
可以看到,這是React源碼運(yùn)行流程的幾個(gè)feature綜合起來造成的bug。
如何修復(fù)呢?在現(xiàn)有v17架構(gòu)下無法很好修復(fù)。
在v18,伴隨Concurrent Mode的「啟發(fā)式更新算法」,會(huì)修復(fù)該bug。
bug修復(fù)見Flush discrete passive effects before paint #21150
修復(fù)的方式很簡單:如果一個(gè)useEffect回調(diào)是由離散事件造成的,則該useEffect回調(diào)不會(huì)異步執(zhí)行,而是會(huì)在本輪DOM更新完成后同步執(zhí)行。
至于為什么v16及之前版本不會(huì)復(fù)現(xiàn)這個(gè)bug?
因?yàn)橹暗陌姹舅小冈录苟甲栽趆tml DOM上。
就不存在「原生事件」在冒泡過程中觸發(fā)多個(gè)事件代理的情況。
當(dāng)bug來臨,沒有一片feature是無辜的。
現(xiàn)在,終于有點(diǎn)能體會(huì)為啥React團(tuán)隊(duì)開發(fā)Concurrent Mode相關(guān)功能花了2年多時(shí)間。
真是,牽一發(fā)動(dòng)全身啊~
參考資料
[1]material-ui:
https://github.com/mui-org/material-ui/issues/23215
[2]在線Demo地址:
https://codesandbox.io/s/react-playground-forked-v42kn
[3]離散事件:
https://github.com/facebook/react/blob/a8a4742f1c54493df00da648a3f9d26e3db9c8b5/packages/react-dom/src/events/ReactDOMEventListener.js#L294-L350