JavaScript: Scope & Hoisting


Posted by Nicolakacha on 2020-09-29

這篇主要會介紹 JavaScript 作用域(Scope)和提升(Hoisting)的觀念,引用的範例為了方便理解都偏簡單,但真正遇到 JavaScript 作用域和提升問題時,還是可以仰賴本篇核心的觀念來理解。

Scope 初探

Scope 就是作用域,指的是一個變數的生存範圍,在 ES6 以前,變數 var 的作用域是 function。

在 function 內宣告的變數稱為區域變數(Local Variable),而不在 function 內宣告的變數則稱為全域變數(Global Variable)。

作用域是往上找的

全域變數會被整個程式碼的任何部份給讀取到,也會被 function 讀到,而在作用域內的區域變數就只會在作用域內有效。

下列程式碼中,console.log(a) 會在作用域裡面找有沒有 a 這個變數,若找不到,就往上一層找,找到了全域變數 var a = 10,所以印出來 10:

var a = 10; //這是全域變數
function test(){
    console.log(a);
}

test();
// 10

再看一個例子:

var a = 20;
function test() {
    var a = 10;
    console.log(a);
}

test(); // 10
console.log(a); //20

console.log(a) 在自己的作用域內找到變數 a ,所以印出 10,而外面的 console.log(a) 則找到全域變數 a,印出 20。

再來一個例子吧:

var a = 20;
function test() {
    a = 10;
    console.log(a);
}

test(); // 10
console.log(a); //10

test() 裡面將全域變數 a 重新賦值為 10,所以兩個 console.log(a) 印出來都會是 10。

如果作用域內沒有宣告 a 變數,卻把 a 賦值為 10,JavaScript 會自動幫我們在全域宣告一個全域變數 var a = 10

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

test(); // 10
console.log(a); //10

再看一個例子:

var a = 'global';

function change() {
    var a = 10;
    test();
}

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

change(); //global

這裡印出來的是 global,我們只是在 change() 裡面呼叫 test(),但 test() 並不是在 change() 裡被宣告的。所以 test() 往外層找 a,找到的會是全域變數 var a = 'global'

Function Scope 可能發生的問題

狀況一:若 var 變數不是宣告在 function 作用域內,而是在迴圈或是判斷式,這個變數可能就會覆蓋到外面的全域函數。

下面的程式碼中,if 判斷式裡面的變數 str,覆蓋了外面的 str,所以印出 Hello:

var str = 'Origin'
if (true) {
  var str = 'Hello';
}
console.log(str);

狀況二:循環變數可能會洩漏成全域變數。

for () 裡面的 i,在迴圈跑完的時候,蓋過了外面的全域變數,使 console.log(i) 被重新賦值成 5:

var s = 'Hello';
var i = 1;
for (var i = 0; i < s.length; i++) {
    console.log(s[i]);
}
console.log(i);

為了解決這種 function scope 的變數之亂,在 ES6 裡面新增了 block 的概念。

ES6 以後的 Scope:以 block 為單位

在 ES6 新增了區塊 (block) 的概念,並有了兩種新的變數宣告方式:let 和 const,它們的共通點都是以 block 來做為作用域範圍,所謂 block 就是程式碼中任何看得到的 {} 內,或是 for 迴圈和 if 判斷式中的 () 內也算。

此外,let 和 const 最大的區別,在於該變數是否能被重新賦值,使用 const 宣告之後之後就不能再重新賦值了,所以一開始宣告時就一定要賦值,而 let 可以被重新賦值,但又不會像 var 一樣汙染到外部的變數。

function test() {
    var a = 1;
    if (a === 1) {
        let b = 10;
    }
    console.log(b);
}

test(); // b is not defined

在上面的例子中,變數 b 的生存範圍只存在於 if ( a === 1) {} 的 block 裡面,所以在 function 內 console.log(b) 會得到:b is not defined。

function test() {
    var a = 1;
    if (a === 1) {
        var b = 10;
    }
    console.log(b);
}

test(); // b is not defined

如果像這樣改成用 var 宣告變數,作用域就會是整個 function,所以 console.log(b) 印出來就是 10 了。

Hoisting 初探

提升(Hoisting)是在變數宣告時發生,不多說,直接來看一個簡單的範例:

console.log(b);
var b = 10;

======= 經過提升後 =======

var b;
console.log(b)
b = 10;

// undefined

上面兩段程式碼是一樣的,當我們宣告一個變數時,宣告本身會被提升至程式碼最上面,而賦值則留在原地,所以上面的 console.log(b) 才會印出 undefined。

我們可以在宣告一個 function 以前就呼叫它,這是因為整個 function 會被提升到上面,也是一種 Hoisting 的展現:

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

//123

只有宣告會被提升,賦值不會

先前有說,只有宣告會被提升,賦值不會,所以下列程式碼的提升會像這樣,宣告被提升上來了,賦值留在原地:

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

======= 經過提升後 =======

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

// test is not a function

Hoisting 的順序問題

研究 Hoisting 的順序以前,先來看一下這個簡單的範例:

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

test();

======= 經過提升後 =======

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

test();
// undefined

console.log() 在執行時,底下宣告的變數 a 會被提升上來,所以印出來才會是 undefined。

如果是同時宣告一個 function 和變數,function 的提升會優先於變數的提升

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

    };
    var a = 'local';
}

test();

======= 經過提升後 =======

var a = 'global';
function test() {
    function a () {

    };
    // var a; 提升沒用惹
    console.log(a);
    a = 'local';
}

test();
// [Function a]

如果有兩個重複的 function,會依順序來提升比較後面宣告的 function

function test() {
    a();
    function a () {
        console.log(1);
    };
    function a () {
        console.log(2);
    };
}

======= 經過提升後 =======

function test() {
    /** 提升沒用惹
    function a () {
        console.log(1);
    };
    **/
    function a () {
        console.log(2);
    };
    a();
}

test() // 2

當 function 有被傳參數的時候,傳進的參數就是 argument,而 argument 的宣告會優先於被提升的變數宣告

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

test(123);

======= 經過提升後 =======

function test(a) {
    // var a; 我沒用惹
    console.log(a);
    a = 456;
}

test(123);

// 123

而提升的 function 宣告則會優先於 argument 的宣告:

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

test(123);

======= 經過提升後 =======

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

test(123); // 我傳的參數被無視惹

// [Function a]

argument 和 function 宣告的提升只會優先於「 變數宣告的提升」,而不是蓋過「變數宣告並賦值」的程式碼:

function test(a) {
    console.log(a);
    var a = 'hello';
    console.log(a);
    function a () {
            console.log(2);
    };
}

test(123);

======= 經過提升後 =======

function test(a) {
    function a () {
            console.log(2);
    };
    console.log(a); // 印出 function a 
    var a = 'hello';
    console.log(a); // 上有變數宣告賦值,其他都不重要,印出 hello
}

test(123); // 有 function 提升,我被無視了

// undefined

因此我們可以知道,Hoisting 的優先順序是:

  1. 函式的宣告(function declaration )
  2. 傳進函式的參數(argument object)
  3. 變數宣告(variable declaration)

最後再看一個例子來熟悉 Hoisting 順序觀念:

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

    };
  var a;
  console.log(a);
  var a = 456;
  console.log(a);
}

test(123);

第一個 console.log(a) 因為 function a 的 hoisting 優先於傳進函式的 argument 123,所以印出 function a。

第二個 console.log(a),雖然前面有一個變數宣告 a,但並未賦值,所以依 hoisting 的優先順序還是 function a,印出 function a。

第三個 console.log(a),因為前面 var a = 456 是宣告變數賦值,所以完全不關 function a 提升的事,印出 456。

Hoisting 牛刀小試:

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);

解答:

// 1. undefined

// 2. 7

// 3. 8

// 4. 30

// 5. 1

// 6. 70

// 7. 200

console.log('1.', a); 的 a 是 undefined,是因為底下的變數宣告 var a 會提升上來。

console.log('2.', a); 的 a 是 7,因為上面有 var a = 7,宣告變數 a 並賦值為 7。

console.log('3.', a); 因為 inner() 本身裡面沒有宣告 a,所以這裡的 a 就會去找外層宣告過的 var a =7 ,因為後來又經過 a++ 把這個 a 重新賦值為 8,所以這裡的 a 是 8。

另外可以發現 b = 200 ,因為外層沒有 b ,所以當 inner() 函式被呼叫的時候,會宣告成一個全域變數 b 並賦值為 200。

console.log('4.', a); 因為剛才呼叫 inner() 時,a = 30 會把外層 test() 的 a 變數重新賦值為 30,所以這裡的 a 會是 30。

console.log('5.', a); 這裡的 a 和 test() 裡的 a 沒有關係,因為這一層已經有一個全域變數 var a = 1 了,所以這裡的 a 是 1。

console.log('6.', a); 因為上面 a = 70 把 a 變數重新賦值了,所以這裡的 a 是 70。

console.log('7.', b); 記得剛才呼叫 inner() 時宣告了一個全域變數 b 並賦值為 200 嗎?所以這裡的 b 是 200。

Hoisting 背後的原理

為了理解 Hoisting 的原理,先用極短的篇幅來說明一下 Execution Context 和 Variable Object 兩個概念。

執行環境(Execution Context)

JavaScript 執行時,會先建立一個執行環境(Execution Context,簡稱 EC),一支 .js 檔案最初建立的 EC 被稱為 Global EC。

當一個 function 被宣告、呼叫或執行,都會建立一個新的 local EC,並放進 JavaScript 的執行堆疊(Call Stack)裡,最新被宣告的 funcion 就會在執行堆疊最上面。

變數物件(Variable Object)

EC 會關聯一個變數物件(Variable Object,簡稱 VO),在 EC 裡的變數和 function 都會被加到這個 VO 內,對 function 所產生的 EC 而言,參數也會被加到 VO 內。

當進入到一個 EC 時

參數:

  • 把參數放進 VO
  • 沒有傳的參數會被初始化成 undefined

function 宣告:

  • 把 function 宣告放進 VO
  • 如果 VO 已經有個同名的 property,則取代之

變數宣告:

  • 把變數宣告放進 VO,並初始化成 undefined
  • 如果 VO 已經有重複的同名 property,該 property 所指向的值和屬性都不會改變
  • 如果變數宣告和函式同名,則不改變先前的 property

範例程式碼:

// 範例用的程式碼
function test(a, b) {
    function b() {
    };
    var c = 30;
}

test(123);

VO 模型:

VO: {
    a: 123,   // 把參數放進 VO,初始化成 123
    b: function , // 把 funcion b 放進 VO
    c: undefined // 把變數放進 VO,初始化成 undefined

來看一個簡單的範例,以這段程式碼來說:

function test(a) {
    console.log(a);
    var a = 'hello';
    console.log(a);
    function a () {
            console.log(2);
    };
}

test()

初始化的 VO 是這樣的:

test EC
test VO {
    // a: undefined 被取代掉
    a: function
}

global EC
global VO {
    test: function a
}

開始執行程式之後:

function test(a) {
    console.log(a); // 參考 VO,這個 a 是 function a 
    var a = 'hello'; // a 被重新賦值為 hello
    console.log(a); // a 是 hello
    function a () {
            console.log(2);
    };
}

test();

所以只要掌握了 EC 和 VO 模型的技能之後,就可以解決任何 Hoisting 和 Scope 的問題了!

let 和 const 的 Hoisting

但上面的變數宣告都是 var,那 let 和 const 呢?

看下面這個例子,直到 a 被賦值以前,都不能去存取 a,一旦存取 a,就會出現 a is not defined 錯誤。

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

test();

====== 經過提升之後 ======

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

test();

// a is not defined

暫時性死區(Temporal Dead Zone,簡稱 TDZ)

也就是在宣告變數到賦值之前的時間,如果存取了該變數,都會報錯,這個時間區間通常被稱為暫時性死區(Temporal Dead Zone,簡稱 TDZ)。

總結

到這裡 Scope 和 Hoisting 的核心概念都理解完了,但還有很多可以延伸探究的細節,也可以多查查 MDN 或 EMCAScript 的文件來進一步了解。

參考資源

[JS201] 進階 JavaScript:那些你一直搞不懂的地方

我知道你懂 hoisting,可是你了解到多深?by Huli

所有的函式都是閉包:談 JS 中的作用域與 Closure by Huli


#javascript #scope #hoisting







Related Posts

一步一步會留下足跡,一言一行會留下成績

一步一步會留下足跡,一言一行會留下成績

【THM Walkthrough】Breaching Active Directory

【THM Walkthrough】Breaching Active Directory

var、let 、const

var、let 、const


Comments