[React] 透過實例熟悉 Effect Hook 操作技巧

透過實例理解 Hook 技術要點,實際體驗 useState 及 useEffect 操作方式及重點所在。

前言


作為一名軟體工程師,最重要的技巧就是「學習」本身,因為技術會不斷推陳出新,必須掌握適合自己的學習策略及節奏,找出最有效率的方式來幫助自己不被這浪潮淹沒;筆者通常都會在概略性地瀏覽各技術重點後,發想一些實際的例子,盡可能去擴張涵蓋所需的技術重點,深刻體會各 API 所存在價值。

回到正題,絕大部分的人都是先接觸到 class 方式去定義一個 component 組件,想當然應該也會對各生命週期有一定的了解,再來接觸 Hook 時一定直覺式的映射原有生命週期到 useEffect 方法中,但兩者並無明顯的對應關係,我們僅能說利用 userEffect 某些情境下的特性恰好可達成以往特定生命週期的效果。

 

實例說明


本範例目的在熟悉 useStateuseEffect 這兩個比較常使用到的 Hook 技巧,範例說明如下:

  1. 實作一個 CountDownTimer 倒數計時組件,可以接收 seconds 倒數秒數 ,並且在倒數結束後透過 onTimeUp 事件通知使用者。
  2. 加入一個輸入框,所輸入的秒數會直接傳入 CountDownTimer 中,畫面如下所示。

 

 

使用 State Hook 存放輸入秒數


首先,需將 input 輸入的內容值存放在 state 中,因此會使用 useState 宣告 state 變數,方式如下:

  1. 傳入 useState10 作為該 state 初始值,並回傳一個陣列。
  2. 狀態變數:命名 seconds 作為倒數秒數。
  3. 修改狀態方法:當輸入變動時可以透過 setSeconds 修改 seconds 狀態變數。
     
利用 Array Destructuring 清楚命名 array[0] 與 array[1] 值分別的意義。

 

透過 setSeconds 方法將 input 值寫入 seconds 狀態中,並且呈現在畫面上。

import React, { useState } from 'react'

const Practice = () => {
  // 宣告一個新的 state 變數,我們稱作為「seconds」。
  // 透過「setSeconds」 方法去修改「seconds」狀態。
  // 給予「seconds」狀態初始值為 10。
  const [seconds, setSeconds] = useState(10)

  return (
    <>
      <div>
        請輸入倒數秒數
        <input
          value={seconds}
          type='number'
          onChange={e => setSeconds(Number(e.target.value) || 0)}
        />
      </div>
      <div>
          您所輸入的秒數 {seconds}
      </div>
    </>
  )
}

export default Practice
其中 setSeconds 方法除可直接設定特定值外,亦可於方法中取得目前 seconds 的資料,方便資料疊加等需求使用。  i.g. setSeconds( val => val + 1 ) 

 

數值變動時確實存入 seconds 中,並呈現在畫面上。

 

 

建立 CountDownTimer


設計組件接收 secondsonTimeUp 兩個 props,並將 seconds 秒數轉為時分秒顯示。


import React from 'react'
import PropTypes from 'prop-types'

const CountDownTimer = ({ seconds, onTimeUp }) => {
  return (
    <div className='tp-count-down-timer'>
      <div className='tp-count-down-timer__time'>
        {new Date(seconds * 1000).toISOString().substr(11, 8)}
      </div>
    </div>
  )
}

CountDownTimer.propTypes = {
  onTimeUp: PropTypes.func,
  seconds: PropTypes.number
}

export default CountDownTimer

 

測試頁面將輸入值 seconds 傳入 CountDownTimer 中顯示,並在 onTimeUp 倒數完畢時印出提示。

import React, { useState } from 'react'
import CountDownTimer from './CountDownTimer'

const Practice = () => {
  const [seconds, setSeconds] = useState(10)

  return (
    <>
      <div>
        請輸入倒數秒數
        <input
          value={seconds}
          type='number'
          onChange={e => setSeconds(Number(e.target.value) || 0)}
        />
      </div>

      <CountDownTimer
        seconds={seconds}
        onTimeUp={() => { console.log('time up!!') }}
      />

    </>
  )
}

export default Practice

 

目前效果如下:

 

 

使用 Effect Hook 在載入組件時開始倒數


在載入組件時需執行的事情通常會在 componentDidMount 生命週期事件中執行,但在 functional component 並無此生命週期存在,因此可以透過 useEffect 來達成目的。我們先來了解一下 useEffect 用法:

  1. Effect FunctionReact 將記住此方法(保留當下各狀態值),且在執行 DOM 更新之後呼叫它。
  2. Effect Cleanup Function:執行下一次 effect function 前會執行上個 effect function 的 cleanup function,我們可以於此時機清除上個 effect function 中所需執行的清除行為。
  3. 這個 Effect 所相依的值 (e.g. state, props),當這些相依值變動時才觸發 effection function 執行。
     
    1. [propsA, stateB]:propsA 屬性或 stateB 狀態有變動時會觸發這個 effect function 執行
    2. 空陣列 []: 無任何相依,僅在組件載入時觸發 effect function 執行一次而已
    3. 不傳入物件:預設就是每次渲染時都會呼叫這個 effect function 執行

 

理解 useEffect 使用方式後,隨即建立一個 Effect 在組件載入時啟動 Timer 進行倒數。

import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'

const CountDownTimer = ({ seconds, onTimeUp }) => {
  const [remainSecond, setRemainSecond] = useState(0)

  // effect
  useEffect(() => {
    const countDownSecond = seconds

    // 產生 Timer
    console.log(`[timer] == start count down ${countDownSecond}s  ==`)
    const startTime = Date.now()
    const countDownTimer = setInterval(() => {
      // 計算剩餘秒數
      const pastSeconds = parseInt((Date.now() - startTime) / 1000)
      const remain = (countDownSecond - pastSeconds)
      setRemainSecond(remain < 0 ? 0 : remain)
      console.log('[timer] count down: ', remain)

      // 檢查是否結束
      if (remain <= 0) {
        clearInterval(countDownTimer)
        console.log(`[timer] == stop count down ${countDownSecond}s  ==`)
        onTimeUp() // 透過 prop 通知外部時間已到
      }
    }, 1000)
  }, []) // 相依 prop / state 值的 Effect

  return (
    <div className='tp-count-down-timer'>
      <div className='tp-count-down-timer__time'>
        {new Date(remainSecond * 1000).toISOString().substr(11, 8)}
      </div>
    </div>
  )
}

CountDownTimer.propTypes = {
  onTimeUp: PropTypes.func,
  seconds: PropTypes.number
}

export default CountDownTimer

 

驗證一下當頁面切換時,載入組件後隨即開始依據初始 seconds10 倒數。

 

 

Effect Hook 的相依


目前雖可完成倒數作業,但仍不符我們的需求,我們想做的是傳入的 seconds 倒數秒數變動時可以重新依據新給的倒數秒數值行倒數作業,因此直覺地加入 seconds 到先前介紹 useEffect 的相依陣列中。

 

測試一下,當修改倒數秒數為 100 時,確實成功觸發 Effect 執行,但同時也產生另一個 Timer 同時運作,造成數值混亂;因此回想一下先前介紹的 Cleanup Effect 方法,正好是我們所需要的。

 

 

Effect Hook 的 Cleanup Function


為了避免上述情況發生,我們需要在下一個 Effect 執行前,將上一個 Effect 產生的 Timer 清除,讓下一個 Effect 依據新的 seconds 秒數建立 Timer 來執行倒數,因此加入 Effect Cleanup Function 清理 Timer

 

 測試一下,組件如預期般地在倒數秒數 seconds 變化的時候重新執行倒數,搞定收工。

 

 

自動檢查 Effect Hook 相依狀態


Effect Hook 當有設定相依值時,就僅有在該相依值變動時才會觸發 Effect,但在開發過程中的 Effect 邏輯會時常變動,往往會忘記將 Effect 中使用到的相依值 (e.g. props, state) 列入相依陣列中,而造成 Effect 未適時地被觸發;因此官方建議使用 exhaustive-deps 規則作為我們 eslint-plugin-react-hooks 的一部分,當不正確地指定相依時,它會發出警告,並提出修改建議。

.eslintrc.js

module.exports = {
  
  /* ..略.. */

  plugins: [
    'react',
    'react-hooks' // rules for hooks (eslint-plugin-react-hooks)
  ],
  rules: {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }

}

 

設置後馬上發現缺漏了一個 onTimeUp 相依。

 

 

使用 useCallback 避免不必要的渲染


由於 CountDownTimer 子組件 useEffect 相依 onTimeUp 事件,若父組件有渲染時 (例如有其他輸入框 state 改變造成畫面變動) 會變動 handleTimeup 產生新實體而觸發子組件的 useEffect 動作,造成多餘或錯誤的執行,因此需要使用 useCallback 來處理 handleTimeup 事件,並設定相依值陣列,而以此例來說僅顯示固定文字,因此 useCallback 第二個參數傳入空陣列即可。

 

 

完整測試代碼


最後提供以下完整的測試代碼,建議可以透過修改練習了解各 API 使用時機,會有更深刻的體認。

Practice.js

import React, { useState, useCallback } from 'react'
import CountDownTimer from './CountDownTimer'

const Practice = () => {
  const [seconds, setSeconds] = useState(10)

  // useCallback 會回傳該 callback 的 memoized 版本,它僅在依賴改變時才會更新
  // 當傳遞 callback 到已經最佳化的 child component 時非常有用,這些 child component 依賴於引用相等性來防止不必要的 render
  const handleTimeup = useCallback(
    () => {
      console.log('time up!!')
    },
    []
  )

  return (
    <>
      <div>
        請輸入倒數秒數
        <input
          value={seconds}
          type='number'
          onChange={e => setSeconds(Number(e.target.value) || 0)}
        />
      </div>

      <CountDownTimer
        seconds={seconds}
        onTimeUp={handleTimeup}
      />

    </>
  )
}

export default Practice

 

CountDownTimer.js


import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'

const CountDownTimer = ({ seconds, onTimeUp }) => {
  const [remainSecond, setRemainSecond] = useState(0)

  // effect
  useEffect(() => {
    const countDownSecond = seconds

    // 產生 Timer
    console.log(`[timer] == start count down ${countDownSecond}s  ==`)
    const startTime = Date.now()
    const countDownTimer = setInterval(() => {
      // 計算剩餘秒數
      const pastSeconds = parseInt((Date.now() - startTime) / 1000)
      const remain = (countDownSecond - pastSeconds)
      setRemainSecond(remain < 0 ? 0 : remain)
      console.log('[timer] count down: ', remain)

      // 檢查是否結束
      if (remain <= 0) {
        clearInterval(countDownTimer)
        console.log(`[timer] == stop count down ${countDownSecond}s  ==`)
        onTimeUp() // 透過 prop 通知外部時間已到
      }
    }, 1000)

    return () => {
      // 清除 Timer
      clearInterval(countDownTimer)
      console.log(`[timer] == stop count down ${countDownSecond}s  ==`)
    }
  }, [onTimeUp, seconds]) // 相依 prop / state 值的 Effect

  return (
    <div className='tp-count-down-timer'>
      <div className='tp-count-down-timer__time'>
        {new Date(remainSecond * 1000).toISOString().substr(11, 8)}
      </div>
    </div>
  )
}

CountDownTimer.propTypes = {
  onTimeUp: PropTypes.func,
  seconds: PropTypes.number
}

export default CountDownTimer

 

 

參考資訊


使用 Effect Hook

useEffect 的完整指南

 

 


希望此篇文章可以幫助到需要的人

若內容有誤或有其他建議請不吝留言給筆者喔 !