談談JavaScript的閉包

前言

閉包(closure)這個概念總是那麼晦澀難懂,這篇文章希望結合自己的經驗來說說這個概念。

作用域

在談閉包之前,作用域這個概念是一定繞不開的。但是這裏並不準備展開來詳細說明他。簡單總結幾點。

  1. JavaScript中函數能形成一個作用域。
  2. 在使用某個變量時,會在當前作用域查找;如果沒有找到,則向外層作用域查找,直到全局作用域。也就是說,變量的搜索是從內到外,而不是從外到內。

變量的生存週期

JavaScript對無用變量佔用的內存會進行回收,現代瀏覽器是通過標記清除來實現垃圾回收的。以一個函數的局部變量爲例。

  1. 當函數開始執行時,申明瞭一個變量,爲他分配了內存。此時會將其標記爲進入環境。
  2. 函數執行完成後,會將該變量標記爲離開環境
  3. JavaScript引擎會在合適的時機將所有離開環境的變量全部回收。

閉包

先看一段十分簡單的代碼

const foo = () => {
    let duck = 0;
    return () => {
        console.log(duck++);
    }
}
try {
    console.log(duck);
} catch (e) {
    console.log(e.message);
}

const bar = foo();
for(let i = 0;i < 5; i++) {
    bar();
}
  1. 首先我們嘗試打印duck,這裏的異常會被捕獲,打印出duck is not defined,因爲外部無法訪問內部的一個變量
  2. 我們在foo這個函數,返回了一個匿名函數並且賦值給bar,然後連續調用了他,連續的打印出了0.1.2.3.4,可以看出,在foo這個函數作用域內的duck變量在函數執行完後,沒有被回收。

以上現象出現的原因就是因爲我們返回了一個函數並且給到外部的環境,而這個函數內的作用域依然保存了一個變量的引用(duck)。所以即使在foo函數執行完後,duck這個變量依然存在於內存中。在這裏,這個匿名函數可以理解爲一個閉包

閉包有哪些實際應用場景

在面向對象設計中,閉包可以用來實現自定義屬性讀寫。看一個簡單的例子

function Student(name,age){
    this.name = name;
    this.age = age;
}

const s = new Student('xiaoming',27);

這裏Student我們作爲函數構造器來使用,但是這樣得到的實例有兩個問題

  1. 比如我們訪問了一個不存在的屬性,例如s.height,這時候會返回undefined,假如你又同時對這個未知的值做一些額外的操作,那麼此時很大概率會拋出TypeError。
  2. JavaScript是弱類型的腳本語言,我們在定義Student的age時,明顯希望他是一個Number類型,並且應該在一個合適的範圍之內。但是實際上你有可能會這樣
 s.age = "手動狗頭"

瞧瞧,這是人乾的事麼…

這個時候利用下閉包就可以這麼幹了

function Person(name,age){
    var pName = name;
    var pAge = age;

    this.getName = function(){
        return pName || '';
    }
    this.setName = function(name){
        pName = name;
    }

    this.getAge = function(){
        return pAge || '';
    }
    this.setAge = function(age){
        if (isNaN(Number(age))) throw new Error('age must be a number');
        if (age < 0 || age > 200) throw new Error('emmmm...');
        pAge = age;
    }
}


const s = new Person('xiaoming',27);

我們可以利用閉包來延長某些局部變量的存活時間
前端中很多有這樣的場景,希望發起一個日誌請求,比如打點

var report = function (src) {
    var img = new Image();
    img.src = src;
};
report('http://xxx.com/getUserInfo');

用圖片來構造請求,的確十分方便,但是實際使用的時候會發現並不是所有請求都能發出去。原因就在於report函數執行完後,img這個局部變量被回收了,此時的請求還沒來得及發出去。
利用閉包可以這麼改造一下。

var reportGen = function () {
    var imgs = [];
    return function (src){
        var img = new Image();
        img.src = src;
        imgs.push(img);
    }
};
var report = reportGen();
report('http://xxx.com/getUserInfo');

閉包會不會引起內存泄漏

之前我們提到, 閉包可以使某些本應該被回收的變量存活了下來,那麼使用閉包會引起內存泄漏麼。看一段小Demo
注意,以下代碼運行時都是Node

function createArray(){
    const SIZE = 20 * 1024 * 1024;
    return new Array(SIZE);
}

for (let i = 0; i < 15;i++ ) {
    createArray();
    console.log(`第${i+1}次執行,目前已使用內存${process.memoryUsage().heapUsed/1024/1024}M`);
}

看下結果

第1次,目前已使用內存164.11568450927734M
第2次,目前已使用內存324.1443176269531M
第3次,目前已使用內存484.1500015258789M
第4次,目前已使用內存644.1517562866211M
第5次,目前已使用內存804.1533584594727M
第6次,目前已使用內存964.1549606323242M
第7次,目前已使用內存1124.1565628051758M
第8次,目前已使用內存1284.1581649780273M
第9次,目前已使用內存323.87422943115234M
第10次,目前已使用內存483.87635040283203M
第11次,目前已使用內存643.8779983520508M
第12次,目前已使用內存803.879524230957M
第13次,目前已使用內存964.1310577392578M
第14次,目前已使用內存1124.132583618164M
第15次,目前已使用內存1284.1341094970703M

我們不斷的開闢內存,發現從第1次到第8次內存使用量都在不停的增加,從第9次開始又開始回到正常水平,證明此時JavaScript進行了一次內存的回收,你們可以自己試試看,值得一提的是,從8到9程序會有細微的卡頓,這可以說明一些問題,這裏不展開。

我們來改造下這個Demo

const createArray = (function(){
    const arrays = [];
    return function (){
        const SIZE = 20 * 1024 * 1024;
        arrays.push(new Array(SIZE));
    }
})()

for (let i = 0; i < 15;i++ ) {
    createArray();
    console.log(`第${i+1}次執行,目前已使用內存${process.memoryUsage().heapUsed/1024/1024}M`);
}

我們來看下執行結果

<--- Last few GCs --->

[72721:0x102802400]     3012 ms: Mark-sweep 1283.9 (1290.9) -> 1283.9 (1290.9) MB, 549.4 / 0.0 ms  allocation failure GC in old space requested
[72721:0x102802400]     3589 ms: Mark-sweep 1283.9 (1290.9) -> 1283.8 (1287.9) MB, 576.8 / 0.0 ms  last resort GC in old space requested
[72721:0x102802400]     4122 ms: Mark-sweep 1283.8 (1287.9) -> 1283.8 (1287.9) MB, 533.2 / 0.0 ms  last resort GC in old space requested


<--- JS stacktrace --->

==== JS stack trace =========================================

Security context: 0x294d13f258b9 <JSObject>
    2: createArray [/Users/zhongzeming/myWork/practise/design-mode/advance-fun/memory.js:18] [bytecode=0x294dce463991 offset=16](this=0x294d90c8c2f1 <JSGlobal Object>)
    3: /* anonymous */ [/Users/zhongzeming/myWork/practise/design-mode/advance-fun/memory.js:22] [bytecode=0x294dce4633e1 offset=22](this=0x294d93b3d5d9 <Object map = 0x294dd66823b9>,exports=0x294d93b3d5d9 <Object map = 0x294dd668...

FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory

這裏截取了部分報錯的信息,看最後一句 JavaScript heap out of memory。
我們的arrays這個變量沒有被回收,導致他佔的內存越來越大。最後就內存溢出了。這樣看起來,閉包似乎會引起內存泄漏

再看一段Demo

let s = [];

function createArray(){
    const SIZE = 20 * 1024 * 1024;
    return new Array(SIZE);
}

for (let i = 0; i < 15;i++ ) {
    s[i] = createArray();
    console.log(`第${i+1}次執行,目前已使用內存${process.memoryUsage().heapUsed/1024/1024}M`);
}

這段代碼的執行結果也會報錯,內存也會溢出。

改造一下這個代碼,我們在合適的時機把數組s的元素引用置爲null

let s = [];

function createArray(){
    const SIZE = 20 * 1024 * 1024;
    return new Array(SIZE);
}

for (let i = 0; i < 15;i++ ) {
    s[i] = createArray();
    console.log(`第${i+1}次執行,目前已使用內存${process.memoryUsage().heapUsed/1024/1024}M`);
    s[i] = null;
}

這樣又可以正常運行了

閉包和內存泄漏其實沒有關係

爲什麼這麼說,其實在我看來,內存泄漏只是因爲你沒有對內存進行妥善的管理。尤其在瀏覽器環境,我們經常在閉包裏面形成對象的循環引用,而這在早期的一些瀏覽器中,由於他們使用的垃圾回收機制是引用清除,這會導致兩個對象佔用的內存都不會被回收(現代瀏覽器大多數已經解決了這個問題)
所以說,內存泄露在本質上也不是閉包造成的。

服務端要妥善使用

瀏覽器環境,我們一般不會用到需要申請非常大內存的場景。但是如果你通過Node來實現某個服務,這個時候就要特別注意,因爲大量的請求很容易讓你的內存泄漏問題暴露出來,一旦內存溢出會導致服務crash,即使通過守護進程重啓,也會造成很大的損失。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章