使用 JS 及 React Hook 時需要注意過時閉包的坑

為了保證的可讀性,本文采用意譯而非直譯。


1. JS 中的閉包

下面定義了一個工廠函數 <code>createIncrement(i)/<code>,它返回一個<code>increment/<code>函數。之後,每次調用<code>increment/<code>函數時,內部計數器的值都會增加<code>i/<code>。

<code>function createIncrement(i) {
let value = 0;
function increment() {
value += i;
console.log(value);
}
return increment;
}

const inc = createIncrement(1);
inc(); // 1
inc(); // 2
/<code>

<code>createIncrement(1)/<code> 返回一個增量函數,該函數賦值給<code>inc/<code>變量。當調用<code>inc()/<code>時,<code>value/<code> 變量加<code>1/<code>。

第一次調用<code>inc()/<code>返回<code>1/<code>,第二次調用返回<code>2/<code>,依此類推。

這挺趣的,只要調用<code>inc()/<code>還不帶參數,JS 仍然知道當前 <code>value/<code> 和 <code>i/<code> 的增量,來看看這玩意是如何工作的。

原理就在 <code>createIncrement()/<code> 中。當在函數上返回一個函數時,會有閉包產生。閉包捕獲詞法作用域中的變量 <code>value/<code> 和 <code>i/<code>。

詞法作用域是定義閉包的外部作用域。在本例中,<code>increment()/<code> 的詞法作用域是<code>createIncrement()/<code>的作用域,其中包含變量 <code>value/<code> 和 <code>i/<code>。

使用 JS 及 React Hook 時需要注意過時閉包的坑

無論在何處調用 <code>inc()/<code>,甚至在 <code>createIncrement()/<code> 的作用域之外,它都可以訪問 <code>value/<code> 和 <code>i/<code>。

閉包是一個可以從其詞法作用域記住和修改變量的函數,不管執行作用域是什麼。

繼續這個例子,可以在任何地方調用 <code>inc()/<code>,甚至在異步回調中也可以:

<code>(function() {
inc(); // 3
}());

setTimeout(function() {
inc(); // 4
}, 1000);
/<code>

2. React Hooks 中的閉包

通過簡化狀態重用和副作用管理,Hooks 取代了基於類的組件。此外,咱們可以將重複的邏輯提取到自定義 Hook 中,以便在應用程序之間重用。

Hooks 嚴重依賴於 JS 閉包,但是閉包有時很棘手。

當咱們使用一個有多種副作用和狀態管理的 React 組件時,可能會遇到的一個問題是過時的閉包,這可能很難解決。

咱們從提煉出過時的閉包開始。然後,看看過時的閉包如何影響 React Hook,以及如何解決這個問題。

3. 過時的閉包

工廠函數<code>createIncrement(i)/<code>返回一個<code>increment/<code>函數。<code>increment/<code> 函數對 <code>value/<code> 增加i<code>請輸入代碼/<code> ,並返回一個記錄當前 <code>value/<code> 的函數

<code>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"
/<code>

在第一次調用<code>inc()/<code>時,返回的閉包被分配給變量 <code>log/<code>。對 <code>inc()/<code> 的 <code>3/<code> 次調用的增量 <code>value/<code> 為 <code>3/<code>。

最後,調用<code>log()/<code> 打印 message <code>“Current value is 1”/<code>,這是出乎意料的,因為此時 <code>value/<code> 等於 <code>3/<code>。

<code>log()/<code>是過時的閉包。在第一次調用 <code>inc()/<code> 時,閉包 <code>log()/<code> 捕獲了具有 <code>“Current value is 1”/<code> 的 <code>message/<code> 變量。而現在,當 <code>value/<code> 已經是 <code>3/<code> 時,<code>message/<code> 變量已經過時了。

過時的閉包捕獲具有過時值的變量。

4.修復過時閉包的問題

使用新的閉包

解決過時閉包的第一種方法是找到捕獲最新變量的閉包。

咱們找到捕獲了最新 <code>message/<code> 變量的閉包。就是從最後一次調用 inc() 返回的閉包。

<code>const inc = createIncrement(1);

inc(); // 打印 1
inc(); // 打印 2
const latestLog = inc(); // 打印 3
// 正常工作
latestLog(); // 打印 "Current value is 3"
/<code>

<code>latestLog/<code> 捕獲的 <code>message/<code> 變量具有最新的的值 “Current value is 3”。

順便說一下,這大概就是 React Hook 處理閉包新鮮度的方式。

Hooks 實現假設在組件重新渲染之間,作為 Hook 回調提供的最新閉包(例如 <code>useEffect(callback)/<code>) 已經從組件的函數作用域捕獲了最新的變量。

關閉已更改的變量

第二種方法是讓<code>logValue()/<code>直接使用 <code>value/<code>。

讓我們移動行 <code>const message = ...;/<code> 到 <code>logValue()/<code> 函數體中:

<code>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"
/<code>

<code>logValue()/<code> 關閉 <code>createIncrementFixed()/<code> 作用域內的 <code>value/<code> 變量。<code>log()/<code> 現在打印正確的消息“<code>Current value is 3/<code>”。

5. Hook 中過時的閉包

useEffect()

現在來研究一下在使用 <code>useEffect()/<code> Hook 時出現過時閉包的常見情況。

在組件 <code><watchcount>/<code> 中,<code>useEffect()/<code>每秒打印 <code>count/<code> 的值。

<code>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>
);
}
/<code>

打開 CodeSandbox 並單擊幾次加1按鈕。然後看看控制檯,每2秒打印 <code>Count is: 0/<code>。

咋這樣呢?

在第一次渲染時,<code>log()/<code> 中閉包捕獲 <code>count/<code> 變量的值 <code>0/<code>。過後,即使 <code>count/<code> 增加,<code>log()/<code>中使用的仍然是初始化的值 <code>0/<code>。<code>log()/<code> 中的閉包是一個過時的閉包。

解決方案是讓 <code>useEffect()/<code>知道 <code>log()/<code> 中的閉包依賴於<code>count/<code>:

<code>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>
);
}
/<code>

適當地設置依賴項後,一旦 <code>count/<code> 更改,<code>useEffect()/<code> 就更新閉包。

同樣打開修復的 codesandbox,單擊幾次加1按鈕。然後看看控制檯,這次打印就是正確的值了。

正確管理 Hook 依賴關係是解決過時閉包問題的關鍵。推薦安裝 eslint-plugin-react-hooks,它可以幫助咱們檢測被遺忘的依賴項。

useState()

組件<code><delayedcount>/<code>有 2 個按鈕:

  • 點擊按鍵 “Increase async” 在異步模式下以<code>1/<code>秒的延遲遞增計數器

  • 在同步模式下,點擊按鍵 “Increase sync” 會立即增加計數器。

<code> 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 asyncbutton>
<button onClick={handleClickSync}>Increase syncbutton>
div>
);
}
/<code>

現在打開 codesandbox 演示。點擊 “Increase async” 按鍵然後立即點擊 “Increase sync” 按鈕,<code>count/<code> 只更新到 <code>1/<code>。

這是因為 <code>delay()/<code> 是一個過時的閉包。

來看看這個過程發生了什麼:

  1. 初始渲染:<code>count/<code> 值為 <code>0/<code>。

  2. 點擊 'Increase async' 按鈕。<code>delay()/<code> 閉包捕獲 <code>count/<code> 的值 <code>0/<code>。<code>setTimeout()/<code> 1 秒後調用 <code>delay()/<code>。

  3. 點擊 “Increase async” 按鍵。<code>handleClickSync()/<code> 調用 <code>setCount(0 + 1)/<code> 將 <code>count/<code> 的值設置為 <code>1/<code>,組件重新渲染。

  4. <code>1/<code> 秒之後,<code>setTimeout()/<code> 執行 <code>delay()/<code> 函數。但是 <code>delay()/<code> 中閉包保存 <code>count/<code> 的值是初始渲染的值 <code>0/<code>,所以調用 <code>setState(0 + 1)/<code>,結果<code>count/<code>保持為 <code>1/<code>。

<code>delay()/<code> 是一個過時的閉包,它使用在初始渲染期間捕獲的過時的 <code>count/<code> 變量。

為了解決這個問題,可以使用函數方法來更新 <code>count/<code> 狀態:

<code>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 asyncbutton>
<button onClick={handleClickSync}>Increase syncbutton>
div>
);
}
/<code>

現在 <code>setCount(count => count + 1)/<code> 更新了 <code>delay()/<code> 中的 <code>count/<code> 狀態。React 確保將最新狀態值作為參數提供給更新狀態函數,過時的閉包的問題就解決了。

總結

閉包是一個函數,它從定義變量的地方(或其詞法範圍)捕獲變量。閉包是每個 JS 開發人員都應該知道的一個重要概念。

當閉包捕獲過時的變量時,就會出現過時閉包的問題。解決過時閉包的一個有效方法是正確設置 React Hook 的依賴項。或者,對於過時的狀態,使用函數方式更新狀態。

你認為閉包使得 React Hook 很難理解嗎?

代碼部署後可能存在的BUG沒法實時知道,事後為了解決這些BUG,花了大量的時間進行log 調試,這邊順便給大家推薦一個好用的BUG監控工具 Fundebug。


分享到:


相關文章: