自查,你的 React Hooks 夠優(yōu)雅嗎?
時(shí)至今日,React Hooks 已在 React 生態(tài)中大放異彩,席卷了幾乎所有的 React 應(yīng)用。而其又與 Function Component 以及 Fiber 架構(gòu)幾近天作之合,在當(dāng)下,我們好像毫無拒絕它的道理。
大廠技術(shù) 高級(jí)前端 Node進(jìn)階
點(diǎn)擊上方 程序員成長指北,關(guān)注公眾號(hào)
回復(fù)1,加入高級(jí)Node交流群
誠然,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à)替代。
官方文檔[1]中最開始的示例代碼可以佐證這一點(diǎn)(如下所示,有刪減):
function TextInputWithFocusButton() {
const inputEl = useRef(null);
return (
<input ref={inputEl} 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)?nbsp;`a` 變化而執(zhí)行
}, [b, Date.now()]) // 因?yàn)?nbsp;Date.now() 每次都是新的值
useStateEffect的理解也并不到位,因?yàn)?code style="font-size: 14px;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;color: rgb(255, 93, 108);">useEffect實(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 (
<Sub onClick={this.onClick} onChange={this.onChange} />
)
}
}
在 Function Component 中對應(yīng)的方案即 useCallback :
// ? 有效優(yōu)化
function App() {
const onClick = useCallback(() => {
console.log('I am `onClick`')
}, [])
return (<Sub onClick={onClick} />)
}
// ? 錯(cuò)誤示范,`onClick` 在每次 Render 中都是全新的,<Sub> 會(huì)因此重渲染
function App() {
// ... some states
const onClick = () => {
console.log('I am `onClick`')
}
return (<Sub onClick={onClick} />)
}
useCallback可以在多次重渲染中仍然保持函數(shù)的引用, 第2行的onClick也始終是同一個(gè),從而避免了子組件<Sub>的重渲染。
useCallback源碼其實(shí)也很簡單:
Mount 時(shí)期僅保存 callback 及其依賴數(shù)組
Update 時(shí)期判斷如果依賴數(shù)組一致,則返回上次的 callback
順便再看看useMemo 的實(shí)現(xiàn),其實(shí)它與 useCallback 的區(qū)別僅僅是多一步 Invoke:
無限套娃?[2]
相比較未使用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 (
<>
<Comp onClick={reqA}></Comp>
<Comp onClick={reqB}></Comp>
</>
)
}
從上面示例可以看到,當(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 (<div>123</div>)
}
// --------------------------------------------------
// 2. 自定義 Hooks
function App() {
useABC()
return (<div>123</div>)
}
function useABC() {
useEffect(() => {
(async () => {
await Promise.all([fetchA(), fetchB()])
await postC()
})()
}, [])
}
// --------------------------------------------------
// 3. 傳統(tǒng)封裝
function App() {
useEffect(() => {
requestABC()
}, [])
return (<div>123</div>)
}
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 (
<ul>
{Array.from({ length: 2e3 }).map((_, i) => (<li key={i}>{i}</li>))}
</ul>
)
}
// 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 (<div>{user?.name}</div>)
}
實(shí)際上,組件重渲染會(huì)導(dǎo)致請求入?yún)⒅匦掠?jì)算 -> 字面量聲明的對象每次都是全新的 -> useFetchUser因此不停請求 -> 請求變更了 Hook 內(nèi)的 State user -> 外層組件<App>重渲染。
這是一個(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(<Test />) // 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ì)吧。
“原文鏈接:https://zhuanlan.zhihu.com/p/455317250?utm_source=ZHShareTargetIDMore&utm_medium=social&utm_oi=28389876432896
”
參考資料
官方文檔: https://link.zhihu.com/?target=https%3A//reactjs.org/docs/hooks-reference.html%23useref
[2]?: https://link.zhihu.com/?target=https%3A//www.alt-codes.net/check-mark-symbols.php
Node 社群
我組建了一個(gè)氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對Node.js學(xué)習(xí)感興趣的話(后續(xù)有計(jì)劃也可以),我們可以一起進(jìn)行Node.js相關(guān)的交流、學(xué)習(xí)、共建。下方加 考拉 好友回復(fù)「Node」即可。
如果你覺得這篇內(nèi)容對你有幫助,我想請你幫我2個(gè)小忙:
1. 點(diǎn)個(gè)「在看」,讓更多人也能看到這篇文章 2. 訂閱官方博客 www.inode.club 讓我們一起成長 點(diǎn)贊和在看就是最大的支持??





