在 JavaScript 中,執(zhí)行上下文是一個基本的概念,但其中又包含了變量對象、作用域鏈、this 指向等更深入的內(nèi)容,深入理解執(zhí)行上下文以及其中的內(nèi)容,對我們以后理解 JavaScript 中其它更深入的知識點(函數(shù)/變量提升、閉包等)會有很大的幫助。
執(zhí)行上下文(Execution Context)
執(zhí)行上下文可以理解為當(dāng)前代碼的運行環(huán)境。在 JavaScript 中,運行環(huán)境主要包含了全局環(huán)境和函數(shù)環(huán)境。
在 JavaScript 代碼運行過程中,最先進入的是全局環(huán)境,而在函數(shù)被調(diào)用時則進入相應(yīng)的函數(shù)環(huán)境。全局環(huán)境和函數(shù)環(huán)境所對應(yīng)的執(zhí)行上下文我們分別稱為全局上下文和函數(shù)上下文。
在一個 JavaScript 文件中,經(jīng)常會有多個函數(shù)被調(diào)用,也就是說在 JavaScript 代碼運行過程中很可能會產(chǎn)生多個執(zhí)行上下文,那么如何去管理這多個執(zhí)行上下文呢?
執(zhí)行上下文是以棧(一種 LIFO 的數(shù)據(jù)結(jié)構(gòu))的方式被存放起來的,我們稱之為執(zhí)行上下文棧(Execution Context Stack)。
在 JavaScript 代碼開始執(zhí)行時,首先進入全局環(huán)境,此時全局上下文被創(chuàng)建并入棧,之后當(dāng)調(diào)用函數(shù)時則進入相應(yīng)的函數(shù)環(huán)境,此時相應(yīng)函數(shù)上下文被創(chuàng)建并入棧,當(dāng)處于棧頂?shù)膱?zhí)行上下文代碼執(zhí)行完畢后,則會將其出棧。
所以在執(zhí)行上下文棧中,棧底永遠(yuǎn)是全局上下文,而棧頂則是當(dāng)前正在執(zhí)行的函數(shù)上下文。
文字表達既枯燥又難以理解,讓我們來看一個簡單的栗子吧~
function fn2() {
console.log('fn2')
}
function fn1() {
console.log('fn1')
fn2();
}
fn1();
運行上述代碼,可以得到相應(yīng)的輸出,那么上述代碼在執(zhí)行過程中執(zhí)行上下文棧的行為是怎樣的呢?
/* 偽代碼 以數(shù)組來表示執(zhí)行上下文棧 ECStack=[] */
// 代碼執(zhí)行時最先進入全局環(huán)境,全局上下文被創(chuàng)建并入棧
ECStack.push(global_EC);
// fn1 被調(diào)用,fn1 函數(shù)上下文被創(chuàng)建并入棧
ECStack.push(fn1_EC);
// fn1 中調(diào)用 fn2,fn2 函數(shù)上下文被創(chuàng)建并入棧
ECStack.push(fn2_EC);
// fn2 執(zhí)行完畢,fn2 函數(shù)上下文出棧
ECStack.pop();
// fn1 執(zhí)行完畢,fn1 函數(shù)上下文出棧
ECStack.pop();
// 代碼執(zhí)行完畢,全局上下文出棧
ECStack.pop();
以一個更形象的圖來說明上述的流程

在一個執(zhí)行上下文中,最重要的三個屬性分別是變量對象(Variable Object)、作用域鏈(Scope Chain)和 this 指向。
我們可以采用如下方式表示
EC = {
VO,
SC,
this
}
一個執(zhí)行上下文的生命周期分為創(chuàng)建和執(zhí)行階段。創(chuàng)建階段主要工作是生成變量對象、建立作用域鏈和確定 this 指向。而執(zhí)行階段主要工作是變量賦值以及執(zhí)行其它代碼等。
變量對象(Variable Object)
我們已經(jīng)知道,在執(zhí)行上下文的創(chuàng)建階段會生成變量對象,生成變量對象主要有以下三個過程:
- 檢索當(dāng)前上下文中的參數(shù)。該過程生成 Arguments 對象,并建立以形參變量名為屬性名,形參變量值為屬性值的屬性;
- 檢索當(dāng)前上下文中的函數(shù)聲明。該過程建立以函數(shù)名為屬性名,函數(shù)所在內(nèi)存地址引用為屬性值的屬性;
- 檢索當(dāng)前上下文中的變量聲明。該過程建立以變量名為屬性名,undefined 為屬性值的屬性(如果變量名跟已聲明的形參變量名或函數(shù)名相同,則該變量聲明不會干擾已經(jīng)存在的這類屬性)。
我們可以通過以下偽代碼來表示變量對象
VO = {
Arguments: {},
ParamVariable: 具體值, //形參變量
Function: <function reference>,
Variable:undefined
}
當(dāng)執(zhí)行上下文進入執(zhí)行階段后,變量對象會變?yōu)?strong>活動對象(Active Object)。此時原先聲明的變量會被賦值。
變量對象和活動對象都是指同一個對象,只是處于執(zhí)行上下文的不同階段。
我們可以通過以下偽代碼來表示活動對象
AO = {
Arguments: {},
ParamVariable: 具體值, //形參變量
Function: <function reference>,
Variable:具體值
}
同樣的,讓我們以實際栗子來理解在代碼執(zhí)行過程中某執(zhí)行上下文中變量對象的變化情況~
function fn1(a) {
var b = 1;
function fn2() {}
var c = function () {};
}
fn1(0);
當(dāng) fn1 函數(shù)被調(diào)用時,fn1 執(zhí)行上下文被創(chuàng)建(創(chuàng)建階段)并入棧,其變量對象如下所示
fn1_EC = {
VO = {
Arguments: {
'0': 0,
length: 1
},
a: 0,
b: undefined,
fn2: <function fn2 reference>,
c:undefined
}
}
而在 fn1 函數(shù)代碼的執(zhí)行過程中(執(zhí)行階段),變量對象變?yōu)榛顒訉ο?,原先聲明的變量會被賦值,其活動對象如下所示
fn1_EC = {
AO = {
Arguments: {
'0': 0,
length: 1
},
a: 0,
b: 1,
fn2: <function fn2 reference>,
c:<function express c reference>,
}
}
對于全局上下文來說,由于其不會有參數(shù)傳遞,所以在生成變量對象的過程中只有檢索當(dāng)前上下文中的函數(shù)聲明和檢索當(dāng)前上下文中的變量聲明兩個步驟。
在瀏覽器環(huán)境中,全局上下文中的變量對象(全局對象)即我們熟悉的 window 對象,通過該對象可以使用其預(yù)定義的變量和函數(shù),在全局環(huán)境中所聲明的變量和函數(shù),也會成為全局對象的屬性。
弄明白了變量對象的生成過程后,我們就能夠更深入地理解函數(shù)提升以及變量提升的內(nèi)在機制了。
console.log(a) // undefined
fn(0); // fn
var a = 0;
function fn() {
console.log('fn')
}
上述代碼中,在全局上下文的創(chuàng)建階段,會檢索上下文中的函數(shù)聲明以及變量聲明,函數(shù)會被賦值具體的引用地址而變量會被賦值為 undefined。
所以上述代碼實際上的運行過程如下
function fn() {
console.log('fn')
}
var a = undefined;
console.log(a) // undefined
fn(0); // fn
a = 0;
所以,這就是我們經(jīng)常提到的函數(shù)提升以及變量提升的內(nèi)在機制。
作用域鏈(Scope Chain)
作用域鏈?zhǔn)侵赣僧?dāng)前上下文和上層上下文的一系列變量對象組成的層級鏈。它保證了當(dāng)前執(zhí)行環(huán)境對符合訪問權(quán)限的變量和函數(shù)的有序訪問。
我們已經(jīng)知道,執(zhí)行上下文分為創(chuàng)建和執(zhí)行兩個階段,在執(zhí)行上下文的執(zhí)行階段,當(dāng)需要查找某個變量或函數(shù)時,會在當(dāng)前上下文的變量對象(活動對象)中進行查找,若是沒有找到,則會沿著上層上下文的變量對象進行查找,直到全局上下文中的變量對象(全局對象)。
那么當(dāng)前上下文是如何有序地去查找它所需要的變量或函數(shù)的呢?
答案就是依靠當(dāng)前上下文中的作用域鏈,其包含了當(dāng)前上下文和上層上下文中的變量對象,以便其一層一層地去查找其所需要的變量和函數(shù)。
執(zhí)行上下文中的作用域鏈又是怎么建立的呢?
我們都知道,JavaScript 中主要包含了全局作用域和函數(shù)作用域,而函數(shù)作用域是在函數(shù)被聲明的時候確定的。
每一個函數(shù)都會包含一個 [[scope]] 內(nèi)部屬性,在函數(shù)被聲明的時候,該函數(shù)的 [[scope]] 屬性會保存其上層上下文的變量對象,形成包含上層上下文變量對象的層級鏈。[[scope]] 屬性的值是在函數(shù)被聲明的時候確定的。
當(dāng)函數(shù)被調(diào)用的時候,其執(zhí)行上下文會被創(chuàng)建并入棧。在創(chuàng)建階段生成其變量對象后,會將該變量對象添加到作用域鏈的頂端并將 [[scope]] 添加進該作用域鏈中。而在執(zhí)行階段,變量對象會變?yōu)榛顒訉ο?,其相?yīng)屬性會被賦值。
所以,作用域鏈?zhǔn)怯僧?dāng)前上下文變量對象及上層上下文變量對象組成的
SC = AO + [[scope]]
讓我們來看個栗子~
var a = 1;
function fn1() {
var b = 1;
function fn2() {
var c = 1;
}
fn2();
}
fn1();
在 fn1 函數(shù)上下文中,fn2 函數(shù)被聲明,所以
fn2.[[scope]]=[fn1_EC.VO, globalObj]
當(dāng) fn2 被調(diào)用的時候,其執(zhí)行上下文被創(chuàng)建并入棧,此時會將生成的變量對象添加進作用域鏈的頂端,并且將 [[scope]] 添加進作用域鏈
fn2_EC.SC=[fn2_EC.VO].concat(fn2.[[scope]])
=>
fn2_EC.SC=[fn2_EC.VO, fn1_EC.VO, globalObj]
this 指向
this 的指向,是在函數(shù)被調(diào)用的時候確定的。也就是執(zhí)行上下文被創(chuàng)建時確定的。
關(guān)于 this 的指向,其實最主要的是三種場景,分別是全局上下文中 this、函數(shù)中 this 和構(gòu)造函數(shù)中 this。
全局上下文中 this
在全局上下文中,this 指代全局對象。
// 在瀏覽器環(huán)境中,全局對象是 window 對象:
console.log(this === window); // true
a = 1;
this.b = 2;
console.log(window.a); // 1
console.log(window.b); // 2
console.log(b); // 2
函數(shù)中 this
函數(shù)中的 this 指向是怎樣一種情況呢?
如果被調(diào)用的函數(shù),被某一個對象所擁有,那么其內(nèi)部的 this 指向該對象;如果該函數(shù)被獨立調(diào)用,那么其內(nèi)部的 this 指向 undefined(非嚴(yán)格模式下指向 window)。
舉個栗子~
var a = 1;
function fn() {
console.log(this.a)
}
var obj = {
a: 2,
fn: fn
}
obj.fn(); // 2
fn(); // 1
上述代碼中 fn 函數(shù)都是輸出 this.a,根據(jù)上述的結(jié)論,obj.fn() 由于其是被 obj 對象所擁有,所以 this 指向 obj 對象;而 fn 是被獨立調(diào)用,在非嚴(yán)格模式下 this 指向 window。
構(gòu)造函數(shù)中 this
要清楚構(gòu)造函數(shù)中 this 的指向,則必須先了解通過 new 操作符調(diào)用構(gòu)造函數(shù)時所經(jīng)歷的階段。
通過 new 操作符調(diào)用構(gòu)造函數(shù)時所經(jīng)歷的階段如下:
- 創(chuàng)建一個新對象;
- 將構(gòu)造函數(shù)的 this 指向這個新對象;
- 執(zhí)行構(gòu)造函數(shù)內(nèi)部代碼;
- 返回這個新對象。
所以從上述流程可知,對于構(gòu)造函數(shù)來說,其內(nèi)部 this 指向新創(chuàng)建的對象實例。
function Person(name, age) {
this.name = name;
this.age = age;
}
var ttsy = new Person('ttsy', 24);
console.log(ttsy.name); // ttsy
console.log(ttsy.age); // 24
需要注意的是,在 ES6 中箭頭函數(shù)中,this 是在函數(shù)聲明的時候確定的,具體可看 http://es6.ruanyifeng.com/#docs/function 。
一個完整的栗子
接下來,讓我們來完整地 look 一下程序運行過程中執(zhí)行上下文及其內(nèi)部屬性的變化情況。
function fn1() {
var a = 1;
function fn2(b) {
var c = 3
}
fn2(2)
}
fn1();
上述代碼在執(zhí)行過程中,執(zhí)行上下文棧的變化過程如下
/* 偽代碼 以數(shù)組來表示執(zhí)行上下文棧 ECStack=[] */
// 代碼執(zhí)行時最先進入全局環(huán)境,全局上下文被創(chuàng)建并入棧
ECStack.push(global_EC);
// fn1 被調(diào)用,fn1 函數(shù)上下文被創(chuàng)建并入棧
ECStack.push(fn1_EC);
// fn1 中調(diào)用 fn2,fn2 函數(shù)上下文被創(chuàng)建并入棧
ECStack.push(fn2_EC);
// fn2 執(zhí)行完畢,fn2 函數(shù)上下文出棧
ECStack.pop();
// fn1 執(zhí)行完畢,fn1 函數(shù)上下文出棧
ECStack.pop();
// 代碼執(zhí)行完畢,全局上下文出棧
ECStack.pop();
首先進入全局環(huán)境,全局上下文被創(chuàng)建并入棧
全局上下文如下
global_EC = {
VO: globalObj,
SC: [globalObj],
this: globalObj,
}
接著 fn1 被調(diào)用,fn1 函數(shù)上下文被創(chuàng)建并入棧
在 fn1 函數(shù)上下文被創(chuàng)建之前,會有一個函數(shù)聲明過程,這個過程發(fā)生在全局上下文創(chuàng)建階段,在這個過程中,fn1.[[scope]] 會保存其上層作用域的變量對象。
在 fn1 函數(shù)上下文創(chuàng)建階段,其執(zhí)行上下文如下
fn1_EC = {
VO: {
Arguments: {
length: 0
},
fn2: <function fn2 reference>,
a:undefined
},
SC:[fn1_EC.VO, globalObj],
this:null
}
在 fn1 函數(shù)上下文執(zhí)行階段,其執(zhí)行上下文如下
fn1_EC = {
VO: {
Arguments: {
length: 0
},
fn2: <function fn2 reference>,
a:1
},
SC:[fn1_EC.VO, globalObj],
this:globalObj
}
然后在 fn1 中調(diào)用 fn2,fn2 函數(shù)上下文被創(chuàng)建并入棧
在 fn2 函數(shù)上下文創(chuàng)建階段,其執(zhí)行上下文如下
fn2_EC = {
VO: {
Arguments: {
'0': 2,
length: 0
},
b: 2,
c: undefined
},
SC: [fn2_EC.VO, fn1_EC.VO, globalObj],
this: null
}
在 fn2 函數(shù)上下文執(zhí)行階段,其執(zhí)行上下文如下
fn2_EC = {
VO: {
Arguments: {
'0': 2,
length: 0
},
b: 2,
c: 3
},
SC: [fn2_EC.VO, fn1_EC.VO, globalObj],
this: globalObj
}
最后是各個上下文出棧
在各個上下文出棧后,其對應(yīng)的變量對象會被 JavaScript 中的自動垃圾收集機制回收。
而我們經(jīng)常說閉包能夠訪問其所在環(huán)境的變量,其實是因為閉包能夠阻止上述變量對象被回收的過程。
深入地理解了執(zhí)行上下文的內(nèi)容后,對于我們理解閉包也會有很大的幫助,關(guān)于閉包我寫過一篇 《 JavaScript 閉包詳解 》,感興趣的童鞋也可以繼續(xù)閱讀。