這次的分享結合我在專案中使用 full hooks-based React Components 的一些經驗,給大家介紹一些我所認為的 React Hooks 最佳實踐。

文中的很多 term 是為了闡明一些概念所設,並非專有名詞,不需要當真。

回顧一下 React Hooks

首先還是簡單回顧一下 React Hooks。

先看傳統的 React Class-based Component。一個元件由四部分構成:

狀態

state

:一個統一的集中的 state

生命週期回撥

lifecycle methods

:一些需要誦記的生命週期相關的回撥函式(WillMount / Mounted / WillReceiveProps / Updated / WillUnmount 等等)

回撥函式

handlers

:一些回撥方法,在 view 層被呼叫,作用在 state 層上

渲染函式

render

:即元件的 view 層,負責產出元件的 VirtualDOM 節點,掛載回撥函式等

React Hooks 元件其實可以簡單地理解成一個 render 函式。這個 render 函式本身即元件。他透過 useState 和 useEffect 兩個函式來實現函式的“狀態化”,即獲得對 state 和生命週期的註冊和訪問能力。

Hooks 是一套新的框架

你可能不知道的流式 React Hooks(關於組織程式碼的最佳實踐)

相比類元件,Hooks 元件有以下特點

自上而下

:相比類元件的方法之間相互呼叫,作為函式的 Hooks 元件的具有更單純的邏輯流,即自上而下

弱化 handlers

:相比類元件的方法註冊,函式式元件雖然也可以實現在函式上下文中宣告回撥,但這對不如類方法來得自然。

簡化生命週期

:Hooks 透過一個單純的 useEffect 來註冊基於依賴變更的生命週期函式,把類元件中的生命週期都混合在一起。事實上,我們完全可以徹底拋棄對原先 React 元件生命週期的理解,直接來理解 useEffect,把他單純地當成在 render 過程中註冊的函式副作用。

分散的 state 和 effect 註冊和訪問

:Hooks 不再像類元件一般要求在統一的地方註冊元件用到的所有狀態以及生命週期方法,這使得更「模型內聚」的邏輯組織成為可能。

依賴驅動

:多個基礎 Hooks 在設計上都有 deps 的概念,用以實現基於依賴項的變更來執行所宣告的函式的能力

基於上述迥異的語法和完全平行的 API,基於 Hooks 的元件書寫可以被當作一門獨立於基於類元件的全新框架。我們應儘量避免以模仿類元件的風格去書寫 Hooks 元件的邏輯,而應當重新審視這種新的語法。

由於上述的語法特點,Hooks 適合透過「

基於變更

」的宣告風格來書寫,而非「

基於回撥

」的命令式方式來書寫。這會讓一個元件更易於拆分和複用邏輯並擁有更清晰的邏輯依賴關係。大家將逐步看到「基於變更」的風格的優勢,下面小舉兩個例子來對比一下「基於變更」和「基於回撥」的寫法:

例一:透過 useEffect 宣告請求

需求場景:更改一個 keyword state 併發起查詢的請求

基於回撥的寫法(仿類寫法)

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

}

這種寫法有幾個問題:

handleKeywordChange 若在兩次渲染中被多次呼叫,會出現 state 過舊的問題,從而得到的 latestState 將不是最新的,會產生bug。

(這個問題類元件也會存在)

query 方法每次都需要在 handler 中被命令式地呼叫,如果需要呼叫它的 handler 變多,則依賴關係語法複雜,且容易疏忽忘記手動呼叫。

query 使用的 queryState 就是最新的 state,卻每次需要由 handler 將 state 計算好交給 query 函式,方法間職責分割得不明確。

基於變更的寫法

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

}

上面的寫法解決了「基於回撥」寫法的所有問題。它把 state 作為了 query 的依賴,只要 state 發生變更,query 就會自動執行,且執行時機一定是在 state 變更以後。我們沒有命令式地呼叫 query,而是聲明瞭在什麼情況下它應當被呼叫。

當然這種寫法也不是沒有問題:

萬一需求場景要求我們在 state 的某些特定欄位變更的時候不觸發 query,上面的寫法就失效了

事實上,這個問題恰恰要求我們在寫 Hooks 時花更多的精力專注於「變」與「不變」的管理,而不是「調」與「不調」的管理上。

例二:註冊對 window size 的監聽

需求場景:在 window resize 時觸發 callback 函式

基於回撥的寫法(仿類寫法)

const Demo: FC = () => {

const callback = // 。。。

useEffect(() => {

window。addEventListener(‘resize’, callback);

return () => window。removeEventListener(‘resize’, callback);

}, []);

return // view

}

在「componentDidMount」的時候註冊這個監聽,在「componentWillUnmount」的時候登出它。很單純啊是不是?

但是問題來了,在類元件中,callback 可以是一個類方法(

method

),它的引用在整個元件生命週期中都不會發生改變。但是函式式元件中的 callback 是在每次執行的上下文中生成的,它極有可能每次都不一樣!這樣 window 物件上掛載的監聽將會是元件第一次執行產生的 callback,之後所有執行輪次中產生的 callback 都將不會被掛載到 window 的訂閱者中,bug 就出現了。

那改一下?

基於回撥的寫法2

const

Demo

FC

=

()

=>

{

const

callback

=

// 。。。

useEffect

(()

=>

{

window

addEventListener

‘resize’

callback

);

return

()

=>

window

removeEventListener

‘resize’

callback

);

},

callback

]);

return

// view

}

這樣把 callback 放到註冊監聽的 effect 的依賴中看起來似乎能 work,但是也太不優雅了。在元件的執行過程中,我們將瘋狂地在 window 物件上註冊登出註冊登出,聽起來就不太合理。下面看看基於變更的寫法:

基於變更的寫法

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

};

這裡我們透過一個 useState 和一個 useEffect 首先把 window resize 從一個回撥的註冊登出過程轉換成了一個表示 window size 的 state。之後依賴這個 state 的變更實現了對 callback 的呼叫。這個呼叫同樣是宣告式的,而不是直接手動命令式的呼叫的,而宣告式往往意味著更好的可測性。

上面的程式碼看似更復雜了,但事實上,只要我們把 2-10 行的程式碼抽離出來,很快就得到了一個跨元件可複用的自定義 Hooks:useWindowSize。使得在別的元件中使用基於 window resize 的回撥變得非常方便:

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

}

基於變更的寫法的關鍵在於把「

動作

」轉換成「

狀態

Marble Diagrams

透過上面的論述和例子我們可以看到在 Hooks-based 元件中合理地使用基於變更的程式碼可以帶來一定的好處。為了更好地理解「基於變更」這件事。這裡引入流式程式設計中常用於輔助理解的 Marble 圖。你將很快發現,我們一直在說的「基於變更」於流式程式設計中的「流」沒有兩樣:

RxMarble圖例

你可能不知道的流式 React Hooks(關於組織程式碼的最佳實踐)

流式程式設計中,一個珠子_(marble)_就代表一個推送過來的資料,一串橫向的珠子就代表一個數據流(

Observable

Subject

)在時間上的一系列推送資料。流式程式設計透過一系列運算子,對資料流實現加工整合對映等操作來實現程式設計邏輯。上圖的 merge 操作,是非常常用的合併兩個資料來源的運算子。

不可變資料流與「執行幀」

基於變更的 Hooks coding 其實是與 stream coding 相當同構的概念。兩者都弱化 callback,把 callback 包裝起來成為流或運算子。

Hooks 元件中的一個 state 就是流式程式設計中的流,即一串珠子

而一個 state 的每一次變更,便是一顆珠子

不可變資料流 immutable dataflow

為了完全地體現「變更」,所有的狀態更新都要做到

immutable

簡而言之:

讓引用的變化與值的變化完全一致

為了實現這一點,你可以:

每次 setState 的時候注意

自己實現一些 immutable utils

藉助第三方的資料結構庫,如 facebook的 ImmutableJS

(個人推薦 1 或 2,可以儘可能減少引入不必要的概念)

執行幀

在 Hooks-based 程式設計中,我們還要有所謂「執行幀」的概念。這種概念在其他框架如 vue / Angular 中很被弱化,而對 React 尤其是函式式元件中卻很有助於思考

你可能不知道的流式 React Hooks(關於組織程式碼的最佳實踐)

在元件上下文中的 state 或 props 一旦發生變更,就會觸發元件的執行。每次執行就相當於一幀渲染的繪製。所有的 marble 就串在執行幀與狀態構成的網格中

變更的源頭

對一個元件來說,能觸發它重新渲染的變更稱為「源」source。 一個元件的變更源一般有以下幾種:

props 變更

:即父元件傳遞給元件的 props 發生變更

事件 event

:如點選,如上文的 window resize 事件。對事件,需要將事件回撥包裝成 state

排程器

:即 animationFrame / interval / timeout

上述源頭,有些已經被「marble化」了,如 props。有些還沒有,需要我們包裝的方式把他們「marble 化」

例一:對事件的包裝

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

}

例二:對排程器的包裝(以 interval 為例)

你可能不知道的流式 React Hooks(關於組織程式碼的最佳實踐)

const

useInterval

=

interval

number

=>

{

const

intervalCount

setIntervalCount

=

useState

();

useEffect

(()

=>

{

const

intervalId

=

setInterval

(()

=>

{

setIntervalCount

count

=>

count

+

1

});

return

()

=>

clearInterval

intervalId

);

},

[]);

return

intervalCount

};

流式運算子

從源變更到最終 view 層需要的資料狀態,一個元件的資料組織可以抽象成下圖:

你可能不知道的流式 React Hooks(關於組織程式碼的最佳實踐)

中間的 operators 就是元件處理資料的核心邏輯。在流式程式設計中的 operator 幾乎都可以在 Hooks 中透過自定義 Hooks 寫出同構的表示。

這些「流式 Hook」是由基本 Hooks 複合而成的更高階的 Hooks,可以具有高度的複用性,使得程式碼邏輯更簡練。

對映(map)

透過 useMemo 就可以直接實現把一些變更整合到一起得到一個「computed」狀態

對應 ReactiveX 概念:map / combine / latestFrom

你可能不知道的流式 React Hooks(關於組織程式碼的最佳實踐)

const

state1

setState1

=

useState

initalState1

);

const

state2

setState2

=

useState

initialState2

);

const

computedState

=

useMemo

(()

=>

{

return

Array

state2

)。

fill

state1

)。

join

‘’

);

},

state1

state2

]);

跳過前幾次(skip) / 只在前幾次響應(take)

有時候我們不想在第一次的時候執行 effect 裡的函式,或進行 computed 對映。可以實現自己實現的 useCountEffect / useCountMemo 來實現

對應 ReactiveX 概念:take / skip

你可能不知道的流式 React Hooks(關於組織程式碼的最佳實踐)

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

);

};

流程與排程(debounce / throttle / delay)

在基於變更的 Hooks 元件中,debounce / throttle / delay 等操作變得非常簡單。debounce / throttle / delay 的物件將不再是 callback 函式本身,而是變更的狀態

對應 ReactiveX 的概念:debounce / delay / throttle

你可能不知道的流式 React Hooks(關於組織程式碼的最佳實踐)

你可能不知道的流式 React Hooks(關於組織程式碼的最佳實踐)

你可能不知道的流式 React Hooks(關於組織程式碼的最佳實踐)

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 的核心架構 action / reducer 模式在 Hooks 中的實現非常簡單,React 甚至專門提供了一個經過封裝的語法糖鉤子 useReducer 來實現這種模式。

對於非同步流程,我們同樣可以採用 action / reducer 的模式來實現一個 useAsync 鉤子來幫助我們處理非同步流程。

你可能不知道的流式 React Hooks(關於組織程式碼的最佳實踐)

這裡示意的是一個最簡單的基於 promise 的函式模式,類似 redux 中使用 redux-thunk 中介軟體。

同時,我們伴隨請求的資料狀態維護一組 loading / error / ready 欄位,用來標示當前資料的狀態。

useAsync 鉤子還可以內建對多個非同步流程的

競爭 / 保序 / 自動取消

等機制的控制邏輯。

下面示例了 useAsync 鉤子的用法,採用了 generator 來實現一個非同步流程對狀態的多步修改。甚至可以實現類似 redux-saga 的複雜非同步流程管理。

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

}

})

下面的程式碼例舉了一個透過類「action/ reducer」模式的非同步鉤子來維護一個字典型別的資料狀態的場景:

// 來自 props 或 state 的 actions

// fetch action: 獲取

let

fetchAction

{

type

‘query’

id

number

};

let

clearAction

{

type

‘clear’

ids

number

[];

// 需要保留的 ids

}

let

updateAction

{

type

‘update’

id

number

}

// 透過一個自定義的 merge 鉤子來保留上述三個狀態中最新變更的一個狀態

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

// 已經存在子列表的情況下,不對資料作變更,返回一個 identity 函式

if

action

type

===

‘query’

&&

prev

id

])

return

prevState

=>

prevState

// 拉取指定 id 下的列表資料

const

{

data

}

=

await

httpService

fetchListData

({

id

});

// 返回一個插入資料的狀態對映函式

return

prev

=>

({

。。。

prev

id

data

});

}

case

‘clear’

{

// 返回一個保留特定 id 資料的狀態對映函式

return

prev

=>

pick

// pick 是一個從物件裡獲取一部分 key value 對組成新物件的方法

prev

action

ids

);

}

default

return

prev

}

},

{

mode

‘multi’

immediate

false

}

);

你可能不知道的流式 React Hooks(關於組織程式碼的最佳實踐)

單例的 Hooks——全域性狀態管理

透過 Hooks 管理全域性狀態可以與傳統方式一樣,例如藉助 context 配合 redux 透過 Provider 來下發全域性狀態。這裡推薦更 Hooks 更方便的一種方式——單例 Hooks:Hox

透過第三方庫 Hox 提供的 createModel 方法可以產生一個掛載在虛擬元件中的全域性單例的 Hooks。這個虛擬元件的例項一經建立將在 app 的整個生命週期中存活,等於是產生了一個全域性的「marble 源」,從而任何的元件都可以使用這個 Hooks 來獲取這個源來處理自己的邏輯。

hox 的具體實現涉及自定義 React Reconciler,感興趣的同學可以去看一下它原始碼的實現。

流式 Hooks 侷限性

「基於變更」的 Hooks 元件書寫由於與流式程式設計非常相似,我也把他稱作「流式 Hooks」。

上面介紹了很多流式 Hooks 的好處。透過合適的邏輯拆分和複用,流式 Hooks 可以實現非常細粒度且高內聚的程式碼邏輯。在長期實踐中也證明了它是比較易於維護的。那麼這種風格 Hooks 存在什麼侷限性呢?

「過頻繁」的變更

在 React 中,存在三種不同「幀率」或「頻繁度」的東西:

調和 reconcile

:把 virtualDOM 的變更同步到真實的 DOM 上去

執行幀 rendering

:即 React 元件的執行頻率

事件 event

:即事件 dispatch 的頻率

這三者的觸發頻率是從上至下越來越高的

由於 React Hooks 的變更傳播的最小粒度是「執行幀」粒度,故一旦事件的發生頻率高過它(一般來說只會是同步的多次事件的觸發),這種風格的 Hooks 就需要一些較為 Hack 的邏輯來兜底處理。

你可能不知道的流式 React Hooks(關於組織程式碼的最佳實踐)

避免「為了流而流」

流式程式設計如 RxJS 大量被用於訊息通訊(如在 Angular 中),被用於處理複雜的事件流程。但其本身一直沒有成為主流的應用架構。導致這個狀況的一個瓶頸就在於它幾乎沒有辦法寫一星半點命令式的程式碼,從而會出現把一些透過命令式/回撥式很好實現的程式碼寫得非常冗長難懂的情況。

React Hooks 雖然可以與 RxJS 的語法產生很大成都的同構,但其本質仍然是命令式為底層的程式設計,故它可以是多正規化的。在編碼中,我們在絕大部分場景下可以透過流式的風格實現,但也應當避免為了流而流。如 Redux 下的一個關於哪些狀態應該放到全域性哪些應該放到元件內的 Issue 下評論的:選擇看起來更不奇怪

(less weird)

的那個

願景

目前我正在規劃和產出一套基礎的流式 Hooks,便於業務邏輯引用來書寫具有流式風格的 Hooks 程式碼 Marble Hooks

❤️

謝謝支援

以上便是本次分享的全部內容,希望對你有所幫助^_^

喜歡的話別忘了

分享、點贊、收藏

三連哦~。

歡迎關注公眾號

ELab團隊

收貨大廠一手好文章~

我們來自位元組跳動,是旗下大力教育前端部門,負責位元組跳動教育全線產品前端開發工作。 我們圍繞產品品質提升、開發效率、創意與前沿技術等方向沉澱與傳播專業知識及案例,為業界貢獻經驗價值。包括但不限於效能監控、元件庫、多端技術、Serverless、視覺化搭建、音影片、人工智慧、產品設計與營銷等內容。 歡迎感興趣的同學在評論區或使用內推碼內推到作者部門拍磚哦