深刻淺出javascript (1)—— 變量提高

提到前端面試,對於 javascript 語言層面的考察,這幾個概念是避不開的:執行上下文,變量提高,閉包,This,做用域,做用域鏈,原型鏈,Event Loop等。
與其說面試很機械,倒不如說這就是 javascript 語音最最核心的概念,弄不清楚這些概念,那你必定不是一名合格的前端開發er。
因此,接下來我會分幾篇文章來說這幾個核心概念,並將他們串起來,讓你們能夠更好的全方位理解。
下面進入正題,今天第一篇文章咱們來講 —— 變量提高。javascript

先看代碼:前端

showName() 
console.log(myname) 
var myname = 'wens' 
function showName() { 
    console.log('函數showName被執行'); 
}

使用過 JavaScript 開發的程序員應該都知道,JavaScript 是按順序執行的。若按照這個邏輯來理解的話,那麼:java

  • 當執行到第 1 行的時候,因爲函數 showName 尚未定義,因此執行應該會報錯;
  • 執行第 2 行的時候,因爲變量 myname 也未定義,因此一樣也會報錯。然而實際執行結果卻並不是如此, 以下圖:

image.png

第 1 行輸出「函數 showName 被執行」,第 2 行輸出「undefined」,這和想象中的順序執行有點不同啊!程序員

經過上面的執行結果,咱們已經知道了函數或者變量能夠在定義以前使用,那若是使用沒有定義的變量或者函數,JavaScript 代碼還能繼續執行嗎?爲了驗證這點,咱們能夠刪除第 3 行變量 myname 的定義,以下所示:面試

showName() 
console.log(myname) 
function showName() { 
    console.log('函數showName被執行'); 
}

這時候 JavaScript 引擎就會報錯,結果以下:閉包

image.png

從上面兩段代碼的執行結果來看,咱們能夠得出以下三個結論。函數

  1. 在執行過程當中,若使用了未聲明的變量,那麼 JavaScript 執行會報錯。
  2. 在一個變量定義以前使用它,不會出錯,可是該變量的值會爲 undefined,而不是定義時的值。
  3. 在一個函數定義以前使用它,不會出錯,且函數能正確執行。

第一個結論很好理解,由於變量沒有定義,這樣在執行 JavaScript 代碼時,就找不到該變量,因此 JavaScript 會拋出錯誤。oop

可是對於後兩個結論,就挺讓人費解的:優化

  • 變量和函數爲何能在其定義以前使用?這彷佛代表 JavaScript 代碼並非一行一行執行的。
  • 一樣的方式,變量和函數的處理結果爲何不同?好比上面的執行結果,提早使用的 showName 函數能打印出來完整結果,可是提早使用的 myname 變量值倒是 undefined,而不是定義時使用的「wens」這個值。

變量提高(Hoisting)

要解釋這兩個問題,咱們須要先了解下什麼是變量提高。this

不過在介紹變量提高以前,咱們先經過下面這段代碼,來看看什麼是 JavaScript 中的聲明和賦值。

var myname = 'wens'

這行代碼須要這樣理解:

var myname //聲明部分 
myname = 'wens' //賦值部分

上面是變量的聲明和賦值,那接下來咱們再來結合代碼看看函數的聲明和賦值:

function foo(){ 
    console.log('foo') 
} 
var bar = function(){ 
    console.log('bar') 
}

第一個函數 foo 是一個完整的函數聲明,也就是說沒有涉及到賦值操做;
第二個函數和上面那一行變量的賦值同樣,是先聲明變量 bar,再把function(){console.log('bar')}賦值給 bar。

好了,理解了聲明和賦值操做,那接下來咱們就能夠聊聊什麼是變量提高了。

所謂的變量提高,是指在 JavaScript 代碼執行過程當中,JavaScript 引擎把變量的聲明部分和函數的聲明部分提高到代碼開頭的行爲。變量被提高後,會給變量設置默認值,這個默認值就是咱們熟悉的 undefined。

針對第一個代碼片斷,咱們來模擬一下變量提高:

// 把變量 myname提高到開頭, 
// 同時給myname賦值爲undefined 
var myname = undefined 

// 把函數showName提高到開頭 
function showName() { 
    console.log('showName被調用'); 
} 

showName() // 因此這裏能夠正常執行
console.log(myname) // 因此這裏能夠正常打印myname的值

// 去掉var聲明部分,保留賦值語句 
myname = 'wens'

從代碼中能夠看出,對原來的代碼主要作了兩處調整:

  • 第一處是把聲明的部分都提高到了代碼開頭,如變量 myname 和函數 showName,並給變量設置默認值 undefined;
  • 第二處是移除本來聲明的變量和函數,如var myname = 'wens'的語句,移除了 var 聲明,整個移除 showName 的函數聲明。

經過這兩步,就能夠實現變量提高的效果。你也能夠執行這段模擬變量提高的代碼,其輸出結果和第一段代碼是徹底同樣的。

JavaScript 代碼的執行流程

從字面意義上來看,「變量提高」意味着變量和函數的聲明會移動到代碼的最前面,就像咱們所模擬的那樣。其實這並不許確。實際上變量和函數聲明在代碼裏的位置是不會改變的。由於一段 JavaScript 的可執行代碼(executable code) 在真正被執行以前還要先經歷引擎的編譯

編譯階段

上面說到 JavaScript 的可執行代碼(executable code),那麼哪些是可執行代碼呢?

其實很簡單,就三種:

  1. 當 JavaScript 執行全局代碼的時候,會編譯全局代碼並建立全局執行上下文,並且在整個頁面的生存週期內,全局執行上下文只有一份。
  2. 當調用一個函數的時候,函數體內的代碼會被編譯,並建立函數執行上下文,通常狀況下,函數執行結束以後,建立的函數執行上下文會被銷燬。
  3. 當使用 eval 函數的時候,eval 的代碼也會被編譯,並建立執行上下文。

那麼編譯階段和變量提高存在什麼關係呢?

爲了搞清楚這個問題,咱們仍是回過頭來看上面那段模擬變量提高的代碼,爲了方便介紹,能夠把這段代碼分紅兩部分。
image.png

從上圖能夠看出,輸入一段代碼,通過編譯後,會生成兩部份內容:執行上下文(Execution context)和可執行代碼。

執行上下文是 JavaScript 執行一段代碼時的運行環境,好比調用一個函數,就會進入這個函數的執行上下文,在這裏肯定該函數在執行期間用到的諸如 this、變量、對象以及函數等。

關於執行上下文的細節,我會在下一篇文章作詳細介紹,如今咱們只須要知道,在執行上下文中存在一個變量環境的對象(Viriable Environment),該對象中保存了變量提高的內容,好比上面代碼中的變量 myname 和函數 showName,都保存在該對象中。

咱們能夠簡單地把變量環境對象當作是以下結構:

VariableEnvironment: 
    myname -> undefined, 
    showName -> function {console.log(myname)}

接下來,咱們再結合代碼來分析下是如何生成變量環境對象的:

showName() 
console.log(myname) 
var myname = 'wens' 
function showName() { 
    console.log('函數showName被執行'); 
}

咱們逐行分析上述代碼:

  • 第 1 行和第 2 行,因爲這兩行代碼不是聲明操做,因此 JavaScript 引擎不會作任何處理;
  • 第 3 行,因爲這行是通過 var 聲明的,所以 JavaScript 引擎將在環境對象中建立一個名爲 myname 的屬性,並使用 undefined 對其初始化;
  • 第 4 行,JavaScript 引擎發現了一個經過 function 定義的函數,因此它將函數定義存儲到堆 (HEAP)中,並在環境對象中建立一個 showName 的屬性,而後將該屬性值指向堆中函數的位置。

這樣就生成了變量環境對象。接下來 JavaScript 引擎會把聲明之外的代碼編譯爲字節碼,至於字節碼的細節,我也會在後面文章中作詳細介紹。如今有了執行上下文和可執行代碼了,那麼接下來就到了執行階段了。

執行階段

JavaScript 引擎開始執行「可執行代碼」,按照順序逐行執行。下面咱們就分析下這個執行過程:

  • 當執行到 showName 函數時,JavaScript 引擎便開始在變量環境對象中查找該函數,因爲變量環境對象中存在該函數的引用,因此 JavaScript 引擎便開始執行該函數,並輸出「函數 showName 被執行」結果。
  • 接下來打印「myname」信息,JavaScript 引擎繼續在變量環境對象中查找該對象,因爲變量環境存在 myname 變量,而且其值爲 undefined,因此這時候就輸出 undefined。
  • 接下來執行第 3 行,把「wens」賦給 myname 變量,賦值後變量環境中的 myname 屬性值變爲「wens」,變量環境以下所示:
VariableEnvironment: 
    myname -> wens, 
    showName -> function {console.log(myname)}

好了,以上就是一段代碼的編譯和執行流程。實際上,編譯階段和執行階段都是很是複雜的,包括了詞法分析、語法解析、代碼優化、代碼生成等,全部的這些內容我都會在接下來的文章中介紹,在本篇文章中咱們暫時只介紹變量提高相關內容。

代碼中出現相同的變量或者函數怎麼辦?

如今咱們知道了,在執行一段 JavaScript 代碼以前,會編譯代碼,並將代碼中的函數和變量保存到執行上下文的變量環境中,那麼若是代碼中出現了重名的函數或者變量,JavaScript 引擎會如何處理?

相同變量

咱們看下面的代碼:

console.log(myName);
var myName = 'wens';
console.log(myName);
var myName = 'leon';
console.log(myName);

咱們來分析下其完整執行流程:

  • 首先是編譯階段。遇到了第一個 myName 變量,在環境對象中建立 myName 並賦予undefined 初始值。接下來是第二個 myName 變量,此時變量環境對象中已經存在 myName ,因此跳過這個過程。
  • 接下來是執行階段。第一個log輸出 undefined ,由於這時候沒有給 myName 再次賦值。第二行myName變量被賦值 wens, 因此第二個log輸出 wens。同理,第三個log輸出leon。

綜上所述,一段代碼若是定義了兩個相同名字的值,那麼最終的值取決於被賦予的最新的值,若是沒有就是初始值undefined。

相同函數

咱們先看下面這樣一段代碼:

function showName() { 
    console.log('wens'); 
} 
showName(); 
function showName() { 
    console.log('leon'); 
} 
showName();

在上面代碼中,咱們先定義了一個 showName 的函數,該函數打印出來「wens」;而後調用 showName,又定義了一個 showName 函數,這個 showName 函數打印出來的是「leon」;一段代碼中出現了同名的函數,並分別調用。
咱們來分析下其完整執行流程:

  • 首先是編譯階段。遇到了第一個 showName 函數,將函數定義存儲到堆中,並在環境對象中建立一個 showName 的屬性指向堆中定義的函數。接下來是第二個 showName 函數,發現變量環境中已經存在一個 showName 函數了,此時,堆中定義的 showName 函數會被這個 showName 函數覆蓋掉。這樣變量環境中的 showName 函數就指向了第二個 showName 函數了。
  • 接下來是執行階段。先執行第一個 showName 函數,但因爲是從變量環境中查找 showName 函數,而變量環境中只保存了第二個 showName 函數,因此最終調用的是第二個函數,打印的內容是「leon」。第二次執行 showName 函數也是走一樣的流程,因此輸出的結果也是「leon」。

綜上所述,一段代碼若是定義了兩個相同名字的函數,那麼最終生效的是最後一個函數。

總結

好了,今天就到這裏,下面我來簡單總結下今天的主要內容:

  • JavaScript 代碼執行過程當中,須要先作變量提高,而之因此須要實現變量提高,是由於 JavaScript 代碼在執行以前須要先編譯。
  • 在編譯階段,變量和函數會被存放到變量環境中,變量的默認值會被設置爲 undefined;在代碼執行階段,JavaScript 引擎會從變量環境中去查找自定義的變量和函數。
  • 若是在編譯階段,存在兩個相同的變量,那麼值取決於被賦予的最新的值,若是沒有就是初始值undefined。
  • 若是在編譯階段,存在兩個相同的函數,那麼最終存放在變量環境中的是最後定義的那個,這是由於後定義的會覆蓋掉以前定義的。

以上就是今天所講的主要內容,接下來的文章咱們會重點介紹執行上下文。

相關文章
相關標籤/搜索