不管是前端老司機,還是前端小白,看到標題中列舉的這些概念,想必都是頭大。其實你知道麼?這些概念背後是有聯絡的,理清楚他們的關係,你才能準確且牢靠地記住他們。
也只有理清楚這些基本且重要的概念,你才能在前端的道路上越走越遠。
好了,讓我們開始吧。
執行上下文
執行上下文可以理解為函式執行的環境。每個函式執行時,都會給對應的函式建立這樣一個執行環境。
JS執行環境大概包括三種情況:全域性環境、函式環境、eval環境(不推薦使用,所以不討論)。
一個JS程式中,必定會產生多個執行上下文,JS引擎會以棧的方式處理它們,這個棧,我們稱之為函式呼叫棧。棧底永遠都是全域性上下文,棧頂就是當前正在執行的上下文。
由於棧是先進後出的結構,我們不難推出以下四點:
- 只有棧頂的上下文處於執行中,其他上下文需要等待
- 全域性上下文只有唯一的一個,它在瀏覽器關閉時出棧
- 函式的執行上下文的個數沒有限制
- 每次某個函式被呼叫,就會有個新的執行上下文為其建立。
當然,光知道這些還是不夠,我們還必須瞭解執行上下文的生命週期。
執行上下文的生命週期
當呼叫一個函式時,一個新的執行上下文就會被建立。而一個執行上下文的生命週期可以分為兩個階段。
建立階段
在這個階段中,執行上下文會分別建立變數物件,建立作用域鏈,以及確定this的指向。
程式碼執行階段
建立完成之後,就會開始執行程式碼,這個時候,會完成變數賦值,函式引用,以及執行其他程式碼。
至此,我們終於知道執行上下文跟變數物件、作用域鏈及this的關係。
接下來我們重點介紹這三個概念。
變數物件
當一個函式被呼叫時,執行上下文就建立了,執行上下文包含了函式所有宣告的變數和函式,儲存這些變數跟函式的物件,我們稱之為變數物件。
變數物件的建立,依次經歷了以下幾個過程。
- 建立arguments物件。檢查當前上下文中的引數,建立該物件下的屬性與屬性值。
- 檢查當前上下文的函式宣告,也就是使用function關鍵字宣告的函式。在變數物件中以函式名建立一個屬性,屬性值為指向該函式所在記憶體地址的引用。如果函式名的屬性已經存在,那麼該屬性將會被新的引用所覆蓋。
- 檢查當前上下文中的變數宣告,每找到一個變數宣告,就在變數物件中以變數名建立一個屬性,屬性值為undefined。如果該變數名的屬性已經存在,為了防止同名的函式被修改為undefined,則會直接跳過,原屬性值不會被修改。 舉個反例,很多人對以下程式碼存在疑問,既然變數宣告的foo遇到函式宣告的foo會跳過,可是為什麼最後foo的輸出結果仍然是被覆蓋了?
function foo() { console.log('function foo') }
var foo = 20;
console.log(foo); // 20
複製程式碼
這是因為上面的三條規則僅僅適用於變數物件的建立過程。也就是執行上下文的建立過程。而foo = 20
是在執行上下文的執行過程中執行的,輸出結果自然會是20。對比下例。
console.log(foo); // function foo
function foo() { console.log('function foo') }
var foo = 20;
複製程式碼
// 上慄的執行順序為
// 首先將所有函式宣告放入變數物件中
function foo() { console.log('function foo') }
// 其次將所有變數宣告放入變數物件中,但是因為foo已經存在同名函式,因此此時會跳過undefined的賦值
// var foo = undefined;
// 然後開始執行階段程式碼的執行
console.log(foo); // function foo
foo = 20;
複製程式碼
再看一個例子:
// demo01
function test() {
console.log(a);
console.log(foo());
var a = 1;
function foo() {
return 2;
}
}
test();
複製程式碼
我們直接從test()的執行上下文開始理解。全域性作用域中執行test()時,test()的執行上下文開始建立。為了便於理解,我們用如下的形式來表示
// 建立過程
testEC = {
// 變數物件
VO: {},
scopeChain: {}
}
// 因為本文暫時不詳細解釋作用域鏈,所以把變數物件專門提出來說明
// VO 為 Variable Object的縮寫,即變數物件
VO = {
arguments: {...}, //注:在瀏覽器的展示中,函式的引數可能並不是放在arguments物件中,這裡為了方便理解,我做了這樣的處理
foo: <foo reference> // 表示foo的地址引用
a: undefined
}
複製程式碼
未進入執行階段之前,變數物件中的屬性都不能訪問!但是進入執行階段之後,變數物件轉變為了活動物件,裡面的屬性都能被訪問了,然後開始進行執行階段的操作。
變數物件和活動物件其實都是同一個物件,只是處於執行上下文的不同生命週期。不過只有處於函式呼叫棧棧頂的執行上下文中的變數物件,才會變成活動物件。
// 執行階段
VO -> AO // Active Object
AO = {
arguments: {...},
foo: <foo reference>,
a: 1,
this: Window
}
複製程式碼
因此,上面的例子demo1,執行順序就變成了這樣
function test() {
function foo() {
return 2;
}
var a;
console.log(a);
console.log(foo());
a = 1;
}
test();
複製程式碼
作用域鏈與閉包
變數物件講完了,接著是作用域鏈,這裡就不得不先提下作用域。
作用域
作用域最大的用處就是隔離變數,不同作用域下同名變數不會有衝突。
JavaScript中只有全域性作用域與函式作用域。言外之意是:javascript除了全域性作用域之外,只有函式可以建立的作用域。
JavaScript程式碼的整個執行過程,分為兩個階段,程式碼編譯階段與程式碼執行階段。
編譯階段由編譯器完成,將程式碼翻譯成可執行程式碼,這個階段作用域規則會確定。
執行階段由引擎完成,主要任務是執行可執行程式碼,執行上下文在這個階段建立。
理解這點很重要,我們面試過程中,經常會被問到“自由變數”的取值問題。
什麼是“自由變數”?先看個例子:
var x = 10;
function fn() {
var b = 20;
console.log(x+b); // x在這裡就是一個自由變數
}
複製程式碼
取x的值時,需要到另一個作用域中取,x就被稱作“自由變數”。
“自由變數”的取值,難倒一片的人,不信,看看下面這個例子:
var x = 10;
function fn() {
console.log(x);
}
function show(f){
var x = 20;
(function () {
f(); // 這裡輸出什麼???
})();
}
show(fn);
複製程式碼
你的第一反應是不是20?答案是10!!
其實這個問題很簡單,自由變數要到建立這個函式的那個作用域中取值——是“建立”,而不是“呼叫”。
為什麼呢?因為作用域是在程式碼編譯過程就確定下來的,然後就不會改變,這就是所謂的“靜態作用域”。
本例中,在fn函式取自由變數x的值時,要到哪個作用域中取?——要到建立fn函式的那個作用域中取——無論fn函式將在哪裡呼叫。fn明顯是在全域性環境下建立的,x明顯就是10。
作用域鏈
上面的例子,只是跨一個作用域去尋找。
如果跨了一步,還沒找到呢?——接著跨!——一直跨到全域性作用域為止。要是在全域性作用域中都沒有找到,那就是真的沒有了。
這個一步一步“跨”的路線,我們稱之為——作用域鏈。
我們拿文字總結一下取自由變數時的這個“作用域鏈”過程:(假設a是自由量)
第一步,現在當前作用域查詢a,如果有則獲取並結束。如果沒有則繼續;
第二步,如果當前作用域是全域性作用域,則證明a未定義,結束;否則繼續;
第三步,(不是全域性作用域,那就是函式作用域)將建立該函式的作用域作為當前作用域;
第四步,跳轉到第一步。
閉包
閉包是一種特殊的物件。
它由兩部分組成。執行上下文(代號A),以及在該執行上下文中建立的函式(代號B)。
當B執行時,如果訪問了A中變數物件中的值,那麼閉包就會產生。
// demo01
function foo() {
var a = 20;
var b = 30;
function bar() {
return a + b;
}
return bar;
}
var bar = foo();
bar();
複製程式碼
上面的例子,首先有執行上下文foo,在foo中定義了函式bar,而通過對外返回bar的方式讓bar得以執行。當bar執行時,訪問了foo內部的變數a,b。因此這個時候閉包產生。
閉包的應用場景
除了面試,在實踐中,閉包有兩個非常重要的應用場景。分別是模組化與柯里化。
this
this或許是最讓初學者頭疼的概念了吧。this難就難在指向上。
請記住:this的指向,是在函式被呼叫的時候確定的,在函式執行過程中,this一旦被確定,就不可更改了。
我們來看看幾種情況:
全域性物件中的this
全域性環境中的this,指向它本身。
函式中的this
在一個函式上下文中,this由呼叫者提供,由呼叫函式的方式來決定。如果呼叫者函式,被某一個物件所擁有,那麼該函式在呼叫時,內部的this指向該物件。如果函式獨立呼叫,那麼該函式內部的this,則指向undefined。但是在非嚴格模式中,當this指向undefined時,它會被自動指向全域性物件。切記,函式執行過程中,this一旦被確定,就不可更改。
'use strict';
var a = 20;
function foo () {
var a = 1;
var obj = {
a: 10,
c: this.a + 20,
fn: function () {
return this.a;
}
}
return obj.c;
}
console.log(foo()); // ?
console.log(window.foo()); // ?
複製程式碼
執行foo()時,函式獨立呼叫,所以this指向undefined(因為是嚴格模式),所以執行this.a時報錯。
執行window.foo()時,this.a = 20,結果為40.
function foo() {
console.log(this.a)
}
function active(fn) {
fn(); // 真實呼叫者,為獨立呼叫
}
var a = 20;
var obj = {
a: 10,
getA: foo
}
active(obj.getA); // 20
複製程式碼
使用call,apply顯示指定this
call與applay
建構函式與原型方法上的this
function Person(name, age) {
// 這裡的this指向了誰?
this.name = name;
this.age = age;
}
Person.prototype.getName = function() {
// 這裡的this又指向了誰?
return this.name;
}
// 上面的2個this,是同一個嗎,他們是否指向了原型物件?
var p1 = new Person('Nick', 20);
p1.getName();
複製程式碼
this,是在函式呼叫過程中確定,因此,搞明白new的過程中到底發生了什麼就變得十分重要。
通過new操作符呼叫建構函式,會經歷以下4個階段。
- 建立一個新的物件;
- 將建構函式的this指向這個新物件;
- 指向建構函式的程式碼,為這個物件新增屬性,方法等;
- 返回新物件。
因此,當new操作符呼叫建構函式時,this其實指向的是這個新建立的物件,最後又將新的物件返回出來,被例項物件p1接收。因此,我們可以說,這個時候,建構函式的this,指向了新的例項物件,p1。
而原型方法上的this就好理解多了,根據上邊對函式中this的定義,p1.getName()中的getName為呼叫者,他被p1所擁有,因此getName中的this,也是指向了p1。
寫在最後
本文提到的概念,都是JavaScript中相對晦澀的,平時開發過程中,要多思考其原理,這是一個必經的階段,只要不斷加深理解,我們才能真正掌握這些概念,也只有掌握好這些概念,我們才能在前端的道理上越走越遠。