為什麼選擇 React Hooks 基礎範例解析與優缺點全面分析

me
林彥成
2024-08-17 | 5 min.
文章目錄
  1. 1. React Hooks 介紹
  2. 2. React 元件的兩種形式
  3. 3. useState 的概念與理解
    1. 3.1. useState 純值與物件比較
    2. 3.2. useState(Primitive) vs useState(Object)
    3. 3.3. JavaScript 物件比較
    4. 3.4. Module Pattern 與 useState 的比較
  4. 4. useEffect 的概念與理解
    1. 4.1. 使用 useEffect 的實際案例
    2. 4.2. useEffect 的常見問題與解決方案
  5. 5. hook 效能優化
    1. 5.1. React 18 useState Automatic Batching
  6. 6. hooks 優點與缺點
    1. 6.1. 優點
    2. 6.2. 缺點
    3. 6.3. 壓縮 (minified)

React Hooks 介紹

React 引用的 Hooks 設計,讓副作用處理和外部功能更輕鬆地進入函式元件,相比於類別元件的寫法,提供了更簡單的解決方案。舉例來說,Redux 的 useSelector 可以取代 connectmapStateToProps。接下來,我們將通過 useStateuseEffect 兩個 Hooks 的範例,來對比其與類別寫法的不同。

React 元件的兩種形式

React 元件有兩種形式:類別(Class)和函式(Function)。這兩種形式在狀態管理上有所不同:

  • Class 元件:通過 this.setState() 來直接設定元件中的狀態。
  • Function 元件:使用 const [state, setState] = useState() 來回傳的 setState() 函數進行狀態更新。
// 使用 connect 的寫法
const mapStateToProps = (state) => ({
  counter: state.myState,
});

export default connect(mapStateToProps)(App);

// 使用 hook 的寫法
const counter = useSelector((state) => state.myState);

useState 的概念與理解

useState 是一個讓函式元件擁有狀態的 Hook。它的運作方式類似於 JavaScript 的閉包模式,讓你可以在函式中管理狀態,而不是使用類別元件中的 this.state

useState 純值與物件比較

狀態可以是基本型別(Primitive)或物件型別(Object):

  • Primitive type:通過值傳遞(by value)。
  • Object type:通過引用傳遞(by reference)。

在 React 中,使用 useState 時的考量包括:

  • Primitive:比較單純,適合新手使用。
  • Object:對於多層物件結構,建議將物件進行正規化 (normalize)以簡化比較和更新。

useState(Primitive) vs useState(Object)

在使用 Hook 時,可以採用以下兩種策略來處理複雜狀態:

  • useState(Primitive):宣告多個 useState,每個狀態變數單獨管理。
  • useState(Object):使用一個 useState,將多個狀態存儲在一個物件中。

由於 class 的狀態一定是物件的型態,對於 Object 型態的狀態會有比較好的處理,舉例來說像是物件的合併機制。

// class setState 實際上做的事情
this.setState({ ...state, value: 0 });

相對於 hook 在設定上實際上沒有針對物件做預設的物件合併機制。

//  setHookState 其實就是單純設定
const [state, setHookState] = useState(0);
setHookState();

JavaScript 物件比較

由於物件型態是 by reference ,使用上會有需要考量相等的比較方式。

  • 這個例子來說物件並不相等 {display: "flex"} === {display: "flex"} // false

在 JavaScrpit 中有三種比較方式

  • strict equality operator ===
  • loose equality operator ==
  • Object.is()

在 React 中會用到的方式是 shallow object equality check

  • Shallow Equality: 會迭代物件物件中的 keys 如果都是 === 就當他相同
    • React.PureComponent 的 shouldComponentUpdate 實作的判斷方式

物件型態是按引用比較的,即 {display: "flex"} === {display: "flex"} // false
React 使用淺層比較來判斷物件是否相同。

function shallowEqual(object1, object2) {
  const keys1 = Object.keys(object1);
  const keys2 = Object.keys(object2);
  if (keys1.length !== keys2.length) {
    return false;
  }
  for (let key of keys1) {
    if (object1[key] !== object2[key]) {
      return false;
    }
  }
  return true;
}

const hero1 = {
  name: "Batman",
  address: {
    city: "Gotham",
  },
};
const hero2 = {
  name: "Batman",
  address: {
    city: "Gotham",
  },
};
// 兩層
shallowEqual(hero1, hero2); // => false

Module Pattern 與 useState 的比較

Module Pattern 使用 IIFE(Immediately Invoked Function Expression)來創建私有變數和公共函式,類似於 useState 提供的狀態管理方式。

const moduleCounter = (function () {
  let count = 0;
  return {
    getValue: function () {
      return count;
    },
    increment: function () {
      return ++count;
    },
    reset: function () {
      console.log("reset:" + count);
      count = 0;
    },
  };
})();

這與 useState 類似,都是利用閉包來保存狀態和操作函式。

function Counter() {
  const [count, setCount] = useState(0);

  function increment() {
    setCount(count + 1);
  }

  function reset() {
    setCount(0);
  }

  function getValue() {
    return count;
  }

  return (
    <div>
      <button type="button" onClick={increment}>
        add
      </button>
      <button type="button" onClick={reset}>
        reset
      </button>
      <span>{getValue()}</span>
    </div>
  );
}

useEffect 的概念與理解

useEffect 用來處理副作用,相當於類別元件中的 componentDidMountcomponentDidUpdatecomponentWillUnmount。它接受兩個參數:副作用函式和依賴陣列。

// componentDidMount
useEffect(() => {}, []);

// componentDidUpdate
useEffect(() => {
  const subscription = props.source.subscribe();
}, [props.source]);

useEffect(() => {
  const subscription = props.source.subscribe();
  return () => {
    // componentWillUnMount
    subscription.unsubscribe();
  };
}, [props.source]);

使用 useEffect 的實際案例

例如,從 API 取得資料:

async componentDidMount() {
    const response = await axios.get(`https://jsonplaceholder.typicode.com/users`)
    this.setState({ users: response.data })
};

async componentDidUpdate(prevProps) {
  if (prevProps.resource !== this.props.resource) {
    const response = await axios.get(`https://jsonplaceholder.typicode.com/users`)
    this.setState({ users: response.data })
  }
};

const fetchUsers = async () => {
  const response = await axios.get(`https://jsonplaceholder.typicode.com/users`);

  setUsers(response.data);
};

useEffect( () => { fetchUsers(users) }, [ users ] );

由於 React 是 component-based 的一個函式庫,所以元件本身的定義和規範就蠻重要的,其中比較特殊的是元件在實際運用上會有一些生命週期,大致上我們平常會使用到的就是 componentDidMountcomponentDidUpdate,剩下可能會用到但比較少的是 componentWillUnmount

由於是寫在 function 中,所以可以想像整個 function 的內容都是原來寫法中 render() 裡的內容,差別在把 constructor 中的狀態用其他的方法寫在這個 function 裡面,元件原本由狀態改變來驅動的特性一樣沒有改變。

更好的寫法則是再抽出來,就會變下面這樣,更清楚也更好測試,也可以重複的去使用相關邏輯。


function useCounter(initial) {
  const [count, setCount] = useState(initial);
  const increment = () => setCount(count + 1);
  const reset = () => setCount(0);
  return { reset, count, increment };
}

function Counter() {
    const [reset, count, increment] = useCounter(0);

    return (
    <div>
        <button type="button" onClick={increment}>add</button>
        <button type="button" onClick={reset}>reset</button>
        <span>{count)}</span>
    </div>
    );
}

useEffect 的常見問題與解決方案

  1. 依賴缺失警告 (useEffect has a missing dependency):確保所有在 useEffect 中使用的變數都包含在依賴陣列中。
  2. 元件卸載後更新 (update on a unmounted component):在清理函式中處理 API 請求或計時器的中止。
useEffect(() => {
  fetchData();
  updateCloumn();
}, []);
  1. useEffect 中去打 API,但是資料回來時使用者已經切換到其他畫面
useEffect(() => {
  let controller = new AbortController();
  (async () => {
    try {
      const response = await fetch(
        `https://jsonplaceholder.typicode.com/posts`,
        {
          signal: controller.signal,
        }
      );
      setList(await response.json());
      controller = null;
    } catch (e) {
      // Handle the error
    }
  })();
  // aborts the request when the component umounts
  return () => controller?.abort();
});
  1. timer function 也是同樣的概念
useEffect(() => {
  let timerId = setTimeout(() => {
    timerId = null;
    setList([]);
  }, 1000);
  return () => clearTimeout(timerId);
});
  1. websocket 由於跟 API 的概念不太一樣,做法就是直接關掉
useEffect(() => {
  const webSocket = new WebSocket(url);

  return () => webSocket.close();
});

hook 效能優化

在撰寫 hook 且狀態較複雜時,會有底下兩種策略

  • useState(Primitive): 宣告多個 useState 搭配多個值
  • useState(Object): 宣告一個 useState 搭配一個物件中含有多個值

在 React 18 版之前,宣告多個 State 的寫法如下:

const [isLoading, setLoading] = useState(true);
const [list, setlist] = useState([]);
useEffect(async () => {
  fetch("/list")
    .then((res) => res.json())
    .then((data) => {
      setlist(data);
      setLoading(false);
    });
}, []);

這樣的寫法會因為兩個 setState 發生在非同步完成之後,所以 React 並不會 batch 他們而造成兩次的畫面渲染。

所以在 React 還沒協助處理之前,最簡單的方式就是透過物件一次設定,或是使用 unstable_batchedUpdates

setStateOnce({
  list: data,
  isLoading: false,
});

有篇 Github 上的討論蠻精彩的,有興趣可以參考如下:
https://github.com/reactwg/react-18/discussions/21

內文中也有提供範例,大家可以進去試用看看兩者差異。

在剛開始寫 hook 的時候,可能會寫像是以下的程式碼。下面 console.log 會一直印,代表每次都重新 render,但 Increment 這個其實沒有改變,該怎麼避免?

import { useState } from "react";

const CountDisplay = ({ count }) => <h3>Count: {count}</h3>;

const CountButton = ({ updateCount }) => {
  // 下面這行會一直印,代表每次都重新 render,但 Increment 這個其實沒有改變,該怎麼避免
  console.log("=> CountButton render", Date.now());

  return <button onClick={() => updateCount()}>Increment</button>;
};

const App = () => {
  const [count, setCount] = useState(0);

  const updateCount = () => setCount((c) => c + 1);

  return (
    <>
      <CountButton updateCount={updateCount} />
      <CountDisplay count={count} />
    </>
  );
};

export default App;

除了基本的 hook 以外,常用的還有以下:

  • useState: 加入元件狀態,但這裡的 setState 不會幫我們自動合併物件型態的狀態,需要用 callback 方式寫並且自行合併
  • useReducer: 可以用來處理物件型態的狀態
  • useEffect: 處理 side effect,取代 componentDidMount, componentDidUpdate, componentWillUnmount
  • useCallback: 當 function 需要在 useEffect 中被使用但又不想加入觸發條件
  • useMemo: 把較高成本計算記起來
  • useRef: 取得參考用的 object

剛剛解決方案的小提示

  1. useMemo: 可以記住函式運算值
  2. useCallBack: 可以記住函式

React 18 useState Automatic Batching

前面提到的問題將在 React 18 之後獲得解決,即使是非同步 callback 中的 setState 也會自動 batch 而不會造成兩次的渲染,還是想分開反而要自己使用 ReactDOM.flushSync 來分開。

function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    setCount((c) => c + 1); // Does not re-render yet
    setFlag((f) => !f); // Does not re-render yet
    // React will only re-render once at the end (that's batching!)
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    fetchSomething().then(() => {
      // React 17 and earlier does NOT batch these because
      // they run *after* the event in a callback, not *during* it
      setCount((c) => c + 1); // Causes a re-render
      setFlag((f) => !f); // Causes a re-render
    });
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

hooks 優點與缺點

優點

  • 更接近原生 JavaScript,對於初學者友好,且不需要理解 ES6。
  • 減少複雜的元件週期管理,只需控制 useEffect
  • 提供簡化的解決方案,如 Redux 的 useSelector
  • 更容易進行程式碼壓縮和最佳化。

缺點

  • useEffect 可能會將多個副作用合併,需謹慎管理依賴。
  • 避免在函式中使用 new 或未處理的事件監聽器,避免每次渲染時都重做一次。
  • 尚未涵蓋 getSnapshotBeforeUpdatecomponentDidCatch 這兩個生命周期方法。

壓縮 (minified)

React 團隊指出,Hook 是純函式,這使得程式碼壓縮(minified)變得更簡單,相對於類別元件來說更易於進行壓縮和優化。

注意: 詳細閱讀 React Hooks 文件 以了解更多信息。


喜歡這篇文章,請幫忙拍拍手喔 🤣


share