JS Advanced --Hoisting


Posted by s103071049 on 2021-07-27

為甚麼需要 Hoisting ?

  1. 可以不宣告 function 就 call function
  2. function 可以互相呼叫 (for mutual recursion & generally to avoid painful bottom-up ML-like order)

從 Hoisting 理解底層運作機制

什麼是 hoisting(提升)?

沒辦法 log 一個沒有宣告的變數

console.log(b) //ReferenceError: b is not defined

這個現象叫提升。只有宣告會提升,賦值不會。

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

雖然實際不是這麼跑,可是可以想成

var b
console.log(b)
b = 10

可以在宣告 function 以前就直接呼叫他,可以先呼叫再宣告。不是所有程式語言都做得到這件事情。也是一種提升,因為在宣告以前就用它。

test() // 123
function test() {
  console.log(123)
}

在狀況二底下可以拆成變數宣告與賦值兩個步驟,只有變數宣告會被提升到上面去,賦值不會。所以狀況二就是狀況三的樣子,不同寫法但等價。

// 狀況一
var test = function test() {
  console.log(123)
}

test() // 123
// 狀況二
test() // TypeError: test is not a function

var test = function test() {
  console.log(123)
}
var test // test 是 undefined 他不是一個 function
test() // TypeError: test is not a function

test = function test() {
  console.log(123)
}

一個一個來:hoisting 的順序

hoisting 是和變數有關,所以 hoisting 只會發生在 scope 裡面。
狀況一的程式碼可以看成狀況二

// 狀況一
var a = 'global'
function test() {
  console.log(a)
  var a = 'local'
}

test() // undefined

將他拆成兩個部分,宣告變數與賦值。只有宣告變數會提升到上面去。

// 狀況二
var a = 'global'
function test() {
  var a 
  console.log(a)
  a = 'local'
}

test() // undefined

提升的順序到底和甚麼有關呢 ? 顯然 function 佔有優先提升權,可以將下面程式碼看成狀況三。
提升優先度:function > argument > var

function test() {
  console.log(a)
  var a = 'local'
  function a() {

  }
}
test() // [Function: a]
=======

function test() {
  console.log(a)
  function a() {

  }
  var a = 'local'
}

test() // [Function: a]
// 狀況三
function test() {
  function a() {

  }
  console.log(a)
  a = 'local'
}

如果同時宣告兩個相同名稱的,後面宣告的會蓋掉前面的。

function test() {
  console.log(a)
  a() // 2
  function a() {
    console.log(1)
  }
  function a() {
    console.log(2)
  }
  var a = 'local'
}

test() // [Function: a]

狀況四、狀況五,a 都還是 123,因為在 function 內已經有引數 a,所以他就不會理var a

// 狀況四
function test(a) {

  console.log(a)
  var a = 456
}

test(123) // 123
// 狀況五
function test(a) {
  var a // 宣告 a 但沒有初始化或賦值
  console.log(a)
  a = 456
}

test(123) // 123

如果今天換成一個同名的 function,會被蓋過去嗎 ? 會

function test(a) {
  console.log(a) 
  function a() {

  }
}

test(123) // [Function: a]

易混淆

因為已經宣告,所以有寫沒寫 var a 答案是一樣的。

function test(a) {
  var a = 'test' 
  var a // 我要宣告變數 a
  var a
  console.log(a)
}

test() // 'test'

我已經宣告變數 a 只是沒有給他值,所以他會是 undefined

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

test() // undefined

hoisting 順序總結

  • function > argument > var
  • 留意執行順序
function test(a) {
  var a 
  console.log(a) // 123
  var a = 456
  console.log(a) // 456
}

test(123) // 123 456

hoisting 的原理為何?從 ECMAScript 下手

前測:

var a = 1;
function test(){
  console.log('1.', a); // 1 錯誤=> undefined
  var a = 7;
  console.log('2.', a); // 7
  a++;
  var a; // 8
  inner();
  console.log('4.', a); // 30
  function inner(){
    console.log('3.', a); // undefined 錯誤=> 8
    a = 30;
    b = 200;
  }
}
test();
console.log('5.', a); // 1
a = 70;
console.log('6.', a); // 70
console.log('7.', b); // undefined 錯誤=> 200

先從 test( ) 看起

  1. console.log('1.', a) ,上面有 var a = 1
  2. 下面沒有 a 名稱的 function
  3. 下面有宣告變數 a :var a = 7;,所以 a 會 hoisting 到上面 => 所以 a 的值會是undefined
  4. var a = 7; a 的值變成 7,所以 console.log('2.', a); // 7
  5. a++ ,a 的值變成 8
  6. var a ,沒有作用,因為前面已經宣告過
  7. 執行 inner()
  8. inner 本身沒有 a 他只有賦值,所以她的 a 會是在上層 test 宣告的 a,所以 console.log('3.', a); //8
  9. log 後將 a 的值變成 30,因為 b 都沒有被宣告,所以 b 會變成全域變數
  10. console.log('5.', a) //1,因為 test scope 的 a 與外面的 a 沒有影響,所以看 global scope,確定 a = 1
  11. a = 70; a 的值變成 70,所以 console.log('6.', a); // 70
  12. console.log('7.', b); b 是全域變數 200

ECMAScript 是 js 所遵循的標準。在 ES6 名詞變得不太一樣,但原理差不多。

  • execution contexts (執行環境)
    執行環境就是一個 stack。可以先將 executable code 想成是 function。當進入 function 他又會進入新的 execution contexts,當我又進去一個又會多一個。一旦結束執行 function 他就會將東西拿掉。

  • 變數初始化
    可以將 variable objects 想成 js 物件。依照宣告的順序將變數綁在 variable object 上。

可以簡單的這樣想像

variable_obj = {
  a: 1
}

function test() {
  var a = 1
  console.log(a)
}

進入執行環境,對於 function 的每個參數,他會把參數放到 variable objects 上面。他的 value 取決於 call 進的值。如果沒有傳的參數就會初始化為 undefined。

variable_obj = {
  a: 123
  b: undefined
}

function test(a, b) {

}

test(123)

對於 function 的宣告,作法一樣。

如果 variable objects 已經有這個 property,他會 replace 他的 value,

variable_obj = {
  a: pointer to function a
  b: undefined
}

function test(a, b) {
  function a() {

  }
}

test(123)

對於變數的宣告,一樣會將他加入 variable objects 裡面,然後初始化為 undefined,如果 variable objects 裡已經有重複的東西,他的值不該被改變。

因為 a 跟 b 都已經有值,所以就不理他。

variable_obj = {
  a: 123
  b: pointer to function a
  c: undefined
}

function test(a, b) {
  function b() {
    var a = 10
    var b = 20
    var c = 30
  }
}

test(123)
  • 心得:進入 function 就產生執行環境就開始出理 variable objects 的初始化。
  • 執行模型:
  1. 把參數放到裡面,你傳甚麼他就是甚麼
  2. 把 function 放到裡面,如果存在同名的值,就把它蓋掉
  3. 變數宣告:如果不在裡面就把它放進去並初始化為 undefined

體驗 JS 引擎的一天,理解 Execution Context 與 Variable Object

很多種直譯器內部的運作方式都是先把原始碼編譯成某種中間碼再去執行,所以編譯這個步驟還是很常見的,而 JS 也是這樣運作的。

hoisting 其實就是在編譯這個階段做處理的。引入了編譯階段以後,可以把 JS 分成編譯階段跟執行階段兩個步驟

編譯階段的時候,會處理好所有的變數及函式宣告並且加入到 scope 裡面

var a = 1;
function test(){
  console.log('1.', a);
  var a = 7;
  console.log('2.', a);
  a++;
  var a;
  inner();
  console.log('4.', a);
  function inner(){
    console.log('3.', a);
    a = 30;
    b = 200;
  }
}
test();
console.log('5.', a);
a = 70;
console.log('6.', a);
console.log('7.', b);

進去 execution context

  • first step : 找參數,但執行檔案不是 function 所以找不到
  • second step : 找 function 的宣告,找到了 test()
  • third step : 找變數宣告,找到了 var a = 1;
global execution context (EC)

global_variable_object : {
  test: func,
  a: undefined
}

執行 code,執行第一行 a 換成 1、function 宣告跳過、進去 test()

當呼叫一個 function 時就進去一個新的 execution context。一樣按照上面的三個步驟進行初始化。

test execution context
test_variable_object : {
  inner: func,
  a: undefined
}


global execution context (EC)

global_variable_object : {
  test: func,
  a: 1
}

將 test 的 variable object 初始化好後,執行程式碼

  1. console.log('1.', a); => a 是 undefined
  2. var a = 7 => a 的值改成 7
test execution context
test_variable_object : {
  inner: func,
  a: 7
}


global execution context (EC)

global_variable_object : {
  test: func,
  a: 1
}
  1. console.log('2.', a); => a 是 7
  2. a ++ => a 變成 8
test execution context
test_variable_object : {
  inner: func,
  a: 8
}
  1. var a => 已經宣告過 a 所以 test_variable_object 裡的 a 值就不會變
  2. inner() => 進去一個新的 execution context、初始化
inner execution context
inner_variable_object: {

}

test execution context
test_variable_object : {
  inner: func,
  a: 8
}


global execution context (EC)

global_variable_object : {
  test: func,
  a: 1
}
  1. 初始化檢視三步驟,其中,inner function 中沒有傳任何參數
inner execution context
inner_variable_object: {

}
  1. console.log('3.', a); a 往上找找到 test_variable_object 這邊的 a 是 8,所以 console.log('3.', a) => a 是 8
  2. a = 30; 因為 inner_variable_object 沒有 a 所以往上找,test_variable_object 這邊的 a 就變成 30
  3. b = 200,因為 inner_variable_object 沒有 b 所以往上找,test_variable_object 也沒有 b 再往上找,global_variable_object 也沒有 b,所以只好將 b 放在這邊
global execution context (EC)

global_variable_object : {
  test: func,
  a: 1
  b: 200
}
  1. inner 執行完後,inner execution context、inner_variable_object 就不見了
  2. 回到 test console.log('4.', a); a 就是 30
test execution context
test_variable_object : {
  inner: func,
  a: 30
}

global execution context (EC)
global_variable_object : {
  test: func,
  a: 1
  b: 200
}
  1. test 執行完 test execution context、test_variable_object 就掰掰了
global execution context (EC)
global_variable_object : {
  test: func,
  a: 1
  b: 200
}
  1. 回到 global console.log('5.', a); => a 就是 1
  2. a = 70; => a 變成 70
global execution context (EC)
global_variable_object : {
  test: func,
  a: 70
  b: 200
}
  1. console.log('6.', a); => a 就是 70
  2. console.log('7.', b); => b 就是 200
  3. 全部執行完,global execution context、global_variable_object 也不見

執行模型:execution context、variable_object、初始化

variable_object 只有在存取值的時候才會用到。參數優先序是第一,第二是 function,最後才是變數。

  1. 對於參數,它會直接被放到 variable_object 裡面去,如果有些參數沒有值的話,那它的值會被初始化成 undefined

  2. 對於 function 的宣告,一樣在 variable_object 裡面新增一個屬性,至於值的話就是建立 function 完之後回傳的東西(可以想成就是一個指向 function 的指標就好)。如果 variable_object 裡面已經有同名的屬性,就把它覆蓋掉'

  3. 對於變數,在 variable_object 裡面新增一個屬性並且把值設為 undefined,再來是重點:「如果 variable_object 已經有這個屬性的話,值不會被改變」

  • 舉例:var a 去 variable_object 新增一個屬性叫 a (如果沒有 a 這個屬性),並初始化為 undefined,若之後出現 a = 10 則在 variable_object 裡面找到 a 這個屬性,並設定為 10。variable_object 如果找不到屬性,會透過 scope chain 不斷往上尋找,如果每一層都找不到就會拋出錯誤。

小結、

當我們在進入一個 EC 的時候(你可以把它想成就是在執行 function 後,但還沒開始跑 function 內部的程式碼以前),會按照順序做以下三件事:

  1. 把參數放到 variable_object 裡面並設定好值,傳什麼進來就是什麼,沒有值的設成 undefine
  2. 把 function 宣告放到 variable_object 裡,如果已經有同名的就覆蓋掉
  3. 把變數宣告放到 variable_object 裡,如果已經有同名的則忽略

每個 function 你都可以想成其實執行有兩個階段,第一個階段是進入 EC,第二個階段才是真的一行行執行程式。所以我們每次 call function 前會先初始化 variable_object,接著才一行一行執行 code。

let 與 const 的詭異行為

回憶 hoisting

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

以為 let const 沒有 hoisting。

console.log(b)
let b = 10 // ReferenceError: Cannot access 'b' before initialization

可以確認 let const 有 hositing,因為沒有 hoisting => a 是 10

let a = 10
function test() {
  console.log(a)
  let a = 39
}

test() // ReferenceError: Cannot access 'a' before initialization
  • 結論、let const 有 hosting 但底層的運作方式與 var 不同

TDZ:Temporal Dead Zone

狀況一的程式碼可以看成狀況二

// 狀況一
let a = 10
function test() {
  console.log(a)
  let a = 39
}

test() // ReferenceError: Cannot access 'a' before initialization

使用 let 或 const 直到賦值前我都不能存取 a,一旦在賦值前存取 a 就會出現 ReferenceError。進入 function 到賦值前,這段區間叫 TDZ (Temporal Dead Zone),暫時性死區。這也是 let const 與 var 不同之處。

一旦在 TDZ 內就無法存取變數的值。TDZ 並不是一個空間上的概念,而是時間。

// 狀況二
let a = 10
function test() {
  let a // a 的 TDZ 開始
  console.log(a)
  a = 39 // a 的 TDZ 結束
}

test() // ReferenceError: Cannot access 'a' before initialization

let 與 const 確實有 hoisting,與 var 的差別在於提升之後,var 宣告的變數會被初始化為 undefined,而 let 與 const 的宣告不會被初始化為 undefined,而且如果你在「賦值之前」就存取它,就會拋出錯誤。

let 與 const 也有 hoisting 但沒有初始化為 undefined,而且在賦值之前試圖取值會發生錯誤。


#JS Advanced --Hoisting #hoisting







Related Posts

DAY 07 : 圖形

DAY 07 : 圖形

VS Code 中文套件導致 TypeScript 偵錯問題

VS Code 中文套件導致 TypeScript 偵錯問題

調用棧(Call Stack)

調用棧(Call Stack)


Comments