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》

最後

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章