我所認識的 JavaScript 作用域鏈和原型鏈

請叫我王磊同學發表於2017-06-28

  畢業也整整一年了,看著很多學弟都畢業了,忽然心中頗有感慨,時間一去不復還呀。記得從去年這個時候接觸到JavaScript,從一開始就很喜歡這門語言,當時迷迷糊糊看完了《JavaScript高階程式設計》這本書,似懂非懂。這幾天又再次回顧了這本書,之前很多不理解的內容似乎開始有些豁然開朗了。為了防止之後自己又開始模糊,所以自己來總結一下JavaScript中關於 作用域鏈和原型鏈的知識,並將二者相比較看待進一步加深理解。以下內容都純屬於自己的理解,有不對的地方歡迎指正。

作用域鏈

作用域

  首先我們需要了解的是作用域做什麼的?當JavaScript引擎在某一作用域中遇見變數函式的時候,需要能夠明確變數和函式所對應的值是什麼,所以就需要作用域來對變數和函式進行查詢,並且還需要確定當前程式碼是否對該變數具有訪問許可權。也就是說作用域主要有以下的任務:

  • 收集並維護所有宣告的識別符號(變數和函式)
  • 依照特定的規則對識別符號進行查詢
  • 確定當前的程式碼對識別符號的訪問許可權

  舉一個例子:

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

foo( 2 );複製程式碼

  對於上述程式碼,JavaScript引擎需要對作用域發出以下的命令

  • 查詢識別符號foo,得到變數後執行該變數
  • 查詢識別符號a,得到變數後對其賦值為2
  • 查詢識別符號console,得到變數後準備執行屬性log
  • 查詢識別符號a,得到變數後,作為引數傳入console.log執行

  我們省略了函式console.log內部的執行過程,我們可以看到對JavaScript引擎來說,作用域最重要的功能就是查詢識別符號。從上面的例子來看,引擎對變數的使用其實不是都一樣的。比如第一步引擎得到識別符號foo的目的是執行它(或者說是為了拿到識別符號裡儲存的值)。
但第二步中引擎查詢識別符號a的目的是為了對其賦值(也就是改變儲存的值)。所以查詢也分為兩種:LHSRHS

  我在之前的一篇文章中從LHS與RHS角度淺談Js變數宣告與賦值曾經介紹過LHSRHS,這兩個看起來很高大上的名詞其實非常簡單。LHS指的是Left-hand Side,而RHS指的是Right-hand Side。分別對應於兩種不同目的的詞法查詢。LHS所查詢的目的是為了賦值(類似於該變數會位於賦值符號=的左邊),例如第二步查詢變數a的過程。而RHS所查詢的目的是為了引用(類似於變數會位於賦值符號=的右邊),例如第一步查詢變數foo的過程。   

作用域鏈

  我們知道程式碼不僅僅可以訪問當前的作用域的變數,對於巢狀的父級作用域中的變數也可以訪問。我們先只在ES5中表述,我們知道JavaScript在ES5中是沒有塊級作用域的,只有函式可以建立作用域。舉個例子:   

function Outer(){
    var outer = 'outer';
    Inner();
    function Inner(){
        var inner = 'inner';
        console.log(outer,inner) // outer inner
    }
}複製程式碼

  當引擎執行到函式Inner內部的時候,不僅可以訪問當前作用域而且可以訪問到Outer的作用域,從而可以訪問到識別符號outer。因此我們發現當多個作用域相互巢狀的時候,就形成了作用域鏈。詞法作用域在查詢識別符號的時候,優先在本作用域中查詢。如果在本作用域沒有找到識別符號,會繼續向上一級查詢,當抵達最外層的全域性作用域仍然沒有找到,則會停止對識別符號的搜尋。如果沒有查詢到識別符號,會根據不同的查詢方式作出不同的反應。如果是RHS,則會丟擲Uncaught ReferenceError的錯誤,如果是LHS,則會在查詢最外層的作用域宣告該變數,這就解釋了為什麼對未宣告的變數賦值後該變數會成為全域性變數。所以上面的程式碼執行

console.log(outer,inner)

的時候,引擎會首先要求Inner函式的詞法作用域查詢(RHS)識別符號outer,被告知該詞法作用域不存在該識別符號,然後引擎會要求巢狀的上一級Outer詞法作用域查詢(RHS)識別符號outer,Outer詞法作用域的查詢成功並將結果返回給引擎。

換個角度理解作用域鏈

  上面我們理解作用域鏈都是從作用域鏈查詢變數的角度去考慮的,其實這已經足夠了,大部分作用域鏈的場景都是查詢識別符號。但是我們可以換一個角度去理解作用域鏈。其實JavaScript的每個函式都有對應的執行環境(execution context)。當執行流進入進入一個函式時,該函式的執行環境就會被推入環境棧,當函式執行結束之後,該函式的執行環境就會被彈出環境棧,執行環境被變更為之前的執行環境。而每建立一個執行環境時,會同時生成一個變數物件(variable object)(函式生成的是活動變數(activation object)),用來儲存當前執行環境中定義的變數和函式,當執行環境結束時,當前的變數(活動)物件就會被銷燬(全域性的變數物件是一直存在的,不會被銷燬)。雖然我們無法訪問到變數(活動)物件,但詞法作用域查詢識別符號會使用它。
  當對於函式的執行環境生成的活動物件,初始化就會存在兩個變數:thisarguments,因此我們在函式中就直接可以使用這兩個變數。對於作用域鏈儲存都是變數(活動)物件,而當前執行環境的變數物件就儲存在作用域鏈的最前端,優先被查詢。從這個角度看,識別符號解析是沿著作用域鏈一級一級地在變數(活動)物件中搜尋識別符號的過程。搜尋過程始終從作用域鏈的前端開始,然後逐級地向後回溯,直至找到識別符號為止。   

閉包

  這年頭出去面試JavaScript的崗位,各個都要問你閉包的問題,開始的時候覺得閉包的概念蠻高階的,後來覺得這個也沒啥東西可講的。老早的之前就寫過一篇關於閉包的文章淺談JavaScript閉包,講到現在我覺得把閉包放到作用域鏈一起將會更好。還是繼續講個例子:

function fn(){
    var a = 'JavaScript';
    function func(){
        console.log(a);
    }
    return func;
}

var func = fn();
func(); //JavaScript複製程式碼

  首先明確一下什麼是閉包?我認為閉包最好的概念解釋就是:

函式在定義的詞法作用域以外的地方被呼叫,閉包使得函式可以繼續訪問定義時的詞法作用域。

  func函式執行的位置和定義的位置是不相同的,func是在函式fn中定義的,但執行卻是在全域性環境中,雖然是在全域性函式中執行的,但函式仍然可以訪問當定義時的詞法作用域。如下圖所示:

  我們之前說過,當函式執行結束後其活動變數就會被銷燬,但是在上面的例子中卻不是這個樣子。但函式fn執行結束之後,fn物件的活動變數並沒有被銷燬,這是因為fn返回的函式func的作用域鏈還保持著fn的活動變數,因此JavaScript的垃圾回收機制不會回收fn活動變數。雖然返回的函式func是在全域性環境下執行的,但是其作用域鏈的儲存的活動(變數)物件的順序分別是:func的活動變數、fn的活動變數、全域性變數物件。因此在func函式執行時,會順著作用域鏈查詢識別符號,也就能訪問到fn所定義的詞法作用域(即fn函式的活動變數)也就不足為奇了。這樣看起來是不是覺得閉包也是非常的簡單。   

原型鏈

原型

  說完了作用域鏈,我們來講講原型鏈。首先也是要明確什麼是原型?所有的函式都有一個特殊的屬性: prototype(原型)prototype屬性是一個指標,指向的是一個物件(原型物件),原型物件中的方法和屬性都可以被函式的例項所共享。所謂的函式例項是指以函式作為建構函式建立的物件,這些物件例項都可以共享建構函式的原型的方法。舉個例子:   

var Person = function(name){
    this.name = name;
}
Person.prototype.sayName = function(){
    console.log('name: ', this.name)
};

var person = new Person('JavaScript');
person.sayName(); //JavaScript複製程式碼

  在上面的例子中,物件person是建構函式Person建立的例項。所謂的建構函式也只不過是普通的函式通過操作符new來呼叫。在使用new操作符呼叫函式時主要執行以下幾個步驟:

  • 建立新的物件,並將函式的this指向新建立的物件
  • 執行函式
  • 返回新建立的物件

  通過建構函式返回的物件,其中含有一個內部指標[[Prototype]]指向建構函式的原型物件,當然我們是無法訪問到這個標準的內部指標[[Prototype]],但是在Firefox、Safari和Chrome在上都支援一個屬性__proto__,用來指向建構函式的原型物件。下圖就解釋了上面的結構:

  

  我們可以看到,建構函式Personprototype屬性指向Prototype的原型物件。而person作為建構函式Person建立的例項,其中存在內部指標也指向Person的原型物件。需要注意的是,在Person的原型物件中存在一個特殊的屬性constructor,指向建構函式Person。在我們的例子中,執行到:

person.sayName(); //JavaScript複製程式碼

  當執行personsayName屬性時,首先會在物件例項中查詢sayName屬性,當發現物件例項中不存在sayName時,會轉而去搜尋person內部指標[[Prototpe]]所指向的原型物件,當發現原型物件中存在sayName屬性時,執行該屬性。關於函式sayNamethis的指向,有興趣可以戳這篇文章一個小小的JavaScript題目。   

原型鏈

  講完了原型,再講講原型鏈,其實我們上面的圖並不完整,因為所有函式的預設原型都是Object的例項,所以函式原型例項的內部指標[[Prototype]]指向的是Object.prototype,讓我們繼續來完善一下:
  


  
  這就是完整的原型鏈,假如我們執行下面程式碼:

person.toString()複製程式碼

  
  執行上面程式碼時,首先會在物件例項person中查詢屬性toString方法,我們發現例項中不存在toString屬性。然後我們轉到person內部指標[[Prototype]]指向的Person原型物件去尋找toString屬性,結果是仍然不存在。這找不到我們就放棄了?開玩笑,我們這麼有毅力。我們會再接著到Person原型物件的內部指標[[Prototype]]指向的Object原型物件中查詢,這次我們發現其中確實存在toString屬性,然後我們執行toString方法。發現了沒有,這一連串的原型形成了一條鏈,這就是原型鏈
  
  其實我們上面例子中對屬性toString查詢屬於RHS,以RHS方式尋找屬性時,會在原型鏈中依次查詢,如果在當前的原型中已經查詢到所需要的屬性,那麼就會停止搜尋,否則會一直向後查詢原型鏈,直到原型鏈的結尾(這一點有點類似於作用域鏈),如果直到原型鏈結尾仍未找到,那麼該屬性就是undefined。但執行LHS方式的查詢卻截然不同,當發現物件例項本身不存在該屬性,直接在該物件例項中宣告變數,而不會去查詢原型鏈。例如:

person.toString = function(){
    console.log('person')
}
person.toString(); //person複製程式碼

  當對person執行LHS的方式查詢toString屬性時,我們發現person中並不存在toString,這時會直接在person中宣告屬性,而不會去查詢原型鏈,接著我們執行person.toString()時,我們在例項中找到了toString屬性並將其執行,這樣例項中的toString就遮蔽了原型鏈中的toString屬性。   

作用域鏈和原型鏈的比較

  講完了作用域鏈和原型鏈,我們可以比較一下。作用域鏈的作用主要用於查詢識別符號,當作用域需要查詢變數的時候會沿著作用域鏈依次查詢,如果找到識別符號就會停止搜尋,否則將會沿著作用域鏈依次向後查詢,直到作用域鏈的結尾。而原型鏈是用於查詢引用型別的屬性,查詢屬性會沿著原型鏈依次進行,如果找到該屬性會停止搜尋並做相應的操作,否則將會沿著原型鏈依次查詢直到結尾。
    
  如果覺得閱讀完了本篇文章對你有些許幫助,歡迎大家我關注我的掘金賬號或者star我的Github的blog專案,也算是對我的鼓勵啦!

相關文章