徹底搞懂JavaScript作用域

BY 張建成(prettyEcho@github)

除非另行注明,頁(yè)面上所有內(nèi)容采用知識(shí)共享-署名(CC BY 2.5 AU)協(xié)議共享

原文地址deep.js , 歡迎 評(píng)論star

我們常說(shuō),萬(wàn)物都有其存在的價(jià)值,這話的確不錯(cuò),但是深思一下,是不是需要有個(gè)前提,萬(wàn)物都在某些領(lǐng)域或多或少的存在某些價(jià)值。

舉個(gè)例子,汽車,絕對(duì)是個(gè)非常有價(jià)值的stuff,它給我們的日常出行,貨物運(yùn)輸?shù)葞?lái)了極大的便利;筷子,同樣也是個(gè)非常有價(jià)值的stuff,它給我們吃飯帶來(lái)了極大的方便。但是,汽車能幫我們把菜送到嘴里嗎?筷子能載著我們出行嗎?

那么,我上面所說(shuō)的某些領(lǐng)域,我們是不是可以稱其為作用域,我想是可以的。

說(shuō)到這,那么我就想問(wèn)了:在JS里,作用域是不是也是類似的概念呢?

首先,我可以肯定的說(shuō)這是一個(gè)在JavaScript中灰常灰常重要的概念,關(guān)系著JS里很多核心的機(jī)制,理解它,很多問(wèn)題都迎刃而解了。

那么,問(wèn)問(wèn)自己,在JS里,作用域是什么?

心里大概知道是什么,但是細(xì)細(xì)一想又好像說(shuō)不太清。

沒(méi)關(guān)系,下面我們就細(xì)細(xì)品味這個(gè)有意思的東東。

先throw概念吧:

<p style="text-decoration: underline; color: blue; font-weight: 500">作用域負(fù)責(zé)收集并維護(hù)由所有聲明的標(biāo)識(shí)符(變量)組成的一系列查詢,并實(shí)施一套非常嚴(yán)格的規(guī)則,確定當(dāng)前執(zhí)行的代碼對(duì)這些標(biāo)識(shí)符的訪問(wèn)權(quán)限。</p>

通俗來(lái)說(shuō),作用域相當(dāng)于一個(gè)管理員(有自己的一套規(guī)則),他負(fù)責(zé)管理所有聲明的標(biāo)識(shí)符的有序查詢。

我們來(lái)講個(gè)故事,說(shuō)說(shuō)作用域到底干了啥。

三兄弟齊上陣

long long ago,有3個(gè)關(guān)系很好的基友,老大叫引擎,老二叫編輯器,老三叫作用域。三兄弟眼看年歲已長(zhǎng),可手上還是沒(méi)有幾個(gè)銀子。個(gè)個(gè)都很著急,于是三兄弟謀劃一同做個(gè)事。

求職過(guò)程:此粗略去數(shù)萬(wàn)個(gè)字。。。

最終他們做的工作是:負(fù)責(zé)JS的編譯和運(yùn)行。

他們的工作內(nèi)容是這樣的:

老板甩給他們一項(xiàng)任務(wù)編譯并執(zhí)行下面代碼:

var a = 1;
console.log( a );

開始工作:

  • 編譯器:作用域,幫我看看你那有沒(méi)有儲(chǔ)存變量a。
  • 作用域:二哥,還沒(méi)有。
  • 編譯器:那好,幫我儲(chǔ)存一個(gè)。
  • 引擎: 老三,你那有沒(méi)有一個(gè)叫做a的變量。
  • 編譯器:大哥,還真有,剛二哥讓我存儲(chǔ)了一個(gè)。
  • 引擎: 真是太好了,幫我拿出來(lái),它的值是幾,我需要給它復(fù)制。
  • 編譯器:大哥,它的值是2。
  • 引擎: 謝謝你,三弟,這樣我就能打印它的值了。

上面講了一個(gè)不恰當(dāng)?shù)男」适?,但是三者之間的關(guān)系大概就是這樣。

詞法作用域 VS 動(dòng)態(tài)作用域

  • 詞法作用域

徹底搞懂JavaScript作用域里介紹過(guò),大部分標(biāo)準(zhǔn)語(yǔ)言編譯器的第一個(gè)工作階段叫作詞法化(也叫單詞化)?;貞浺幌拢~法化的過(guò)程會(huì)對(duì)源代碼中的字符進(jìn)行檢查,如果是有狀態(tài)的解析過(guò)程,還會(huì)賦予單詞語(yǔ)義。

在JS里,使用的作用域就是詞法作用域。

簡(jiǎn)單地說(shuō),詞法作用域就是定義在詞法階段的作用域。換句話說(shuō),詞法作用域是由你在寫代碼時(shí)將變量和塊作用域?qū)懺谀睦飦?lái)決定的,因此當(dāng)詞法分析器處理代碼時(shí)會(huì)保持作用域不變(大部分情況下是這樣的)。

  • 動(dòng)態(tài)作用域

在JS里,動(dòng)態(tài)作用域和this機(jī)制息息相關(guān)。它的作用域詩(shī)是在運(yùn)行的過(guò)程中確定的

var a = 1;

function foo() {
    var a = 2;
    console.log( this.a );
}

foo(); // 1

從上面的代碼,我們可以看出:foo中打印a的值不是由寫代碼的位置確定的,而是取決于foo執(zhí)行的位置。

  • 區(qū)別

    • 詞法作用域是在寫代碼或者說(shuō)定義時(shí)確定的,而動(dòng)態(tài)作用域是在運(yùn)行時(shí)確定的。(this 也是!)
    • 詞法作用域關(guān)注函數(shù)在何處聲明,而動(dòng)態(tài)作用域關(guān)注函數(shù)從何處調(diào)用。

函數(shù)作用域

JS里,生成作用域的方式:

  • 函數(shù)
  • with、eval (不建議使用,影響性能)

由此,我們知道JS里,絕大多數(shù)的作用域都是基于函數(shù)生成的。

每個(gè)函數(shù)都會(huì)為自身生成一個(gè)作用域氣泡。這個(gè)氣泡內(nèi)所有的標(biāo)識(shí)符都可以在這個(gè)氣泡中使用。

function bar() {
    var a = 1;

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

    foo();

    console.log(a);
}

bar();

上面代碼,bar氣泡有標(biāo)識(shí)符a、foo,因此在bar氣泡中可以訪問(wèn)到a、foo; foo氣泡有標(biāo)識(shí)符b,因此在bar氣泡中可以訪問(wèn)到b; 當(dāng)然還有一個(gè)全局氣泡,全局氣泡中有bar標(biāo)識(shí)符,因此在全局氣泡中可以訪問(wèn)到bar。

最小授權(quán)原則

最小授權(quán)原則是指在軟件設(shè)計(jì)中,應(yīng)該最小限度地暴露必要內(nèi)容,而將其他內(nèi)容都“隱藏”起來(lái),比如某個(gè)模塊或?qū)ο蟮?API 設(shè)計(jì)。

這個(gè)原則可以延伸到如何選擇作用域來(lái)包含變量和函數(shù)。如果所有變量和函數(shù)都在全局作 用域中,當(dāng)然可以在所有的內(nèi)部嵌套作用域中訪問(wèn)到它們。但這樣會(huì)破壞前面提到的最小 特權(quán)原則,因?yàn)榭赡軙?huì)暴漏過(guò)多的變量或函數(shù),而這些變量或函數(shù)本應(yīng)該是私有的,正確 的代碼應(yīng)該是可以阻止對(duì)這些變量或函數(shù)進(jìn)行訪問(wèn)的。

例如:

function doSomething(a) {
        
    b = a + doSomethingElse( a * 2 );
    console.log( b * 3 );

}

function doSomethingElse(a) { 
    return a - 1;
}

var b;
doSomething( 2 ); // 15

在這個(gè)代碼片段中,變量 b 和函數(shù) doSomethingElse(..) 應(yīng)該是 doSomething(..) 內(nèi)部具體 實(shí)現(xiàn)的“私有”內(nèi)容。給予外部作用域?qū)?b 和 doSomethingElse(..) 的“訪問(wèn)權(quán)限”不僅 沒(méi)有必要,而且可能是“危險(xiǎn)”的,因?yàn)樗鼈兛赡鼙挥幸饣驘o(wú)意地以非預(yù)期的方式使用, 從而導(dǎo)致超出了 doSomething(..) 的適用條件。更“合理”的設(shè)計(jì)會(huì)將這些私有的具體內(nèi)容隱藏在 doSomething(..) 內(nèi)部,

例如:

function doSomething(a) { 

    function doSomethingElse(a) {
        return a - 1; 
    }

    var b;
    
    b = a + doSomethingElse( a * 2 );

    console.log( b * 3 );
}
doSomething( 2 ); // 15

現(xiàn)在,b 和 doSomethingElse(..) 都無(wú)法從外部被訪問(wèn),而只能被 doSomething(..) 所控制。 功能性和最終效果都沒(méi)有受影響,但是設(shè)計(jì)上將具體內(nèi)容私有化了,設(shè)計(jì)良好的軟件都會(huì) 依此進(jìn)行實(shí)現(xiàn)。

規(guī)避沖突

當(dāng)我們的程序代碼逐漸多起來(lái),難免會(huì)出現(xiàn)變量沖突。那么如何規(guī)避沖突就顯得額外重要。

函數(shù)可以把標(biāo)識(shí)符嚴(yán)謹(jǐn)?shù)?隱藏"起來(lái),外部無(wú)法訪問(wèn)到,利用這個(gè)特性我們可以很好的規(guī)避沖突。

function foo() {
    var a = 1;
}

function bar() {
    var a = 2;
}

foo和bar中定義了相同的變量a,但是卻不會(huì)相互造成影響。因?yàn)楹瘮?shù)可以很好的把標(biāo)識(shí)符"隱藏"起來(lái)。

  • 全局命名空間

變量沖突的一個(gè)典型例子存在于全局作用域中。當(dāng)程序中加載了多個(gè)第三方庫(kù)時(shí),如果它 們沒(méi)有妥善地將內(nèi)部私有的函數(shù)或變量隱藏起來(lái),就會(huì)很容易引發(fā)沖突。
這些庫(kù)通常會(huì)在全局作用域中聲明一個(gè)名字足夠獨(dú)特的變量,通常是一個(gè)對(duì)象。這個(gè)對(duì)象 被用作庫(kù)的命名空間,所有需要暴露給外界的功能都會(huì)成為這個(gè)對(duì)象(命名空間)的屬 性,而不是將自己的標(biāo)識(shí)符暴漏在頂級(jí)的詞法作用域中。

例如:

var myLibrary = {
    name: 'echo',
    getName: function() {
        console.log( this.name );
    }
}

函數(shù)聲明 VS 函數(shù)表達(dá)式

函數(shù)聲明和函數(shù)表達(dá)式判別的依據(jù)是:函數(shù)的生命是否以function關(guān)鍵詞開始
以關(guān)鍵詞function 開始的聲明是函數(shù)聲明,其余的函數(shù)聲明全部是函數(shù)表達(dá)式。

//函數(shù)聲明
function foo() {

}

//函數(shù)表達(dá)式
var foo = function () {

};

(function() {

})();

具名函數(shù) VS 匿名函數(shù)

  • 具名函數(shù)
    擁有名字的函數(shù)

    function foo() {
    
    }
    
    var foo = function bar() {
    
    }
    
    setTimeout( function foo() {
    
    } )
    
    +function foo() {
    
    }();
    

需要注意:函數(shù)聲明一定要是具名函數(shù)

  • 匿名函數(shù)
    沒(méi)有名字的函數(shù)

    var foo = function () {
    
    }
    
    setTimeout( function foo() {
    
    } )
    
    -function foo() {
    
    }();
    

立即執(zhí)行函數(shù)(IIFE)

vara=2;

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

console.log( a ); // 2

該函數(shù)是以()開始,不是以關(guān)鍵詞function開始,因此IIFE是函數(shù)表達(dá)式

函數(shù)名對(duì) IIFE 當(dāng)然不是必須的,IIFE 最常見(jiàn)的用法是使用一個(gè)匿名函數(shù)表達(dá)式。雖然使 用具名函數(shù)的 IIFE 并不常見(jiàn),但它具有以下優(yōu)勢(shì):

  1. 匿名函數(shù)在棧追蹤中不會(huì)顯示出有意義的函數(shù)名,使得調(diào)試很困難。
  2. 如果沒(méi)有函數(shù)名,當(dāng)函數(shù)需要引用自身時(shí)只能使用已經(jīng)過(guò)期的arguments.callee引用, 比如在遞歸中。另一個(gè)函數(shù)需要引用自身的例子,是在事件觸發(fā)后事件監(jiān)聽器需要解綁 自身。
  3. 匿名函數(shù)省略了對(duì)于代碼可讀性/可理解性很重要的函數(shù)名。一個(gè)描述性的名稱可以讓 代碼不言自明。

因此具名函數(shù)的 IIFE 也是一個(gè)值得推廣的實(shí)踐。

  • 另一種表達(dá)形式
(function() {

}())

這也是IIFE的一種表達(dá)方式,功能上和上面那種方式是一致的。選擇哪種全憑個(gè)人愛(ài)好。

  • 參數(shù)傳遞

IIFE 也可以和其他形式的函數(shù)一樣實(shí)現(xiàn)參數(shù)的傳遞(多說(shuō)一句:參數(shù)傳遞是按值傳遞)。

(function foo(a) {
    console.log(a);
})(3);

這個(gè)模式的另外一個(gè)應(yīng)用場(chǎng)景是解決 undefined 標(biāo)識(shí)符的默認(rèn)值被錯(cuò)誤覆蓋導(dǎo)致的異常(雖 然不常見(jiàn))。將一個(gè)參數(shù)命名為 undefined,但是在對(duì)應(yīng)的位置不傳入任何值,這樣就可以 保證在代碼塊中 undefined 標(biāo)識(shí)符的值真的是 undefined:

undefined = true; // 給其他代碼挖了一個(gè)大坑!絕對(duì)不要這樣做! 
(function IIFE( undefined ) {
    var a;
    if (a === undefined) {
        console.log( "Undefined is safe here!" );
    }
})();
  • UMD (Universal Module Definition)

IIFE 還有一種變化的用途是倒置代碼的運(yùn)行順序,將需要運(yùn)行的函數(shù)放在第二位,在 IIFE 執(zhí)行之后當(dāng)作參數(shù)傳遞進(jìn)去。盡管這種模式略顯冗長(zhǎng),但有些人認(rèn)為它更易理解。

var a=2;

(function IIFE( def ) { 
    //參數(shù)的處理
    def( window );
})(function def( global ) {
    //邏輯運(yùn)算
    var a=3;
    console.log( a ); // 3 
    console.log( global.a ); // 2
});

塊作用域

盡管函數(shù)作用域是最常見(jiàn)的作用域單元,當(dāng)然也是現(xiàn)行大多數(shù) JavaScript 中最普遍的設(shè)計(jì) 方法,但其他類型的作用域單元也是存在的,并且通過(guò)使用其他類型的作用域單元甚至可 以實(shí)現(xiàn)維護(hù)起來(lái)更加優(yōu)秀、簡(jiǎn)潔的代碼。

  • try...catch
    非常少有人會(huì)注意到 JavaScript 的 ES3 規(guī)范中規(guī)定 try/catch 的 catch 分句會(huì)創(chuàng)建一個(gè)塊作用域, catch 的參數(shù)變量?jī)H在 catch 內(nèi)部有效。
try{
    throw undefined;
}catch(a){
    a = 2;
    console.log(a); // 2
}
console.log(a);  // ReferenceError
  • let

ES6的標(biāo)準(zhǔn)使我們能夠簡(jiǎn)單的創(chuàng)建塊作用域,其中一個(gè)變量定義方式是let關(guān)鍵詞定義。

let定義的變量具有以下的特點(diǎn):

  1. let隱形的創(chuàng)建塊作用域({...})
  2. let聲明的變量不能進(jìn)行變量提升,因此只能先定義,后使用
{
    let a = 1;
    console.log(a); // 1
}
console.log(a);  // ReferenceError

let一個(gè)典型的應(yīng)用就是在for循環(huán)里

我們看下面兩個(gè)例子:

// 每秒輸出一個(gè)5
for( var i = 0; i < 5 ; i++ ) {
    setTimeout(() => {
        console.log( i );
    }, i *1000)
}

// 依次輸出0,1,2,3,4,時(shí)間間隔位1秒
for( let i = 0; i < 5 ; i++ ) {
    setTimeout(() => {
        console.log( i );
    }, i *1000)
}

其原因就是let形成了5個(gè)塊作用域,使每次輸出的變量都從本次循環(huán)的塊作用域中獲取。

當(dāng)然我們還可以有其他方式做到第二種效果,我們將在 閉包,是真的美中說(shuō)道。

  • const

除了 let 以外,ES6 還引入了 const,同樣可以用來(lái)創(chuàng)建塊作用域變量,但其值是固定的 (常量)。之后任何試圖修改值的操作都會(huì)引起錯(cuò)誤。

var foo = true;
if (foo) { 
    var a=2;
    const b = 3; // 包含在 if 中的塊作用域常量

    a=3;//正常!
    b=4;//錯(cuò)誤! 
}

console.log( a ); // 3
console.log( b ); // ReferenceError!

作用域鏈

作用域鏈?zhǔn)怯僧?dāng)前作用域與上層一系列父級(jí)作用域組成,作用域的頭部永遠(yuǎn)是當(dāng)前作用域,尾部永遠(yuǎn)是全局作用域。作用域鏈保證了當(dāng)前上下文對(duì)其有權(quán)訪問(wèn)的變量的有序訪問(wèn)。

var a = 2;

function bar() {

    function foo() {
        console.log(a);
    } 

    foo();
}
bar(); // 2

上面代碼是由3層作用域氣泡組成,foo氣泡中試圖打印變量a,引擎在foo氣泡中未找到a變量,于是去其父作用域氣泡bar中尋找...以此類推直到找到全局作用域氣泡,發(fā)現(xiàn)有變量a,將其值打印出來(lái)。如若沒(méi)找到,報(bào)ReferenceError錯(cuò)誤。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容