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

  畢業也整整一年了,看着不少學弟都畢業了,突然心中很有感慨,時間一去不復還呀。記得從去年這個時候接觸到JavaScript,從一開始就很喜歡這門語言,當時迷迷糊糊看完了《JavaScript高級程序設計》這本書,似懂非懂。這幾天又再次回顧了這本書,以前不少不理解的內容彷佛開始有些豁然開朗了。爲了防止以後本身又開始模糊,因此本身來總結一下JavaScript中關於 做用域鏈和原型鏈的知識,並將兩者相比較看待進一步加深理解。如下內容都純屬於本身的理解,有不對的地方歡迎指正。javascript

做用域鏈

做用域

  首先咱們須要瞭解的是做用域作什麼的?當JavaScript引擎在某一做用域中碰見變量函數的時候,須要可以明確變量和函數所對應的值是什麼,因此就須要做用域來對變量和函數進行查找,而且還須要肯定當前代碼是否對該變量具備訪問權限。也就是說做用域主要有如下的任務:前端

  • 收集並維護全部聲明的標識符(變量和函數)
  • 依照特定的規則對標識符進行查找
  • 肯定當前的代碼對標識符的訪問權限

  舉一個例子:java

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

foo( 2 );複製代碼

  對於上述代碼,JavaScript引擎須要對做用域發出如下的命令git

  • 查詢標識符foo,獲得變量後執行該變量
  • 查詢標識符a,獲得變量後對其賦值爲2
  • 查詢標識符console,獲得變量後準備執行屬性log
  • 查詢標識符a,獲得變量後,做爲參數傳入console.log執行

  咱們省略了函數console.log內部的執行過程,咱們能夠看到對JavaScript引擎來講,做用域最重要的功能就是查詢標識符。從上面的例子來看,引擎對變量的使用其實不是都同樣的。好比第一步引擎獲得標識符foo的目的是執行它(或者說是爲了拿到標識符裏存儲的值)。
但第二步中引擎查找標識符a的目的是爲了對其賦值(也就是改變存儲的值)。因此查找也分爲兩種:LHSRHSgithub

  我在以前的一篇文章中從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,則會在查找最外層的做用域聲明該變量,這就解釋了爲何對未聲明的變量賦值後該變量會成爲全局變量。因此上面的代碼執行ide

console.log(outer,inner)函數

的時候,引擎會首先要求Inner函數的詞法做用域查找(RHS)標識符outer,被告知該詞法做用域不存在該標識符,而後引擎會要求嵌套的上一級Outer詞法做用域查找(RHS)標識符outer,Outer詞法做用域的查找成功並將結果返回給引擎。ui

換個角度理解做用域鏈

  上面咱們理解做用域鏈都是從做用域鏈查找變量的角度去考慮的,其實這已經足夠了,大部分做用域鏈的場景都是查找標識符。可是咱們能夠換一個角度去理解做用域鏈。其實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項目,也算是對個人鼓勵啦!

相關文章
相關標籤/搜索