[翻譯] Javascript中函式反編譯的歷史,現狀和未來

zzNucker發表於2014-03-06

翻譯自 Perfection Kills: State of function decompilation in Javascript by kangax

Javascript中的函式反編譯的歷史,現狀和未來

  1. 理論

  2. 實踐

  3. 現在的情況

    • 反編譯的目的
    • 使用者定義的函式
    • 函式的構造器
    • 繫結函式
    • 非標準的情況
    • ES6所增加的
    • Minifiers及前處理器
  4. 長話短說

圖1

去發現那些在Javascript世界中被稱之為"magic"的東西,總是一件有趣的事情。

我最近遇到的一個這樣的例子是AngularJS的 依賴注入機制。我從來沒有去熟悉過這個概念,但我覺得它在實踐中看起來聰明又方便,雖然並不是特別的神奇。

它是幹什麼的?簡而言之:通過函式的引數來定義所需的“模組”。像這樣:

angular.module('App', [ ])
  .controller('Ctrl', function($scope, $timeout, $http) {
    ...
  });

注意$scope$timeout$http這些識別符號。

啊哈,所以,它並非將它們作為字串或變數或什麼其它的東西來傳遞,而是將他們定義為程式碼的一部分。當然,為了“讀懂”這程式碼,有件事不得不提。

函式反編譯

我們在prototype.js中使用的那種實現$super的方法還是早在2007年?是的,就是它。後來實現它的方式來自於Resig的simple inheritance(用一個安全的方式)或其它地方。

看到像Angular這樣一個現代化的框架使用函式反編譯讓我很驚訝。即使它不是Angular的專有依賴,但這個黑魔法已經讓人覺得不習慣了很多年。我在2009年寫了一些有關這個問題的東西

像這樣本質上來說不標準並且在不同實現中也不一樣的東西只能通過使用者代理嗅探來進行比較。

但它是真的是這樣嗎?或者說,近日裡事情並沒有那麼糟糕 ?我4年前研究過這一點 - 用了一大片的時間。當涉及到函式字串表示形式時,將來會不會取得某種統一的實現?我是不是已經完全過時了?

出於好奇,我決定去看看目前的狀況。函式反編譯現在可以依賴麼?我們究竟能夠依賴什麼?

首先。。。

理論

簡單地說,函式反編譯是將函式程式碼作為一個字串來訪問(然後解析它的內容或提取引數或其他)的過程。

在Javascript中,這是通過函式物件的toString()方法來實現的,所以fn.toString()String(fn)fn + ''或其他任何委託到Function.prototype.toString的用法都可以實現相同功能 。

這在Javascript中被認為是不可靠的原因是由於其不規範的性質 。ES5規範的一段著名引用這麼說:

15.3.4.2 Function.prototype.toString ( )

返回一個依賴於實現的函式表達形式。這個表達符合一個FunctionDeclaration的語法。請特別注意在表達字串中空格符,行終止,分​​號的使用是實現相關的。

當然,當某些東西是與實現相關的的時候 ,它必然會以各種可以想象的方式偏離軌道。

實踐

..事實就是這樣。比如你會認為這樣的一個函式:

function foo(x, y) {
  return x + y;
}

..會被序列化成這樣的字串:

"function foo(x, y) {\n  return x + y;\n }"

它幾乎確實這麼做了,除了某些JS引擎可能會忽略掉換行。另一些引擎可能忽略掉註釋。另一些會忽略掉那些“dead code”。另外一些則會包括進註釋和(!)函式。而其它的則可能完全隱藏掉程式碼。

在以前,事情是非常糟糕的。例如在版本號<=2.X的Safari瀏覽器中,甚至不會檢查函式宣告的語法是否合法。如果使用像"(Inner Function)""[function]"或從NFE中捨棄那些標示符 將會是件瘋狂的行為,這只是因為—

在以前,一些移動瀏覽器(黑莓,Opera Turbo)將完全隱藏程式碼(而代之以禮貌的"/ *原始碼不可用* /"類似的註釋 ),也許這是為了“節省”記憶體。真是一個公平的優化。

現在的情況

但現在又是什麼情況呢?當然,事情肯定會變得更好。現在我們有著引擎的趨同,相對合理的WebKit的佔有率,大量的標準化,和引擎效能的巨大增長。

事實上,事情看起來還不錯。但是並不是所有的事情都很好,有些更加“好玩”的東西出現在我們的視野裡。

我做了一個簡單的測試頁面 ,檢查了函式和它們的字串表示形式的不同情況。然後測試了它在桌面瀏覽器,包括那些非常“古老”的那些(IE6 +,FF3 +,Safari4 +,Opera9.6 +,Chrome瀏覽器),以及手機上 的表現,並觀察了一些通用模式。

反編譯的目的

瞭解Javascript函式反編譯的不同目的,是非常重要的。

原生序列,內建函式和使用者定義的函式在序列化上是不同的。例如,從Angular的角度來看,我們正在談論的是使用者定義的函式 ,所以我們不必關注本地函式是如何被序列化的。此外,如果我們談論的只是獲取引數 ,那需要處理的問題相比“解析”原始碼就少得多了。

有些東西是更可靠的。 但是別人的東西,就少一些。

使用者定義的函式

當涉及到使用者定義的函式時,所發生的事情是相當一致的。

除了那些古怪的和垂死掙扎的環境,例如IE<9 — 這些瀏覽器有時會在字串結果表示中包含那些函式週圍的註釋(甚至括號)——或Konqueror,它會在省略從new Function中產生的(即生成的函式)函式體的括號。

多數的差別都在空格 (和換行符)。有些瀏覽器(如Firefox<17)會從原始碼中去除所有的註釋,並刪除“side”,無法訪問的程式碼。

但是,不要高興得太早,因為我們還沒談論未來會是什麼樣的呢...

函式的構造器

對於生成的函式(new Function(...))來說,事情會有些忙亂,但不會很麻煩。雖然大多數的引擎會給這些函式建立一個“anonymous”的標示符,但是對於空格和換行的處理是不一致的。Chrome將會在引數列表後插入額外的註釋(額外的註釋並不會影響什麼,對麼?)

new Function('x, y', 'return x + y')

事情會變成這樣:

"function anonymous(x, y
/**/) {
return x + y
}"

繫結函式

在我所測試的每個引擎中,繫結(通過Function.prototype.bind)函式的表現與本地函式是一樣的。是的,這意味著繫結函式在字串表示中“丟失”了它們的原始碼

"function () { [native code] }"

可以說,這是一個合理的做法,雖然有點“奇葩?”當你第一次看到它的時候,你可能會問:“為什麼不直接使用“[繫結程式碼]”這樣來表示呢?”

奇怪的是,有些引擎(例如最新的WebKit) 會保留函式的原始識別符號,而另一些則不會。

非標準的情況

非標準的擴充套件是怎樣的呢? 比如Mozilla的表示式閉包

var expressionClosure = function(x, y) x + y

是的,那些(非標準的函式)將像它們原始碼一樣被表示出來,沒有函式體的括號(從技術上講,這是不符合函式宣告的語法的,但是Function.prototype.toString的MDN頁面甚至都沒有提及。 有些東西必須要進行修改了!)。

ES6所增加的

在我幾乎已經完成了編寫測試用例的時候,突然一個想法出現在我心裡。等等,這些問題在EcmaScript 6裡會是什麼樣的 ?

所有這些在這門語言中新加入的東西;讓函式看起來不一樣的新語法——類,生成器,不定引數,預設引數,箭頭函式等功能。這些會不會影響到函式的序列化表示呢?

一些快速測試給出了答案 - 是的,它們會有影響。顯而易見的是,Firefox24 +,ES6大隊的引領者,向我們展示了這些新構造形式的字串表示:

// Arrow functions
var fn = () => 5; // "() => 5"

// Rest params
function fn(...args) { } // "function fn(...args) { }"

// Default params
function fn(foo=1) { } // "function fn(foo=1) { }"

// Generators
(function *(){ yield 1 }); // "function *() { yield 1 }"

通過檢查ES6規範我們進一步證實了這一點 :

將返回一個實現相關的此物件原始碼的字串表示。這個表示需要有FunctionDeclaration,FunctionExpression,GeneratorDeclaration,GeneratorExpession,ClassDeclaration,ClassExpression,ArrowFunction,MethodDefinition,或GeneratorMethod中的一個語法,這具體取決於物件的實際特性。需要特別注意的是對於空格、行終止符和分號在字串表示中是如何被替換的,因為這也與實現相關。

如果物件是使用ECMAScript程式碼所定義的,並且返回的字串表示形式是FunctionDeclaration,FunctionExpression,GeneratorDeclaration,GeneratorExpession,ClassDeclaration,ClassExpression,或ArrowFunction中的一種,則字串表示必須是這樣:如果使用eval來執行了字串,並且是在一個等同於用來建立原始物件的詞法上下文的上下文中使用eval,則將會產生一個新的但功能等效的物件。返回的原始碼表示不能使用任何沒有在原始函式的原始碼中使用到的變數,即使這些“額外”的變數名確實原本就是在作用域中的。如果原始碼字串表示不符合這些規定,那麼它就是一個在eval時將會丟擲一個SyntaxError異常的字串。

請注意ES6仍然將函式的表示留給實現自己決定 ,儘管它說明了不再僅檢查是否符合FunctionDeclaration語法。我還發現了一個有趣的附加要求 —— “返回的原始碼(表示)不能自由地使用任何沒有在原始函式原始碼中使用過的變數”(如果你在不到7次嘗試中就理解了這一點,你可以得到加分!)。

我不太清楚這將如何影響未來的引擎和他們的表現,但有一點是可以肯定的,隨著ES6的崛起,函式表示將不再只是一個後面跟著引數和函式體的可選的識別符號。正在有一大堆新的東西將襲來。

那些已有的正規表示式將會再次被強制要求更新來應對這些變化。(話說我有說過這類似於UA嗅探嗎? 噓。)

Minifiers及前處理器

我還要提到幾個陳詞濫調,這些玩意從來沒有和函式反編譯好好相處過—— minifiers和前處理器

像UglifyJS這樣的minifiers,和像Caja這樣的前處理器/編譯器傾向於自己調整這些地獄般的原始碼,並重新命名引數。這就是為什麼Angular的依賴注入與minifiers不能正常一起工作 ,除非你使用替代方法

也許這些並不是什麼大不了的事,但它仍然是一個需要記住相關問題,不是嗎?

長話短說

總結一下這些東西:看來函式反編譯變得更安全了,但是——這取決於你的分析需求——它可能仍然還沒有智慧到你能夠完全依賴它 。

你想把它用在你的應用程式/庫中?

切記:

  • 它仍然還不是標準
  • 使用者定義的函式一般看上去結果都很正常
  • 有些引擎很古怪 (特別是當它涉及到原始碼的佈局,空格,註釋,死程式碼等
  • 要注意有可能存在的未來的奇怪引擎 (特別是那些需要保守處理記憶體/電量消耗的移動裝置或其它不尋常的裝置)
  • 繫結函式的字串表示不會顯示其原始碼(但 有時會保留識別符號 )
  • 你可能會遇到非標準的擴充套件 (如Mozilla的表示式閉包)
  • (冬天) ES6來了 ,函式現在可能會看起來與他們過去的樣子非常不同
  • Minifiers /前處理器不是你的好夥伴

P.S. 那些被重寫了toString方法和/或Proxy.createFunction的函式是一種特別的情況,在那些特殊的情況下,我們需要特殊的考慮一下。

特別感謝Andrea Giammarchi提供的一些移動端的測試(在BrowserStack上所沒有的)。

如果你喜歡這個,歡迎捐款給我 :) Gittip / Flattr

相關文章