iT邦幫忙

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

重新認識 JavaScript系列 第 10

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

  • 分享至 

  • twitterImage
  •  

本系列文章已重新編修,並在加入部分 ES6 新篇章後集結成書,有興趣的朋友可至天瓏書局選購,感謝大家支持。

購書連結 https://www.tenlong.com.tw/products/9789864344130

讓我們再次重新認識 JavaScript!


如果要說 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
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中
2
konekoya
iT邦新手 5 級 ‧ 2017-12-14 09:13:10

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

Kuro Hsu iT邦新手 1 級 ‧ 2017-12-14 09:41:48 檢舉

/images/emoticon/emoticon07.gif

0
jack1234552000
iT邦新手 5 級 ‧ 2019-06-02 22:23:40

https://ithelp.ithome.com.tw/upload/images/20190602/20108315Wt9Xa2LtJr.jpg

想請問為什麼 印出的aaa是最後跑完的

看更多先前的回應...收起先前的回應...
Kuro Hsu iT邦新手 1 級 ‧ 2019-06-02 22:33:05 檢舉

because object is call by reference.

var a = new Array();
var b = new Object();

for( let i = 0; i < 5; i++ ){
  b.x = i;
  console.log( b );
}

and it will print

{ x: 0 }
{ x: 1 }
{ x: 2 }
{ x: 3 }
{ x: 4 }

意思是 迴圈跑完到最後一個"4"
但是aaa每個索引都是引用到這個最後跑完的4 的意思嗎

可是 如果要得到正確的aaa要如何解決?
有試過你另一篇說的 匿名函式包起來的方法 但是不知是我用錯
還是本來就不是這樣用

Kuro Hsu iT邦新手 1 級 ‧ 2019-06-02 22:59:16 檢舉

IIFE 那篇與你的問題是兩件事。

你 push 到 aaa 的物件,從 i = 0i = 4 這五次 push 進去的都是同一個 bbb 物件,每執行一次迴圈, bbb.x 就會被更新成新的數值。

如果你希望每次 push 進去都是新的物件,那麼就在 for 迴圈內建立一個新的物件吧:

var a = new Array();

for( let i = 0; i < 5; i++ ){
  let b = new Object();
  b.x = i;
  a.push( b );
}

console.log(a);

謝謝你
抱歉 這個問題我留錯地方了

0
chunwen
iT邦新手 4 級 ‧ 2019-07-26 10:41:37

Kuro 大大您好
想請教關於函式作用域問題,我改寫ㄧ下您文中的範例

var x = 1;

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

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

我好奇為何console.log(x),印出來1的結果.
我自己想了一下原因,是否為:JS運作是從上到下解析,因此還未執行到dosomething這個函式,所以函式中未宣告的變數x,並沒有跑去變更外層的變數x

Kuro Hsu iT邦新手 1 級 ‧ 2019-07-26 11:25:36 檢舉

你好,在未呼叫 doSomeThing() 這個 function 以前,裡面的程式碼都不會被執行,所以 x 當然就是原本設定的 1 了。

你可以試試在你提供的程式碼執行的最後再加一行:

var x = 1;

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

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

console.log( x );  // 1 or 100 ?

最後的 x 會是 1 或 100 ?

doSomeThing 裡頭的 x = 100; 改成 var x = 100; 後,又會有什麼不同 :)

chunwen iT邦新手 4 級 ‧ 2019-07-26 14:05:19 檢舉

原來如此,我懂了!謝謝kuro大大

0
Alec
iT邦新手 3 級 ‧ 2020-04-06 18:03:33

Kuro 老師 你好:

正在研讀您的大作,有些觀念想和您確認一下,所以就來了鐵人賽相同的篇章下發問,

// 若是在全域的狀況下

var a = 10;   //使用 var 宣告
b = 10;    // 不使用 var

// 是不是都是一樣的宣告方式?因為

console.log(a);   // 10
console.log(b);   // 10
console.log(window.a);   // 10
console.log(window.b);   // 10

在 window 下也都能找到 a 與 b 這兩個變數
所以是不是代表在全域的狀況下,有沒有使用 var 宣告變數都是一樣的

而若是在 function 的 {} 內宣告變數,
使用 var 宣告,則可以將變數限制在 {} 內,不會汙染全域,
而若是在 {} 內宣告變數不使用 var 的話,也是會跳出 {} 的範圍汙染到全域,

請問我以上的觀念是否正確,請不吝指正,謝謝

看更多先前的回應...收起先前的回應...
Kuro Hsu iT邦新手 1 級 ‧ 2020-04-06 18:10:26 檢舉

沒錯。 在全域的環境下,不管有沒有透過 var 宣告,這個變數都是全域的。

在 function (){ } 範圍內,若在自己的 scope 沒有宣告變數,則會一層層往上找,直到最外層 (全域) 為止。

a = 1000;

(function (){
  var a = 10;

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

  console.log(a);
})()

console.log(a);

像這種情況,雖然內層的 a = 20 沒有 var 但它也不是全域的變數。

Alec iT邦新手 3 級 ‧ 2020-04-06 19:14:57 檢舉

那是因為內層的 a = 20 重新賦值外層原本的 var a = 10,這樣對嗎?

我將這段程式放進了 Console,顯示為 20 20 1000

若是最內層的 a = 20 也有 var 的話,
那 Console 就會變成 20 10 1000 了,
這樣是否正確呢?謝謝指教~

Kuro Hsu iT邦新手 1 級 ‧ 2020-04-07 10:17:13 檢舉

是的

Alec iT邦新手 3 級 ‧ 2020-04-07 14:14:51 檢舉

/images/emoticon/emoticon08.gif
謝謝 Kuro 老師

0
King Tzeng
iT邦新手 3 級 ‧ 2020-08-05 01:50:03

嗚嗚嗚...我真的是JS中文名詞苦手QQ
什麼函式、函數、參數、物件屬性、匿名函式等等等等...搞的頭昏腦花的QQ

好險有Kuro大大寫重新認識JS,謝謝Kuro大大真的幫助我很多... (跪Orz

Kuro Hsu iT邦新手 1 級 ‧ 2020-08-05 10:43:38 檢舉

寫英文也可以啊 XD

0
sydars
iT邦新手 5 級 ‧ 2021-04-04 01:57:31
var x = 1;

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

console.log( doSomeThing(50) ); 

要是不小心在 var 宣告前就使用了這個變數,這時候 JavaScript 就會開始尋找變數 x 了,在自己的 scope 找...

這一段我不太理解想請問「var 宣告前」這句話的意思,因為不是已經有宣告var x = 1;了,還是說是指 function 裡面的宣告?如果不是指後者的意思的話,不知道我下面的理解是否正確?

我的理解是:當執行到 console.log( doSomeThing(50) ); 的時候,JS 會已經從最上方開始找到 var x = 1,然後再執行 doSomeThing()
因為 doSomeThing() 裡面先用到了console.log(x);然後又再次宣告 x = 100 ,所以發生 hoisting. 可是這樣的話,跟您提到的「var 宣告前」好像又沒有關係了@@,因為我認為這樣的情況是因為「再次宣告」才造成 hoisting.

Kuro Hsu iT邦新手 1 級 ‧ 2021-04-06 11:36:18 檢舉

你好, JavaScript var 變數的作用範圍 (scoped) 為 function,所以此範例可以無視外面的 var x = 1;

以此範例來說,除非在 function 內沒有加上 var x = 100; 這行,那麼 function 內的 x 與外面的 x 就會是同一個。

var x = 1;

var doSomeThing = function(y) {
  // 此即「宣告前」
  console.log(x);   // 會出現什麼?
  
  // 此處透過 var 宣告 x 變數
  var x = 100;
  return x + y;
};

console.log( doSomeThing(50) ); 
0
wilbur825882
iT邦新手 5 級 ‧ 2021-06-21 18:34:08

想請問~
我新增一個.js檔然後使用vscode做編輯
但是當我直接這樣宣告
a = 10
卻印出a is not defined的error呢?
但是在jsbin上卻正常輸出10。

看更多先前的回應...收起先前的回應...
Kuro Hsu iT邦新手 1 級 ‧ 2021-06-22 10:03:12 檢舉

你好,因為我看不到你的環境,所以無法判斷你的問題出在哪
可以請你附上截圖說明你的問題嗎?

https://ithelp.ithome.com.tw/upload/images/20210622/20121190sCetaZQiGW.png

Kuro Hsu iT邦新手 1 級 ‧ 2021-06-22 18:55:22 檢舉

你好,因為 node 環境下不像瀏覽器會將所有未宣告的變數納入 window 這個全域物件下,所以當你在 node 嘗試設定一個尚未宣告的變數 a 就會出現像這樣的訊息。

而在瀏覽器環境下,這個未宣告的變數 a 會自動變成 window 這個全域物件下的屬性,也就是俗稱的全域變數,意義等同於 window.a

明白了~
感謝酷囉大大的回覆!!!

我要留言

立即登入留言