當 JS 引擎處理一段腳本內容的時候,它是以怎樣的順序解析和執行的?腳本中的那些變量是什麼時候被定義的?它們之間錯綜複雜的訪問關係又是怎樣建立和連接的?要解釋這些問題,就必須瞭解 JS 執行上下文的概念。javascript
當 JS
引擎解析到可執行代碼片斷(一般是函數調用階段)的時候,就會先作一些執行前的準備工做,這個 「準備工做」,就叫作 "執行上下文(execution context 簡稱 EC
)" 或者也能夠叫作執行環境。html
執行上下文 爲咱們的可執行代碼塊提供了執行前的必要準備工做,例如變量對象的定義、做用域鏈的擴展、提供調用者的對象引用等信息。前端
javascript
中有三種執行上下文類型,分別是:java
全局執行上下文——這是默認或者說是最基礎的執行上下文,一個程序中只會存在一個全局上下文,它在整個 javascript
腳本的生命週期內都會存在於執行堆棧的最底部不會被棧彈出銷燬。全局上下文會生成一個全局對象(以瀏覽器環境爲例,這個全局對象是 window
),而且將 this
值綁定到這個全局對象上。node
函數執行上下文——每當一個函數被調用時,都會建立一個新的函數執行上下文(無論這個函數是否是被重複調用的)webpack
Eval 函數執行上下文—— 執行在 eval
函數內部的代碼也會有它屬於本身的執行上下文,但因爲並不常用 eval
,因此在這裏不作分析。git
執行上下文是一個抽象的概念,咱們能夠將它理解爲一個 object
,一個執行上下文裏包括如下內容:github
variable object
簡稱 VO
)原文:Every execution context has associated with it a variable object. Variables and functions declared in the source text are added as properties of the variable object. For function code, parameters are added as properties of the variable object.web
每一個執行環境文都有一個表示變量的對象——變量對象,全局執行環境的變量對象始終存在,而函數這樣局部環境的變量,只會在函數執行的過程當中存在,在函數被調用時且在具體的函數代碼運行以前,JS 引擎會用當前函數的參數列表(arguments
)初始化一個 「變量對象」 並將當前執行上下文與之關聯 ,函數代碼塊中聲明的 變量 和 函數 將做爲屬性添加到這個變量對象上。面試
有一點須要注意,只有函數聲明(function declaration)會被加入到變量對象中,而函數表達式(function expression)會被忽略。
// 這種叫作函數聲明,會被加入變量對象
function a () {}
// b 是變量聲明,也會被加入變量對象,可是做爲一個函數表達式 _b 不會被加入變量對象
var b = function _b () {}
複製代碼
全局執行上下文和函數執行上下文中的變量對象還略有不一樣,它們之間的差異簡單來講:
window
對象。 VO
)被激活爲活動對象(AO
)時,咱們才能訪問到其中的屬性和方法。activation object
簡稱 AO
)原文:When control enters an execution context for function code, an object called the activation object is created and associated with the execution context. The activation object is initialised with a property with name arguments and attributes { DontDelete }. The initial value of this property is the arguments object described below. The activation object is then used as the variable object for the purposes of variable instantiation.
函數進入執行階段時,本來不能訪問的變量對象被激活成爲一個活動對象,自此,咱們能夠訪問到其中的各類屬性。
其實變量對象和活動對象是一個東西,只不過處於不一樣的狀態和階段而已。
scope chain
)做用域 規定了如何查找變量,也就是肯定當前執行代碼對變量的訪問權限。當查找變量的時候,會先從當前上下文的變量對象中查找,若是沒有找到,就會從父級(詞法層面上的父級)執行上下文的變量對象中查找,一直找到全局上下文的變量對象,也就是全局對象。這樣由多個執行上下文的變量對象構成的鏈表就叫作 做用域鏈。
函數的做用域在函數建立時就已經肯定了。當函數建立時,會有一個名爲 [[scope]]
的內部屬性保存全部父變量對象到其中。當函數執行時,會建立一個執行環境,而後經過複製函數的 [[scope]]
屬性中的對象構建起執行環境的做用域鏈,而後,變量對象 VO
被激活生成 AO
並添加到做用域鏈的前端,完整做用域鏈建立完成:
Scope = [AO].concat([[Scope]]);
複製代碼
若是當前函數被做爲對象方法調用或使用 bind
call
apply
等 API
進行委託調用,則將當前代碼塊的調用者信息(this value
)存入當前執行上下文,不然默認爲全局對象調用。
關於 this
的建立細節,有點煩,有興趣的話能夠進入 傳送門 學習。
若是將上述一個完整的執行上下文使用代碼形式表現出來的話,應該相似於下面這種:
executionContext:{
[variable object | activation object]:{
arguments,
variables: [...],
funcions: [...]
},
scope chain: variable object + all parents scopes
thisValue: context object
}
複製代碼
執行上下文的生命週期有三個階段,分別是:
函數執行上下文的建立階段,發生在函數調用時且在執行函數體內的具體代碼以前,在建立階段,JS 引擎會作以下操做:
用當前函數的參數列表(arguments
)初始化一個 「變量對象」 並將當前執行上下文與之關聯 ,函數代碼塊中聲明的 變量 和 函數 將做爲屬性添加到這個變量對象上。在這一階段,會進行變量和函數的初始化聲明,變量統必定義爲 undefined
須要等到賦值時纔會有確值,而函數則會直接定義。
有沒有發現這段加粗的描述很是熟悉?沒錯,這個操做就是 變量聲明提高(變量和函數聲明都會提高,可是函數提高更靠前)。
構建做用域鏈(前面已經說過構建細節)
肯定 this
的值
執行階段中,JS 代碼開始逐條執行,在這個階段,JS 引擎開始對定義的變量賦值、開始順着做用域鏈訪問變量、若是內部有函數調用就建立一個新的執行上下文壓入執行棧並把控制權交出……
通常來說當函數執行完成後,當前執行上下文(局部環境)會被彈出執行上下文棧而且銷燬,控制權被從新交給執行棧上一層的執行上下文。
注意這只是通常狀況,閉包的狀況又有所不一樣。
閉包的定義:有權訪問另外一個函數內部變量的函數。簡單說來,若是一個函數被做爲另外一個函數的返回值,並在外部被引用,那麼這個函數就被稱爲閉包。
function funcFactory () {
var a = 1;
return function () {
alert(a);
}
}
// 閉包
var sayA = funcFactory();
sayA();
複製代碼
當閉包的父包裹函數執行完成後,父函數自己執行環境的做用域鏈會被銷燬,可是因爲閉包的做用域鏈仍然在引用父函數的變量對象,致使了父函數的變量對象會一直駐存於內存,沒法銷燬,除非閉包的引用被銷燬,閉包再也不引用父函數的變量對象,這塊內存才能被釋放掉。過分使用閉包會形成 內存泄露 的問題,這塊等到閉包章節再作詳細分析。
當一段腳本運行起來的時候,可能會調用不少函數併產生不少函數執行上下文,那麼問題來了,這些執行上下文該怎麼管理呢?爲了解決這個問題,javascript
引擎就建立了 「執行上下文棧」 (Execution context stack
簡稱 ECS
)來管理執行上下文。
顧名思義,執行上下文棧是棧結構的,所以遵循 LIFO
(後進先出)的特性,代碼執行期間建立的全部執行上下文,都會交給執行上下文棧進行管理。
當 JS 引擎開始解析腳本代碼時,會首先建立一個全局執行上下文,壓入棧底(這個全局執行上下文從建立一直到程序銷燬,都會存在於棧的底部)。
每當引擎發現一處函數調用,就會建立一個新的函數執行上下文壓入棧內,並將控制權交給該上下文,待函數執行完成後,即將該執行上下文從棧內彈出銷燬,將控制權從新給到棧內上一個執行上下文。
在瞭解了調用棧的運行機制後,咱們能夠考慮一個問題,這個執行上下文棧能夠被無限壓棧嗎?很顯然是不行的,執行棧自己也是有容量限制的,當執行棧內部的執行上下文對象積壓到必定程度若是繼續積壓,就會報 「棧溢出(stack overflow
)」 的錯誤。棧溢出錯誤常常會發生在 遞歸 中。
程序調用自身的編程技巧稱爲遞歸(recursion)。
遞歸的使用場景,一般是在運行次數未知的狀況下,程序會設定一個限定條件,除非達到該限定條件不然程序將一直調用自身運行下去。遞歸的適用場景很是普遍,好比累加函數:
// 求 1~num 的累加,此時 num 由外部傳入,是未知的
function recursion (num) {
if (num === 0) return num;
return recursion(num - 1) + num;
}
recursion(100) // => 5050
recursion(1000) // => 500500
recursion(10000) // => 50005000
recursion(100000) // => Uncaught RangeError: Maximum call stack size exceeded
複製代碼
從代碼中能夠看到,這個遞歸的累加函數,在計算 1 ~ 100000 的累加和的時候,執行棧就崩不住了,觸發了棧溢出的錯誤。
針對遞歸存在的 「爆棧」 問題,咱們能夠學習一下 尾遞歸優化。「遞歸」 咱們已經瞭解了,那麼 「尾」 是什麼意思呢?「尾」 的意思是 「尾調用(Tail Call
)」,即函數的最後一步是返回一個函數的運行結果:
// 尾調用正確示範1
function a(x){
return b(x);
}
// 尾調用正確示範2
// 尾調用不必定要寫在函數的最後爲止,只要保證執行時是最後一部操做就好了。
function c(x) {
if (x > 0) {
return d(x);
}
return e(x);
}
複製代碼
尾調用之因此與其餘調用不一樣,就在於它的特殊的調用位置。尾調用因爲是函數的最後一步操做,因此不須要保留外層函數的相關信息,由於調用位置、內部變量等信息都不會再用到了,只要直接用內層函數的調用記錄,取代外層函數的調用記錄就能夠了,這樣一來,運行尾遞歸函數時,執行棧永遠只會新增一個上下文。
咱們可使用尾調用的方式改寫下上面的累加遞歸:
// 尾遞歸優化
function recursion (num, sum = 0) {
if (num === 0) return sum;
return recursion(num - 1, sum + num);
}
recursion(100000) // => Uncaught RangeError: Maximum call stack size exceeded
複製代碼
運行以後怎麼仍是報錯了 😳 ??裂開了呀。。。
其實,尾遞歸優化這種東西,如今沒有任何一個瀏覽器是支持的(聽說 Safari 13 是支持的),babel
編譯也不支持。那 nodejs
裏的 V8
引擎呢?它作好了,可是不給你用,官方回答以下:
Proper tail calls have been implemented but not yet shipped given that a change to the feature is currently under discussion at TC39.
理由呢,它也頗有道理:
抱着就是不服的心態,我開始 google
看 js 怎麼才能支持尾遞歸。
看 stackoverflow
上的意思是說,只有 Safari
支持尾遞歸優化,看來有戲,先弄個 safari
下下看。
下好了,怎麼是祖傳界面???算了,先運行。
說好的 Safari
能夠呢??繼續找緣由,我找到了下面這個圖,相似於 caniuse
:
看樣子只有 Safari 13+
支持,我電腦上的版本是 5.1
的,硬着頭皮,找 13+
的版本。一路摸,摸到蘋果官網:
沒有 win10
版本的下載??我不能再買個 mbp 吧?(點開支付寶看了看,算了算了)有 mbp
的大佬們能夠試試看可不可行,好像 iOS12+
也能支持。
總之,尾遞歸優化這個東西暫時仍是不要想用到了,不過先了解個概念也是好的。
在網上找了幾條執行上下文比較典型的面試題,你們能夠試一試:
var foo = function () {
console.log('foo1');
}
foo();
var foo = function () {
console.log('foo2');
}
foo();
複製代碼
第一題沒什麼,應該能寫出來。
foo();
var foo = function foo() {
console.log('foo1');
}
function foo() {
console.log('foo2');
}
foo();
複製代碼
全局執行環境自動建立,過程當中生成了變量對象進行函數變量的屬性收集,形成了函數聲明提高、變量聲明提高。因爲函數聲明提高更加靠前,且若是 var
定義變量的時候發現已有同名函數定義則跳過變量定義,上面的代碼其實能夠寫成下面這樣:
function foo () {
console.log('foo2');
}
foo();
foo = function foo() {
console.log('foo1');
};
foo();
複製代碼
var foo = 1;
function bar () {
console.log(foo);
var foo = 10;
console.log(foo);
}
bar();
複製代碼
bar
函數運行,內部變量申明提高,當執行代碼塊中有訪問變量時,先查找本地做用域,找到了 foo
爲 undefined
,打印出來。而後 foo
被賦值爲 10
,打印出 10
。
var foo = 1;
function bar () {
console.log(foo);
foo = 2;
}
bar();
console.log(foo);
複製代碼
這題也是考察的做用域鏈查找,bar
裏操做的 foo
本地沒有定義,因此應該是上層做用域的變量。
var foo = 1;
function bar (foo) {
console.log(foo);
foo = 234;
}
bar(123);
console.log(foo);
複製代碼
運行 bar
函數的時候將 123
數字做爲實參傳入,因此操做的仍是本地做用域的 foo
。
var a = 1;
function foo () {
var a = 2;
return function () {
console.log(a);
}
}
var bar = foo();
bar();
複製代碼
這道題目主要考察閉包和函數做用域的概念,咱們只要記住:函數可以訪問到的上層做用域,是在函數聲明時候就已經肯定了的,函數聲明在哪裏,上層做用域就在哪裏,和拿到哪裏執行沒有關係。這道題目中,匿名函數被做爲閉包返回並在外部調用,但它內部的做用域鏈引用到了父函數的變量對象中的 a
,因此做用域鏈查找時,打印出來的是 2
。
var a = 1;
function foo () {
var a = 2;
return function () {
console.log(this.a);
}
}
var bar = foo().bind(this);
bar();
複製代碼
這題考察的是執行環境中的 this
指向的問題,因爲閉包內明確指定訪問 this
中的 a
屬性,而且閉包被 bind
綁定在全局環境下運行,因此打印出的是全局對象中的 a
。
undefined
。[[scope]]
屬性裏,和函數拿到哪裏去執行沒有關係。this
指向,取決於它的調用者,一般有如下幾種方式能夠改變函數的 this
值:對象調用、call
、bind
、apply
。本篇文章已收錄入 前端面試指南專欄