2022 年 5 月 3 日

函數物件,NFE

正如我們所知,JavaScript 中的函數是一個值。

JavaScript 中的每個值都有類型。函數是什麼類型?

在 JavaScript 中,函數是物件。

可以將函數想像成可呼叫的「動作物件」。我們不僅可以呼叫它們,還可以將它們視為物件:新增/移除屬性、傳遞參考等。

「名稱」屬性

函數物件包含一些可用的屬性。

例如,函數的名稱可作為「名稱」屬性存取

function sayHi() {
  alert("Hi");
}

alert(sayHi.name); // sayHi

有點有趣的是,名稱指派邏輯很聰明。即使函數在沒有名稱的情況下建立,然後立即指派,它也會指派正確的名稱

let sayHi = function() {
  alert("Hi");
};

alert(sayHi.name); // sayHi (there's a name!)

如果指派是透過預設值完成,它也會運作

function f(sayHi = function() {}) {
  alert(sayHi.name); // sayHi (works!)
}

f();

在規範中,此功能稱為「情境名稱」。如果函式未提供名稱,則在指定時會從情境中找出。

物件方法也有名稱

let user = {

  sayHi() {
    // ...
  },

  sayBye: function() {
    // ...
  }

}

alert(user.sayHi.name); // sayHi
alert(user.sayBye.name); // sayBye

不過沒有什麼神奇的事。有些情況無法找出正確的名稱。在這種情況下,名稱屬性會是空的,如下所示

// function created inside array
let arr = [function() {}];

alert( arr[0].name ); // <empty string>
// the engine has no way to set up the right name, so there is none

然而,實際上大多數函式都有名稱。

「長度」屬性

還有另一個內建屬性「長度」,用於傳回函式參數的數量,例如

function f1(a) {}
function f2(a, b) {}
function many(a, b, ...more) {}

alert(f1.length); // 1
alert(f2.length); // 2
alert(many.length); // 2

在此處,我們可以看到 rest 參數並未計算在內。

length 屬性有時用於對其他函式進行運算的函式中的內省

例如,在以下程式碼中,ask 函式接受一個要詢問的question,以及任意數量的要呼叫的handler 函式。

使用者提供答案後,函式會呼叫處理常式。我們可以傳遞兩種處理常式

  • 零參數函式,僅在使用者提供肯定答案時呼叫。
  • 有參數函式,在任何情況下都會呼叫,並傳回答案。

若要正確呼叫handler,我們會檢查handler.length 屬性。

構想是,我們有一個簡單的無參數處理常式語法,適用於肯定情況(最常見的變體),但也能支援通用處理常式

function ask(question, ...handlers) {
  let isYes = confirm(question);

  for(let handler of handlers) {
    if (handler.length == 0) {
      if (isYes) handler();
    } else {
      handler(isYes);
    }
  }

}

// for positive answer, both handlers are called
// for negative answer, only the second one
ask("Question?", () => alert('You said yes'), result => alert(result));

這是所謂多型的特殊情況,會根據參數的類型或在我們的案例中根據length,以不同的方式處理參數。這個構想在 JavaScript 函式庫中確實有用。

自訂屬性

我們也可以新增自己的屬性。

在此處,我們新增counter 屬性來追蹤總呼叫次數

function sayHi() {
  alert("Hi");

  // let's count how many times we run
  sayHi.counter++;
}
sayHi.counter = 0; // initial value

sayHi(); // Hi
sayHi(); // Hi

alert( `Called ${sayHi.counter} times` ); // Called 2 times
屬性不是變數

指定給函式的屬性,例如sayHi.counter = 0不會在函式內定義一個區域變數counter。換句話說,屬性counter和變數let counter是兩個不相關的事物。

我們可以將函式視為物件,在其中儲存屬性,但這不會對其執行產生任何影響。變數不是函式屬性,反之亦然。這些只是平行的世界。

函式屬性有時可以取代閉包。例如,我們可以改寫章節變數範圍、閉包中的計數器函式範例,以使用函式屬性

function makeCounter() {
  // instead of:
  // let count = 0

  function counter() {
    return counter.count++;
  };

  counter.count = 0;

  return counter;
}

let counter = makeCounter();
alert( counter() ); // 0
alert( counter() ); // 1

count 現在直接儲存在函式中,而不是儲存在其外部詞法環境中。

這樣做比使用閉包好還是差?

主要的差別在於,如果 count 的值存在於外部變數中,則外部程式碼無法存取它。只有巢狀函式可以修改它。如果它與函式繫結,則可以這樣做

function makeCounter() {

  function counter() {
    return counter.count++;
  };

  counter.count = 0;

  return counter;
}

let counter = makeCounter();

counter.count = 10;
alert( counter() ); // 10

因此,實作的選擇取決於我們的目標。

命名函式表達式

命名函式表達式,或 NFE,是具有名稱的函式表達式的術語。

例如,我們來取一個普通的函式表達式

let sayHi = function(who) {
  alert(`Hello, ${who}`);
};

並為其加上一個名稱

let sayHi = function func(who) {
  alert(`Hello, ${who}`);
};

我們在此達到了什麼?附加的 "func" 名稱的目的是什麼?

首先,我們注意到,我們仍然有一個函式表達式。在 function 之後加上名稱 "func" 並不會使其成為函式宣告,因為它仍然是作為賦值表達式的一部分建立的。

加上這樣的名稱也不會破壞任何東西。

函式仍然可用作 sayHi()

let sayHi = function func(who) {
  alert(`Hello, ${who}`);
};

sayHi("John"); // Hello, John

名稱 func 有兩個特殊之處,這就是原因

  1. 它允許函式在內部參照自身。
  2. 它在函式外部不可見。

例如,如果未提供 who,函式 sayHi 以下會使用 "Guest" 再次呼叫自身

let sayHi = function func(who) {
  if (who) {
    alert(`Hello, ${who}`);
  } else {
    func("Guest"); // use func to re-call itself
  }
};

sayHi(); // Hello, Guest

// But this won't work:
func(); // Error, func is not defined (not visible outside of the function)

我們為什麼使用 func?也許只對巢狀呼叫使用 sayHi

實際上,在大多數情況下,我們都可以

let sayHi = function(who) {
  if (who) {
    alert(`Hello, ${who}`);
  } else {
    sayHi("Guest");
  }
};

該程式碼的問題在於 sayHi 可能會在外層程式碼中變更。如果函式改指派給另一個變數,程式碼將開始產生錯誤

let sayHi = function(who) {
  if (who) {
    alert(`Hello, ${who}`);
  } else {
    sayHi("Guest"); // Error: sayHi is not a function
  }
};

let welcome = sayHi;
sayHi = null;

welcome(); // Error, the nested sayHi call doesn't work any more!

這是因為函式從其外層詞彙環境中取得 sayHi。沒有本地的 sayHi,因此使用外層變數。在呼叫的那一刻,外層 sayHinull

我們可以放入函式表達式的選用名稱旨在解決這類問題。

我們使用它來修正我們的程式碼

let sayHi = function func(who) {
  if (who) {
    alert(`Hello, ${who}`);
  } else {
    func("Guest"); // Now all fine
  }
};

let welcome = sayHi;
sayHi = null;

welcome(); // Hello, Guest (nested call works)

現在它可以運作,因為名稱 "func" 是函式本地的。它不會從外部取得(也不會在那裡可見)。規格保證它將始終參照目前的函式。

外層程式碼仍然有其變數 sayHiwelcome。而 func 是「內部函式名稱」,函式可以可靠地呼叫自己的方式。

函式宣告沒有這種東西

此處所述的「內部名稱」功能僅適用於函式表達式,不適用於函式宣告。對於函式宣告,沒有語法可以加入「內部」名稱。

有時,當我們需要可靠的內部名稱時,這便是將函式宣告改寫為命名函式表達式形式的原因。

摘要

函式是物件。

我們在此介紹函式的屬性

  • name – 函式名稱。通常從函式定義中取得,但如果沒有,JavaScript 會嘗試從內容中猜測(例如指派)。
  • length – 函式定義中的參數數量。不計算 rest 參數。

如果函式宣告為函式表達式(不在主程式碼流程中),且帶有名稱,則稱為命名函式表達式。名稱可以在函式內部用於參照函式本身,例如遞迴呼叫等。

此外,函式可以攜帶其他屬性。許多知名的 JavaScript 函式庫大量使用此功能。

它們會建立一個「主」函式,並將許多其他「輔助」函式附加到其中。例如,jQuery 函式庫會建立一個名為 $ 的函式。lodash 函式庫會建立一個函式 _,然後將 _.clone_.keyBy 和其他屬性新增到其中(當您想進一步了解這些屬性時,請參閱文件)。實際上,它們這樣做是為了減少對全域空間的污染,讓單一函式庫只提供一個全域變數。這會降低命名衝突的可能性。

因此,函式本身可以執行有用的工作,也可以在屬性中攜帶許多其他功能。

任務

重要性:5

修改 makeCounter() 的程式碼,讓計數器也可以減少和設定數字

  • counter() 應傳回下一個數字(與之前相同)。
  • counter.set(value) 應將計數器設定為 value
  • counter.decrease() 應將計數器減少 1。

請參閱沙盒程式碼,了解完整的用法範例。

附註:您可以使用封閉或函式屬性來保留目前的計數。或者撰寫兩種變體。

在沙盒中開啟測試。

解答在區域變數中使用 count,但加法方法直接寫入 counter 中。它們共用相同的外部詞法環境,而且也可以存取目前的 count

function makeCounter() {
  let count = 0;

  function counter() {
    return count++;
  }

  counter.set = value => count = value;

  counter.decrease = () => count--;

  return counter;
}

在沙盒中開啟包含測試的解答。

重要性:2

撰寫 sum 函式,其運作方式如下

sum(1)(2) == 3; // 1 + 2
sum(1)(2)(3) == 6; // 1 + 2 + 3
sum(5)(-1)(2) == 6
sum(6)(-1)(-2)(-3) == 0
sum(0)(1)(2)(3)(4)(5) == 15

P.S. 提示:你可能需要為函式設定自訂物件至原始資料轉換。

在沙盒中開啟測試。

  1. 若要讓整體運作無論如何sum 的結果必須是函式。
  2. 該函式必須在呼叫之間記住目前的數值。
  3. 根據工作,當在 == 中使用時,函式必須變成數字。函式是物件,因此轉換會如 物件至原始資料轉換 章節中所述發生,而且我們可以提供傳回數字的自己的方法。

現在的程式碼

function sum(a) {

  let currentSum = a;

  function f(b) {
    currentSum += b;
    return f;
  }

  f.toString = function() {
    return currentSum;
  };

  return f;
}

alert( sum(1)(2) ); // 3
alert( sum(5)(-1)(2) ); // 6
alert( sum(6)(-1)(-2)(-3) ); // 0
alert( sum(0)(1)(2)(3)(4)(5) ); // 15

請注意,sum 函式實際上只運作一次。它傳回函式 f

然後,在每個後續呼叫中,f 會將其參數新增至總和 currentSum,並傳回它自己。

f 的最後一行沒有遞迴。

遞迴如下所示

function f(b) {
  currentSum += b;
  return f(); // <-- recursive call
}

而在我們的案例中,我們只傳回函式,而不會呼叫它

function f(b) {
  currentSum += b;
  return f; // <-- does not call itself, returns itself
}

這個 f 會用於下一個呼叫,再次傳回它自己,次數視需要而定。然後,當用作數字或字串時,toString 會傳回 currentSum。我們也可以在此處使用 Symbol.toPrimitivevalueOf 進行轉換。

在沙盒中開啟包含測試的解答。

教學地圖

留言

在留言前請先閱讀此內容…
  • 如果你有建議要如何改進,請 提交 GitHub 問題 或提出 pull 要求,而非留言。
  • 如果您無法理解文章中的某些內容,請詳細說明。
  • 若要插入少數幾個字元的程式碼,請使用 <code> 標籤,若要插入多行程式碼,請將它們包覆在 <pre> 標籤中,若要插入超過 10 行的程式碼,請使用沙盒 (plnkrjsbincodepen…)