本系列文章已重新編修,並在加入部分 ES6 新篇章後集結成書,有興趣的朋友可至天瓏書局選購,感謝大家支持。
購書連結 https://www.tenlong.com.tw/products/9789864344130
讓我們再次重新認識 JavaScript!
過去我們花了將近一週的時間介紹了瀏覽器裡的 JavaScript,也知道了 JavaScript 實際上是透過 BOM、DOM API 來與瀏覽器打交道。 相信看過了前面幾天的文章後,你已經對 DOM API 以及網頁事件有了基本的認識。
接著,如同我在系列文前言的預告,在接下來的分享中,要進入到「深入探討 JavaScript 篇」囉!
這系列的重點會著重於 JavaScript 語言的核心概念:「函式、物件、原型鍊」,與各位一同更深入地探討 JavaScript 核心的部份。
在 Day 10 函式 Functions 的基本概念 一文中,我們不斷地強調一個概念「函式是物件的一種」。 那麼,也許你也聽過這種說法:在 JavaScript 裡,「函式是第一級公民」。
「第一級公民」? 什麼意思?
「第一級公民」指的是你可以將函式存放在變數、物件以及陣列之中,同時,你也可以將函式傳遞到函式,或者由另一個函式來回傳它。 而且,函式具有屬性,因為它實際上是一個「物件」。
換句話說,任何你對其他類型可以做的事,你都可以對「函式」做。
// 把函式存入「變數」, 呼叫時執行 funcA();
var funcA = function(){};
// 把函式存入「陣列」, 呼叫時執行 funcB[0]()
var funcB = [function(){}];
// 把函式存入「物件」的屬性, 呼叫時執行 funcC.method() 或 funcC['method']()
var funcC = { method: function(){} };
// 把函式當作「參數」,傳入到另一個函式中。
var funcD = function(func){
  return func;
};
// 「另一個」函式: 存放的是 funcD,而參數為一個匿名函式
var runFuncPassedToFuncD = funcD(function(){console.log('Hi');});
// 呼叫「另一個」函式
runFuncPassedToFuncD();   // "Hi"
// 函式是「物件」的一種,當然也可以有自己的「屬性」
var funcE = function(){};
funcE.answer = 'yup';
console.log(funcE.answer);      // 'yup'
當我們透過 typeof 去檢查某個函式的時候,雖然你會得到 "function" 的結果,讓你以為 function 也是 JavaScript 定義的一種型別,但實際上它仍屬於 Object 的一種。
函式是一種物件,值也是物件。 你可以把它想像成是一種可以被呼叫 (be invoked) 的特殊物件 (值)。
之前我們說過,當我們呼叫一個函式的時候,可以透過「函式名稱」加上「小括號」的方式呼叫。 而小括號內的資料,就是「參數」。
var plus = function (numA, numB) {
  return numA + numB;
};
plus(1, 2);      // 3
plus(3, 4);      // 7
像上面這樣,呼叫 plus(1, 2) ,其中的 1, 2 作為參數傳至 plus 這個 function,這時 numA 與 numB 內的值就會分別是 1 與 2。
於是,回傳的內容 numA + numB 自然就會是 1 + 2 的結果了。
然而,即便我們定義函式時有指定「參數的數量」(如上面範例,分別為 numA 與 numB),但是在呼叫的時候,並不會針對代入的參數數量做檢查。
也就是說,呼叫 plus 你可以寫成:
plus(1, 2, 3, 4, 5);
或是
plus( );
在 JavaScript 都是合法的,只不過在沒有傳入值作為參數的情況下,那些沒有指定的參數預設會是 undefined。 而多傳入的那些參數,在「大部分」情況下是沒有意義的。
既然我說了「大部分」那就代表還是可以拿得到的。
arguments 物件事實上,當函式被呼叫的時候,會產生一個 arguments 物件。 而這個 arguments 物件的內容,其實就是我們呼叫函式所代入的「參數」。
以剛剛的 plus 作為範例:
plus(1, 2, 3, 4, 5);
很明顯我們代入的參數數量超過了先前定義好的參數數量,那麼多餘的 3, 4, 5 我們有辦法可以取得嗎?
可以,就透過 arguments 這個物件。
var plus = function (numA, numB) {
  console.log( arguments.length );
  return numA + numB;
};
// 因為有 5 個參數,會先 log 出 5,然後回傳 1+2 的結果
plus(1, 2, 3, 4, 5);
請注意, arguments 雖然看起來像個「陣列」,但實際上他只是個帶有「索引」特性的物件,然後內建個 length 屬性,其他地方與「陣列」完全不同,當然也沒有 .map() 或 .filter() 這些陣列才有的方法。 [註1]
所以說,即便在定義函式的時候完全沒有指定參數給它,我們仍然可以在函式內透過 arguments 來取得參數。
var plus = function (numA, numB) {
  for( var i = 0; i < arguments.length; i++ ){
    console.log( arguments[i] );
  }
  return numA + numB;
};
// console.log 印出 1 2 3 4 5
plus(1, 2, 3, 4, 5);
除此之外, arguments 物件還有另一個屬性: callee,指的是目前執行的函式。
var plus = function (numA, numB) {
  // arguments.callee 指的是 plus 這個 function
  console.log( arguments.callee );
  return numA + numB;
};
當我們需要在函式執行「遞迴」 (在函式內自我呼叫) 時,可以執行 arguments.callee() 來達成,這屬性在「匿名函式」時特別有用。 但要小心的是,在「嚴格模式」下不允許存取 arguments.caller 和 arguments.callee 這兩個屬性。
另外, ES6 的箭頭函式 (Arrow Function) 也沒有提供 arguments 物件。
除了我們可以透過 arguments 去取得超出宣告數量的參數外,另外也有一種常見的方式:將多個參數用一個「物件」包裝起來。
假設我們要做一個「將某人加入通訊錄」的功能,那麼就用一個叫 addPerson() 的函式來實作吧!
首先要有「姓名」、「電話」:
var addPerson = function(firstName, lastName, phone){
  // 略...
};
看起來很 ok 呢!
這時候 PM 大人來了,要求要可以加入 email 欄位:
// 加入了 email
var addPerson = function(firstName, lastName, phone, email){
  // 略...
};
但是,這時候問題又來了!
客服表示有使用者 英文不好 眼睛不好,在輸入資料後「Michelle」與「Michael」傻傻分不清楚,要求加入「性別」的欄位:
// 加入了 gender
var addPerson = function(firstName, lastName, phone, email, gender){
  // 略...
};
然後又多了奇奇怪怪的需求,加入「生日」、「地址」等等...
// 又加入了各種欄位
var addPerson = function(firstName, lastName, phone, email, gender, birthday, address){
  // 略...
};
於是最後工程師在呼叫這個 addPerson() 的時候整個大崩潰。
addPerson('Kuro', 'Hsu', '0987654321', 'kurotanshi@gmail.com', 'male', null, 'Taipei City');
順序不能錯,參數不能漏,一個蘿蔔一個坑。
只要中間少了一個參數,你的通訊錄欄位就整個 gg 了。
那麼這個時候,改用「物件」的方式來取代這一堆參數,就會是很簡便的做法:
var people = {
  firstName: 'Kuro',
  lastName: 'Hsu',
  phone: '0987654321',
  email: 'kurotanshi@gmail.com',
  gender: 'male',
  address: 'Taipei City'
};
// 最後把 people 物件作為參數傳入 addPerson
addPerson(people);
像這樣,不僅呼叫函式變得更加簡便,而且由於物件的屬性不要求「順序」,所以就算中間忽略掉幾個非必填的屬性也沒問題,使得程式碼更容易閱讀,也易於維護。 往後就算要新增參數也不用擔心影響到過去的程式。
前面說過,在 JavaScript 中,即使函式的參數數量已經定義過,但實際在呼叫的時候仍然可以不傳參數或者傳入不對等的數量。
傳多了還無所謂,傳少了那些接不到值的參數們就會變成 undefined。
那麼,有個很實用的檢查法,就是透過我們在 Day 08 運算式與運算子 [3]: Boolean 的真假判斷 曾介紹過的 || (OR) 這個運算子來幫助我們處理。
var plus = function (numA, numB) {
  return numA + numB;
};
在 plus 這個範例中,要是我們呼叫時,只帶入了一個參數:
plus(1);    // NaN
那麼 numB 就會變成 undefined,加總後的結果就是 NaN。
為了要避免這種情況發生,我們可以改成這樣:
var plus = function (numA, numB) {
  numA = numA || 0;
  numB = numB || 0;
  return numA + numB;
};
當然我們知道,會被判斷成 false 的值不只 undefined,或是改用嚴謹一點的寫法:
var plus = function (numA, numB) {
  numA = (typeof numA !== 'undefined') ? numA : 0;
  numB = (typeof numB !== 'undefined') ? numB : 0;
  return numA + numB;
};
那麼這時,即便我們呼叫時只給定一個參數 plus(1); 最終得到的結果至少也會是 1 而不是 NaN 了。
另外,在 ES6 之後,我們也可以像這樣替參數指定預設值:
var plus = function (numA = 0, numB = 0) {
  return numA + numB;
};
也可以達到一樣的效果。
[註1] : 雖然 arguments 物件並非是陣列類型,但仍然可以透過 slice 或是 ES6 的 Array.from 來將它轉成一個新的陣列。
var args = Array.prototype.slice.call(arguments);
var args = [].slice.call(arguments);
// ES6
const args = Array.from(arguments);
[註2]:在使用 function 傳遞參數時,要小心 「Pass by sharing」帶來的誤解。
var o = { value: 10 };
function changeValue(obj) {
  obj = { value: 123 };
}
changeValue(o);
console.log(o);   // 此時 o 仍是 { value: 10 }
詳情可參閱本系列 重新認識 JavaScript: Day 05 JavaScript 是「傳值」或「傳址」? 一文
那麼,以上就是針對函式的參數所做的說明。