[譯] 為什么 React Hooks 是錯誤的抽象
本文已獲得原作者的獨家授權(quán),有想轉(zhuǎn)載的朋友們可以在后臺聯(lián)系我申請開白哦! PS:歡迎掘友們向我投稿哦,被采用的文章還可以送你掘金精美周邊!
原文地址:Why React Hooks Are the Wrong Abstraction 原文作者:Austin Malerba 譯文出自:掘金翻譯計劃 本文永久鏈接:github.com/xitu/gold-m… 譯者:fltenwall 校對者:zenblo PassionPenguin
在開始之前, 我想表達(dá)我對 React 團(tuán)隊多年來所付出的努力的感激。他們創(chuàng)建了一個很棒的框架,從很多方面來說,它是我對現(xiàn)代 Web 開發(fā)的引路人。他們?yōu)槲忆伷搅说缆罚屛掖_信我的想法是正確的,如果沒有他們的聰明才智,我不可能得出這些結(jié)論。
在今天的文章中,我將提出我所觀察到的 Hooks 的缺點,并提出一種同樣強大但不需要太多注意事項的替代 API 。我要說的是,這個 替代 API 有點冗長,但它對計算的浪費較少,概念上更準(zhǔn)確,而且與框架無關(guān)。
Hooks 的問題 #1: 附加渲染
作為設(shè)計的一般規(guī)則,我認(rèn)識到我們應(yīng)該首先禁止用戶犯錯誤。只有當(dāng)我們無法阻止用戶犯錯誤時,我們才應(yīng)該在他們犯了錯誤后通知他們。
舉個例子,當(dāng)允許用戶在輸入字段中輸入數(shù)量時,我們可以允許他們輸入字母數(shù)字字符,如果在他們的輸入中發(fā)現(xiàn)字母字符,就向他們顯示錯誤消息。但是,如果我們只允許用戶在字段中輸入數(shù)字字符,我們就可以提供更好的用戶體驗,這樣就不需要檢查是否包含字母字符了。
React 的行為與此非常相似。如果我們從概念上考慮 Hooks,它們在組件的整個生命周期內(nèi)都是靜態(tài)的。我的意思是說,一旦聲明了,我們就不能從組件中移除它們,也不能改變它們相對于其他 Hooks 的位置。React 使用 lint 規(guī)則并拋出錯誤,試圖阻止開發(fā)人員違反這個 Hooks 的細(xì)節(jié)。
從這個意義上說,React 允許開發(fā)者犯錯誤,然后試圖警告用戶他們的錯誤。為了說明白我的意思,看下這個例子:
const App = () => {
const [countOne, setCountOne] = useState(0);
if (countOne === 0) {
const [countTwo, setCountTwo] = useState(0);
}
return (
<button
onClick={() => {
setCountOne((prev) => prev + 1);
}}
>
Increment Count One: {countOne}
</button>
);
};
復(fù)制代碼
當(dāng)計數(shù)器增加時,會在第二次渲染時產(chǎn)生一個錯誤,因為組件將刪除第二個 useState Hooks:
Error: Rendered fewer hooks than expected. This may be caused by an accidental early return statement.
復(fù)制代碼
組件第一次渲染時,Hooks 的位置決定了 React 在后續(xù)渲染時必須在哪里找到 Hooks。
既然 Hooks 在組件的生命周期內(nèi)都是靜態(tài)的,那么我們在組件構(gòu)造時聲明它們不是比在渲染階段聲明它們更有意義嗎?如果我們在組件的構(gòu)造過程中附加了 Hooks,我們就不再需要擔(dān)心強制執(zhí)行 Hooks 的規(guī)則,因為在組件的生命周期中,Hooks 不會再有機會改變位置或被移除。
不幸的是,函數(shù)組件沒有構(gòu)造函數(shù)的概念,但是讓我們假設(shè)它們是構(gòu)造函數(shù)。我想它會像下面這樣:
const App = createComponent(() => {
// This is a constructor function that only runs once
// per component instance.
// We would declare our hooks in the constructor.
const [count, setCount] = useState(0)
// The constructor function returns a render function.
return (props, state) => (
<div>
{/*...*/}
</div>
);
});
復(fù)制代碼
通過在構(gòu)造函數(shù)中將 Hooks 附加到組件上,我們不必?fù)?dān)心它們在重新渲染時發(fā)生移位。
如果你在想,“你不能僅僅將 Hooks 移動到構(gòu)造函數(shù)。他們需要在每次渲染時運行,以獲取最新的值” 在這一點上,那么你是完全正確的!
我們不能只是將 Hooks 移出渲染函數(shù),因為我們會破壞它們。所以我們要用別的東西來代替它們。但首先,Hooks 的第二個主要問題是:
Hooks 的問題 #2: 假設(shè)狀態(tài)改變
我們知道,任何時候組件的狀態(tài)發(fā)生變化,React 都會重新渲染該組件。當(dāng)我們的組件因大量的狀態(tài)和邏輯而變得臃腫時,這就會成為一個問題。假設(shè)我們有一個組件,它有兩個不相關(guān)的狀態(tài): A 和 B。如果我們更新狀態(tài) A,我們的組件會因為狀態(tài)的改變而重新呈現(xiàn)。即使 B 沒有改變,任何依賴于它的邏輯都會重新運行,除非我們用 useMemo/useCallback 包裝這個邏輯。
這是一種浪費,因為 React 本質(zhì)上是說 “好吧,在渲染函數(shù)中重新計算所有這些值”,然后當(dāng)它遇到 useMemo 或者 useCallback 時,它就會返回那個決定,并在碎片上退出。但是,如果 React 只運行它需要運行的內(nèi)容,那就更有意義了。
響應(yīng)式編程
響應(yīng)式編程已經(jīng)存在很長一段時間了,但最近在 UI 框架中成為一種流行的編程范式。
響應(yīng)式編程的核心思想是,變量是可觀察的,當(dāng)一個可觀察對象的值發(fā)生變化時,觀察者會通過回調(diào)函數(shù)來通知這個變化:
const count$ = observable(5)
observe(count$, (count) => {
console.log(count)
})
count$.set(6)
count$.set(7)
// Output:
// 6
// 7
復(fù)制代碼
注意,當(dāng)我們修改可觀察的 count$ 值時,傳遞給 observe 的回調(diào)函數(shù)是如何執(zhí)行的。您可能想知道 count$ 后面的 $。這就是所謂的 Finnish Notation,它簡單地指出變量包含一個可觀察對象。
在響應(yīng)式編程中,還有一個計算或派生的可觀察對象的概念,它既可以觀察也可以被觀察。下面是一個派生的可觀察對象的例子,它跟蹤另一個可觀察對象的值,并對它應(yīng)用 transform:
const count$ = observable(5)
const doubledCount$ = derived(count$, (count) => count * 2)
observe(doubledCount$, (doubledCount) => {
console.log(doubledCount)
})
count$.set(6)
count$.set(7)
// Output:
// 12
// 14
復(fù)制代碼
這與我們前面的示例類似,只是現(xiàn)在我們將記錄重復(fù)的計數(shù)。
用響應(yīng)式來改造 React
在介紹了響應(yīng)式編程的基礎(chǔ)知識之后,讓我們看一下 React 中的一個示例,并通過使其更具響應(yīng)性來改進(jìn)它。
考慮一個應(yīng)用程序有兩個計數(shù)器和一個依賴于其中一個計數(shù)器的派生狀態(tài):
const App = () => {
const [countOne, setCountOne] = useState(0);
const [countTwo, setCountTwo] = useState(0);
const countTwoDoubled = useMemo(() => {
return countTwo * 2;
}, [countTwo]);
return (
<div>
<button
onClick={() => {
setCountOne((prev) => prev + 1);
}}
>
Increment Count One: {countOne}
</button>
<button
onClick={() => {
setCountTwo((prev) => prev + 1);
}}
>
Increment Count Two: {countTwo}
</button>
<p>Count Two Doubled: {countTwoDoubled}</p>
</div>
);
};
復(fù)制代碼
在這里,我們有邏輯將 countTwo 的值在渲染兩次,但如果 useMemo 發(fā)現(xiàn) countTwo 的值與它在前一個渲染上的值相同,那么再次渲染的值將不會在該渲染上重新派生。
結(jié)合我們早期的想法,我們可以從 React 中提取狀態(tài)職責(zé),并在構(gòu)造函數(shù)中將狀態(tài)設(shè)置為可觀察對象的圖形。當(dāng) observable 發(fā)生變化時,它就會通知組件,這樣組件就知道要重新渲染了:
const App = createComponent(({ setState }) => {
// This is a constructor layer that only runs once.
// Create observables to hold our counter state.
const countOne$ = observable(0);
const countTwo$ = observable(0);
const countTwoDoubled$ = derived(countTwo$, (countTwo) => {
return countTwo * 2;
});
observe(
[countOne$, countTwo$, countTwoDoubled$],
(countOne, countTwo, countTwoDoubled) => {
setState({
countOne,
countTwo,
countTwoDoubled
});
}
);
// The constructor returns the render function.
return (props, state) => (
<div>
<button
onClick={() => {
countOne$.set((prev) => prev + 1);
}}
>
Increment Count One: {state.countOne}
</button>
<button
onClick={() => {
countTwo$.set((prev) => prev + 1);
}}
>
Increment Count Two: {state.countTwo}
</button>
<p>Count Two Doubled: {state.countTwoDoubled}</p>
</div>
);
});
復(fù)制代碼
在上面的例子中,我們在構(gòu)造函數(shù)中創(chuàng)建的可觀察對象通過閉包在 render 函數(shù)中可用,閉包允許我們設(shè)置它們的值以響應(yīng)單擊事件。只有當(dāng) countwo$ 的值改變時,doubledCountTwo$ 觀察 countwo$ 并將其值加倍。注意,我們不是在渲染過程中而是在渲染之前獲得重復(fù)計數(shù)。最后,當(dāng)任何可觀察對象發(fā)生變化時,我們使用 observe 函數(shù)重新渲染組件。
這是一個優(yōu)雅的解決方案,有以下幾個原因:
狀態(tài)和效果不再是 React 的責(zé)任,而是一個專用的狀態(tài)管理庫的責(zé)任,這個庫可以跨框架使用,甚至不需要框架。 我們的可觀察對象只在構(gòu)造時進(jìn)行初始化,所以我們不必?fù)?dān)心違反 Hooks 規(guī)則或在呈現(xiàn)期間不必要地重新運行 Hooks 邏輯。 通過選擇僅在依賴項發(fā)生變化時重新派生值,我們避免了在不必要的時候重新運行派生邏輯。
通過對 React API 進(jìn)行一些修改,我們可以實現(xiàn)上面的代碼。
在這個沙盒中嘗試我們的演示!
這實際上與 Vue 3 使用其組合 API 的方式非常相似。盡管命名不同,但是可以看到這個 Vue 代碼片段驚人地相似:
// 示例來自 https://composition-api.vuejs.org/#usage-in-components
import { reactive, computed, watchEffect } from 'vue'
function setup() {
const state = reactive({
count: 0,
double: computed(() => state.count * 2)
})
function increment() {
state.count++
}
return {
state,
increment
}
}
const renderContext = setup()
watchEffect(() => {
renderTemplate(
`<button @click="increment">
Count is: {{ state.count }}, double is: {{ state.double }}
</button>`,
renderContext
)
})
復(fù)制代碼
如果這還不夠令人信服,看看當(dāng)我們引入一個構(gòu)造函數(shù)層來反應(yīng)函數(shù)組件時,“引用” 變得多么簡單:
const App = createComponent(() => {
// We can achieve ref functionality via closures
let divEl = null;
return (props, state) => (
<div ref={(el) => divEl = el}>
{/*...*/}
</div>
);
});
復(fù)制代碼
實際上,我們不需要使用 useRef,因為我們可以在構(gòu)造函數(shù)中聲明變量,然后在組件的生命周期中從任何地方讀寫它們。
也許更酷的是,我們可以很容易地將 refs 變成可觀察的:
const App = createComponent(() => {
const divEl$ = observable(null);
// Do something any time our "ref" changes
observe(divEl$, (divEl) => {
console.log(divEl)
});
return (props, state) => (
<div ref={divEl$.set}>
{/*...*/}
</div>
);
});
復(fù)制代碼
當(dāng)然,我的 observable,derived,和 observe 的實現(xiàn)都有 bug,并沒有形成一個完整的狀態(tài)管理解決方案。更不用說這些精心設(shè)計的示例忽略了一些考慮因素,但不用擔(dān)心:我在這個問題上花了很多心思,我的想法在名為 Elementos 的新響應(yīng)式狀態(tài)管理庫中達(dá)到了頂峰!

Elementos 是一個與框架無關(guān)的響應(yīng)式狀態(tài)管理庫,強調(diào)狀態(tài)的可組合性和封裝性。如果你喜歡這篇文章,我強烈建議你去看看!
如果發(fā)現(xiàn)譯文存在錯誤或其他需要改進(jìn)的地方,歡迎到 掘金翻譯計劃 對譯文進(jìn)行修改并 PR,也可獲得相應(yīng)獎勵積分。文章開頭的 本文永久鏈接 即為本文在 GitHub 上的 MarkDown 鏈接。
最后
如果你覺得這篇內(nèi)容對你挺有啟發(fā),我想邀請你幫我三個小忙:
點個「在看」,讓更多的人也能看到這篇內(nèi)容(喜歡不點在看,都是耍流氓 -_-)
歡迎加我微信「huab119」拉你進(jìn)技術(shù)群,長期交流學(xué)習(xí)...
關(guān)注公眾號「前端勸退師」,持續(xù)為你推送精選好文,也可以加我為好友,隨時聊騷。

