在深入 hooks 的設計脈絡之前,我們得先來談談 hooks 的誕生究竟是為了解決什麼問題。首先,hooks 是綁定配合 function component 使用的。這是因為在一律重繪的渲染策略之下,原本 class component 這種偏物件導向的設計會有很多概念上的衝突,例如 class component 裡的成員方法無法參與資料流的變化、this.props
、this.state
在非同步事件中可能拿到錯置的資料之類的等等問題…在本系列文前面的篇幅中有許多深入的介紹,這邊就不再贅述太多。
為了能夠更貼近一率重繪、immutable 等核心設計的概念,React 決定往更加靠攏 functional programming 的方向去發展。在有了 function component 之後,React 還必須設計一套全新的機制與 API 來解決幾個重要的問題:
Function component 能夠讓 React 的每次 render 能夠獨立不互相干擾,你不會需要再擔心非同步事件從 this.props
讀取資料可能導致的問題。然而 UI 的本質是需要能夠擁有「狀態」的,而且一個 component 中有可能同時有多種狀態。同時這個 API 還得滿足支援多種狀態相互引用傳值的需求、同時又要避免命名衝突等問題。我們需要一個有足夠的彈性來與開發者互動,同時又能在內部維護 fiber node 的 API 設計。
讓不同的 components 之間重用邏輯一直是前端的開發當中相當重要的需求。不過事實上,在整個 class components 的時代 React 都從來沒有推出過官方的 components 邏輯重用 API。這是因為在 class component 的寫法中,狀態與生命週期都必須直接寫在一個 component 中才能定義,並且同一個功能你可能會需要在許多生命週期都放入邏輯,因此你其實很難把它們抽出來在多個 components 之間共用。
在 class component 的時代中,也有許多社群提出的 patterns 來繞圈解決邏輯重用的問題,主流的像是 higher order component 以及 render props。不過它們都無法完美的解決所有問題,仍然有命名衝突、依賴不透明…等等不足之處。
Hooks 的目標,是想要配合 function component 設計一套能夠定義並管理狀態並且方便共用邏輯的 API,同時解決幾個過去的方案會遇到的問題:
為此,hooks API 採用了以下一些設計思路:
當我們想共用一個功能的邏輯或流程,以 component 為單位來進行包裹的方式(像是 higher order component)可能會遇到一些問題,例如兩段為了重用邏輯而寫的 component,裡面都有名為 name
的 prop,此時如果同時套用到同一個目標的 component 上時候,就有可能遇到命名衝突問題。另外這樣做也會讓這兩段邏輯之間無法彈性的互動,只能是 A 覆蓋 B 或 B 覆蓋 A,兩者擇一。
因此能讓邏輯與流程能夠以最大的彈性被拆分與調用的形式,仍然是函式。函式可以自由的設計參數與回傳值,也能很好的自由拆分與組合,這是 hooks 被設計成都是函式的一個主要原因:
function App() {
const [page, setPage] = useState(1);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [productList, setProductList] = useState([]);
const [productListLoading, setProductListLoading] = useState(false);
useEffect(
() => {
let ignore = false;
setProductListLoading(true);
const startIndex = (page - 1) * 10;
const endIndex = (page * 10) - 1;
ProductAPI.queryList({ startIndex, endIndex })
.then((data) => {
if (!ignore) {
setProductList(data);
setProductListLoading(true);
}
});
return () => {
ignore = true;
};
},
[page, rowsPerPage]
);
// ...
}
我們還可以把上面範例中的「控制 pagination options」以及「query product list API」兩段邏輯抽出來,以方便共用:
function usePaginationOptions() {
const [page, setPage] = useState(1);
const [rowsPerPage, setRowsPerPage] = useState(10);
const startIndex = (page - 1) * 10;
const endIndex = (page * 10) - 1;
return {
page,
rowsPerPage,
startIndex,
endIndex,
setPage,
setRowsPerPage,
};
}
function useProductListQuery({ startIndex, endIndex }) {
const [productList, setProductList] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(
() => {
let ignore = false;
setLoading(true);
const startIndex = (page - 1) * 10;
const endIndex = (page * 10) - 1;
ProductAPI.queryList({ startIndex, endIndex })
.then((data) => {
if (!ignore) {
setProductList(data);
setLoading(false);
}
});
return () => {
ignore = true;
};
},
[page, rowsPerPage]
);
return { productList, loading };
}
function App() {
const { startIndex, endIndex } = usePaginationOptions();
const { productList, loading } = useProductListQuery({
startIndex,
endIndex
});
// ...
}
在抽出成 custom hooks 之後,不僅我們主要的 component 中裡的邏輯簡化了很多,也能很方便清楚的讓這些 hooks 之間進行資料的傳遞。這樣的 hooks API 設計幫助我們很好的滿足邏輯之間依賴透明的目標。
並且由於 hooks 的調用是在 render 過程中發生的,這些狀態與邏輯的定義載體不需要依賴於獨立的 component,因此我們無論你在一個 component 中調用了多少 hooks,都不會污染到 render 出來的 React element 結構,這能讓與畫面渲染無關的邏輯與畫面本身分離,提升我們的 components 可讀性。
然而,以函式的形式定義流程以及邏輯雖然很直覺方便,但是定義狀態資料則有點微妙。如果一個用來定義狀態資料的 hook,我在某次 render 時有調用,但卻在其它次的 render 時沒有調用,那這到底代表什麼意思?是這個狀態再也用不到了,應該直接移除?如果後面的 render 中再次出現的話那之前留存資料應該還在嗎?從各種角度上來說這都是相當不直覺且很容易讓人誤解其行為。
為此,我們必須保證 component 裡的所有 hooks 在每一次 render 時都會固定的被調用到。關於細節可上參考前一篇章中的解析:[Day 28] 一次弄懂 React hooks 的運作原理與設計思維(上)。
然而為什麼同一個 component 中的多個 hooks 是設計成依賴順序來存放並區分的?大多數人的第一直覺應該是會想要以一個唯一的 key 來定義它們:
// ❌ 注意,以下不是真實的 hooks API,只是假設的 API 設計:
// useState(stateKey, defaultValue)
const [name, setName] = useState('name', '');
const [surname, setSurname] = useState('surname', '');
const [width, setWidth] = useState('width', 0);
然而這種基於自定義 key 的設計會有個難以避免的問題 — 命名衝突。
你無法在同一個 component 裡調用兩次 key 皆為 'name'
的 useState
。如果你的這些狀態與邏輯僅定義在一個 component 裡的話這種情況可能還在可以控制的範圍內,畢竟你可以自己在 component 中避免重名。然而,如果還要考慮到重用問題的話,每當你在 custom hooks 內定義 state,就可能導致重用了這個 custom hook 的 component 壞掉 — 因為在 component 內有可能也定義了相同 key 的 state。
而依賴調用順序的方式,基本上就是讓 hooks 的 key 都是一種順序性的 index,如果這個 hook 在 render 中第三個被調用的 hook,那只要它在往後的 render 中也一直維持是第三個被調用的 hook,我們就能保持這個機制運作正常。
基於 key 的 hooks 設計也會導致一個在程式設計領域惡名昭彰的問題 — 鑽石問題,又被稱為多重繼承問題或菱形繼承問題。這其實是命名衝突問題的延伸進階版,讓我們以一個範例來解釋:
在以下的範例中,我們想要在遊戲資料中定義「玩家」以及「怪物」兩種類型,而它們兩者都有「位置座標」這種相同的資料概念,我們想要重用這個部分:
function usePosition() {
// ❌ 注意,這裡是假想的 hooks API,指定這個 hook 的 key 為 'positionX'
const [x, setX] = useState('positionX', 0);
// ❌ 注意,這裡是假想的 hooks API,指定這個 hook 的 key 為 'positionY'
const [y, setY] = useState('positionY', 0);
return { x, setX, y, setY };
}
function usePlayer() {
const posotion = usePosition();
// ...其他 player 才會有的資料或方法
return { ....., posotion };
}
function useMonster() {
const posotion = usePosition();
// ...其他 monster 才會有的資料或方法
return { ....., posotion };
}
// component
function GameApp() {
const player = usePlayer();
const moneter = useMonster();
// ...
}
在上面這段 React 程式碼中,usePlayer
與 useMonster
這兩個 custom hooks 的內部都重用到 usePosition
這個 custom hook,而 usePosition
裡面以 key 的方式定義了兩種 state positionX
以及 positionY
。而此時當我們的 GameApp
component 中同時調用了 usePlayer
以及 useMonster
兩種 hooks 時,鑽石問題就產生了:
當我們同時在一個 component 中去調用 usePosition
兩次時,它們兩者會分別在 component 裡都嘗試註冊名為 positionX
與 positionY
的 hooks,這會導致命名衝突問題。
而如果是基於 hooks 在 component 裡的固定調用順序,則可以很自然的解決了這個問題:
純粹的函式調用並不會有鑽石問題,它們會自然的形成樹狀結構。而 component 只需要以這些 custom hooks 層層的調用 stack 展開後的調用順序,來區分並追蹤 hooks 的狀態資料即可。這樣的設計能讓我們可以對於命名衝突的惡夢說再見。
useEffect
的資料流同步化取代生命週期 API我們在系列文的前面篇章中,曾花了許多篇幅來解析 useEffect
的概念與正確用途。其中曾提到過一個重要的觀念:function component 沒有提供生命週期的 API,只有 useEffect
用於「從資料同步到 effect 的行為與影響」。
在這裡我們想要進一步探究的是,為什麼 React 在 hooks API 中做出這樣的設計決策,以往的 class component 暴露生命週期 API 給開發者的方式出了什麼問題?
首先,事實上我們在 component 中會執行的副作用,絕大多數都是為了讓 React 內部的某些資料與React 外的事情同步。例如最常見的就是以某些參數去請求伺服器端的 API,並取回某些資料。而這些與外部同步並取回資料的動作,又通常會需要當不再運行時進行一些清理或還原的處理:
componentDidMount() {
OrderAPI.subscribeStatus(
this.props.id,
this.handleStatusChange
);
}
componentWillUnmount() {
OrderAPI.unsubscribeStatus(
this.props.id,
this.handleStatusChange
);
}
這個範例中的 class component 會以 props.id
來在 componentDidMount
時訂閱指定訂單的狀態,並在 componentWillUnmount
去取消這個訂單狀態的訂閱。看起來似乎一切都很好。
然而當 component 以新的 props.id
進行了 re-render 時,這個 component 將不會自動以新的 id 來重新訂閱對應的訂單狀態資料,也不會正確的取消原本那個訂單的狀態訂閱,進而導致 memory leak 等問題。而忘記正確的處理 componentDidUpdate
正是 class component 中常見的 bug 來源。
componentDidMount() {
OrderAPI.subscribeStatus(
this.props.id,
this.handleStatusChange
);
}
// 加上 componentDidUpdate 的處理以解決 bug
componentDidUpdate(prevProps) {
// 從先前的訂單 id 取消訂閱
OrderAPI.unsubscribeStatus(
prevProps.id,
this.handleStatusChange
);
// 訂閱下一個訂單 id
OrderAPI.subscribeStatus(
this.props.id,
this.handleStatusChange
);
}
componentWillUnmount() {
OrderAPI.unsubscribeStatus(
this.props.id,
this.handleStatusChange
);
}
Class component 中原有的生命週期 API 設計,潛移默化的讓身為開發者的我們習慣將「didMount
」、「didUpdate
」、「willUnmount
」的情境拆開來思考,我們必須自己去考慮要在哪些生命週期中做哪些動作,才能完成這個「以 this.props.id
來訂閱訂單的狀態資料」持續同步化的效果。只要我們遺漏處理了其中任何一種情況,component 的行為就會是有 bug 的。然而當應用程式日漸龐大的情況下,身為凡人的我們通常很難完全沒有錯漏,這對開發者縝密的思維以及細心程度的要求非常高。
另外,上面這些 class component 生命週期 API 的程式碼也很難被抽出並重用在其它 components 中。你可以想像如果有兩個可重用的功能都會需要在 componentDidMount
、componentDidUpdate
、componentWillUnmount
裡添加邏輯,那麼當我們想要在同一個 component 中同時添加這兩個功能上去時,它們就非常容易打架並弄壞對方。
而類似的邏輯在 function component 中其實只需要一個 useEffect
就能搞定,只需要描述 effect 的同步邏輯,以及清除這個同步所造成的副作用的 cleanup,就能一次搞定 mount、update、unmount 等情境。同時,由於它只是一個函式,因此我們可以很輕易地將它抽出成一個 custom hook 來重用,而不需要在定義時與其他功能的生命週期 API 打架:
function useOrderStatusSubscribe() {
useEffect(() => {
OrderAPI.subscribeStatus(props.id, handleChange);
return () => {
OrderAPI.unsubscribeStatus(props.id, handleChange);
};
});
}
這樣重視「同步結果」而非「執行細節或時機」的 API 設計,讓我們在處理副作用時也能更享受到「宣告式」風格的好處。這可以使身為開發者的我們能更好的專注在商業邏輯本身,而不是 component 的內部運作生命週期。
在經過快要一年的努力後,本系列文的實體書版本推出了~其中新增並補充了許多鐵人賽版本中沒有的脈絡與細節,並以全彩印刷拉滿視覺上的閱讀體驗,現正熱銷中!有興趣的話歡迎參考看看,也歡迎分享給其他有接觸前端的朋友們,非常感謝大家~
《React 思維進化:一次打破常見的觀念誤解,躍升專業前端開發者》
目前首刷的軟精裝版本各大通路已經幾乎都銷售一空,接下來會再刷推出新的平裝版本:
天瓏(平裝版預購):
https://www.tenlong.com.tw/products/9786263337695
博客來(平裝版):
https://www.books.com.tw/products/0010982322
momo(平裝版):
https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12528845