【React】1260- 聊聊我眼中的 React Hooks
時(shí)至 2022 年年初,React Hooks 已在 React 生態(tài)中大放異彩,席卷了幾乎所有的 React 應(yīng)用。而其又與 Function Component 以及 Fiber 架構(gòu)幾近天作之合,在當(dāng)下,我們好像毫無拒絕它的道理。
誠然,Hooks 解決了 React Mixins 這個(gè)老大難的問題,但從它各種奇怪的使用體驗(yàn)上來說,我認(rèn)為現(xiàn)階段的 Hooks 并不是一個(gè)好的抽象。
紅臉太常見,也來唱個(gè)黑臉,本文將站在一個(gè)「挑刺兒」的視角,聊聊我眼中的 React Hooks ~
「奇怪的」規(guī)矩
React 官方制定了一些 Hooks 書寫規(guī)范用來規(guī)避 Bug,但這也恰恰暴露了它存在的問題。
命名
Hooks 并非普通函數(shù),我們一般用use開頭命名,以便與其他函數(shù)區(qū)分。
但相應(yīng)地,這也破壞了函數(shù)命名的語義。固定的use前綴使 Hooks 很難命名,你既為useGetState這樣的命名感到困惑,也無法理解useTitle到底是怎么個(gè)use法兒。
相比較而言,以_開頭的私有成員變量和$尾綴的流,則沒有類似的困擾。當(dāng)然,這只是使用習(xí)慣上的差異,并不是什么大問題。
調(diào)用時(shí)序
在使用useState的時(shí)候,你有沒有過這樣的疑惑:useState雖然每次render()都會(huì)調(diào)用,但卻可以為我保持住 State,如果我寫了很多個(gè),那它怎么知道我想要的是什么 State 呢?
const?[name,?setName]?=?useState('xiaoming')
console.log('some?sentences')
const?[age,?setAge]?=?useState(18)
兩次useState只有參數(shù)上的區(qū)別,而且也沒有語義上的區(qū)分(我們僅僅是給返回值賦予了語義),站在 useState的視角,React 怎么知道我什么時(shí)候想要name而什么時(shí)候又想要age的呢?
以上面的示例代碼來看,為什么第 1 行的useState會(huì)返回字符串name,而第 3 行會(huì)返回?cái)?shù)字age呢? 畢竟看起來,我們只是「平平無奇」地調(diào)用了兩次useState而已。答案是「時(shí)序」。useState的調(diào)用時(shí)序決定了結(jié)果,也就是,第一次的useState「保存」了 name的狀態(tài),而第二次「保存」了age的狀態(tài)。
//?Class?Component?中通過字面量聲明與更新?State,無一致性問題
this.setState({
??name:?'xiaoming',??//?State?字面量?`name`,`age`
??age:?18,
})
React 簡單粗暴地用「時(shí)序」決定了這一切(背后的數(shù)據(jù)結(jié)構(gòu)是鏈表),這也導(dǎo)致 Hooks 對調(diào)用時(shí)序的嚴(yán)格要求。也就是要避免所有的分支結(jié)構(gòu),不能讓 Hooks 「時(shí)有時(shí)無」。
//???典型錯(cuò)誤
if?(some)?{
??const?[name,?setName]?=?useState('xiaoming')
}
這種要求完全依賴開發(fā)者的經(jīng)驗(yàn)抑或是 Lint,而站在一般第三方 Lib 的角度看,這種要求調(diào)用時(shí)序的 API 設(shè)計(jì)是極為罕見的,非常反直覺。
最理想的 API 封裝應(yīng)當(dāng)是給開發(fā)者認(rèn)知負(fù)擔(dān)最小的。好比封裝一個(gè)純函數(shù)add(),不論開發(fā)者是在什么環(huán)境調(diào)用、在多么深的層級(jí)調(diào)用、用什么樣的調(diào)用時(shí)序,只要傳入的參數(shù)符合要求,它就可以正常運(yùn)作,簡單而純粹。
function?add(a:?number,?b:?number)?{
??return?a?+?b
}
function?outer()?{
??const?m?=?123;
??setTimeout(()?=>?{
????request('xx').then((n)?=>?{
????? const result = add(m, n)?????????//?符合直覺的調(diào)用:無環(huán)境要求
????})
??},?1e3)
}
可以說「React 確實(shí)沒辦法讓 Hooks 不要求環(huán)境」,但也不能否認(rèn)這種方式的怪異。
類似的情況在redux-saga里也有,開發(fā)者很容易寫出下面這種「符合直覺」的代碼,而且怎么也「看」不出有問題。
import?{?call?}?from?'redux-saga/effects'
function*?fetch()?{
??setTimeout(function*?()?{
????const?user?=?yield?call(fetchUser)?
????console.log('hi',?user)??????????????????//?不會(huì)執(zhí)行到這兒
??},?1e3)?????????????????????
}
yield call()在 Generator 里調(diào)用,看起來真的很「合理」。但實(shí)際上,function*需要 Generator 執(zhí)行環(huán)境,而call也需要redux-saga的執(zhí)行環(huán)境。雙重要求之下,實(shí)例代碼自然無法正常運(yùn)行。
useRef 的「排除萬難」
從本義上來說,useRef其實(shí)是 Class Component 時(shí)代React.createRef()的等價(jià)替代。
官方文檔中最開始的示例代碼可以佐證這一點(diǎn)(如下所示,有刪減):
function?TextInputWithFocusButton()?{
??const?inputEl?=?useRef(null);
??return?(
????type="text"?/>
??);
}
但因?yàn)槠鋵?shí)現(xiàn)特殊,也常作他用。
React Hooks 源碼中,useRef僅在 Mount 時(shí)期初始化對象,而 Update 時(shí)期返回 Mount 時(shí)期的結(jié)果(memoizedState)。這意味著一次完整的生命周期中,useRef保留的引用始終不會(huì)改變。


而這一特點(diǎn)卻讓它成為了 Hooks 閉包救星。
「遇事不決,useRef !」(useRef存在許多濫用的情況,本文不多贅述)
每一個(gè) Function 的執(zhí)行都有與之相應(yīng)的 Scope,對于面向?qū)ο髞碚f,this引用即是連接了所有 Scope 的 Context(當(dāng)然前提是在同一個(gè) Class 下)。
class?Runner?{
??runCount?=?0
??run()?{
????console.log('run')
????this.runCount?+=?1
??}
??xrun()?{
????this.run()
????this.run()
????this.run()
??}
??output()?{
????this.xrun()
????//?即便是「間接調(diào)用」`run`,這里「仍然」能獲取?`run`?的執(zhí)行信息
????console.log(this.runCount)?//?3
??}?
}
在 React Hooks 中,每一次的 Render 由彼時(shí)的 State 決定,Render 完成 Context 即刷新。優(yōu)雅的 UI 渲染,干凈而利落。
但useRef多少違背了設(shè)計(jì)者的初衷, useRef可以橫跨多次 Render 生成的 Scope,它能保留下已執(zhí)行的渲染邏輯,卻也能使已渲染的 Context 得不到釋放,威力無窮卻也作惡多端。
而如果說 this引用是面向?qū)ο笾凶钪饕母弊饔茫敲?useRef亦同。從這一點(diǎn)來說,擁有 useRef寫法的 Function Component 注定難以達(dá)成「函數(shù)式」。
有缺陷的生命周期
構(gòu)造時(shí)
Class Component 和 Function Component 之間還有一個(gè)很大的「Bug」,Class Component 僅實(shí)例化一次后續(xù)僅執(zhí)行render() ,而 Function Component 卻是在不斷執(zhí)行自身。
這導(dǎo)致 Function Component 相較 Class Component 實(shí)際缺失了對應(yīng)的constructor構(gòu)造時(shí)。當(dāng)然如果你有辦法只讓 Function 里的某段邏輯只執(zhí)行一遍,倒是也可以模擬出constructor。
//?比如使用?useRef?來構(gòu)造
function?useConstructor(callback)?{
??const?init?=?useRef(true)
??if?(init.current)?{
????callback()
????init.current?=?false
??}
}
生命周期而言, constructor 不能類同 useEffect ,如果實(shí)際節(jié)點(diǎn)渲染時(shí)長較長,二者會(huì)有很大時(shí)差。
也就是說,Class Component 和 Function Component 的生命周期 API 并不能完全一一對應(yīng),這是一個(gè)很引發(fā)錯(cuò)誤的地方。
設(shè)計(jì)混亂的 useEffect
在了解useEffect的基本用法后,加上對其字面意思的理解(監(jiān)聽副作用),你會(huì)誤以為它等同于 Watcher。
useEffect(()?=>?{
??//?watch?到?`a`?的變化
??doSomething4A()
},?[a])
但很快你就會(huì)發(fā)現(xiàn)不對勁,如果變量a未能觸發(fā) re-render,監(jiān)聽并不會(huì)生效。也就是說,實(shí)際還是應(yīng)該用于監(jiān)聽 State 的變化,即useStateEffect。但參數(shù)deps卻并未限制僅輸入 State。如果不是為了某些特殊動(dòng)作,很難不讓人認(rèn)為是設(shè)計(jì)缺陷。
const?[a]?=?useState(0)
const?[b]?=?useState(0)
useEffect(()?=>?{
????//?假定此處為?`a`?的監(jiān)聽
},?[a])
useEffect(()?=>?{
????//?假定此處為?`b`?的監(jiān)聽
??//?實(shí)際即便?`b`?未變化也并未監(jiān)聽?`a`,但此處仍然因?yàn)闀?huì)因?yàn)?`a`?變化而執(zhí)行
},?[b,?Date.now()])????????//?因?yàn)?Date.now()?每次都是新的值
useStateEffect的理解也并不到位,因?yàn)閡seEffect實(shí)際還負(fù)責(zé)了 Mount 的監(jiān)聽,你需要用「空依賴」來區(qū)分 Mount 和 Update。
useEffect(onMount,?[])
單一 API 支持的能力越多,也意味著其設(shè)計(jì)越混亂。復(fù)雜的功能不僅考驗(yàn)開發(fā)者的記憶,也難于理解,更容易因錯(cuò)誤理解而引發(fā)故障。
useCallback
性能問題?
在 Class Component 中我們常常把函數(shù)綁在this上,保持其的唯一引用,以減少子組件不必要的重渲染。
class?App?{
??constructor()?{
????//?方法一
????this.onClick?=?this.onClick.bind(this)
??}
??onClick()?{
????console.log('I?am?`onClick`')
??}
??//?方法二
??onChange?=?()?=>?{}
??render()?{
????return?(
??????
????)
??}
}
在 Function Component 中對應(yīng)的方案即useCallback :
//???有效優(yōu)化
function?App()?{
??const?onClick?=?useCallback(()?=>?{
????console.log('I?am?`onClick`')
??},?[])
??return?()
}
//???錯(cuò)誤示范,`onClick`?在每次?Render?中都是全新的,?會(huì)因此重渲染
function?App()?{
??//?...?some?states
??const?onClick?=?()?=>?{
????console.log('I?am?`onClick`')
??}
??return?()
}
useCallback可以在多次重渲染中仍然保持函數(shù)的引用, 第2行的onClick也始終是同一個(gè),從而避免了子組件的重渲染。
useCallback源碼其實(shí)也很簡單:

Mount 時(shí)期僅保存 callback 及其依賴數(shù)組

Update 時(shí)期判斷如果依賴數(shù)組一致,則返回上次的 callback
順便再看看useMemo的實(shí)現(xiàn),其實(shí)它與 useCallback 的區(qū)別僅僅是多一步 Invoke:

無限套娃?
相比較未使用useCallback帶來的性能問題,真正麻煩的是useCallback帶來的引用依賴問題。
??//?當(dāng)你決定引入?`useCallback`?來解決重復(fù)渲染問題
function?App()?{
??//?請求?A?所需要的參數(shù)
??const?[a1,?setA1]?=?useState('')
??const?[a2,?setA2]?=?useState('')
??//?請求?B?所需要的參數(shù)
??const?[b1,?setB1]?=?useState('')
??const?[b2,?setB2]?=?useState('')
??//?請求?A,并處理返回結(jié)果
??const?reqA?=?useCallback(()?=>?{
????requestA(a1,?a2)
??},?[a1,?a2])
??//?請求?A、B,并處理返回結(jié)果
??const?reqB?=?useCallback(()?=>?{
????reqA()???????????????????????????????????????????//?`reqA`的引用始終是最開始的那個(gè),
??? requestB(b1, b2)???????????????????????//?當(dāng)`a1`,`a2`變化后`reqB`中的`reqA`其實(shí)是過時(shí)的。
??}, [b1, b2])???????????????????//?當(dāng)然,把`reqA`加到`reqB`的依賴數(shù)組里不就好了?
?????????????????????????????????????????????????????????????//?但你在調(diào)用`reqA`這個(gè)函數(shù)的時(shí)候,
?????????????????????????????????????????????????????????????????//?你怎么知道「應(yīng)該」要加到依賴數(shù)組里呢?
??return?(
????<>
??????
??????
????>
??)
}
從上面示例可以看到,當(dāng)useCallback之前存在依賴關(guān)系時(shí),它們的引用維護(hù)也變得復(fù)雜。調(diào)用某個(gè)函數(shù)時(shí)要小心翼翼,你需要考慮它有沒有引用過時(shí)的問題,如有遺漏又沒有將其加入依賴數(shù)組,就會(huì)產(chǎn)生 Bug。
Use-Universal
Hooks 百花齊放的時(shí)期誕生了許多工具庫,僅ahooks就有 62 個(gè)自定義 Hooks,真可謂「萬物皆可 use」~ 真的有必要封裝這么多 Hooks 嗎?又或者說我們真的需要這么多 Hooks 嗎?
合理封裝?
盡管在 React 文檔中,官方也建議封裝自定義 Hooks 提高邏輯的復(fù)用性。但我覺得這也要看情況,并不是所有的生命周期都有必要封裝成 Hooks。
//?1.?封裝前
function?App()?{
??useEffect(()?=>?{???????????//?`useEffect`?參數(shù)不能是?async?function
????(async?()?=>?{
??????await?Promise.all([fetchA(),?fetchB()])
??????await?postC()
????})()
??},?[])
??return?(123)
}
//?--------------------------------------------------
//?2.?自定義?Hooks
function?App()?{
??useABC()
??return?(123)
}
function?useABC()?{
??useEffect(()?=>?{
????(async?()?=>?{
??????await?Promise.all([fetchA(),?fetchB()])
??????await?postC()
????})()
??},?[])
}
//?--------------------------------------------------
//?3.?傳統(tǒng)封裝
function?App()?{
??useEffect(()?=>?{
????requestABC()
??},?[])
??return?(123)
}
async?function?requestABC()?{
??await?Promise.all([fetchA(),?fetchB()])
??await?postC()
}
在上面的代碼中,對生命周期中的邏輯封裝為 HookuseABC反而使其耦合了生命周期回調(diào),降低了復(fù)用性。即便我們的封裝中不包含任何 Hooks,在調(diào)用時(shí)也僅僅是包一層useEffect而已,不算費(fèi)事,而且讓這段邏輯也可以在 Hooks 以外的地方使用。
如果自定義 Hooks 中使用到的useEffect和useState總次數(shù)不超過 2 次,真的應(yīng)該想一想這個(gè) Hook 的必要性了,是否可以不封裝。
簡單來說,Hook 要么「掛靠生命周期」要么「處理 State」,否則就沒必要。
重復(fù)調(diào)用
Hook 調(diào)用很「反直覺」的就是它會(huì)隨重渲染而不停調(diào)用,這要求 Hook 開發(fā)者要對這種反復(fù)調(diào)用有一定預(yù)期。
正如上文示例,對請求的封裝,很容易依賴useEffect,畢竟掛靠了生命周期就能確定請求不會(huì)反復(fù)調(diào)。
function?useFetchUser(userInfo)?{
??const?[user,?setUser]?=?useState(null)
??useEffect(()?=>?{
????fetch(userInfo).then(setUser)
??},?[])
??return?user
}
但,useEffect真的合適嗎?這個(gè)時(shí)機(jī)如果是DidMount,那執(zhí)行的時(shí)機(jī)還是比較晚的,畢竟如果渲染結(jié)構(gòu)復(fù)雜、層級(jí)過深,DidMount就會(huì)很遲。
比如,ul中渲染了 2000 個(gè) li:
function?App()?{
??const?start?=?Date.now()
??useEffect(()?=>?{
????console.log('elapsed:',?Date.now()?-?start,?'ms')
??},?[])
??return?(
????
??????{Array.from({?length:?2e3?}).map((_,?i)?=>?(- {i}
))}
????
??)
}
//?output
//?elapsed:?242?ms
那不掛靠生命周期,而使用狀態(tài)驅(qū)動(dòng)呢?似乎是個(gè)好主意,如果狀態(tài)有變更,就重新獲取數(shù)據(jù),好像很合理。
useEffect(()?=>?{
??fetch(userInfo).then(setUser)
},?[userInfo])???????????????????//?請求參數(shù)變化時(shí),重新獲取數(shù)據(jù)
但初次執(zhí)行時(shí)機(jī)仍然不理想,還是在DidMount。
let?start?=?0
let?f?=?false
function?App()?{
??const?[id,?setId]?=?useState('123')
??const?renderStart?=?Date.now()
??useEffect(()?=>?{
????const?now?=?Date.now()
????console.log('elapsed?from?start:',?now?-?start,?'ms')
????console.log('elapsed?from?render:',?now?-?renderStart,?'ms')
??},?[id])???????????????????????//?此處監(jiān)聽?`id`?的變化
??if?(!f)?{
????f?=?true
????start?=?Date.now()
????setTimeout(()?=>?{
??????setId('456')
????},?10)
??}
??return?null
}
//?output
//?elapsed?from?start:?57?ms
//?elapsed?from?render:?57?ms
//?elapsed?from?start:?67?ms
//?elapsed?from?render:?1?ms
這也是上文為什么說useEffect設(shè)計(jì)混亂,你把它當(dāng)做 State Watcher 的時(shí)候,其實(shí)它還暗含了「初次執(zhí)行在DidMount」的邏輯。從字面意思Effect來看,這個(gè)邏輯才是副作用吧。。。
狀態(tài)驅(qū)動(dòng)的封裝除了調(diào)用時(shí)機(jī)以外,其實(shí)還有別的問題:
function?App()?{
??const?user?=?useFetchUser({??????????//?乍一看似乎沒什么問題
????name:?'zhang',???????????????
????age:?20,???????????????????????
??})
??return?({user?.name})
}
實(shí)際上,組件重渲染會(huì)導(dǎo)致請求入?yún)⒅匦掠?jì)算 -> 字面量聲明的對象每次都是全新的 -> useFetchUser因此不停請求 -> 請求變更了 Hook 內(nèi)的 State user -> 外層組件
這是一個(gè)死循環(huán)!
當(dāng)然,你可以用Immutable來解決同一參數(shù)重復(fù)請求的問題。
useEffect(()?=>?{
??//?xxxx
},?[?Immutable.Map(userInfo)?])
但總的看來,封裝 Hooks 遠(yuǎn)遠(yuǎn)不止是變更了你代碼的組織形式而已。比如做數(shù)據(jù)請求,你可能因此而走上狀態(tài)驅(qū)動(dòng)的道路,同時(shí),你也要解決狀態(tài)驅(qū)動(dòng)隨之帶來的新麻煩。
為了 Mixin ?
其實(shí),Mixin 的能力也并非 Hooks 一家獨(dú)占,我們完全可以使用 Decorator 封裝一套 Mixin 機(jī)制。也就是說, Hooks 不能依仗 Mixin 能力去力排眾議。
const?HelloMixin?=?{
??componentDidMount()?{
????console.log('Hello,')
??}
}
function?mixin(Mixin)?{
??return?function?(constructor)?{
????return?class?extends?constructor?{
??????componentDidMount()?{
????????Mixin.componentDidMount()
????????super.componentDidMount()
??????}
????}
??}
}
@mixin(HelloMixin)
class?Test?extends?React.PureComponent?{
??componentDidMount()?{
????console.log('I?am?Test')
??}
??render()?{
????return?null
??}
}
render( )?//?output:?Hello,?\n?I?am?Test
不過 Hooks 的組裝能力更強(qiáng)一些,也容易嵌套使用。但需要警惕層數(shù)較深的 Hooks,很可能在某個(gè)你不知道的角落就潛伏著一個(gè)有隱患的useEffect。
小結(jié)
本文沒有鼓吹 Class Component 拒絕使用 React Hooks 的意思,反而是希望通過細(xì)致地比對二者,從而更深入理解 Hooks。 React Hooks 的各種奇怪之處,也正是潛在癥結(jié)之所在。 在 Hooks 之前,F(xiàn)unction Component 都是 Stateless 的,小巧、可靠但功能有限。Hooks 為 Function Component 賦予了 State 能力并提供了生命周期,使 Function Component 的大規(guī)模使用成為了可能。 Hooks 的「優(yōu)雅」來自向函數(shù)式的致敬,但useRef的濫用讓 Hooks 離「優(yōu)雅」相去甚遠(yuǎn)。 大規(guī)模實(shí)踐 React Hooks 仍然有諸多問題,無論是從語義理解抑或是封裝的必要性。 創(chuàng)新不易,期待 React 官方之后會(huì)有更好的設(shè)計(jì)吧。
