前端基礎

-

閉包是什麼?JS Closure 完整應用教學

this.web

閉包 Closure 一直是很重要的前端觀念,也是面試愛考題。有很多實務場景都有使用到閉包(Closure),像是 debounce、資料封裝、非同步處理等等,都很常看到閉包的身影,但對於程式新手而言,閉包不是那麼好理解,所以今天這篇文章,就想帶你由淺入深了解閉包 Closure!

閉包 Closure 是什麼?

用最簡單的一句話說就是:內部函數訪問它外部的變數,像以下程式碼,一個內部函數 inner,它在一個外部函數 outer 的裡面,並且它用到了它外部的變數:

function outer() {
	let a = 1;
	return function inner() {
		console.log(a); // 訪問 inner 外部的變數
	};
}

let closure = outer();
closure(); // 1

outer 函數內,我們先宣告變數 a 為 1,並且返回一個函數 innerinner 會在控制台打印變數 a。因為 inner 訪問了它外部的變數 a,這樣就形成了一個閉包。

閉包 Closure 可以做什麼?

閉包最大的用處是做私有變數,舉例來說,我們現在要做很多個計數器 counter,每個計數器都有自己的數字 counts,我們不希望每個計數器的值互相影響,此時就可以利用閉包做到這件事情:

function makeCounter() {
	let counts = 0;
	return function () {
		console.log(++counts);
	};
}

const counter1 = makeCounter();
counter1(); // 1
counter1(); // 2

const counter2 = makeCounter();
counter2(); //1
counter1(); //3

可以發現 counter1counter2 之間不會互相影響,這就是閉包的好處,他把資料隔離起來,避免全局污染或互相影響。

那為什麼閉包可以製作私有變數呢?這就要提到 JS 的回收機制。

閉包 Closure 的底層機制

為了節省記憶體,JavaScript 會自動把沒用到的變數回收,避免佔用記憶體,也就是 JS 的垃圾回收 Garbage Collection。通常一般的函數在執行結束後,裡面的變數就會被自動回收,例如:

function sayHi() {
	const message = 'hi';
	console.log(message);
}

sayHi(); // hi

當我們執行完 sayHi 函數後,message 就會被回收,不會存在記憶體了。

反過來說,如果某個變數再之後的程式碼還會用到,那就會持續留在記憶體中,

而上面 makeCounter 例子中,因為使用了閉包,JavaScript 就無法確定 counts 之後還會不會用到,所以就會一直保存在記憶體之中,造成閉包的發生。

閉包 Closure 的缺點

其實閉包的缺點正是它的優點造成的,因為變數不會被回收,會佔著記憶體,所以過度使用閉包會影響效能。如果我們確定未來不會再使用到變數,也可以使用以下程式碼來手動釋放記憶體:

couter1=null

閉包 Closure 的應用

閉包的應用很多,這邊講幾個最常出現的實際場景。

閉包的應用 1 - 防止頻繁的觸發 Debounce

第一個應用是防止頻繁觸發,有時候我們不希望某個函數在短時間內觸發多次,而是只觸發最後一次,這被稱為防抖(Debounce),例如滾動頁面的觸發的函數,就可以利用閉包解決:

function debounce(fn, delay) {
  let timer = null;

  // 返回匿名函數
  return function () {
    if (timer) {
      // timer 第一次執行後會被保存在記憶體中,不會被回收
      clearTimeout(timer); // 執行 clearTimeout 後才被回收
    }

    // 一段時間後觸發我們傳入的函數
    timer = setTimeout(() => {
      fn();
    }, delay);
  };
}

function scrollEvent() {
  console.log('觸發滾動事件');
}

const betterScroll = debounce(scrollEvent, 500); // 使用閉包避免不斷執行函數
document.addEventListener('scroll', betterScroll); 

整段程式碼的解釋如下:

第一次滾動頁面時,觸發 betterScroll ,宣告外部變數 timer 值為 null,並回傳內部的匿名函數給 betterScroll,匿名函數內會設置 setTimeout,一段時間後執行 scrollEvent,若 500 毫秒內又滾動一次,則 clearTimeout(timer),並重新設置 setTimeout,值到 500 毫秒內都沒有滾動才會執行 scrollEvent。

這就是閉包最常見的第一個應用,用來防止用戶不小心多次點擊或短時間內觸發多次函數,造成一些意料之外的錯誤發生,接著來看看閉包的第二個應用。

閉包的應用 2 - 防止頻繁的觸發 Throttle

和防抖相反,有時候我們只想觸發第一次,這稱為節流(Throttle)。實際程式碼如下:

function throttle(fm, interval) {
  // last 為上一次觸發的時間
  let last = 0;

  // 返回匿名函數
  return function () {
    // 紀錄現在時間
    let now = new Date();
    if (now - last >= interval) {
      last = now;
      fn();
    }
  };
}

function scrollEvent() {
  console.log('觸發滾動事件');
}

const betterScroll = throttle(scrollEvent, 500);
document.addEventListener('scroll', betterScroll);

閉包的應用 3 - 封裝函式庫

相信你或多或少有聽過 JQuery,它就是利用到了閉包的概念來封裝函式庫來避免變數全局汙染:

(function () {
	var jQuery = (window.$ = function () {
		// ...
	});
})();

它將 jQuery 掛載到 window.$ 上,並搭配閉包和 IIFE 來防止 $ 變數被系統回收,因為不會被回收,所以會一直存在記憶體中,這樣 $ 變數就成為了一個閉包,它可以在全局被訪問到,但內部的變數和方法卻是私有的,不會污染全局命名空間,達到函式庫封裝的效果。

如果你的專案有些方法是需要一個內部空間來避免汙染命名,就可以利用閉包搭配 IIFE 來做到這件事情。

總結

總而言之,閉包就是內部函數訪問外部變數,閉包最常用的場景就是製作私有變數,常見的應用有防抖、節流和封裝函式庫。

你可能會感興趣的文章 👇