深刻理解JavaScript系列(15):函數(Functions)

介紹

本章節咱們要着重介紹的是一個很是常見的ECMAScript對象——函數(function),咱們將詳細講解一下各類類型的函數是如何影響上下文的變量對象以及每一個函數的做用域鏈都包含什麼,以及回答諸如像下面這樣的問題:下面聲明的函數有什麼區別麼?(若是有,區別是什麼)。html

原文:http://dmitrysoshnikov.com/ecmascript/chapter-5-functions/
var foo = function () {
...
};

平時的慣用方式:前端

function foo() {
...
}

或者,下面的函數爲何要用括號括住?算法

(function () {
...
})();

關於具體的介紹,早前面的12章變量對象14章做用域鏈都有介紹,若是須要詳細瞭解這些內容,請查詢上述2個章節的詳細內容。express

但咱們依然要一個一個分別看看,首先從函數的類型講起:數組

函數類型

在ECMAScript 中有三種函數類型:函數聲明,函數表達式和函數構造器建立的函數。每一種都有本身的特色。瀏覽器

函數聲明

函數聲明(縮寫爲FD)是這樣一種函數:
  1. 有一個特定的名稱
  2. 在源碼中的位置:要麼處於程序級(Program level),要麼處於其它函數的主體(FunctionBody)中
  3. 在進入上下文階段建立
  4. 影響變量對象
  5. 如下面的方式聲明
function exampleFunc() {
...
}

這種函數類型的主要特色在於它們僅僅影響變量對象(即存儲在上下文的VO中的變量對象)。該特色也解釋了第二個重要點(它是變量對象特性的結果)——在代碼執行階段它們已經可用(由於FD在進入上下文階段已經存在於VO中——代碼執行以前)。閉包

例如(函數在其聲明以前被調用)ecmascript

foo();

function foo() {
alert('foo');
}

另一個重點知識點是上述定義中的第二點——函數聲明在源碼中的位置:ide

// 函數能夠在以下地方聲明:
// 1) 直接在全局上下文中
function globalFD() {
// 2) 或者在一個函數的函數體內
function innerFD() {}
}

只有這2個位置能夠聲明函數,也就是說:不可能在表達式位置或一個代碼塊中定義它。函數

另一種能夠取代函數聲明的方式是函數表達式,解釋以下:

函數表達式

函數表達式(縮寫爲FE)是這樣一種函數:
  1. 在源碼中須出如今表達式的位置
  2. 有可選的名稱
  3. 不會影響變量對象
  4. 在代碼執行階段建立

這種函數類型的主要特色在於它在源碼中老是處在表達式的位置。最簡單的一個例子就是一個賦值聲明:

var foo = function () {
...
};

該例演示是讓一個匿名函數表達式賦值給變量foo,而後該函數能夠用foo這個名稱進行訪問——foo()。

同時和定義裏描述的同樣,函數表達式也能夠擁有可選的名稱:

var foo = function _foo() {
...
};

須要注意的是,在外部FE經過變量「foo」來訪問——foo(),而在函數內部(如遞歸調用),有可能使用名稱「_foo」。

若是FE有一個名稱,就很難與FD區分。可是,若是你明白定義,區分起來就簡單明瞭:FE老是處在表達式的位置。在下面的例子中咱們能夠看到各類ECMAScript 表達式:

// 圓括號(分組操做符)內只能是表達式
(function foo() {});

// 在數組初始化器內只能是表達式
[function bar() {}];

// 逗號也只能操做表達式
1, function baz() {};

表達式定義裏說明:FE只能在代碼執行階段建立並且不存在於變量對象中,讓咱們來看一個示例行爲:

// FE在定義階段以前不可用(由於它是在代碼執行階段建立)

alert(foo); // "foo" 未定義

(function foo() {});

// 定義階段以後也不可用,由於他不在變量對象VO中

alert(foo); // "foo" 未定義

至關一部分問題出現了,咱們爲何須要函數表達式?答案很明顯——在表達式中使用它們,」不會污染」變量對象。最簡單的例子是將一個函數做爲參數傳遞給其它函數。

function foo(callback) {
callback();
}

foo(function bar() {
alert('foo.bar');
});

foo(function baz() {
alert('foo.baz');
});

在上述例子裏,FE賦值給了一個變量(也就是參數),函數將該表達式保存在內存中,並經過變量名來訪問(由於變量影響變量對象),以下:

var foo = function () {
alert('foo');
};

foo();

另一個例子是建立封裝的閉包從外部上下文中隱藏輔助性數據(在下面的例子中咱們使用FE,它在建立後當即調用):

var foo = {};

(function initialize() {

var x = 10;

foo.bar = function () {
alert(x);
};

})();

foo.bar(); // 10;

alert(x); // "x" 未定義

咱們看到函數foo.bar(經過[[Scope]]屬性)訪問到函數initialize的內部變量「x」。同時,「x」在外部不能直接訪問。在許多庫中,這種策略經常使用來建立」私有」數據和隱藏輔助實體。在這種模式中,初始化的FE的名稱一般被忽略:

(function () {
// 初始化做用域
})();

還有一個例子是:在代碼執行階段經過條件語句進行建立FE,不會污染變量對象VO。

var foo = 10;

var bar = (foo % 2 == 0
? function () { alert(0); }
: function () { alert(1); }
);

bar(); // 0

關於圓括號的問題

讓咱們回頭並回答在文章開頭提到的問題——」爲什麼在函數建立後的當即調用中必須用圓括號來包圍它?」,答案就是:表達式句子的限制就是這樣的。

按照標準,表達式語句不能以一個大括號{開始是由於他很難與代碼塊區分,一樣,他也不能以函數關鍵字開始,由於很難與函數聲明進行區分。即,因此,若是咱們定義一個當即執行的函數,在其建立後當即按如下方式調用:

function () {
...
}();

// 即使有名稱

function foo() {
...
}();

咱們使用了函數聲明,上述2個定義,解釋器在解釋的時候都會報錯,可是可能有多種緣由。

若是在全局代碼裏定義(也就是程序級別),解釋器會將它看作是函數聲明,由於他是以function關鍵字開頭,第一個例子,咱們會獲得SyntaxError錯誤,是由於函數聲明沒有名字(咱們前面提到了函數聲明必須有名字)。

第二個例子,咱們有一個名稱爲foo的一個函數聲明正常建立,可是咱們依然獲得了一個語法錯誤——沒有任何表達式的分組操做符錯誤。在函數聲明後面他確實是一個分組操做符,而不是一個函數調用所使用的圓括號。因此若是咱們聲明以下代碼:

// "foo" 是一個函數聲明,在進入上下文的時候建立

alert(foo); // 函數

function foo(x) {
alert(x);
}(1); // 這只是一個分組操做符,不是函數調用!

foo(10); // 這纔是一個真正的函數調用,結果是10

上述代碼是沒有問題的,由於聲明的時候產生了2個對象:一個函數聲明,一個帶有1的分組操做,上面的例子能夠理解爲以下代碼:

// 函數聲明
function foo(x) {
alert(x);
}

// 一個分組操做符,包含一個表達式1
(1);

// 另一個操做符,包含一個function表達式
(function () {});

// 這個操做符裏,包含的也是一個表達式"foo"
("foo");

// 等等

若是咱們定義一個以下代碼(定義裏包含一個語句),咱們可能會說,定義歧義,會獲得報錯:

if (true) function foo() {alert(1)}

根據規範,上述代碼是錯誤的(一個表達式語句不能以function關鍵字開頭),但下面的例子就沒有報錯,想一想爲何?

咱們若是來告訴解釋器:我就像在函數聲明以後當即調用,答案是很明確的,你得聲明函數表達式function expression,而不是函數聲明function declaration,而且建立表達式最簡單的方式就是用分組操做符括號,裏邊放入的永遠是表達式,因此解釋器在解釋的時候就不會出現歧義。在代碼執行階段這個的function就會被建立,而且當即執行,而後自動銷燬(若是沒有引用的話)。

(function foo(x) {
alert(x);
})(1); // 這纔是調用,不是分組操做符

上述代碼就是咱們所說的在用括號括住一個表達式,而後經過(1)去調用。

注意,下面一個當即執行的函數,周圍的括號不是必須的,由於函數已經處在表達式的位置,解析器知道它處理的是在函數執行階段應該被建立的FE,這樣在函數建立後當即調用了函數。

var foo = {

bar: function (x) {
return x % 2 != 0 ? 'yes' : 'no';
}(1)

};

alert(foo.bar); // 'yes'

就像咱們看到的,foo.bar是一個字符串而不是一個函數,這裏的函數僅僅用來根據條件參數初始化這個屬性——它建立後並當即調用。

所以,」關於圓括號」問題完整的答案以下:當函數不在表達式的位置的時候,分組操做符圓括號是必須的——也就是手工將函數轉化成FE。
若是解析器知道它處理的是FE,就不必用圓括號。

除了大括號之外,以下形式也能夠將函數轉化爲FE類型,例如:

// 注意是1,後面的聲明
1, function () {
alert('anonymous function is called');
}();

// 或者這個
!function () {
alert('ECMAScript');
}();

// 其它手工轉化的形式

...

可是,在這個例子中,圓括號是最簡潔的方式。

順便提一句,組表達式包圍函數描述能夠沒有調用圓括號,也可包含調用圓括號,即,下面的兩個表達式都是正確的FE。

實現擴展:函數語句

下面的代碼,根據貴方任何一個function聲明都不該該被執行:

if (true) {

function foo() {
alert(0);
}

} else {

function foo() {
alert(1);
}

}

foo(); // 1 or 0 ?實際在上不一樣環境下測試得出個結果不同

這裏有必要說明的是,按照標準,這種句法結構一般是不正確的,由於咱們還記得,一個函數聲明(FD)不能出如今代碼塊中(這裏if和else包含代碼塊)。咱們曾經講過,FD僅出如今兩個位置:程序級(Program level)或直接位於其它函數體中。

由於代碼塊僅包含語句,因此這是不正確的。能夠出如今塊中的函數的惟一位置是這些語句中的一個——上面已經討論過的表達式語句。可是,按照定義它不能以大括號開始(既然它有別於代碼塊)或以一個函數關鍵字開始(既然它有別於FD)。

可是,在標準的錯誤處理章節中,它容許程序語法的擴展執行。這樣的擴展之一就是咱們見到的出如今代碼塊中的函數。在這個例子中,現今的全部存在的執行都不會拋出異常,都會處理它。可是它們都有本身的方式。

if-else分支語句的出現意味着一個動態的選擇。即,從邏輯上來講,它應該是在代碼執行階段動態建立的函數表達式(FE)。可是,大多數執行在進入上下文階段時簡單的建立函數聲明(FD),並使用最後聲明的函數。即,函數foo將顯示」1″,事實上else分支將永遠不會執行。

可是,SpiderMonkey (和TraceMonkey)以兩種方式對待這種狀況:一方面它不會將函數做爲聲明處理(即,函數在代碼執行階段根據條件建立),但另外一方面,既然沒有括號包圍(再次出現解析錯誤——」與FD有別」),他們不能被調用,因此也不是真正的函數表達式,它儲存在變量對象中。

我我的認爲這個例子中SpiderMonkey 的行爲是正確的,拆分了它自身的函數中間類型——(FE+FD)。這些函數在合適的時間建立,根據條件,也不像FE,倒像一個能夠從外部調用的FD,SpiderMonkey將這種語法擴展 稱之爲函數語句(縮寫爲FS);該語法在MDC中說起過。

命名函數表達式的特性

當函數表達式FE有一個名稱(稱爲命名函數表達式,縮寫爲NFE)時,將會出現一個重要的特色。從定義(正如咱們從上面示例中看到的那樣)中咱們知道函數表達式不會影響一個上下文的變量對象(那樣意味着既不可能經過名稱在函數聲明以前調用它,也不可能在聲明以後調用它)。可是,FE在遞歸調用中能夠經過名稱調用自身。

(function foo(bar) {

if (bar) {
return;
}

foo(true); // "foo" 是可用的

})();

// 在外部,是不可用的
foo(); // "foo" 未定義

「foo」儲存在什麼地方?在foo的活動對象中?不是,由於在foo中沒有定義任何」foo」。在上下文的父變量對象中建立foo?也不是,由於按照定義——FE不會影響VO(變量對象)——從外部調用foo咱們能夠實實在在的看到。那麼在哪裏呢?

如下是關鍵點。當解釋器在代碼執行階段遇到命名的FE時,在FE建立以前,它建立了輔助的特定對象,並添加到當前做用域鏈的最前端。而後它建立了FE,此時(正如咱們在第四章 做用域鏈知道的那樣)函數獲取了[[Scope]] 屬性——建立這個函數上下文的做用域鏈)。此後,FE的名稱添加到特定對象上做爲惟一的屬性;這個屬性的值是引用到FE上。最後一步是從父做用域鏈中移除那個特定的對象。讓咱們在僞碼中看看這個算法:

specialObject = {};

Scope = specialObject + Scope;

foo = new FunctionExpression;
foo.[[Scope]] = Scope;
specialObject.foo = foo; // {DontDelete}, {ReadOnly}

delete Scope[0]; // 從做用域鏈中刪除定義的特殊對象specialObject

所以,在函數外部這個名稱不可用的(由於它不在父做用域鏈中),可是,特定對象已經存儲在函數的[[scope]]中,在那裏名稱是可用的。

可是須要注意的是一些實現(如Rhino)不是在特定對象中而是在FE的激活對象中存儲這個可選的名稱。Microsoft 中的執行徹底打破了FE規則,它在父變量對象中保持了這個名稱,這樣函數在外部變得能夠訪問。

NFE 與SpiderMonkey

咱們來看看NFE和SpiderMonkey的區別,SpiderMonkey 的一些版本有一個與特定對象相關的屬性,它能夠做爲bug來對待(雖然按照標準全部的都那樣實現了,但更像一個ECMAScript標準上的bug)。它與標識符的解析機制相關:做用域鏈的分析是二維的,在標識符的解析中,一樣考慮到做用域鏈中每一個對象的原型鏈。

若是咱們在Object.prototype中定義一個屬性,並引用一個」不存在(nonexistent)」的變量。咱們就能看到這種執行機制。這樣,在下面示例的」x」解析中,咱們將到達全局對象,可是沒發現」x」。可是,在SpiderMonkey 中全局對象繼承了Object.prototype中的屬性,相應地,」x」也能被解析。

Object.prototype.x = 10;

(function () {
alert(x); // 10
})();

活動對象沒有原型。按照一樣的起始條件,在上面的例子中,不可能看到內部函數的這種行爲。若是定義一個局部變量」x」,並定義內部函數(FD或匿名的FE),而後再內部函數中引用」x」。那麼這個變量將在父函數上下文(即,應該在哪裏被解析)中而不是在Object.prototype中被解析。

Object.prototype.x = 10;

function foo() {

var x = 20;

// 函數聲明

function bar() {
alert(x);
}

bar(); // 20, 從foo的變量對象AO中查詢

// 匿名函數表達式也是同樣

(function () {
alert(x); // 20, 也是從foo的變量對象AO中查詢
})();

}

foo();

儘管如此,一些執行會出現例外,它給活動對象設置了一個原型。所以,在Blackberry 的執行中,上面例子中的」x」被解析爲」10″。也就是說,既然在Object.prototype中已經找到了foo的值,那麼它就不會到達foo的活動對象。

AO(bar FD or anonymous FE) -> no ->
AO(bar FD or anonymous FE).[[Prototype]] -> yes - 10

在SpiderMonkey 中,一樣的情形咱們徹底能夠在命名FE的特定對象中看到。這個特定的對象(按照標準)是普通對象——」就像表達式new Object()「,相應地,它應該從Object.prototype 繼承屬性,這偏偏是咱們在SpiderMonkey (1.7以上的版本)看到的執行。其他的執行(包括新的TraceMonkey)不會爲特定的對象設置一個原型。

function foo() {

var x = 10;

(function bar() {

alert(x); // 20, 不上10,不是從foo的活動對象上獲得的

// "x"從鏈上查找:
// AO(bar) - no -> __specialObject(bar) -> no
// __specialObject(bar).[[Prototype]] - yes: 20

})();
}

Object.prototype.x = 20;

foo();

NFE與Jscript

當前IE瀏覽器(直到JScript 5.8 — IE8)中內置的JScript 執行有不少與函數表達式(NFE)相關的bug。全部的這些bug都徹底與ECMA-262-3標準矛盾;有些可能會致使嚴重的錯誤。

首先,這個例子中JScript 破壞了FE的主要規則,它不該該經過函數名存儲在變量對象中。可選的FE名稱應該存儲在特定的對象中,並只能在函數自身(而不是別的地方)中訪問。但IE直接將它存儲在父變量對象中。此外,命名的FE在JScript 中做爲函數聲明(FD)對待。即建立於進入上下文的階段,在源代碼中的定義以前能夠訪問。

// FE 在變量對象裏可見
testNFE();

(function testNFE() {
alert('testNFE');
});

// FE 在定義結束之後也可見
// 就像函數聲明同樣
testNFE();

正如咱們所見,它徹底違背了規則。

其次,在聲明中將命名FE賦給一個變量時,JScript 建立了兩個不一樣的函數對象。邏輯上(特別注意的是在NFE的外部它的名稱根本不該該被訪問)很難命名這種行爲。

var foo = function bar() {
alert('foo');
};

alert(typeof bar); // "function",

// 有趣的是
alert(foo === bar); // false!

foo.x = 10;
alert(bar.x); // 未定義

// 但執行的時候結果同樣

foo(); // "foo"
bar(); // "foo"

再次看到,已經亂成一片了。

可是,須要注意的是,若是與變量賦值分開,單獨描述NFE(如經過組運算符),而後將它賦給一個變量,並檢查其相等性,結果爲true,就好像是一個對象。

(function bar() {});

var foo = bar;

alert(foo === bar); // true

foo.x = 10;
alert(bar.x); // 10

此時是能夠解釋的。實際上,再次建立兩個對象,但那樣作事實上仍保持一個。若是咱們再次認爲這裏的NFE被做爲FD對待,而後在進入上下文階段建立FD bar。此後,在代碼執行階段第二個對象——函數表達式(FE)bar 被建立,它不會被存儲。相應地,沒有FE bar的任何引用,它被移除了。這樣就只有一個對象——FD bar,對它的引用賦給了變量foo。

第三,就經過arguments.callee間接引用一個函數而言,它引用的是被激活的那個對象的名稱(確切的說——再這裏有兩個函數對象。

var foo = function bar() {

alert([
arguments.callee === foo,
arguments.callee === bar
]);

};

foo(); // [true, false]
bar(); // [false, true]

第四,JScript 像對待普通的FD同樣對待NFE,他不服從條件表達式規則。即,就像一個FD,NFE在進入上下文時建立,在代碼中最後的定義被使用。

var foo = function bar() {
alert(1);
};

if (false) {

foo = function bar() {
alert(2);
};

}
bar(); // 2
foo(); // 1

這種行爲從」邏輯上」也能夠解釋。在進入上下文階段,最後遇到的FD bar被建立,即包含alert(2)的函數。此後,在代碼執行階段,新的函數——FE bar建立,對它的引用賦給了變量foo。這樣foo激活產生alert(1)。邏輯很清楚,但考慮到IE的bug,既然執行明顯被破壞,並依賴於JScript 的bug,我給單詞」邏輯上(logically)」加上了引號。

JScript 的第五個bug與全局對象的屬性建立相關,全局對象由賦值給一個未限定的標識符(即,沒有var關鍵字)來生成。既然NFE在這被做爲FD對待,相應地,它存儲在變量對象中,賦給一個未限定的標識符(即不是賦給變量而是全局對象的普通屬性),萬一函數的名稱與未限定的標識符相同,這樣該屬性就不是全局的了。

(function () {

// 不用var的話,就不是當前上下文的一個變量了
// 而是全局對象的一個屬性

foo = function foo() {};

})();

// 但,在匿名函數的外部,foo這個名字是不可用的

alert(typeof foo); // 未定義

「邏輯」已經很清楚了:在進入上下文階段,函數聲明foo取得了匿名函數局部上下文的活動對象。在代碼執行階段,名稱foo在AO中已經存在,即,它被做爲局部變量。相應地,在賦值操做中,只是簡單的更新已存在於AO中的屬性foo,而不是按照ECMA-262-3的邏輯建立全局對象的新屬性。

經過函數構造器建立的函數

既然這種函數對象也有本身的特點,咱們將它與FD和FE區分開來。其主要特色在於這種函數的[[Scope]]屬性僅包含全局對象:

var x = 10;

function foo() {

var x = 20;
var y = 30;

var bar = new Function('alert(x); alert(y);');

bar(); // 10, "y" 未定義

}

咱們看到,函數bar的[[Scope]]屬性不包含foo上下文的Ao——變量」y」不能訪問,變量」x」從全局對象中取得。順便提醒一句,Function構造器既可以使用new 關鍵字,也能夠沒有,這樣說來,這些變體是等價的。

這些函數的其餘特色與Equated Grammar Productions 和Joined Objects相關。做爲優化建議(可是,實現上能夠不使用優化),規範提供了這些機制。如,若是咱們有一個100個元素的數組,在函數的一個循環中,執行可能使用Joined Objects 機制。結果是數組中的全部元素僅一個函數對象可使用。

var a = [];

for (var k = 0; k < 100; k++) {
a[k] = function () {}; // 可能使用了joined objects
}

可是經過函數構造器建立的函數不會被鏈接。

var a = [];

for (var k = 0; k < 100; k++) {
a[k] = Function(''); // 一直是100個不一樣的函數
}

另一個與聯合對象(joined objects)相關的例子:

function foo() {

function bar(z) {
return z * z;
}

return bar;
}

var x = foo();
var y = foo();

這裏的實現,也有權利鏈接對象x和對象y(使用同一個對象),由於函數(包括它們的內部[[Scope]] 屬性)在根本上是沒有區別的。所以,經過函數構造器建立的函數老是須要更多的內存資源。

建立函數的算法

下面的僞碼描述了函數建立的算法(與聯合對象相關的步驟除外)。這些描述有助於你理解ECMAScript中函數對象的更多細節。這種算法適合全部的函數類型。

F = new NativeObject();

// 屬性[[Class]]是"Function"
F.[[Class]] = "Function"

// 函數對象的原型是Function的原型
F.[[Prototype]] = Function.prototype

// 醫用到函數自身
// 調用表達式F的時候激活[[Call]]
// 而且建立新的執行上下文
F.[[Call]] = <reference to function>

// 在對象的普通構造器裏編譯
// [[Construct]] 經過new關鍵字激活
// 而且給新對象分配內存
// 而後調用F.[[Call]]初始化做爲this傳遞的新建立的對象
F.[[Construct]] = internalConstructor

// 當前執行上下文的做用域鏈
// 例如,建立F的上下文
F.[[Scope]] = activeContext.Scope
// 若是函數經過new Function(...)來建立,
// 那麼
F.[[Scope]] = globalContext.Scope

// 傳入參數的個數
F.length = countParameters

// F對象建立的原型
__objectPrototype = new Object();
__objectPrototype.constructor = F // {DontEnum}, 在循環裏不可枚舉x
F.prototype = __objectPrototype

return F

注意,F.[[Prototype]]是函數(構造器)的一個原型,F.prototype是經過這個函數建立的對象的原型(由於術語經常混亂,一些文章中F.prototype被稱之爲「構造器的原型」,這是不正確的)。

結論

這篇文章有些長。可是,當咱們在接下來關於對象和原型章節中將繼續討論函數,一樣,我很樂意在評論中回答您的任何問題。

其它參考

相關文章
相關標籤/搜索