動態

詳情 返回 返回

淺談 Javascript 閉包 - 動態 詳情

微信公眾號搜索並關注:進二開物, 更多技術週刊,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 面板

  1. 打開開發者工具,選擇 Timeline 面板
  2. 在頂部的Capture字段裏面勾選 Memory
  3. 點擊左上角的錄製按鈕。
  4. 在頁面上進行各種操作,模擬用户的使用情況。
  5. 一段時間後,點擊對話框的 stop 按鈕,面板上就會顯示這段時間的內存佔用情況。

小結

  • 知曉閉包的概念,並且有圖示
  • 閉包的經典問題,以及解決辦法
  • React useCallback 閉包陷阱

參考

  • # setTimeout 第三個參數
  • # JavaScript 內存泄漏教程

Add a new 評論

Some HTML is okay.