大家好,我是若川。持續組織了8個月源碼共讀活動,感興趣的可以?點此加我微信ruochuan12?參與,每周大家一起學習200行左右的源碼,共同進步。同時極力推薦訂閱我寫的《學習源碼整體架構系列》?包含20余篇源碼文章。歷史面試系列。另外:目前建有江西|湖南|湖北
籍前端群,可加我微信進群。
React Hooks 是 React 16.8 引入的新特性,允許我們在不使用 Class 的前提下使用 state 和其他特性。React Hooks 要解決的問題是狀態共享,是繼 render-props 和 higher-order components 之后的第三種狀態邏輯復用方案,不會產生 JSX 嵌套地獄問題。
1. 前言
React Hooks 是 React 16.8 引入的新特性,允許我們在不使用 Class 的前提下使用 state 和其他特性。React Hooks 要解決的問題是狀態共享,是繼 render-props 和 higher-order components 之后的第三種狀態邏輯復用方案,不會產生 JSX 嵌套地獄問題。
2. 狀態邏輯復用
一般來說,組件是 UI 和邏輯,但是邏輯這一層面卻很難復用。對用戶而言,組件就像一個黑盒,我們應該拿來即用。但當組件的樣式或者結構不滿足需求的時候,我們只能去重新實現這個組件。

在我們開發 React 應用的時候,經常會遇到類似下面這種場景,你可能會有兩個疑問:
Loading 是否可以復用?
Loading 該怎么復用?
這幾個例子都指向了同一個問題,那就是如何實現組件的邏輯復用?
2.1 render props
將函數作為 props 傳給父組件,父組件中的狀態共享,通過參數傳給函數,實現渲染,這就是 render props
。使用 render prop
的庫有 React Router、Downshift 以及 Formik。以下面這個 Toggle 組件為例子,我們一般可以這樣用:
可以看到,控制 Modal 組件是否展示的狀態被提取到了 Toggle 組件中,這個 Toggle 組件還可以拿來多次復用到其他組件里面。那么這個 Toggle 是怎么實現的呢?看到實現后你就會理解 render props
的原理
關于 render props
的更多內容可以參考 React 中文網的相關章節:Render Props
2.2 higher-order components
higher-order components 一般簡稱 hoc,中文翻譯為高階組件。從名字上就可以看出來,高階組件肯定和高階函數有什么千絲萬縷的關系。高階組件的本質是一個高階函數,它接收一個組件,返回一個新的組件。在這個新的組件中的狀態共享,通過 props 傳給原來的組件。以剛剛那個 Toggle 組件為例子,高階組件同樣可以被多次復用,常常可以配合裝飾器一起使用。
高階組件的實現和 render props
也不太一樣,主要是一個高階函數。
2.3 render props 和高階組件的弊端
不管是 render props 還是高階組件,他們要做的都是實現狀態邏輯的復用,可這倆是完美的解決方案嗎?考慮一下,如果我們依賴了多個需要復用的狀態邏輯的時候,該怎么寫呢?以 render props 為例:
看看這個代碼,你有沒有一種似曾相識的感覺?這一天,我們終于想起被“回調地獄”支配的恐懼。不得不再次祭出這張圖了。

同樣地,高階組件也會有這個問題,但由于裝飾器的簡潔性,沒有 render props 看起來那么可怕。除此之外,他們倆還有另一個問題,那就是組件嵌套過深之后,會給調試帶來很大的麻煩。這個是 render props 中組件嵌套在 React 開發者工具中的表現。

對于高階組件來說,如果你沒有對組件手動設置 name/displayName
,就會遇到更嚴重的問題,那就是一個個匿名組件嵌套。畢竟上面 render props 的嵌套至少能知道組件名。

社區里面也已經有很多解決 render props 嵌套的方案,其中 Epitath 提供了一種以 generator 的方法來解決嵌套問題,利用 generator 實現了偽同步代碼。
更多細節可以參考黃子毅的這篇文章:精讀《Epitath 源碼 - renderProps 新用法》
2.4 React Hooks
React Hooks 則可以完美解決上面的嵌套問題,它擁有下面這幾個特性。
多個狀態不會產生嵌套,寫法還是平鋪的
允許函數組件使用 state 和部分生命周期
更容易將組件的 UI 與狀態分離
上面是一個結合了 useState 和 useEffect 兩個 hook 方法的例子,主要是在 resize 事件觸發時獲取到當前的 window.innerWidth
。這個 useWindowWidth 方法可以拿來在多個地方使用。常用的 Hook 方法如下:

3. useState & useRef
useState 是 React Hooks 中很基本的一個 API,它的用法主要有這幾種:
useState 接收一個初始值,返回一個數組,數組里面分別是當前值和修改這個值的方法(類似 state 和 setState)。
useState 接收一個函數,返回一個數組。
setCount 可以接收新值,也可以接收一個返回新值的函數。
const [ count1, setCount1 ] = useState(0);
const [ count2, setCount2 ] = useState(() => 0);
setCount1(1); // 修改 state
3.1 和 class state 的區別
雖然函數組件也有了 state,但是 function state 和 class state 還是有一些差異:
function state 的粒度更細,class state 過于無腦。
function state 保存的是快照,class state 保存的是最新值。
引用類型的情況下,class state 不需要傳入新的引用,而 function state 必須保證是個新的引用。
3.2 快照(閉包) vs 最新值(引用)
在開始前,先拋出這么一個問題。在 1s 內頻繁點擊10次按鈕,下面代碼的執行表現是什么?
如果是這段代碼呢?它又會是什么表現?
如果你能成功答對,那么恭喜你,你已經掌握了 useState 的用法。在第一個例子中,連續點擊十次,頁面上的數字會從0增長到10。而第二個例子中,連續點擊十次,頁面上的數字只會從0增長到1。
這個是為什么呢?其實這主要是引用和閉包的區別。
class 組件里面可以通過 this.state 引用到 count,所以每次 setTimeout 的時候都能通過引用拿到上一次的最新 count,所以點擊多少次最后就加了多少。
在 function component 里面每次更新都是重新執行當前函數,也就是說 setTimeout 里面讀取到的 count 是通過閉包獲取的,而這個 count 實際上只是初始值,并不是上次執行完成后的最新值,所以最后只加了1次。
3.3 快照和引用的轉換
如果我想讓函數組件也是從0加到10,那么該怎么來解決呢?聰明的你一定會想到,如果模仿類組件里面的 this.state
,我們用一個引用來保存 count 不就好了嗎?沒錯,這樣是可以解決,只是這個引用該怎么寫呢?我在 state 里面設置一個對象好不好?就像下面這樣:
const [state, setState] = useState({ count: 0 })
答案是不行,因為即使 state 是個對象,但每次更新的時候,要傳一個新的引用進去,這樣的引用依然是沒有意義。
setState({count: state.count + 1
})
3.3 useRef
想要解決這個問題,那就涉及到另一個新的 Hook 方法 —— useRef。useRef 是一個對象,它擁有一個 current 屬性,并且不管函數組件執行多少次,而 useRef 返回的對象永遠都是原來那一個。
useRef 有下面這幾個特點:
useRef
是一個只能用于函數組件的方法。useRef
是除字符串ref
、函數ref
、createRef
之外的第四種獲取ref
的方法。useRef
在渲染周期內永遠不會變,因此可以用來引用某些數據。修改
ref.current
不會引發組件重新渲染。
useRef vs createRef:
兩者都是獲取 ref 的方式,都有一個 current 屬性。
useRef 只能用于函數組件,createRef 可以用在類組件中。
useRef 在每次重新渲染后都保持不變,而 createRef 每次都會發生變化。
3.4 寫需求遇到的坑

之前在寫需求的時候遇到過這樣的一個坑。bankId
和 ref
都是從接口獲取到的,這里很自然就想到在 useCallback
里面指定依賴。

但是呢,這個 handlerReappear
方法需要在第一次進入頁面的時候,向 JS Bridge 注冊的事件,這就導致了一個問題,不管后來 handlerReappear
如何變化,registerHandler
里面依賴的 callback
都是第一次的,這也是閉包導致的問題。當然,你可能會說,我在 useEffect
里面也指定了依賴不好嗎?但要注意這是個注冊事件,意味著每次我都要清除上一次的事件,需要調用到 JS Bridge,在性能上肯定不是個好辦法。
最終,我選擇使用 useRef
來保存 bankId
和 ref
,這樣就可以通過引用來獲取到最新的值。

3.5 Vue3 Composition API

在 vue3 里面提供了新的 Composition API,之前知乎有個問題是 React Hooks 是否可以改為用類似 Vue 3 Composition API 的方式實現?
然后我寫了一篇文章,利用 Object.defineProperty
簡單實現了 Composition API,可以參考:用 React Hooks 簡單實現 Vue3 Composition API
當然這個實現還有很多問題,也比較簡單,可以參考工業聚寫的完整實現:react-use-setup
4. useEffect
useEffect
是一個 Effect Hook
,常用于一些副作用的操作,在一定程度上可以充當 componentDidMount
、componentDidUpdate
、componentWillUnmount
這三個生命周期。useEffect
是非常重要的一個方法,可以說是 React Hooks 的靈魂,它用法主要有這么幾種:
useEffect
接收兩個參數,分別是要執行的回調函數、依賴數組。如果依賴數組為空數組,那么回調函數會在第一次渲染結束后(
componentDidMount
)執行,返回的函數會在組件卸載時(componentWillUnmount
)執行。如果不傳依賴數組,那么回調函數會在每一次渲染結束后(
componentDidMount
和componentDidUpdate
)執行。如果依賴數組不為空數組,那么回調函數會在依賴值每次更新渲染結束后(componentDidUpdate)執行,這個依賴值一般是 state 或者 props。
useEffect 比較重要,它主要有這幾個作用:
代替部分生命周期,如 componentDidMount、componentDidUpdate、componentWillUnmount。
更加 reactive,類似 mobx 的 reaction 和 vue 的 watch。
從命令式變成聲明式,不需要再關注應該在哪一步做某些操作,只需要關注依賴數據。
通過 useEffect 和 useState 可以編寫一系列自定義的 Hook。
4.1 useEffect vs useLayoutEffect
useLayoutEffect 也是一個 Hook 方法,從名字上看和 useEffect 差不多,他倆用法也比較像。在90%的場景下我們都會用 useEffect,然而在某些場景下卻不得不用 useLayoutEffect。useEffect 和 useLayoutEffect 的區別是:
useEffect 不會 block 瀏覽器渲染,而 useLayoutEffect 會。
useEffect 會在瀏覽器渲染結束后執行,useLayoutEffect 則是在 DOM 更新完成后,瀏覽器繪制之前執行。
這兩句話該怎么來理解呢?我們以一個移動的方塊為例子:

在 useEffect 里面會讓這個方塊往后移動 600px 距離,可以看到這個方塊在移動過程中會閃一下。但如果換成了 useLayoutEffect 呢?會發現方塊不會再閃動,而是直接出現在了 600px 的位置。

原因是 useEffect 是在瀏覽器繪制之后執行的,所以方塊一開始就在最左邊,于是我們看到了方塊移動的動畫。然而 useLayoutEffect 是在繪制之前執行的,會阻塞頁面的繪制,所以頁面會在 useLayoutEffect 里面的代碼執行結束后才去繼續繪制,于是方塊就直接出現在了右邊。那么這里的代碼是怎么實現的呢?以 preact 為例,useEffect 在 options.commit
階段執行,而 useLayoutEffect 在 options.diffed
階段執行。然而在實現 useEffect 的時候使用了 requestAnimationFrame
,requestAnimationFrame
可以控制 useEffect 里面的函數在瀏覽器重繪結束,下次繪制之前執行。

5. useMemo
useMemo 的用法類似 useEffect,常常用于緩存一些復雜計算的結果。useMemo 接收一個函數和依賴數組,當數組中依賴項變化的時候,這個函數就會執行,返回新的值。
const sum = useMemo(() => {// 一系列計算
}, [count])
舉個例子會更加清楚 useMemo 的使用場景,我們就以下面這個 DatePicker 組件的計算為例:
DatePicker 組件每次打開或者切換月份的時候,都需要大量的計算來算出當前需要展示哪些日期。然后再將計算后的結果渲染到單元格里面,這里可以使用 useMemo 來緩存,只有當傳入的日期變化時才去計算。
6. useCallback
和 useMemo 類似,只不過 useCallback 是用來緩存函數。
6.1 匿名函數導致不必要的渲染
在我們編寫 React 組件的時候,經常會用到事件處理函數,很多人都會簡單粗暴的傳一個箭頭函數。
class App extends Component {render() {return <h1 onClick={() => {}}></h1>}
}
這種箭頭函數有個問題,那就是在每一次組件重新渲染的時候都會生成一個重復的匿名箭頭函數,導致傳給組件的參數發生了變化,對性能造成一定的損耗。
在函數組件里面,同樣會有這個傳遞新的匿名函數的問題。從下面這個例子來看,每次點擊 div,就會引起 Counter 組件重新渲染。這次更新明顯和 Input 組件無關,但每次重新渲染之后,都會創建新的 onChange 方法。這樣相當于傳給 Input 的 onChange 參數變化,即使 Input 內部做過 shadowEqual 也沒有意義了,都會跟著重新渲染。原本只想更新 count 值的,可 Input 組件 卻做了不必要的渲染。
這就是體現 useCallback 價值的地方了,我們可以用 useCallback 指定依賴項。在無關更新之后,通過 useCallback 取的還是上一次緩存起來的函數。因此,useCallback 常常配合 React.memo
來一起使用,用于進行性能優化。
7. useReducer && useContext
7.1 useReducer
useReducer 和 useState 的用法很相似,甚至在 preact 中,兩者實現都是一樣的。useReducer 接收一個 reducer 函數和初始 state,返回了 state 和 dispatch 函數,常常用于管理一些復雜的狀態,適合 action 比較多的場景。
7.2 useContext
在上一節講解 React16 新特性的時候,我們講過新版 Context API 的用法。
新版 Context 常常有一個提供數據的生產者(Provider),和一個消費數據的消費者(Consumer),我們需要通過 Consumer 來以 render props
的形式獲取到數據。如果從祖先組件傳來了多個 Provider,那最終就又陷入了 render props
嵌套地獄。
useContext 允許我們以扁平化的形式獲取到 Context 數據。即使有多個祖先組件使用多個 Context.Provider 傳值,我們也可以扁平化獲取到每一個 Context 數據。
7.3 實現一個簡單的 Redux
通過 useReducer 和 useContext,我們完全可以實現一個小型的 Redux。
reducer.js
Context.js
export const Context = createContext(null);
App.js
8. Custom Hooks
對于 react 來說,在函數組件中使用 state 固然有一些價值,但最有價值的還是可以編寫通用 custom hooks 的能力。想像一下,一個單純不依賴 UI 的業務邏輯 hook,我們開箱即用。不僅可以在不同的項目中復用,甚至還可以跨平臺使用,react、react native、react vr 等等。編寫自定義 hook 也需要以 use 開頭,這樣保證可以配合 eslint 插件使用。在 custom hooks 中也可以調用其他 hook,當前的 hook 也可以被其他 hook 或者組件調用。以官網上這個獲取好友狀態的自定義 Hook 為例:
這個自定義 Hook 里面對好友的狀態進行了監聽,每次狀態更新的時候都會去更新 isOnline,當組件卸載的時候會清除掉這個監聽。這就是 React Hooks 最有用的地方,它允許我們編寫自定義 Hook,然后這個自定義 Hook 可以復用給多個組件,并且不會和 UI 耦合到一起。
9. React Hooks 原理
由于 preact hooks 的代碼和原有的邏輯耦合度很小,這里為了更加淺顯易懂,我選用了 preact hooks 的源碼來解讀。
9.1 Hooks 執行流程
在 React 中,組件返回的 JSX 元素也會被轉換為虛擬 DOM,就是下方的 vnode,每個 vnode 上面掛載了一個 _component 屬性,這個屬性指向了組件實例。而在組件實例上面又掛載了一個 _hooks 屬性,這個 _hooks 屬性里面保存了我們執行一個組件的時候,里面所有 Hook 方法相關的信息。

首先,我們有一個全局的 currentIndex 變量,當組件第一次渲染或者更新的時候,它會在每次進入一個函數組件的時候都重置為0,每次遇到一個 Hook 方法就會增加1,同時將這個 Hook 方法的信息放到 _list 里面。
當我們下次進來或者進入下一個組件的時候, currentIndex 又會被置為0。
★組件渲染 => currentIndex 重置 0 => 遇到 Hooks 方法,放進 _list => currentIndex++ => 渲染結束
”
★組件更新 => currentIndex 重置 0 => 遇到 Hooks 方法,獲取 _list[currentIndex]=> currentIndex++ => 重復上面步驟 => 更新結束
”
這個時候就會從剛才的 _list 里面根據 currentIndex 來取出對應項,所以我們每次進來執行 useState,它依然能拿到上一次更新后的值,因為這里是緩存了起來。

通過上面的分析,你就不難發現,為什么 hooks 方法不能放在條件語句里面了。因為每次進入這個函數的時候,都是要和 currentIndex 一一匹配的,如果更新前后少了一個 Hook 方法,那么就完全對不上了,導致出現大問題。
9.2 useState 和 useReducer
這樣你再來看下面 useState 和 useReducer 的源碼就會更容易理解一些。
很明顯,getHookState 是根據 currentIndex 來從 _list 里面取和當前 Hook 相關的一些信息。如果是初始化狀態(即沒有 hookState._component
)這個屬性的時候,就會去初始化 useState 的兩個返回值,否則就會直接返回上一次緩存的結果。
9.3 useEffect
useEffect 和 useState 差不多,區別就在 useEffect 接收的函數會放到一個 _pendingEffects 里面,而非 _list 里面。
在 diff 結束之后會從 _pendingEffects 里面取出來函數一個個執行。afterPaint 里面使用了 requestAnimateFrame 這個方法,所以傳給 useEffect 里面的方法是在瀏覽器繪制結束之后才會執行的。
9.4 總結
最后,這里對 React Hooks 的整個運行流程來進行一下總結和梳理。
每個組件實例上掛載一個 _hooks 屬性,保證了組件之間不會影響。
每當遇到一個 hooks 方法,就將其 push 到
currentComponent._hooks._list
中,且 currentIndex 加一。每次渲染進入一個組件的時候,都會從將 currentIndex 重置為 0 。遇到 hooks 方法時,currentIndex 重復第二步。這樣可以把 currentIndex 和
currentComponent._hooks._list
中的對應項匹配起來,直接取上次緩存的值。函數組件每次重新執行后,useState 中還能保持上一次的值,就是來自于步驟3中的緩存。
由于依賴了 currentComponent 實例,所以 hooks 不能用于普通函數中。
10. React Hooks 實踐
得益于 react hooks 將業務邏輯從 ui 中抽離出來,目前社區里面關于 react hooks 的實踐,大都是從功能點出發。
從最簡單的 api 封裝,例如 useDebounce、useThrottle、useImmerState 等等,再到業務層面功能封裝,比較出名的庫有 react-use、umijs/hooks 等等。
舉個栗子:umijs/hooks 的表格:

在后臺管理系統開發中,表格是非常常見的場景,將分頁、查詢、loading、排序等等功能打包封裝成通用 Hook,就能發揮很大的潛力。

11. 推薦閱讀
Umi Hooks - 助力擁抱 React Hooks
為什么 React 現在要推行函數式組件,用 class 不好嗎?
useRequest- 螞蟻中臺標準請求 Hooks
·················?若川簡介?·················
你好,我是若川,畢業于江西高校。現在是一名前端開發“工程師”。寫有《學習源碼整體架構系列》20余篇,在知乎、掘金收獲超百萬閱讀。
從2014年起,每年都會寫一篇年度總結,已經堅持寫了8年,點擊查看年度總結。
同時,最近組織了源碼共讀活動,幫助4000+前端人學會看源碼。公眾號愿景:幫助5年內前端人走向前列。
掃碼加我微信 ruochuan02、拉你進源碼共讀群
今日話題
目前建有江西|湖南|湖北?籍 前端群,想進群的可以加我微信 ruochuan12?進群。分享、收藏、點贊、在看我的文章就是對我最大的支持~