hello 大家好,我是 superZidan,這篇文章想跟大家聊聊 React 中的閉包 這個話題,如果大家遇到任何問題,歡迎 聯繫我
JavaScript 中的閉包一定是最可怕的特性之一。 即使是無所不知的 ChatGPT 也會告訴你這一點。 它也可能是最隱秘的語言概念之一。 每次編寫任何 React 代碼時,我們都會用到它,大多數時候我們甚至沒有意識到。 但最終還是無法擺脱它們:如果我們想編寫複雜且高性能的 React 應用程序,我們就必須瞭解閉包。
因此,讓我們深入研究它,並在此過程中學習以下內容:
- 什麼是閉包,它們是怎麼出現的,為什麼我們需要它們
- 什麼是過時閉包,為什麼它們會出現
- React 中哪些常見的場景會導致過時閉包,以及如何應對它們
警告:如果你從未處理過 React 中的閉包,這篇文章可能會讓你的大腦爆炸。 當你閲讀本文時,請確保隨身攜帶足夠的巧克力來刺激腦細胞。
問題出現
想象一下你正在實現一個帶有幾個輸入框的表單。 其中一個字段是來自某些外部庫的非常重的組件。 你沒有辦法瞭解其內部結構,因此無法修復其性能問題。 但你的表單中確實需要它,因此你決定將其包裝在 React.memo 中,以在表單中的狀態發生變化時最大程度地減少重新渲染的頻率。 像這樣:
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)和一個 onClick 回調函數。 當單擊這個組件內的 “完成” 按鈕時會觸發此回調函數。 並且希望在發生此單擊時提交表單數據。 也很簡單:只需將標題和 onClick 屬性傳遞給它即可。
const HeavyComponentMemo = React.memo(HeavyComponent);
const Form = () => {
const [value, setValue] = useState();
const onClick = () => {
// 在這裏提交表單數據
console.log(value);
};
return (
<>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<HeavyComponentMemo
title="Welcome to the form"
onClick={onClick}
/>
</>
);
};
現在你將面臨兩難境地。 眾所周知,React.memo 中包裝的組件上的每個 prop 都需要是原始值或在重新渲染之間保持不變。 否則,記憶緩存將不起作用。 因此從技術上講,我們需要將 onClick 包裝在 useCallback 中:
const onClick = useCallback(() => {
// 在這裏提交表單數據
}, []);
而且,我們知道 useCallback 這個 hook 應該在其依賴項數組中聲明所有依賴項。 因此,如果我們想在內部提交表單數據,我們必須將該數據聲明為依賴項:
const onClick = useCallback(() => {
// 在這裏提交表單數據
console.log(value);
// 添加數據作為依賴項
}, [value]);
這就是一個困境:儘管我們的 onClick 被記憶緩存了,但每次有人在輸入框中輸入時它仍然會發生變化。 所以我們的性能優化是沒有用的。
好吧,讓我們尋找其他解決方案。 React.memo 有一個叫做 comparison function 的東西。它允許我們更精細地控制 React.memo 中的 props 比較。通常,React 會自行將所有 “上一次更新”的 props 與所有 “下一次更新” props 進行比較。 如果我們使用了這個函數,它將依賴於它的返回結果。 如果它返回 true,那麼 React 就會知道 props 是相同的,並且組件不應該被重新渲染。 這聽起來正是我們所需要的
我們只關心更新一個 props,即我們的 title,所以它不會那麼複雜:
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 = () => {
// 在這裏提交表單數據
console.log(value);
};
return (
<>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<HeavyComponentMemo
title="Welcome to the form"
onClick={onClick}
/>
</>
);
};
成功了! 我們在輸入了一些內容,這個「非常重」的組件不會重新渲染,並且性能不會受到影響。
然而有一個小問題:它實際上並沒有成功。 如果你在輸入某些內容,然後按下該按鈕,則我們在 onClick 中打印的 value 是 undefined 。 但它不能是undefined 的,但是如果我在 onClick 之外添加 console.log ,它會正確打印它。 在 onClick 內部則不正確。
這是怎麼回事呢?
這就是所謂的“過時閉包”問題。 為了解決這個問題,我們首先需要深入研究 JavaScript 中最令人恐懼的主題:閉包及其工作原理。
JavaScipt,作用域,閉包
讓我們從函數和變量開始。 當我們在 JavaScript 中通過普通聲明或箭頭函數聲明函數時會發生什麼
function something() {
//
}
const something = () => {};
通過這樣做,我們創建了一個局部作用域:代碼中的一個作用域,其中聲明的變量在外部是不可見的。
const something = () => {
const value = 'text';
};
console.log(value); // 不起作用,value 是 something 函數的內部變量
每次我們創建函數時都會發生這種情況。 在另一個函數內部創建的函數將具有自己的局部作用域,對於外部函數不可見
const something = () => {
const inside = () => {
const value = 'text';
};
console.log(value); // 不起作用,value 是 inside 函數的內部變量
};
然而如果反過來,這是一條可行的道路。 最裏面的函數將能「看到」外部聲明的所有變量
const something = () => {
const value = 'text';
const inside = () => {
// 非常好,value 可以在這裏訪問到
console.log(value);
};
};
這是通過創建所謂的「閉包」來實現的。 內部函數「關閉」來自外部的所有數據。 它本質上是所有「外部」數據的快照,這些數據會被及時凍結並單獨存儲在內存中。
如果我不是在 something 函數內創建 value,而是將其作為參數傳遞並返回inside 函數:
const something = (value) => {
const inside = () => {
// 非常好,value 可以在這裏訪問到
console.log(value);
};
return inside;
};
我們會得到這樣的行為:
const first = something('first');
const second = something('second');
first(); // 打印 "first"
second(); // 打印 "second"
我們用字符串 “first” 作為參數調用 something 函數,並將結果賦值給了一個變量。 該變量則是對內部聲明的函數的引用。 形成閉合。 從現在開始,只要保存該引用的 first 變量存在,我們傳遞給它的字符串“first”就會被凍結,並且內部函數將可以訪問它
第二次調用也是同樣的情況:我們傳遞一個不同的值,形成一個閉包,並且返回的函數將永遠可以訪問該變量。
對於在 something 函數內本地聲明的任何變量都是如此:
const something = (value) => {
const r = Math.random();
const inside = () => {
// ...
console.log(r)
};
return inside;
};
const first = something('first');
const second = something('second');
first(); // 打印一個隨機數
second(); // 打印另外一個隨機數
這就像拍攝一些動態場景的照片:只要按下按鈕,整個場景就會永遠“凍結”在照片中。 下次按下該按鈕不會改變之前拍攝的照片中的任何內容。
在 React 中,我們一直在創建閉包,甚至沒有意識到。 組件內聲明的每個回調函數都是一個閉包:
const Component = () => {
const onClick = () => {
// 閉包!
};
return <button onClick={onClick} />;
};
所有在 useEffect 或者 useCallback 中的都是閉包
const Component = () => {
const onClick = useCallback(() => {
// 閉包!
});
useEffect(() => {
// 閉包!
});
};
所有閉包都可以訪問組件中聲明的 state、props 和局部變量:
const Component = () => {
const [state, setState] = useState();
const onClick = useCallback(() => {
// 沒問題
console.log(state);
});
useEffect(() => {
// 沒問題
console.log(state);
});
組件內的每個函數都是一個閉包,因為組件本身只是一個函數。
過時閉包問題
以上所有內容,即使你之前沒有接觸過太多閉包的概念,仍然相對簡單。 你創建幾個函數幾次,它就會變得很自然。 很多年來,使用 React 編寫應用程序甚至都不需要理解“閉包”的概念。
那麼問題出在哪裏呢? 為什麼閉包是 JavaScript 中最可怕的事情之一,也是許多開發人員的痛苦之源?
這是因為只要閉包函數的引用存在,閉包就存在。 對函數的引用只是一個可以賦值給任何東西的值。 讓我們稍微動動腦子。 這是上面我們的函數,它返回一個閉包:
const something = (value) => {
const inside = () => {
console.log(value);
};
return inside;
};
但是 inside 函數會隨着每次 something 調用而重新創建。 如果我決定重構它並緩存它,會發生什麼? 像這樣:
const cache = {};
const something = (value) => {
if (!cache.current) {
cache.current = () => {
console.log(value);
};
}
return cache.current;
};
表面上看,這個代碼似乎沒什麼問題。 我們剛剛創建了一個名為 cache 的外部變量,並將內部函數賦值給 cache.current 屬性。 現在,我們只需返回已保存的值,而不是每次都重新創建該函數。
然而,如果我們嘗試調用它幾次,我們會看到一個奇怪的事情:
const first = something('first');
const second = something('second');
const third = something('third');
first(); // 打印 "first"
second(); // 打印 "first"
third(); // 打印 "first"
無論我們使用不同的參數調用 something 函數多少次,打印的值始終是第一個!
為了修復此行為,我們希望在每次入參發生變化時重新創建該函數及其閉包。 像這樣:
const cache = {};
let prevValue;
const something = (value) => {
// 檢查值是否改變
if (!cache.current || value !== prevValue) {
cache.current = () => {
console.log(value);
};
}
// 更新它
prevValue = value;
return cache.current;
};
將值保存在變量中,以便我們可以將下一個入參與前一個入參進行比較。 如果變量發生了變化,則更新 cache.current 閉包
現在它將正確打印變量,如果我們比較具有相同入參的函數,則將返回 true:
const first = something('first');
const anotherFirst = something('first');
const second = something('second');
first(); // 打印 "first"
second(); // 打印 "second"
console.log(first === anotherFirst); // 返回 true
useCallback中的過時閉包
我們剛剛實現了幾乎完全一樣的 useCallback hook 為我們所做的事情! 每次我們使用 useCallback 時,我們都會創建一個閉包,並且我們傳遞給它的函數會被緩存:
// 該內聯函數的緩存與之前的部分完全相同
const onClick = useCallback(() => {}, []);
如果我們需要訪問此函數內的 state 或 props,我們需要將它們添加到依賴項數組中:
const Component = () => {
const [state, setState] = useState();
const onClick = useCallback(() => {
// 訪問內部 state
console.log(state);
// 需要添加到依賴數組裏面
}, [state]);
};
這個依賴數組使得 React 刷新緩存的閉包,就像我們比較 value !== prevValue 時所做的那樣。 如果我忘記填這個數組,我們的閉包就會變得過時:
const Component = () => {
const [state, setState] = useState();
const onClick = useCallback(() => {
// state 將永遠都是初始值
// 閉包永遠不會刷新
console.log(state);
// 忘記寫依賴數組
}, []);
};
每次觸發這個回調函數,都會打印 undefined
Refs 中的過時閉包
在 useCallback 和 useMemo hook 之後,引入過時閉包問題的第二個最常見的方法是 Refs
如果我嘗試對 onClick 回調函數使用 Ref 而不是 useCallback hook,會發生什麼情況? 有時,網上的文章建議這樣做來緩存組件上的 props。 從表面上看,它確實看起來更簡單:只需將一個函數傳遞給 useRef 並通過 ref.current 訪問它。 沒有依賴,也不用擔心。
const Component = () => {
const ref = useRef(() => {
// 點擊回調
});
// ref.current 存儲了函數
return <HeavyComponent onClick={ref.current} />;
};
然而。 組件內的每個函數都會形成一個閉包,包括我們傳遞給 useRef 的函數。 我們的 ref 在創建時只會初始化一次,並且不會自行更新。 這基本上就是我們一開始創建的邏輯。 只是我們傳遞的不是 value,而是我們想要保留的函數。 像這樣:
const ref = {};
const useRef = (callback) => {
if (!ref.current) {
ref.current = callback;
}
return ref.current;
};
因此,在這種情況下,在剛載入組件時一開始形成的閉包將被保留並且永遠不會刷新。 當我們嘗試訪問存儲在 Ref 中的函數內的 state 或 props 時,我們只會獲得它們的初始值:
const Component = ({ someProp }) => {
const [state, setState] = useState();
const ref = useRef(() => {
// 所有都會被緩存並且永遠不會改變
console.log(someProp);
console.log(state);
});
};
為了解決這個問題,我們需要確保每次嘗試訪問內部內容發生變化時都會更新該引用值。 本質上,我們需要實現 useCallback hook 中依賴數組所做的事情
const Component = ({ someProp }) => {
// 初始化 ref - 創建閉包
const ref = useRef(() => {
// 所有都會被緩存並且永遠不會改變
console.log(someProp);
console.log(state);
});
useEffect(() => {
// 當 state 或者 props 更新時,及時更新閉包
ref.current = () => {
console.log(someProp);
console.log(state);
};
}, [state, someProp]);
};
React.memo 中的過時閉包
最後,我們回到文章的開頭以及引發這一切的謎團。 我們再看一下有問題的代碼:
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 中我們的 value 永遠不會更新。 現在你能説出原因嗎
當然,這又是一個過時的閉包。 當我們創建 onClick 時,首先使用默認 state 值(即“ undefined ”)形成閉包。 我們將該閉包與 title 屬性一起傳遞給我們的記憶組件。 在比較函數中,我們僅比較標題。 它永遠不會改變,它只是一個字符串。 比較函數始終返回 true,HeavyComponent 永遠不會更新,因此它保存對第一個 onClick 閉包的引用,並具有凍結的 “undefined” 值。
既然我們知道了問題所在,那麼我們該如何解決呢? 這裏説起來容易做起來難……
理想情況下,我們應該在比較函數中比較每個 prop,因此我們需要在其中包含 onClick:
(before, after) => {
return (
before.title === after.title &&
before.onClick === after.onClick
);
};
然而,在這種情況下,這意味着我們只是重新實現 React 默認行為,並完全執行沒有比較函數的 React.memo 的操作。 所以我們可以放棄它,只將其保留為 React.memo(HeavyComponent)。
但這樣做意味着我們需要將 onClick 包裝在 useCallback 中。 但這取決於 state,因此它會隨着每次擊鍵而改變。 我們回到了第一點:我們的「重組件」將在每次狀態變化時重新渲染,這正是我們試圖避免的。
我們可以嘗試組合並嘗試提取和隔離 state 或者是 HeavyComponent。 但這並不容易:輸入和 HeavyComponent 都依賴於該 state。
我們可以嘗試很多其他的事情。 但我們不需要進行任何大量的重構來擺脱閉包陷阱。 有一個很酷的技巧可以在這裏幫助我們。
使用 Refs 逃離閉包陷阱
這個技巧絕對令人興奮:它非常簡單,但它可以永遠改變你在 React 中記憶函數的方式。 或者也許沒有……無論如何,它可能有用,所以讓我們深入研究它。
現在讓我們去掉 React.memo 和 onClick 中的比較函數。 只是一個帶有 state 和記憶的 HeavyComponent 的純組件:
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={...} />
</>
);
}
現在我們需要添加一個 onClick 函數,該函數在重新渲染之間保持穩定,但也可以訪問最新狀態而無需重新創建自身。
我們將把它存儲在 Ref 中,所以讓我們添加它。 暫時為空:
const Form = () => {
const [value, setValue] = useState();
// 添加一個空的 ref
const ref = useRef();
};
為了使函數能夠訪問最新狀態,需要在每次重新渲染時重新創建它。 這是無法迴避的,這是閉包的本質,與 React 無關。 我們應該在 useEffect 中修改 Refs,而不是直接在渲染中修改,所以讓我們這樣做
const Form = () => {
const [value, setValue] = useState();
// 添加一個空的 ref
const ref = useRef();
useEffect(() => {
// 我們需要去觸發的回調函數
// 帶上 state
ref.current = () => {
console.log(value);
};
// 沒有依賴數組
});
};
不帶依賴數組的 useEffect 將在每次重新渲染時觸發。 這正是我們想要的。 所以現在我們的 ref.current 中,我們有一個每次重新渲染都會重新創建的閉包,因此記錄的狀態始終是最新的。
但我們不能只是將 ref.current 傳遞給記憶組件。 每次重新渲染時該值都會有所不同,因此緩存記憶是行不通的。
const Form = () => {
const ref = useRef();
useEffect(() => {
ref.current = () => {
console.log(value);
};
});
return (
<>
{/* 不能這麼做, 將會擊穿緩存,讓記憶失效 */}
<HeavyComponentMemo onClick={ref.current} />
</>
);
};
因此,我們創建一個封裝在 useCallback 中的小的空函數,並且不依賴它。
const Form = () => {
const ref = useRef();
useEffect(() => {
ref.current = () => {
console.log(value);
};
});
const onClick = useCallback(() => {
// 依賴是空的,所以函數永遠不會改變
}, []);
return (
<>
{/* 現在緩存生效了, onClick 永遠不會改變 */}
<HeavyComponentMemo onClick={onClick} />
</>
);
};
現在,緩存記憶功能完美地起作用了—— onClick 永遠不會改變。 但有一個問題:它什麼也不做。
這是一個魔術:讓它工作所需的只是在該記憶回調函數中調用 ref.current :
useEffect(() => {
ref.current = () => {
console.log(value);
};
});
const onClick = useCallback(() => {
// 在這裏調用 ref
ref.current();
// 依然是空的依賴數組!
}, []);
請注意 ref 為何不在 useCallback 的依賴項中? 沒必要這樣。 ref 本身永遠不會改變。 它僅僅是 useRef hook 返回的可變對象的引用。
但是,當閉包凍結其周圍的所有內容時,它不會使對象變得不可變或凍結。 對象存儲在內存的不同部分,多個變量可以包含對完全相同對象的引用。
const a = { value: 'one' };
// b 是不同的變量,指向相同的對象
const b = a;
如果我通過其中一個引用改變對象,然後通過另一個引用訪問它,則更改將可以生效:
a.value = 'two';
console.log(b.value); // 生效了,打印 "two"
例子中,我們在 useCallback 和 useEffect 中有完全相同的引用。 因此,當我們改變 useEffect 中ref 對象的當前屬性時,我們可以在 useCallback 中訪問該確切屬性。 這個屬性恰好是一個捕獲最新狀態數據的閉包。
完整的代碼如下所示:
const Form = () => {
const [value, setValue] = useState();
const ref = useRef();
useEffect(() => {
ref.current = () => {
// 最新的值
console.log(value);
};
});
const onClick = useCallback(() => {
// 最新的值
ref.current?.();
}, []);
return (
<>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<HeavyComponentMemo
title="Welcome closures"
onClick={onClick}
/>
</>
);
};
現在,我們擁有了兩全其美的優點:重組件已被正確記憶緩存,並且不會隨着每次狀態更改而重新渲染。 它的 onClick 回調可以訪問組件中的最新數據,而不會擊穿緩存。 現在可以安全地將我們需要的一切發送到後端了!
總結
希望所有這些都是有意義的,並且現在閉包對你來説很容易。 在你開始編碼之前,請記住有關閉包的注意事項:
- 每次在一個函數內創建另一個函數時都會形成閉包
- 由於 React 組件只是函數,因此內部創建的每個函數都會形成一個閉包,包括
useCallback和useRef等 hook - 當調用形成閉包的函數時,它周圍的所有數據都被“凍結”,就像快照一樣。
- 要更新該數據,我們需要重新創建“關閉”函數。 這就是
useCallback等 hook 的依賴項允許我們做的事情 - 如果我們錯過了依賴項,或者不刷新分配給
ref.current的關閉函數,則閉包將變得“過時”。 - 我們可以利用 Ref 是一個可變對象這一情況來逃離 React 中的“過時閉包”陷阱。 我們可以在過時閉包之外改變
ref.current,然後在內部訪問它。 將會是最新的數據。
感謝您的觀看,如果您對本篇文章有任何的意見或建議,歡迎關注我或者給我留言🌹
本文為翻譯文,原文地址:https://medium.com/@adevnadia/fantastic-closures-and-how-to-f...