在Javascript中,函數可以很容易的被序列化(字符串化),也就是得到函數的源碼.但其實這個操作的內部實現(引擎實現)並不是你想象的那么簡單.SpiderMonkey中一共使用過兩種函數序列化的技術:一種是利用反編譯器(decompiler)將函數編譯后的字節碼反編譯成源碼字符串,另一種是在將函數編譯成字節碼之前就把函數源碼壓縮並存儲下來,用到的時候再解壓還原.
如何進行函數序列化
在SpiderMonkey中,能將函數序列化的方法或函數有三個:Function.prototype.toString,Function.prototype.toSource,uneval.只有toString方法是標准的,也就是各引擎通用的.但是ES標准中關於Function.prototype.toString方法的規定(ES5 15.3.4.2)只有寥寥數語,也就是說,基本沒有標准,引擎自己決定該如何實現.
函數序列化的作用
函數序列化最主要的作用應該是利用序列化生成的函數源碼來重新定義這個函數.
function a() { ... alert("a") ... } a() //執行時可能會彈出"a" a = eval("(" + a.toString().replace('alert("a")', 'alert("b")') + ")") a() //執行時可能會彈出"b"
你也許會想:"我寫了這么多年Javascript,怎么沒有遇到這種需求".的確,如果是自己的網站,自己完全控制的js文件,不需要以這種打補丁的方式來修改函數,直接修改就可以了.但是如果源文件不是你能控制的了的話,就很有可能要這樣做了.比如常用的地方有greasemonkey腳本:你可能需要禁用或修改某個網站中的某個函數.還有就是Firefox擴展:你需要修改Firefox自身的某個函數(可以說Firefox是用JS寫的).舉個我自己寫的Firefox腳本的例子:
location == "chrome://browser/content/browser.xul" && eval("gURLBar.handleCommand=" + gURLBar.handleCommand.toString().replace(/^\s*(load.+);/gm, "/^javascript:/.test(url)||(content.location=='about:blank'||content.location=='about:newtab')?$1:gBrowser.loadOneTab(url,{postData:postData,inBackground:false, allowThirdPartyFixup: true});"))
這個代碼的作用是:在地址欄上回車時,讓Firefox在新標簽中打開頁面,而不是占用當前標簽.實現方式就是用toString方法讀取到gURLBar.handleCommand函數的源碼,然后用正則替換后傳給eval,重新定義了這個函數.
為什么不用直接定義的方式,也就是直接重寫函數呢:
gURLBar.handleCommand = function(){...//將原本的函數更改了一個小地方}
不能這么做的原因是因為我們得考慮兼容性,我們應該盡可能小的更改這個函數的源碼.如果這么寫的話,Firefox的gURLBar.handleCommand源碼一旦發生變化,這個腳本就失效了.比如Firefox3和Firefox4中都有這個函數,但函數內容差別非常大,可是如果用正則替換部分關鍵字的話,只要這個被替換的這個關鍵字沒有發生變化的話,就不會出現不兼容的現象.
反編譯字節碼
在SpiderMonkey中,函數在被解析之后會被編譯成字節碼(bytecode),也就是說,內存中存儲着並不是原始的函數源碼.SpiderMonkey中存在一個反編譯器,它的主要作用就是把函數的字節碼反編譯成函數源碼的形式.
在Firefox16以及之前的版本中,SpiderMonkey使用的就是這種方法,如果你使用的是這些版本的Firefox的話,可以嘗試下面的代碼:
alert(function () { "字符串"; //注釋 return 1 + 2 + 3 }.toString())
返回的字符串是
function () {
return 6;
}
輸出和其他的瀏覽器完全不同:
1.沒有意義的原始值字面量在編譯的時候會被刪除,這個例子中就是"字符串".
你也許會覺得:"貌似沒什么問題,反正這些值對於函數的運行來說並沒有什么意義".等等,你是不是忘了個東西,表示嚴格模式的字符串"use strict"怎么辦呢?
在不支持嚴格模式的版本中,比如Firefox3.6,這個"use strict"和其他字符串沒什么區別,編譯的時候會被刪除.在SpiderMonkey實現了嚴格模式之后,雖然編譯的時候同樣會忽略掉這個字符串"use strict",但在反編譯的時候會進行判斷,如果這個函數處於嚴格模式中,則會在函數體的第一行添加上"use strict",下面是對應的引擎源碼.
static JSBool DecompileBody(JSPrinter *jp, JSScript *script, jsbytecode *pc) { /* Print a strict mode code directive, if needed. */ if (script->strictModeCode && !jp->strict) { if (jp->fun && (jp->fun->flags & JSFUN_EXPR_CLOSURE)) { /* * We have no syntax for strict function expressions; * at least give a hint. */ js_printf(jp, "\t/* use strict */ \n"); } else { js_printf(jp, "\t\"use strict\";\n"); } jp->strict = true; } jsbytecode *end = script->code + script->length; return DecompileCode(jp, script, pc, end - pc, 0); }
2.注釋在編譯的時候也會被刪除
這個貌似沒太大影響,不過有些人願意利用函數注釋來實現多行字符串,這個方法在Firefox 17之前的版本中是不可用的.
function hereDoc(f) { return f.toString().replace(/^.+\s/,"").replace(/.+$/,""); } var string = hereDoc(function () {/* 我 你 他 */}); console.log(string) 我 你 他
3.原始值字面量的運算會在編譯時進行.
這算是一種優化方式,《高性能JavaScript》提到過:
反編譯的弊端
由於新技術的出現(比如嚴格模式)以及在修改其他相關bug的時候,反編譯器這部分的實現經常需要更改,更改就有可能產生新的bug,我自己就親身遇到過一個bug.大概是在Firefox10左右的時候,具體問題記不大清了,反正是關於反編譯時小括號是否要保留的問題,大概是這樣的:
>(function (a,b,c){return (a+b)+c}).toString() "function (a, b, c) { return a + b + c; }"
在反編譯時,(a+b)中的小括號被省略了,由於加法結合律從左到右,所以這沒關系.但我遇到的bug是這樣的:
>(function (a,b,c){return a+(b+c)}).toString() "function (a, b, c) { return a + b + c; }"
這就就不行了,a+b+c不等於a+(b+c),比如在a=1,b=2,c="3"的情況下,a+b+c等於"33",而a+(b+c)等於"123".
關於反編譯器,Mozilla工程師Luke Wagner指出,反編譯器對他們實現一些新功能的阻礙很大,而且經常會出現一些bug:
Not to pile on, but I too have felt an immense drag from the decompiler in the last year. Testing coverage is also poor and any non-trivial change inevitably produces fuzz bugs.The sooner we remove this drag the sooner we start reaping the benefits. In particular,I think now is a much better time to remove it than after doing significant frontend/bytecode hacking for new language features.
Brendan Eich也表示,反編譯器的確有很多不理想:
I have no love for the decompiler, it has been hacked over for 17 years.
存儲函數源碼
從Firefox17之后,SpiderMonkey改成了第二種實現方法,其他瀏覽器也應該是這樣實現的吧.函數序列化得到的字符串完全和源碼一致,包括空白符,注釋等等.這樣的話,大部分問題就應該沒有了吧.不過,貌似我又想到個問題.還是關於嚴格模式的.
比如:
(function A() { "use strict"; alert("A"); }) + ""
當然,返回的源碼中也應該有"use strict",所有瀏覽器都是這么實現的:
function A() { "use strict"; alert("A"); }
但如果是這樣呢:
(function A() { "use strict"; return function B() { alert("B") } })() + ""
內部函數B也處於嚴格模式中,輸出B的函數源碼應不應該加上"use strict"呢.試驗一下:
上面說了,Firefox17之前Firefox4之后的版本是通過判斷當前函數是否處於嚴格模式來決定輸出不輸出"use strict"的,函數B繼承了函數A的嚴格模式,所以會有"use strict".
同時函數源碼是縮進嚴格的,因為在反編譯的時候,SpiderMonkey會給反編譯出的源碼進行格式化,即使之前的源碼完全沒有縮進也沒關系:
function B() { "use strict"; alert("B"); }
Firefox17之后的版本會不會帶有"use strict"呢?因為是直接把函數源碼保存下來的,而且函數B中的確沒有"use strict"字樣.試驗結果是:會添加上"use strict",只是縮進有點問題,因為沒有格式化這一步了.
function B() { "use strict"; alert("B") }
SpiderMonkey最新版的jsfun.cpp源碼中有對應的注釋
// 如果一個函數的某個上層函數中擁有"use strict",那么這個函數就繼承了上層函數的嚴格模式.
// 我們也會在這個內部函數的函數體內插入"use strict".
// 這就確保了,如果這個函數的toString方法的返回值被重新求值時,
// 重新生成的函數會和原函數有着相同的語義.
而不同的是,其他瀏覽器都是不帶"use strict"的:
function B() { alert("B") }
雖然這不會有什么太大影響,但我覺的Firefox的實現是更合理的.
補充一句:harmony提案里已經有相關規定了,其他瀏覽器以后也應該要遵循這樣的規定.