iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 26
3
Modern Web

從比入門再往前一點開始,一直到深入React.js系列 第 26

【Day.26】React進階 - useEffect v.s useLayoutEffect

  • 分享至 

  • xImage
  •  

(2024/04/06更新) 因應React在18後更新了許多不同的語法,更新後的教學之後將陸續放在 新的blog 中,歡迎讀者到該處閱讀,我依然會回覆這邊的提問


如果你在撰寫React專案時,有試著在第一次渲染後,透過useEffect以state修改綁定給元件的資料,應該會發現一個特殊的現象:

嗯?為甚麼我的元件會閃一下?

以下方的程式為例

  • src/page/MenuPage.js
import React, {useState,useMemo,useEffect} from 'react';

import MenuItem from '../component/MenuItem';
import Menu from '../component/Menu';
import { OpenContext } from '../context/ControlContext';

let menuItemWording = new Array(20);
menuItemWording.fill("沒有東西");

const MenuPage = () =>{
    const [isOpen, setIsOpen] = useState(true);
    const [menuItemData, setMenuItemData] = useState(menuItemWording);
    let menuItemArr = useMemo(()=>menuItemData.map((wording) => <MenuItem text={wording} key={wording}/>),[menuItemData]);

    useEffect(()=>{
        setMenuItemData([
            "Like的發問",
            "Like的回答",
            "Like的文章",
            "Like的留言"
        ]);
    },[])

    return (
        <OpenContext.Provider value={{ 
            openContext: isOpen, 
            setOpenContext: setIsOpen
        }} >
            <Menu title={"Andy Chang的like"}>
                {menuItemArr}
            </Menu>
            <button onClick={()=>{
                let menuDataCopy = ["測試資料"].concat(menuItemData);
                setMenuItemData(menuDataCopy); 
            }}>更改第一個menuItem</button>
        </OpenContext.Provider>
    );
}

export default MenuPage;

你會得到下列的結果:

這是為什麼呢?

非同步、畫面渲染後執行的useEffect

這是因為我們先前說過,useEffect的執行時間點是這樣:

  1. 建立、呼叫function component
  2. 真正更新DOM
  3. 渲染畫面
  4. 呼叫useEffect
  5. 「某個時間點」,偵測到state、props被改變
  6. 重新呼叫function component
  7. 在virtual DOM比較所有和原始DOM不一樣的地方
  8. 真正更新DOM
  9. 渲染畫面
  10. 呼叫useEffect
  11. 「某個時間點」,元件被移除
  12. 呼叫useEffect

也就是在第一次渲染時,React還沒有拿到你在useEffect中修改的state,所以畫面上顯示的會是你一開始拿來當初始值的資料。這也是我們的畫面會閃一下的原因。

(2020/10/11) 但如果你是從鐵人賽一開始就在跟我文章的人,我當初這裡有不小心寫錯。現在已經修正了。雖然這種人應該不多就是(?)

同步、畫面渲染前執行的useLayoutEffect

雖然這種需求很少,但React提供了一個解決上述問題的hook-useLayoutEffect。它和useEffect的語法、使用上一模一樣。唯一的差別是useLayoutEffect被提升到了渲染畫面前、更新DOM後執行。

useLayoutEffect的執行時間點是這樣:

  1. 建立、呼叫function component
  2. 真正更新DOM
  3. 呼叫useLayoutEffect
  4. 渲染畫面
  5. 「某個時間點」,偵測到state、props被改變
  6. 重新呼叫function component
  7. 在virtual DOM比較所有和原始DOM不一樣的地方
  8. 真正更新DOM
  9. 呼叫useLayoutEffect
  10. 渲染畫面
  11. 「某個時間點」,元件被移除
  12. 呼叫useLayoutEffect

接下來你可以試著把剛剛的useEffect換成useLayoutEffect

  • src/page/MenuPage.js
import React, {useState,useMemo,useLayoutEffect} from 'react';

import MenuItem from '../component/MenuItem';
import Menu from '../component/Menu';
import { OpenContext } from '../context/ControlContext';

let menuItemWording = new Array(20);
menuItemWording.fill("沒有東西");

const MenuPage = () =>{
    const [isOpen, setIsOpen] = useState(true);
    const [menuItemData, setMenuItemData] = useState(menuItemWording);
    let menuItemArr = useMemo(()=>menuItemData.map((wording) => <MenuItem text={wording} key={wording}/>),[menuItemData]);

    useLayoutEffect(()=>{
        setMenuItemData([
            "Like的發問",
            "Like的回答",
            "Like的文章",
            "Like的留言"
        ]);
    },[])

    return (
        <OpenContext.Provider value={{ 
            openContext: isOpen, 
            setOpenContext: setIsOpen
        }} >
            <Menu title={"Andy Chang的like"}>
                {menuItemArr}
            </Menu>
            <button onClick={()=>{
                let menuDataCopy = ["測試資料"].concat(menuItemData);
                setMenuItemData(menuDataCopy); 
            }}>更改第一個menuItem</button>
        </OpenContext.Provider>
    );
}

export default MenuPage;

你會發現畫面不再會閃過一次初始的資料了。這是因為React在第一次渲染畫面前已經執行了useLayoutEffect中的setState

然而必須要注意的事情是,useLayoutEffect本身是一個同步函式,也就是說UI會等useLayoutEffect中做的事情結束才會渲染。所以不要在useLayoutEffect做太多事情,否則使用者看到UI的間隔會拉長,導致UX變差。

這件事衍伸的問題是,當你要在React做SSR時,因為useLayoutEffectusetEffect都不會在server-side執行,有需要useLayoutEffect的元件就可能會以不符你的預期的方式運作。

請記得,除非你有特殊的需求,否則大部份的狀況useEffect都應該能夠解決你的問題

如果你是從class component切換過來的人,實質上useLayoutEffect的執行時機點才是真正等於componentDidMount和componentDidUpdate。 但使用上官方還是希望你使用useEffect。

參考資料:
React Hooks 一些紀錄
官方文件


上一篇
【Day.25】React進階 - Custom hook | 把React component API模組化吧!
下一篇
【Day.27】React進階 - 用useReducer定義state的更動原則
系列文
從比入門再往前一點開始,一直到深入React.js30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
salia0923
iT邦新手 5 級 ‧ 2022-05-06 12:13:08

想問關於 useLayoutEffect 因為是在畫面 render 前執行的
所以像 fetch data 之類串接 API 的動作 是不是也是搬到 useLayoutEffect 中執行會比較好呢 謝謝

Andy Chang iT邦研究生 4 級 ‧ 2022-05-06 19:06:54 檢舉

Hi, 正好相反喔!雖然這邊說useLayoutEffect是同步且在畫面繪製前執行的,但是call http request本身是Javascript的非同步行為,呼叫後會走event loop的流程。而React本身運作是在Javasciprt主thread中,所以不會等API reponse回來再去繪製畫面。

會用到useLayoutEffect的情境主要是「UI和Client side Javascript運算有關」。如果UI是和Server side有關(需要後端資料等等),有兩種解法:

  • UI/UX(適用於大部份情境): 預先顯示一個讀取圖示,等到拿到server side資料後用useEffect把圖示改成要顯示的資料。
  • SSR(通常與SEO有關): 在Server side等到資料取得後,先在server side以React產生DOM結構,直接傳送畫面給前端使用者,再到client side去綁定使用者互動所需要的Javascript行為。 可以參考 React SSR框架Next.js的getServerSideProps

另外要注意的是useLayoutEffect並不會在server side執行,所以如果你的專案需要後端資料又真的不允許讀取圖示的這段空檔,就應該要使用像Next.js這類的方案,使用useLayoutEffect並不能解決這個問題

salia0923 iT邦新手 5 級 ‧ 2022-05-07 13:39:04 檢舉

哦哦哦 原來如此!
了解了 非常感謝!

我要留言

立即登入留言