使用 React Hooks 重構 React Class Component 的改造筆記
30 Jan 2021React hooks 的導入解決的很多過去在使用 class-based component 的問題。
就像是陳近南的武功。
「一掌打出」
「不論人畜、蝦蟹、跳蚤都會化成飛灰」那樣的神 (  ̄ 3 ̄)y▂ξ
緣由、想要解決什麼問題
基本上就是 stateful logic 產生了一些問題。
- 重用性:對於很難抽離狀態邏輯(stateful logic)而難以重用。雖然現行的 HOC 或 render props 已可解決這個問題,但還是過於複雜,而且不好寫測試。
- class component 過於複雜、難以理解:class component 的 lifecycle method 往往像個便利商店般包了很多雜七雜八的事情,例如:在 componentDidMount 與 componentDidUpdate 加入讀取 API 來抓資料的 action,而 componentDidMount 可能還包含一些事件綁定,最後還會在 componentWillUnmount 把這些事件綁定解除掉…(是不是跟小七店員一樣包山包海什麼都會什麼都做)…我們都知道每個 function 只要做一件事情就好,包山包海在這裡就不被鼓勵了,畢竟缺點很多-很難懂也很難維護、容易有 bug、很難寫測試。
- 關於狀態管理…通常會找個狀態管理的 library 一同使用,但這樣就與狀態管理的 library 綁死了,不是那麼容易想換就換。
- class component 容易造成開發者與工具的誤解:像是 hooks 是將 props 當成參數傳入,而不是像 React class component 般用 this.props 取得,更符合一般寫 function 的習慣,就不是用 React 的另一套規則,對不熟悉 react 的工程師的學習與適應成本降低,也能減少誤會和冗長的撰寫格式。還有,我們寫了很多 bind this,除了冗長之外,「this」到底是什麼大概也沒人真的在乎。而對於打包和壓縮等工具來說難以好好優化,甚至 hot reload 上也常有錯誤。
- 過去我們常要思考元件要寫成 class-based component vs functional component,判斷的基準就是這個元件會不會需要有生命週期、保存自己的狀態和是不是資料都是從父元件帶入並且只是純粹做畫面的渲染而已。但若用 hooks 之後,一切都是 functional component,就不用事前做判別或是因需求變更導致需要將 functional component 重構成 class-based component 了。
改寫的 Tips
既然 hooks 可以解決這麼多問題,那要怎麼改寫呢?
在策略上
- 別急著改 hooks,先重構體質,像是拆分大的元件為小的元件。
- 新的元件用 hook 來寫,避免再度留下技術債。
- 若要重構 class 元件,建議 (1) 先寫測試,避免更動原先的邏輯;(2) 找小的、簡單、不重要的元件來著手,用來適應 hooks 的思維。
- 確定團隊已適應 hooks 的思維後,就可開始著手重構。同樣的,在重構前建議先寫測試,避免更動原先的邏輯。
在技術上
元件改寫
- 改寫 import 的成員:只要 import 要用到的 hooks 即可,例如稍後會提到的 useState、useEffect 等。
// 修改前
import React, { Component } from 'react';
// 修改後
import React, { useEffect } from 'react';
- 去除 class,改寫成 pure function 的語法。
// 修改前
class SampleComponent extends Component {
// 以下省略
}
// 修改後
const SampleComponent= () => {
// 以下省略
}
- 刪除 this,不管是 thie.state、this.props、還是各種 bind this。
- 改寫 state:使用 useState 來取代一開始初始設定的
this.state = { ... }
與 setState。過去在 setState 時往往會將多個 state 一同做設定,但在使用 useState 時一次只做一個 state 宣告會比較清楚明瞭、容易控制。這是因為 useState 不會自動做 object 的合併(可以想像成你丟什麼進去它就整包取代掉),所以要自己用 object spread 來處理,避免取代掉沒有要重新設定的狀態。若在重構時真的很難處理這一塊,改用 useReducer 即可,useReducer 是比較適合處理複雜的物件與邏輯的解法,點此看範例。
// 範例:以下是一個簡單的計數器,點擊按鈕即可把計數加 1,但只能設定 10 次
// 使用 useState 來取代 state 宣告與 setState
import React, { useState } from 'react';
const Count = ({ count, times }) => {
return (
<>
{count}
你還剩下 {times} 次!
</>
);
};
const Counter = ({ initValue }) => {
const [count, setCounter] = useState(0);
const [times, setTimes] = useState(10);
const handleClickIncrement = () => {
setCounter(count + 1);
setTimes(times - 1);
};
return (
<>
<Count count={count} times={times} />
<button disabled={!times} onClick={handleClickIncrement}>
click
</button>
</>
);
};
const App = () => {
return <Counter />;
};
export default App;
// 承上,改用 useReducer 來改寫複雜的多個的 useState 情況
import React, { useReducer } from 'react';
const initialState = {
count: 0,
times: 10
};
const reducer = (state, action) => {
switch (action) {
case 'SET_TIMES':
return {
...state,
times: state.times - 1
};
case 'SET_COUNT':
return {
...state,
count: state.count + 1
};
default:
return state;
}
};
const Count = ({ count, times }) => {
return (
<>
{count}
你還剩下 {times} 次!
</>
);
};
const Counter = ({ initValue }) => {
const [state, dispatch] = useReducer(reducer, initialState);
const { count, times } = state;
const handleClickIncrement = () => {
dispatch('SET_TIMES');
dispatch('SET_COUNT');
};
return (
<>
<Count count={count} times={times} />
<button disabled={!times} onClick={handleClickIncrement}>
click
</button>
</>
);
};
const App = () => {
return <Counter />;
};
export default App;
- 改寫 life cycle
- 使用 useEffect 並設定 dependency 來取代 componentDidMount 和 componentDidUpdate。另外,useEffect 內的 return 後接的 function 可設定 componentWillUnmount。
- 由於 useEffect 觸發時機點是在瀏覽器繪製完成後,而 useLayoutEffect 則是在瀏覽器繪製完成前,因此主要是看 UI 呈現的狀況而決定要用哪一個 hook。注意,雖然先前 componentWillMount 已經被廢棄了,但可用 useLayoutEffect 來取代 componentWillMount。
- 對於共用的 function,使用 useCallback 來記憶,便於之後作為 useEffect 的 dependency。
- 去除 render method,只要留下 return 即可。
// 範例:在元件渲染完成後,從 API 取得商店列表的資料並存到 redux 中
// 修改前
componentDidMount() {
const { fetchStoreList } = this.props;
fetchStoreList();
}
// 修改後
useEffect(() => {
// 備註,在重構過程中 redux 無法一下子被移除,因此會使用 react-redux 的 useSelector 和 useDispatch 來做過渡
dispatch(fetchStoreList());
}, []);
// 範例:由於點選不同的分類或是標籤需要顯示不同的商店清單,
// 而分類或是標籤是從網址參數取得的,
// 因此當 keyword 有變化的時候就要重發一次 API 來取資料
// 修改前
componentDidUpdate(prevProps) {
const {
fetchStoreList,
match: { params: { keyword }}
} = this.props;
if (keyword !== prevProps.match.params.keyword) {
fetchStoreList(keyword);
}
}
// 修改後:在 useEffect 的 dependency 陣列加入 keyword
// 這樣 keyword 一有變化就會重發一次 API 來取資料
const dispatch = useDispatch();
const { keyword } = useParams();
const stores = useSelector(state => state.stores);
useEffect(() => {
dispatch(fetchStoreList(keyword));
}, [keyword]);
// 範例:元件渲染完成後設定監聽 click 事件,使用者只要點擊變觸發 handleSomething -> 等同 componentDidMount
// 元件移除前移除監聽 click 事件不再讓使用者點擊後觸發 handleSomething -> 等同於 componentWillUnmount
useEffect(() => {
document.addEventListener('click', handleSomething, true);
return () => document.removeEventListener('click', handleSomething, true);
}, []);
- 改寫 props:從父元件層層迭代至子元件的屬性,可用 useContext 來改寫。這在層數很多的狀況下優勢尤其明顯。
// 範例:由於父元件 StoreList 需要將 store 傳給子元件 Stores,
// 為了避免參數層層傳遞,使用 createContext 與 useContext 來改寫
// 修改前:父元件 StoreList 需要將 store 傳給子元件 Stores
class StoreList extends Component {
componentDidMount() {
const { fetchStoreList } = this.props;
fetchStoreList(); // 觸發 action,觸發 API 取資料
}
render() {
return <Stores stores={stores} />;
}
}
const Stores = (stores) => {
return _.map(stores, (store) =>
<Store key={store.id} store={store} />
);
};
// 修改後:使用 useContext 而不用層層傳遞
import React, { createContext, useContext, useEffect } from 'react';
const StoreListContext = createContext();
const Stores = () => {
const stores = useContext(StoreListContext); // 要用就這樣取出資料來用
return _.map(stores, (store) =>
<Store key={store.id} store={store} />
);
};
const StoreList = () => {
const dispatch = useDispatch();
const stores = useSelector(state => state.stores);
useEffect(() => {
dispatch(fetchStoreList());
}, [keyword]);
// 使用 `StoreListContext.Provider` 來包覆要用到的元件,
// 並且把值用 props 的方式傳入,在此是 value
return (
<StoreListContext.Provider value={stores}>
<Stores />
</StoreListContext.Provider>
)
};
- 改寫 redux:利用 useReducer + useContext 來取代 redux,可參考這裡。
- 先前若用 Reselect 來避免不必要的計算所造成的渲染,這裡可以改用 useMemo 來提升效能,但這樣的寫法會把 view 和 model 綁在一起,在架構上比較不漂亮,可參考這裡。
- ref 可用 useRef 與 useImperativeHandle 來改寫,基本上 useRef 的用途就是拿來取 DOM 物件,點此看範例。
// 範例:點擊按鈕後 scroll 到最下方
// 滑到最下方可看到 "You see me!" 的訊息
// 修改前
import React, { Component } from 'react';
class App extends Component {
constructor(props) {
super(props);
this.bottomRef = React.createRef();
this.scrollToBottom = this.scrollToBottom.bind(this);
}
scrollToBottom() {
console.log(this.bottomRef);
this.bottomRef.current.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'start'
});
}
render() {
return (
<>
<button onClick={this.scrollToBottom}>
Click to scroll to bottom!
</button>
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
You see me!
<div ref={this.bottomRef} />
</>
);
}
}
export default App;
// 修改後
import React, { useRef } from 'react';
const App = () => {
const bottomRef = useRef(null);
const scrollToBottom = () => {
bottomRef.current.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'start'
});
};
return (
<>
<button onClick={scrollToBottom}>Click to scroll to bottom!</button>
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
... <br />
You see me!
<div ref={bottomRef} />
</>
);
};
export default App;
無法一次改完整個專案
很多時候呢,是通通都要的 在這裡這樣講不太好,不要學。
- redux 與 hooks 並用:在重構專案的過程中,一定是無法一時之間完全將整個專案的 class component 重構為 hooks,因此必定會有並存的狀況,也就是說,在 hooks 裡面也會需要用到 redux 所存的狀態。
- 使用 react-redux 的 useSelector 取出 store 的資料。
- 使用 react-redux 的 useDispatch 觸發 reducer。
- 簡易範例如下,在元件渲染完成後,從 API 取得商店列表的資料並存到 redux 中,在這裡使用 useDispatch 來取得 dispatch 並觸發 fetchStoreList 這個 action,用 useSelector 取得存在 redux 的商店列表的資料。
import { useDispatch, useSelector } from 'react-redux';
const dispatch = useDispatch();
const stores = useSelector(state => state.stores);
useEffect(() => {
dispatch(fetchStoreList());
}, []);
renderStores(stores);
這樣應該足夠做簡易的重構了,那就先寫到這裡,之後想到再來補補 (✪ω✪)