iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 20
2
Modern Web

給初入JS框架新手的React.js入門系列 第 20

【React.js入門 - 20】 useEffect - 在function component用生命週期

花了那麼多時間講生命週期,那麼function component的生命週期也是直接宣告componentDidMount那些嗎?

呃,不是。跟state一樣,在function component中我們必須要使用React hook才能設定生命週期函數。

在前面講生命週期時,我們提到最常被使用到的是componentDidMountcomponentWillUnmountcomponentDidUpdate這三個函數,而React hook把這三者整合起來,變成了useEffect

React hook兩大守則

  1. 只能在最外層scope宣告
  2. 只能在function component或custom hook中使用

之前沒有特別講,在這裡提一下,後面會講custom hook是什麼。

useEffect基本語法

useEffect接收兩個參數,第一個是一個函式,定義componentDidMount或componentDidUpdate要做什麼事,此函式的回傳值也要是一個函式,表示componentWillUnmount 要做什麼事。第二個是一個array,裡面是定義當哪些變數被改變時,這個useEffect要重新被觸發。有點像是過去我們在componentDidUpdate寫prevState!=this.state這種感覺。

這樣講有點抽象,我們來看語法:

useEffect替代純componentDidMount

第二個參數為空array時(不是省略歐),代表除了第一次以外,接下來每次re-render時,沒有任何東西的改變可以重新觸發useEffect,所以就等同於componentDidMount。

useEffect(() => {
    /* 下面是 componentDidMount */
    
    
    /* 上面是 componentDidMount */
    
}, []); 

/* 第二個參數是用來限定當哪些變數被改變時useEffect要觸發 */

useEffect替代componentWillUnmount

componentWillUnmount就是useEffect第一個用來當參數的函式的return值。以下是componentDidMount和componentWillUnmount的集合體。

useEffect(() => {
    /* 下面是 componentDidMount*/
    
    
    /* 上面是 componentDidMount */
    
    return (() => {
      /* 下面是 componentWillUnmount */
      
      
      /* 上面是 componentWillUnmount */
    });
    
}, []); 
/* 第二個參數是用來限定當哪些變數被改變時useEffect要觸發 */

useEffect替代componentDidUpdate... ? 咦 ?

我一開始在看useEffect的時候,以為第二個參數array不為空、不省略就是componentDidUpdate:

useEffect(() => {
  
  
}, [dependencies參數]); /* 第二個參數是用來限定當哪些變數被改變時useEffect要觸發 */

但實際上我觀察到在第二個參數array不為空下,第一次render還是會執行effect。查了一下官方文件,精確的說法是第二個參數是用來限制哪些參數在re-render時如果被改變,可以重新觸發useEffect,也就是第一次render無論如何都會執行。(「可以重新觸發useEffect」這句話用英文看會更精確,應該是recreate an effect)。

如果想要實現componentDidUpdate,應該要搭配另外一個React hook: useRef。這裡先不解釋它,可以當作是產生一個「被改變後不會觸發re-render」變數的hook(其實我也還沒有特別去研究它XD)。也就是我們用useRef去產生一個紀錄是否完成第一次渲染的變數,初始值預設會給false,第一次執行effect後改為true。

也就是嚴格實現componentDidUpdate的語法為:

    const mounted=useRef();
    useEffect(()=>{
      if(mounted.current===false){
        mounted.current=true;
        /* 下面是 componentDidMount*/
    
    
        /* 上面是 componentDidMount */      
      }
      else{
        /* 下面是componentDidUpdate */
    
    
        /* 上面是componentDidUpdate */

      }
      
      return (()=>{
           /* 下面是 componentWillUnmount */
      
      
          /* 上面是 componentWillUnmount */
      })
    },[dependencies參數]); /* 第二個參數是用來限定當哪些變數被改變時useEffect要觸發 */

如果有第二個非空參數就相當於過去寫的prevState!=this.stateprevProps!=this.props,如果省略,mount==true的if-else scope就是完全等於純componentDidUpdate。記得要引入useRef()。

useEffect替代componentDidMount + componentDidUpdate

當你「省略第二個用來監控的參數array」或是「該array不為空」時,它就等同於componentDidMount + componentDidUpdate的集合體。省略的話就是代表每次re-render時都會觸發useEffect,不省略不為空則代表當re-render時,如果你放在array中的值有被改變,就會觸發useEffect。

useEffect(() => {
    /* 下面是 componentDidMount 和  componentDidUpdate */
    
    
    /* 上面是 componentDidMount 和  componentDidUpdate */
    
    return () => {
      /* 下面是 componentWillUnmount */
      
      
      /* 上面是 componentWillUnmount */
    };
    
}, [dependencies參數]); /* 第二個參數是用來限定當哪些變數被改變時useEffect要觸發 */

下面省略了第二個參數,也是componentDidMount 和 componentDidUpdate集合體:

useEffect(() => {
    /* 下面是 componentDidMount 和  componentDidUpdate */
    
    
    /* 上面是 componentDidMount 和  componentDidUpdate */
    
    return () => {
      /* 下面是 componentWillUnmount */
      
      
      /* 上面是 componentWillUnmount */
    };
    
});

useEffect的其他

可以有多個useEffect存在同一function component和custom hook中,所以我們可以針對不同的變數去寫不同的useEffect

來個練習吧!

step 0 : 把之前的Baby.js改成function component吧!

我們把之前的嬰兒整合一下,不過因為它太胖了,我們先把它變成function component,也就是把所有的state改成useState形式,class 的 member function改成function中的function。讓巨嬰變回中嬰(?)之後,再來慢慢把生命週期補上去。(這裡寫法其實可以更簡單,只是因為我覺得這樣寫比較快理解)

import React, { useState } from 'react';
const Baby=(props)=>{
    /* 把state變成useState */
    const [isGetData,setGetData]=useState(false);
    const [Mom,setMom]=useState("");
    const [isRightDad,setRightDad]=useState(false);

    /* 把class 的 member function改成function中的function */
    const ajaxSimulator=()=>{
        setTimeout(()=>{
            setGetData(true);
            setMom("小美");
        },3000)
    }
    const checkDad=()=>{
        if(props.dad==="Chang")
            setRightDad(true)
        else
            setRightDad(false)
    }

    if(isRightDad===false)
        return(
            <div>他的媽媽,是誰,干你X事</div>
        );
    else if(isGetData===false)
        return(
            <div id="msg">讀取中</div>
        );
    else
        return(
            <div id="msg">他的媽媽是{Mom}</div>
    );  
}
export default Baby;

App.js也順便換成function component,然後加入一個可以換爸爸的功能:

import React, { useState } from 'react';
import Baby from './Baby'

const App=()=>{
    const [dad, setDad] = useState("Chang");
    const [born, setBorn] = useState(true);
    
    const changeDad=()=>{
      if(dad==="Chang"){
        setDad("Wang")
      }
      else{
        setDad("Chang")
      }
    }

    const spawnBaby=()=>{
      if(born===true){
        return <Baby dad={dad}/>;
      }
    }

    return(
      <div>
        {spawnBaby()}
        <div id="talk"></div>
        <button onClick={changeDad}>換爸爸!</button>
        <button onClick={()=>{setBorn(!born)}}>{(born===true)?"讓他回去肚子裡":"讓他生"}</button>
      </div>
    );
}
export default App;

step 1 : 引入useEffect

import React, { useState, useEffect } from 'react';

step 2 : 替代Baby.js中的componentDidMount的ajax部份

第二個參數為空array時,這個useEffect就相當於是componentDidMount
所以,我們現在讓Baby.js在被創造時,執行花3秒取得媽媽資訊的ajaxSimulator:


const checkDad=()=>{
    (省略)
}

useEffect(() => {
    /* 下面是 componentDidMount */
    
    ajaxSimulator();

    /* 上面是 componentDidMount */  
}, []);

if(isRightDad===false){
    (省略)
}
        

step 3 : 替代Baby.js中的componentDidMount+componentDidUpdate

因為我們在第一次設定爸爸跟更改爸爸時都要檢查爸爸是否正確,所以我們要用useEffect一起替代componentDidMount + componentDidUpdate。

現在加入一個useEffect替代componentDidMount + componentDidUpdate。在這邊,我們讓Baby.js的props中的爸爸第一次被渲染以及dad被改變時,用checkDad去檢查爸爸正確性,以決定是否提供寶寶的媽媽資訊:

const checkDad=()=>{
    (省略)
}

useEffect(() => {
    ajaxSimulator();    
}, []);

useEffect(() => {
    /* 下面是 componentDidMount和componentDidUpdate */
    
    checkDad();
    
    /* 上面是 componentDidMount和componentDidUpdate */    
}, [props.dad]); /* 加入監控的props.dad */ 

if(isRightDad===false){
    (省略)
}


step 4 : 替代Baby.js中的用來去除喊聲爸componentWillMount

現在先在剛剛專為ajax的useEffect加入喊聲爸的函式,讓我們等等可以用componentWillMount移除。

useEffect(() => {
    ajaxSimulator();
    document.getElementById("talk").append('爸!')
}, []);

這樣按很多次之後就會呈現跟之前很像的一堆「爸!」狀況:

接下來我們加入componentWillUnmount來在元件死去時移除「爸!」。在useEffect中,第一個參數函式的return就是componentWillUnmount,也就是第一個參數函式的return值也要是一個函式:

useEffect(() => {
    ajaxSimulator();
    document.getElementById("talk").append('爸!')
    
    return(()=>{
        /* 下面是 componentWillUnmount */

        document.getElementById("talk").innerHTML="";
        
        /* 上面是 componentWillUnmount */
    })
}, []);

小結:

useStateuseEffect是目前最常被使用的React hook。本系列關於React hook api只會介紹這兩個和之後會講的custom hook,其他還有useRefuseContextuseReducer等,有興趣可以參考官方文件,或是其他目前幾篇以React hook為主體的系列。

下一篇是我覺得這次自己寫這系列想表達的重點-統整component間的溝通方式。


上一篇
【React.js入門 - 19】 React生命週期(4/4): Update系列一次講完
下一篇
【React.js入門 - 21】 各階層Component的溝通
系列文
給初入JS框架新手的React.js入門31

2 則留言

0
iT邦新手 5 級 ‧ 2020-08-02 17:32:50

第二個參數為空array時(不是省略歐),代表除了第一次以外,接下來每次re-render時,沒有任何東西的改變可以重新觸發useEffect,所以就等同於componentDidMount。

不好意思,這句話我覺得有點怪,我的理解是「任何東西的改變都會重新觸發 useRffect」

然後關於第一次 render 時,我是理解成,[]中的 state 從無被建立,所以也算有改變,所以會觸發 useEffect。

const mounted=useRef();
這個部分,好奇是否可以直接用 const [mounted, setMounted] = useState(false) 來替代?

看更多先前的回應...收起先前的回應...

不好意思,這句話我覺得有點怪,我的理解是「任何東西的改變都會重新觸發 useRffect」。然後關於第一次 render 時,我是理解成,[]中的 state 從無被建立,所以也算有改變,所以會觸發 useEffect。

我覺得我自己解釋這個可能會講不太清楚,但可以從這篇分析原始碼code的文章來看,應該可以找到你要的答案(你會發現第二個array為undefined的case是有被handle的)

總之單就使用上而言,這兩個情形會觸發useEffect:

  1. 初次render後(effect被創造)
  2. 第二個參數中任一值被改變後(當沒有這個參數,就如同你說的,任何props和state的改變都會觸發useEffect)

const mounted=useRef();
這個部分,好奇是否可以直接用 const [mounted, setMounted] = useState(false) 來替代?

我們來看以下程式碼:

function Layout() {
  const [mount, setMount] = useState(false);

  useEffect(()=>{
    if(!mount){
      setMount(true);
      console.log("componentDidMount");
    }else{
      console.log("componentDidUpdate");
    }
    console.log("componentDidMount & componentDidUpdate ");
  });

  return (
    <>
    </>
  );
}

執行結果:

你會發現componentDidMountcomponentDidUpdate都被執行了。原因是當你呼叫setMount的時候,React會重新檢查有沒有跟mount相關的渲染內容&hook依賴,整個流程是這樣的:

  1. 初始觸發useEffect
  2. 呼叫setMount、console.log("componentDidMount")
  3. 呼叫useEffect中的console.log("componentDidMount & componentDidUpdate "),第一個effect結束
  4. React偵測到mount這個state被改變
  5. React檢查、更新Virtual DOM
  6. React檢查、發現相關依賴的useEffect(沒有依賴就代表也要觸發)
  7. 呼叫console.log("componentDidUpdate")
  8. 呼叫useEffect中的console.log("componentDidMount & componentDidUpdate "),第二個effect結束

當然,這個情形你可以用新增第二個參數array並且不把mount加入這個array來解決。但是這就違反了useEffect的用意(希望effect中的內容只跟第二個參數的相依有關),所以React此時會跳warning。

另外,使用useRef也可以避免React再去做「檢查DOM是否要更新」這個動作。

iT邦新手 5 級 ‧ 2020-08-05 10:38:47 檢舉

感謝解答,一開始多做一次componentDidUpdate的事。
要來多研究一下useRef了

iT邦新手 5 級 ‧ 2020-08-19 15:31:19 檢舉

這邊想補充一下,是不是 componentDidMount 的那個 useEffect 需要寫在最後一個 useEffect?
因為 mounted.current=true 執行後會馬上生效,不像 state 會用舊的。

這邊想補充一下,是不是 componentDidMount 的那個 useEffect 需要寫在最後一個 useEffect?

如果你希望用單一變數去記憶的話,是的。

因為 mounted.current=true 執行後會馬上生效,不像 state 會用舊的。

更精確的說法是

  1. React hook的運作其實是依賴著你在function component內呼叫的順序
  2. 當擁有多個useEffect,且有state在第一個useEffect被改變時,state並不會馬上被改變,原因是其他useEffect還沒有跑完。如果React不等其他useEffect跑完就直接去改變state,就等於是讓這些effect被跳過了一次update的生命週期。
iT邦新手 5 級 ‧ 2020-08-19 22:50:23 檢舉

更精確的說法是...

這部分有在此系列前面的文章看到,在之前都沒了解到這一點,獲益良多。

如果你希望用單一變數去記憶的話,是的。

是有想說可以用多個變數去記憶,但後來想想,component mount 也就那一次,感覺意義上是一進來要做的初始化,所以想說把 componentDidMount 統一寫。componentDidUpdate 再個別依不同依賴或功能分開寫。這樣好像比較順。

如果是我自己啦,會把所有comonentDidMount的東西塞在同一個effect裡。因為這樣別人review的時候比較好理解XD

iT邦新手 5 級 ‧ 2020-08-21 07:40:01 檢舉

哈了解,感謝

0
thuartlynn
iT邦新手 5 級 ‧ 2021-10-21 17:15:54

不好意思,請問const checkDad這段function 一定要在外面宣告嗎?
目前會報出 "React Hook useEffect has a missing dependency: 'checkDad'."
請問這個會否影響?

我是看此篇文章是說如果不是任何的地方都要使用的話,合併在useEffect中,
https://stackoverflow.com/questions/55840294/how-to-fix-missing-dependency-warning-when-using-useeffect-react-hook
但想請問是不是要看function的作用是什麼,不一定放在useEffect中就是對的呢?

謝謝您

看更多先前的回應...收起先前的回應...

Hi,這邊的範例只是為了講解class component對照到function component時會是什麼狀況。

關於useEffect和一般function的關係,我們應該要換個方式來思考。接下來我會分比較多段來講解,最後再來解答你的問題。

Part.1 - 為什麼要有useEffect?

「Effect」這個單字是「副作用」的意思。放在程式碼中,通常代表的是「某個改動」會造成的「連帶影響」。舉例來說,當我們希望當props.dad被改動時,元件中的isRightDad根據props.dad去賦予新的值,此時應該這樣寫:

useEffect(()=> {
    if(props.dad === 'Chang')
        setIsRightDad(true);
    else
        setIsRightDad(false);
}, [props.dad]);

在閱讀程式語意時,我們可以很明確的知道這一段是props.dad的副作用。

()=> {
    if(props.dad === 'Chang')
        setIsRightDad(true);
    else
        setIsRightDad(false);
}

同時,React也會希望在「副作用內容」使用到的所有可能會改變的React變數,都要被定義在useEffect的第二個dep參數array中。一方面是因為當副作用中使用到的變數被改變時,副作用的定義內容被改變,那就等於這個副作用也是「該變數的副作用」了。另一方面是這樣可以讓React避免運作時出現未預期的錯誤。

所以到這邊我們可以猜測,範例的狀況會噴錯,是因為checkDad直接使用了props.dad,所以checkDad這個函式會重新定義但又沒被放進dep array,React偵測到了可能發生的未預期錯誤。(實際上不完全是這樣,下一part我們會解釋)

Part.2 - React function component的缺陷

但是這樣還是沒有解決我們的問題。你會發現如果我們想辦法把定義在useEffect外的checkDad改成不是直接使用props.dad,而是把props.dad透過參數傳進來,理論上函式本身定義內容應該是不變的。但是useEffect還是噴錯了:

import React, { useState } from 'react';

const Baby=(props)=>{
    /* 把state變成useState */
    const [isGetData,setGetData]=useState(false);
    const [Mom,setMom]=useState("");
    const [isRightDad,setIsRightDad]=useState(false);

    const checkDad=(dad)=>{
        if(dad==="Chang")
            setRightDad(true)
        else
            setRightDad(false)
    }
    
    useEffect(()=> {
        checkDad(props.dad);
    }, [props.dad]);

    return <></>;
}

這是為什麼呢?

React的function component和class component不同的地方在,class component在更新元件時,只會去呼叫對應的生命週期函式。但是function component在更新元件時是重新呼叫整個function component函式定義域

舉例來說,你會發現下方程式碼中,每次按下按鍵時,"hello world!"都被重新印出了一次:

import { useState } from 'react';

function Test(){
    const [ctn, setCtn] = useState(0);
    console.log("hello world!");
    return (
        <button onClick={()=>setCtn(ctn+1)}>
            +
        </button>
    );
}

回到剛剛的範例,當Baby這個元件被更新時,實際上「定義checkDad」的這幾行程式碼又會被重新執行,也代表checkDad重新被定義。對於Javascript來說,checkDad就是被改變了(這段如果有疑惑可以搜尋call by reference)。

const Baby=(props)=>{

    const checkDad = (dad) => {
        if(dad==="Chang")
            setRightDad(true)
        else
            setRightDad(false)
    }
    
}

當專案規模大的時候,這會造成相當多不必要的效能問題。所以這種情形要盡量避免。

到這邊我知道問題還沒解決,但是目前為止我們可以知道兩件事:

  1. useEffect中,有用到會改變的React變數都要放進dep array。(為了避免錯誤和影響程式易讀性)
  2. 盡量避免將函式定義在function component內,不然他會一直改變(正確說法是reference被改變)。

Part 3 - 該如何正確地在function component中使用函式

情境一: 該函式和任何state、props無關

此時我們應該把函式定義在function component定義域的 「外面」 。因為這樣是一般原生js用法,也就是只會在初始執行js檔時定義一次。

import { useState, useEffect } from 'react';

function callHelloWorld(){
    console.log("hello world!");
}

function Test(){
    const [ctn, setCtn] = useState(0);
    useEffect(()=>{
        callHelloWorld()
    }, []);
    
    return (
        <button onClick={()=>setCtn(ctn+1)}>
            +
        </button>
    );
}

情境二: 該函式只和某個特定的state、props改變有關

這也是我們範例中的狀況(在props.dad改變後修改isRightDad,但跟isRightDad原始值無關)。

處理方式就跟你查到的stackoverflow說法一樣,透過useEffect處理即可,通常不需要多拉一個函式(當然還是要看每個case,不同情況可能有不同適合的方式),詳情可以參考我今年8月撰寫的文章的part.4 「如何正確的取得setState後的新state值?」: 理解React的setState到底是同步還是非同步(下),做法是類似的。

情境三: 該函式和多個state、props有關

這個時候我們要使用React提供的原生hook useCallback(但是在useEffect中使用該函式依然要把函式加在dep內)。請參考我12屆鐵人賽文章 【Day.20】React效能 - 用useCallback避免函式的重新定義

另外以下的狀況React並不會噴錯。這是因為React有特別設計讓useState所回傳的setState函式的reference不會改變,所以這個情形useEffect不會發生問題。

import { useState, useEffect } from 'react';

function Test(){
    const [ctn, setCtn] = useState(0);
    useEffect(()=>{
        setCtn(1);
    }, []);
    
    return (
        <button onClick={()=>setCtn(ctn+1)}>
            +
        </button>
    );
}

以上希望能幫助到你。使用FC的思維會跟使用class有點不太一樣,現在回過頭來看覺得兩三年前自己寫的文章有些寫不是很好的地方,不好意思


千萬不要這麼說,對於我這個剛學React的新手來說,我覺得您的文章對我來說是非常好的呢,哈,謝謝您,我懂了,會努力加緊學習,到時候來看進階版的React教學!

我要留言

立即登入留言