前端優化系列 - JS混淆引入效能天坑

阿里云云棲社群發表於2018-03-19

摘要:
前言 現在使用者手機效能,瀏覽器效能,網路效能,越來越好,後端邏輯逐漸向前端轉移,前端渲染變得越來越普遍。前端渲染主要依賴JS去完成核心邏輯,JS正變得越來越重要。而JS檔案是以原始碼的形式傳輸,可以在Chrome Devtools上輕易地被修改和除錯。

前言

現在使用者手機效能,瀏覽器效能,網路效能,越來越好,後端邏輯逐漸向前端轉移,前端渲染變得越來越普遍。前端渲染主要依賴JS去完成核心邏輯,JS正變得越來越重要。而JS檔案是以原始碼的形式傳輸,可以在Chrome Devtools上輕易地被修改和除錯。我們一般不希望核心業務邏輯輕易的被別人瞭解,往往會通過程式碼混淆的方式去進行保護。

那麼,程式碼混淆對JS效能是否有影響呢?我們下面討論一個真實的案例,看看混淆如何讓JS效能變差100倍,並詳細介紹如何去跟進和處理類似問題。

混淆引入效能問題

通常JS混淆有兩種方式,一種是正則替換,強度比較弱,很容易被破解;另外一種是修改抽象語法樹,比較難破解。

一些比較重要的JS檔案,一般會使用修改抽象語法樹的方式去進行混淆保護。相關的原理請參考知乎上的文章:前端如何給 JavaScript 加密

一般來說,JS混淆會引入多餘程式碼,修改原來的抽象語法樹,可能會引入效能問題,但效能影響一般非常小。

但是,也有異常的情況,我們在一個業務上發現它的isdsp_securitydata_send.js執行非常耗時,竟然達到驚人的1.6秒。Trace資訊如下,

b9f9c20687e8c3221852f5b3e16b6a0ec5c91779

而使用它未混淆的原始碼去執行時,發現在15毫秒就執行完了。這是一個非常明顯的混淆引入效能問題的案例。

分析效能問題

大部分問題,在找到根本原因之後,我們都會覺得非常簡單,也很容易解決。而分析問題原因的過程和方法則更加重要,我們下面分享一些通用的分析問題的方法。

(1)確認效能問題

一般來說,確認一個JS執行是否存在效能問題,使用Chrome Trace還是比較方便的。我們下面先說說怎麼看Trace資訊。

上圖中,

v8.run 對應核心的V8ScriptRunner::runCompiledScript, 代表blink端的JS的執行時間,即JS執行的實際耗時。

V8.Execute 代表v8內部的JS執行時間,與v8.run代表的意義一樣,耗時也相近。

顏色與V8.ParseLazy一樣的部分,代表JS編譯耗時,從上圖可以看到,編譯耗時佔了絕大部分。

注:上圖僅僅為了展示Trace中V8相關的含義,不是我們要討論的JS耗時問題。

我們再來看看存在效能問題的Trace資訊,

c58fc75f6053d2274141f158540706bea6a4f680

從上圖可以看到,v8.run下面幾乎沒有藍色的片段,即幾乎沒有編譯耗時,基本上都是JS程式碼執行的耗時。

這樣我們可以判斷,abc.js 執行的耗時達到了驚人的1.6秒,而這個JS的邏輯非常簡單,它很有可能是存在嚴重效能問題的。

注:上圖是abc.js在真實環境執行消耗的時間。

(2)分析問題原因

在上面我們已經定位到abc.js的執行耗時存在較大問題,那麼可以怎麼去定位問題的準確原因呢?

我們先將問題簡化,把這個JS抽取出來單獨去執行,比如,使用下面示例程式碼:

<html>
<body>
<script type="text/javascript" src="xxx.com/.../abc.js"></script>
</body>
</html>

然後抓取該示例程式碼的Trace資訊,

從上面Trace可以看到,裡面一些JS函式的執行非常耗時,每個耗時都有幾百毫秒。

但這個外聯的JS是無法定位到程式碼行的,我們可以將外聯JS檔案的內容直接拷貝到上述<script>標籤裡面去執行,看看具體的程式碼行在哪裡?

從上圖可以發現,耗時的程式碼在2117行,直接點選可以定位到具體的程式碼行,

4100d1a1c02528d2a717c1d2333690cff736676a

從上圖可以看到,下面函式執行非常耗時,耗時800多毫秒。

function a(r) {
var n = Mo;
var a = sn;
for (var o = S; o < r[L[No + J[Lo](U)](U) + P[Qo + Z[Lo](U)](U)]; o++) {
var t = ((r[yr[No + J[Lo](U)](U) + mv + xv](o) - _) * cr + X - a) % V + _;
n += String[Oa[Wo + D[Lo](U)](U) + ad + fr[Qo + Z[Lo](U)](U) + kt](t);
a = t
}
return n
}

上述函式為什麼會非常耗時呢?這裡就是JS引擎專家發揮的地方了! 通過我們技術專家分析JS引擎的執行,發現String[Oa[Wo + D[Lo](U)](U) + ad + fr[Qo + Z[Lo](U)](U) + kt](t) 這一句程式碼,其實是 s += String.fromCharCode(p) 混淆之後的結果。

這種混淆會帶來什麼問題呢?V8和JSC引擎的字串拼接查詢效能都非常弱,比如,String["toS" + "tring"](),number to string,都是V8和JSC引擎的超級弱點。

JS字串拼接的效能為什麼會很差呢?
在JavaScript中,字串是不可變的(immutable),只能被另外一個字串替換。

var combined = "";
for (var i = 0; i < 1000000; i++) {
    combined = combined + "hello ";
}複製程式碼

上述示例程式碼中,combined + "hello " 不會直接修改combined變數,而會新建一個臨時物件儲存計算結果,然後再使用該臨時物件替換combined變數。所以上述for迴圈中會產生海量的臨時變數,JS引擎GC需要大量工作來清理這些臨時變數,從而會影響效能。
注:上述解析來自Why is + so bad for concatenation?

我們再進一步去驗證去掉字串混淆的程式碼效果,

<html>
<body>
<script type="text/javascript" src="xxx.com/.../abc.js"></script>
</body>
</html>

我們看看改動之後的JS執行的Trace資訊,

35469ec1652e67783f36d29daef00f19271ad1ef

從上圖可以看到,isdsp_securitydata_send.js在幾毫秒就執行完了。

我們再在真實的業務頁面上驗證優化後的效果,

8c33b60fb17dcc5f4e8559bb57c09845fdff2287

執行耗時直接從1.6秒,優化為15毫秒,優化幅度大於100倍!

解決效能問題

從上面的分析可以看到,JS混淆引入了大量的字串拼接,從而導致效能大幅下降。

那麼,解決問題的方案也就很顯然了,那就是去掉這些字串拼接,即降低混淆的強度,把字串混淆部分去掉。

去掉字串混淆部分之後,isdsp_securitydata_send.js的執行耗時變為15毫秒,完美的實現了優化。

結束語

現在前端渲染非常流行,頁面大部分邏輯由JS控制。從我們長期進行頁面效能優化的經驗來看,頁面效能優化的20-40%與瀏覽器核心相關,而60-80%與前端JS相關,即前端JS是效能優化的重中之重。

那麼,前端JS優化有那些比較好的實踐呢?核心直接參與分析前端JS,成本非常大,並非長久之計,核心更應該做的是賦能前端。

在賦能前端方面,核心可以做那些事情呢?

(1)將一些通用的前端分析方法整理成文件,供前端參考。

(2)將一些人工分析總結的經驗,固化到自動化的工具,比如,WDPS Lighthouse。

(3)提供一些更有效的分析工具。比如,在Trace中更清晰的展現JS引擎的執行邏輯。

(4)與前端更多交流合作,建立互信,深入合作研究疑難問題和普遍問題。

參考文件

前端如何給 JavaScript 加密

Why is + so bad for concatenation?

Optimization killers

作者:小扎zack

原文連結


相關文章