選擇 React Hook 的原因 基礎範例介紹與優缺點分析

me
林彥成
2019-04-12 | 6 min.
文章目錄
  1. 1. React Hooks 介紹
  2. 2. useState 的概念與理解
    1. 2.1. useState 純值與物件比較
    2. 2.2. useState(Primitive) vs useState(Object)
      1. 2.2.1. JavaScript Reference
    3. 2.3. 讓我想起 Module Pattern 的 useState
  3. 3. useEffect 的概念與理解
    1. 3.1. useEffect has a missing dependency
    2. 3.2. update on a unmounted component
  4. 4. hook 效能優化
    1. 4.1. React 18 useState Automatic Batching
  5. 5. hooks 優點與缺點
    1. 5.1. 壓縮 (minified)

React Hooks 介紹

React 提供 hooks 的設計讓副作用處理和外部功能 hook 進 functional component,相對 class 的處理能提供更簡單的寫法,像 redux 的 useSelector 就取代 connect 和 mapStateToProps,接下來主要會用 React hooks 的 useState、useEffect 範例來和 class 寫法做比較。

React 元件有 class 和 function 兩種形式,設定上分兩種:

  • class: 物件型態,透過 this.setState() 直接設定元件中的狀態
  • function: 值或是物件型態,透過 const [state, setState] = useState(); 回傳的 setState()
1
2
3
4
5
6
7
8
9
10
11
// 原來的寫法
const mapStateToProps = (state) => {
return {
counter: state.myState,
};
};

export default connect(mapStateToProps)(App);

// hook
const counter = useSelector((state) => state.myState);

useState 的概念與理解

為在剛開始學習這個新的寫法時,直覺的會讓人想到利用 js closure 衍生出來的 Module Pattern,畢竟 hooks 並沒有提出太多新的概念,僅僅是利用會 return jsx 的 function 搭配特製的函式來取得保存的狀態做為元件的寫法。

useState 純值與物件比較

狀態的型態分成 Primitive 和 Object 兩種:

  • Primitive type: by value
  • Object type: by reference

由於 React 元件的渲染或更新是依照狀態,useState 的使用上考量如下:

  • Primitive 相對單純且好實做,適合新手
  • 單層的物件在 shallow equality 的機制較容易理解
  • 避免多層巢狀的的物件結構,建議將物件正規化 (normalize)

useState(Primitive) vs useState(Object)

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

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

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

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

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

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

JavaScript Reference

由於物件型態是 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 實作的判斷方式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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 是為了製作函式庫,所以有一些需要存的狀態還有需要讓外部叫用的功能,運用 function 會產生 closure 的特性,我們就可以區分出 private, public 的變數以及函式。

底下示範了一個運用 IIFE (Immediately Invoked Function Expression) 來實作的計數器模組,使用 IIFE 的目的是希望一定義就執行,這就是最基本的 Module pattern 了,可以發現 count 就是我們 private 的變數,指能夠透過回傳的 public function 來進行操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const moduleCounter = (function(){
let count = 0;
return {
getValue(): function() {
return count;
},
increment: function() {
return ++count;
},
reset: function() {
console.log('reset:' + count);
count = 0;
}
};
}());

雖然沒有真的去看原始碼,但感覺差異在 IIFE 是立即就定義,另外一個是 JSX 由 react 編譯時才進行相關操作及定義,其中 useState 感覺就是要把 private 變數也就是狀態放進 closure 的寫法,狀態則是 react 用來操作 Dom 變化一個重要的依據。最後當 JSX 編譯完後,我們就可以透過類似叫用 public function 的方法去改變 private 變數。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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 的概念與理解

在 hooks 中,是用 useEffect(didUpdateCallback, [條件]) 來運用元件週期的,最重要的是第二個參數可以加上條件這個部分,底下是官方的範例,中括號陣列裡面的就是觸發條件,如果使用空陣列,就相當於 componentDidMount 的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// componentDidMount
useEffect(() => {}, []);

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

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

將元件週期改寫的實際案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 裡面,元件原本由狀態改變來驅動的特性一樣沒有改變。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

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 has a missing dependency

只要 useEffect 中有使用到的變數但沒被列出來的就會出現這樣的警告,這也是為了提醒大家避免在同個 useEffect 中同時操作兩件事情,舉例來說大家可以想想如果 componentDidMount 想要 fetchData 又想要 updateCloumn 底下的寫法會有什麼問題?

1
2
3
4
useEffect(() => {
fetchData();
updateCloumn();
}, []);

update on a unmounted component

會需要在 return cleanup function 的時候去處理。

  1. useEffect 中去打 API,但是資料回來時使用者已經切換到其他畫面
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 也是同樣的概念
1
2
3
4
5
6
7
useEffect(() => {
let timerId = setTimeout(() => {
timerId = null;
setList([]);
}, 1000);
return () => clearTimeout(timerId);
});
  1. websocket 由於跟 API 的概念不太一樣,做法就是直接關掉
1
2
3
4
5
6
useEffect(() => {
const webSocket = new WebSocket(url);

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

hook 效能優化

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

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

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

1
2
3
4
5
6
7
8
9
10
11
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

1
2
3
4
setStateOnce({
list: data,
isLoading: false,
});

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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 來分開。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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 優點與缺點

優點

  • 較接近原生的 js 寫法,對於剛開始接觸的人有好處,且不需要懂 ES6 也可以寫
  • 減少了解太過多餘的元件週期,只要控制好 useEffect 即可
  • 用相對簡單的寫法解決複雜問題,舉例來說 redux 提供的 useSelector 介面
  • 程式碼較容易被壓縮和最佳化

缺點

  • useEffect 把三個元件狀態合在一起,寫法太過簡單所以使用時要注意,如果沒有加上限制就容易造成不停的觸發
  • 盡量避免在 function 中寫到 new 或是可能沒有防呆事件的 listener,因為在每次更新畫面的時候都會重做一次
  • 尚沒有包含 getSnapshotBeforeUpdatecomponentDidCatch 這兩個元件周期

壓縮 (minified)

React 的團隊在推出 hook 的時候其實也有提到 hook 只是純 function,相對 class 較容易做程式碼的 minified

However, we found that class components can encourage unintentional patterns that make these optimizations fall back to a slower path. Classes present issues for today’s tools, too. For example, classes don’t minify very well, and they make hot reloading flaky and unreliable.


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

share