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

你們好,我是卡頌,人稱卡爾摩斯。html

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

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

  • useEffect執行時機(極可能與你想的不同)github

  • 合成事件原理算法

  • v17源碼中對合成事件的改動瀏覽器

  • Portal原理markdown

這篇文章很長很長,有很是多源碼細節。架構

你能夠用以下Demo和我一塊兒debug源碼,更有破案的感受app

在線Demo地址dom

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

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

只在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"));
複製代碼

效果以下:

show.gif

接下來,咱們再增長一個渲染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.createPortaldocument.body上掛載一個div,內容爲who is handsome?

咱們將兩個組件一塊兒放在App中:

function App() {
  return (
    <div> <PortalRenderer /> <ToastButton /> </div>
  );
}
複製代碼

點擊PortalRenderer效果以下:

portal-dev.gif

如今問題來了:

若是先點擊PortalRendererbutton,再點擊ToastButton會怎麼樣?

理所固然的答案是:

  • 先顯示who is handsome?

  • 再顯示Hey, Ka Song~

然而,在React v17效果以下:

bug.gif

先點擊PortalRendererbutton後,再點擊ToastButton,不會看見toast的內容。

可是,只要不點擊PortalRendererbutton就不會有問題:

bug2.gif

這只是一個可復現該bug的極簡Demo

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

在使用瞭如上所示的在document掛載原生click事件方式實現toast的同時,

再使用Portaldocument.body掛載DOM都會觸發該bug

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

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

div去哪了?

首先,咱們要明確,點擊Show Toast沒反應,是由於沒渲染toast,仍是由於渲染了toast又馬上刪除了。

審查元素後發現,每當點擊Show ToastToastButton渲染的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有哪些變化?

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

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

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

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

這個改動是爲了讓一個應用下能夠存在多個不一樣模式的子應用(兼容legacy modeconcurrent mode同時存在於一個應用)。

會不會是這個緣由呢?

因而,卡爾摩斯將目光鎖定在源碼中註冊事件的方法:addTrappedEventListener

在應用初始化時(調用ReactDOM.render首屏渲染時),React會遍歷全部原生事件名,依次在根節點調用該方法註冊事件回調。

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

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

  1. 原生點擊事件向上冒泡

  2. 原生點擊事件冒泡到根節點,觸發addTrappedEventListener註冊的事件處理函數

  3. 合成事件會在React組件樹中從底向上冒泡

  4. 合成事件冒泡到觸發點擊的組件時,調用onClick方法

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

那麼,爲何只有在掛載了Portal的狀況下bug能復現?

難道Portal與合成事件有關?

果真,當咱們點擊PortalRendererbutton後,又進入了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事件舉例,當點擊發生後,會依次執行:

  1. 原生點擊事件向上冒泡

  2. 原生事件冒泡到根節點(div#root),觸發addTrappedEventListener註冊的事件處理函數

  3. 合成事件會在React組件樹中從底向上冒泡

  4. 合成事件冒泡到觸發點擊的組件時,調用onClick方法

  5. 原生點擊事件繼續向上冒泡到document.body

  6. 重複觸發步驟3

難道bug的緣由是onClick被重複執行兩次?

若是是這麼明顯的bug你們開發過程當中確定很容易復現。

咱們能夠在onClick中打印日誌,能夠看到:一次點擊只會打印一條日誌。

click.gif

那麼問題出在哪呢?

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狀態連續變化的緣由極可能是:

  1. 點擊ToastButton原生點擊事件冒泡到應用掛載的根節點

  2. 進入合成事件的冒泡邏輯,冒泡到ToastButton時觸發onClick

  3. onClicksetShow(true)state變爲true,渲染toast DOM

  4. useEffect回調執行,爲document綁定click事件

  5. 原生點擊事件繼續冒泡,當冒泡到document時,觸發其綁定的click事件

  6. 調用clickHandlerstate變爲false,移除toast DOM

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

要知足如上邏輯,步驟4和步驟5之間必須是同步執行。

由於一旦步驟4是異步執行,則當步驟5原生點擊事件冒泡到document時,步驟4documentclick事件還未綁定。

步驟4在useEffect回調函數中,而useEffect的回調是在執行完DOM操做後異步執行的。

若是useEffect回調在DOM變化後同步執行,會阻塞DOM重排、重繪,因此被設計爲異步執行。若是必定要在DOM變化後同步執行反作用,可使用useLayoutEffect

因此,正常狀況下,步驟4和步驟5是在不一樣的兩個瀏覽器task執行。

然而,總有意外。

useEffect的邊界case

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

用戶觸發事件 -> 改變state -> 依賴該stateuseEffect回調執行

去掉中間環節,就是這樣:

用戶觸發事件 -> ... -> useEffect回調執行

而咱們剛纔說,useEffect回調是異步執行的。

那麼設想如下場景:

用戶快速點擊鼠標觸發onClick事件,如何保證每次點擊產生的useEffect回調按順序執行呢?

爲了解決這個問題,React將不一樣原生事件分類。

其中clickkeydown等這種不連續觸發的事件被稱爲離散事件(與之對應的就是scroll這種能連續觸發的事件)。

源碼中全部離散事件的定義見這裏

爲了保證以下鏈路中的useEffect回調都能按順序執行

離散事件 -> ... -> useEffect回調執行

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

該方法會將還未執行的useEffect回調執行。

這樣就能保證下一次useEffect回調執行前上一次的useEffect回調已經執行。

因此,當不點擊PortalRendererbutton掛載Portal時,點擊ToastButton的完整流程以下:

  1. 點擊ToastButton原生點擊事件冒泡到應用掛載的根節點

  2. 進入合成事件的冒泡邏輯,冒泡到ToastButton時觸發onClick

  3. onClicksetShow(true)state變爲true,渲染toast DOM

  4. useEffect回調異步執行,爲document綁定click事件

  5. 原生點擊事件繼續冒泡到document,此時document還未綁定click事件

UI表現爲:點擊ToastButton,展現toast

當點擊PortalRendererbutton掛載Portal後,再點擊ToastButton的完整流程以下:

  1. 點擊PortalRendererbutton,在document.body掛載Portal對應DOM

  2. document.body執行綁定事件代理邏輯

  3. 點擊ToastButton原生點擊事件冒泡到應用掛載的根節點

  4. 進入合成事件的冒泡邏輯,冒泡到ToastButton時觸發onClick

  5. onClicksetShow(true)state變爲true,渲染toast DOM

  6. useEffect回調異步執行,爲document綁定click事件

  7. 原生點擊事件繼續冒泡到document.body,因爲body綁定了事件代理邏輯,因此會處理離散事件

  8. 處理的第一步是將還未執行的步驟6同步執行,此時document綁定click事件

  9. 原生點擊事件繼續冒泡到document,觸發步驟6綁定的click事件

  10. 調用clickHandlerstate變爲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上。

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

gulu.gif

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

如今,終於有點能體會爲啥React團隊開發Concurrent Mode相關功能花了2年多時間。

真是,牽一髮動全身啊~

相關文章
相關標籤/搜索