《JavaScript 闖關記》之作用域和閉包

劼哥stone發表於2016-12-13

作用域和閉包是 JavaScript 最重要的概念之一,想要進一步學習 JavaScript,就必須理解 JavaScript 作用域和閉包的工作原理。

作用域

任何程式設計語言都有作用域的概念,簡單的說,作用域就是變數與函式的可訪問範圍,即作用域控制著變數與函式的可見性和生命週期。在 JavaScript 中,變數的作用域有全域性作用域和區域性作用域兩種。

全域性作用域(Global Scope)

在程式碼中任何地方都能訪問到的物件擁有全域性作用域,一般來說以下三種情形擁有全域性作用域:

1.最外層函式和在最外層函式外面定義的變數擁有全域性作用域,例如:

var global = "global";     // 顯式宣告一個全域性變數
function checkscope() {
    var local = "local";   // 顯式宣告一個區域性變數
    return global;         // 返回全域性變數的值
}
console.log(global);       // "global"
console.log(checkscope()); // "global"
console.log(local);        // error: local is not defined.複製程式碼

上面程式碼中,global 是全域性變數,不管是在 checkscope() 函式內部還是外部,都能訪問到全域性變數 global

2.所有末定義直接賦值的變數自動宣告為擁有全域性作用域,例如:

function checkscope() {
    var local = "local"; // 顯式宣告一個區域性變數
    global = "global";   // 隱式宣告一個全域性變數(不好的寫法)
}
console.log(global);     // "global"
console.log(local);      // error: local is not defined.複製程式碼

上面程式碼中,變數 global 未用 var 關鍵字定義就直接賦值,所以隱式的建立了全域性變數 global,但這種寫法容易造成誤解,應儘量避免這種寫法。

3.所有 window 物件的屬性擁有全域性作用域

一般情況下,window 物件的內建屬性都擁有全域性作用域,例如 window.namewindow.locationwindow.top 等等。

區域性作用域(Local Scope)

和全域性作用域相反,區域性作用域一般只在固定的程式碼片段內可訪問到。最常見的是在函式體內定義的變數,只能在函式體內使用。例如:

function checkscope() {
    var local = "local";   // 顯式宣告一個區域性變數
    return local;         // 返回全域性變數的值
}
console.log(checkscope()); // "local"
console.log(local);        // error: local is not defined.複製程式碼

上面程式碼中,在函式體內定義了變數 local,在函式體內是可以訪問了,在函式外訪問就報錯了。

全域性和區域性作用域的關係

在函式體內,區域性變數的優先順序高於同名的全域性變數。如果在函式內宣告的一個區域性變數或者函式引數中帶有的變數和全域性變數重名,那麼全域性變數就被區域性變數所遮蓋。

var scope = "global";      // 宣告一個全域性變數
function checkscope() {
    var scope = "local";   // 宣告一個同名的區域性變數
    return scope;          // 返回區域性變數的值,而不是全域性變數的值
}
console.log(checkscope()); // "local"複製程式碼

儘管在全域性作用域編寫程式碼時可以不寫 var 語句,但宣告區域性變數時則必須使用 var 語句。思考一下如果不這樣做會怎樣:

scope = "global";           // 宣告一個全域性變數,甚至不用 var 來宣告
function checkscope2() {
    scope = "local";        // 糟糕!我們剛修改了全域性變數
    myscope = "local";      // 這裡顯式地宣告瞭一個新的全域性變數
    return [scope, myscope];// 返回兩個值
}
console.log(checkscope2()); // ["local", "local"],產生了副作用
console.log(scope);         // "local",全域性變數修改了
console.log(myscope);       // "local",全域性名稱空間搞亂了複製程式碼

函式定義是可以巢狀的。由於每個函式都有它自己的作用域,因此會出現幾個區域性作用域巢狀的情況,例如:

var scope = "global scope";         // 全域性變數
function checkscope() {
    var scope = "local scope";      //區域性變數 
    function nested() {
        var scope = "nested scope"; // 巢狀作用域內的區域性變數
        return scope;               // 返回當前作用域內的值
    }
    return nested();
}
console.log(checkscope());          // "nested scope"複製程式碼

函式作用域和宣告提前

在一些類似 C 語言的程式語言中,花括號內的每一段程式碼都具有各自的作用域,而且變數在宣告它們的程式碼段之外是不可見的,我們稱為塊級作用域(block scope),而 JavaScript 中沒有塊級作用域。JavaScript 取而代之地使用了函式作用域(function scope),變數在宣告它們的函式體以及這個函式體巢狀的任意函式體內都是有定義的。

在如下所示的程式碼中,在不同位置定義了變數 ijk,它們都在同一個作用域內,這三個變數在函式體內均是有定義的。

function test(o) {
    var i = 0; // i在整個函式體內均是有定義的
    if (typeof o == "object") {
        var j = 0; // j在函式體內是有定義的,不僅僅是在這個程式碼段內
        for (var k = 0; k < 10; k++) { // k在函式體內是有定義的,不僅僅是在迴圈內
            console.log(k); // 輸出數字0~9
        }
        console.log(k); // k已經定義了,輸出10
    }
    console.log(j); // j已經定義了,但可能沒有初始化
}複製程式碼

JavaScript 的函式作用域是指在函式內宣告的所有變數在函式體內始終是可見的。有意思的是,這意味著變數在宣告之前甚至已經可用。JavaScript 的這個特性被非正式地稱為宣告提前(hoisting),即 JavaScript 函式裡宣告的所有變數(但不涉及賦值)都被「提前」至函式體的頂部,看一下如下程式碼:

var scope = "global";
function f() {
    console.log(scope);  // 輸出"undefined",而不是"global"
    var scope = "local"; // 變數在這裡賦初始值,但變數本身在函式體內任何地方均是有定義的
    console.log(scope);  // 輸出"local"
}複製程式碼

你可能會誤以為函式中的第一行會輸出 "global",因為程式碼還沒有執行到 var 語句宣告區域性變數的地方。其實不然,由於函式作用域的特性,區域性變數在整個函式體始終是有定義的,也就是說,在函式體內區域性變數遮蓋了同名全域性變數。儘管如此,只有在程式執行到 var 語句的時候,區域性變數才會被真正賦值。因此,上述過程等價於:將函式內的變數宣告“提前”至函式體頂部,同時變數初始化留在原來的位置:

function f() {
    var scope;          // 在函式頂部宣告瞭區域性變數
    console.log(scope); // 變數存在,但其值是"undefined"
    scope = "local";    // 這裡將其初始化並賦值
    console.log(scope); // 這裡它具有了我們所期望的值
}複製程式碼

在具有塊級作用域的程式語言中,在狹小的作用域裡讓變數宣告和使用變數的程式碼儘可能靠近彼此,通常來講,這是一個非常不錯的程式設計習慣。由於 JavaScript 沒有塊級作用域,因此一些程式設計師特意將變數宣告放在函式體頂部,而不是將宣告靠近放在使用變數之處。這種做法使得他們的原始碼非常清晰地反映了真實的變數作用域。

作用域鏈

當程式碼在一個環境中執行時,會建立變數物件的一個作用域鏈(scope chain)。作用域鏈的用途,是保證對執行環境有權訪問的所有變數和函式的有序訪問。作用域鏈的前端,始終都是當前執行的程式碼所在環境的變數物件。如果這個環境是函式,則將其活動物件(activation object)作為變數物件。活動物件在最開始時只包含一個變數,即 arguments 物件(這個物件在全域性環境中是不存在的)。作用域鏈中的下一個變數物件來自包含(外部)環境,而再下一個變數物件則來自下一個包含環境。這樣,一直延續到全域性執行環境;全域性執行環境的變數物件始終都是作用域鏈中的最後一個物件。

識別符號解析是沿著作用域鏈一級一級地搜尋識別符號的過程。搜尋過程始終從作用域鏈的前端開始,然後逐級地向後回溯,直至找到識別符號為止(如果找不到識別符號,通常會導致錯誤發生)。

請看下面的示例程式碼:

var color = "blue";

function changeColor(){
    if (color === "blue"){
        color = "red";
    } else {
        color = "blue";
    }
}

console.log(changeColor());複製程式碼

在這個簡單的例子中,函式 changeColor() 的作用域鏈包含兩個物件:它自己的變數物件(其中定義著 arguments 物件)和全域性環境的變數物件。可以在函式內部訪問變數 color,就是因為可以在這個作用域鏈中找到它。

此外,在區域性作用域中定義的變數可以在區域性環境中與全域性變數互換使用,如下面這個例子所示:

var color = "blue";

function changeColor(){
    var anotherColor = "red";

    function swapColors(){
        var tempColor = anotherColor;
        anotherColor = color;
        color = tempColor;

        // 這裡可以訪問color、anotherColor和tempColor
    }

    // 這裡可以訪問color和anotherColor,但不能訪問tempColor
    swapColors();
}

// 這裡只能訪問color
changeColor();複製程式碼

以上程式碼共涉及3個執行環境:全域性環境、changeColor() 的區域性環境和 swapColors() 的區域性環境。全域性環境中有一個變數 color 和一個函式 changeColor()changeColor() 的區域性環境中有一個名為 anotherColor 的變數和一個名為 swapColors() 的函式,但它也可以訪問全域性環境中的變數 colorswapColors() 的區域性環境中有一個變數 tempColor,該變數只能在這個環境中訪問到。無論全域性環境還是 changeColor() 的區域性環境都無權訪問 tempColor。然而,在 swapColors() 內部則可以訪問其他兩個環境中的所有變數,因為那兩個環境是它的父執行環境。下圖形象地展示了前面這個例子的作用域鏈。

《JavaScript 闖關記》之作用域和閉包

上圖中的矩形表示特定的執行環境。其中,內部環境可以通過作用域鏈訪問所有的外部環境,但外部環境不能訪問內部環境中的任何變數和函式。這些環境之間的聯絡是線性、有次序的。每個環境都可以向上搜尋作用域鏈,以查詢變數和函式名;但任何環境都不能通過向下搜尋作用域鏈而進入另一個執行環境。對於這個例子中的 swapColors() 而言,其作用域鏈中包含3個物件:swapColors() 的變數物件、changeColor() 的變數物件和全域性變數物件。swapColors() 的區域性環境開始時會先在自己的變數物件中搜尋變數和函式名,如果搜尋不到則再搜尋上一級作用域鏈。changeColor() 的作用域鏈中只包含兩個物件:它自己的變數物件和全域性變數物件。這也就是說,它不能訪問 swapColors() 的環境。函式引數也被當作變數來對待,因此其訪問規則與執行環境中的其他變數相同。

閉包

MDN 對閉包的定義:

閉包是指那些能夠訪問獨立(自由)變數的函式(變數在本地使用,但定義在一個封閉的作用域中)。換句話說,這些函式可以「記憶」它被建立時候的環境。

《JavaScript 權威指南(第6版)》對閉包的定義:

函式物件可以通過作用域鏈相互關聯起來,函式體內部的變數都可以儲存在函式作用域內,這種特性在電腦科學文獻中稱為閉包。

《JavaScript 高階程式設計(第3版)》對閉包的定義:

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

上面這些定義都比較晦澀難懂,阮一峰的解釋稍微好理解一些:

由於在 Javascript 語言中,只有函式內部的子函式才能讀取區域性變數,因此可以把閉包簡單理解成定義在一個函式內部的函式。

閉包的用途

閉包可以用在許多地方。它的最大用處有兩個,一個是可以讀取函式內部的變數(作用域鏈),另一個就是讓這些變數的值始終保持在記憶體中。怎麼來理解這句話呢?請看下面的程式碼。

function fun() {   
    var n = 1;

    add = function() {
        n += 1
    }

    function fun2(){
        console.log(n);
    }

    return fun2;
}

var result = fun();  
result(); // 1
add();
result(); // 2複製程式碼

在這段程式碼中,result 實際上就是函式 fun2。它一共執行了兩次,第一次的值是 1,第二次的值是 2。這證明了,函式 fun 中的區域性變數 n 一直儲存在記憶體中,並沒有在 fun 呼叫後被自動清除。

為什麼會這樣呢?原因就在於 funfun2 的父函式,而 fun2 被賦給了一個全域性變數,這導致 fun2 始終在記憶體中,而 fun2 的存在依賴於 fun,因此 fun 也始終在記憶體中,不會在呼叫結束後,被垃圾回收機制(garbage collection)回收。

這段程式碼中另一個值得注意的地方,就是 add = function() { n += 1 } 這一行。首先,變數 add 前面沒有使用 var 關鍵字,因此 add 是一個全域性變數,而不是區域性變數。其次,add 的值是一個匿名函式(anonymous function),而這個匿名函式本身也是一個閉包,和 fun2 處於同一作用域,所以 add 相當於是一個 setter,可以在函式外部對函式內部的區域性變數進行操作。

計數器的困境

我們再來看一個經典例子「計數器的困境」,假設你想統計一些數值,且該計數器在所有函式中都是可用的。你可以定義一個全域性變數 counter 當做計數器,再定義一個 add() 函式來設定計數器遞增。程式碼如下:

var counter = 0;
function add() {
    return counter += 1;
}

console.log(add());
console.log(add());
console.log(add());
// 計數器現在為 3複製程式碼

計數器數值在執行 add() 函式時發生變化。但問題來了,頁面上的任何指令碼都能改變計數器 counter,即便沒有呼叫 add() 函式。如果我們將計數器 counter 定義在 add() 函式內部,就不會被外部指令碼隨意修改到計數器的值了。程式碼如下:

function add() {
    var counter = 0;
    return counter += 1;
}

console.log(add());
console.log(add());
console.log(add());
// 本意是想輸出 3, 但事與願違,輸出的都是 1複製程式碼

因為每次呼叫 add() 函式,計數器都會被重置為 0,輸出的都是 1,這並不是我們想要的結果。閉包正好可以解決這個問題,我們在 add() 函式內部,再定義一個 plus() 內嵌函式(閉包),內嵌函式 plus() 可以訪問父函式的 counter 變數。程式碼如下:

function add() {
    var counter = 0;
    var plus = function() {counter += 1;}
    plus();
    return counter; 
}複製程式碼

接下來,只要我們能在外部訪問 plus() 函式,並且確保 counter = 0 只執行一次,就能解決計數器的困境。程式碼如下:

var add = function() {
    var counter = 0;
    var plus = function() {return counter += 1;}
    return plus;
}

var puls2 = add();
console.log(puls2());
console.log(puls2());
console.log(puls2());
// 計數器為 3複製程式碼

計數器 counteradd() 函式的作用域保護,只能通過 puls2 方法修改。

使用閉包的注意點

  • 由於閉包會使得函式中的變數都被儲存在記憶體中,記憶體消耗很大,所以不能濫用閉包,否則會造成網頁的效能問題,在 IE 中可能導致記憶體洩露。解決方法是,在退出函式之前,將不使用的區域性變數全部刪除或設定為 null,斷開變數和記憶體的聯絡。
  • 閉包會在父函式外部,改變父函式內部變數的值。所以,如果你把父函式當作物件(object)使用,把閉包當作它的公用方法(public method),把內部變數當作它的私有屬性(private value),這時一定要小心,不要隨便改變父函式內部變數的值。

JavaScript 閉包是一種強大的語言特性。通過使用這個語言特性來隱藏變數,可以避免覆蓋其他地方使用的同名變數,理解閉包有助於編寫出更有效也更簡潔的程式碼。

this 關鍵字

談到作用域和閉包就不得不說 this 關鍵字,雖然它們之間關聯不大,但是它們一起使用卻容易讓人產生疑惑。下面列出了使用 this 的大部分場景,帶大家一探究竟。

this 是 JavaScript 的關鍵字,指函式執行時的上下文,跟函式定義時的上下文無關。隨著函式使用場合的不同,this 的值會發生變化。但是有一個總的原則,那就是 this 指代的是呼叫函式的那個物件。

全域性上下文

在全域性上下文中,也就是在任何函式體外部,this 指代全域性物件。

// 在瀏覽器中,this 指代全域性物件 window
console.log(this === window);  // true複製程式碼

函式上下文

在函式上下文中,也就是在任何函式體內部,this 指代呼叫函式的那個物件。

函式呼叫中的 this

function f1(){
    return this;
}

console.log(f1() === window); // true複製程式碼

如上程式碼所示,直接定義一個函式 f1(),相當於為 window 物件定義了一個屬性。直接執行函式 f1(),相當於執行 window.f1()。所以函式 f1() 中的 this 指代呼叫函式的那個物件,也就是 window 物件。

function f2(){
    "use strict"; // 這裡是嚴格模式
    return this;
}

console.log(f2() === undefined); // true複製程式碼

如上程式碼所示,在「嚴格模式」下,禁止 this 關鍵字指向全域性物件(在瀏覽器環境中也就是 window 物件),this 的值將維持 undefined 狀態。

物件方法中的 this

var o = {
    name: "stone",
    f: function() {
        return this.name;
    }
};

console.log(o.f()); // "stone"複製程式碼

如上程式碼所示,物件 o 中包含一個屬性 name 和一個方法 f()。當我們執行 o.f() 時,方法 f() 中的 this 指代呼叫函式的那個物件,也就是物件 o,所以 this.name 也就是 o.name

注意,在何處定義函式完全不會影響到 this 的行為,我們也可以首先定義函式,然後再將其附屬到 o.f。這樣做 this 的行為也一致。如下程式碼所示:

var fun = function() {
    return this.name;
};

var o = { name: "stone" };
o.f = fun;

console.log(o.f()); // "stone"複製程式碼

類似的,this 的繫結只受最靠近的成員引用的影響。在下面的這個例子中,我們把一個方法 g() 當作物件 o.b 的函式呼叫。在這次執行期間,函式中的 this 將指向 o.b。事實上,這與物件本身的成員沒有多大關係,最靠近的引用才是最重要的。

o.b = {
    name: "sophie"
    g: fun,
};

console.log(o.b.g()); // "sophie"複製程式碼

eval() 方法中的 this

eval() 方法可以將字串轉換為 JavaScript 程式碼,使用 eval() 方法時,this 指向哪裡呢?答案很簡單,看誰在呼叫 eval() 方法,呼叫者的執行環境中的 this 就被 eval() 方法繼承下來了。如下程式碼所示:

// 全域性上下文
function f1(){
    return eval("this");
}
console.log(f1() === window); // true

// 函式上下文
var o = {
    name: "stone",
    f: function() {
        return eval("this.name");
    }
};
console.log(o.f()); // "stone"複製程式碼

call()apply() 方法中的 this

call()apply() 是函式物件的方法,它的作用是改變函式的呼叫物件,它的第一個引數就表示改變後的呼叫這個函式的物件。因此,this 指代的就是這兩個方法的第一個引數。

var x = 0;  
function f() {    
    console.log(this.x);  
}  
var o = {};  
o.x = 1;
o.m = f;  
o.m.apply(); // 0複製程式碼

call()apply() 的引數為空時,預設呼叫全域性物件。因此,這時的執行結果為 0,證明 this 指的是全域性物件。如果把最後一行程式碼修改為:

o.m.apply(o); // 1複製程式碼

執行結果就變成了 1,證明了這時 this 指代的是物件 o

bind() 方法中的 this

ECMAScript 5 引入了 Function.prototype.bind。呼叫 f.bind(someObject) 會建立一個與 f 具有相同函式體和作用域的函式,但是在這個新函式中,this 將永久地被繫結到了 bind 的第一個引數,無論這個函式是如何被呼叫的。如下程式碼所示:

function f() {
    return this.a;
}

var g = f.bind({
    a: "stone"
});
console.log(g()); // stone

var o = {
    a: 28,
    f: f,
    g: g
};
console.log(o.f(), o.g()); // 28, stone複製程式碼

DOM 事件處理函式中的 this

一般來講,當函式使用 addEventListener,被用作事件處理函式時,它的 this 指向觸發事件的元素。如下程式碼所示:

<!DOCTYPE HTML>
<html>
<head>
    <meta charset="UTF-8">
    <title>test</title>
</head>
<body>
    <button id="btn" type="button">click</button>
    <script>
        var btn = document.getElementById("btn");
        btn.addEventListener("click", function(){
            this.style.backgroundColor = "#A5D9F3";
        }, false);
    </script>
</body>
</html>複製程式碼

但在 IE 瀏覽器中,當函式使用 attachEvent ,被用作事件處理函式時,它的 this 卻指向 window。如下程式碼所示:

<!DOCTYPE HTML>
<html>
<head>
    <meta charset="UTF-8">
    <title>test</title>
</head>
<body>
    <button id="btn" type="button">click</button>
    <script>
        var btn = document.getElementById("btn");
        btn.attachEvent("onclick", function(){
            console.log(this === window);  // true
        });
    </script>
</body>
</html>複製程式碼

內聯事件處理函式中的 this

當程式碼被內聯處理函式呼叫時,它的 this 指向監聽器所在的 DOM 元素。如下程式碼所示:

<button onclick="alert(this.tagName.toLowerCase());">
  Show this
</button>複製程式碼

上面的 alert 會顯示 button,注意只有外層程式碼中的 this 是這樣設定的。如果 this 被包含在匿名函式中,則又是另外一種情況了。如下程式碼所示:

<button onclick="alert((function(){return this})());">
  Show inner this
</button>複製程式碼

在這種情況下,this 被包含在匿名函式中,相當於處於全域性上下文中,所以它指向 window 物件。

關卡

仔細想想,下面程式碼塊會輸出什麼結果呢?

// 挑戰一
function func1() {
    function func2() {
        console.log(this)
    }
    return func2;
}
func1()();  // ???複製程式碼
// 挑戰二
scope = "stone";

function Func() {
    var scope = "sophie";

    function inner() {
        console.log(scope);
    }
    return inner;
}

var ret = Func();
ret();    // ???複製程式碼
// 挑戰三
scope = "stone";

function Func() {
    var scope = "sophie";

    function inner() {
        console.log(scope);
    }
    scope = "tommy";
    return inner;
}

var ret = Func();
ret();    // ???複製程式碼
// 挑戰四
scope = "stone";

function Bar() {
    console.log(scope);
}

function Func() {
    var scope = "sophie";
    return Bar;
}

var ret = Func();
ret();    // ???複製程式碼
// 挑戰五
var name = "The Window";  
var object = {    
    name: "My Object",
    getNameFunc: function() {      
        return function() {        
            return this.name;      
        };    
    }  
};  
console.log(object.getNameFunc()());    // ???複製程式碼
// 挑戰六
var name = "The Window";  
var object = {    
    name: "My Object",
    getNameFunc: function() {      
        var that = this;      
        return function() {        
            return that.name;      
        };    
    }  
};  
console.log(object.getNameFunc()());    // ???複製程式碼

更多

關注微信公眾號「劼哥舍」回覆「答案」,獲取關卡詳解。
關注 github.com/stone0090/j…,獲取最新動態。

相關文章