Kalan's Blog

Kalan 頭像照片,在淡水拍攝,淺藍背景

四零二曜日電子報上線啦!訂閱訂起來

本部落主要是關於前端、軟體開發以及我在日本的生活,也會寫一些對時事的觀察和雜感
本部落格支援 RSS feed(全文章內容),可點擊下方 RSS 連結或透過第三方服務設定。若技術文章裡有程式碼語法等特殊樣式,仍建議至原網站瀏覽以獲得最佳體驗。

目前主題 亮色

我會把一些不成文的筆記或是最近的生活雜感放在短筆記,如果有興趣的話可以來看看唷!

useMemo 的幾個使用場景

useMemo 的幾個使用場景

在前端應用當中,很常會遇到一個情形是在畫面上顯示的值是透過其他的值複合計算而成;或是先經過計算再放入畫面當中。舉例來說:

  • 時間:將秒轉換為 xx:xx 分秒的形式顯示
  • 如果勾選篩選器選項,則資料會先經過處理再顯示

當作 computed prop 使用

我們可以這樣子寫:

const FormatTime = (ts) => {
  const formattedTime = `${Math.floor(ts/60)}:${ts%60}`
  return <time>{formattedTime}</time>
}

這樣寫其實沒有太大問題,不過像這種「先經過某種計算再顯示」的場景,我喜歡用 useMemo 近一步表達意圖:

const FormatTime = (ts) => {
  const formattedTime = useMemo(() => {
    return `${Math.floor(ts/60)}:${ts%60}`
  }, [ts])
  
  return <time>{formattedTime}</time>
}

從時間格式這個例子當中,兩者的差異並不大,因為原本的寫法已經相當簡潔,計算量也不大,使用 useMemo 看起來很像過度優化。不過在實際場景當中,當值的依賴越來越多,透過 useMemo 可以讓開發者在函數裡面做計算,避免過度使用 ternary 造成的混肴,其他開發者看到 useMemo 時也可以快速認知到「哦這裡的計算會依賴這幾個變數,然後變出另一個變數」。

或許有人會反駁,那跟用函數包裝起來有什麼不一樣?舉例來說:

const Component = (ts) => {
  const computed = calculateMyProp(ts)
  return <div>...</div>
}

我的看法是,除非這個計算是非常通用的函數,不然把計算包成函數充其量也只是在帳面上減少了元件的行數,開發者在處理時還是需要跳到函數內部的實作看看做了什麼事,那麼還不如直接用 useMemo 把實作寫在裡頭。

運算依賴於數個狀態

另外一種使用情景是資料依賴於其他篩選器,像是:

const MyComponent = ({ data }) => {
  const [filtered, setFiltered] = useState(false)
  
  return <div>
    <button onClick={() => setFiltered(s => !s)}>toggle filtered</button>
    {data.filter(d => filtered ? d.favorite : true).map(...)}
  </div>
}

當使用者按下按鈕時會改變 filtered,當 filtered 為 true 時會篩選 favorite 的資料。這樣寫有一個缺點是資料的處理邏輯寫在 jsx 裡面,當邏輯變得複雜時會較難維護,因此我們可以拆出來為單一變數:

const MyComponent = ({ data }) => {
  const [filtered, setFiltered] = useState(false)
+ const filteredData = data.filter(d => filtered ? d.favorite : true)
  return <div>
    <button onClick={() => setFiltered(s => !s)}>toggle filtered</button>
+   {filterData.map(...)}
  </div>
}

這樣子在 jsx 裡頭的表達式變得更簡單了,但 filteredData 的變數宣告裡的實作看起來也有點不直覺,在本案例當中這個條件式還算簡單,但就如同剛剛所說的,當依賴變得越來越多時,寫起來也就會變得越來越複雜。

這個時候我會把計算丟到 useMemo 裡頭:

const MyComponent = ({ data }) => {
  const [filtered, setFiltered] = useState(false)
  const [sorted, setSorted] = useState(false)
  const filteredData = useMemo(() => {
    if (filtered) {
      return data.filter(d => d.favorite)      
    }

    return data
  }, [data.length, filtered])
  return <div>
    <button onClick={() => setFiltered(s => !s)}>toggle filtered</button>
  	{filterData.map(...)}
  </div>
}

假設今天加上另一個選項 sorted,那麼在 useMemo 當中只要把對應的處理和依賴加上即可:

const MyComponent = ({ data }) => {
  const [filtered, setFiltered] = useState(false)
  const [sorted, setSorted] = useState(false)
  const filteredData = useMemo(() => {
    const origin = [...data]
    if (filtered) {
      return origin.filter(d => d.favorite)      
    }

    if (sorted) {
      return data.sort()
    }

    return origin
  }, [data.length, filtered, sorted]) // 這邊假設 data 的變化只發生在長度有變化時
  return <div>
    <button onClick={() => setFiltered(s => !s)}>toggle filtered</button>
    {filterData.map(...)}
  </div>
}

這樣寫的好處在於依賴的宣告變得相當清晰,開發者可以一眼看出這個資料依賴於哪些變數。

derived state

還有一個使用場景是「derive state」,意思是狀態本身仰賴於 props 變化的狀態。我們從上面的例子改寫:

const MyComponent = ({ data }) => {
  const [query, setQuery] = useState(value)
  const [filteredData, setFiltered] = useState(data.filter(d => query ? d.includes(query) : true))
  return <>
    <input onChange={e => setQuery(e.target.value)} value={query} />
  	{filteredData.map(...)}
  </>
}

我們希望每次 query 有更新時,都會順便更新到元件狀態當中。然而這樣子做有兩個問題:

  • 每次 data 有變化時,因為還要再更新一次狀態,多了一次不必要的更新
  • 輸入值同時仰賴於 prop 與 state 造成 single source of truth 無法維持。

其中對開發最危險的事情在於 single source of truth 無法維持這件事,因為對開發者來說相當不直覺且非常難以 debug。

除了剛剛提到的兩個問題之外,其實這樣的寫法並不正確,原因在於 useState(value) 的參數定義為初始值,也就是儘管 value 有變化了也不會改變 state。這會導致之後就算 dataquery 更新了,filteredData 仍然還是第一次渲染時的值。

因為 useState 的參數只有在第一次渲染時才會生效。正確(但不推薦)的寫法應該為:

/* 不推薦此寫法 */
const MyComponent = ({ data }) => {
  const [query, setQuery] = useState(value)
  const [filteredData, setFiltered] = useState(data.filter(d => query ? d.includes(query) : true))
+ useEffect(() => { data.filter(d => d.includes(query)) }, [data, query])
  return <>
    <input onChange={e => setQuery(e.target.value)} value={query} />
  	{filteredData.map(...)}
  </>
}

當你宣告了一個 state 且這個 state 依賴於某個 props 時,大部分的情況下都可以改寫成 useMemo 。以這個範例來說,其實就跟剛剛的例子差不多,可以用 useMemo 改寫成:

const MyComponent = ({ data }) => {
  const [query, setQuery] = useState(value)
  const filteredData = useMemo(() => {
    if (query) {
      return data.filter(d => d.includes(query))
    }
    
    return data
  }, [data, query])
	
  return <>
    <input onChange={e => setQuery(e.target.value)} value={query} />
  	{filteredData.map(...)}
  </>
}

總結

其實從以上幾個例子我們歸納出幾點:

  • computed props 可以用 useMemo 幫助其他開發者減低閱讀程式碼的認知負擔
    • 在運算量較大的情況下也可以節省每次渲染都要重新運算的成本
  • 使用 useState(props) 時需要特別小心
  • derived state 在大部分情況下可以搭配 useMemo 解決

另外,對我來說 useMemo 的一大優勢就是幫助程式碼表達意圖,雖然看起來有點 overhead(事實上也真的是XD,其他框架都沒在跟你 memo 的),但用一點效能犧牲換取易讀性還是相當值得的。

在 React beta 的文件上有關於是否要到處使用 useMemo 的討論,個人覺得蠻值得參考的。

上一篇

從 prismjs 改為 shiki

下一篇

如何判斷兩個網域的擁有者是否相同?

如果覺得這篇文章對你有幫助的話,可以考慮到下面的連結請我喝一杯 ☕️ 可以讓我平凡的一天變得閃閃發光 ✨

Buy me a coffee