自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

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

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

[[405755]]

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

今天,我們來追查一個(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組件,代碼如下:

  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.   ); 

 點(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ù)如下:

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

效果如下:

圖片

接下來,我們再增加一個(gè)渲染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.   ); 

 點(diǎn)擊button后會(huì)將show狀態(tài)置為true。

會(huì)使用ReactDOM.createPortal在document.body上掛載一個(gè)div,內(nèi)容為who is handsome?。

我們將兩個(gè)組件一起放在App中:

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

 點(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)影響,

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

  1. 為什么一次點(diǎn)擊,ToastButton組件的show狀態(tài)先變?yōu)閠rue,后變?yōu)閒alse?
  2. 為什么只有在掛載了Portal的情況下bug能復(fù)現(xiàn)?
  3. 為什么該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í)行:

  1. 「原生點(diǎn)擊事件」向上冒泡
  2. 「原生點(diǎn)擊事件」冒泡到根節(jié)點(diǎn),觸發(fā)addTrappedEventListener注冊的事件處理函數(shù)
  3. 「合成事件」會(huì)在React組件樹中從底向上冒泡
  4. 當(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é)選代碼:

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

 所以會(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í)行:

  1. 「原生點(diǎn)擊事件」向上冒泡
  2. 「原生事件」冒泡到根節(jié)點(diǎn)(div#root),觸發(fā)addTrappedEventListener注冊的事件處理函數(shù)
  3. 「合成事件」會(huì)在React組件樹中從底向上冒泡
  4. 當(dāng)「合成事件」冒泡到觸發(fā)點(diǎn)擊的組件時(shí),調(diào)用onClick方法
  5. 「原生點(diǎn)擊事件」繼續(xù)向上冒泡到document.body
  6. 重復(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)中找找線索。

  1. // 節(jié)選自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變?yōu)閒alse是由于clickHandler調(diào)用。

而clickHandler調(diào)用是由于document被點(diǎn)擊。

所以show狀態(tài)連續(xù)變化的原因很可能是:

  1. 點(diǎn)擊ToastButton,「原生點(diǎn)擊事件」冒泡到應(yīng)用掛載的根節(jié)點(diǎn)
  2. 進(jìn)入「合成事件」的冒泡邏輯,冒泡到ToastButton時(shí)觸發(fā)onClick
  3. onClick中setShow(true),state變?yōu)閠rue,渲染toast DOM
  4. useEffect回調(diào)執(zhí)行,為document綁定click事件
  5. 「原生點(diǎn)擊事件」繼續(xù)冒泡,當(dāng)冒泡到document時(shí),觸發(fā)其綁定的click事件
  6. 調(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的完整流程如下:

  1. 點(diǎn)擊ToastButton,「原生點(diǎn)擊事件」冒泡到應(yīng)用掛載的根節(jié)點(diǎn)
  2. 進(jìn)入「合成事件」的冒泡邏輯,冒泡到ToastButton時(shí)觸發(fā)onClick
  3. onClick中setShow(true),state變?yōu)閠rue,渲染toast DOM
  4. useEffect回調(diào)「異步執(zhí)行」,為document綁定click事件
  5. 「原生點(diǎn)擊事件」繼續(xù)冒泡到document,此時(shí)document還未綁定click事件

UI表現(xiàn)為:點(diǎn)擊ToastButton,展示toast。

當(dāng)點(diǎn)擊PortalRenderer的button掛載Portal后,再點(diǎn)擊ToastButton的完整流程如下:

  1. 點(diǎn)擊PortalRenderer的button,在document.body掛載Portal對(duì)應(yīng)DOM
  2. 在document.body執(zhí)行綁定事件代理邏輯
  3. 點(diǎn)擊ToastButton,「原生點(diǎn)擊事件」冒泡到應(yīng)用掛載的根節(jié)點(diǎn)
  4. 進(jìn)入「合成事件」的冒泡邏輯,冒泡到ToastButton時(shí)觸發(fā)onClick
  5. onClick中setShow(true),state變?yōu)閠rue,渲染toast DOM
  6. useEffect回調(diào)「異步執(zhí)行」,為document綁定click事件
  7. 「原生點(diǎn)擊事件」繼續(xù)冒泡到document.body,由于body綁定了事件代理邏輯,所以會(huì)處理離散事件
  8. 處理的第一步是將還未執(zhí)行的步驟6同步執(zhí)行,此時(shí)document綁定click事件
  9. 「原生點(diǎn)擊事件」繼續(xù)冒泡到document,觸發(fā)步驟6綁定的click事件
  10. 調(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è)事件代理的情況。

[[405756]]

當(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

 

責(zé)任編輯:姜華 來源: 魔術(shù)師卡頌
相關(guān)推薦

2021-08-27 12:59:59

React前端命令

2022-06-28 07:41:38

useMountReactahooks

2022-06-16 08:30:03

React 17null

2012-11-22 10:39:37

漏洞PDF文件

2022-01-13 23:14:12

Windows 11Windows微軟

2021-05-21 09:34:40

React React 17前端

2022-03-24 12:28:03

React 17React 18React

2022-10-10 08:28:57

接口內(nèi)網(wǎng)服務(wù)AOP

2022-03-02 14:00:46

Nest.jsExpress端口

2024-10-17 10:25:34

2009-11-27 11:26:02

VS2003.NET不

2023-07-11 08:55:26

系統(tǒng)白名單AO

2024-07-08 11:30:35

2019-10-12 09:50:46

Redis內(nèi)存數(shù)據(jù)庫

2018-01-28 20:39:39

戴爾

2022-07-05 11:48:47

MySQL死鎖表鎖

2009-11-03 08:56:02

linux死機(jī)操作系統(tǒng)

2022-12-19 11:31:57

緩存失效數(shù)據(jù)庫

2024-04-22 08:17:23

MySQL誤刪數(shù)據(jù)

2017-02-21 13:11:43

SDN網(wǎng)絡(luò)體系SDN架構(gòu)
點(diǎn)贊
收藏

51CTO技術(shù)棧公眾號(hào)