要脫離單純勞動力的角色,就要深刻去了解那些底層的原理,今天咱們就來聊聊執行上下文。只有理解了 JavaScrip 的執行上下文,你才能更好地理解 JavaScript 語言自己,由於JavaScript中的不少概念變量提高、做用域和閉包等等都是和執行上下文有關的。
執行上下文主要包括:javascript
全局執行上下文——代碼首次執行的默認環境。前端
函數執行上下文——每當進入一個函數內部。java
Eval執行上下文——eval內部的文本被執行時。面試
咱們就從那個這些概念出發,看看執行上下文的那些事情。瀏覽器
變量提高對於前端人員來講是已經習覺得常的事情,但從別的語言來看,這種特性顯得很是奇怪。
所謂的變量提高,是指在 JavaScript 代碼執行過程當中,JavaScript 引擎把變量的聲明部分和函數的聲明部分提高到代碼開頭的「行爲」。變量被提高後,會給變量設置默認值,這個默認值就是咱們熟悉的 「undefined」。性能優化
對於變量提高的過程咱們就不詳細講了,不少面試題都在變着花樣的考察變量、函數的提高。bash
咱們來看下對於JavaScript引擎來講在執行過程當中作了什麼,怎麼實現的變量提高。 一段代碼的執行分爲兩個階段:編譯階段、執行階段。閉包
是的,JavaScript的執行是須要編譯的,只不過它屬於解釋型語言,編譯過程是在執行以前的很短期以內完成的(具體的編譯原理,後面的文章中會詳細介紹)。app
咱們以一個例子來進行分析:函數
showMyName()
console.log(myname)
var myname = '前端記事本'
function showMyName() {
console.log('函數 showName 被執行');
}
複製代碼
這段代碼在執行的時候,變量myname和showMyName的函數聲明會被提高。這就是在編譯過程當中處理的。
代碼編譯以後會分爲兩個部分:執行上下文和可執行代碼。(變量環境中的函數體其實是存在堆內存中的,變量環境中會建立一個指向這個函數體的變量,咱們這裏圖中不作詳細標註了)。
而執行上下文中又分爲:
變量環境
詞法環境
this綁定
咱們能夠看到,被提高的變量都被放在了變量環境中,剩餘的部分則是可執行代碼,在執行階段處理。 過程是怎樣的呢? 第 1 行和第 2 行不是變量或者函數聲明,因此 JavaScript 引擎不會作任何處理;
第 3 行是通過 var 聲明的變量,所以 JavaScript 引擎將在環境對象中建立一個名爲 myname 的屬性,並將其初始化爲undefined;
第 4 行是函數聲明, JavaScript 引擎會將函數定義存儲到堆 (HEAP)中,並在環境對象中建立一個 showName 的屬性,而後將該屬性值指向堆中函數的位置(這裏涉及到JavaScript內存管理,你們能夠去查資料瞭解一下啊)。
而後就是將代碼編譯成字節碼的過程了,這裏就不詳細說了。
這段代碼的變量被提高以後還剩幾部分,就是咱們要執行的代碼了。
showMyName()
console.log(myname)
myname = '前端記事本'
複製代碼
在執行到showName()時會到變量環境中查找這個函數,由於引擎將對這個函數的引用存在了變量環境中,因此很容易找到這段函數代碼並執行;
在執行 console.log 的時候,會在變量環境中找到 myname 變量,但此時它的值爲 undifined,因此會輸出undifined。
最後是對變量myname的賦值操做,會將變量環境中myname的值由undifined -> '前端記事本'
這就是整個變量提高的過程了,其實經過這個過程咱們也就知道了,若是聲明瞭兩個相同名稱的函數,在執行的時候會怎麼樣了:由於名稱相同,因此變量環境中只會存在一個函數名稱變量,這個變量名稱會指向最後聲明的那個函數。
好了,變量提高講完了,咱們能夠看到對於執行上下文的三個部分,咱們只涉及到了變量環境,那其它兩個部分呢?咱們下面再來看另一個概念,做用域。
上面在提到變量提高的時候咱們說,對於其它語言,變量提高是一個很奇怪的特性。由於JavaScript 變量提高這種特性,會致使了不少與直覺不符的代碼,這也是 JavaScript 的一個重要設計缺陷。
其中一個很大的影響就是做用域。
做用域是指在程序中定義變量的區域,該位置決定了變量的生命週期。通俗地理解,做用域就是變量與函數的可訪問範圍,即做用域控制着變量和函數的可見性和生命週期。
javascript的做用域主要包括:全局做用域、函數做用域。沒有塊級做用域。這就形成了咱們面試中那個經典的for循環輸出i的問題。相信你們對於這種面試題的結果已經很瞭解了。
但如今,咱們從執行上下文的角度分析一下。
對於做用域:
全局做用域中的對象在代碼中的任何地方都能訪問,其生命週期伴隨着頁面的生命週期。
函數做用域就是在函數內部定義的變量或者函數,而且定義的變量或者函數只能在函數內部被訪問。函數執行結束以後,函數內部定義的變量會被銷燬。
咱們說過,變量提高會對做用域的理解產生誤解。
再來分析一個例子。
var myname = " 三木 "
function showName(){
console.log(myname);
var myname = " 前端記事本 "
console.log(myname);
}
showName()
複製代碼
這段代碼輸出是「undifined」和「前端記事本」,爲何呢?你能夠看到這裏有兩個 myname 變量:一個在全局執行上下文中,其值是「三木」;另一個在 showName 函數的執行上下文中,變量被提高了,其值是 undefined。
因此在執行第一個 console.log(myname)時直覺覺得會是「三木」,但實際用的是 showName 函數的執行上下文中的myname,此時的值爲 undefined。執行第二個console.log(myname)時,showName 函數的執行上下文中的 myname 已經被賦值爲「前端記事本」了。
因此經過第一個 myname 的輸出能夠看到變量容易在不被察覺的狀況下被覆蓋掉
後來ES6出現了,推出了let、const關鍵字,使得 javascript 多了塊級做用域。 let 和 const 聲明的變量再也不被提高了。
因此下面這段代碼:
function foo(){
var a = 1
let b = 2
{
let b = 3
var c = 4
let d = 5
console.log(a)
console.log(b)
}
console.log(b)
console.log(c)
console.log(d)
}
foo()
複製代碼
咱們前面說過 javaScript 引擎會先對其進行編譯並建立執行上下文,而後再按照順序執行代碼。如今咱們引入了 let 關鍵字,let 關鍵字會建立塊級做用域,那麼 let 關鍵字是如何影響執行上下文的呢?
看下上面的例子,它在編譯以後的 foo 函數執行上下文是這樣的:
另外,函數代碼塊中用 let 定義的 b 和 d 並無出如今foo函數的執行上下文中。 因此能夠看到:
函數內部經過 var 聲明的變量,在編譯階段全都被存放到變量環境裏面了。
經過 let 聲明的變量,在編譯階段會被存放到詞法環境(Lexical Environment)中。
在函數的做用域內部,經過 let 聲明的變量並無被存放到詞法環境中。
而後是執行階段,在執行到代碼塊的時候,會在foo函數的詞法環境中單獨一塊區域存放 b 和 d。
能夠看到在代碼塊外部定義的 b 和內部定義的 b 是獨立分開的。
其實,在詞法環境內部,維護了一個小型棧結構,棧底是函數最外層的變量,進入一個做用域塊後,就會把該做用域塊內部的變量壓到棧頂;看成用域執行完成以後,該做用域的信息就會從棧頂彈出,這就是詞法環境的結構。
須要注意下我這裏所講的變量是指經過 let 或者 const 聲明的變量。
因此這樣在執行下面代碼在查詢變量值的時候,會先從詞法環境的棧頂開始查找,若是找不到就去變量環境中去查找。
由於詞法環境維護了一個小的棧,因此在代碼塊執行完以後, b 和 d 所在的區塊就被彈出銷燬了。
這就是塊級做用域在執行棧的處理過程。
Tips:對於 let 和 const 定義的變量有個「暫時性死區」的概念,咱們執行一個例子。
這就是暫時性死區的報錯,初始化以前沒法訪問tmp。其實變量的整個建立過程包括:建立、初始化、賦值。因此 let 變量不會變量提高的真實狀況是:在塊做用域內,let聲明的變量被提高,但變量只是建立被提高,初始化並無被提高,在初始化以前使用變量,就會造成一個暫時性死區。
var 的建立和初始化被提高,賦值不會被提高。
let 的建立被提高,初始化和賦值不會被提高。
function的建立、初始化和賦值均會被提高。
看個例子:
function bar() {
console.log(myName)
}
function foo() {
var myName = " 三木 "
bar()
}
var myName = " 前端記事本 "
foo()
複製代碼
我原本覺得結果是:「三木」,結果它輸出的是 " 前端記事本 "。爲何會這樣? 這段代碼的執行棧是這樣的:
按照直覺來講,查找 myName 變量的順序應該是:當前函數執行上下文(bar) -> 外部函數執行上下文(foo) -> 全局執行上下文
但這裏有個做用域鏈的概念。
在每一個執行上下文的變量環境中,都包含了一個外部引用,用來指向外部的執行上下文,咱們把這個外部引用稱爲outer。
因此,真實狀況是:
當一段代碼使用了一個變量時,JavaScript 引擎首先會在「當前的執行上下文」中查找該變量,好比上面那段代碼在查找 myName 變量時,若是在當前的變量環境中沒有查找到,那麼 JavaScript 引擎會繼續在 outer 所指向的執行上下文中查找。
那又是什麼決定outer的指向呢?
那就是:詞法做用域: 詞法做用域就是指做用域是由代碼中函數聲明的位置來決定的,因此詞法做用域是靜態的做用域,經過它就可以預測代碼在執行過程當中如何查找標識符。
JavaScript 做用域鏈是由詞法做用域決定的,詞法做用域是由函數聲明位置決定的,因此上面 bar() 函數在全局環境聲明的,因此 outer 指向是全局執行上下文,而不是 foo 函數的執行上下文。
對於剛學習 JavaScript 的同窗來講,this 真的很讓人頭大。而這個 this 的指向就是綁定在第一部分咱們說過執行上下文中的 this綁定 上的。
在函數執行上下文中,this 的值取決於該函數是如何被調用的,而不是函數聲明的位置。
通常對於 this 的調用就分爲幾種:
對象調用內部的函數,該方法的執行上下文中的 this 指向對象自己;
call、bind和apply改變方法的 this 指向;
new 一個構造函數的時候也是 this 指向改變的過程。
**函數和對其周圍狀態(lexical environment,詞法環境)的引用捆綁在一塊兒構成閉包(closure)。也就是說,閉包可讓你從內部函數訪問外部函數做用域。 ** 對於下面這段代碼,內部函數showName函數,訪問了外部 foo 函數的變量 myName。
function foo() {
var myName = " 三木 "
function showName() {
console.log(myName)
}
return showName
}
var bar = foo();
bar()
複製代碼
整個調用棧的狀況以下圖:
根據詞法做用域的規則,內部函數 showName 老是能夠訪問它們的外部函數 foo 中的變量,因此返回給全局變量 bar 時,雖然 foo 函數已經執行結束,可是 showName 函數依然可使用 foo 函數中的變量 myName 。因此當 foo 函數執行完成以後,其整個調用棧的狀態以下圖所示:
在瀏覽器中看下是這樣的:
從上圖能夠看出,foo 函數執行完成以後,其執行上下文從棧頂彈出了,可是因爲返回的 showName 方法中使用了 foo 函數內部的變量 myName,因此這個變量依然保存在內存中。就像單獨給showName方法配置了一個數據包,不管在哪裏調用showName函數,都會帶着這個數據包。
由於是專屬於showName函數的數據包,因此除了 showtName 函數以外,其餘任何地方都是沒法訪問該數據包的,咱們就能夠把這個數據包稱爲 foo 函數的閉包。
好了,如今咱們終於能夠給閉包一個正式的定義了。在 JavaScript 中,根據詞法做用域的規則,內部函數老是能夠訪問其外部函數中聲明的變量,當經過調用一個外部函數返回一個內部函數後,即便該外部函數已經執行結束了,可是內部函數引用外部函數的變量依然保存在內存中,咱們就把這些變量的集合稱爲閉包。好比外部函數是 foo,那麼這些變量的集合就稱爲 foo 函數的閉包。
咱們從JavaScript的執行上下文的角度分析了一下咱們常見的幾個概念,總體較少比較粗略,感興趣的同窗能夠本身去深刻研究一下執行上下文,對於咱們分析一些執行原理和處理性能優化是很是有幫助的。
參考文章:
《瞭解JavaScript的執行上下文》
《瀏覽器工做原理與實踐》 - 李兵
《Understanding Execution Context and Execution Stack in Javascript》 - Sukhjinder Arora
若是你但願瞭解更多前端知識,請關注個人公衆號「前端記事本」