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

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

做用域

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

全局做用域(Global Scope)

在代碼中任何地方都能訪問到的對象擁有全局做用域,通常來講如下三種情形擁有全局做用域:前端

  1. 最外層函數和在最外層函數外面定義的變量擁有全局做用域,例如:
var global = "global";     // 顯式聲明一個全局變量
function checkscope() {
    var local = "local";   // 顯式聲明一個局部變量
    return global;         // 返回全局變量的值
}
console.log(scope);        // "global"
console.log(checkscope()); // "global"
console.log(local);        // error: local is not defined.

上面代碼中,global 是全局變量,無論是在 checkscope() 函數內部仍是外部,都能訪問到全局變量 globaljava

  1. 全部末定義直接賦值的變量自動聲明爲擁有全局做用域,例如:
function checkscope() {
    var local = "local"; // 顯式聲明一個局部變量
    global = "global";   // 隱式聲明一個全局變量(很差的寫法)
}
console.log(global);     // "global"
console.log(local);      // error: local is not defined.

上面代碼中,變量 global 未用 var 關鍵字定義就直接賦值,因此隱式的建立了全局變量 global,但這種寫法容易形成誤解,應儘可能避免這種寫法。git

  1. 全部 window 對象的屬性擁有全局做用域

通常狀況下,window 對象的內置屬性都擁有全局做用域,例如 window.namewindow.locationwindow.top 等等。程序員

局部做用域(Local Scope)

和全局做用域相反,局部做用域通常只在固定的代碼片斷內可訪問到。最多見的是在函數體內定義的變量,只能在函數體內使用。例如:github

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() 內部則能夠訪問其餘兩個環境中的全部變量,由於那兩個環境是它的父執行環境。下圖形象地展現了前面這個例子的做用域鏈。

上圖中的矩形表示特定的執行環境。其中,內部環境能夠經過做用域鏈訪問全部的外部環境,但外部環境不能訪問內部環境中的任何變量和函數。這些環境之間的聯繫是線性、有次序的。每一個環境均可以向上搜索做用域鏈,以查詢變量和函數名;但任何環境都不能經過向下搜索做用域鏈而進入另外一個執行環境。對於這個例子中的 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()());    // ???

更多

關注微信公衆號「劼哥舍」回覆「答案」,獲取關卡詳解。
關注 https://github.com/stone0090/javascript-lessons,獲取最新動態。

相關文章
相關標籤/搜索