成人免费xxxxx在线视频软件_久久精品久久久_亚洲国产精品久久久_天天色天天色_亚洲人成一区_欧美一级欧美三级在线观看

大佬,怎么辦?升級React17,Toast組件不能用了

開發 前端
今天,我們來追查一個棘手的React bug,知名組件庫material-ui就受其影響。

[[405755]]

大家好,我是卡頌,人稱卡爾摩斯。

今天,我們來追查一個棘手的React bug,知名組件庫material-ui就受其影響。

這個bug的產生涉及多方因素,包括:

  • useEffect執行時機(很可能與你想的不一樣)
  • 合成事件原理
  • v17源碼中對合成事件的改動
  • Portal原理

這篇文章很長很長,有非常多源碼細節。

你可以用如下Demo和我一起debug源碼,更有破案的感覺

在線Demo地址

相信整篇文章過完,你能對如上知識點有更深的理解。

接下來,讓我們復現案發現場吧。

只在v17下復現的bug

假設,我們有個ToastButton組件,代碼如下:

  1. function ToastButton() { 
  2.   const [show, setShow] = useState(false); 
  3.  
  4.   useEffect(() => { 
  5.     if (!show) return
  6.  
  7.     function clickHandler(e) { 
  8.       setShow(false); 
  9.     } 
  10.  
  11.     document.addEventListener("click", clickHandler); 
  12.     return () => { 
  13.       document.removeEventListener("click", clickHandler); 
  14.     }; 
  15.   }, [show]); 
  16.  
  17.   return ( 
  18.     <div> 
  19.       <button type="button" onClick={() => setShow(true)}>Show Toast</button> 
  20.       {show && <div className="toast">Hey, Ka Song~</div>} 
  21.     </div> 
  22.   ); 

 點擊button后,show狀態變為true,展示toast。

同時在useEffect回調中,在document上注冊「點擊事件」。

觸發點擊事件會讓show狀態置為false,達到「點擊頁面任意區域關閉toast」的效果。

入口函數如下:

  1. function App() { 
  2.   return ( 
  3.     <ToastButton /> 
  4.   ); 
  5.  
  6. ReactDOM.render(<App />, document.getElementById("root")); 

效果如下:

圖片

接下來,我們再增加一個渲染Portal的組件PortalRenderer,代碼如下:

  1. function PortalRenderer() { 
  2.   const [show, setShow] = useState(false); 
  3.  
  4.   return ( 
  5.     <React.Fragment> 
  6.       <button type="button" onClick={() => setShow(true)}> 
  7.         Render portal 
  8.       </button> 
  9.  
  10.       {show && 
  11.         ReactDOM.createPortal( 
  12.           <div>who is handsome?</div>, 
  13.           document.body 
  14.         )} 
  15.     </React.Fragment> 
  16.   ); 

 點擊button后會將show狀態置為true。

會使用ReactDOM.createPortal在document.body上掛載一個div,內容為who is handsome?。

我們將兩個組件一起放在App中:

  1. function App() { 
  2.   return ( 
  3.     <div> 
  4.       <PortalRenderer /> 
  5.       <ToastButton /> 
  6.     </div> 
  7.   ); 

 點擊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狀態影響,

于是,接下來的線索有三條:

  1. 為什么一次點擊,ToastButton組件的show狀態先變為true,后變為false?
  2. 為什么只有在掛載了Portal的情況下bug能復現?
  3. 為什么該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事件舉例,當點擊發生后,會依次執行:

  1. 「原生點擊事件」向上冒泡
  2. 「原生點擊事件」冒泡到根節點,觸發addTrappedEventListener注冊的事件處理函數
  3. 「合成事件」會在React組件樹中從底向上冒泡
  4. 當「合成事件」冒泡到觸發點擊的組件時,調用onClick方法

這就是React合成事件的原理。

那么,為什么只有在掛載了Portal的情況下bug能復現?

難道Portal與合成事件有關?

果然,當我們點擊PortalRenderer的button后,又進入了addTrappedEventListener的斷點。

與初始化時(執行ReactDOM.render時)事件掛載的目標節點(div#root)不同,

由于Portal掛載在document.body上,見如下節選代碼:

  1. // 節選自PortalRenderer 
  2. {show && 
  3.   ReactDOM.createPortal( 
  4.     <div>who is handsome?</div>, 
  5.     document.body 
  6. )} 

 所以會在document.body再執行一遍所有原生事件的代理邏輯。

可以看到此時事件會在body上注冊:

這就意味著,原生事件冒泡到根節點(div#root)后,繼續向上冒泡,在document.body又會觸發一遍事件處理函數。

以一個React組件的onClick事件舉例,當點擊發生后,會依次執行:

  1. 「原生點擊事件」向上冒泡
  2. 「原生事件」冒泡到根節點(div#root),觸發addTrappedEventListener注冊的事件處理函數
  3. 「合成事件」會在React組件樹中從底向上冒泡
  4. 當「合成事件」冒泡到觸發點擊的組件時,調用onClick方法
  5. 「原生點擊事件」繼續向上冒泡到document.body
  6. 重復觸發步驟3

難道bug的原因是onClick被重復執行兩次?

如果是這么明顯的bug大家開發過程中肯定很容易復現。

我們可以在onClick中打印日志,可以看到:一次點擊只會打印一條日志。

圖片

那么問題出在哪呢?

useEffect的執行時機

讓我們回到第一條線索:

  • 為什么一次點擊,ToastButton組件的show狀態先變為true,后變為false?

我們可以從useEffect回調中找找線索。

  1. // 節選自ToastButton 
  2.  useEffect(() => { 
  3.   if (!show) return
  4.  
  5.   function clickHandler(e) { 
  6.     setShow(false); 
  7.   } 
  8.  
  9.   document.addEventListener("click", clickHandler); 
  10.   return () => { 
  11.     document.removeEventListener("click", clickHandler); 
  12.   }; 
  13. }, [show]); 

可以看到,state變為false是由于clickHandler調用。

而clickHandler調用是由于document被點擊。

所以show狀態連續變化的原因很可能是:

  1. 點擊ToastButton,「原生點擊事件」冒泡到應用掛載的根節點
  2. 進入「合成事件」的冒泡邏輯,冒泡到ToastButton時觸發onClick
  3. onClick中setShow(true),state變為true,渲染toast DOM
  4. useEffect回調執行,為document綁定click事件
  5. 「原生點擊事件」繼續冒泡,當冒泡到document時,觸發其綁定的click事件
  6. 調用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的完整流程如下:

  1. 點擊ToastButton,「原生點擊事件」冒泡到應用掛載的根節點
  2. 進入「合成事件」的冒泡邏輯,冒泡到ToastButton時觸發onClick
  3. onClick中setShow(true),state變為true,渲染toast DOM
  4. useEffect回調「異步執行」,為document綁定click事件
  5. 「原生點擊事件」繼續冒泡到document,此時document還未綁定click事件

UI表現為:點擊ToastButton,展示toast。

當點擊PortalRenderer的button掛載Portal后,再點擊ToastButton的完整流程如下:

  1. 點擊PortalRenderer的button,在document.body掛載Portal對應DOM
  2. 在document.body執行綁定事件代理邏輯
  3. 點擊ToastButton,「原生點擊事件」冒泡到應用掛載的根節點
  4. 進入「合成事件」的冒泡邏輯,冒泡到ToastButton時觸發onClick
  5. onClick中setShow(true),state變為true,渲染toast DOM
  6. useEffect回調「異步執行」,為document綁定click事件
  7. 「原生點擊事件」繼續冒泡到document.body,由于body綁定了事件代理邏輯,所以會處理離散事件
  8. 處理的第一步是將還未執行的步驟6同步執行,此時document綁定click事件
  9. 「原生點擊事件」繼續冒泡到document,觸發步驟6綁定的click事件
  10. 調用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上。

就不存在「原生事件」在冒泡過程中觸發多個事件代理的情況。

[[405756]]

當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

 

責任編輯:姜華 來源: 魔術師卡頌
相關推薦

2021-08-27 12:59:59

React前端命令

2022-06-16 08:30:03

React 17null

2022-06-28 07:41:38

useMountReactahooks

2012-11-22 10:39:37

漏洞PDF文件

2021-05-21 09:34:40

React React 17前端

2022-03-24 12:28:03

React 17React 18React

2022-01-13 23:14:12

Windows 11Windows微軟

2022-10-10 08:28:57

接口內網服務AOP

2022-03-02 14:00:46

Nest.jsExpress端口

2009-11-27 11:26:02

VS2003.NET不

2024-07-08 11:30:35

2023-07-11 08:55:26

系統白名單AO

2024-10-17 10:25:34

2009-11-03 08:56:02

linux死機操作系統

2024-04-22 08:17:23

MySQL誤刪數據

2022-12-19 11:31:57

緩存失效數據庫

2017-02-21 13:11:43

SDN網絡體系SDN架構

2022-05-19 08:01:49

PostgreSQL數據庫

2013-02-28 11:00:51

IE10瀏覽器

2019-10-12 09:50:46

Redis內存數據庫
點贊
收藏

51CTO技術棧公眾號

主站蜘蛛池模板: 国产成人小视频 | 国产在线1| 亚洲在线看 | 久久精品日产第一区二区三区 | 不卡欧美| 欧美一区日韩一区 | 国产精品免费一区二区 | 一区二区三区回区在观看免费视频 | 国产成人一区 | 天天躁日日躁狠狠的躁天龙影院 | 特级做a爰片毛片免费看108 | 亚洲国产精品一区二区久久 | 欧美三区在线观看 | 日韩在线精品视频 | 色婷婷亚洲一区二区三区 | 视频在线日韩 | 日韩一级精品视频在线观看 | 国产97在线 | 日韩 | 男女视频免费 | 啪一啪 | 久久ww| 日韩欧美在线观看 | 99re视频在线观看 | 99精品欧美一区二区三区综合在线 | 久久久久久久av麻豆果冻 | 久久亚洲一区二区 | 国产精品久久亚洲7777 | 国产激情视频在线观看 | 青青草视频免费观看 | 亚洲欧美中文日韩在线v日本 | 精品视频一区二区三区 | 欧美不卡一区二区 | 国产欧美一区二区三区在线看蜜臀 | 欧美久久电影 | 国产精品久久毛片av大全日韩 | 色综合成人网 | 国产精品一区二区三区四区五区 | 99视频在线播放 | 中文字幕亚洲区一区二 | 亚洲视频免费观看 | 国产一二三区在线 |