作用域鏈
作用域
首先我們需要了解的是作用域做什么的?當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
屬性。
作用域鏈和原型鏈的比較
講完了作用域鏈和原型鏈,我們可以比較一下。作用域鏈的作用主要用於查找標識符,當作用域需要查詢變量的時候會沿着作用域鏈依次查找,如果找到標識符就會停止搜索,否則將會沿着作用域鏈依次向后查找,直到作用域鏈的結尾。而原型鏈是用於查找引用類型的屬性,查找屬性會沿着原型鏈依次進行,如果找到該屬性會停止搜索並做相應的操作,否則將會沿着原型鏈依次查找直到結尾。