大家應該寫過下面類似的代碼吧,其實這里我想要表達的是有時候一個方法定義的地方和使用的地方會相隔十萬八千里,那方法執行時,它能訪問哪些變量,不能訪問哪些變量,這個怎么判斷呢?這個就是我們這次需要分析的問題——詞法作用域
const classA = function(){
this.prop1 = 1;
}
classA.prototype.func1 = function(){
let that = this,
var1 = 2;
function a(){
return function(){
console.log(var1);
console.log(this.prop1);
}.apply(that);
};
a();
}
let objA = new ClassA();
objA.func1();
詞法作用域:變量的作用域是在定義的時候已經確定而不是執行時決定,換句話說,變量的作用域在你編寫代碼的時候已經確定了,也就是說詞法作用域取決于源碼,當解析器對源碼進行靜態分析就能確定,因此詞法作用域也叫做靜態作用域。而javascript的作用域機制與詞法作用域幾乎一樣。 with和eval除外。
下面通過幾個小小的案例,讓我熱熱身,這是我們深入的了解對理解詞法作用域和閉包必不可少的,JS執行時底層的一些概念和理論知識。
1.案例一
/*全局(window)域下的一段代碼*/
function a(i) {
var i;
console.log(i);
};
a(10);
疑問:上面的代碼會輸出什么呢?
答案:沒錯,就是彈出10。具體執行過程應該是這樣的
a 函數有一個形參 i,調用 a 函數時傳入實參 10,形參 i=10
接著定義一個同名的局部變量 i,未賦值
console.log 輸出 10
但是疑問來了,我們明明重新定義了變量i,卻沒有賦值啊,為什么打印出來是10,不是undefined啊?
引出思考:局部變量 i 和形參 i 是同一個存儲空間嗎?
2.案例二
/*全局(window)域下的一段代碼*/
function a(i) {
console.log(i);
console.log(arguments[0]); //arguments[0]應該就是形參 i
var i = 2;
console.log(i);
console.log(arguments[0]);
};
a(10);
疑問:上面的代碼又會輸出什么呢?10,10,2,10 || 10,10,2,2
答案:10,10,2,2,下面簡單說一下具體執行過程
a 函數有一個形參i,調用 a 函數時傳入實參 10,形參 i=10
第一個 console.log 把形參 i 的值 10 輸出
第二個 console.log 把 arguments[0] 輸出,應該也是 i
接著定義個局部變量 i 并賦值為2,這時候局部變量 i=2
第三個 console.log 就把局部變量 i 的值 2 輸出
第四個console.log再次把 arguments[0] 輸出
這里好像能說明局部變量 i 和形參 i 的值引用是相同啊!!當形參 i,argument[0]為10時,內部變量i也是10,當內部變量i重新定義卻賦值為10,形參 i也變為10。哦,原來局部變量 i 和形參 i 指向了同一個存儲地址!
喂!等等,你這個專題好像說詞法作用域啊,怎么一直說一些有的沒的?
好,我們先做完第三題先
/*全局(window)域下的一段代碼*/
var i=10;
function a() {
console.log(i);
var i = 2;
console.log(i);
};
a();
疑問:上面的代碼又會輸出什么呢?
答案:undefined , 2
第一個console.log輸出undefined
第二個console.log輸出 2
思考:為什么運行結果第一個不是10,而是undefined,第2個是2不是10?
這里肯定有同學問,因為函數內部存在變量提升啊,這么簡單的問題。我想說,你說的對,但是還未夠深入,為什么函數解析會有變量提升,我就這樣問你。不如讓我們把 JS 引擎對一個方法的解析過程進行一個稍微深入理解吧!!
1.代碼解析過程
總所周知,javascript是解析類語言,而解析類語言的的執行順序一般為: 通過詞法分析和語法分析得到語法分析樹后,就可以開始解釋執行了。
這里簡單說一下javascript的執行過程(以多個script代碼段為例)
1.讀入第一個代碼段(js執行引擎并非一行一行地執行程序,而是一段一段地分析執行的)
2.當讀取了第一個代碼段之后,javascript引擎就對著代碼段進行語法分析和詞法分析,如果有錯則報語法錯誤(比如括號不匹配等),并跳轉到步驟5
3.對【var,let,const】變量和【function】定義做“預解析“。(這就是變量為什么會提升的原因,先被與解析,然后在執行)
4.執行代碼段,有錯則報錯(比如變量未定義)
5.如果還有下一個代碼段,則讀入下一個代碼段,重復步驟2
6.ending
2.特殊說明
這里我先提一個假設,我們都知道window/global(node.js),這個最頂層的域,在我們打開網頁的那一刻。它好像一個被立刻執行的匿名函數,提早預定一些變量和方法等等。我們暫且這樣理解,window是一個立刻執行函數,里面定義著我們所寫的源碼,其它方法則是在被顯示調用的時候才被執行
3、關鍵步驟
這里我們再理一理javascript的執行步驟
javascript解析:就是通過語法分析,詞法分析和預解析構造合法的語法分析樹。
javascript執行:執行具體的某個function,JS引擎在執行每個函數實例時,都會創建一個執行環境(ExecutionContext)和活動對象(activeObject)(它們屬于宿主對象,與函數實例的生命周期保持一致)
4、關鍵概念
到這里,我們再更強調以下一些概念,這些概念都會在下面用一個一個的實體來表示,便于大家理解
語法分析樹(SyntaxTree)
可以直觀地表示這段代碼的相關信息,具體的實現就是JS引擎創建了一些表,用來記錄每個方法內的變量集(variables),方法集(functions)和作用域(scope)等。(是不是跟v-dom樹有點相似,紀錄每個dom的相關信息)
執行環境(ExecutionContext)
我們可以理解為一個紀錄當前執行的方法的(外部信息描述對象),,記錄所執行方法的類型,名稱,參數和活動對象(activeObject)。
活動對象(activeObject)
可理解為一個記錄當前執行的方法【內部執行信息】的對象,記錄內部變量集(variables)、內嵌函數集(functions)、實參(arguments)、作用域鏈(scopeChain)等執行所需信息,其中內部變量集(variables)、內嵌函數集(functions)是直接從第一步建立的語法分析樹復制過來的。
作用域鏈(scopeChain)
詞法作用域的實現機制就是作用域鏈(scopeChain)。作用域鏈是一套按名稱查找(Name Lookup)的機制,首先在當前執行環境的 ActiveObject 中尋找,沒找到,則順著作用域鏈到父 ActiveObject 中尋找,一直找到全局調用對象(Global Object)。
詞法作用域
變量的作用域是在定義時決定而不是執行時決定,也就是說詞法作用域取決于源碼,通過靜態分析就能確定,因此詞法作用域也叫做靜態作用域。
5.例子表示
javascript解析模擬
估計,看了這么多非人話的信息,大家會很朦朧吧,什么是語法分析樹,語法分析樹到底長什么樣子,作用域鏈又怎么實現的,活動對象又有什么內容等等,還是不是太清晰,下面我們就通過一段實際的代碼來模擬整個解析過程,我們就把語法分析樹,活動對象實實在在的創建出來,理解作用域,作用域鏈的到底是怎么實現的
例子代碼
/*全局(window)域下的一段代碼*/
var i = 1,j = 2,k = 3;
function a(o,p,x,q){
var x = 4;
console.log(i);
function b(r,s) {
var i = 11,y = 5;
console.log(i);
function c(t){
var z = 6;
console.log(i);
};
//函數表達式
var d = function(){
console.log(y);
};
c(60);
d();
};
b(40,50);
}
a(10,20,30);
1.構造語法分析樹
下面我們以一種簡單的結構:一個 JS 對象(為了清晰表示個各種對象間的引用關系,這里的只是偽對象表示,可能無法運行)來描述語法分析樹(這是我們比較熟悉的,實際結構我們不去深究,肯定復雜得多,這里是為了幫助理解解析過程)。
我們回顧一下語法分析樹紀錄了什么?
答:變量集,方法集,作用域,好,我們開始造一顆語法分析樹
/**
* 模擬建立一棵語法分析樹,存儲function內的變量和方法
*/
const SyntaxTree = {
// 全局對象在語法分析樹中的表示
window: {
variables:{
i:{ value:1},
j:{ value:2},
k:{ value:3}
},
functions:{
a: this.a
}
},
a:{
variables:{
x:'undefined'
},
functions:{
b: this.b
},
scope: this.window
}
b:{
variables:{
y:'undefined'
},
functions:{
c: this.c,
d: this.d
},
scope: this.a
},
c:{
variables:{
z:'undefined'
},
functions:{},
scope: this.b
},
d:{
variables:{},
functions:{},
scope: this.b
}
}
上面就是關于語法分析樹的一個簡單表示,正如我們前面分析的,語法分析樹主要記錄了每個 function 中的變量集(variables),方法集(functions)和作用域(scope)。
語法分析樹關鍵點
1.變量集(variables)中,只有變量定義,沒有變量值,這時候的變量值全部為“undefined”
2.作用域(scope),根據詞法作用域的特點,這個時候每個變量的作用域就已經明確了,而不會隨執行時的環境而改變。【什么意思呢?就是我們經常將一個方法 return 回去,然后在另外一個方法中去執行,執行時,方法中變量的作用域是按照方法定義時的作用域走。其實這里想表達的意思就是不管你在多么復雜,多么遠的地方執行該方法,最終判斷方法中變量能否被訪問還是得回到方法定義時的地方查證】
2.構造執行環境
語法分析完成,開始執行代碼。我們調用每一個方法的時候,JS 引擎都會自動為其建立一個執行環境和一個活動對象,它們和方法實例的生命周期保持一致,為方法執行提供必要的執行支持
我們回顧一下執行環境紀錄了什么?
答:執行環境紀錄了我們的方法的類型,名稱,參數和活動對象
/**
* 執行環境:函數執行時創建的執行環境
*/
const ExecutionContext = {
window: {
type: 'global',
name: 'global',
body: ActiveObject.window
},
a:{
type: 'function',
name: 'a',
body: ActiveObject.a,
scopeChain: this.window.body
},
b:{
type: 'function',
name: 'b',
body: ActiveObject.b,
scopeChain: this.a.body
},
c:{
type: 'function',
name: 'c',
body: ActiveObject.c,
scopeChain: this.b.body
},
d:{
type: 'function',
name: 'd',
body: ActiveObject.d,
scopeChain: this.b.body
}
}
上面每一個方法的執行環境都存儲了相應方法的類型(function)、方法名稱(funcName)、活動對象(ActiveObject)、作用域鏈(scopeChain)等信息,其關鍵點如下:
body屬性,直接指向當前方法的活動對象
scopeChain屬性, scopeChain屬性,作用域鏈,它是一個鏈表結構,根據語法分析樹中當前方法對應的scope屬性,它指向scope對應的方法的活動對象(ActivceObject),變量查找就是跟著這條鏈條查找的
3.構造活動對象
我們回顧一下構造活動對象紀錄了什么?
答:內部變量集(variables)、內嵌函數集(functions) 實參(arguments)、作用域鏈(scopeChain)。
const ActiveObject = {
window: {
variables:{
i: { value:1},
j: { value:2},
k: { value:3}
},
functions:{
a: this.a
}
},
a:{
variables:{
x: {value:4}//與內部實參同一引用
},
functions:{
b: SyntaxTree.b
},
parameters:{
o: {value: 10},
p: {value: 20},
x: this.variables.x,//與內部變量同一引用
q: 'undefined'
},
arguments:[this.parameters.o,this.parameters.p,this.parameters.x]
},
b:{
variables:{
y:{ value:5}
},
functions:{
c: SyntaxTree.c,
d: SyntaxTree.d
},
parameters:{
r:{value:40},
s:{value:50}
},
arguments:[this.parameters.r,this.parameters.s]
},
c:{
variables:{
z:{ value:6}
},
functions:{},
parameters:{
u:{value:70}
},
arguments:[this.parameters.u]
},
d:{
variables:{},
functions:{},
parameters:{},
arguments:[]
}
}
1.上面每一個活動對象都存儲了相應方法的內部變量集(variables)、內嵌函數集(functions)、形參(parameters)、實參(arguments)等執行所需信息。
2.創建活動對象,從語法分析樹復制方法的內部變量集(variables)和內嵌函數集(functions)
方法開始執行,活動對象里的內部變量集全部被重置為 undefined
- 創建形參(parameters)和實參(arguments)對象,同名的實參,形參和變量之間是【引用】關系
執行方法內的賦值語句,這才會對變量集中的變量進行賦值處理
4.變量查找規則是首先在當前執行環境的 ActiveObject 中尋找,沒找到,則順著執行環境中屬性 ScopeChain 指向的ActiveObject 中尋找,一直到 Global Object(window)
- 方法執行完成后,內部變量值不會被重置,至于變量什么時候被銷毀,請參考下面一條
方法內變量的生存周期取決于方法實例是否存在活動引用,如沒有就銷毀活動對象
這里我們就可以對面試幾乎必定會問的閉包進行一翻詳盡的解析:
面試官:請解析一下閉包是什么?
萌新:要了解閉包是什么,我們首先要知道javascript的執行步驟分為兩步,第一是javascript解析,通過語法分析,詞法分析,預解析構造語法分析樹(SyntaxTree),
然后當我們執行某個function時,js引擎在執行當前函數時,會分別創造一個執行環境(ExecutionContext)和對應活動對象(activeObject),當函數從內部返回一個引用供外部變量使用時(這個引用常見的就是返回一個function),而這個外部變量所引用的就是當前返回引用可以訪問的作用域區域,而這個作用域區域就是這個引用可以訪問的對應的所有活動對象(activeObject),而這個活動對象(activeObject)的總集就是我們平時說的閉包。
而內存泄漏就是,外部變量持續訪問這個返回的引用,導致這個活動對象(activeObject)的總集所對應的函數周期未能結束,因此導致這些活動對象和執行環境未能被js的垃圾回收機制回收和銷毀,所以導致內存泄漏。
哈哈,好像太長,我都覺得我自己啰嗦,雖然還能更詳細的說明,還是算了。哈哈
總結
以上是我在學習和使用了JS一段時間后,為了更深入的了解它, 也為了更好的把握對它的應用, 從而在對閉包的學習過程中,自己對于詞法作用域的一些理解和總結,中間可能有一些地方和真實的JS解釋引擎有差異