JavaScript: 圖解JavaScript規則 - 拉升

21.04.04

Photo by Edu Lauton on Unsplash



一開始決定著手撰寫這個系列,只是為了用最簡單的方式解釋什麼是閉包,以及閉包到底有什麼用,甚至連標題都想好了「閉包是什麼,能吃嗎?」。後來發現想用圖解的方式來解說閉包,勢必要一同解釋什麼是作用域鏈、詞彙環境等背景知識。因此本系列預計分成三篇,分別解釋什麼是拉升、作用域以及閉包。

執行環境 Execution context

JavaScript底層要執行的時候,第一步會先建立一個「執行環境」(Execution Context)。

執行環境內部有許多物件,其中最重要的是「詞彙環境」Lexical Environment(也有翻譯成「詞法環境」)。 詞彙環境主要儲存了:

  1. 全域物件,在有瀏覽器的環境下,指的是window內的物件
  2. 登記這個環境所宣告的變數和function(函式)
  3. 外部環境,也就是Scope Chain,台灣多半翻譯成「作用域(鏈)」,中國則翻譯成「範疇」
  4. 這個環境的”this”是什麼

執行環境解析程式碼的過程中,主要分成「建立階段(Creation Phase)」和「執行階段(Execution Phase)」。

  • 建立階段:建立「詞彙環境」。
  • 執行階段:由上到下、一行一行地執行程式,只會先跳過函式「裡面」的程式碼。畢竟你宣告了函式,不代表你要立刻執行它,可以等到別的事情先做完了,再執行這個函式。

第一個執行環境就是全域執行環境,當全域執行環境建立完,進入執行階段後,一行一行往下跑著,當遇到呼叫A函式時,這個全域執行環境就會為A函式建立一個A函式執行環境,再進入A函式中逐行執行。

若在A函式中又呼叫另一個B函式,就會再建立新的B函式執行環境,並堆疊在A函式執行環境的上方。如果不斷地在函式中呼叫另一個函式,所有執行環境就會不斷往上堆疊,如下面的動畫:

JS會優先處理堆疊(stack)中最上面執行環境。一旦最上面的環境執行完畢,該執行環境就會被移除,以此類推。

理解了執行環境的堆疊之後,下一步就可以說明什麼是「拉升」(hoisting)。

什麼是拉升 hoisting?

先說結論,比較「粗淺易懂」的回答是: 拉升是執行環境登記「這裡宣告了哪些變數」的行為。

functionvar作為關鍵字來宣告的情況下,會產生下列兩點現象:

  1. 變數、函式的「宣告」會被拉升
  2. 只有「宣告」會拉升,「賦值」不會拉升

接著,再用下列範例來解釋「拉升」的細節。

變數

宣告的拉升

第一個範例,使用log印出一個尚未宣告的變數:

console.log(a)
// ReferenceError: a is not defined
// a 還沒有被宣告

執行環境:「我沒看到你有宣告變數a啊」,因此拋出Error:a 還沒有被宣告。

ReferenceError的意思是,記憶體當中找不到任何宣稱是a變數的位址。 對執行環境而言,只要看到宣告變數的程式碼,例如var a,就會在記憶體中標記:「留一塊地給a。」

因此,只要執行環境找不到任何一塊地是a的,就會拋出錯誤:「沒有a變數的地址,你沒宣告啦!」

那麼,我在log下一行宣告a變數,會發生什麼事呢?

console.log(a) // undefined
var a

這次,執行環境看到程式碼有宣告變數a了,於是在記憶體上圈出一小塊地:「這塊地是a的。」並將型別預設為undefined

剛接觸JS的新人,看到這一段code會滿滿的不適應:「程式碼不是應該由上往下,一行一行執行下來的嗎?怎麼我是在第2行才宣告,JS就先圈出一塊地給a了?」

別忘了一開始提到的執行環境,在執行程式之前,執行環境會先建立「詞彙環境」,把環境中宣告的變數先登記下來、發配記憶體,這個紀錄的動作就是「拉升」。等環境建立完成了,才會一行一行往下執行。

因此,下面這張圖不論是左邊或右邊,對執行環境來說都是一樣的。

下面再來一個執行函式例子:

function printName() {
  name = "Harry";  console.log(name); // Harry  var name;}

printName()

一樣是閱讀上很違反直覺的範例:「第4行才想起來要補上宣告,但2、3行就存取,而且都沒有跳錯,什麼鬼啊!」

在進入printName()之前,全域執行環境會先為printName函式建立一個printName()執行環境printName()的詞彙環境(Lexical Environment)看到了var name,把變數登記在「環境變數宣告記錄表」裡面,分配記憶體給name,型別預設為undefined。這個環境變數宣告記錄表,在此先簡寫為DeclEnvRec(Declarative Environment Record)。

進入執行階段,首先看到變數name被賦值為字串"Harry"。下一行console.log,理所當然印出name的值為Harry。

這個例子主要說明,var拉升在程式碼閱讀上「看起來」是可以先存取,再宣告的。但背後的原因其實是JS「先建置環境,後執行」的邏輯。

賦值不會拉升

現在,我要a變數的值是10:

console.log(a) // undefined
var a = 10

可是..等等,不是拉升了嗎?怎麼a還沒有被賦值呢? 因為「環境變數宣告記錄表」只登記這裡宣告了哪些變數,並預設為undefined,賦值是等到「執行階段」一行一行執行下來,且執行到「賦值的那一行」才會做的。

站在JS的角度來看,畫面是這樣的:

所以,以var來說,若要賦值,還是乖乖寫在log上面吧!

var a = 10
console.log(a) // 10

進階變化

function run(n){  console.log(n) // ?
  var n = 2
}
run(10)

這裡你可能懵了,執行環境是怎麼看待「參數」呢?

執行環境環境來說,參數也是宣告的變數。run函式執行環境是這樣看的:

環境建立 階段

「參數」(Arguments)是函式的執行環境特別的物件,它包含0~n個參數,以及參數的長度(length)。

run()執行環境的「環境建立」階段,Arguments紀錄了run函式有1個參數n。比較特殊的是,由於參數的值是從外部傳進來的,在環境建立的階段已知n=10

run函式裡面也另外宣告了一個var n。兩個n要怎麼登記在「環境變數宣告記錄表」呢?對「環境變數宣告記錄表」來說,相同的變數它只會登記一個,因此只會有一個n

那麼n的值呢?在環境建立階段,已知參數n的值為10,而n=2是要等到「執行階段」執行到第3行,才會賦值。因此,目前n的值為10

環境執行 階段

進入函式執行後,第2行遇到了console.log(),目前n=10,因此log印出來為10。 第3行遇到n=2,於是將n賦予2的值,在「環境變數宣告記錄表」中,n=2


回到「函式的執行環境是怎麼看待參數呢?」這個問題,在環境建置的階段來說,參數也是這個環境宣告的變數之一,會登記在「環境變數宣告記錄表」當中。由於參數是外部環境傳進來的,因此在「環境建立階段」就已知道它的值。唯有「參數」會在「環境建立階段」賦值,其餘宣告只會先登記,直到「執行階段」執行到那一行才會賦值。

換言之,「除了參數之外,宣告會拉升,但賦值不會拉升」。

下一題的變化,觀念也是一樣的:

var v = 3
var v
console.log(v) // ?

有些人會猜,v可能是undefined。第二個v蓋過第一個v,那v不就是undefined了嗎?

但掌握「宣告會拉升,但賦值不會拉升」原則就會理解,「賦值」是執行階段才會做的事情。 對執行環境而言,它是這樣看的:

這樣即可很容易理解,v的值為什麼是3了。

function

函式的宣告也會被拉升。

console.log(a)
var a
function b(){}

全域執行環境看到這段code,會先在「環境變數宣告記錄表」登記變數ab

globalEC = {
  lexicalEnv: {
    DeclEnvRec: {
      a: undefined,
      b: function
    }
  }
}

函式的拉升基本上沒什麼陷阱要注意,但以下範例可以當作冷知識,看看就好了:

console.log(a) // [Function: a]
var afunction a(){}

同時宣告變數a以及function a()的時候,JS會默認「function的權限比較高」,所以上面的log會印出它是function,而不是預設的undefined

既然hoisting這麼多地雷,那當初設計hoisting是為了什麼?

<我知道你懂 hoisting,可是你了解到多深?>的作者寫得很好:「可以反過來想,如果沒有拉升會怎樣?」

一、變數一定要先宣告才能使用

不能「先存取變數,後來才想起來要宣告變數」,例如下面這樣違反閱讀習慣的行為:NONO!

a = 10
console.log(a)
var a

嗯,很好!變數先宣告才能使用是好習慣。因此es6的letconst補上了這一點。

二、函式也要先宣告才能呼叫

嗯~有點麻煩,這樣每次要呼叫函式的時候,都要努力將滑鼠滾到上面,找出自己需要呼叫的函式。 但也不是不行啦!上面都是function,下面都是執行的程式,看起來也滿整齊的啊!

三、沒辦法做到函式彼此之間互相呼叫

這裡需要舉個例子:

var students = [
  {
    name: "Eren Jaeger",
    gender: "male"
  },
  {
    name: "Mikasa Ackerman",
    gender: "female"
  },
  {
    name: "Armin Arlert",
    gender: "male"
  },
  {
    name: "Reiner Braun",
    gender: "male"
  },
  {
    name: "Historia Reiss",
    gender: "female"
  },
]

function printAll(stu){
  var arr = stu.map(i => i)
  if (arr.length > 0) {
    logDetail(arr)  }
}

function logDetail(arr) {
  console.log(arr[0].name + ', ' + arr[0].gender)
  arr.shift()
  printAll(arr)}
  
printAll(students)

printAll()裡面呼叫了logDetail(),又在logDetail()裡面呼叫了printAll()。如果函式的宣告都要放在呼叫的上面,那麼根本做不到互相呼叫。

這就是為什麼拉升在JS當中仍然是必要的,對function來說尤其重要。

let, const有沒有拉升?

先在檯面上覆蓋一張考前(?)複習表:

自從es6推出了letconst,網路上已有非常多文章解釋varletconst的差異。

多數人的既定印象,包括過去的我自己,都抱有簡單的結論:用letconst來宣告變數,就沒有「拉升」的現象啦!var是屬於舊時代的冷知識,新專案也不再使用var來宣告變數了。彷彿用了letconst就揮別了hoisting的惡夢。

沒有拉升?就結果來看是這樣沒錯。反正知道進一步的細節對於完成專案似乎沒有什麼幫助

先來一個letvar的基礎比較:

console.log(a) // undefined
var a

/**
 * 有拉升,先預設為undefined
 * /
console.log(a) // ReferenceError: a is not defined
let a

/**
 * 沒有拉升,a還沒有被宣告,找不到地址
 * /

就上面的比較來說,let確實是沒有被拉升的。 letconst一定要先宣告才能使用,這確實是解決很多痛點的改良。

但下面是一個很故意的例子:

var x = "global"var y = "global"
function run(){
	console.log(x) // ?
	console.log(y) // ?

	var x = "local"	let y = "local"}

run()

在全域,變數xy都已宣告,並賦予了字串"global"的值。 進入了run()函式之後,x, y又重複宣告,並賦予字串"local"的值,只是宣告的位置在log下方。 此時,log印出的xy,分別是什麼?

為了避免混亂,以下先解釋執行環境怎麼看變數x

var x = "global"// var y = "global"

function run(){
    console.log(x)
    // console.log(y)

    var x = "local"    // let y = "local"
}

run()

run()執行環境正在建立時,它會登記run()函式當中宣告了一個變數x。 進入執行階段後,對於尚未賦值的變數通通預設為undefined。 取值時,run()執行環境會先尋找環境內有沒有x,有的話,直接取用環境內的x(沒有的話,再向外尋找x)。 因此log印出來,xundefined,合情合理。

接著,重頭戲是,執行環境怎麼看變數yrun()當中的let y真的沒有拉升嗎?

// var x = "global"
var y = "global"
function run(){
	// console.log(x)
	console.log(y)

	// var x = "local"
	let y = "local"}

run()

如果let y真的沒有拉升,那麼log印出來的值,就應該"global"。如下圖:

如果let y沒有拉升,那麼run()執行環境在建立時不會先登記y。 而開始運行後,第一行看見y,因尚未登記,run()執行環境會向外尋找y的值,然後在全域執行環境找到y = "global"

但實際上y印出來的,卻是ReferenceError,這到底怎麼回事呢?

其實,letconst一樣是有拉升的,只是在「賦值」之前,它們是不能被使用的:

run()執行環境建立時,一樣會先登記環境裡宣告了哪些變數。與var的差別在於,執行環境並不會給未賦值的let預設undefined,而是先設定為「尚未初始化,不能使用」。直到let賦值了,才能存取。

因此,若對「尚未初始化」的let進行存取,JS在執行時會直接拋出錯誤ReferenceError

簡單來說,varlet/const/class的宣告差別在於「初始化」。 var在賦值之前,會初始化為undefined。而let/const/class則保持「未初始化」的狀態,無法存取值。當試圖存取,會拋出ReferenceError的錯誤。

所以,回到那張考前複習表,與其說let/const沒有拉升,更貼切的說法應該是:「拉升時,沒有初始化。」

最後,Huli大神在<我知道你懂 hoisting,可是你了解到多深?>內文提到,hoisting要追根究柢,分為地下10層。小妹我目前停留在地下5層,若想進一步往深淵走去,請移駕參考Huli大神的文章。

Summary

  • 拉升是執行環境登記「這裡宣告了哪些變數」的行為。在執行程式之前,執行環境會先建立「詞彙環境」,把環境中宣告的變數先登記在「環境變數宣告記錄表」、發配記憶體,這個紀錄的動作就是「拉升」。等環境建立完成了,才會一行一行往下執行。
  • 不論是varletconst,都有拉升的行為。
  • 宣告會拉升,但賦值不會拉升。
  • 對「環境變數宣告記錄表」來說,命名相同的變數它只會登記一個。
  • 函式的參數對函式的執行環境來說,也是這個環境宣告的變數之一,會登記在「環境變數宣告記錄表」當中。由於參數是外部環境傳進來的,唯有「參數」會在「環境建立階段」賦值,其餘宣告只會先登記,直到「執行階段」執行到那一行才會賦值。換言之,除了參數之外,宣告會拉升,但賦值不會拉升。
  • 拉升在JS當中是必要的,對function的宣告及呼叫尤其重要。
  • varlet/const/class的宣告差別在於「初始化」。var在賦值之前,會初始化為undefined。而let/const/class則保持「未初始化」的狀態,無法存取值。在賦值之前試圖存取,會拋出ReferenceError的錯誤。

Reference