有時候,我們就是會不由自主地寫出一些低效的程式碼,嚴重影響頁面執行的效率。或者我們接手的專案中,前人寫出來的程式碼千奇百怪,比如為了一個 Canvas 特效需要同時繪製 600 個三角形,又比如 Coding.net 的任務中心需要同時 watch 上萬個變數的變化等等。那麼,如果我們遇到了一個比較低效的頁面,應該如何去優化它呢?
優化前的準備:知己知彼
在一切開始之前,我們先開啟 F12 皮膚,熟悉一下我們接下來要用到的工具:Timeline:
嗯沒錯就是它。下面逐一介紹一下吧。區域 1 是一個縮圖,可以看到除了時間軸以外被上下分成了四塊,分別代表 FPS、CPU 時間、網路通訊時間、堆疊佔用;這個縮圖可以橫向縮放,白色區域是下面可以看到的時間段(灰色當然是不可見的啦)。區域 2 可以看一些互動事件,例如你滾動了一下頁面,那麼這裡會出現一個 scroll 的線段,線段覆蓋的範圍就是滾動經過的時間。區域 3 則是具體的事件列表了。
一開始沒有記錄的時候,所有的區域都是空的。開始統計和結束統計都很簡單,左上角那坨黑色的圓圈就是。它右邊那個長得像“禁止通行”的按鈕是用來清除現有記錄的。當有資料的時候,我們把滑鼠滾輪向上滾,可以看到區域被放大了:
短短的時間裡,瀏覽器做了這麼多事情。對於一般的螢幕,原則上來說一秒要往螢幕上繪製 60 幀,所以理論上講我們一幀內的計算時間不能超過 16 毫秒,然而瀏覽器除了執行我們的程式碼以外,還要乾點別的(例如計算 CSS,播放音訊……),所以其實我們能用的只有 10~12 毫秒左右。
差不多熟悉操作了,那麼就來一下實戰吧!假如有一天,你接手了這樣一段程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
<!-- 一段小動畫:點選按鈕之後會有一個爆炸的粒子效果 --> <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Test</title> <style> .main { position: relative; width: 500px; height: 500px; background: #000; overflow: hidden; } .circle { position: absolute; border-radius: 50%; border: 1px solid #FFF; width: 8px; height: 8px; } </style> </head> <body> <div class="main"></div> <hr> <button onclick="showAnimation()">點我</button> <script src="jquery.min.js"></script> <script src="animation.js"></script> </body> </html> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
// animation.js // 粒子總數 var COUNT = 500; // 重力 var G = -0.1; // 摩擦力 var F = -0.04; function init() { for (var i = 0; i < COUNT; i++) { var d = Math.random() * 2 * Math.PI; var v = Math.random() * 5; var circle = $('<div id="circle-' + i + '" class="circle" data-x="250" data-y="250" data-d="' + d + '" data-v="' + v + '"></div>'); circle.appendTo($('.main')); } } function updateCircle() { for (var i = 0; i < COUNT; i++) { var x = parseFloat($('#circle-' + i).attr('data-x')); var y = parseFloat($('#circle-' + i).attr('data-y')); var d = parseFloat($('#circle-' + i).attr('data-d')); var v = parseFloat($('#circle-' + i).attr('data-v')); var vx = v * Math.cos(d); var vy = v * Math.sin(d); if (Math.abs(vx) < 1e-9) vx = 0; // 速度分量改變 vx += F * Math.cos(d); vy += F * Math.sin(d) + G; // 計算新速度 v = Math.sqrt(vx * vx + vy * vy); if (vy > 0) d = Math.acos(vx / v); else d = -Math.acos(vx / v); // 位移分量改變 x += vx; y += vy; $('#circle-' + i).attr('data-x', x); $('#circle-' + i).attr('data-y', y); $('#circle-' + i).attr('data-d', d); $('#circle-' + i).attr('data-v', v); $('#circle-' + i).css({'top': 400 - y, 'left': x}); } } var interval = null; function showAnimation() { if (interval) clearInterval(interval); $('.main').html(''); init(); interval = setInterval(updateCircle, 1000 / 60); } |
效果如下(右上角的 FPS 計數器是 Chrome 除錯工具自帶的):
只有 10 FPS……10 FPS……坑爹呢這是!
好吧,開啟 Timeline,按下記錄按鈕,點一下頁面中的“點我”,稍微過一會兒停止記錄,就會得到一些資料。放大一些,對 jQuery 比較熟悉的同學可以看出來,這些大部分是 jQuery 的函式。我們點一下那個 updateCircle
的區塊,然後看下面:
這裡告訴我們,這個函式執行了多久、函式程式碼在哪兒。我們點一下那個連結,於是就跳到了 Source 頁:
是不是很震撼,之前這個頁面只是用來 Debug 的,沒想到現在居然帶了精確到行的執行時間統計。當然,這個時間是當前這一行在“剛才我們點選的區塊對應的執行時間段”中執行的時間。所以我們就拿最慢的幾句話來下手吧!
優化一:減少 DOM 操作
看到這幾行程式碼,第一反應是:mdzz。本來 DOM 操作就慢,還要在字串和 float 之間轉來轉去。果斷改掉!於是用一個單獨的陣列來存 x
、y
、d
、v
這些屬性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
var objects = []; // 在 init 函式中 objects.push({ x: 250, y: 250, d: d, v: v }); // 在 updateCircle 函式中 var x = objects[i].x; var y = objects[i].y; var d = objects[i].d; var v = objects[i].v; // …. objects[i].x = x; objects[i].y = y; objects[i].d = d; objects[i].v = v; |
效果顯著!我們再來看一下精確到行的資料:
優化二:減少不必要的運算
所以最耗時的那句話已經變成了計算 vx
和 vy
,畢竟三角函式演算法比較複雜嘛,可以理解。至於後面的三角函式為什麼那麼快,我猜可能是 Chrome 的 V8 引擎將其快取了(這句話不保證正確性)。然而不知道大家有沒有發現,其實計算 d
完全沒必要!我們只需要存 vx
和 vy
即可,不需要存 v
和 d
!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
// init var vx = v * Math.cos(d); var vy = v * Math.sin(d); objects.push({ x: 250, y: 250, vx: vx, vy: vy }); // updateCircle var vx = objects[i].vx; var vy = objects[i].vy; // 計算新速度 var v = Math.sqrt(vx * vx + vy * vy); if (Math.abs(vx) < 1e-9) vx = 0; // 速度分量改變 vx += F * vx / v; vy += F * vy / v + G; // …. objects[i].vx = vx; objects[i].vy = vy; |
只有加減乘除和開平方運算,每次比原來的時間又少了兩毫秒。從流暢的角度來說其實已經可以滿幀執行了,然而為什麼我還是覺得偶爾會有點卡呢?
優化三:替換 setInterval
既然偶爾會掉幀,那麼就看看是怎麼掉的唄~原則上來說,在每一次瀏覽器進行繪製之前,Timeline 裡面應該有一個叫 Paint 的事件,就像這樣:
看到這些綠色的東西了沒?就是它們!看上面的時間軸,雖然程式碼中 setInterval 的長度是 1000/16 毫秒,但是其實根本不能保證!所以我們需要使用 requestAnimationFrame
來代替它。這是瀏覽器自帶的專門為動畫服務的函式,瀏覽器會自動優化這個函式的呼叫時機。並且如果頁面被隱藏,瀏覽器還會自動暫停呼叫,有效地減少了 CPU 的開銷。
1 2 3 4 |
// 在 updateCircle 最後加一句 requestAnimationFrame(updateCircle); // 去掉全部跟 setInterval 有關的句子,把 showAnimation 最後一句直接改成這個 updateCircle(); |
我們至少可以保證,我們每算一次,螢幕上就會顯示一次,因此不會掉幀(前提是每計算一次的時間小於 12ms)。但是雖然計算時間少了,瀏覽器重計算樣式、繪製影象的時間可是一點都沒變。能不能再做優化呢?
優化四:使用硬體加速、避免反覆查詢元素
如果我們用 transform
來代替 left
和 top
來對元素進行定位,那麼瀏覽器會為這個元素單獨創立一個合成層,專門使用 GPU 進行渲染,這樣可以把重計算的代價降到最低。有興趣的同學可以研究一下“CSS 硬體加速”的機制。同時,我們可以快取一下 jQuery 的元素(或者 DOM 元素),這樣不用每次都重新查詢,也能稍微提高一點效率。如果把元素快取在 objects
陣列中,那麼連 id 都不用寫了!
1 2 3 4 5 6 7 8 9 10 11 12 |
// init var circle = $('<div class="circle"></div>'); objects.push({ x: 250, y: 250, vx: vx, vy: vy, // 其實可以只存 DOM,不存 jQuery 物件 circle: circle[0] }); // updateCircle 裡面 for 迴圈的最後一句話替換掉 objects[i].circle.style.transform = 'translate(' + x + 'px, ' + (400 - y) + 'px)'; |
看起來是不是很爽了?
其實,優化是無止境的,例如我在 init
函式中完全可以不用 jQuery,改用 createDocumentFragment
來拼接元素,這樣初始化的時間就可以急劇縮短;調換 updateCircle
中的幾個語句的順序,在 V8 引擎下效率可能會有一定的提升;甚至還可以結合 Profile 皮膚來分析記憶體佔用,檢視瀏覽器繪圖的細節……然而個人感覺並用不到這麼極限的優化。對於一個專案來說,如果單純為了優化而寫一些奇怪的程式碼,是很不合算的。
—
P.S. 全部的程式碼在這裡,歡迎吐槽: