1. 程式人生 > >前端進擊的巨人(三):從作用域走進閉包

前端進擊的巨人(三):從作用域走進閉包

es6 嚴格 向上 script 有序 for 占用 分享 !=

進擊的巨人第三篇,本篇就作用域、作用域鏈、閉包等知識點,一一擊破。

技術分享圖片

作用域

作用域:負責收集並維護由所有聲明的標識符(變量)組成的一系列查詢,並實施一套非常嚴格的規則,確定當前執行的代碼對這些標識符(變量)的訪問權限

——《你不知道的JavaScript上卷》

作用域有點像圈地盤,大家劃好區域,然後各自經營管理,井水不犯河水。

var globaValue = ‘我是全局作用域‘;
function foo() {
    var fooValue = ‘我是foo作用域‘;
    function bar() {
        var barValue = ‘我是bar作用域‘;
    }
}

function other() {
    var otherValue = ‘我是other作用域‘;
}

技術分享圖片

作用域的變量聲明

不同作用域下命名相同的變量不會發生沖突,"就近原則"選取。

var name = ‘任何名字‘;
function getName() {
    var name = ‘以樂之名‘;
    console.log(name);    // ‘以樂之名‘
}
console.log(name);        // ‘任何名字‘

作用域的類型

執行上下文環境有:全局、函數、eval。那麽作用域也有三種,ES6新增了塊級作用域。

  1. 全局作用域
  2. 函數作用域
  3. eval作用域(不推薦使用eval,暫時忽略)
  4. 塊級作用域(ES6新增)

全局作用域

JavaScript中全局環境只有一個,對應的全局作用域也只有一個。沒有用var/let/const

聲明的變量默認都會成為全局變量。

function foo() {
    a = 10;
};
console.log(a);    // 10 變全局變量(意外由此發生)

函數作用域

ES6之前,想要實現局部作用域的方式,都是是通過在函數中聲明變量來實現的,所以也稱函數作用域,支持嵌套多個。

var a = 20;
function foo() {
    var a = 10;
    console.log(a);    // 10;
}

函數中聲明變量時,建議在函數起始部分聲明所有變量,方便查看,切記要用var/let/const聲明,防止手抖將局部變量變成成全局變量。

function getClient() {
    var name;
    var phone;
    var sex;
}

塊級作用域

我們先來理解什麽是塊?所謂塊,其實就是被大括號{}包裹的代碼部分。

if (true) {
    // 這裏就是塊了,也可稱代碼塊
}

ES6前沒有塊級作用域的概念,所以{}中並沒有自己的作用域。如果我們想在ES5的環境下構建塊級作用域,一般都是是通過立即執行函數來實現的。

var name = ‘任何名字‘;
(function(window) {
    var name = ‘以樂之名‘;
    console.log(name);    // ‘以樂之名‘
}(window));
console.log(name);        // ‘任何名字‘

ES5借助函數作用域來實現塊級作用域的方式,會讓我們的代碼充斥大量的立即執行函數(IIFE),不便於代碼的閱讀。好的代碼的就跟好的文章一樣,讓閱讀的人讀來舒暢明了。

為此,ES6新增塊級作用域的概念,使用let/const聲明變量的方式,即可將其作用域指定在代碼塊中,跟函數作用域一樣支持嵌套。

let i = 0;
for (let i = 0; i < 10; i++){
    console.log(i);
}
i;    // 0

let/const不允許變量提升,必須"先聲明再使用"。這種限制,稱為"暫時性死區"。這也能讓我們在代碼編寫階段變得更加規範化,執行跟書寫順序保持一致。

作用域鏈(變量查詢規則)

變量被作用域所管理,那麽變量在作用域中的查找規則,就是所謂的作用域鏈。

作用域鏈的用途,是保證對執行環境有權訪問的所有變量和函數的有序訪問

——《JavaScript高級程序涉及》

"在當前執行環境開始查找使用到的變量,如果找到,則返回其值。如果找不到,會逐層往上級(父作用域)查找,直到全局作用域"

var money = 100;
function foo() {
    function bar() {
        console.log(money);
    }
    bar();
}
foo();

技術分享圖片

自由變量

變量我們見的不少,但"自由變量"聽著是不是挺唬人的。其實對它,我們並不陌生。

"自由變量:當前執行環境使用到,但並未在當前執行環境聲明的變量(函數參數arguments排除)"

函數調用時,進入執行上下文創建階段,會對argument進行隱式的變量聲明。

var outer = ‘我是外面變量‘;
function foo() {
    var inner = ‘我是裏面變量,不是自由變量‘;
    console.log(outer);   
    // 這裏用到了outer,但outer並不在函數foo中聲明,所以outer就是foo中的自由變量
}

"自由變量的作用域由詞法環境決定,也就是它的作用域在代碼書寫階段就已經確定了,而不是在代碼編譯執行階段確定。"

"自由變量的值是在代碼執行時確定的,變量變量變量,值肯定要變,所以自由變量的值只有在程序運行階段才能確定。"

閉包

開篇第一文我們就執行環境,執行棧做出了詳解,有所遺忘的可再溫習。執行棧是我們理解閉包原理基礎中的基礎。

函數調用棧過程的圖再曬出來,順便溫習下。

function foo () {
    function bar () {
        return ‘I am bar‘;
    }
    return bar();
}
foo();

技術分享圖片

函數調用時入棧,調用結束出棧。執行函數時,會創建一個變量對象去存儲函數中的變量,方法,參數arguments等,結束調用時,該變量對象就會被銷毀。(理想的情況下,不理想的情況就是出現"閉包"調用了)。

什麽是閉包?

閉包是指有權訪問另外一個函數作用域的變量的函數。

——《JavaScript高級程序設計》

閉包是指那些能夠訪問自由變量的函數。

——MDN

閉包的特點首先是函數,其次是它可以訪問到父級作用域的變量對象,即使父級函數完成調用後"理應出棧銷毀"

判定閉包出現

  1. 函數作為參數傳遞
  2. 函數作為返回值傳遞
function foo() {
    var fooVal = ‘2019‘;
    var bar = function() {
        console.log(fooVal);    // bar中使用到了自由變量fooVal
    }
    return bar;                 // 函數作為參數返回
}

var getValue = foo();
getValue();                     // 2019

對函數中誰是閉包,各文檔解釋不一。在此我們遵照Chrome的方式,暫且稱foo是閉包。

因為作用域和作用域鏈規則的限定,子環境的自由變量只能逐層向上到父環境查找。

但是通過閉包,我們在外部環境也可以獲取到變量fooVal,雖然foo()函數執行完成了,但它並沒從函數調用棧中銷毀,其變量對象存儲仍然能被訪問到。

實際執行過程請看圖:
技術分享圖片

把上述代碼改以下,接著看:

function foo() {
 var fooVal = ‘2019‘;
 var bar = function() {
 console.log(fooVal);     // bar中使用到了自由變量fooVal
 }
 return bar;              // 函數作為參數返回
}
var getValue = foo();
var fooVal = ‘2018‘;      // 這裏的fooVal是全局作用域的變量
getValue();               // 2019

答案與結果不符的小夥伴要回頭理解下自由變量了。"自由變量的作用域在代碼書寫時(函數創建時)就確定了",所以函數中getValue()使用的fooValfoo的作用域下,而不是在全局作用域下。

答對的小夥伴們再來一道題,加深你的記憶

function fn() {
    var max = 10;
    function bar(x) {
        if (x > max) {    
            console.log(x)
        }
    }
    return bar;
}
var f1 = fn();
var max = 100;

f1(20);                 // 輸出20

題目解析:max作為函數bar中的自由變量,它的作用域在函數bar創建的時候就確定了,就是函數fn中的max,所以它的作用域鏈查找到fn中已經結束並返回了,不會再向上找到全局作用域。

註意:棧中存儲的不只是閉包中使用到的自由變量,而是父級函數的整個變量對象(父級函數作用域中聲明的方法,變量,參數等)

閉包的應用場景

上文中已經闡述了閉包的特點,就是能夠讓我們跨作用域取值(不局限於父子作用域)。列舉兩個實際開放中常用的栗子:

  1. 封裝回調保存作用域
for(var i = 1; i < 5; i++) {
    setTimeout((function(i){
       return function() {
           console.log(i);        
       } 
    })(i), i * 1000)
}
// 原理:通過自執行函數傳參i,然後返回一個函數(閉包)中使用i,使父函數的變量對象一直存在
  1. 私有變量和方法實現模塊化
var makePeople = function () {
    var _name = ‘以樂之名‘;
    return {
        getName: function () {
            console.log(_name);
        },
        setName: function (name) {
            if (name != ‘Hello world‘) {
                _name = name;
            }
        }
    }
}

var me = makePeople();
me.getName();                   // ‘以樂之名‘
me.setName(‘KenTsang‘);         
me.getName();                   // ‘KenTsang‘

// 原理:私有變量_name沒有對外訪問權限,但通過閉包使其一直保留在內存中,可以被外部調用

閉包的應用場景還有很多,具體實際情況還需具體分析。

閉包造成的內存泄露

閉包的使用,破壞了函數的出棧過程。解釋執行棧的時候,講到同個函數即使調用自身,創建的變量對象也並非同一個,其內存存儲是各自獨立的。

棧中只入不出,函數的變量對象沒有被有效回收,就會造成瀏覽器內存占用逐步增加,內存占用過高的情況下,就會導致頁面卡頓,甚至瀏覽器崩潰。這就是我們常說的閉包造成的"內存泄露"

所以,一名合格的前端,除了會用閉包,還要正確的解除閉包引用。
垃圾回收機制講解時,通過設置變量值為null時可已解除變量的引用,以便下一次垃圾回收銷毀它。

function foo() {
 var fooVal = ‘2019‘;
 var bar = function() {
 console.log(fooVal);     
 }
 return bar;              
}
var getValue = foo();
var fooVal = ‘2018‘;     
getValue();
getValue = null;         // 解除引用,下一次垃圾回收就會回收了

寫在結尾

閉包算是前端初學者的一個難點,能解釋清除並不容易,涉及到作用域,執行上下文環境、變量對象等等。

零散知識的內聚匯總,正是是系列更文的初衷所在。

知識不是小段子,聽完笑過就忘,唯有形成體系,達成閉環,才能深植入記憶中。


參考文檔:

  • 深入理解javascript原型和閉包

本文首發Github,期待Star!
https://github.com/ZengLingYong/blog

作者:以樂之名
本文原創,有不當的地方歡迎指出。轉載請指明出處。

前端進擊的巨人(三):從作用域走進閉包