為什麼順序呼叫對 React Hooks 很重要?
在 React Conf 2018 上,React團隊提出了Hooks提案。
如果你想知道什麼是 Hooks,及它能解決什麼問題,檢視我們的講座(介紹),理解React Hooks(常見的誤解)。
最初你可能會不喜歡 Hooks:

它們就像一段音樂,只有經過幾次用心聆聽才會慢慢愛上:

當你閱讀文件時,不要錯過關於最重要的部分——創造屬於你自己的Hooks!太多的人糾結於反對我們的觀點(class學習成本高等)以至於錯過了Hooks更重要的一面,Hooks像 functional mixins
,可以讓你創造和搭建屬於自己的Hook。
Hooks受啟發於一些現有技術,但在 Sebastian 和團隊分享他的想法之後,我才知道這些。不幸的是,這些API和現在在用的之間的關聯很容易被忽略,通過這篇文章,我希望可以幫助更多的人理解 Hooks提案中爭議較大的點。
接下來的部分需要你知道 Hook API 的 useState
和如何寫自定義Hook。如果你還不懂,可以看看早先的連結。
(免責說明:文章的觀點僅代表個人想法,與React團隊無關。話題大且複雜,其中可能有錯誤的觀點。)
一開始當你學習時你可能會震驚,Hooks 重渲染時是依賴於固定順序呼叫的,這裡有說明。
這個決定顯然是有爭議的,這也是為什麼會有人反對我們的提案。我們會在恰當的時機發布這個提案,當我們覺得文件和講座可以足夠好的描繪它時。
如果你在關注 Hooks API 的某些點,我建議你閱讀下 Sebastian對 1000+ 評論RFC的 全部回覆 , 足夠透澈但內容非常多,我可能會將評論中的每一段都變成自己的部落格文章。(事實上,我已經做過一次!)
我今天要關注一個具體部分。你可能還記得,每個 Hook 可以在元件裡被多次使用,例如,我們可以用 useState
宣告多個state:
function Form() { const [name, setName] = useState('Mary');// State variable 1 const [surname, setSurname] = useState('Poppins');// State variable 2 const [width, setWidth] = useState(window.innerWidth); // State variable 3 useEffect(() => { const handleResize = () => setWidth(window.innerWidth); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }); function handleNameChange(e) { setName(e.target.value); } function handleSurnameChange(e) { setSurname(e.target.value); } return ( <> <input value={name} onChange={handleNameChange} /> <input value={surname} onChange={handleSurnameChange} /> <p>Hello, {name} {surname}</p> <p>Window width: {width}</p> </> ); } 複製程式碼
注意我們用陣列解構語法來命名 useState()
返回的 state 變數,但這些變數不會連線到React元件上。相反,這個例子中, React將 name
視為“第一個state變數”, surname
視為“第二個state變數”,以此類推 。它們在重新渲染時用 順序呼叫 來保證被正確識別。這篇文章詳細的解釋了原因。
表面上看,依賴於順序呼叫只是 感覺有問題 ,直覺是一個有用的訊號,但它有時會誤導我們 —— 特別是當我們還沒有完全消化困惑的問題。 這篇文章,我會提到幾個經常有人提出修改Hooks的方案,及它們存在的問題 。
這篇文章不會詳盡無遺,如你所見,我們已經看過十幾種至數百種不同的替代方案,我們一直在 考慮 替換元件API。
諸如此類的部落格很棘手,因為即使你涉及了一百種替代方案,也有人強行提出一個來:“哈哈,你沒有想到 這個 ”!
在實踐中,不同替代方案提到的問題會有很多重複,我不會列舉 所有 建議的API(這需要花費數月時間),而是通過幾個具體示例展示最常見的問題,更多的問題就考驗讀者舉一反三的能力了。
這不是說 Hooks 就是完美的 ,但是一旦你瞭解其他解決方案的缺陷,你可能會發現 Hooks 的設計是有道理的。
缺陷 #1:無法提取 custom hook
出乎意料的是,大多數替代方案完全沒有提到custom hooks。可能是因為我們在“motivation”文件中沒有足夠強調 custom hooks,不過在弄懂 Hooks 基本原理之前,這是很難做到的。就像雞和蛋問題,但很大程度上 custom hooks 是提案的重點。
例如:有個替代方案是限制一個元件呼叫多次 useState()
,你可以把 state 放在一個物件裡,這樣還可以相容class不是更好嗎?
function Form() { const [state, setState] = useState({ name: 'Mary', surname: 'Poppins', width: window.innerWidth, }); // ... } 複製程式碼
要清楚,Hooks 是允許這種風格寫的,你不必將state拆分成一堆state變數(請參閱參見問題解答中的建議)。
但支援多次呼叫 useState()
的關鍵在於,你可以從元件中提取出部分有狀態邏輯(state + effect)到 custom hooks 中,同時可以單獨使用本地 state 和 effects:
function Form() { // 在元件內直接定義一些state變數 const [name, setName] = useState('Mary'); const [surname, setSurname] = useState('Poppins'); // 我們將部分state和effects移至custom hook const width = useWindowWidth(); // ... } function useWindowWidth() { // 在custom hook內定義一些state變數 const [width, setWidth] = useState(window.innerWidth); useEffect(() => { // ... }); return width; } 複製程式碼
如果你只允許每個元件呼叫一次 useState()
,你將失去用 custom hook 引入 state 能力,這就是 custom hooks 的關鍵。
缺陷 #2: 命名衝突
一個常見的建議是讓元件內 useState()
接收一個唯一標識key引數(string等)區分 state 變數。
和這主意有些出入,但看起來大致像這樣:
// :warning: This is NOT the React Hooks API function Form() { // 我們傳幾種state key給useState() const [name, setName] = useState('name'); const [surname, setSurname] = useState('surname'); const [width, setWidth] = useState('width'); // ... 複製程式碼
這試圖擺脫依賴順序呼叫(顯示key),但引入了另外一個問題 —— 命名衝突。
當然除了錯誤之外,你可能無法在同一個元件呼叫兩次 useState('name')
,這種偶然發生的可以歸結於其他任意bug,但是,當你使用一個 custom hook 時,你總會遇到想新增或移除state變數和effects的情況。
這個提議中,每當你在 custom hook 裡新增一個新的 state 變數時,就有可能破壞使用它的任何元件(直接或者間接),因為 可能已經有同名的變數 位於元件內。
這是一個沒有針對變化而優化的API,當前程式碼可能看起來總是“優雅的”,但應對需求變化時十分脆弱,我們應該從錯誤中吸取教訓。
實際中 Hooks 提案通過依賴順序呼叫來解決這個問題:即使兩個 Hooks 都用 name
state變數,它們也會彼此隔離,每次呼叫 useState()
都會獲得獨立的 “記憶體單元”。
我們還有其他一些方法可以解決這個缺陷,但這些方法也有自身的缺陷。讓我們加深探索這個問題。
缺陷 #3:同一個 Hook 無法呼叫兩次
給 useState
“加key”的另一種衍生提案是使用像Symbol這樣的東西,這樣就不衝突了對吧?
// :warning: This is NOT the React Hooks API const nameKey = Symbol(); const surnameKey = Symbol(); const widthKey = Symbol(); function Form() { // 我們傳幾種state key給useState() const [name, setName] = useState(nameKey); const [surname, setSurname] = useState(surnameKey); const [width, setWidth] = useState(widthKey); // ... 複製程式碼
這個提案看上去對提取出來的 useWindowWidth
Hook 有效:
// :warning: This is NOT the React Hooks API function Form() { // ... const width = useWindowWidth(); // ... } /********************* * useWindowWidth.js * ********************/ const widthKey = Symbol(); function useWindowWidth() { const [width, setWidth] = useState(widthKey); // ... return width; } 複製程式碼
但如果嘗試提取出來的 input handling,它會失敗:
// :warning: This is NOT the React Hooks API function Form() { // ... const name = useFormInput(); const surname = useFormInput(); // ... return ( <> <input {...name} /> <input {...surname} /> {/* ... */} </> ) } /******************* * useFormInput.js * ******************/ const valueKey = Symbol(); function useFormInput() { const [value, setValue] = useState(valueKey); return { value, onChange(e) { setValue(e.target.value); }, }; } 複製程式碼
(我承認 useFormInput()
Hook 不是特別好用,但你可以想象下它處理諸如驗證和 dirty state 標誌之類,如 Formik 。)
你能發現這個bug嗎?
我們呼叫 useFormInput()
兩次,但 useFormInput()
總是用同一個 key 呼叫 useState()
,就像這樣:
const [name, setName] = useState(valueKey); const [surname, setSurname] = useState(valueKey); 複製程式碼
我們再次發生了衝突。
實際中 Hooks 提案沒有這種問題,因為 每次 呼叫 useState()
會獲得單獨的state 。依賴於固定順序呼叫使我們免於擔心命名衝突。
缺陷 #4:鑽石問題(多層繼承問題)
從技術上來說這個和上一個缺陷相同,但它的臭名值得說說,甚至維基百科都有介紹。(有些時候還被稱為“致命的死亡鑽石” —— cool!)
我們自己的 mixin 系統就受到了傷害。
比如 useWindowWidth()
和 useNetworkStatus()
這兩個 custom hooks 可能要用像 useSubscription()
這樣的 custom hook,如下:
function StatusMessage() { const width = useWindowWidth(); const isOnline = useNetworkStatus(); return ( <> <p>Window width is {width}</p> <p>You are {isOnline ? 'online' : 'offline'}</p> </> ); } function useSubscription(subscribe, unsubscribe, getValue) { const [state, setState] = useState(getValue()); useEffect(() => { const handleChange = () => setState(getValue()); subscribe(handleChange); return () => unsubscribe(handleChange); }); return state; } function useWindowWidth() { const width = useSubscription( handler => window.addEventListener('resize', handler), handler => window.removeEventListener('resize', handler), () => window.innerWidth ); return width; } function useNetworkStatus() { const isOnline = useSubscription( handler => { window.addEventListener('online', handler); window.addEventListener('offline', handler); }, handler => { window.removeEventListener('online', handler); window.removeEventListener('offline', handler); }, () => navigator.onLine ); return isOnline; } 複製程式碼
這是一個真實可執行的示例。 custom hook 作者準備或停止使用另一個 custom hook 應該是要安全的,而不必擔心它是否已在鏈中某處“被用過了” 。
(作為反例,遺留的 React createClass()
的 mixins 不允許你這樣做,有時你會有兩個 mixins,它們都是你想要的,但由於擴充套件了同一個 “base” mixin,因此互不相容。)
這是我們的 “鑽石”::gem:
/ useWindowWidth()\/ useState():red_circle: Clash StatususeSubscription() \ useNetworkStatus() /\ useEffect() :red_circle: Clash 複製程式碼
依賴於固定的順序呼叫很自然的解決了它:
/ useState():white_check_mark: #1. State / useWindowWidth()-> useSubscription() /\ useEffect() :white_check_mark: #2. Effect Status \/ useState():white_check_mark: #3. State \ useNetworkStatus() -> useSubscription() \ useEffect() :white_check_mark: #4. Effect 複製程式碼
函式呼叫不會有“鑽石”問題,因為它們會形成樹狀結構。:christmas_tree:
缺陷 #5:複製貼上的主意被打亂
或許我們可以通過引入某種名稱空間來挽救給 state 加“key”提議,有幾種不同的方法可以做到這一點。
一種方法是使用閉包隔離state的key,這需要你在 “例項化” custom hooks時給每個 hook 裹上一層 function:
/******************* * useFormInput.js * ******************/ function createUseFormInput() { // 每次例項化都唯一 const valueKey = Symbol(); return function useFormInput() { const [value, setValue] = useState(valueKey); return { value, onChange(e) { setValue(e.target.value); }, }; } } 複製程式碼
這種作法非常繁瑣,Hooks 的設計目標之一就是避免使用高階元件和render props的深層巢狀函式。在這裡,我們不得不在使用 任何 custom hook 時進行“例項化” —— 而且在元件主體中只能單次使用生產的函式,這比直接呼叫 Hooks 麻煩好多。
另外,你不得不操作兩次才能使元件用上 custom hook。一次在最頂層(或在編寫 custom hook 時的函式裡頭),還有一次是最終的呼叫。這意味著即使一個很小的改動,你也得在頂層宣告和render函式間來回跳轉:
// :warning: This is NOT the React Hooks API const useNameFormInput = createUseFormInput(); const useSurnameFormInput = createUseFormInput(); function Form() { // ... const name = useNameFormInput(); const surname = useNameFormInput(); // ... } 複製程式碼
你還需要非常精確的命名,總是需要考慮“兩層”命名 —— 像 createUseFormInput
這樣的工廠函式和 useNameFormInput
、 useSurnameFormInput
這樣的例項 Hooks。
如果你同時呼叫兩次相同的 custom hook “例項”,你會發生state衝突。事實上,上面的程式碼就是這種錯誤 —— 發現了嗎? 它應該為:
const name = useNameFormInput(); const surname = useSurnameFormInput(); // Not useNameFormInput! 複製程式碼
這些問題並非不可克服,但我認為它們會比遵守Hooks規則 的阻力大些。
重要的是,它們打破了複製貼上的小算盤。在沒有封裝外層的情況下這種 custom hook 仍然可以使用,但它們只可以被呼叫一次(這在使用時會產生問題)。不幸的是,當一個API看起來可以正常執行,一旦你意識到在鏈的某個地方出現了衝突時,就不得不把所有定義好的東西包起來了。
缺陷 #6:我們仍然需要一個程式碼檢查工具
還有另外一種使用金鑰state來避免衝突的方法,如果你知道,可能會真的很生氣,因為我不看好它,抱歉。
這個主意就是每次寫 custom hook 時 組合 一個金鑰,就像這樣:
// :warning: This is NOT the React Hooks API function Form() { // ... const name = useFormInput('name'); const surname = useFormInput('surname'); // ... return ( <> <input {...name} /> <input {...surname} /> {/* ... */} </> ) } function useFormInput(formInputKey) { const [value, setValue] = useState('useFormInput(' + formInputKey + ').value'); return { value, onChange(e) { setValue(e.target.value); }, }; } 複製程式碼
和其他替代提議比,我最不喜歡這個,我覺得它沒有什麼價值。
一個 Hook 經過多次呼叫或者與其他 Hook 衝突之後,程式碼可能 意外產出 非唯一或合成無效金鑰進行傳遞。更糟糕的是,如果它是在某些條件下發生的(我們會試圖 “修復” 它對吧?),可能在一段時間後才發生衝突。
我們想提醒大家,記住所有通過金鑰來標記的 custom hooks 都很脆弱,它們不僅增加了執行時的工作量(別忘了它們要轉成 金鑰 ),而且會漸漸增大 bundle 大小。 但如果說我們非要提醒一個問題,是哪個問題呢 ?
如果非要在條件判斷裡宣告 state 和 effects,這種方法可能是有作用的,但按過去經驗來說,我發現它令人困惑。事實上,我不記得有人會在條件判斷裡定義 this.state
或者 componentMount
的。
這段程式碼到底意味著什麼?
// :warning: This is NOT the React Hooks API function Counter(props) { if (props.isActive) { const [count, setCount] = useState('count'); return ( <p onClick={() => setCount(count + 1)}> {count} </p>; ); } return null; } 複製程式碼
當 props.isActive
為 false
時 count
是否被保留?或者由於 useState('count')
沒有被呼叫而重置 count
?
如果條件為保留 state,effect 又會發生什麼?
// :warning: This is NOT the React Hooks API function Counter(props) { if (props.isActive) { const [count, setCount] = useState('count'); useEffect(() => { const id = setInterval(() => setCount(c => c + 1), 1000); return () => clearInterval(id); }, []); return ( <p onClick={() => setCount(count + 1)}> {count} </p>; ); } return null; } 複製程式碼
無疑它不會在 props.isActive
第一次是 true
之前 執行,但一旦變成 true
,它會停止執行嗎?當 props.isActive
轉變為 false
時 interval 會重置嗎?如果是這樣,effect 與 state(我們說不重置時) 的行為不同令人困惑。如果 effect 繼續執行,那麼 effect 外層的 if
不再控制 effect,這也令人感到困惑,我們不是說我們想要基於條件控制的 effects 嗎?
如果在渲染期間我們沒有“使用” state 但 它卻被重置,如果有多個 if
分支包含 useState('count')
但只有其中一個會在給定時間裡執行,會發生什麼?這是有效的程式碼嗎?如果我們的核心思想是 “以金鑰分佈”,那為什麼要 “丟棄” 它?開發人員是否希望在這之後從元件中提前 return
以重置所有state呢? 其實如果我們真的需要重置state,我們可以通過提取元件使其明確:
function Counter(props) { if (props.isActive) { // Clearly has its own state return <TickingCounter />; } return null; } 複製程式碼
無論如何這可能成為是解決這些困惑問題的“最佳實踐”,所以不管你選擇哪種方式去解釋,我覺得條件裡 宣告 state 和 effect的語義怎樣都很怪異,你可能會不知不覺的感受到。
如果還要提醒的是 —— 正確地組合金鑰的需求會變成“負擔”,它並沒有給我們帶來任何想要的。但是,放棄這個需求(並回到最初的提案)確實給我們帶來了一些東西,它使元件程式碼能夠安全地複製貼上到一個 custom hook 中,且不需要名稱空間,減小bundle大小及輕微的效率提升(不需要Map查詢)。
慢慢理解。
缺陷 #7:Hooks 之間無法傳值
Hooks 有個最好的功能就是可以在它們之間傳值。
以下是一個選擇資訊收件人的模擬示例,它顯示了當前選擇的好友是否線上:
const friendList = [ { id: 1, name: 'Phoebe' }, { id: 2, name: 'Rachel' }, { id: 3, name: 'Ross' }, ]; function ChatRecipientPicker() { const [recipientID, setRecipientID] = useState(1); const isRecipientOnline = useFriendStatus(recipientID); return ( <> <Circle color={isRecipientOnline ? 'green' : 'red'} /> <select value={recipientID} onChange={e => setRecipientID(Number(e.target.value))} > {friendList.map(friend => ( <option key={friend.id} value={friend.id}> {friend.name} </option> ))} </select> </> ); } function useFriendStatus(friendID) { const [isOnline, setIsOnline] = useState(null); const handleStatusChange = (status) => setIsOnline(status.isOnline); useEffect(() => { ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange); return () => { ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange); }; }); return isOnline; } 複製程式碼
當改變收件人時, useFriendStatus
Hook 就會退訂上一個好友的狀態,訂閱接下來的這個。
這是可行的,因為我們可以將 useState()
Hook 返回的值傳給 useFriendStatus()
Hook:
const [recipientID, setRecipientID] = useState(1); const isRecipientOnline = useFriendStatus(recipientID); 複製程式碼
Hooks之間傳值非常有用。例如:React Spring可以建立一個尾隨動畫,其中多個值彼此“跟隨”:
const [{ pos1 }, set] = useSpring({ pos1: [0, 0], config: fast }); const [{ pos2 }] = useSpring({ pos2: pos1, config: slow }); const [{ pos3 }] = useSpring({ pos3: pos2, config: slow }); 複製程式碼
(這是demo。)
在Hook初始化時新增預設引數或者將Hook寫在裝飾器表單中的提議,很難實現這種情況的邏輯。
如果不在函式體內呼叫 Hooks,就不可以輕鬆地在它們之間傳值了。你可以改變這些值結構,讓它們不需要在多層元件之間傳遞,也可以用 useMemo
來儲存計算結果。但你也無法在 effects 中引用這些值,因為它們無法在閉包中被獲取到。有些方法可以通過某些約定來解決這些問題,但它們需要你在心裡“核算”輸入和輸出,這違背了 React 直接了當的風格。
在 Hooks 之間傳值是我們提案的核心,Render props 模式在沒有 Hooks 時是你最先能想到的,但像Component Component 這樣的庫,是無法適用於你遇到的所有場景的,它由於“錯誤的層次結構”存在大量的語法干擾。Hooks 用扁平化層次結構來實現傳值 —— 且函式呼叫是最簡單的傳值方式。
缺陷 #8:步驟繁瑣
有許多提議處於這種範疇裡。他們儘可能的想讓React擺脫對 Hooks 的依賴感,大多數方法是這麼做的:讓 this
擁有內建 Hooks,使它們變成額外的引數在React中無處不在,等等等。
我覺得 Sebastian的回答 比我的描述,更能說服這種方式,我建議你去了解下“注入模型”。
我只想說這和程式設計師傾向於用 try
/ catch
捕獲方法中的錯誤程式碼是一樣的道理,同樣對比 AMD由我們自己傳入 require
的“顯示”宣告,我們更喜歡 import
(或者 CommonJS require
) 的 ES模組。
// 有誰想念 AMD? define(['require', 'dependency1', 'dependency2'], function (require) { var dependency1 = require('dependency1'), var dependency2 = require('dependency2'); return function () {}; }); 複製程式碼
是的,AMD 可能更“誠實” 的陳述了在瀏覽器環境中模組不是同步載入的,但當你知道了這個後,寫 define
三明治 就變成做無用功了。
try
/ catch
、 require
和 React Context API都是我們更喜歡“環境”式體驗,多於直接宣告使用的真例項子(即使通常我們更喜歡直爽風格),我覺得 Hooks 也屬於這種。
這類似於當我們宣告元件時,就像從 React
抓個 Component
過來。如果我們用工廠的方式匯出每個元件,可能我們的程式碼會更解耦:
function createModal(React) { return class Modal extends React.Component { // ... }; } 複製程式碼
但在實際中,這最後會變得多此一舉而令人厭煩。當我們真的想以某種方式抓React時,我們應該在模組系統層面上實現。
這同樣適用於 Hooks。儘管如此,正如 Sebastian的回答 中提到的,在 技術上 可以做到從 react
中“直接”匯入不同實現的 Hooks。(我以前的文章有提到過。)
另一種強行復雜化想法是把Hooksmonadic(單子化) 或者新增像 React.createHook()
這樣的class理念。除了runtime之外,其他任何新增巢狀的方案都會失去普通函式的優點: 便於除錯 。
在除錯過程中,普通函式中不會夾雜任何類庫程式碼,且可以清晰的知道元件內部值的流向,間接性很難做到這點。像啟發於高階元件(“裝飾器” Hooks)或者 render props( adopt
提案 或 generators的 yield
等)類似的方案,都存在這樣的問題。間接性也使靜態型別變得複雜。
如我之前提到的,這篇文章不會詳盡無遺,在其他提案中有許多有趣的問題,其中有一些更加晦澀(例如於併發和高階編譯相關),這可能是在未來另一篇文章的主題。
Hooks並非完美無瑕,但這是我們可以找到解決這些問題的最佳權衡。還有一些我們 仍然需要修復 的東西,這些問題在 Hooks 中比在 class 中更加彆扭,這也會寫在別的文章裡頭。
無論我是否覆蓋掉你喜歡的替換方案,我希望這篇文章有助於闡述我們的思考過程及我們在選擇API時考慮的標準。如你所見,很多(例如確保複製貼上、移動程式碼、按希望的方式進行增刪依賴包)不得不針對變化而優化。我希望React開發者們會看好我們所做的這些決定。
翻譯原文 Why Do React Hooks Rely on Call Order? (2018-12-13)