iT邦幫忙

2023 iThome 鐵人賽

DAY 16
0
Modern Web

從Vue學React!不只要會用,還要真的懂~系列 第 16

【Day 16】不要再重新計算啦!把計算複雜的值緩存起來 - computed & useMemo

  • 分享至 

  • xImage
  •  

需要一個經歷複雜計算所產出的值時,大家通常都會怎麼做呢?是用函式把這個計算過程包裝起來,並回傳出這個計算後的值?還是透過其他的方式達成呢?其實在Vue和React都有除了單純用函式包裝起來的更好的方式可以使用。今天就來透過一些例子來看看這個部分在Vue和React可以怎麼做吧!

當你需要一個經複雜計算產生的值,你會?

這裡假設一個情境,現在我們有一個商品購物車,裡面有五樣商品,會需要把商品別的總價計算出來,可以怎麼做呢?

以下先看看Vue和React的透過函式進行計算的寫法,這裡有兩段完整的程式碼。
Vue

<template>
  <div class="app">
    <h2>Computed vs Non-Computed (Shopping Cart)</h2>
    <div v-for="(product, index) in cart" :key="product.id">
      <p>
        {{ product.name }}: ${{ product.price }} || <span>Amount: {{ product.amount }}</span>
        <!-- 更新cart商品數量的按鈕 -->
        <button class="add-button" @click="handleAddAmount(product.id)">+</button>
        <button class="reduce-button" @click="handleReduceAmount(product.id)">-</button>
      </p>
    </div>
    <p class="total">Total Price: {{ calculateTotalPriceByFunction() }}</p>
    <p>isGuest: {{ isGuest }}</p>
    <button @click="handleStateChange">change boolean state</button>
  </div>
</template>

<script setup>
import { ref } from 'vue';
const cart = ref([
  { id: 1, name: 'Product 1', price: 100, amount: 1 },
  { id: 2, name: 'Product 2', price: 250, amount: 1 },
  { id: 3, name: 'Product 3', price: 190, amount: 1 },
]);

// 中間略

// 計算總金額的部分
const calculateTotalPriceByFunction = () => {
  console.log('function update');
  return cart.value.reduce((total, product) => total + product.price * product.amount, 0);
}
</script>

React

import { useState } from 'react';

function ShoppingCart() {
  const [cart, setCart] = useState([
    { id: 1, name: 'Product 1', price: 100, amount: 1 },
    { id: 2, name: 'Product 2', price: 250, amount: 1 },
    { id: 3, name: 'Product 3', price: 190, amount: 1 },
  ]);

  const [isGuest, setIsGuest] = useState(false);
  
  // 中間略

  // 計算總金額的部分
  const calculateTotalPriceByFunction = () => {
    console.log('function update');
    return cart.reduce((total, product) => total + product.price * product.amount, 0);
  };

  const totalPrice = calculateTotalPriceByFunction(cart);

  return (
    <div className="App">
      <h2>memo vs Non-memo (Shopping Cart)</h2>
      {cart.map((product) => (
        <div key={product.id}>
          <p>
            {product.name}: {product.price} || <span>Amount: {product.amount}</span>
            <button className='add-button' onClick={() => handleAddAmount(product.id)}>+</button>
            <button className='reduce-button' onClick={() => handleReduceAmount(product.id)}>-</button>
          </p>
        </div>
      ))}
      <p className="total">Total Price: ${calculateTotalPriceByFunction()}</p>
      <p>isGuest: {isGuest ? 'true' : 'false'}</p>
      <button onClick={handleStateChange}>change boolean state</button>
    </div>
  );
}

export default ShoppingCart;

最快、最直覺的方式當然是寫一個函式進行五樣商品的加總,對吧?
也就是下面這兩段寫法。

Vue的寫法

const calculateTotalPriceByFunction = () => {
  return cart.value.reduce((total, product) => total + product.price * product.amount, 0);
}

https://i.imgur.com/07T0aT8.gif

React的寫法

const calculateTotalPriceByFunction = () => {
  return cart.reduce((total, product) => total + product.price * product.amount, 0);
};

另外,除了寫成函式外,因為React有set state時,確認到state有變動,就會重新呼叫component function的特性在,所以也可以不寫成函式,只單純寫一個透過計算式賦值的變數。

const calculateTotalPrice = cart.reduce((total, product) => total + product.price * product.amount, 0);

https://i.imgur.com/rRQlGX0.gif

這樣的確達到我們要的目的「加總金額」了,但是如果仔細看的話,卻會發現一個問題,那就是「只要畫面重新渲染,就算相依的值沒有變動,還是會再重新計算一次」。這種感覺就有點像是你請店員幫你算你拿到結帳櫃檯的商品總共多少錢,他一個個用他超強的心算算完後,每當有其他客人來問問題,他就忘記剛剛算了多少錢,又要再重新計算一次。如果只買了兩樣商品,計算過程不複雜,重新計算可能不太花時間,但是如果是好幾樣商品,還需要計算不同的優惠折數,那就會花費很多時間。把這個比喻的情境套用回網頁的話,就是假如每次渲染都要重新計算一次,就有可能會影響效能。

既然忘記就需要重算,那就把計算結果暫存起來吧!

如果想要避免因畫面重新渲染而導致的不必要的重新計算,單純使用函式是無法辦到的,因為單純的函式在每次重新渲染時,都會再次被呼叫並重新計算,無法把計算好的值緩存起來,所以還是需要找一個可以把計算後的值保存下來的方式,這個時候Vue的computedReact的useMemo就可以派上用場了!

按照慣例,一樣先來看看Vue的computed吧!

Vue - computed

<template>
  <div class="app">
    <h2>Computed vs Non-Computed (Shopping Cart)</h2>
    <div v-for="(product, index) in cart" :key="product.id">
      <p>
        {{ product.name }}: ${{ product.price }} || <span>Amount: {{ product.amount }}</span>
        <!-- 更新cart商品數量的按鈕 -->
        <button class="add-button" @click="handleAddAmount(product.id)">+</button>
        <button class="reduce-button" @click="handleReduceAmount(product.id)">-</button>
      </p>
    </div>
    <p class="total">Computed Total Price: {{ computedTotalPrice }}</p>
    <p>isGuest: {{ isGuest }}</p>
    <button @click="handleStateChange">change boolean state</button>
  </div>
</template>

<script setup>
// 略...
const computedTotalPrice = computed(() => {
  console.log('computed update');
  return cart.value.reduce((total, product) => total + product.price * product.amount, 0)
});
</script>

https://i.imgur.com/BsFE01x.gif
改成computed的寫法後,只有相依的state有改動(也就是被使用於計算內容中的state),才會重新進行計算,如果是其他不相關的state,即使觸發重新渲染,也不會讓computed的值重新計算。

React - useMemo

接下來再看看React的useMemo的寫法!
這邊的寫法跟Vue有點不太一樣,除了寫出計算的邏輯外,需要在useMemo的第二個變數用陣列帶入相依的值,當陣列中的值有變動時,才會讓useMemo重新進行計算。

import { useMemo, useState } from 'react';

function ShoppingCart() {
  const [cart, setCart] = useState([
    { id: 1, name: 'Product 1', price: 100, amount: 1 },
    { id: 2, name: 'Product 2', price: 250, amount: 1 },
    { id: 3, name: 'Product 3', price: 190, amount: 1 },
  ]);

  // 略

  const memoTotalPrice = useMemo(() => {
    console.log('memo update');
    return cart.reduce((total, product) => total + product.price* product.amount, 0);
  }, [cart]);

  return (
    <div className="App">
      <h2>memo vs Non-memo (Shopping Cart)</h2>
      {cart.map((product) => (
        <div key={product.id}>
          <p>
            {product.name}: {product.price} || <span>Amount: {product.amount}</span>
            <button className='add-button' onClick={() => handleAddAmount(product.id)}>+</button>
            <button className='reduce-button' onClick={() => handleReduceAmount(product.id)}>-</button>
          </p>
        </div>
      ))}
      <p className="total">Computed Total Price: {memoTotalPrice}</p>
      <p>isGuest: {isGuest ? 'true' : 'false'}</p>
      <button onClick={handleStateChange}>change boolean state</button>
    </div>
  );
}

export default ShoppingCart;

https://i.imgur.com/JvVOuxe.gif
這裡可以看到改成這樣的寫法後,無關的state有變動時,並不會讓memo進行重新計算的動作。那是因為React會去比較上一次的相依值和這次的相依值是否有變動,這裡一樣是透過Object.is來比較,如果相依值一樣的話,就會直接返回之前已經計算好的值,不會再另外進行計算的動作。

React除了能把值緩存起來,還可以把元件緩存起來

React除了提供useMemo這個把值緩存的方法外,還有提供可以把整個元件緩存下來的方法,那就是react.memo
當一個頁面中是一個父元件內包含多個子元件時,在父元件進行set state的動作,觸發重新渲染時,子元件也會連帶地重新渲染,這樣的情況感覺好像不會有什麼問題,但是當父元件中的子元件數量很多,子元件只是單純因為父元件進行set state的操作而觸發重新渲染,就很容易造成一些效能的問題。想要解決這樣的問題,一樣可以用緩存的方式,使用react.memo把整個元件緩存起來。

一樣透過範例來看看有沒有把元件記起來的差異在哪裡,先看沒有特別用react.memo緩存起來的情境。

// 這是一個有使用到父層state的子元件
export default function ChildrenComponent({ parentState }) {
  console.log('child render');
  return (
    <div>
      {parentState}
    </div>
  )
}
// 這是使用到上面子元件的父元件
function App() {
 const [count, setCount] = useState(0);
 const [count1, setCount1] = useState(0);
 // const [parentState, setParentState] = useState('parent state');
 const addCount = () => {
  setCount(count + 1);
 }
  return (
    <div className="App">
      <ChildrenComponent parentState={count1} />
      <p>{count}</p>
      <button onClick={addCount}>add count</button>
    </div>
  );
}

export default App;

當我們點擊增加count的按鈕,改動到count這個state時,也會造成無關的子元件也一起重新渲染。

https://i.imgur.com/DHNHFpg.gif

但是如果我們今天有React.memo把這個子元件包裝起來時,就不會因為子元件沒有使用到的state有變動而重新渲染。

const ChildrenComponent = React.memo(({ parentState }) => {
  console.log('child render');
  return (
    <div>
      {parentState}
    </div>
  )
});

export default ChildrenComponent;

這樣調整後,增加count不會再觸發子元件的渲染。
https://i.imgur.com/lGSPUC9.gif

只有在有透過props傳入子元件中的parentState有變動的時候,才會觸發子元件的渲染。
https://i.imgur.com/MF9RiM3.gif

緩存計算結果,是效能優化解藥還是毒藥?

以前在寫Vue時,有聽到同事提醒避免使用computed,同事的說法是「因為computed反而會造成效能變差」,後來也有聽說一些人覺得使用React的useMemo反而會讓效能變差,因為它還要把值緩存起來,還有需要進行比較的動作。我自己是覺得使用上當然還是要自己去判斷當下的情況是否真的適合,如果計算內容不複雜,也許可以不讓React或Vue額外緩存一個值和進行state變動的比較的動作,但是實際使用後,是否反而對效能帶來負面影響,還是得實際地透過dev tools等工具去比較差異。另外,使用了之後,是不是在這個情況下,反而更好維護,或是延伸到對其它hooks的使用上,有正面地影響(例如: useEffec),適當地使用也才能對整體效能有更好的效果。不要一昧的使用,了解自己是要解決什麼問題,為何而用,才能真正達到自己希望的效果。

參考資料

useMemo
memo


上一篇
【Day 15】究竟是watch?還是生命週期API?處理副作用的useEffect
下一篇
【Day 17】想要避免多餘的渲染就用它?了解useCallback的最終目的
系列文
從Vue學React!不只要會用,還要真的懂~30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言