你懂 JavaScript 嗎?#12 函式範疇與區塊範疇(Function vs Block Scope)

你所不知道的 JS

本文會提到

前言

前情提要,「範疇」(scope)是指編譯器或 JavaScript 引擎藉由識別字名稱查找變數的一組規則,而劃分範疇的單位可分為兩種-函式範疇與區塊範疇,也就是說,每個「函式」或「區塊」是可以建立各自的範疇。在 ES6 以前,只有函式能建立範疇,而在 ES6 之後,可用大括號 { ... } 定義區塊範疇,讓 const 和 let 宣告以區塊為範疇的變數。

鴨子轉圈圈

題外話,我一直覺得「鴨子轉圈圈」這張圖跟範疇很搭,就是把大家都匡在這裡嘛,好好待不要亂跑,想出去可是有條件的!

函式範疇(Function Scope)

函式會建立自己的範疇,其內的識別字(不管是變數、函式)僅能在這個函式裡面使用。

範例如下,在全域範疇底下,是無法存取 foo 內的 a、b、c 和 bar,否則會導致 ReferrenceError;但在 foo 自己的函式範疇內,可以存取 a、b、c 和 bar。

foo 可自由存取其內的 a、b、c 和 bar。

function foo(a) {
  var b = 2;

  function bar() {
    // ...
  }

  var c = 3;

  console.log(a); // 2
  console.log(b); // 2
  console.log(c); // 3

  bar();
}

foo(2);

全域範疇之下是無法存取 foo 內的 a、b、c 和 bar 的,但可存取 foo 喔!

function foo(a) {
  var b = 2;

  function bar() {
    // ...
  }

  var c = 3;
}

foo(2);

console.log(a); // ReferrenceError
console.log(b); // ReferrenceError
console.log(c); // ReferrenceError

bar(); // ReferrenceError

使用「函式範疇」有什麼好處?

很好!

使用「函式範疇」有什麼好處呢?或說解決什麼問題呢?大致上有這兩點…

最小權限原則

函式範疇能維持「最小權限原則」(principle of least privilege),或稱為「最小授權」(least authority)、「最小暴露」(least exposure),可防止變數或函式被不當存取。

範例如下,secretData 是 foo 的私有變數,可能是儲存了 foo 之外其他程式碼不需要知道的資料,因此對於其他地方(包含全域範疇)的程式碼來說,是無法直接存取到 secretData 的,只能透過 foo 公開的 API「bar」取得經過處理後的資料,如 publicData。這樣的好處是,除了 foo 之外是無法經由任何管道修改它的私有變數 secretData 的,可防止其他地方的程式碼的不當存取。

function foo() {
  var secretData = 'HelloWorld';

  function bar() {
    return secretData.split('').join('-');
  }

  return {
    bar
  }
}

var baz = foo();
var publicData = baz.bar();

console.log(publicData); // H-e-l-l-o-W-o-r-l-d
console.log(secretData); // Uncaught ReferenceError: secretData is not defined

避免衝突

避免同名變數或函式所造成的衝突。

如下範例,這裡有兩個函式 doSomething 與 doSomethingElse。

function doSomething(a) {
  b = a + doSomethingElse(a * 2);
  console.log(b * 3);
}

function doSomethingElse(a) {
  return a - 1;
}

var b;

doSomething(2); // 15

若此時還有一個同名的函式 doSomethingElse,就會導致衝突,回傳的答案就不是原本預期的 15,而是 12。

function doSomething(a) {
  b = a + doSomethingElse(a * 2);
  console.log(b * 3);
}

function doSomethingElse(a) {
  return a - 1;
}

var b;

doSomething(2); // 12

function doSomethingElse(a) {
  return a - 2;
}

改寫如下,將 doSomething 私有的細節(也就是第一個 doSomethingElse 函式)藏在其範疇中,這樣兩個 doSomethingElse 函式就不會造成衝突了。

function doSomething(a) {
  var b;
  b = a + doSomethingElse(a * 2);

  console.log(b * 3);

  function doSomethingElse(a) {
    return a - 1;
  }
}

doSomething(2); // 15

function doSomethingElse(a) {
  return a - 2;
}

再看一個例子,如下,函式 bar 內的 i 是個全域變數,它無意間修改的 for loop 的 i,導致 i 永遠都是 3,而進入了無窮迴圈。

function foo() {
  function bar(a) {
    i = 3;
    console.log(a + i);
  }

  for (var i = 0; i < 10; i++) {
    bar(i * 2);
  }
}

foo();

解法是將 bar 內的 i 宣告為區域變數,這樣就會將這個 i 包在 bar 的範疇裡面,避免被其他不相干的程式碼存取。

function foo() {
  function bar(a) {
    var i = 3; // 將 bar 內的 i 宣告為區域變數
    console.log(a + i);
  }

  for (var i = 0; i < 10; i++) {
    bar(i * 2);
  }
}

foo();

全域命名空間(Global Namespace)

通常我們使用的函式庫都會適當的隱藏自己內部所使用的變數和函式,意即將它們做成某物件的屬性和方法而非暴露在全域底下,而物件即是它們的命名空間(namespace),這樣就可以避免在全域範疇中因同名而產生的衝突。

範例如下,物件 MyReallyCoolLibrary 內含有屬性 awesome 和方法 doSomething 與 doAnotherThing,可避免全域範疇中也有同名的變數 awesome 或函式 doSomething 或 doAnotherThing。

var MyReallyCoolLibrary = {
  awesome: 'stuff',
  doSomething: function() {
    // ...
  },
  doAnotherThing: function() {
    // ...
  }
};

模組管理(Module Management)

除了使用前面提到的命名空間來避免衝突外,另一個解法是使用模組(module),藉由工具(例如:webpack)產生相依管理機制,避免函式庫新增任何識別字到全域範疇,而是要求函式庫將識別字匯入(import)至特定的範疇。模組管理機制並沒有跳脫範疇的掌控,而是巧妙地避免污染全域範疇,將函式庫的識別字保持在私有範疇中,解決了衝突的問題。若不使用工具,也可在撰寫程式碼時使用模組模式(module pattern)

即刻調用函式運算式(Immediately Invoked Function Expression, IIFE)

IIFE 是可立即執行的函式運算式,主要好處是不污染全域範疇,並且匿名或具名皆合法。

在談論 IIFE 前,先來看幾個重要觀念

函式宣告(Function Declaration)vs 函式運算式(Function Expression)

函式宣告(function declaration)就像是其他資料型別所宣告的字面值一樣,利用關鍵字 function 宣告一個函式,後接函式名稱與其本體,範例如下。

function foo() {
  var a = 3;
  console.log(a); // 3
}

foo();

函式運算式(function expression)是指將一個函式指定給特定變數的過程,範例如下。

var foo = function bar() {
  var a = 3;
  console.log(a); // 3
}

foo();

廣義上來說,只要函式述句並非以 function 開頭,而是以 var foo = function ...(function foo() ... 起始的(像是稍後提到的 IIFE),都是函式運算式。

匿名 vs 具名(Anonymous vs Named)

承上,函式運算式可分為具名和匿名的,範例如下。

具名的函式運算式,具有名稱識別字 bar。

var foo = function bar() {
  var a = 3;
  console.log(a); // 3
}

foo();

匿名的函式運算式,匿名就沒有名稱識別字。

var foo = function() {
  var a = 3;
  console.log(a); // 3
}

foo();

再看另一個例子,我們很習慣在 callback 中使用匿名運算式。這好嗎?

setTimeout(function() {
  console.log('等一秒後執行');
}, 1000);

或寫成 arrow function

setTimeout(() => {
  console.log('等一秒後執行');
}, 1000);

而匿名的函式運算式有以下缺點

bad idea

解法就是給它一個名字,例如 timeoutHandler,百利而無一害,用吧。

setTimeout(function timeoutHandler() {
  console.log('等一秒後執行');
}, 1000);

const timeoutHandler = () => { console.log('等一秒後執行'); }
setTimeout(timeoutHandler, 1000);

先前提到的例子中,不管是函式宣告或函式運算式,都會污染到全域範疇,因此可能會遇到剛才所提到的問題…像是避免變數或函式被不當存取、同名識別字所造成的衝突等。因此,我們可使用「即刻調用函式運算式」(Immediately Invoked Function Expression, IIFE)來解決這個問題。

IIFE 是可立即執行的函式運算式,主要好處是不污染全域範疇,並且匿名或具名皆合法。

具名為 foo 的 IIFE。

(function foo(){
  var a = 3;
  console.log( a ); // 3
})();

foo(); // foo is not defined

匿名的 IIFE。

(function() {
  var a = 3;
  console.log(a); // 3
})();

IIFE 還有一些功能,例如:指定範疇、確保 undefined 的正確性與反轉順序。

指定範疇

將傳入的參數當作範疇。

如下,將 window 傳入以作為具名的 IIFE 的範疇,並指名為 global,這樣的命名方式有助於程式的可讀性,簡單易懂。

var a = 2;

(function IIFE(global) {
  var a = 3;
  console.log(a); // 3
  console.log(global.a); // 2
})(window);

console.log(a); // 2

確保 undefined 的正確性

有些程式碼會因為錯誤的撰寫方式,導致污染了 undefined 的值,因此可指定一個參數,但不傳入值,以維持undefined 的正確性。

如下,IIFE 雖然有設定參數 undefined,但 () 卻是空的。

undefined = true;

(function IIFE(undefined) {
  var a;
  if (a === undefined) {
    console.log('Undefined 在這裡很安全!');
  }
})();

反轉順序

前方放置呼叫的參數並執行未來傳入的函式,而後方放置將要執行的函式。這種寫法常用於 UMD(universal module definition)。

var a = 2;

(function IIFE(def) {
  def(window);
})(function def(global) {
  var a = 3;
  console.log(a); // 3
  console.log(global.a); // 2
});

把上面這段程式碼拆開來,可當成這裡有兩個變數 a 和 def,其中 def 是待會要執行的函式。

var a = 2;
var def = function(global) {
  var a = 3;
  console.log(a); // 3
  console.log(global.a); // 2
};

使用 IIFE 結構,前方將要執行的函式當成參數 func 傳入,並且 func 代入 window 這個參數。接著,由後方傳入要執行的函式 def。

(function IIFE(func) {
  func(window);
})(def);

不過呢,自從有了 ES6 的 let 與 var 搭配區塊範疇 {...} 之後,我們再也不需要 IIFE 了。

(function foo(){
  var a = 3;
  console.log( a ); // 3
})();

foo(); // Uncaught ReferenceError: foo is not defined

剛剛的例子就可以改成…

{
  const foo = () => {
    let a = 3;
    // 做一些運算...
    console.log(a);
  };
}

foo(); // Uncaught ReferenceError: foo is not defined

區塊範疇(Block Scope)

在 ES6 以前,只有函式能建立範疇,而在 ES6 之後,可用大括號 { ... } 定義區塊範疇,讓 const 和 let 宣告以區塊為範疇的變數。

如下,i 屬於函式 foo 的範疇,而非假想的 for loop 的區塊範疇。

function foo() {
  for(var i = 0; i < 10; i++) {
    console.log(i);
  }
}

而 ES6 的 const 與 let 可宣告以區塊為範疇的變數。

const。

var foo = true;

if (foo) {
  const bar = foo * 2;
  console.log(bar); // 2
}

console.log(bar); // ReferenceError

const 表示常數(constant),宣告時就必須賦值,賦值後不可修改其值。

const bar = foo * 2;
bar = 3; // Uncaught TypeError: Assignment to constant variable.

let。

var foo = true;

if (foo) {
  let bar = foo * 2;
  console.log(bar); // 2
}

console.log(bar); // ReferenceError

當 let 宣告於 for 迴圈內時…

for (let i = 0; i < 10; i++) {
  console.log(i);
}

console.log(i); // ReferenceError: i is not defined

上面這段程式碼可以看成是這樣…i 是屬於第一個大括號所包含的區塊的,因此 i 一但出了第一個大括號所包含的範圍就會報錯。

{
  let i;
  for (i = 0; i < 10; i++) {
    console.log(i);
  }
}

console.log(i); // ReferenceError: i is not defined

注意,迴圈的每次迭代都會對 i 重新綁定(rebind),這樣就能確保重新賦值。

const 與 let 不會有拉升(hoisting)的狀況。

if (foo) {
  console.log(bar); // ReferenceError
  let bar = foo * 2;
}

垃圾回收(Garbage Collection)

一但變數用不到了,JavaScript 引擎就可能會將它回收,但由於範疇的緣故,仍須保留這些變數存取值的能力,而區塊範疇明確表達資料不再用到,而解決這個不需要被保留的狀況,可釋出更多記憶體空間。這部份與閉包(closure)有關,待後續詳細說明閉包的機制。

範例如下,雖然 clickHandler 用不到變數 someReallyBigData,因此函式 process 處理完 someReallyBigData 應該就可回收 someReallyBigData 的記憶體空間,但由於 clickHandler 擁有對整個範疇的閉包(後續會提到,閉包是函式記得並存取語彙範疇的能力,可說是指向特定範疇的參考,因此當函式是在其語彙範疇之外執行時也能正常運作),因此 JavaScript 就不會把它回收了。

function process(data) {
  // 做一些有趣的事情...
}

var someReallyBigData = { .. };

process(someReallyBigData);

var btn = document.getElementById('this_button');

btn.addEventListener('click'), function clickHandler(e){
  console.log('按鈕按下去了');
});

但是呢,區塊範疇能幫我們解決這個問題,區塊範疇會告訴 JavaScript 引擎這些內容僅在這一塊範圍內用到而已,之後就能讓 JavaScript 引擎順利地把用不到的資料回收掉。

function process(data) {
  // 做一些有趣的事情...
}

// 在這個區塊內宣告的任何資料在處理完後就可被丟棄!
{
  let someReallyBigData = { .. };
  process(someReallyBigData);
}

var btn = document.getElementById('this_button');

btn.addEventListener('click'), function clickHandler(e){
  console.log('按鈕按下去了');
});

丟垃圾

回顧

看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到…

最後推薦去年 Kuro 大大的優質好文

References


同步發表於2019 鐵人賽


comments powered by Disqus