1. 程式人生 > >JavaScript的作用域詳解

JavaScript的作用域詳解

作用域

作用域(scope),程式設計概念,通常來說,一段程式程式碼中所用到的變數並不總是有效/可用的,而限定這個變數的可用性的程式碼範圍就是這個變數的作用域。通俗一點就是我要把我的變數分成一坨一坨保管起來,有些地方只能用這幾個變數,有些地方只能用另外幾個變數,而這個分開的一坨一坨的區域就是作用域~

那這個作用域什麼時候用到的呢?

沒錯就是編譯的時候~讓我們來看看編譯的大概流程

  • 詞法分析(這個過程會將由字元組成的字串分解成(對程式語言來說)有意義的程式碼塊)
  • 語法分析(這個過程是將詞法單元流(陣列)轉換成一個由元素逐級巢狀所組成的代表了程式語法結構的樹。這個樹被稱為“抽象語法樹”)
  • 程式碼生成(將這棵“樹” 轉換為可執行程式碼,將我們寫的程式碼變成機器指令並執行)

比起上面這些編譯過程只有三個步驟的語言的編譯器,JavaScript 引擎要複雜得多。例如,在語法分析和程式碼生成階段有特定的步驟來對執行效能進行優化,包括對冗餘元素進行優化等,但是大體上也是差不多的流程~

那我們要編譯var a = 2的話,是‘誰’來執行編譯的過程呢?

噹噹噹當~

  • 引擎:負責整個編譯執行的全部過程。
  • 編譯器:負責詞法分析以及程式碼生成。
  • 作用域:負責收集維護所有宣告的識別符號,確定當前執行程式碼對識別符號的訪問許可權。

當我們看到var a = 2的時候,我們認為是一條宣告,但是對於引擎來說,這是兩個完全不一樣的宣告,分為下面兩部分

  • 1.遇到 var a,編譯器會詢問作用域是否已經有一個該名稱的變數存在於同一個作用域的集合中。如果是,編譯器會忽略該宣告,繼續進行編譯;否則它會要求作用域在當前作用域的集合中宣告一個新的變數,並命名為a(嚴格模式下報錯)。
  • 2.接下來編譯器會為引擎生成執行時所需的程式碼,這些程式碼被用來處理 a = 2這個賦值操作。引擎執行時會首先詢問作用域,在當前的作用域集合中是否存在一個叫作 a的變數。如果是,引擎就會使用這個變數;如果否,引擎會繼續查詢該變數。

可以看到,編譯的時候,編譯器和引擎需要詢問作用域,所求變數是否存在,然後根據查詢結果來進行不同的操作

作用域巢狀

上面我們展示了只有一個作用域,變數的宣告和賦值過程。實際情況中,我們通常需要同時顧及幾個作用域。當一個塊或函式巢狀在另一個塊或函式中時,就發生了作用域的巢狀。因此,在當前作用域中無法找到某個變數時,引擎就會在外層巢狀的作用域中繼續查詢,直到找到該變數,或抵達最外層的作用域(也就是全域性作用域)為止;但是反過來,外層的作用域無法訪問內層作用域的變數,如果可以的話那不就全都是全域性變量了嗎嘿嘿嘿

function foo(a) {
console.log( a + b );
}
var b = 2;
foo( 2 ); // 4

當引擎需要變數b的時候,首先在foo的作用域中查詢,發現沒有b的蹤影,於是就跑出來,往上面一層作用域走一走,發現了這個b原來在全域性作用域裡待著,那可不得一頓引用!如果全域性作用域也沒有b的話,那就得報錯了,告訴寫程式碼的傻子“你豬呢?一天到晚淨會寫bug!”。

clipboard.png

第一層樓代表當前的執行作用域,也就是你所處的位置。建築的頂層代表全域性作用域。變數引用都會在當前樓層進行查詢,如果沒有找到,就會坐電梯前往上一層樓,如果還是沒有找到就繼續向上,以此類推。一旦抵達頂層(全域性作用域),可能找到了你所需的變數,也可能沒找到,但無論如何查詢過程都將停止

函式作用域

可以看到我們在上面生成兩層作用域(一層foo一層全域性)的時候用了函式。因為JavaScript的函式可以產生一層函式作用域。上程式碼!

function foo(a) {
    var b = a * 2;
    function bar(c) {
        console.log( a, b, c );
    }
    bar( b * 3 );
}
foo( 2 ); // 2, 4, 12

我們來分析一下上面幾行程式碼。這個例子裡面包含了三層逐級巢狀的作用域,其中兩個函式生成了兩層巢狀作用域。

1.包含著整個全域性作用域,其中只有一個識別符號: foo 。
2.包含著 foo 所建立的作用域,其中有三個識別符號: a 、 bar 和 b 。
3.包含著 bar 所建立的作用域,其中只有一個識別符號: c 。

由於bar是最內層的作用域,如果在它作用域內的查詢不到它需要的值,它會逐級往外查詢外層作用域的同名變數。如果查詢到了則取用~

塊級作用域

儘管函式作用域是最常見的作用域單元,當然也是現行大多數 JavaScript 中最普遍的設計方法,但其他型別的作用域單元也是存在的,並且通過使用其他型別的作用域單元甚至可以實現維護起來更加優秀、簡潔的程式碼。(如果你會其他一些語言你就會發現一個花括號不就一個塊級作用域了嗎)我們來看看JavaScript中的花括號~

for(var i=0;i<5;i++){console.log(window.i)} //0 1 2 3 4

你驚奇的發現,媽耶,我這個var不等於白var嘛,反正都是全域性變數(如果你沒在函式內使用的話)。是的JavaScript就是這麼的高階兼靈性~(滑稽)if的花括號和for是一樣的,不做贅述。

那我們怎樣整一個獨立作用域?然後我又不想一直宣告函式JavaScript有四種方式可以產生塊級作用域。

  • with
  • try/catch
  • let
  • const

讓我們來介紹一下這四種東西吧~

1.首先是with,算了,垃圾,不講。好處不多,壞處倒是挺多,有興趣百度用法~不建議使用
2.然後是try/catch, ES3 規範中規定 try / catch 的 catch 分句會建立一個塊作用域,其中宣告的變數僅在 catch 內部有效
try{throw 2}catch(a){console.log(a)};
console.log(a);//Uncaught ReferenceError
3.let,這個是es6引入的新關鍵字,非常香~看下面可以和上面的var i的迴圈做對比
for(let j=0;j<5;j++)(console.log(window.j));//undefined *5
4.這個跟let差不多,但是是用來定義常量的。var a = 5;a = 6;//報錯

ok~這個很敢單~讓我們來學習下一部分

提升

在最開始之前,我們先來學習一下兩種報錯。

  • ReferenceError 異常
  • TypeError

第一種的出現是因為遍歷了所有的作用域都查詢不到變數,第二種是找到了這個變數,但是對這個變數的值進行了錯誤的操作,比如試圖對一個非函式型別的值進行函式呼叫

我們先來看看下面的程式碼會輸出什麼

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

你可能會以為,我先給a賦值了2,然後var a又給a賦值了undefined,所以會輸出undefined。但是這個輸出了2。我們再來看一題

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

這個時候你可能認為會報ReferenceError異常,因為使用在前,使用的時候a還沒有定義,作用域肯定也找不到a,但是這個卻輸出了undefined。

Why?

為了搞明白這個問題,我們需要回顧一下前面關於編譯器的內容。回憶一下,引擎會在解釋 JavaScript 程式碼之前首先對其進行編譯。編譯階段中的一部分工作就是找到所有的宣告,並用合適的作用域將它們關聯起來。因此,正確的思考思路是,包括變數和函式在內的所有宣告都會在任何程式碼被執行前首先被處理。當你看到 var a = 2; 時,可能會認為這是一個宣告。但 JavaScript 實際上會將其看成兩個宣告: var a; 和 a = 2; 。第一個定義宣告是在編譯階段進行的。第二個賦值宣告會被留在原地等待執行階段。上面的第一段程式碼就可以看做

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

第二段程式碼則可以看成

var a;
console.log(a);//此時a還沒賦值,所以是undefined
a = 2;

打個比方,這個過程就好像變數從它們在程式碼中出現的位置被“移動”到了最上面(變數所在作用域)。這個過程就叫作提升。

我們從上面可以看到變數宣告的提升,那麼對於函式宣告呢?當然是no趴笨啦~

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

但是,需要注意的是,函式宣告會被提升,但是函式表示式卻不會。

foo(); // 不是 ReferenceError, 而是 TypeError!
var foo = function bar() {
// ...
};

這個就相當於

var foo;
foo(); // 此時foo肯定是undefined啦,undefined()? 對undefined值進行函式呼叫顯然是錯誤操作!TypeError!
foo = function bar() {
// ...
};

既然函式宣告和變數宣告都會被提升,那它們兩個哪個提升到更前面呢?

是函式!!函式作為JavaScript的一名大將,確實是有一些牌面。

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

我們可以將上面看成

function foo() {
    console.log( 1 );
}
var foo;//重複宣告,可以去掉
foo(); // 1
foo = function() {
    console.log( 2 );
};

注意:後面的宣告會覆蓋前面的宣告。

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

相當於


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

閉包

我們剛剛講那麼多,相信大家都已經知道並且深信,作用域只能一層一層往外查詢,不能往裡走,那我如果要找一個函式裡的變數值呢?那可咋整啊?很簡單,我們不能往裡走,但是我們可以再給這個函式裡面整一層作用域,這樣函式裡面的子作用域不就可以訪問它的變量了嗎?perfect~


function foo() {
    var a = 2;
    function bar() {
        console.log( a ); // 2
    }
    return bar;
}
var baz = foo();執行了foo()就返回了一個bar;現在相當於baz=bar;
baz();//2

這裡我們需要獲取a的值,我們就在裡面寫一個函式bar,顯然這個bar是有權利訪問a的,那我們返回這個有權利訪問a的函式不就頂呱呱了嗎?

在 foo() 執行後,通常會期待 foo() 的整個內部作用域都被銷燬,因為我們知道引擎有垃圾回收器用來釋放不再使用的記憶體空間。由於看上去 foo() 的內容不會再被使用,所以很自然地會考慮對其進行回收。而閉包的“神奇”之處正是可以阻止這件事情的發生。事實上內部作用域依然存在,因此沒有被回收(頻繁使用閉包可能導致記憶體洩漏)。誰在使用這個內部作用域?原來是 bar() 本身在使用。拜 bar() 所宣告的位置所賜,它擁有涵蓋 foo() 內部作用域的閉包,使得該作用域能夠一直存活,以供 bar() 在之後任何時間進行引用。

來點練習題

第一題
var tt = 'aa'; 
function test(){ 
    alert(tt); 
    var tt = 'dd'; 
    alert(tt); 
} 
test();
第二題
var a = 100;
function test(){
    console.log(a);
    a = 10;
    console.log(a);
}
test();
console.log(a);
第三題
var a=10; 
function aaa(){ 
    alert(a);
};            
function bbb(){
    var a=20;
    aaa();
}
bbb();

答案:

  1. undefined dd
  2. 100 10 10
  3. 10

參考文獻

《你不知道的JavaScript》

最後

有什麼錯誤或者建議可以在評論區告訴我~謝謝