詳解如何在React中逃離閉包陷阱
眾所周知,JavaScript 中的閉包(Closures)一定是這種語言最可怕的特性之一,即使是無所不知的 ChatGPT 也是這樣說的。另外它可能也是最隱蔽的語言特性之一,我們在編寫 React 代碼時經(jīng)常會用到它,但是大多數(shù)時候我們甚至沒有意識到這一點。但是,我們終究還是離不開它:如果我們想編寫復(fù)雜且性能很好的 React 應(yīng)用,就必須了解閉包。所以,今天我們一起來學(xué)習(xí)以下幾點:
- 什么是閉包,它們是如何出現(xiàn)的,為什么我們需要它們。
- 什么是過期的閉包,它們?yōu)槭裁磿霈F(xiàn)。
- React 中導(dǎo)致過期閉包的常見場景是什么,以及如何應(yīng)對它們。
警告:如果你從未接觸過 React 中的閉包,本文可能會讓你腦漿迸裂,在閱讀本文時,請確保隨身攜帶足夠的巧克力來刺激你的腦細(xì)胞。
一個常見的問題
比如現(xiàn)在有這樣一個場景:你正在實現(xiàn)一個帶有幾個輸入字段的表單。其中一個字段是來自某個外部的組件庫。你無法訪問它的內(nèi)部結(jié)構(gòu),所以也沒辦法解決它的性能問題。但你確實需要在表單中使用它,因此你決定用 React.memo
封裝它,以便在表單中的狀態(tài)發(fā)生變化時盡量減少它的重新渲染。類似這樣:
const HeavyComponentMemo = React.memo(HeavyComponent); const Form = () => { const [value, setValue] = useState(); return ( <> <input type="text" value={value} onChange={(e) => setValue(e.target.value)} /> <HeavyComponentMemo /> </> ); };
這個 Heavy 組件只接受一個字符串 props(比如 title)和一個 onClick 回調(diào)。當(dāng)你點擊該組件中的 "完成" 按鈕時,就會觸發(fā)這個回調(diào)。如果你想在點擊時提交表單數(shù)據(jù)。這也很簡單:只需將 title 和 onClick 這兩個 props 傳遞給它即可。
const HeavyComponentMemo = React.memo(HeavyComponent); const Form = () => { const [value, setValue] = useState(); const onClick = () => { // submit our form data here console.log(value); }; return ( <> <input type="text" value={value} onChange={(e) => setValue(e.target.value)} /> <HeavyComponentMemo title="Welcome to the form" onClick={onClick} /> </> ); };
現(xiàn)在,你又會面臨一個新的問題。我們知道,React.memo 封裝的組件上的每個 props 都必須是原始值,或者在重新渲染時是保持不變的。否則,memoization 就是不起作用的。所以,從技術(shù)上講,我們需要將 onClick 包裝為 useCallback:
const onClick = useCallback(() => { // submit data here }, []);
但我們也知道,useCallback 鉤子應(yīng)在其依賴關(guān)系數(shù)組中聲明所有依賴關(guān)系。因此,如果我們想在其中提交表單數(shù)據(jù),就必須將該數(shù)據(jù)聲明為依賴項:
const onClick = useCallback(() => { // submit data here console.log(value); // adding value to the dependency }, [value]);
現(xiàn)在的難題是:即使我們的 onClick 被 memo 化了,但每次表單有重新輸入時,它仍然會發(fā)生變化。因此,我們的性能優(yōu)化毫無用處。
下面讓我們尋找一下其他的解決方案。React.memo
有一個叫做比較函數(shù)的東西,它允許我們對 React.memo
中的 props 比較進(jìn)行更精細(xì)的控制。通常,React 會自行比較前后的 props 。如果我們提供這個函數(shù),它將依賴于其返回的結(jié)果。如果返回結(jié)果為 true,那么 React 就會知道 props 是相同的,組件就不應(yīng)該被重新渲染,聽起來正是我們需要的。我們只需要更新一個 props ,那就是我們的 title ,所以不會很復(fù)雜:
const HeavyComponentMemo = React.memo( HeavyComponent, (before, after) => { return before.title === after.title; }, );
這樣,完整的代碼就是這樣的:
const HeavyComponentMemo = React.memo( HeavyComponent, (before, after) => { return before.title === after.title; }, ); const Form = () => { const [value, setValue] = useState(); const onClick = () => { // submit our form data here console.log(value); }; return ( <> <input type="text" value={value} onChange={(e) => setValue(e.target.value)} /> <HeavyComponentMemo title="Welcome to the form" onClick={onClick} /> </> ); };
起作用了,我們在輸入框中輸入內(nèi)容,Heavy 組件不會重新渲染,性能也不會受到影響。
但是我們又遇到了新的問題:如果在輸入框中輸入內(nèi)容,然后按下按鈕,我們在 onClick 中打印的值是 undefined 。但它不可能是 undefined,如果我在 onClick 之外添加 console.log
,它就會正確打印。
// those one logs it correctly console.log(value); const onClick = () => { // this is always undefined console.log(value); };
這是怎么回事呢?
這就是所謂的 "過期閉包" 問題。為了解決這個問題,我們首先需要了解一下 JavaScript 中最令人恐懼的話題:閉包及其工作原理。
JavaScript、作用域和閉包
讓我們從函數(shù)和變量開始,當(dāng)我們在 JavaScript 中聲明一個普通函數(shù)或者尖頭函數(shù)會發(fā)生什么呢?
function something() { // } const something = () => {};
通過這樣的操作,我們創(chuàng)建了一個局部作用域:代碼中的一個區(qū)域,其中聲明的變量從外部是不可見的。
const something = () => { const value = 'text'; }; console.log(value); // not going to work, "value" is local to "something" function
每次我們創(chuàng)建函數(shù)時都會發(fā)生這種情況。在另一個函數(shù)內(nèi)部創(chuàng)建的函數(shù)將具有自己的局部作用域,對于外部函數(shù)不可見。
const something = () => { const inside = () => { const value = 'text'; }; console.log(value); // not going to work, "value" is local to "inside" function };
然而,在相反的方向就不一樣了,最里面的函數(shù)可以訪問到外部聲明的所有變量。
const something = () => { const value = 'text'; const inside = () => { // perfectly fine, value is available here console.log(value); }; };
這就是通過創(chuàng)建所謂的 “閉包” 來實現(xiàn)的。內(nèi)部函數(shù) “閉包” 了來自外部的所有數(shù)據(jù),它本質(zhì)上就是所有 “外部” 數(shù)據(jù)的快照,這些數(shù)據(jù)被凍結(jié)并單獨存儲在內(nèi)存中。如果我們不是在 something 函數(shù)內(nèi)創(chuàng)建該值,而是將其作為參數(shù)傳遞并返回內(nèi)部函數(shù)呢:
const something = (value) => { const inside = () => { // perfectly fine, value is available here console.log(value); }; return inside; };
我們會得到這樣的行為:
const first = something('first'); const second = something('second'); first(); // logs "first" second(); // logs "second"
我們調(diào)用 something 函數(shù)時傳入值 first,并將結(jié)果分配給一個變量。結(jié)果是對內(nèi)部聲明的函數(shù)的引用,形成閉包。從現(xiàn)在開始,只要保存這個引用的第一個變量是存在的,我們傳遞給它的值 “first” 就會被凍結(jié)掉,并且內(nèi)部函數(shù)將可以訪問它。
第二次調(diào)用也是同樣的情況:我們傳遞了一個不同的值,形成一個閉包,返回的函數(shù)也將永遠(yuǎn)可以訪問該變量。
在 something 函數(shù)中本地聲明的任何變量都是如此:
const something = (value) => { const r = Math.random(); const inside = () => { // ... }; return inside; }; const first = something('first'); const second = something('second'); first(); // logs random number second(); // logs another random number
這就像拍攝一些動態(tài)場景的照片一樣:只要按下按鈕,整個場景就會永遠(yuǎn) “凍結(jié)” 在照片中。下次按下按鈕不會改變之前拍攝的照片中的任何內(nèi)容。
在 React 中,我們一直都在創(chuàng)建閉包,甚至沒有意識到,組件內(nèi)聲明的每個回調(diào)函數(shù)都是一個閉包:
const Component = () => { const onClick = () => { // closure! }; return <button onClick={onClick} />; };
useEffect 或 useCallback 鉤子中的所有內(nèi)容都是一個閉包:
const Component = () => { const onClick = useCallback(() => { // closure! }); useEffect(() => { // closure! }); };
它們都可以訪問組件中聲明的 state、props 和局部變量:
const Component = () => { const [state, setState] = useState(); const onClick = useCallback(() => { // perfectly fine console.log(state); }); useEffect(() => { // perfectly fine console.log(state); }); };
組件內(nèi)的每個函數(shù)都是一個閉包,因為組件本身只是一個函數(shù)。
過期閉包的問題
但是,以上所有的內(nèi)容,如果你之前沒有接觸過閉包的話會覺得挺新奇的,但其實還是挺簡單的,你多創(chuàng)建幾個函數(shù),就會變得很自然了。我們寫了這么久的 React 甚至也不需要理解 “閉包” 的概念。
那么問題出在哪里呢?為什么閉包是 JavaScript 中最可怕的東西之一,并讓如此多的開發(fā)者感到痛苦?
因為只要引起閉包的函數(shù)存在引用,閉包就會一直存在。而函數(shù)的引用只是一個值,可以賦給任何東西。
比如這個函數(shù),它返回一個完全無辜的閉包:
const something = (value) => { const inside = () => { console.log(value); }; return inside; };
問題是每次調(diào)用都會重新創(chuàng)建內(nèi)部函數(shù),如果我決定嘗試緩存它,會發(fā)生什么情況呢?類似這樣:
const cache = {}; const something = (value) => { if (!cache.current) { cache.current = () => { console.log(value); }; } return cache.current; };
從表面上看,這段代碼并沒有什么問題。我們只是創(chuàng)建了一個名為 cache 的外部變量,并將內(nèi)部函數(shù)分配給 cache.current 屬性。然后,我們就不會再每次都重新創(chuàng)建這個函數(shù)了,而是直接返回已經(jīng)保存的值。
但是,如果我們嘗試多調(diào)用幾次,就會發(fā)現(xiàn)一個奇怪的現(xiàn)象:
const first = something('first'); const second = something('second'); const third = something('third'); first(); // logs "first" second(); // logs "first" third(); // logs "first"
無論我們用不同的參數(shù)調(diào)用多少次 something 函數(shù),記錄的值始終是第一個參數(shù)!
我們剛剛就創(chuàng)建了一個所謂的 "過期閉包"。每個閉包在創(chuàng)建時都是凍結(jié)的,當(dāng)我們第一次調(diào)用 something 函數(shù)時,我們創(chuàng)建了一個值變量中包含 "first" 的閉包。然后,我們把它保存在 something 函數(shù)之外的一個對象中。
當(dāng)我們下一次調(diào)用 something 函數(shù)時,我們將返回之前創(chuàng)建的閉包,而不是創(chuàng)建一個帶有新閉包的新函數(shù)。這個閉包會與 "first" 變量永遠(yuǎn)凍結(jié)在一起。
為了修復(fù)這種問題,我們可以在每次值發(fā)生變化時重新創(chuàng)建函數(shù)及其閉包,類似這樣:
const cache = {}; let prevValue; const something = (value) => { // check whether the value has changed if (!cache.current || value !== prevValue) { cache.current = () => { console.log(value); }; } // refresh it prevValue = value; return cache.current; };
將值保存在變量中,以便我們可以將下一個值與前一個值進(jìn)行比較。如果變量發(fā)生了變化,則刷新 cache.current 閉包?,F(xiàn)在,它就會正確打印變量,如果我們比較具有相同值的函數(shù),比較結(jié)果將返回 true:
const first = something('first'); const anotherFirst = something('first'); const second = something('second'); first(); // logs "first" second(); // logs "second" console.log(first === anotherFirst); // will be true
React 中的過期閉包:useCallback
我們剛剛實現(xiàn)了與 useCallback 鉤子幾乎一模一樣的功能!每次使用 useCallback 時,我們都會創(chuàng)建一個閉包,并緩存?zhèn)鬟f給它的函數(shù):
// that inline function is cached exactly as in the section before const onClick = useCallback(() => { }, []);
如果我們需要訪問此函數(shù)內(nèi)的 state 或 props,我們需要將它們添加到依賴項數(shù)組中:
const Component = () => { const [state, setState] = useState(); const onClick = useCallback(() => { // access to state inside console.log(state); // need to add this to the dependencies array }, [state]); };
這個依賴關(guān)系數(shù)組會讓 React 刷新緩存的閉包,就像我們在比較 value !== prevValue
時所做的一樣。如果我忘記了這個數(shù)組,我們的閉包就會過期:
const Component = () => { const [state, setState] = useState(); const onClick = useCallback(() => { // state will always be the initial state value here // the closure is never refreshed console.log(state); // forgot about dependencies }, []); };
每次我們觸發(fā)該回調(diào)時,所有將被打印的內(nèi)容都是 undefined。
React 中的過期閉包:Refs
在 useCallback 和 useMemo 鉤子之后,引入過期閉包問題的第二個最常見的方法是 Refs。
如果我嘗試對 onClick 回調(diào)使用 Ref 而不是 useCallback 鉤子,會發(fā)生什么情況呢?有些文章會建議通過這樣做來 memoize 組件上的 props。從表面上看,它確實看起來更簡單:只需將一個函數(shù)傳遞給 useRef 并通過 ref.current 訪問它,沒有依賴性,不用擔(dān)心。
const Component = () => { const ref = useRef(() => { // click handler }); // ref.current stores the function and is stable between re-renders return <HeavyComponent onClick={ref.current} />; };
然而,組件內(nèi)的每個函數(shù)都會形成一個閉包,包括我們傳遞給 useRef 的函數(shù)。我們的 ref 在創(chuàng)建時只會初始化一次,并且不會自行更新。這基本上就是我們一開始創(chuàng)建的邏輯,只是我們傳遞的不是值,而是我們想要保留的函數(shù)。像這樣:
const ref = {}; const useRef = (callback) => { if (!ref.current) { ref.current = callback; } return ref.current; };
因此,在這種情況下,一開始(即組件剛剛初始化時)形成的閉包將會被保留,永遠(yuǎn)不會刷新。當(dāng)我們試圖訪問存儲在 Ref 中的函數(shù)內(nèi)部的 state 或 props 時,我們只能得到它們的初始值:
const Component = ({ someProp }) => { const [state, setState] = useState(); const ref = useRef(() => { // both of them will be stale and will never change console.log(someProp); console.log(state); }); };
為了解決這個問題,我們需要確保每次我們試圖訪問的內(nèi)容發(fā)生變化時,ref 值都會更新。本質(zhì)上,我們需要實現(xiàn) useCallback 鉤子的依賴數(shù)組所做的事情。
const Component = ({ someProp }) => { // initialize ref - creates closure! const ref = useRef(() => { // both of them will be stale and will never change console.log(someProp); console.log(state); }); useEffect(() => { // update the closure when state or props change ref.current = () => { console.log(someProp); console.log(state); }; }, [state, someProp]); };
React 中的過期閉包:React.memo
最后,我們回到文章的開頭,回到引發(fā)這一切的謎團(tuán)。讓我們再來看看有問題的代碼:
const HeavyComponentMemo = React.memo( HeavyComponent, (before, after) => { return before.title === after.title; }, ); const Form = () => { const [value, setValue] = useState(); const onClick = () => { // submit our form data here console.log(value); }; return ( <> <input type="text" value={value} onChange={(e) => setValue(e.target.value)} /> <HeavyComponentMemo title="Welcome to the form" onClick={onClick} /> </> ); };
每次點擊按鈕時,都會打印 "undefined" 。我們在 onClick 中的值從未更新過,你能告訴我為什么嗎?
當(dāng)然,這又是一個過期閉包。當(dāng)我們創(chuàng)建 onClick 時,首先使用默認(rèn)狀態(tài)值(undefined)形成閉包。我們將該閉包與 title 屬性一起傳遞給我們的 Memo 組件。在比較函數(shù)中,我們只比較了標(biāo)題。它永遠(yuǎn)不會改變,它只是一個字符串。比較函數(shù)始終返回 true,HeavyComponent 永遠(yuǎn)不會更新,因此,它保存的是對第一個 onClick 閉包的引用,并具有凍結(jié)的 undefined 值。
既然我們知道了問題所在,那么該如何解決呢?說起來容易做起來難...
理想情況下,我們應(yīng)該在比較函數(shù)中對每個 props 進(jìn)行比較,因此我們需要在其中加入 onClick:
(before, after) => { return ( before.title === after.title && before.onClick === after.onClick ); };
不過,在這種情況下,這意味著我們只是重新實現(xiàn)了 React 的默認(rèn)行為,做的事情與不帶比較函數(shù)的 React.memo 完全一樣。因此,我們可以放棄它,只保留 React.memo (HeavyComponent)。
但這樣做意味著我們需要將 onClick 包裝為 useCallback。但這取決于 state ,我們又回到了原點:每次狀態(tài)改變時,我們的 HeavyComponent 都會重新渲染,這正是我們想要避免的。
我們還可以嘗試很多其他方法,但我們不必進(jìn)行任何大量的重構(gòu)就能擺脫閉包陷阱,有一個很酷的技巧可以幫助我們。
使用 Refs 逃離閉包陷阱
讓我們暫時擺脫 React.memo
和 onClick 實現(xiàn)中的比較函數(shù)。只需一個具有 state 和 memo 化 HeavyComponent 的 pure component 即可:
const HeavyComponentMemo = React.memo(HeavyComponent); const Form = () => { const [value, setValue] = useState(); return ( <> <input type="text" value={value} onChange={(e) => setValue(e.target.value)} /> <HeavyComponentMemo title="Welcome to the form" onClick={...} /> </> ); }
現(xiàn)在我們需要添加一個 onClick 函數(shù),該函數(shù)在重新渲染的時候會保持穩(wěn)定,但也可以訪問最新狀態(tài)而無需重新創(chuàng)建。我們將把它存儲在 Ref 中,所以我們暫時添加一個空的:
const Form = () => { const [value, setValue] = useState(); // adding an empty ref const ref = useRef(); };
為了讓函數(shù)能夠訪問最新狀態(tài),每次重新渲染時都需要重新創(chuàng)建函數(shù),這是無法避免的,這也是閉包的本質(zhì),與 React 無關(guān)。我們應(yīng)該在 useEffect 中修改 Ref,而不是直接在渲染中修改 Ref,所以我們可以這樣做:
const Form = () => { const [value, setValue] = useState(); // adding an empty ref const ref = useRef(); useEffect(() => { // our callback that we want to trigger // with state ref.current = () => { console.log(value); }; // no dependencies array! }); };
不帶依賴數(shù)組的 useEffect 會在每次重新渲染時觸發(fā)。這正是我們想要的,所以現(xiàn)在在我們的 ref.current 中,我們有一個每次重新渲染都會重新創(chuàng)建的閉包,因此打印的 state 始終是最新的。
但我們不能把 ref.current
直接傳遞給 memoized 組件。每次重新渲染時,這個值都會不同, memoization 將無法工作。
const Form = () => { const ref = useRef(); useEffect(() => { ref.current = () => { console.log(value); }; }); return ( <> {/* Can't do that, will break memoization */} <HeavyComponentMemo onClick={ref.current} /> </> ); };
所以,我們創(chuàng)建一個封裝在 useCallback 中的空函數(shù),并且不依賴于此函數(shù)。
const Form = () => { const ref = useRef(); useEffect(() => { ref.current = () => { console.log(value); }; }); const onClick = useCallback(() => { // empty dependency! will never change }, []); return ( <> {/* Now memoization will work, onClick never changes */} <HeavyComponentMemo onClick={onClick} /> </> ); };
現(xiàn)在,memoization 可以完美地工作,因為 onClick 從未改變。但有一個問題:它什么也會不做。
這里有一個神奇的竅門:我們只需在 memoized 回調(diào)中調(diào)用 ref.current 即可:
useEffect(() => { ref.current = () => { console.log(value); }; }); const onClick = useCallback(() => { // call the ref here ref.current(); // still empty dependencies array! }, []);
注意到 ref 并不在 useCallback 的依賴關(guān)系中嗎?ref 本身是不會改變的。它只是 useRef 鉤子返回的一個可變對象的引用。但是,當(dāng)閉包凍結(jié)周圍的一切時,并不會使對象不可變或被凍結(jié)。對象存儲在內(nèi)存的不同部分,多個變量可以包含對完全相同對象的引用。
const a = { value: 'one' }; // b is a different variable that references the same object const b = a;
如果我通過其中一個引用更改對象,然后通過另一個引用訪問它,更改就會出現(xiàn):
a.value = 'ConardLi'; console.log(b.value); // will be "ConardLi"
在我們的案例中,這種情況并沒有發(fā)生:我們在 useCallback 和 useEffect 中擁有完全相同的引用。因此,當(dāng)我們更改 useEffect 中 ref 對象的 current 屬性時,我們可以在 useCallback 中訪問該屬性,這個屬性恰好是一個捕獲了最新狀態(tài)數(shù)據(jù)的閉包。完整代碼如下:
const Form = () => { const [value, setValue] = useState(); const ref = useRef(); useEffect(() => { ref.current = () => { // will be latest console.log(value); }; }); const onClick = useCallback(() => { // will be latest ref.current?.(); }, []); return ( <> <input type="text" value={value} onChange={(e) => setValue(e.target.value)} /> <HeavyComponentMemo title="你好 code秘密花園" onClick={onClick} /> </> ); };
現(xiàn)在,我們獲得了兩全其美的結(jié)果:Heavy 組件被適當(dāng)?shù)?memoization,不會因為每次狀態(tài)變化而重新渲染。它的 onClick 回調(diào)可以訪問組件中的最新數(shù)據(jù),而不會破壞 memoization。現(xiàn)在,我們可以安全地將所需的一切發(fā)送到后端!
最后
下面我們再總結(jié)一下本文中提到的知識點:
- 每次在另一個函數(shù)內(nèi)部創(chuàng)建一個函數(shù)時,都會形成閉包。
- 由于 React 組件只是函數(shù),因此內(nèi)部創(chuàng)建的每個函數(shù)都會形成閉包,包括 useCallback 和 useRef 等鉤子。
- 當(dāng)一個形成閉包的函數(shù)被調(diào)用時,它周圍的所有數(shù)據(jù)都會被 "凍結(jié)",就像快照一樣。
- 要更新這些數(shù)據(jù),我們需要重新創(chuàng)建 "閉包" 函數(shù)。這就是使用 useCallback 等鉤子的依賴關(guān)系允許我們做的事情。
- 如果我們錯過了依賴關(guān)系,或者沒有刷新分配給 ref.current 的閉包函數(shù),閉包就會 "過期"。
- 在 React 中,我們可以利用 Ref 是一個可變對象這一特性,從而擺脫 "過期閉包" 的問題。我們可以在過期閉包之外更改 ref.current,然后在閉包之內(nèi)訪問它,就可以獲取最新的數(shù)據(jù)。
以上就是詳解如何在React中逃離閉包陷阱的詳細(xì)內(nèi)容,更多關(guān)于React逃離閉包陷阱的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Mybatis(ParameterType)傳遞多個不同類型的參數(shù)方式
這篇文章主要介紹了Mybatis(ParameterType)傳遞多個不同類型的參數(shù)方式,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2023-04-04MybatisPlus?BaseMapper?實現(xiàn)對數(shù)據(jù)庫增刪改查源碼
MybatisPlus?是一款在?Mybatis?基礎(chǔ)上進(jìn)行的增強(qiáng)?orm?框架,可以實現(xiàn)不寫?sql?就完成數(shù)據(jù)庫相關(guān)的操作,這篇文章主要介紹了MybatisPlus?BaseMapper?實現(xiàn)對數(shù)據(jù)庫增刪改查源碼解析,需要的朋友可以參考下2023-01-01一文總結(jié)RabbitMQ中的消息確認(rèn)機(jī)制
RabbitMQ消息確認(rèn)機(jī)制指的是在消息傳遞過程中,發(fā)送方發(fā)送消息后,接收方需要對消息進(jìn)行確認(rèn),以確保消息被正確地接收和處理,本文為大家整理了RabbitMQ中的消息確認(rèn)機(jī)制,需要的可以參考一下2023-06-06如何開發(fā)一個簡單的Akka Java應(yīng)用
這篇文章主要介紹了如何開發(fā)一個簡單的Akka Java應(yīng)用 ,幫助大家使用Java創(chuàng)建Akka項目并將其打包,感興趣的朋友可以了解下2020-10-10