作者:Dmitri Pavlutin
譯者:前端小智
來源:dmitripavlutin
為了保證的可讀性,本文采用意譯而非直譯。
1. JS 中的閉包
下面定義了一個工廠函式 createIncrement(i)
,它返回一個increment
函式。之後,每次呼叫increment
函式時,內部計數器的值都會增加i
。
function createIncrement(i) {
let value = 0;
function increment() {
value += i;
console.log(value);
}
return increment;
}
const inc = createIncrement(1);
inc(); // 1
inc(); // 2
複製程式碼
createIncrement(1)
返回一個增量函式,該函式賦值給inc
變數。當呼叫inc()
時,value
變數加1
。
第一次呼叫inc()
返回1
,第二次呼叫返回2
,依此類推。
這挺趣的,只要呼叫inc()
還不帶引數,JS 仍然知道當前 value
和 i
的增量,來看看這玩意是如何工作的。
原理就在 createIncrement()
中。當在函式上返回一個函式時,有會有閉包產生。閉包捕獲詞法作用域中的變數 value
和 i
。
詞法作用域是定義閉包的外部作用域。在本例中,increment()
的詞法作用域是createIncrement()
的作用域,其中包含變數 value
和 i
。
無論在何處呼叫 inc()
,甚至在 createIncrement()
的作用域之外,它都可以訪問 value
和 i
。
閉包是一個可以從其詞法作用域記住和修改變數的函式,不管執行作用域是什麼。
繼續這個例子,可以在任何地方呼叫 inc()
,甚至在非同步回撥中也可以:
(function() {
inc(); // 3
}());
setTimeout(function() {
inc(); // 4
}, 1000);
複製程式碼
2. React Hooks 中的閉包
通過簡化狀態重用和副作用管理,Hooks 取代了基於類的元件。此外,我們們可以將重複的邏輯提取到自定義 Hook 中,以便在應用程式之間重用。
Hooks 嚴重依賴於 JS 閉包,但是閉包有時很棘手。
當我們們使用一個有多種副作用和狀態管理的 React 元件時,可能會遇到的一個問題是過時的閉包,這可能很難解決。
我們們從提煉出過時的閉包開始。然後,看看過時的閉包如何影響 React Hook,以及如何解決這個問題。
3. 過時的閉包
工廠函式createIncrement(i)
返回一個increment
函式。increment
函式對 value
增加i請輸入程式碼
,並返回一個記錄當前 value
的函式
function createIncrement(i) {
let value = 0;
function increment() {
value += i;
console.log(value);
const message = `Current value is ${value}`;
return function logValue() {
console.log(message);
};
}
return increment;
}
const inc = createIncrement(1);
const log = inc(); // 列印 1
inc(); // 列印 2
inc(); // 列印 3
// 無法正確工作
log(); // 列印 "Current value is 1"
複製程式碼
在第一次呼叫inc()
時,返回的閉包被分配給變數 log
。對 inc()
的 3
次呼叫的增量 value
為 3
。
最後,呼叫log()
列印 message “Current value is 1”
,這是出乎意料的,因為此時 value
等於 3
。
log()
是過時的閉包。在第一次呼叫 inc()
時,閉包 log()
捕獲了具有 “Current value is 1”
的 message
變數。而現在,當 value
已經是 3
時,message
變數已經過時了。
過時的閉包捕獲具有過時值的變數。
4.修復過時閉包的問題
使用新的閉包
解決過時閉包的第一種方法是找到捕獲最新變數的閉包。
我們們找到捕獲了最新 message
變數的閉包。就是從最後一次呼叫 inc() 返回的閉包。
const inc = createIncrement(1);
inc(); // 列印 1
inc(); // 列印 2
const latestLog = inc(); // 列印 3
// 正常工作
latestLog(); // 列印 "Current value is 3"
複製程式碼
latestLog
捕獲的 message
變數具有最新的的值 “Current value is 3”。
順便說一下,這大概就是 React Hook 處理閉包新鮮度的方式。
Hooks 實現假設在元件重新渲染之間,作為 Hook 回撥提供的最新閉包(例如 useEffect(callback)
) 已經從元件的函式作用域捕獲了最新的變數。
關閉已更改的變數
第二種方法是讓logValue()
直接使用 value
。
讓我們移動行 const message = ...;
到 logValue()
函式體中:
function createIncrementFixed(i) {
let value = 0;
function increment() {
value += i;
console.log(value);
return function logValue() {
const message = `Current value is ${value}`;
console.log(message);
};
}
return increment;
}
const inc = createIncrementFixed(1);
const log = inc(); // 列印 1
inc(); // 列印 2
inc(); // 列印 3
// 正常工作
log(); // 列印 "Current value is 3"
複製程式碼
logValue()
關閉 createIncrementFixed()
作用域內的 value
變數。log()
現在列印正確的訊息“Current value is 3
”。
5. Hook 中過時的閉包
useEffect()
現在來研究一下在使用 useEffect()
Hook 時出現過時閉包的常見情況。
在元件 <WatchCount>
中,useEffect()
每秒列印 count
的值。
function WatchCount() {
const [count, setCount] = useState(0);
useEffect(function() {
setInterval(function log() {
console.log(`Count is: ${count}`);
}, 2000);
}, []);
return (
<div>
{count}
<button onClick={() => setCount(count + 1) }>
加1
</button>
</div>
);
}
複製程式碼
開啟 CodeSandbox 並單擊幾次加1按鈕。然後看看控制檯,每2秒列印 Count is: 0
。
咋這樣呢?
在第一次渲染時,log()
中閉包捕獲 count
變數的值 0
。過後,即使 count
增加,log()
中使用的仍然是初始化的值 0
。log()
中的閉包是一個過時的閉包。
解決方案是讓 useEffect()
知道 log()
中的閉包依賴於count
:
function WatchCount() {
const [count, setCount] = useState(0);
useEffect(function() {
const id = setInterval(function log() {
console.log(`Count is: ${count}`);
}, 2000);
return function() {
clearInterval(id);
}
}, [count]); // 看這裡,這行是重點
return (
<div>
{count}
<button onClick={() => setCount(count + 1) }>
Increase
</button>
</div>
);
}
複製程式碼
適當地設定依賴項後,一旦 count
更改,useEffect()
就更新閉包。
同樣開啟修復的 codesandbox,單擊幾次加1按鈕。然後看看控制檯,這次列印就是正確的值了。
正確管理 Hook 依賴關係是解決過時閉包問題的關鍵。推薦安裝 eslint-plugin-react-hooks,它可以幫助我們們檢測被遺忘的依賴項。
useState()
元件<DelayedCount>
有 2 個按鈕:
-
點選按鍵 “Increase async” 在非同步模式下以
1
秒的延遲遞增計數器 -
在同步模式下,點選按鍵 “Increase sync” 會立即增加計數器。
function DelayedCount() { const [count, setCount] = useState(0);
function handleClickAsync() { setTimeout(function delay() { setCount(count + 1); }, 1000); } function handleClickSync() { setCount(count + 1); } return ( <div> {count} <button onClick={handleClickAsync}>Increase async</button> <button onClick={handleClickSync}>Increase sync</button> </div> ); 複製程式碼
}
現在開啟 codesandbox 演示。點選 “Increase async” 按鍵然後立即點選 “Increase sync” 按鈕,count
只更新到 1
。
這是因為 delay()
是一個過時的閉包。
來看看這個過程發生了什麼:
-
初始渲染:
count
值為0
。 -
點選 'Increase async' 按鈕。
delay()
閉包捕獲count
的值0
。setTimeout()
1 秒後呼叫delay()
。 -
點選 “Increase async” 按鍵。
handleClickSync()
呼叫setCount(0 + 1)
將count
的值設定為1
,元件重新渲染。 -
1
秒之後,setTimeout()
執行delay()
函式。但是delay()
中閉包儲存count
的值是初始渲染的值0
,所以呼叫setState(0 + 1)
,結果count
保持為1
。
delay()
是一個過時的閉包,它使用在初始渲染期間捕獲的過時的 count
變數。
為了解決這個問題,可以使用函式方法來更新 count
狀態:
function DelayedCount() {
const [count, setCount] = useState(0);
function handleClickAsync() {
setTimeout(function delay() {
setCount(count => count + 1); // 這行是重點
}, 1000);
}
function handleClickSync() {
setCount(count + 1);
}
return (
<div>
{count}
<button onClick={handleClickAsync}>Increase async</button>
<button onClick={handleClickSync}>Increase sync</button>
</div>
);
}
複製程式碼
現在 setCount(count => count + 1)
更新了 delay()
中的 count
狀態。React 確保將最新狀態值作為引數提供給更新狀態函式,過時的閉包的問題就解決了。
總結
閉包是一個函式,它從定義變數的地方(或其詞法範圍)捕獲變數。閉包是每個 JS 開發人員都應該知道的一個重要概念。
當閉包捕獲過時的變數時,就會出現過時閉包的問題。解決過時閉包的一個有效方法是正確設定 React Hook 的依賴項。或者,對於過時的狀態,使用函式方式更新狀態。
你認為閉包使得 React Hook 很難理解嗎?
程式碼部署後可能存在的BUG沒法實時知道,事後為了解決這些BUG,花了大量的時間進行log 除錯,這邊順便給大家推薦一個好用的BUG監控工具 Fundebug。
原文: dmitripavlutin.com/simple-expl… dmitripavlutin.com/react-hooks…
交流(歡迎加入群,群工作日都會發紅包,互動討論技術)
乾貨系列文章彙總如下,覺得不錯點個Star,歡迎 加群 互相學習。
因為篇幅的限制,今天的分享只到這裡。如果大家想了解更多的內容的話,可以去掃一掃每篇文章最下面的二維碼,然後關注我們們的微信公眾號,瞭解更多的資訊和有價值的內容。
每次整理文章,一般都到2點才睡覺,一週4次左右,挺苦的,還望支援,給點鼓勵