微信公眾號搜索並關注:進二開物, 更多技術週刊,React 技術棧、JavaScript/TypeScript/Rust 等等編程語言慢慢等你發現...
什麼是閉包?
閉包的概念是有很多版本,不同的地方對閉包的説法不一
維基百科:在計算機科學中,閉包(英語:Closure),又稱詞法閉包(Lexical Closure)或函數閉包(function closures),是在支持頭等函數的編程語言中實現詞法綁定的一種技術。
MDN: 閉包(closure)是一個函數以及其捆綁的周邊環境狀態(lexical environment,詞法環境)的引用的組合。
個人理解:
- 閉包是一個函數(返回一個函數)
- 返回的函數保存了對外變量引用
一個簡單的示例
function fn() {
let num = 1;
return function (n) {
return n + num
}
}
let rFn = fn()
let newN = rFn(3) // 4
num 變量作用域在 fn 函數中, rFn 函數卻能訪問 num 變量,這就是閉包函數能訪問外部函數變量。
從瀏覽器調試和 VSCode Nodejs 調試看閉包
- 瀏覽器
- VS Code 配合 Node.js
看到 Closure 中 fn 是閉包函數,其中保存 num 變量。
一個經典的閉包:單線程事件機制+循環問題,以及解決辦法
for (var i = 1; i <= 5; i++) {
setTimeout(() => {
console.log(i);
}, i * 1000);
}
輸出的結果都是 6,為什麼?
- for 循環是同步任務
- setTimeout 異步任務
for 循環一次,就會將 setTimeout 異步任務加入到瀏覽器的異步任務隊列中,同步任務完成之後,再從異步任務中拿新任務在線程中執行。由於 setTimeout 能夠訪問外部變量 i, 當同步任務完成之後,i 已經變成了6, setTimeout 中能夠訪問變量 i 都是 6。
解決辦法1:使用 let 聲明
for (var i = 1; i <= 5; i++) {
setTimeout(() => {
console.log(i);
}, i * 1000);
}
解決辦法2:自執行函數 + 閉包
for (var i = 1; i <= 5; i++) {
(function(i){
setTimeout(() => {
console.log(i);
}, i * 1000)
})(i)
}
解決辦法3:setTimeout 傳遞第三參數
第三個參數意思:附加參數,一旦定時器到期,它們會作為參數傳遞給要執行的函數
for (var i = 1; i <= 5; i++) {
setTimeout((j) => {
console.log(j);
}, 1000 * i, i);
}
閉包與函數科裏化
function add(num) {
return function (y) {
return num + y;
};
};
let incOneFn = add(1); let n = incOneFn(1); // 2
let decOneFn = add(-1); let m = decOneFn(1); // 0
add 函數的參數保存了閉包函數變量。
實際作用
在函數式編程閉包有非常重要的作用,lodash 等早期工具函數彌補 javascript 缺陷的工具函數,有大量的閉包的使用場景。
使用場景
- 創建私有變量
- 延長變量生命週期
節流函數
防止滾動行為,過度執行函數,必須要節流, 節流函數接受 函數 + 時間作為參數,都是閉包中變量,以下是一個簡單 setTimeout 版本:
function throttle(fn, time=300){
var t = null;
return function(){
if(t) return;
t = setTimeout(() => {
fn.call(this);
t = null;
}, time);
}
}
防抖函數
一個簡單的基於 setTimeout 防抖的函數的實現
function debounce(fn,wait){
var timer = null;
return function(){
if(timer !== null){
clearTimeout(timer);
}
timer = setTimeout(fn,wait);
}
}
React.useCallback 閉包陷阱問題
問題説明:父/子 組件關係, 父子組件都能使用 click 事件同時修改 state 數據, 並且子組件拿到傳遞下的 props 事件屬性,是經過 useCallback 優化過的。也就是這個被優化過的函數,存在閉包陷阱,(保存一直是初始 state 值)
import { useState, useCallback, memo } from "react";
const ChildWithMemo = memo((props: any) => {
return (
<div>
<button onClick={props.handleClick}>Child click</button>
</div>
);
});
const Parent = () => {
const [count, setCount] = useState(1);
const handleClickWithUseCallback = useCallback(() => {
console.log(count);
}, []); // 注意這裏是不能監聽 count, 因為每次變化都會重新綁定,造成造成子組件重新渲染
return (
<div>
<div>parent count : {count}</div>
<button onClick={() => setCount(count + 1)}>click</button>
<ChildWithMemo handleClick={handleClickWithUseCallback} />
</div>
);
};
export default Parent
- ChildWithMemo 使用 memo 進行優化,
- handleClickWithUseCallback 使用 useCallback 優化
問題是點擊子組件時候,輸出的 count 是初始值(被閉包了)。
解決辦法就是使用 useRef 保存操作變量函數:
import { useState, useCallback, memo, useRef } from "react";
const ChildWithMemo = memo((props: any) => {
console.log("rendered children")
return (
<div>
<button onClick={() => props.countRef.current()}>Child click</button>
</div>
);
});
const Parent = () => {
const [count, setCount] = useState(1);
const countRef = useRef<any>(null)
countRef.current = () => {
console.log(count);
}
return (
<div>
<div>parent count : {count}</div>
<button onClick={() => setCount(count + 1)}>click</button>
<ChildWithMemo countRef={countRef} />
</div>
);
};
export default Parent
針對這個問題,React 曾經認可過社區提出的增加 useEvent 方案,但是後面 useEvent 語義問題被廢棄了,對於渲染優化 React 採用了編譯優化的方案。其實類似的問題也會發生在 useEffect 中,使用時要注意閉包陷阱。
性能問題
- 閉包不要隨意定義,定義了一定找到合適的位置進行銷燬。因為閉包的變量保存在內存中,不會被銷燬,佔用較高的內存。
使用 chrome 面板功能 timeline + profiles 面板
- 打開開發者工具,選擇 Timeline 面板
- 在頂部的
Capture字段裏面勾選 Memory- 點擊左上角的錄製按鈕。
- 在頁面上進行各種操作,模擬用户的使用情況。
- 一段時間後,點擊對話框的 stop 按鈕,面板上就會顯示這段時間的內存佔用情況。
小結
- 知曉閉包的概念,並且有圖示
- 閉包的經典問題,以及解決辦法
- React useCallback 閉包陷阱
參考
- # setTimeout 第三個參數
- # JavaScript 內存泄漏教程