大佬,怎么辦?升級React17,Toast組件不能用了
大家好,我是卡頌,人稱卡爾摩斯。
今天,我們來追查一個棘手的React bug,知名組件庫material-ui就受其影響。
這個bug的產生涉及多方因素,包括:
- useEffect執行時機(很可能與你想的不一樣)
- 合成事件原理
- v17源碼中對合成事件的改動
- Portal原理
這篇文章很長很長,有非常多源碼細節。
你可以用如下Demo和我一起debug源碼,更有破案的感覺
在線Demo地址
相信整篇文章過完,你能對如上知識點有更深的理解。
接下來,讓我們復現案發現場吧。
只在v17下復現的bug
假設,我們有個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的廬山真面目。
div去哪了?
首先,我們要明確,點擊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復現?
該從哪條線索下手呢?
v17有哪些變化?
相比第一、二條,第三條線索能更好控制影響范圍。
看看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中打印日志,可以看到:一次點擊只會打印一條日志。

那么問題出在哪呢?
useEffect的執行時機
讓我們回到第一條線索:
- 為什么一次點擊,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執行。
然而,總有意外。
useEffect的邊界case
在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)
bug解決
可以看到,這是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年多時間。
真是,牽一發動全身啊~
參考資料
[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