函式進階內容 函式物件,NFE
函式物件,NFE
我們已經知道,在 JavaScript 中,函式就是值。
JavaScript 中的每個值都有一種型別,那麼函式是什麼型別呢?
在 JavaScript 中,函式就是物件。
一個容易理解的方式是把函式想象成可被呼叫的“行為物件(action object)”。我們不僅可以呼叫它們,還能把它們當作物件來處理:增/刪屬性,按引用傳遞等。
屬性 “name”
函式物件包含一些便於使用的屬性。
比如,一個函式的名字可以通過屬性 “name” 來訪問:
function sayHi() {
alert("Hi");
}
alert(sayHi.name); // sayHi
更有趣的是,名稱賦值的邏輯很智慧。即使函式被建立時沒有名字,名稱賦值的邏輯也能給它賦予一個正確的名字,然後進行賦值:
let sayHi = function() {
alert("Hi");
};
alert(sayHi.name); // sayHi(有名字!)
當以預設值的方式完成了賦值時,它也有效:
function f(sayHi = function() {}) {
alert(sayHi.name); // sayHi(生效了!)
}
f();
規範中把這種特性叫做「上下文命名」。如果函式自己沒有提供,那麼在賦值中,會根據上下文來推測一個。
物件方法也有名字:
let user = {
sayHi() {
// ...
},
sayBye: function() {
// ...
}
}
alert(user.sayHi.name); // sayHi
alert(user.sayBye.name); // sayBye
這沒有什麼神奇的。有時會出現無法推測名字的情況。此時,屬性name
會是空,像這樣:
// 函式是在陣列中建立的
let arr = [function() {}];
alert( arr[0].name ); // <空字串>
// 引擎無法設定正確的名字,所以沒有值
而實際上,大多數函式都是有名字的。
屬性 “length”
還有另一個內建屬性 “length”,它返回函式入參的個數,比如:
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
有時在操作其它函式的函式中用於做內省/執行時檢查(introspection)。
比如,下面的程式碼中函式ask
接受一個詢問答案的引數question
和可能包含任意數量handler
的引數...handlers
。
當用戶提供了自己的答案後,函式會呼叫那些handlers
。我們可以傳入兩種handlers
:
- 一種是無參函式,它僅在使用者回答給出積極的答案時被呼叫。
- 一種是有參函式,它在兩種情況都會被呼叫,並且返回一個答案。
為了正確地呼叫handler
,我們需要檢查handler.length
屬性。
我們的想法是,我們用一個簡單的無引數的handler
語法來處理積極的回答(最常見的變體),但也要能夠提供通用的 handler:
function ask(question, ...handlers) {
let isYes = confirm(question);
for(let handler of handlers) {
if (handler.length == 0) {
if (isYes) handler();
} else {
handler(isYes);
}
}
}
// 對於積極的回答,兩個 handler 都會被呼叫
// 對於負面的回答,只有第二個 handler 被呼叫
ask("Question?", () => alert('You said yes'), result => alert(result));
這種特別的情況就是所謂的多型性—— 根據引數的型別,或者根據在我們的具體情景下的length
來做不同的處理。這種思想在 JavaScript 的庫裡有應用。
自定義屬性
我們也可以新增我們自己的屬性。
這裡我們添加了counter
屬性,用來跟蹤總的呼叫次數:
function sayHi() {
alert("Hi");
// 計算呼叫次數
sayHi.counter++;
}
sayHi.counter = 0; // 初始值
sayHi(); // Hi
sayHi(); // Hi
alert( `Called ${sayHi.counter} times` ); // Called 2 times
屬性不是變數
被賦值給函式的屬性,比如sayHi.counter = 0
,不會在函式內定義一個區域性變數counter
。換句話說,屬性counter
和變數let counter
是毫不相關的兩個東西。
我們可以把函式當作物件,在它裡面儲存屬性,但是這對它的執行沒有任何影響。變數不是函式屬性,反之亦然。它們之間是平行的。
函式屬性有時會用來替代閉包。例如,我們可以使用函式屬性將變數作用域,閉包章節中 counter 函式的例子進行重寫:
function makeCounter() {
// 不需要這個了
// 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,Named Function Expression),指帶有名字的函式表示式的術語。
例如,讓我們寫一個普通的函式表示式:
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
有兩個特殊的地方,這就是新增它的原因:
- 它允許函式在內部引用自己。
- 它在函式外是不可見的。
例如,下面的函式sayHi
會在沒有入參who
時,以"Guest"
為入參呼叫自己:
let sayHi = function func(who) {
if (who) {
alert(`Hello, ${who}`);
} else {
func("Guest"); // 使用 func 再次呼叫函式自身
}
};
sayHi(); // Hello, Guest
// 但這不工作:
func(); // Error, func is not defined(在函式外不可見)
我們為什麼使用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,巢狀呼叫 sayHi 不再有效!
發生這種情況是因為該函式從它的外部詞法環境獲取sayHi
。沒有區域性的sayHi
了,所以使用外部變數。而當呼叫時,外部的sayHi
是null
。
我們給函式表示式新增的可選的名字,正是用來解決這類問題的。
讓我們使用它來修復我們的程式碼:
let sayHi = function func(who) {
if (who) {
alert(`Hello, ${who}`);
} else {
func("Guest"); // 現在一切正常
}
};
let welcome = sayHi;
sayHi = null;
welcome(); // Hello, Guest(巢狀呼叫有效)
現在它可以正常運行了,因為名字func
是函式區域性域的。它不是從外部獲取的(而且它對外部也是不可見的)。規範確保它只會引用當前函式。
外部程式碼仍然有該函式的sayHi
或welcome
變數。而且func
是一個“內部函式名”,可用於函式在自身內部進行自呼叫。
這裡所講的“內部名”特性只針對函式表示式,而不是函式宣告。對於函式宣告,沒有用來新增“內部”名的語法。
有時,當我們需要一個可靠的內部名時,這就成為了你把函式宣告重寫成函式表示式的理由了。
總結
函式就是物件。
我們介紹了它們的一些屬性:
name
—— 函式的名字。通常取自函式定義,但如果函式定義時沒設定函式名,JavaScript 會嘗試通過函式的上下文猜一個函式名(例如把賦值的變數名取為函式名)。length
—— 函式定義時的入參的個數。Rest 引數不參與計數。
如果函式是通過函式表示式的形式被宣告的(不是在主程式碼流裡),並且附帶了名字,那麼它被稱為命名函式表示式(Named Function Expression)。這個名字可以用於在該函式內部進行自呼叫,例如遞迴呼叫等。
此外,函式可以帶有額外的屬性。很多知名的 JavaScript 庫都充分利用了這個功能。
它們建立一個“主”函式,然後給它附加很多其它“輔助”函式。例如,jQuery庫建立了一個名為$
的函式。lodash庫建立一個_
函式,然後為其添加了_.add
、_.keyBy
以及其它屬性(想要了解更多內容,參查閱docs)。實際上,它們這麼做是為了減少對全域性空間的汙染,這樣一個庫就只會有一個全域性變數。這樣就降低了命名衝突的可能性。
所以,一個函式本身可以完成一項有用的工作,還可以在自身的屬性中附帶許多其他功能。