畢業也整整一年了,看著很多學弟都畢業了,忽然心中頗有感慨,時間一去不復還呀。記得從去年這個時候接觸到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
的目的是為了對其賦值(也就是改變儲存的值)。所以查詢也分為兩種:LHS
和RHS
。
我在之前的一篇文章中從LHS與RHS角度淺談Js變數宣告與賦值曾經介紹過LHS
與RHS
,這兩個看起來很高大上的名詞其實非常簡單。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)),用來儲存當前執行環境中定義的變數和函式,當執行環境結束時,當前的變數(活動)物件就會被銷燬(全域性的變數物件是一直存在的,不會被銷燬)。雖然我們無法訪問到變數(活動)物件,但詞法作用域查詢識別符號會使用它。
當對於函式的執行環境生成的活動物件,初始化就會存在兩個變數:this
和arguments
,因此我們在函式中就直接可以使用這兩個變數。對於作用域鏈儲存都是變數(活動)物件,而當前執行環境的變數物件就儲存在作用域鏈的最前端,優先被查詢。從這個角度看,識別符號解析是沿著作用域鏈一級一級地在變數(活動)物件中搜尋識別符號的過程。搜尋過程始終從作用域鏈的前端開始,然後逐級地向後回溯,直至找到識別符號為止。
閉包
這年頭出去面試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__
,用來指向建構函式的原型物件。下圖就解釋了上面的結構:
我們可以看到,建構函式Person
的prototype
屬性指向Prototype
的原型物件。而person
作為建構函式Person
建立的例項,其中存在內部指標也指向Person
的原型物件。需要注意的是,在Person
的原型物件中存在一個特殊的屬性constructor
,指向建構函式Person
。在我們的例子中,執行到:
person.sayName(); //JavaScript複製程式碼
當執行person
的sayName
屬性時,首先會在物件例項中查詢sayName
屬性,當發現物件例項中不存在sayName
時,會轉而去搜尋person
內部指標[[Prototpe]]
所指向的原型物件,當發現原型物件中存在sayName
屬性時,執行該屬性。關於函式sayName
中this
的指向,有興趣可以戳這篇文章一個小小的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專案,也算是對我的鼓勵啦!