1. 程式人生 > >js中的閉包問題(持續更新)

js中的閉包問題(持續更新)

閉包,是一個擁有許多變數和綁定了這些變數的環境的表示式(通常是一個函式),因而這些變數也是該表示式的一部分

好啦說人話:
“我的理解是,閉包就是能夠讀取其他函式內部變數的函式。”--------阮一峰

相較官方文件而言阮一峰老師直接的多。不過由於js語言的特殊性使得不像其他面嚮物件語言一樣擁有明確的類與物件的關係以及特殊的作用域,閉包的作用也就尤為重要了


不過在此之前我們需要做一些鋪墊。++不想看的老鐵可以直接跳到正文哦++

JavaScript記憶體機制

底層語言中使用者一般都可以控制自己的記憶體(比如C中的malloc和free)。而高階語言則有一套自己的垃圾回收機制。

JavaScript同樣也有一套屬於自己的記憶體管理機制:記憶體基元在變數(物件,字串等等)建立時分配,然後在他們不再被使用時“自動”釋放。後者被稱為垃圾回收

記憶體模型

JS記憶體空間分為棧(stack)、堆(heap)、池(一般也會歸類為棧中)。 其中棧存放變數,堆存放複雜物件,池存放常量。

基礎資料型別與棧記憶體

JS中的基礎資料型別,這些值都有固定的大小,往往都儲存在棧記憶體中(閉包除外),由系統自動分配儲存空間。我們可以直接操作儲存在棧記憶體空間的值,因此基礎資料型別都是按值訪問
資料在棧記憶體中的儲存與使用方式類似於資料結構中的堆疊資料結構,遵循後進先出的原則。
基礎資料型別: Number String Null Undefined Boolean

引用資料型別與堆記憶體

JS的引用資料型別,比如陣列Array,它們值的大小是不固定的。

引用資料型別的值是儲存在堆記憶體中的物件。JS不允許直接訪問堆記憶體中的位置,因此我們不能直接操作物件的堆記憶體空間。在操作物件時,實際上是在操作物件的引用而不是實際的物件。因此,引用型別的值都是按引用訪問的。這裡的引用,我們可以粗淺地理解為儲存在棧記憶體中的一個地址,該地址與堆記憶體的實際值相關聯。

堆存取資料的方式,則與書架與書非常相似。

書雖然也有序的存放在書架上,但是我們只要知道書的名字,我們就可以很方便的取出我們想要的書。好比在JSON格式的資料中,我們儲存的key-value是可以無序的,因為順序的不同並不影響我們的使用,我們只需要關心書的名字。

我們來舉個例子

// demo01.js
var a = 20;
var b = a;
b = 30;
// 這時a的值是多少?


在棧記憶體中的資料發生複製行為時,系統會自動為新的變數分配一個新值。var b = a執行之後,a與b雖然值都等於20,但是他們其實已經是相互獨立互不影響的值了。

所以給b重新賦值之後b和a的值互不影響。

// demo02.js
var m = { a: 10, b: 20 };
var n = m;
n.a = 15;
// 這時m.a的值是多少

在demo02中,我們通過var n = m執行一次複製引用型別的操作。引用型別的複製同樣也會為新的變數自動分配一個新的值儲存在棧記憶體中,但不同的是,這個新的值,僅僅只是引用型別的一個地址指標。當地址指標相同時,儘管他們相互獨立,但是在堆記憶體中訪問到的具體物件實際上是同一個。
|棧記憶體空間||
|變數名|具體值|

記憶體的生命週期

這一段對閉包的影響最大!!!!!

JS環境中分配的記憶體一般有如下生命週期:

  • 記憶體分配:當我們申明變數、函式、物件的時候,系統會自動為他 們分配記憶體
  • 記憶體使用:即讀寫記憶體,也就是使用變數、函式等
  • 記憶體回收:使用完畢,由垃圾回收機制自動回收不再使用的記憶體

為了便於理解,我們使用一個簡單的例子來解釋這個週期。

var a = 20;  // 在記憶體中給數值變數分配空間
alert(a + 100);  // 使用記憶體
var a = null; // 使用完畢之後,釋放記憶體空間

第一步和第二步我們都很好理解,JavaScript在定義變數時就完成了記憶體分配。第三步釋放記憶體空間則是我們需要重點理解的一個點。

  • 從記憶體來看 null 和 undefined 本質的區別是什麼
  • 為什麼typeof(null) //object typeof(undefined) //undefined?
  • 現在再想想,建構函式和立即執行函式的宣告週期是什麼?
  • 對了,ES6語法中的 const 宣告一個只讀的常量。一旦宣告,常量的值就不能改變。但是下面的程式碼可以改變 const 的值,這是為什麼?
const foo = {}; 
foo.prop = 123;
foo.prop // 123
foo = {}; // TypeError: "foo" is read-only

記憶體回收

JavaScript有自動垃圾收集機制,那麼這個自動垃圾收集機制的原理是什麼呢?其實很簡單,就是找出那些不再繼續使用的值,然後釋放其佔用的記憶體。垃圾收集器會每隔固定的時間段就執行一次釋放操作。
在JavaScript中,最常用的是通過標記清除的演算法來找到哪些物件是不再繼續使用的,因此 a = null 其實僅僅只是做了一個釋放引用的操作,讓 a 原本對應的值失去引用,脫離執行環境,這個值會在下一次垃圾收集器執行操作時被找到並釋放。而在適當的時候解除引用,是為頁面獲得更好效能的一個重要方式。

  • 在區域性作用域中,當函式執行完畢,區域性變數也就沒有存在的必要了,因此垃圾收集器很容易做出判斷並回收。但是全域性變數什麼時候需要自動釋放記憶體空間則很難判斷,因此在我們的開發中,需要儘量避免使用全域性變數,以確保效能問題。

  • 以Google的V8引擎為例,在V8引擎中所有的JAVASCRIPT物件都是通過堆來進行記憶體分配的。當我們在程式碼中宣告變數並賦值時,V8引擎就會在堆記憶體中分配一部分給這個變數。如果已申請的記憶體不足以儲存這個變數時,V8引擎就會繼續申請記憶體,直到堆的大小達到了V8引擎的記憶體上限為止(預設情況下,V8引擎的堆記憶體的大小上限在64位系統中為1464MB,在32位系統中則為732MB)。

  • 另外,V8引擎對堆記憶體中的JAVASCRIPT物件進行分代管理。新生代:新生代即存活週期較短的JAVASCRIPT物件,如臨時變數、字串等;
    老生代:老生代則為經過多次垃圾回收仍然存活,存活週期較長的物件,如主控制器、伺服器物件等。

function fun1() {
    var obj = {name: 'csa', age: 24};
}
 
function fun2() {
    var obj = {name: 'coder', age: 2}
    return obj;
}
 
var f1 = fun1();
var f2 = fun2();

在上述程式碼中,當執行var f1 = fun1();的時候,執行環境會建立一個{name:‘csa’, age:24}這個物件,當執行var f2 = fun2();的時候,執行環境會建立一個{name:‘coder’, age=2}這個物件,然後在下一次垃圾回收來臨的時候,會釋放{name:‘csa’, age:24}這個物件的記憶體,但並不會釋放{name:‘coder’, age:2}這個物件的記憶體。這就是因為在fun2()函式中將{name:‘coder, age:2’}這個物件返回,並且將其引用賦值給了f2變數,又由於f2這個物件屬於全域性變數,所以在頁面沒有解除安裝的情況下,f2所指向的物件{name:‘coder’, age:2}是不會被回收的。
由於JavaScript語言的特殊性(閉包…),導致如何判斷一個物件是否會被回收的問題上變的異常艱難。

咳咳有點跑題了。


正文!!!

我們先用一個例子來開個頭

var count=10;//全域性作用域 標記為flag1
function add(){
    var count=0;//函式全域性作用域 標記為flag2
    return function(){
        count+=1;//函式的內部作用域
        alert(count);
    }
}
var s=add()
s();//輸出1
s();//輸出2
  • add()的返回值是一個函式,首先第一次呼叫s()的時候,是執行add()的返回的函式
  • 也就是將count+1,在輸出,那count是從哪兒來的的呢,根據作用域鏈的規則,底層作用域沒有宣告的變數,會向上一級找,找到就返回,沒找到就一直找,直到window的變數,沒有就返回undefined。這裡明顯count 是函式內部的flag2 的那個count
  • 不過諸位有沒有發現:為什麼第二次輸出的是2呢?原因在於第二次呼叫add時count的值還是第一次執行add時留下的count變數。

如果一個變數的引用不為0,那麼他不會被垃圾回收機制回收,引用,就是被呼叫

由於再次執行s()的時候,再次引用了第一次add()產生的變數count ,所以count沒有被釋放,第一次s(),count 的值為1,第二次執行s(),count的值再加1,自然就是2了。

//如果我們將count變數的作用域做一下更改,那麼結果就完全不同了

function add(){
    var count=0;//函式全域性作用域
    return function(){
        count+=1;//函式的內部作用域
        alert(count);
    }
}
add()();//輸出1
add()();//輸出1

使用閉包的注意點

  • 由於閉包會使得函式中的變數都被儲存在記憶體中,記憶體消耗很大,所以不能濫用閉包,否則會造成網頁的效能問題,在IE中可能導致記憶體洩露。解決方法是,在退出函式之前,將不使用的區域性變數全部刪除。

  • 閉包會在父函式外部,改變父函式內部變數的值。所以,如果你把父函式當作物件(object)使用,把閉包當作它的公用方法(Public Method),把內部變數當作它的私有屬性(private value),這時一定要小心,不要隨便改變父函式內部變數的值。

閉包的用途

Ⅰ.函式防抖&&函式節流

DOM中的debounce其實是從機械開關和繼電器的“去彈跳”衍生而來的,基本思路是將多個訊號合併為一個訊號

而js中debounce就是用來強制一個函式在某個連續時間段內只執行一次哪怕它會被多次呼叫

  • eg:通過使用者的輸入實時向伺服器傳送Ajax請求獲取資料
function debounce(fn, delay){
    var timer;  //定時器,用來setTimeout
    
    return function(){
        //儲存函式呼叫時的上下文和引數
        var context = this;
        var args = arguments;
        
        //每次返回的函式被呼叫就清空計時器,以保證不執行fn
        clearTimeout(timer);
        
        //當返回函式的最後一次呼叫後(即使用者停止了某連續操作)
        timer = setTimeout(function(){
            fn.apply(context, args);
        }, delay);
    }
}

//當用戶停止輸入時(0.3s)向伺服器傳送資料
$(selector).on('keyup', debounce(function(e){
    //傳送ajax請求
}, 300);
  • eg2:每隔30秒向後臺傳送ajax請求新資料以更新頁面資訊
function throttle(fn, threshhold){
    //記錄上次執行時間
    var last;
    
    //定時器
    var timer;
    
    //預設間隔為30 000毫秒
    threshhold || (threshhold = 30000)
    
    //返回的函式每過threshhold ms執行一次
    return function(){
        var context = this;
        var args = arguments;
        var now = new Date();
        
        //若距上次執行fn函式的時間小於threshhold則放棄
        if(last && now<last+threshhold){
            clearTimeout(timer);
            
            //保證在當前時間結束後再執行一次fn
            timer = setTimeout(function(){
               last = now;
               fn.apply(context, args);
            }, threshhold);
        }else{
            last = now;
            fn.apply(context, args);
        }
    }
}

什麼你告訴我你不知道apply?那你看一下另一篇部落格好啦

Ⅱ.設定私有變數

Java裡可以用private,但是js只能咱們自己搞啦

  • 首先是ES6的方法
let _width = Symbol();

    class Private {
        constructor(s) {
            this[_width] = s
        }

        foo() {
            console.log(this[_width])
        }

    }

    var p = new Private("50");
    p.foo();
    console.log(p[_width]);//可以拿到

  • 然後是傳統閉包
//賦值到閉包裡
    let sque = (function () {
        let _width = Symbol();

        class Squery {
            constructor(s) {
                this[_width] = s
            }

            foo() {
                console.log(this[_width])
            }
        }
        return Squery
    })();

    let ss = new sque(20);
    ss.foo();
    console.log(ss[_width])


Ⅲ.最後是小紅書上的經典問題:拿到正確的值
for(var i=0;i<10;i++){
    setTimeout(function(){
        console.log(i)//10個10
    },1000)
}

只要用到閉包一切就好辦多啦

for(var i=0;i<10;i++){
((j)=>{
  setTimeout(function(){
        console.log(j)//1-10
    },1000)})(i)
}

不會用箭頭函式?那咱們換種傳統的方法

for(var i=0;i<10;i++){
    setTimeout(function(){
        console.log(i)//10個10
    }(i),1000)
}

最後特別感謝幾位大佬–qiudaoermu、樑音、唯情–的分享,大家可以去部落格園,掘金上找到他們的身影