日韩无码专区无码一级三级片|91人人爱网站中日韩无码电影|厨房大战丰满熟妇|AV高清无码在线免费观看|另类AV日韩少妇熟女|中文日本大黄一级黄色片|色情在线视频免费|亚洲成人特黄a片|黄片wwwav色图欧美|欧亚乱色一区二区三区

RELATEED CONSULTING
相關咨詢
選擇下列產(chǎn)品馬上在線溝通
服務時間:8:30-17:00
你可能遇到了下面的問題
關閉右側工具欄

新聞中心

這里有您想知道的互聯(lián)網(wǎng)營銷解決方案
大佬,怎么辦?升級React17,Toast組件不能用了

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

成都創(chuàng)新互聯(lián)公司云計算的互聯(lián)網(wǎng)服務提供商,擁有超過13年的服務器租用、服務器托管、云服務器、雅安服務器托管、網(wǎng)站系統(tǒng)開發(fā)經(jīng)驗,已先后獲得國家工業(yè)和信息化部頒發(fā)的互聯(lián)網(wǎng)數(shù)據(jù)中心業(yè)務許可證。專業(yè)提供云主機、雅安服務器托管、域名注冊、VPS主機、云服務器、香港云服務器、免備案服務器等。

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

這個bug的產(chǎn)生涉及多方因素,包括:

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

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

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

在線Demo地址

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

接下來,讓我們復現(xiàn)案發(fā)現(xiàn)場吧。

只在v17下復現(xiàn)的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.     
     
  19.        setShow(true)}>Show Toast 
  20.       {show && Hey, Ka Song~
  •     
  •  
  •   ); 
  •  點擊button后,show狀態(tài)變?yōu)閠rue,展示toast。

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

    觸發(fā)點擊事件會讓show狀態(tài)置為false,達到「點擊頁面任意區(qū)域關閉toast」的效果。

    入口函數(shù)如下:

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

    效果如下:

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

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

     點擊button后會將show狀態(tài)置為true。

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

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

     
     
     
     
    1. function App() { 
    2.   return ( 
    3.     
       
    4.        
    5.        
    6.     
     
  •   ); 
  •  點擊PortalRenderer效果如下:

    現(xiàn)在問題來了:

    • 如果先點擊PortalRenderer的button,再點擊ToastButton會怎么樣?

    理所當然的答案是:

    • 先顯示「who is handsome?」
    • 再顯示「Hey, Ka Song~」

    然而,在React v17效果如下:

    先點擊PortalRenderer的button后,再點擊ToastButton,不會看見toast的內(nèi)容。

    但是,只要不點擊PortalRenderer的button就不會有問題:

    這只是一個可復現(xiàn)該bug的極簡Demo。

    事實上,在一個大型項目中,如果從v16升級到v17,

    在使用了如上所示的「在document掛載原生click事件」方式實現(xiàn)toast的同時,

    再使用Portal在document.body掛載DOM都會觸發(fā)該bug。

    一旦先渲染了Portal,你的toast就不能用了。意不意外?驚不驚喜?

    接下來,讓我們一步步揭開這個bug的廬山真面目。

    div去哪了?

    首先,我們要明確,點擊Show Toast沒反應,是因為沒渲染toast,還是因為渲染了toast又立刻刪除了。

    審查元素后發(fā)現(xiàn),每當點擊Show Toast,ToastButton渲染的div都會閃一下。

    這代表該div下發(fā)生了DOM變化。

    而我們并沒有看到DOM的插入,那么這就表示:

    這里先發(fā)生了DOM插入,緊接著發(fā)生了DOM移除

    而這個DOM就是toast對應DOM:

    Hey, Ka Song!

    我們知道,該DOM顯示與否受ToastButton組件的show狀態(tài)影響,

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

    1. 為什么一次點擊,ToastButton組件的show狀態(tài)先變?yōu)閠rue,后變?yōu)閒alse?
    2. 為什么只有在掛載了Portal的情況下bug能復現(xiàn)?
    3. 為什么該bug只在v17復現(xiàn)?

    該從哪條線索下手呢?

    v17有哪些變化?

    相比第一、二條,第三條線索能更好控制影響范圍。

    看看v17的更新log,一條特性變化引起了卡爾摩斯的注意:

    在v17之前,整個應用的事件會冒泡到同一個根節(jié)點(html DOM節(jié)點)。

    而在v17,每個應用的事件都會冒泡到該應用自己的根節(jié)點(ReactDOM.render掛載的節(jié)點,在Demo中是div#root)。

    這個改動是為了讓一個應用下可以存在多個不同模式的子應用(兼容legacy mode與concurrent mode同時存在于一個應用)。

    會不會是這個原因呢?

    于是,卡爾摩斯將目光鎖定在源碼中注冊事件的方法:addTrappedEventListener

    在應用初始化時(調用ReactDOM.render首屏渲染時),React會遍歷所有「原生事件名」,依次在根節(jié)點調用該方法注冊事件回調。

    在應用運行過程中,所有原生事件都會由根節(jié)點(Demo中的div#root)代理。

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

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

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

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

    難道Portal與合成事件有關?

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

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

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

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

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

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

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

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

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

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

    如果是這么明顯的bug大家開發(fā)過程中肯定很容易復現(xiàn)。

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

    那么問題出在哪呢?

    useEffect的執(zhí)行時機

    讓我們回到第一條線索:

    • 為什么一次點擊,ToastButton組件的show狀態(tài)先變?yōu)閠rue,后變?yōu)閒alse?

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

     
     
     
     
    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調用。

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

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

    1. 點擊ToastButton,「原生點擊事件」冒泡到應用掛載的根節(jié)點
    2. 進入「合成事件」的冒泡邏輯,冒泡到ToastButton時觸發(fā)onClick
    3. onClick中setShow(true),state變?yōu)閠rue,渲染toast DOM
    4. useEffect回調執(zhí)行,為document綁定click事件
    5. 「原生點擊事件」繼續(xù)冒泡,當冒泡到document時,觸發(fā)其綁定的click事件
    6. 調用clickHandler將state變?yōu)閒alse,移除toast DOM

    正當我為這精妙的推理沾沾自喜時,突然意識到一個問題:

    要滿足如上邏輯,步驟4和步驟5之間必須是同步執(zhí)行。

    因為一旦步驟4是異步執(zhí)行,則當步驟5「原生點擊事件」冒泡到document時,步驟4document的click事件還未綁定。

    步驟4在useEffect回調函數(shù)中,而useEffect的回調是在執(zhí)行完DOM操作后異步執(zhí)行的。

    • 如果useEffect回調在DOM變化后同步執(zhí)行,會阻塞DOM重排、重繪,所以被設計為異步執(zhí)行。如果一定要在DOM變化后同步執(zhí)行副作用,可以使用useLayoutEffect

    所以,「正常情況下」,步驟4和步驟5是在不同的兩個瀏覽器task執(zhí)行。

    然而,總有意外。

    useEffect的邊界case

    在React中,一個常見的操作鏈路是:

    • 用戶觸發(fā)事件 -> 改變state -> 依賴該state的useEffect回調執(zhí)行

    去掉中間環(huán)節(jié),就是這樣:

    • 用戶觸發(fā)事件 -> ... -> useEffect回調執(zhí)行

    而我們剛才說,useEffect回調是異步執(zhí)行的。

    那么設想以下場景:

    用戶快速點擊鼠標觸發(fā)onClick事件,如何保證每次點擊產(chǎn)生的useEffect回調按順序執(zhí)行呢?

    為了解決這個問題,React將不同原生事件分類。

    其中click、keydown等這種不連續(xù)觸發(fā)的事件被稱為「離散事件」(與之對應的就是scroll這種能連續(xù)觸發(fā)的事件)。

    • 源碼中所有離散事件的定義見這里

    為了保證如下鏈路中的useEffect回調都能按順序執(zhí)行

    • 離散事件 -> ... -> useEffect回調執(zhí)行

    每當處理離散事件前,都會執(zhí)行flushPassiveEffects方法。

    該方法會將還未執(zhí)行的useEffect回調執(zhí)行。

    這樣就能保證下一次useEffect回調執(zhí)行前上一次的useEffect回調已經(jīng)執(zhí)行。

    所以,當不點擊PortalRenderer的button掛載Portal時,點擊ToastButton的完整流程如下:

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

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

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

    1. 點擊PortalRenderer的button,在document.body掛載Portal對應DOM
    2. 在document.body執(zhí)行綁定事件代理邏輯
    3. 點擊ToastButton,「原生點擊事件」冒泡到應用掛載的根節(jié)點
    4. 進入「合成事件」的冒泡邏輯,冒泡到ToastButton時觸發(fā)onClick
    5. onClick中setShow(true),state變?yōu)閠rue,渲染toast DOM
    6. useEffect回調「異步執(zhí)行」,為document綁定click事件
    7. 「原生點擊事件」繼續(xù)冒泡到document.body,由于body綁定了事件代理邏輯,所以會處理離散事件
    8. 處理的第一步是將還未執(zhí)行的步驟6同步執(zhí)行,此時document綁定click事件
    9. 「原生點擊事件」繼續(xù)冒泡到document,觸發(fā)步驟6綁定的click事件
    10. 調用clickHandler將state變?yōu)閒alse,移除toast DOM

    UI表現(xiàn)為:點擊ToastButton,無反應(實際是先展示toast,再在同一個瀏覽器task移除toast)

    bug解決

    可以看到,這是React源碼運行流程的幾個feature綜合起來造成的bug。

    如何修復呢?在現(xiàn)有v17架構下無法很好修復。

    在v18,伴隨Concurrent Mode的「啟發(fā)式更新算法」,會修復該bug。

    bug修復見Flush discrete passive effects before paint #21150

    修復的方式很簡單:如果一個useEffect回調是由離散事件造成的,則該useEffect回調不會異步執(zhí)行,而是會在本輪DOM更新完成后同步執(zhí)行。

    至于為什么v16及之前版本不會復現(xiàn)這個bug?

    因為之前的版本所有「原生事件」都注冊在html DOM上。

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

    [[405756]]

    當bug來臨,沒有一片feature是無辜的。

    現(xiàn)在,終于有點能體會為啥React團隊開發(fā)Concurrent Mode相關功能花了2年多時間。

    真是,牽一發(fā)動全身啊~

    參考資料

    [1]material-ui:

    https://github.com/mui-org/material-ui/issues/23215

    [2]在線Demo地址:

    [3]離散事件:

    https://github.com/facebook/react/blob/a8a4742f1c54493df00da648a3f9d26e3db9c8b5/packages/react-dom/src/events/ReactDOMEventListener.js#L294-L350


    分享標題:大佬,怎么辦?升級React17,Toast組件不能用了
    網(wǎng)頁鏈接:http://www.5511xx.com/article/dppiiej.html