esnext:Function.prototype.toString 終於有規範了

紫雲飛發表於2017-03-29

從 ES1 到 ES5 的這 14 年時間裡,Function.prototype.toString 的規範一字未變:

An implementation-dependent representation of the function is returned. This representation has the syntax of a FunctionDeclaration. Note in particular that the use and placement of white space, line terminators, and semicolons within the representation String is implementation-dependent.

這段話說了兩點內容:

1. toString() 返回的字串應該符合 FunctionDeclaration 的語法

2. 要不要保留原始的空白符和分號,規範不管

規範管的一點引擎們從來沒遵守

先說第一點,規範管的。FunctionDeclaration 就是我們通常說的函式宣告,語法是這樣的:

function BindingIdentifier (FormalParameters) { FunctionBody }

規範要求所有函式 toString() 時返回的字串都得符合函式宣告的語法,但其實從 1995 年到今天沒有一個 JS 引擎做到過,違背這個約束的主要有下面兩種情況:

1. 匿名函式表示式 toString() 時返回的是 FunctionExpression 而不是 FunctionDeclaration

var f = function (){}
f.toString() // "function (){}"

"function (){}" 不符合函式宣告的語法,因為缺少函式名,返回的實際上是個函式表示式,直到現在所有引擎也都這樣。

額外小知識:V8 去年實現過將推斷出的函式名放到 function 和 引數列表之間,後來又刪了 

2. 內建函式、宿主函式、繫結函式 toString() 時返回的 FunctionDeclaration 不合法

Object.toString() // "function Object() { [native code] }"
alert.toString() // "function alert() { [native code] }"
(function (){}).bind().toString() // "function () { [native code] }"

包含 [native code] 字樣的函式體顯然不是合法的 JS 語法,更不可能符合 FunctionDeclaration,實際上內建函式和宿主函式根本不是用 JS 寫的,他們不可能有真正的函式體。

這兩點都是需要規範來澄清的,esdiscuss 上也有過多次討論,ES4 的規範草案曾經專門澄清過第一點

Description

The intrinsic toString method converts the executable code of the function to a string representation. This representation has the syntax of a FunctionDeclaration or FunctionExpression. Note in particular that the use and placement of white space, line terminators, and semicolons within the representation string is implementation-dependent.

COMPATIBILITY NOTE   ES3 required the syntax to be that of a FunctionDeclaration only, but that made it impossible to produce a string representation for functions created from unnamed function expressions.

也就是說 ES4 想把曾經限制的 FunctionDeclaration 擴充套件成 “FunctionDeclaration 或 FunctionExpression”,但後來的事你就知道了,ES4 流產了,ES5 並沒有改 ES3 裡的這一段話。

規範不管的一點更是一團糟

關於空白符和分號的處理,引擎愛怎麼實現就怎麼實現,比如下面這個簡單的函式:

function                f                (){return 1}
f.toString()

// Chrome 下是 "function f(){return 1}",函式名右邊的空白符沒了,左邊也只剩下一個空格
// Firefox 17 前曾是 "function f() {\n    return 1;\n}",除了同上面 Chrome 相同的一點外,函式體內多了一些空白符,還多了個分號
// Firefox 17 之後是 "function f(){return 1}",和 Chrome 一樣了
// IE 所有版本都是 "function                f                (){return 1}",原始碼原封不動返回

實際上各引擎實現有差異的不止空白符、分號這兩個語法元素,還有註釋,甚至還有常規的語句,比如:

function f() {
  // foo
  /* bar */
  1+2
  return 2 + 2
}

console.log(f.toString())

上面的程式碼在 Firefox 17 之前輸出會是:

function f() {
    return 4;
}

函式體內部只剩下了一行,註釋都丟了,一些程式碼也被優化了。

還有下面的程式碼:

(function() {
  "use strict"

  function f() {1+1}
  console.log(f.toString())
})()

在 Firefox 48 之前會輸出:

function f() {
"use strict";
1+1}

就是說它會把繼承自上層作用域的嚴格模式在自己的原始碼中體現出來。

還有各種曾經的瀏覽器有著各種各樣的奇怪表現,kangax 在 09 年和 14 年分別寫文章講過 http://perfectionkills.com/those-tricky-functions/ http://perfectionkills.com/state-of-function-decompilation-in-javascript/ 時到如今,研究這些歷史表現已經意義不大了,我們統統跳過。

ES6 的澄清

可以這麼說,函式的 toString() 方法在 ES6 之前就沒有規範。ES6 中引入了箭頭函式、生成器函式、類等 7 種新的函式語法,同時對函式的 toString() 方法做了更詳細的規定:

  • The string representation must have the syntax of a FunctionDeclarationFunctionExpression,GeneratorDeclaration, GeneratorExpression, ClassDeclarationClassExpressionArrowFunction,MethodDefinition, or GeneratorMethod depending upon the actual characteristics of the object.

  • The use and placement of white space, line terminators, and semicolons within the representation String is implementation-dependent.

  • If the object was defined using ECMAScript code and the returned string representation is not in the form of a MethodDefinition or GeneratorMethod then the representation must be such that if the string is evaluated, using eval in a lexical context that is equivalent to the lexical context used to create the original object, it will result in a new functionally equivalent object. In that case the returned source code must not mention freely any variables that were not mentioned freely by the original function’s source code, even if these “extra” names were originally in scope.

  • If the implementation cannot produce a source code string that meets these criteria then it must return a string for which eval will throw a SyntaxError exception.

第一點是對舊規範的澄清,說返回的字串不必須是函式宣告瞭;第二點沒變化;第三四點是新加的,三是說一個函式 fn 和通過 eval(fn.toString()) 生成的新函式功能要等效;四是說假如引擎做不到前面規定的這些,那就必須讓 toString() 返回一個包含非法語法的字串,即向前不相容。

真正的規範來了

但其實 ES6 裡的新規定仍然很模糊,比如說兩個函式功能等效,那究竟啥是功能等效,還有仍然不管空白符和分號,這些導致各瀏覽器中 toString() 的返回結果仍然可以是五花八門。 

ES6 之後,一個新的提案嘗試對 Function.prototype.toString 進行真正的規定,目前在 Stage 3 階段,Chrome 和 Firefox 已經基本實現了這一提案,其實這個新的規範很好記憶:

1. 凡是有完整原始碼的,一字不落把原始碼返回,比如:

 function                f                (){return 1}
"function                f                (){return 1}" === f.toString() // true

Chrome 和 Firefox 以前都是把從引數列表左側的那個小括號開始到函式體右側那個大括號結束的原始碼儲存下來,用的時候前面補上了“function 函式名”,現在是從 “function” 關鍵字就開始儲存原始碼,如果是 async function,會從 “async” 關鍵字開始儲存。

如果是方法,會從方法名開始儲存;如果是生成器方法,會從 * 號開始儲存;如果是 getter/setter,會從 “get” 或 “set” 開始儲存:

({m/*註釋*/(){}}).m.toString() //  "m/*註釋*/(){}"

({*  g/*註釋*/(){}}).g.toString() //  "*  g/*註釋*/(){}"

Object.getOwnPropertyDescriptor({get/*A*/f/*B*/(/*C*/ /*D*/)/*E*/{/*F*/}}, "f").get.toString() 
// "get/*A*/f/*B*/(/*C*/ /*D*/)/*E*/{/*F*/}"

總之最核心的理念就是,原始碼是什麼,toString() 就返回什麼,ES6 裡曾經要求的什麼“功能等效”和“向前不相容”,全部作廢。

2. 通過 Function()/GeneratorFunction()/AsyncFunction() 這些“函式的建構函式”動態生成的函式(沒有真實的原始碼)在 toString() 時返回什麼,這個提案也做了詳細的規定,沒有模稜兩可的地方。

Function("a","b","a+b").toString()

/*
function anonymous(a,b
) {
a+b
}
*/

基本上就是 "function anonymous(" + 引數名列表.join(",") + ""\n) {\n" + 函式體 + "\n}"

3. 內建函式、宿主函式、繫結函式返回的函式體得是 { [native code] },不過這其中的空白符可以任意放置,用程式碼來說話的話,這些函式 toString() 的返回結果要能匹配下面這個正則:

/\bfunction\b[\s\S]*\([\s\S]*\)[\s\S]*\{[\s\S]*\[[\s\S]*\bnative\b[\s\S]+\bcode\b[\s\S]*\][\s\S]*\}/

總結

本文故意省略很多細枝末節,讀完之後你只要記的一句就夠了:“Function.prototype.toString 已經有了嚴格的規範,規範的核心就是函式的原始碼是什麼就返回什麼”。

相關文章