【譯】節選–揭祕命名函式表示式(Named function expressions )

?Badd發表於2019-02-20

簡介

令人驚訝的是,在網上,關於命名函式表示式的討論似乎並不多。這可能因為有很多誤解在流傳。在本文中,我會試著從理論和實踐兩個方面總結這些精彩的Javascript構念,包括其中好的、壞的以及“醜陋”的部分。

簡單說,命名函式表示式只對一種東西有用——除錯工具(debugger)和分析器(profiler)中的描述性函式名。在遞迴時也可能用到函式名,但你很快會發現這種用法在當今往往不怎麼實用。如果你不關心除錯程式碼的體驗,那就不用操心;不然的話,就往下讀,你會看到一些你必須處理的跨瀏覽器問題,以及關於如何處理它們的建議。

I’ll start with a general explanation of what function expressions are how modern debuggers handle them. 請隨意跳到最終方案,該方案解釋瞭如何安全使用這些構念。

函式表示式和函式宣告

ECMAScript中有兩種最常見的方式可以建立function物件:函式宣告和函式表示式。二者的區別確實很讓人迷惘,至少對我來說是這樣的。ECMA規範唯一明確的就是,函式宣告必須帶有一個識別符號(Identifier),或者說函式名,而函式表示式則可以省略它:

FunctionDeclaration :
function Identifier ( FormalParameterListopt ){ FunctionBody }

FunctionExpression :
function Identifieropt ( FormalParameterListopt ){ FunctionBody }

我們可以看到,當識別符號被省略,那段程式碼就是表示式了。但如果識別符號存在呢?怎麼能分清它是一個表示式還是一個宣告?它們看起來一模一樣。ECMAScript似乎是通過上下文來區分它們。如果function foo(){}是一個賦值表示式的一部分,那它就是一個函式表示式。相反地,如果function foo(){}被包含在一個函式體內,或在一個程式(頂層)本身中,那它就是一個函式表示式。

function foo(){} //函式宣告
(function foo(){}); //函式表示式:因為被分組操作符括號包圍

try {
  (var x = 5); //分組操作符只能包含表示式,不能包含語句
} catch(err) {
  // SyntaxError
}
複製程式碼

你可能會想到在用eval計算JSON時,字串通常被括號包圍——eval(`(` + json + `)`)。這當然也是出於相同原因——分組操作符,圓括號強制把JSON括號解析為表示式,而不是一個程式碼塊。(原文:grouping operator, which parenthesis are, forces JSON brackets to be parsed as expression rather than as a block):

try {
  { "x": 5 }; // "{"和"}"解析為程式碼塊
} catch(err) {
  // SyntaxError
}

({ "x": 5 }); //分組操作符強制把"{"和"}"解析為物件字面量
複製程式碼

宣告和表示式的行為有個微妙的不同

首先,即使宣告位於原始碼的最後,函式宣告仍然要比作用域中其他表示式先被解析和計算。下面例子示範了fn函式在alert執行時就已經被定義了,即使它在alert後面:

alert(fn());

function fn() {
  return `Hello world!`;
}
複製程式碼

函式宣告的另一個重要特性就是,根據條件宣告函式是不符合標準的,並且在不同環境中表現不同。絕對不要使用根據條件宣告的函式,而應該使用函式表示式。

// 千萬別這麼寫!
//有些瀏覽器會宣告“foo”為返回“first”的那個,
// 另一些則會宣告為返回“second”的那個

if (true) {
  function foo() {
    return `first`;
  }
}
else {
  function foo() {
    return `second`;
  }
}
foo();

// 應該用表示式方式:
var foo;
if (true) {
  foo = function() {
    return `first`;
  };
}
else {
  foo = function() {
    return `second`;
  };
}
foo();
複製程式碼

如果你對函式宣告的實際生成規則好奇的話,就往下看。否則可以跳過下面的摘錄。

函式宣告只允許出現在程式或另一個函式體中。按照語法,它們不能出現在程式碼塊中({..})——例如ifwhilefor語句。因為程式碼塊只能包含語句,不能包含SourceElement,也就是函式宣告。如果仔細觀察生成規則,就能發現,只有當表示式是表示式語句(ExpressionStatement)的一部分時,它才被允許直接包含在程式碼塊中。然而,表示式語句明確定義了不能以“function”開頭,這就是為何函式宣告不能直接出現在語句或程式碼塊中(記住,程式碼塊也只是一系列語句)。

因為這些限制,不管是函式宣告還是函式表示式,只要直接出現在程式碼塊中(如上例),就會被認為是一個語法錯誤(syntax error)。問題是,我見到的幾乎所有實現都沒有嚴格遵從該規則(BESENDMDScript是例外)。他們用專有方式來解釋(原文:They interpret them in proprietary ways instead)。

值得一提的是,按照規範,實現(implementations)允許引入語法擴充套件(見第十六章),但仍然完全一致。這正是現如今這麼多客戶端存在的情況。Some of them interpret function declarations in blocks as any other function declarations —只是為了把函式宣告提升到作用域頂端;另一些引入不同的語法,遵循稍微複雜的規則。

函式語句

其中一個語法擴充套件就是函式語句,目前在基於Gecko的瀏覽器中實現(測試於Mac OS X中的Firefox 1-3.7a1pre)。不知為何,無論好的壞的方面,這個擴充套件似乎並不廣為人知(MDC提及了該擴充套件,但很簡單)。請記住,我們在此僅以學習為目的討論,滿足我們的好奇心;除非你正在寫針對基於Gecko的環境的指令碼,否則我不推薦依賴該擴充套件。

所以,這些非標準的構念有這些特性:

  1. 在任何允許使用純語句的地方,都可以使用函式語句。這也當然包括程式碼塊:

    if (true) {
      function f(){ }
    }
    else {
      function f(){ }
    }
    複製程式碼
  2. 函式語句像任何其他語句一樣解析,包括條件執行:

    if (true) {
      function foo(){ return 1; }
    }
    else {
      function foo(){ return 2; }
    }
    foo(); // 1
    // 注意,其他環境把這裡的“foo”解讀為函式宣告,
    //第二個“foo”覆寫了第一個, 併產生結果"2",而不是“1”
    複製程式碼
  3. 函式宣告並不在變數例項化的時候被宣告。它們被宣告於執行時,就像函式表示式一樣。然而,一旦宣告瞭,函式語句的識別符號在函式作用域內就可用了。該識別符號的可用性使得函式語句區別於函式表示式(你會在下一章看到命名函式表示式的確切行為)。

    //此時,“foo”還沒有被宣告
    typeof foo; // "undefined"
    if (true) {
      // 一旦進入程式碼塊,“foo”就變成被宣告狀態,
      //在整個作用域內可用
      function foo(){ return 1; }
    }
    else {
      // 沒進入這個程式碼塊,
      //這裡的“foo”永遠不會被宣告
      function foo(){ return 2; }
    }
    typeof foo; // "function"
    複製程式碼

    通常來說,我們可以根據之前的例子,用標準程式碼模擬函式語句行為:

    var foo;
    if (true) {
      foo = function foo(){ return 1; };
    }
    else {
      foo = function foo() { return 2; };
    }
    複製程式碼
  4. 函式語句的字串表示與函式宣告以及命名函式表示式類似(在本例中包括“foo”識別符號):

    if (true) {
      function foo(){ return 1; }
    }
    String(foo); // function foo() { return 1; }
    複製程式碼
  5. 最終,在早期(低於FireFox 3)基於Gecko的實現中出現了一個bug,那就無法用函式語句覆寫函式宣告:

    //函式宣告
    function foo(){ return 1; }
    if (true) {
      //用函式語句覆寫
      function foo(){ return 2; }
    }
    foo(); // 低於FF 3的結果是1,FF 3.5及更高版本是2
    // 然而,覆寫函式表示式就不會這樣
    var foo = function(){ return 1; };
    if (true) {
      function foo(){ return 2; }
    }
    foo(); // 在所有版本中結果都是2
    複製程式碼

注意,舊版Safari(至少1.2.3, 2.0到2.0.4以及3.0.4,更早版本也可能)中,執行函式語句的方式與SpiderMonkey相同。本章所有例子,除了最後一個“bug”例子,在這些版本的Safari中產生與Firefox相同的結果。另一個遵循相同語法的瀏覽器就是黑莓瀏覽器(8230機型起,9000和9350機型)。這種行為的多樣性,再次印證了依賴這些擴充套件是多麼糟糕的主意。

命名函式表示式

函式表示式確實常見。web開發中的一個常見模式就是,基於某種功能測試復刻函式定義,以獲得最佳實踐。這些復刻通常出現在相同作用域,所以總是很有必要使用函式表示式。總之,如目前所知,函式宣告不應該按條件執行:

// `contains` is part of "APE Javascript library" (http://dhtmlkitchen.com/ape/) by Garrett Smith
var contains = (function() {
  var docEl = document.documentElement;

  if (typeof docEl.compareDocumentPosition != `undefined`) {
    return function(el, b) {
      return (el.compareDocumentPosition(b) & 16) !== 0;
    };
  }
  else if (typeof docEl.contains != `undefined`) {
    return function(el, b) {
      return el !== b && el.contains(b);
    };
  }
  return function(el, b) {
    if (el === b) return false;
    while (el != b && (b = b.parentNode) != null);
    return el === b;
  };
})();
複製程式碼

很明顯,當一個函式表示式有一個名字(識別符號),它就是命名函式表示式(named function expression)了。你在的一個例子中看到的——var bar=function foo(){};——恰恰就是一個命名函式表示式,其名字是foo。一個重要細節需要謹記:它的名字只在新定義的函式的作用域中可用;規範要求一個識別符號不該跨作用域使用:

var f = function foo(){
  return typeof foo; // "foo"在最近的大括號內可用
};
// `foo`在外面無效
typeof foo; // "undefined"
f(); // "function"
複製程式碼

所以命名函式表示式有什麼特別嗎?為什麼我們要給它們命名?

因為命名了的函式能夠提升程式碼除錯體驗。當我們除錯一個程式時,有一個描述性的子項的呼叫棧非常有用。

除錯工具(debugger)中的函式名

當一個函式有一個相關鏈的識別符號,除錯工具在檢查呼叫棧時將其作為函式名。某些除錯工具(比如Firebug)會幫你顯示函式名,即使是匿名函式。不幸的是,這些除錯工具通常依賴簡單的解析規則;這種抽象通常脆弱,經常產生錯誤結果。

來看一個簡單例子:

function foo(){
  return bar();
}
function bar(){
  return baz();
}
function baz(){
  debugger;
}
foo();
//這裡,我們用函式宣告定義三個函式
// 當除錯工具停在“debugger”語句,
// (firebug中的)呼叫棧很具有描述性:
baz
bar
foo
expr_test.html()
複製程式碼

可見expr_test.html的全域性作用域呼叫foofoo呼叫barbar呼叫baz。Firebug也會匿名函式解析一個名字:

function foo(){
  return bar();
}
var bar = function(){
  return baz();
}
function baz(){
  debugger;
}
foo();

// Call stack
baz
bar()
foo
expr_test.html()
複製程式碼

但不足之處在於,若一個函式表示式變得非常複雜,除錯工具所做的工作就會變得無用;我們以一個閃亮的問號來代替函式名:

function foo(){
  return bar();
}
var bar = (function(){
  if (window.addEventListener) {
    return function(){
      return baz();
    };
  }
  else if (window.attachEvent) {
    return function() {
      return baz();
    };
  }
})();
function baz(){
  debugger;
}
foo();

// Call stack
baz
(?)()
foo
expr_test.html()
複製程式碼

當一個函式被賦值給多個變數,另一個混亂出現了:

function foo(){
  return baz();
}
var bar = function(){
  debugger;
};
var baz = bar;
bar = function() {
  alert(`spoofed`);
};
foo();

// Call stack:
bar()
foo
expr_test.html()
複製程式碼

你會發現,呼叫棧顯示了foo呼叫了bar。很明顯實際上並非如此。這是因為baz和另一個函式交換了引用——報出“spoofed”的那個。這樣的解析——簡單情況下很棒——在複雜指令碼中無用。

綜上,命名函式表示式是獲得可靠、健壯呼叫棧檢查的唯一方式。讓我們來重寫一下之前的例子:

function foo(){
  return bar();
}
var bar = (function(){
  if (window.addEventListener) {
    return function bar(){
      return baz();
    };
  }
  else if (window.attachEvent) {
    return function bar() {
      return baz();
    };
  }
})();
function baz(){
  debugger;
}
foo();

// 呼叫棧恢復了描述性
baz
bar
foo
expr_test.html()
複製程式碼

JScript bug

不幸的是,JScript(IE的ECMAScript實現)徹底搞亂了命名函式表示式。那時候命名函式表示式被很多人反對,JScript要為負責。可悲的是,即使是上一個版本的JScript(5.8,IE 8),仍然保留著每一個下面說到的怪癖。

讓我們來看看這個破玩意到底哪裡不對勁。理解這些問題能使我們正確處理它們。注意,我把這些差異拆分到不同例子中——清晰起見——即使它們更像是一個主要bug的一系列後果。

例#1: 函式表示式識別符號洩漏進封閉作用域

var f = function g(){};
typeof g; // "function"
複製程式碼

記得嗎?我提到過,一個命名函式的識別符號在封閉作用域中無效。但是,JScript並不認同這點——上面例子中的g解析到了一個函式物件上。這是最廣泛觀察到的差異。這種汙染封閉作用域的行為是危險的——因為作用域可能是全域性的。這種bug不容易排查。

例#2: 命名函式表示式被當成是宣告和表示式

typeof g; // "function"
var f = function g(){};
複製程式碼

正如我之前解釋的,一個特定上下文中的函式宣告要比其他表示式先被解析。上面的例子證明了JScript確實把命名函式表示式當作是函式宣告。你可以看到,在宣告之前可以解析了。

例#3: 命名函式表示式建立兩個不同的函式物件

var f = function g(){};
f === g; // false

f.expando = `foo`;
g.expando; // undefined
複製程式碼

這裡事情就變得有趣了,或者說,完全扯蛋了。在這裡我們要面對這樣的危險性:兩個物件,給一個賦值並不會修改另一個;如果你要使用諸如快取機制之類的,或者在f的屬性中儲存東西再以g的屬性去讀取,就會很麻煩,因為你以為是同一個物件。

例#4: 函式宣告是按序解析的,不受條件程式碼塊影響

var f = function g() {
  return 1;
};
if (false) {
  f = function g(){
    return 2;
  };
}
g(); // 2
複製程式碼

這種例子甚至可能更難追蹤bug。

相關文章