你可能不知道的流式 React Hooks(關(guān)于組織代碼的最佳實(shí)踐)
文中的很多 term 是為了闡明一些概念所設(shè),并非專有名詞,不需要當(dāng)真。
回顧一下 React Hooks
首先還是簡(jiǎn)單回顧一下 React Hooks。
先看傳統(tǒng)的 React Class-based Component。一個(gè)組件由四部分構(gòu)成:
狀態(tài) state:一個(gè)統(tǒng)一的集中的 state 生命周期回調(diào) lifecycle methods:一些需要誦記的生命周期相關(guān)的回調(diào)函數(shù)(WillMount / Mounted / WillReceiveProps / Updated / WillUnmount 等等) 回調(diào)函數(shù) handlers:一些回調(diào)方法,在 view 層被調(diào)用,作用在 state 層上 渲染函數(shù) render:即組件的 view 層,負(fù)責(zé)產(chǎn)出組件的 VirtualDOM 節(jié)點(diǎn),掛載回調(diào)函數(shù)等
React Hooks 組件其實(shí)可以簡(jiǎn)單地理解成一個(gè) render 函數(shù)。這個(gè) render 函數(shù)本身即組件。他通過 useState 和 useEffect 兩個(gè)函數(shù)來實(shí)現(xiàn)函數(shù)的“狀態(tài)化”,即獲得對(duì) state 和生命周期的注冊(cè)和訪問能力。
Hooks 是一套新的框架
相比類組件,Hooks 組件有以下特點(diǎn)
自上而下:相比類組件的方法之間相互調(diào)用,作為函數(shù)的 Hooks 組件的具有更單純的邏輯流,即自上而下 弱化 handlers:相比類組件的方法注冊(cè),函數(shù)式組件雖然也可以實(shí)現(xiàn)在函數(shù)上下文中聲明回調(diào),但這對(duì)不如類方法來得自然。 簡(jiǎn)化生命周期:Hooks 通過一個(gè)單純的 useEffect 來注冊(cè)基于依賴變更的生命周期函數(shù),把類組件中的生命周期都混合在一起。事實(shí)上,我們完全可以徹底拋棄對(duì)原先 React 組件生命周期的理解,直接來理解 useEffect,把他單純地當(dāng)成在 render 過程中注冊(cè)的函數(shù)副作用。 分散的 state 和 effect 注冊(cè)和訪問:Hooks 不再像類組件一般要求在統(tǒng)一的地方注冊(cè)組件用到的所有狀態(tài)以及生命周期方法,這使得更「模型內(nèi)聚」的邏輯組織成為可能。 依賴驅(qū)動(dòng):多個(gè)基礎(chǔ) Hooks 在設(shè)計(jì)上都有 deps 的概念,用以實(shí)現(xiàn)基于依賴項(xiàng)的變更來執(zhí)行所聲明的函數(shù)的能力
基于上述迥異的語法和完全平行的 API,基于 Hooks 的組件書寫可以被當(dāng)作一門獨(dú)立于基于類組件的全新框架。我們應(yīng)盡量避免以模仿類組件的風(fēng)格去書寫 Hooks 組件的邏輯,而應(yīng)當(dāng)重新審視這種新的語法。
由于上述的語法特點(diǎn),Hooks 適合通過「基于變更」的聲明風(fēng)格來書寫,而非「基于回調(diào)」的命令式方式來書寫。這會(huì)讓一個(gè)組件更易于拆分和復(fù)用邏輯并擁有更清晰的邏輯依賴關(guān)系。大家將逐步看到「基于變更」的風(fēng)格的優(yōu)勢(shì),下面小舉兩個(gè)例子來對(duì)比一下「基于變更」和「基于回調(diào)」的寫法:
例一:通過 useEffect 聲明請(qǐng)求
需求場(chǎng)景:更改一個(gè) keyword state 并發(fā)起查詢的請(qǐng)求
基于回調(diào)的寫法(仿類寫法)
const Demo: React.FC = () => {
const [state, setState] = useState({
keyword: '',
});
const query = useCallback((queryState: typeof state) => {
// ...
}, []);
const handleKeywordChange = useCallback((e: React.InputEvent) => {
const latestState = { ...state, keyword: e.target.value };
setState(latestState);
query(latestState);
}, [state, query]);
return // view
}
這種寫法有幾個(gè)問題:
handleKeywordChange 若在兩次渲染中被多次調(diào)用,會(huì)出現(xiàn) state 過舊的問題,從而得到的 latestState 將不是最新的,會(huì)產(chǎn)生bug。(這個(gè)問題類組件也會(huì)存在) query 方法每次都需要在 handler 中被命令式地調(diào)用,如果需要調(diào)用它的 handler 變多,則依賴關(guān)系語法復(fù)雜,且容易疏忽忘記手動(dòng)調(diào)用。 query 使用的 queryState 就是最新的 state,卻每次需要由 handler 將 state 計(jì)算好交給 query 函數(shù),方法間職責(zé)分割得不明確。
基于變更的寫法
const Demo: React.FC = () => {
const [state, setState] = useState({
keyword: '',
});
const handleKeywordChange = useCallback((e: React.InputEvent) =>
{
const nextKeyword = e.target.value;
setState(prev => ({ ...prev, keyword: nextKeyword }))
}, []);
useEffect(() => {
// query
}, [state]);
return // view
}
上面的寫法解決了「基于回調(diào)」寫法的所有問題。它把 state 作為了 query 的依賴,只要 state 發(fā)生變更,query 就會(huì)自動(dòng)執(zhí)行,且執(zhí)行時(shí)機(jī)一定是在 state 變更以后。我們沒有命令式地調(diào)用 query,而是聲明了在什么情況下它應(yīng)當(dāng)被調(diào)用。
當(dāng)然這種寫法也不是沒有問題:
萬一需求場(chǎng)景要求我們?cè)?state 的某些特定字段變更的時(shí)候不觸發(fā) query,上面的寫法就失效了
事實(shí)上,這個(gè)問題恰恰要求我們?cè)趯?Hooks 時(shí)花更多的精力專注于「變」與「不變」的管理,而不是「調(diào)」與「不調(diào)」的管理上。
例二:注冊(cè)對(duì) window size 的監(jiān)聽
需求場(chǎng)景:在 window resize 時(shí)觸發(fā) callback 函數(shù)
基于回調(diào)的寫法(仿類寫法)
const Demo: FC = () => {
const callback = // ...
useEffect(() => {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
}, []);
return // view
}
在「componentDidMount」的時(shí)候注冊(cè)這個(gè)監(jiān)聽,在「componentWillUnmount」的時(shí)候注銷它。很單純啊是不是?
但是問題來了,在類組件中,callback 可以是一個(gè)類方法(method),它的引用在整個(gè)組件生命周期中都不會(huì)發(fā)生改變。但是函數(shù)式組件中的 callback 是在每次執(zhí)行的上下文中生成的,它極有可能每次都不一樣!這樣 window 對(duì)象上掛載的監(jiān)聽將會(huì)是組件第一次執(zhí)行產(chǎn)生的 callback,之后所有執(zhí)行輪次中產(chǎn)生的 callback 都將不會(huì)被掛載到 window 的訂閱者中,bug 就出現(xiàn)了。
那改一下?
基于回調(diào)的寫法2
const Demo: FC = () => {
const callback = // ...
useEffect(() => {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
}, [callback]);
return // view
}
這樣把 callback 放到注冊(cè)監(jiān)聽的 effect 的依賴中看起來似乎能 work,但是也太不優(yōu)雅了。在組件的執(zhí)行過程中,我們將瘋狂地在 window 對(duì)象上注冊(cè)注銷注冊(cè)注銷,聽起來就不太合理。下面看看基于變更的寫法:
基于變更的寫法
const Demo: FC = () => {
const [windowSize, setWindowSize] = useState([
window.innerWidth,
window.innerHeight
] as const);
useEffect(() => {
const handleResize = () => {
setWindowSize([window.innerWidth, window.innerHeight]);
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const callback = // ...
useEffect(callback, [windowSize]);
return // view
};
這里我們通過一個(gè) useState 和一個(gè) useEffect 首先把 window resize 從一個(gè)回調(diào)的注冊(cè)注銷過程轉(zhuǎn)換成了一個(gè)表示 window size 的 state。之后依賴這個(gè) state 的變更實(shí)現(xiàn)了對(duì) callback 的調(diào)用。這個(gè)調(diào)用同樣是聲明式的,而不是直接手動(dòng)命令式的調(diào)用的,而聲明式往往意味著更好的可測(cè)性。
上面的代碼看似更復(fù)雜了,但事實(shí)上,只要我們把 2-10 行的代碼抽離出來,很快就得到了一個(gè)跨組件可復(fù)用的自定義 Hooks:useWindowSize。使得在別的組件中使用基于 window resize 的回調(diào)變得非常方便:
const useWindowSize = () => {
const [windowSize, setWindowSize] = useState([window.innerWidth, window.innerHeight] as const);
useEffect(() => {
const handleResize = () => {
setWindowSize([window.innerWidth, window.innerHeight]);
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return windowSize
}
基于變更的寫法的關(guān)鍵在于把「 動(dòng)作」轉(zhuǎn)換成「 狀態(tài)」
Marble Diagrams
通過上面的論述和例子我們可以看到在 Hooks-based 組件中合理地使用基于變更的代碼可以帶來一定的好處。為了更好地理解「基于變更」這件事。這里引入流式編程中常用于輔助理解的 Marble 圖。你將很快發(fā)現(xiàn),我們一直在說的「基于變更」于流式編程中的「流」沒有兩樣:
RxMarble圖例[1]
流式編程中,一個(gè)珠子(marble)就代表一個(gè)推送過來的數(shù)據(jù),一串橫向的珠子就代表一個(gè)數(shù)據(jù)流(Observable 或 Subject)在時(shí)間上的一系列推送數(shù)據(jù)。流式編程通過一系列操作符,對(duì)數(shù)據(jù)流實(shí)現(xiàn)加工整合映射等操作來實(shí)現(xiàn)編程邏輯。上圖的 merge 操作,是非常常用的合并兩個(gè)數(shù)據(jù)源的操作符。
不可變數(shù)據(jù)流與「執(zhí)行幀」
基于變更的 Hooks coding 其實(shí)是與 stream coding 相當(dāng)同構(gòu)的概念。兩者都弱化 callback,把 callback 包裝起來成為流或操作符。
Hooks 組件中的一個(gè) state 就是流式編程中的流,即一串珠子
而一個(gè) state 的每一次變更,便是一顆珠子
不可變數(shù)據(jù)流 immutable dataflow
為了完全地體現(xiàn)「變更」,所有的狀態(tài)更新都要做到 immutable 簡(jiǎn)而言之:讓引用的變化與值的變化完全一致
為了實(shí)現(xiàn)這一點(diǎn),你可以:
每次 setState 的時(shí)候注意 自己實(shí)現(xiàn)一些 immutable utils 借助第三方的數(shù)據(jù)結(jié)構(gòu)庫(kù),如 facebook的 ImmutableJS[2]
(個(gè)人推薦 1 或 2,可以盡可能減少引入不必要的概念)
執(zhí)行幀
在 Hooks-based 編程中,我們還要有所謂「執(zhí)行幀」的概念。這種概念在其他框架如 vue / Angular 中很被弱化,而對(duì) React 尤其是函數(shù)式組件中卻很有助于思考
在組件上下文中的 state 或 props 一旦發(fā)生變更,就會(huì)觸發(fā)組件的執(zhí)行。每次執(zhí)行就相當(dāng)于一幀渲染的繪制。所有的 marble 就串在執(zhí)行幀與狀態(tài)構(gòu)成的網(wǎng)格中
變更的源頭
對(duì)一個(gè)組件來說,能觸發(fā)它重新渲染的變更稱為「源」source。一個(gè)組件的變更源一般有以下幾種:
props 變更:即父組件傳遞給組件的 props 發(fā)生變更 事件 event:如點(diǎn)擊,如上文的 window resize 事件。對(duì)事件,需要將事件回調(diào)包裝成 state 調(diào)度器:即 animationFrame / interval / timeout
上述源頭,有些已經(jīng)被「marble化」了,如 props。有些還沒有,需要我們包裝的方式把他們「marble 化」
例一:對(duì)事件的包裝
const useClickEvent = () => {
const [clickEvent, setClickEvent] = useState<{ x: number; y: number; }>(null);
const dispatch = useCallback((e: React.MouseEvent) => {
setClickEvent({ x: e.clientX, y: e.clientY });
}, []);
return [clickEvent, dispatch] as const;
}
例二:對(duì)調(diào)度器的包裝(以 interval 為例)

const useInterval = (interval: number) => {
const [intervalCount, setIntervalCount] = useState();
useEffect(() => {
const intervalId = setInterval(() => {
setIntervalCount(count => count + 1)
});
return () => clearInterval(intervalId);
}, []);
return intervalCount;
};
流式操作符
從源變更到最終 view 層需要的數(shù)據(jù)狀態(tài),一個(gè)組件的數(shù)據(jù)組織可以抽象成下圖:
中間的 operators 就是組件處理數(shù)據(jù)的核心邏輯。在流式編程中的 operator 幾乎都可以在 Hooks 中通過自定義 Hooks 寫出同構(gòu)的表示。
這些「流式 Hook」是由基本 Hooks 復(fù)合而成的更高階的 Hooks,可以具有高度的復(fù)用性,使得代碼邏輯更簡(jiǎn)練。
映射(map)
通過 useMemo 就可以直接實(shí)現(xiàn)把一些變更整合到一起得到一個(gè)「computed」?fàn)顟B(tài)
對(duì)應(yīng) ReactiveX 概念:map / combine / latestFrom

const [state1, setState1] = useState(initalState1);
const [state2, setState2] = useState(initialState2);
const computedState = useMemo(() => {
return Array(state2).fill(state1).join('');
}, [state1, state2]);
跳過前幾次(skip) / 只在前幾次響應(yīng)(take)
有時(shí)候我們不想在第一次的時(shí)候執(zhí)行 effect 里的函數(shù),或進(jìn)行 computed 映射。可以實(shí)現(xiàn)自己實(shí)現(xiàn)的 useCountEffect / useCountMemo 來實(shí)現(xiàn)
對(duì)應(yīng) ReactiveX 概念:take / skip

const useCountMemo = <T>(callback: (count: number) => T, deps: any[]): T => {
const countRef = useRef(0);
return useMemo(() => {
const returnValue = callback(countRef.current);
countRef.current++;
return returnValue;
}, deps);
};
export const useCountEffect = (cb: (index: number) => any, deps?: any[]) => {
const countRef = useRef(0);
useEffect(() => {
const returnValue = cb(countRef.current);
currentRef.current++;
return returnValue;
}, deps);
};
流程與調(diào)度(debounce / throttle / delay)
在基于變更的 Hooks 組件中,debounce / throttle / delay 等操作變得非常簡(jiǎn)單。debounce / throttle / delay 的對(duì)象將不再是 callback 函數(shù)本身,而是變更的狀態(tài)
對(duì)應(yīng) ReactiveX 的概念:debounce / delay / throttle



const useDebounce = <T>(value: T, time = 250) => {
const [debouncedState, setDebouncedState] = useState(null);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedState(value);
}, time);
return () => clearTimeout(timer);
}, [value]);
return debouncedState;
};
const useThrottle = <T>(value: T, time = 250) => {
const [throttledState, setThrottledState] = useState(null);
const lastStamp = useRef(0);
useEffect(() => {
const currentStamp = Date.now();
if (currentStamp - lastStamp > time) {
setThrottledState(value);
lastStamp.current = currentStamp;
}
}, [value]);
return throttledState
}
action / reducer 模式的異步流程
Redux 的核心架構(gòu) action / reducer 模式在 Hooks 中的實(shí)現(xiàn)非常簡(jiǎn)單,React 甚至專門提供了一個(gè)經(jīng)過封裝的語法糖鉤子 useReducer 來實(shí)現(xiàn)這種模式。
對(duì)于異步流程,我們同樣可以采用 action / reducer 的模式來實(shí)現(xiàn)一個(gè) useAsync 鉤子來幫助我們處理異步流程。
這里示意的是一個(gè)最簡(jiǎn)單的基于 promise 的函數(shù)模式,類似 redux 中使用 redux-thunk 中間件。
同時(shí),我們伴隨請(qǐng)求的數(shù)據(jù)狀態(tài)維護(hù)一組 loading / error / ready 字段,用來標(biāo)示當(dāng)前數(shù)據(jù)的狀態(tài)。
useAsync 鉤子還可以內(nèi)置對(duì)多個(gè)異步流程的 競(jìng)爭(zhēng) / 保序 / 自動(dòng)取消 等機(jī)制的控制邏輯。
下面示例了 useAsync 鉤子的用法,采用了 generator 來實(shí)現(xiàn)一個(gè)異步流程對(duì)狀態(tài)的多步修改。甚至可以實(shí)現(xiàn)類似 redux-saga 的復(fù)雜異步流程管理。
const responseState = useAsync(responseInitialState, actionState, function * (action, prevState) {
switch (action?.type) {
case 'clear':
return null;
case 'request': {
const { data } = yield apiService.request(action.payload);
return data;
}
default:
return prevState;
}
})
下面的代碼例舉了一個(gè)通過類「action/ reducer」模式的異步鉤子來維護(hù)一個(gè)字典類型的數(shù)據(jù)狀態(tài)的場(chǎng)景:
// 來自 props 或 state 的 actions
// fetch action: 獲取
let fetchAction: {
type: 'query',
id: number;
};
let clearAction: {
type: 'clear',
ids: number[]; // 需要保留的 ids
}
let updateAction: {
type: 'update',
id: number;
}
// 通過一個(gè)自定義的 merge 鉤子來保留上述三個(gè)狀態(tài)中最新變更的一個(gè)狀態(tài)
const actions = useMerge(fetchAction, clearAction, updateAction);
// reducer
const dataState = useQuery(
{} as Record<number, DataType>,
actions,
async (action, prev) => {
switch (action?.type) {
case 'update':
case 'query': {
const { id } = action;
// 已經(jīng)存在子列表的情況下,不對(duì)數(shù)據(jù)作變更,返回一個(gè) identity 函數(shù)
if (action.type === 'query' && prev[id]) return prevState => prevState;
// 拉取指定 id 下的列表數(shù)據(jù)
const { data } = await httpService.fetchListData({ id });
// 返回一個(gè)插入數(shù)據(jù)的狀態(tài)映射函數(shù)
return prev => ({
...prev,
[id]: data,
});
}
case 'clear': {
// 返回一個(gè)保留特定 id 數(shù)據(jù)的狀態(tài)映射函數(shù)
return prev =>
pick( // pick 是一個(gè)從對(duì)象里獲取一部分 key value 對(duì)組成新對(duì)象的方法
prev,
action.ids,
);
}
default:
return prev;
}
},
{ mode: 'multi', immediate: false }
);
單例的 Hooks——全局狀態(tài)管理
通過 Hooks 管理全局狀態(tài)可以與傳統(tǒng)方式一樣,例如借助 context 配合 redux 通過 Provider 來下發(fā)全局狀態(tài)。這里推薦更 Hooks 更方便的一種方式——單例 Hooks:Hox[3]
通過第三方庫(kù) Hox 提供的 createModel 方法可以產(chǎn)生一個(gè)掛載在虛擬組件中的全局單例的 Hooks。這個(gè)虛擬組件的實(shí)例一經(jīng)創(chuàng)建將在 app 的整個(gè)生命周期中存活,等于是產(chǎn)生了一個(gè)全局的「marble 源」,從而任何的組件都可以使用這個(gè) Hooks 來獲取這個(gè)源來處理自己的邏輯。
hox 的具體實(shí)現(xiàn)涉及自定義 React Reconciler,感興趣的同學(xué)可以去看一下它源碼的實(shí)現(xiàn)。
流式 Hooks 局限性
「基于變更」的 Hooks 組件書寫由于與流式編程非常相似,我也把他稱作「流式 Hooks」。
上面介紹了很多流式 Hooks 的好處。通過合適的邏輯拆分和復(fù)用,流式 Hooks 可以實(shí)現(xiàn)非常細(xì)粒度且高內(nèi)聚的代碼邏輯。在長(zhǎng)期實(shí)踐中也證明了它是比較易于維護(hù)的。那么這種風(fēng)格 Hooks 存在什么局限性呢?
「過頻繁」的變更
在 React 中,存在三種不同「幀率」或「頻繁度」的東西:
調(diào)和 reconcile:把 virtualDOM 的變更同步到真實(shí)的 DOM 上去 執(zhí)行幀 rendering:即 React 組件的執(zhí)行頻率 事件 event:即事件 dispatch 的頻率
這三者的觸發(fā)頻率是從上至下越來越高的
由于 React Hooks 的變更傳播的最小粒度是「執(zhí)行幀」粒度,故一旦事件的發(fā)生頻率高過它(一般來說只會(huì)是同步的多次事件的觸發(fā)),這種風(fēng)格的 Hooks 就需要一些較為 Hack 的邏輯來兜底處理。
避免「為了流而流」
流式編程如 RxJS 大量被用于消息通訊(如在 Angular 中),被用于處理復(fù)雜的事件流程。但其本身一直沒有成為主流的應(yīng)用架構(gòu)。導(dǎo)致這個(gè)狀況的一個(gè)瓶頸就在于它幾乎沒有辦法寫一星半點(diǎn)命令式的代碼,從而會(huì)出現(xiàn)把一些通過命令式/回調(diào)式很好實(shí)現(xiàn)的代碼寫得非常冗長(zhǎng)難懂的情況。
React Hooks 雖然可以與 RxJS 的語法產(chǎn)生很大成都的同構(gòu),但其本質(zhì)仍然是命令式為底層的編程,故它可以是多范式的。在編碼中,我們?cè)诮^大部分場(chǎng)景下可以通過流式的風(fēng)格實(shí)現(xiàn),但也應(yīng)當(dāng)避免為了流而流。如 Redux 下的一個(gè)關(guān)于哪些狀態(tài)應(yīng)該放到全局哪些應(yīng)該放到組件內(nèi)的 Issue 下評(píng)論的:選擇看起來更不奇怪(less weird)的那個(gè)
愿景
目前我正在規(guī)劃和產(chǎn)出一套基礎(chǔ)的流式 Hooks,便于業(yè)務(wù)邏輯引用來書寫具有流式風(fēng)格的 Hooks 代碼Marble Hooks[4]
參考資料
RxMarble圖例: https://rxmarbles.com/
[2]ImmutableJS: https://immutable-js.github.io/immutable-js/
[3]Hox: https://github.com/umijs/hox
[4]Marble Hooks: https://github.com/pierrejacques/marble-hooks
