JavaScript:SpiderMonkey中的函数序列化
在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提案里已经有相关规定了,其他浏览器以后也应该要遵循这样的规定.