iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 10
7
Modern Web

重新認識 JavaScript系列 第 10

重新認識 JavaScript: Day 10 函式 Functions 的基本概念

如果要說 JavaScript 最核心也最容易被誤用的部分,我想應該就非函式 (Function) 莫屬了,那麼在接下來的分享中,我們就來聊聊關於函式的部分。

函式是物件的一種

在前面介紹變數型別的時候曾經說過,除了基本型別以外的都是物件

當我們透過 typeof 去檢查一個「函式 (function) 」的時候,雖然你會得到 "function" 的結果,讓你以為 function 也是 JavaScript 定義的一種型別,但實際上它仍屬於 Object 的一種。

你可以把它想像成是一種可以被呼叫 (be invoked) 的特殊物件 (值)。


函式

「函式」指的是將一或多段程式指令包裝起來,可以重複使用,也方便維護。

宣告函式的方法有好幾種,但不管是什麼方式,通常一個函式會包含三個部分:

  • 函式的名稱 (也可能沒有名稱,稍後會提到)
  • 在括號 ( ) 中的部分,稱為「參數 (arguments) 」,參數與參數之間會用逗號 , 隔開
  • 在大括號 { } 內的部分,內含需要重複執行的內容,是函式功能的主要區塊。

例如:

function square(number) {
  return number * number;
}

square(2);        // 4
square(3);        // 9
square(4);        // 16

以上是一個函式的宣告與呼叫簡單示範。

函式使用 function 關鍵字來宣告名稱,參數 number 位於括號之中。

於是透過執行 square(2); 來呼叫 square 函式,此時 square 函式裡面的 number 的值就會是傳進來的 2,而 number * number 的結果就會是 4 了。

最後再透過 return 回傳結果,如果沒有使用 return 回傳,則預設會回傳 undefined


定義函式的方式

常見定義函式的方式有這幾種:

  • 函式宣告(Function Declaration)
  • 函式運算式(Function Expressions)
  • 透過 new Function 關鍵字建立函式

下面我們一一介紹。

函式宣告(Function Declaration)

「函式宣告」應該是屬於最常見的用法:

function 名稱([參數]) {
  // 做某事
}

像本篇一開始的範例就是用這種方式:

function square(number) {
  return number * number;
}

函式運算式(Function Expressions)

另一種方式,則是透過 變數名稱 = function([參數]){ ... }; 的方式,將一個函式透過 = 指定給某個變數。

像這樣:

var square = function (number) {
  return number * number;
};

可能有些人會覺得這樣很奇怪,但還記得我們一直強調的嗎?
函式實際上它仍屬於 Object 的類型,是一種可以被呼叫 (be invoked) 的特殊物件 (值),自然可以透過變數存入囉。

沒有名字的函式?

是的,聰明的你也許已經察覺到了,在範例裡 = 後面的 function 是「沒有名字」的:

var square = function (number) {
  return number * number;
};

像這類沒有名字的函式在 JavaScript 是合法的,通常我們會稱它為「匿名函式」。
匿名函式我們等等還會見到,現在先介紹到這裡。

在函式運算式中,如果想要在 function 後面加上一個名字是可以的嗎?
可以,像這樣:

var square = function func(number) {
  return number * number;
};

但是要注意的是,這個名字只在「自己函式的區塊內」有效,也就是說:

var square = function func(number) {
  console.log( typeof func );   // "function"
  return number * number;
};

console.log( typeof func );     // undefined

像這樣,脫離了函式自身區塊後,變數 func 就不存在了。

當然,在「匿名函式」的函式運算式情況下,你還是可以透過自定義的變數名稱取得 function,沒有一定要替這個函式取名的理由:

var square = function func(number) {
  console.log( typeof square );   // "function"
  return number * number;
};

透過 new Function 關鍵字建立函式

最後一種方式就是直接使用 Function (注意 F 大寫) 這個關鍵字來建立函式物件。 使用時將參數與函式的內容依序傳入 Function,就可以建立一個函式物件了。 像這樣:

// 透過 new 來建立 Function "物件"
var square = new Function('number', 'return number * number');

透過 new Function 所建立的函式物件,每次執行時都會進行解析「字串」(如 'return number * number' ) 的動作,運作效能較差,所以通常實務上也較少會這樣做。

但不管是透過哪一種方式定義函式,呼叫函式的話就直接用「函式名稱(參數)」的方式,像 square(2); 就可以了。 [註1]


變數的有效範圍 (Scope)

終於要講到全域變數與區域變數的差異了。

在 ES6 之前,JavaScript 變數有效範圍的最小單位是以 function 做分界的。 [註2]
什麼意思呢? 讓我用簡單的範例來說明:

var x = 1;

var doSomeThing = function(y) {
  var x = 100;
  return x + y;
};

console.log( doSomeThing(50) );   // ?
console.log( x );                 // ?

猜猜看,這兩組 console.log() 分別會印出什麼?

.
.
.

答案是 1501

由於函式 doSomeThing() 裡面再次定義了變數 x,所以當我們執行 doSomeThing(50) 時,會將 50 作為參數傳入 doSomeThing()y,那麼 return x + y 的結果自然就是 100 + 50150 了。

那麼下一行再印出的 x 呢? 為什麼是 1 而不是 100
因為...

「切分變數有效範圍的最小單位是 "function" 」
「切分變數有效範圍的最小單位是 "function" 」
「切分變數有效範圍的最小單位是 "function" 」

很重要,所以要講三次。

因為切分變數有效範圍的最小單位是 "function",所以在函式區塊內透過 var 定義的 x 實際上只屬於這個函式。
換句話說,外面的 x 跟 function 內的 x 其實是兩個不同的變數。

因此在最後印出來的 console.log( x ); 自然就是外面的 x 也就是 1 了。

所以我們說,變數有效範圍的最小單位是 "function", 這個有效範圍我們通常稱它為「Scope」。


那麼,如果 function 內部沒有 var x 呢?
很簡單,自己的 function 內如果找不到,就會一層層往外找,直到全域變數為止:

var x = 1;
var doSomeThing = function(y) {
  // 內部找不到 x 就會到外面找,直到全域變數為止。
  // 都沒有就會報錯:ReferenceError: x is not defined
  return x + y;
};

console.log( doSomeThing(50) );   // 51

要注意的是, function 可以讀取外層已經宣告的變數,
但外層拿不到裡面宣告的變數。

沒有 var 宣告的變數很危險!

「沒有 var 宣告的變數很危險」什麼意思?

來,稍微修改一下剛剛的範例,把 function 內的 var 拿掉:

var x = 1;

var doSomeThing = function(y) {
  x = 100;
  return x + y;
};

console.log( doSomeThing(50) );   // ?
console.log( x );                 // ?

猜猜看,這兩組 console.log() 分別會印出什麼?

.
.
.

答案是 1501 ...... 才。不。是。勒~~

答案是 150100

https://media.tenor.co/images/5315a964ef0c552972c26188e8ceb0ba/tenor.gif

先別急著崩潰,剛剛說過「切分變數有效範圍的最小單位是 "Function" 」對吧?
但這句話的前提是你得在 function 內部再次用 var 宣告這個變數,否則 JavaScript 會再往外層去找到同名的變數,直到最外層,也就是「全域變數」。

換言之,由於在 function 內沒有重新宣告 x 變數,使得 x = 100 跑去變更了外層的同名變數 x

var doSomeThing = function(y) {
  x = 100;
  return x + y;
};

導致在呼叫 doSomeThing(50) 之後再印出 x 的值自然就變成 100 囉。


提升 (Hoisting)

覺得混亂了嗎? 還沒完呢。

現在我們把 var 加回去,然後在上面加一行 console.log(x) 像這樣:

var x = 1;

var doSomeThing = function(y) {
  console.log(x);   // 會出現什麼?

  var x = 100;
  return x + y;
};

console.log( doSomeThing(50) );   // 150
console.log( x );                 // 1

現在我們已經知道 doSomeThing(50)x 的值是 150 以及 1 了,

那麼要讓各位來猜猜看,在 function 內的 console.log(x) 會出現什麼?

.
.
.

答案是 1100 嗎? (打叉)
再猜一次。

.
.
.

正確答案是 undefined

https://media.tenor.co/images/ebc9937da193cca76b024b9998c4e07b/tenor.gif

醒醒啊,天還沒黑,別急著睡覺。
其實啊,剛剛那份程式碼在瀏覽器 (或者編譯器) 的眼中,是長這樣的:

var x = 1;

var doSomeThing = function(y) {
  var x;
  console.log(x);   // 會出現什麼?

  x = 100;
  return x + y;
};

console.log( doSomeThing(50) );   // 150
console.log( x );                 // 1

看出差異了嗎?

雖然我們這次在函式內部有透過 var 對變數 x 來重新做宣告,但是呢,要是不小心在 var 宣告前就使用了這個變數,這時候 JavaScript
就會開始尋找變數 x 了,在自己的 scope 找... 啊,找到了!

雖然是在下面,但可以確認的是自己的 scope 裡面有宣告,於是就 很貼心地 「只會把宣告的語法」拉到這個 scope 的「最上面」...
(還記得前面介紹變數時講過的嗎? 只要變數有被宣告,使用時就不會有錯誤,否則會出現 ReferenceError 的錯誤。)

最後就變成這個樣子:

var doSomeThing = function(y) {
  var x;
  console.log(x);   // undefined

  x = 100;
  return x + y;
};

而 JavaScript 的這種特性,我們稱作「變數提升」 (Variables Hoisting)。 [註3]
也因為這種奇怪特性的關係,強烈建議所有可能用到的變數都盡量在 scope 的最上面先宣告完成後再使用


除了變數以外,函式有沒有提升? 答案是有。

還記得本文一開始說過,函式的定義有分成幾種,其中也可以分成 var xxx = function() {...} 存入變數的「函式運算式」以及直接用 function xxx() {...} 定義的「函式宣告」對吧?

這兩種定義方式最大的差別在於,透過「函式宣告」方式定義的函式可以在宣告前使用 (函式提升) :

square(2);    // 4

function square(number) {
  return number * number;
}

而透過「函式運算式」定義的函式則是會出現錯誤:

square(2);    // TypeError: square is not a function

var square = function (number) {
  return number * number;
};

與變數提升的差別在於變數提升只有宣告被提升,而函式的提升則是包括內容完全被提升。 除了可呼叫的時機不同外,「函式宣告」與「函式運算式」在執行時期兩者無明顯差異。


全域變數與區域變數

看到這裡,相信你應該對變數的作用範圍有了基本的理解對吧,在本文的最後我再針對「全域變數」與「區域變數」做一些補充說明。

其實在 JavaScript 這門語言中,沒有所謂「全域變數」這種東西。
更準確地說,我們所說的「全域變數」其實指的是「全域物件」(或者叫「頂層物件」) 的屬性。

「全域物件」是什麼?

以瀏覽器來說,「全域物件」指的就是 window,在 node 環境中則叫做 global

什麼叫做全域物件的「屬性」呢?

舉個例子,我們在最外層透過 var 建立一個變數 a,像這樣:

var a = 10;

一直以來我們都稱它叫「全域變數」對吧?
這個時候,請你在後面加一行:

var a = 10;
console.log( window.a );    // ?

看到了什麼?
這時你應該會看到剛剛指定給 a10 這個數字才對。

那麼就可以來下個結論:

  • 變數有效範圍 (scope) 的最小切分單位是 function (ES6 的 letconst 例外)
  • 即使是寫在函式內,沒有 var 的變數會變成「全域變數」
  • 全域變數指的是全域物件 (頂層物件) 的「屬性」

所以看到這裡,相信你應該對「全域變數」與「區域變數」有了更直接的理解吧!


最後分享一下,這是我在網友推特上看到的:
https://ithelp.ithome.com.tw/upload/images/20171213/20065504cKJzQNTpAm.png
以後有人問你類似問題,相信你也可以抬頭挺胸自信地回答他囉!
來源: https://twitter.com/rayshih771012/status/930075889483726849


  • [註1] 函式呼叫:除了單純的 函式() 之外,還有 .call().apply(),在後續的篇章介紹 this 時會提到這些。

  • [註2] ES6 之後有 letconst 分別定義「變數」與「常數」。 與 var 不同的是,它們的 scope 是透過大括號 { } 來切分的。

  • [註3] 提升:提升看起來是將變數和函數的宣告移動到程式區塊頂端,但實際上是變數和函數的宣告會在編譯階段中先被放入記憶體,實際在程式碼中位置還是一樣,往上移動的說法是為了幫助理解。


花了好幾天的時間,「重新認識 JavaScript」JS 基礎篇終於告一段落了,各位對 JavaScript 有了基本的理解之後,接著我們要開始進入瀏覽器的部分了。

  • 到底 HTML、CSS 與 JavaScript 是什麼樣的關係?
  • BOM 與 DOM 又是什麼東西? 可以吃嗎
  • JavaScript 怎麼操作我們的網頁?
  • 網頁事件又是怎麼一回事?

在接下來的部分都會為各位詳細的介紹。
下一篇:前端工程師的主戰場:瀏覽器裡的 JavaScript,我們明天見。


上一篇
重新認識 JavaScript: Day 09 流程判斷與迴圈
下一篇
重新認識 JavaScript: Day 11 前端工程師的主戰場:瀏覽器裡的 JavaScript
系列文
重新認識 JavaScript37

1 則留言

0
konekoya
iT邦新手 5 級 ‧ 2017-12-14 09:13:10

目前看到解釋 Scope, Hoisting 最有趣的文章,而且也說明的很清楚 :)

/images/emoticon/emoticon07.gif

我要留言

立即登入留言