細說JavaScript作用域和閉包

細說JavaScript作用域和閉包,還記得最開始接觸JavaScript時,隻是會用document.getElementById("")這種DOM操作來做點簡單特效,但僅僅如此已經讓我欣喜。後來的知道瞭原來可以利用ajax技術來實現無刷新獲取服務端數據,這讓我開始覺得前端還是有好多事可以做的,在之後因為老師的一個項目的關系接觸瞭AngularJS,便開始瞭AngularJS的學習。AngularJS讓我深刻地認識到在前後端分離的時候,前端原來可以做到這麼多東西,諸如模塊,路由,依賴註入,雙向綁定這樣的詞匯讓我感到陌生而不知所措。即便是我已經在那個項目中使用AngularJS中上面所說的那些特性,我對AngularJS還是充滿瞭困惑——我不清楚AngularJS是如何實現的。

審視自己對JavaScript淺薄認識,我意識到自己應該去先把js語言本身好好學習一下。確實,在較為仔細地學習瞭ECMAScript後,我發現,我開始理解模塊的實現,對如.apply(),.bind()等用法的瞭解讓我對如何寫出更好的js代碼有瞭更多的理解。當然,現在我也隻是入門水平,還需要不斷學習和提高。

這篇文章包含著我對javascript作用域和閉包的理解,寫的過程也是整理和歸納的過程,以後也會用這種方式來歸納總結自己所學所想,希望自己能不斷進步吧。


一JavaScript有哪些作用域類型 二作用域與標識符查詢 執行環境與作用域鏈 標識符查詢 三可以形成獨立作用域的結構 函數作用域 利用函數內部對外部的隱藏 匿名函數 塊作用域 No Block-Level Scopes with trycatch letconst 四提升 提升的表現 提升的優先級 提升的深層原因 五跨越詞法作用域的兩種機制 eval with 六閉包與模塊機制 閉包 什麼是閉包 閉包相關的問題 問題 解決辦法 模塊機制 簡單的模塊 單例模式 現代模塊


一、JavaScript有哪些作用域類型

對於一門編程語言來說,作用域主要有兩種模型,即詞法作用域(Lexical Scope)和動態作用域(Dynamic Scope)。而詞法作用域,即靜態作用域(Static Scope),被目前JavaScript在內的大部分編程語言所采用,而動態作用域隻有Bash腳本等少數編程語言在使用。

詞法作用域就是定義在詞法階段的作用域,當詞法分析器處理代碼的時候會保持作用域不變。在表現上,詞法作用域的函數中遇到既不是形式參數也不是函數內部定義的局部變量的變量時,會自動去函數定義時的環境中查詢(即沿作用域鏈)。

還是舉兩個例子吧:

//這是詞法作用域
function foo() {
    console.log(a);    //2
}
function bar() {
    var a = 3;
    foo();
}
var a = 2;
bar();
//這是動態作用域
function foo() {
    console.log(a);     //3
}
function bar() {
    var a = 3;
    foo();
}
var a = 2;
bar();

二、作用域與標識符查詢

作用域和表示符查詢緊密相關,表示符正是依靠這樣一套嚴密的作用域規則而得以有條理地查詢。而對於作用域,需要理解幾個詞:執行環境,變量對象,活動對象,作用域鏈。

執行環境與作用域鏈

首先解釋執行環境吧,執行環境定義瞭在這個執行環境中變量或者函數有權訪問的數據,決定瞭它們各自的行為。在js中執行環境的結構由全局執行環境和一級級內部嵌套的子執行環境組成。其中全局執行環境由ECMAScript實現所在的宿主環境決定,在瀏覽器中為window對象,在Node中是global對象。每個函數都包含著自己的執行環境,一個個執行環境組成瞭一個執行環境棧,當執行流進入一個函數時,該函數的執行環境會被推入環境棧中,函數執行之後該環境又會被從環境棧中彈出,這便是ECMAScript執行流的機制。

每個執行環境都包含一個變量對象,它保存著環境中定義的所有變量和函數。當一個執行環境的代碼執行的時候會為它的變量對象創建一個由當前執行環境沿著環境棧到全局執行環境的作用域鏈,作用域鏈保證瞭對當前執行環境有權訪問的所有變量和函數的 有序 訪問。如果當前執行環境是一個函數,那麼該函數的活動對象就會成為這個執行環境的變量對象。

而對除瞭全局執行環境外的執行對象,活動對象最前端是arguments對象,而作用域鏈沿著一層層的被包含環境的變量對象沿伸到全局執行環境的變量對象。

標識符查詢

瞭解瞭執行環境和作用域鏈,標識符的查詢就很好理解瞭,js引擎在遇到一個標識符的時候根據作用域鏈沿著變量對象從前端到後端進行查詢(其實就是從子作用域逐層向父作用域查詢),一旦遇到匹配的標識符便會立即停止。如:

function foo(){
    var a = 1;
    function bar(){
        var a = 2;
        console.log(a);
    }
    a = 3;
    bar();
}
foo();  //2

該例中在console.log對a進行RHS查詢時,在bar函數的作用域內便查詢到瞭標識符a,因此便立即停止標識符查詢,所以訪問不到foo函數的標識符a。這種現象是標識符的遮蔽效應,在多層的嵌套作用域內可以定義同名的標識符,遮蔽效應使得內部的標識符可以遮蔽外層表示符。

三、可以形成獨立作用域的結構

在JavaScript中,可以形成獨立作用域的結構有兩類,函數作用域和塊作用域。先說說函數作用域吧。

函數作用域

函數作用域是js中最常見的作用域,每一個函數都擁有一個作用域,而屬於這個函數的變量都能在整個函數的范圍內訪問(當然也能訪問),但是在函數外則無法訪問函數內的任何變量——除非你在函數執行時把一個閉包返回到函數體外。這種函數內部變量對外的隱藏作用使得同級作用域同名標識符之間的沖突得到避免,這樣,也促成瞭模塊機制的良好運行。

利用函數內部對外部的隱藏

如果想創建一個封閉的作用域,讓這個作用域內的變量不被外部訪問,利用立即執行函數表達式(IIFE)便可實現。如:

var a = 1;
(function IIFE(){
    var a = 2;
    console.log(a); //2
})();   //立即執行
console.log(a);     //1

這個函數表達式在聲明後立即執行,這樣函數體內的語句都得到瞭執行且對外部的變量沒有影響。其實,JS中的模塊也利用瞭這點。JS模塊還是放到最後說吧。

匿名函數

JavaScript中的函數表達式存在具名函數表達式和匿名函數表達式兩種,而函數聲明則必須具名。匿名函數有什麼用呢?讓我們不用去冥思苦想標識符怎麼取。如上面的例子中的IIFE函數,該函數即使去掉函數名,程序也可以正常運行,因為它的函數名在這種情況起到作用不大。

雖然匿名函數寫起來十分便捷,但是基於以下原因,始終給每個函數命名是值得推薦的。
1. 在沒有函數名的情況下,函數的遞歸調用等需要引用自身的時候,將會不得不用arguments.callee進行引用,而這在ES5之後便不被推薦使用瞭——甚至在嚴格模式下會拋出TypeError錯誤。
2. 函數名的省略使得代碼可讀性下降

說到瞭匿名函數,不得不提閉包,由於閉包內容較多,將在後面專門說明。

塊作用域

除瞭最常見的函數作用域,JavaScript中的塊作用域也可以創建出一個獨立的作用域。可是,在 Nicholas C.Zakas 著的《Professional JavaScript for Web Developers(3rd Edition)》中說道:

No Block-Level Scopes

JavaScript’s lack of block-level scopes is a common source of confusion. In other C-like languages, code blocks enclosed by brackets have their own scope (more accurately described as their own execution context in ECMAScript), allowing conditional definition of variables.

Nicholas 之所以說JavaScript沒有塊級作用域是因為他沒把with和try/catch作為塊級作用域看待,他在書中把這兩個情況作為延長作用域鏈的手段。實際上,通過with和try/catch創建的獨立作用域也算是塊級作用域的形式,除瞭這兩種外還可以利用ES6中的let和const也可以形成塊級作用域。

with

通過with可以創建出的作用域僅在with聲明中使用。如:

var location = {
    say: 'hello world'
};
with (location){
    console.log(say);//hello world
}
console.log(say);//拋出ReferenceError錯誤

try/catch

try/catch的catch分句會創建一個塊級作用域,其中聲明的變量隻能在內部使用。如:

try{
    throw {
        say: 'hello world'
    }
}
catch(error){
    console.log(error.say);//hello world
}
console.log(error.say);////拋出ReferenceError錯誤

let/const

ES6中引入的let和const關鍵字可以將變量綁定到任意由{}包含的代碼塊中。
以下例子可以看出用let聲明變量和用var聲明變量的區別:

//變量用var聲明
for (var i = 1; i < 5; i++){
    console.log(i);
}
console.log(i);//5

//變量用let聲明
for (let j = 1; j < 5; j++){
    console.log(j);
}
console.log(j);//拋出ReferenceError錯誤

當然,也可以直接綁定在塊中,如:

{
    let a = 10;
    console.log(a);//10
}
console.log(a);拋出ReferenceError錯誤

const關鍵字定義的常量和let一樣,能將其定義的常量綁定到{}包含的塊級作用域中,就再舉例子瞭。

四、提升

提升的概念比較簡單,但是如果對js語言隻是淺嘗輒止的話,可能會理解不清。這裡通過提升的表現,優先級和原因來簡單說明js中的提升。

提升的表現

提升是變量或函數在同個作用域內表現出的可以先使用後定義的現象。先來看看變量提升:

"use strict";//開啟ES5的嚴格模式
a = 2;
var a;
console.log(a);//2

再看看函數提升:

"use strict";
foo(1);//2
function foo(n){
    console.log(n+1);
}

提升的優先級

提升具有優先級,當一個作用域裡對同個標識符既使用瞭變量聲明,也使用瞭函數聲明,那麼函數聲明會優先被提升。如下面的例子。

foo();//1
var foo;
function foo(){
    console.log(1);
}
foo = function(){
    console.log(2);
}

提升的深層原因

提升之所以存在,是因為JavaScript引擎在對代碼解釋前會進行預編譯。在編譯階段,有一部分的工作就是找到所有的函數聲明,並用合適的作用域把它們關聯起來,這也是詞法作用域的核心部分。有瞭預編譯,在解釋執行時,不再檢測變量的聲明,引擎在對變量進行LHS或RHS查詢時會直接向編譯階段生成的作用域取得數據。

也就是說對於var a = 1’;這個語句來說,js引擎會識別為兩個聲明,即var a 和a = 1,他們分別在編譯階段和執行階段處理。

五、跨越詞法作用域的兩種機制

雖然JavaScript采用的是詞法作用域,但是如果真的想要在代碼執行的時候修改作用域的話也是有辦法的,因為js中存在兩個”bug”來做到這點。這兩個”bug”是傳說中的eval還有之前提到過的with。由於這兩個”bug”,js的作用域應該算是不完全的詞法作用域。

eval

eval可能是js中最強大的函數瞭,它接受一個參數即一個字符串,在執行時會把這個字符串作為實際的ECMA語句插入到原位置進行解析執行,正如下面例子所示。

function foo(str){
    eval(str);
    console.log(a);
}
a = 1;
//修改瞭foo函數體內的詞法作用域
foo('var a = 2;');//2

因為在js編譯器預編譯的時候,eval()中的語句並不會被執行,所以,eval()中的變量或者函數不會被提升。

foo();//拋出ReferenceError錯誤
eval("function foo(){console.log('1');}");

當然,如果變量/函數的定義和使用都在eval中,那麼裡面的變量對於裡面的調用來說是有提升的,比如:

eval("foo();function foo(){console.log('1');}");//1

JavaScript中還有一些類似eval()處理方式的函數,比如new Function(..),setInterval(..)和setTimeout(..)等等。

with

with語句同樣可以在執行階段修改作用域。

var obj = {
    a: 'a',
    b: 'b',
    c: 'c'
};
with (obj){
    console.log(a+b+c);//abc
}

在with語句的代碼塊裡面,a,b,c來自obj的三個屬性,這個在js預編譯的時候也是不能判斷的,因此with語句中的變量也不能在詞法階段確定。

六、閉包與模塊機制

閉包

說起閉包,在我剛接觸JavaScript的時候聽到這個詞的時候感覺它特別神秘——從這個奇怪的名字就感覺到瞭神秘感。直到深入瞭解後才發現,閉包,原來是這樣。

什麼是閉包

當一個函數能夠保存自己所在的詞法作用域的時,便產生瞭閉包——無論這個是在當前詞法作用域中還是當前詞法作用域外。

function foo(){
    var a = 2;
    function bar(){
        console.log(a);
    }
    return bar;
}
var baz = foo();
baz();//2

在上面的例子中,在調用foo函數時bar函數保存著foo函數的局部變量a,在bar函數被返回到foo函數外面的時候,便產生瞭閉包,閉包裡保存著bar所在的詞法作用域(包含bar函數和foo函數的所有變量以及全局對象的所有屬性),故調用baz函數的時候能夠正常返回a中的值2。

閉包相關的問題

問題

首先看下面的例子:

for(var i = 1; i <= 5; i++){
    setTimeout(function timer(){
        console.log(i);
    },1000);
}

這個例子中可以看出這段代碼預期是按順序分別輸出1~5的數字。而實際上,每次都輸出6。

現仔細造成這個”出乎意料“的現象的原因:setTimeout函數中傳進來的timer函數由於作用域閉包的原因,保存著對同一個變量 i 的引用,而在循環結束後i的值為6,又由於js的異步性,在過1000ms之後,循環已經處理結束,因此,結果會輸出5個6。

解決辦法

隻需對上面代碼進行一些改進即可解決,代碼如下:

for (var i = 1; i <= 5; i++){
    (function(i){
        setTimeout(function timer(){
            console.log(i);
        },1000);
    })(i)
}

上面代碼通過創建一個自執行的函數表達式來得到一個獨立的作用域,再把外部的i作為參數傳進函數體,因為函數的參數傳遞會創建一個副本,所以每個timer中保存不同的i的副本,問題就得到解決瞭。

模塊機制

JavaScript中的模塊模式正是充分利用瞭作用域閉包的能力而實現的。下面由簡入深地描述js中的模塊機制。

簡單的模塊

有瞭閉包的知識的話,下面的模塊代碼相信能很快看懂。

function myModule(){
    var message = "hello,Ontides";
    var resp = "Hi,";
    function sayHello(){
        console.log(message);
    }
    function getResponse(name){
        console.log(resp+name);
    }
    return {
        sayHello: sayHello,
        getResponse: getResponse
    };
}
var foo = myModule();

foo.sayHello(); //Hello,Ontides
foo.getResponse("Scott");   //Hi,Scott

單例模式

將上面代碼改變一下,可以實現單例模式:

var foo = (function myModule(){
    var message = "hello,Ontides";
    var resp = "Hi,";
    function sayHello(){
        console.log(message);
    }
    function getResponse(name){
        console.log(resp+name);
    }
    return {
        sayHello: sayHello,
        getResponse: getResponse
    };
})();
foo.sayHello(); //Hello,Ontides
foo.getResponse("Scott");   //Hi,Scott

現代模塊

現在實現的模塊通常需要一個模塊管理器,其一般實現如下:

var myModules = (function Manager(){
    var modules = {};
    function define(name, deps, impl){
        for (var i = 0; i < deps.length; i++){
            deps[i] = modules[deps[i]]; //獲取模塊依賴
        }
        modules[name] = impl.apply(impl, deps);//將依賴註入到定義的模塊中
    }
    function get(name){
        return modules[name];
    }
    return {
        define: define,
        get: get
    }
})();

這個模塊管理器的實現中,deps[i] = modules[deps[i]];語句根據目標定義模塊所需要的依賴從modules中查詢,而modules[name] = impl.apply(impl, deps);語句則將目標依賴註入到定義模塊中。

通過上面的模塊管理器,可以輕松創建模塊,管理模塊之間的依賴。

myModules.define("bar",[],function(){
    function sayHello(name){
        return "Hello,"+name;
    }
    return{
        sayHello: sayHello
    }
});

myModules.define("foo",["bar"],function(bar){
    var person = "Ontides";
    function awsome(){
        console.log(bar.sayHello(person).toUpperCase());
    }
    return{
        awsome: awsome
    }
});

var bar = myModules.get("bar");
var foo = myModules.get("foo");

console.log(bar.sayHello("Ontides"));//Hello,Ontides
foo.awsome();//HELLO,ONTIDES

發佈留言