最近工作中一個(gè)項(xiàng)目在運(yùn)行時(shí)有一些性能問題,為此我看了很多與性能優(yōu)化相關(guān)的內(nèi)容,下面做個(gè)簡(jiǎn)單的分享。
前端性能優(yōu)化,這包括 CSS/JS 性能優(yōu)化、網(wǎng)絡(luò)性能優(yōu)化等等內(nèi)容,這方面的內(nèi)容 《高性能網(wǎng)站建設(shè)指南》、《高性能網(wǎng)站建設(shè)進(jìn)階指南》、《高性能JavaScript》 等等書都做了很多講解,強(qiáng)烈推薦閱讀。(這些書單參見本文結(jié)尾)
下面的內(nèi)容,上面提到的書中大都包含了,因此可以考慮轉(zhuǎn)而去讀這些書,做一個(gè)完完全全的了解,對(duì)于本文,也就不要再讀下去了。
如果你堅(jiān)持看到了這里,那就來談?wù)勎矣龅降囊恍┣岸诵阅軉栴},并聊聊解決方案。
1 優(yōu)先優(yōu)化對(duì)性能影響大的部分
當(dāng)應(yīng)用有了性能問題后,不要一股腦扎到代碼中去,首先要想想那部分對(duì)性能影響最大。優(yōu)先優(yōu)化那些對(duì)性能影響大的部分,可以起到立桿見影的效果。
使用 Chrome DevTools ,可以很快地找到導(dǎo)致性能變差的最主要因素,關(guān)于 Chrome DevTools 的使用強(qiáng)烈推薦閱讀 Google Developers 上面的系列教程 - Chrome DevTools。
另外在對(duì)代碼進(jìn)行優(yōu)化的時(shí)候,也首先要關(guān)注那些存在循環(huán)或者高頻調(diào)用的地方。有的時(shí)候我們可能不知道某個(gè)地方是否會(huì)高頻執(zhí)行,比如某些事件的回調(diào)。這個(gè)時(shí)候可以使用 console.count 來對(duì)執(zhí)行次數(shù)進(jìn)行統(tǒng)計(jì)。當(dāng)這部分高頻執(zhí)行的代碼已經(jīng)足夠優(yōu)化的時(shí)候,就要考慮是否能夠減少執(zhí)行次數(shù)。比如一個(gè)時(shí)間復(fù)雜度為 O(n*n*n) 的算法,再怎么優(yōu)化也不如將其變?yōu)?O(n*n) 來的快。
2 對(duì)高頻觸發(fā)的事件進(jìn)行節(jié)流或消抖
對(duì)于 Scroll 和 Touchmove 這類事件,永遠(yuǎn)不要低估了它們的執(zhí)行頻率,處理這類事件的時(shí)候可以考慮是否要給它們添加一個(gè)節(jié)流或者消抖過的回調(diào)。節(jié)流和消抖,可能其他人不這么翻譯,其實(shí)也就是 debounce 和throttle 這兩個(gè)函數(shù)。
debounce 和 throttle 是兩個(gè)相似(但不相同)的用于控制函數(shù)在某段事件內(nèi)的執(zhí)行頻率的技術(shù)。你可以在 underscore 或者 lodash 中找到這兩個(gè)函數(shù)。
2.1 使用 debounce 進(jìn)行消抖
多次連續(xù)的調(diào)用,最終實(shí)際上只會(huì)調(diào)用一次。想象自己在電梯里面,門將要關(guān)上,這個(gè)時(shí)候另外一個(gè)人來了,取消了關(guān)門的操作,過了一會(huì)兒門又要關(guān)上,又來了一個(gè)人,再次取消了關(guān)門的操作。電梯會(huì)一直延遲關(guān)門的操作,直到某段時(shí)間里沒人再來。
所以 debounce 適合用在比如對(duì)用戶輸入內(nèi)容進(jìn)行校驗(yàn)的這種場(chǎng)景下,多次觸發(fā)只需要響應(yīng)最后一次觸發(fā)就好了。
2.2 使用 throttle 進(jìn)行節(jié)流
將頻繁調(diào)用的函數(shù)限定在一個(gè)給定的調(diào)用頻率內(nèi)。它保證某個(gè)函數(shù)頻率再高,也只能在給定的事件內(nèi)調(diào)用一次。比如在滾動(dòng)的時(shí)候要檢查當(dāng)前滾動(dòng)的位置,來顯示或隱藏回到頂部按鈕,這個(gè)時(shí)候可以使用 throttle 來將滾動(dòng)回調(diào)函數(shù)限定在每 300ms 執(zhí)行一次。
需要提到的是,這兩個(gè)函數(shù)常常被誤用,且很多時(shí)候當(dāng)事人并沒有意識(shí)到自己誤用了。我曾經(jīng)用錯(cuò)過,也見過別人用錯(cuò)。這兩個(gè)函數(shù)都接受一個(gè)函數(shù)作為參數(shù),然后返回一個(gè)節(jié)流/去抖后的函數(shù),下面第二種用法才是正確的用法:
// 錯(cuò)誤的用法,每次事件觸發(fā)都得到一個(gè)新的函數(shù)
$(window).on('scroll', function() {
_.throttle(doSomething, 300);
});
// 正確的用法,將節(jié)流后的函數(shù)作為回調(diào)
$(window).on('scroll', _.throttle(doSomething, 200));
3 JavaScript 很快,DOM 很慢
JavaScript 如今已經(jīng)很快了,真正慢的是 DOM。因此避免使用一些不易讀但據(jù)說能提高速度的寫法。不久前,
一位朋友對(duì)我說使用 ‘+’ 號(hào)將字符串轉(zhuǎn)為數(shù)字比使用 parseInt 快。對(duì)此我并沒有懷疑,因?yàn)橹庇X上 parseInt 進(jìn)行了函數(shù)調(diào)用,很可能會(huì)慢一些,我們一起在 node v6.3.0 上進(jìn)行了一些驗(yàn)證,結(jié)果的確如我們所預(yù)計(jì)的那樣,但是差別有多大呢,進(jìn)行了 5 億次迭代,使用 + 號(hào)的方法僅僅快了2秒。雖然快了兩秒,但實(shí)際中將字符轉(zhuǎn)為數(shù)字的操作可能只會(huì)進(jìn)行幾次,因此這樣的做法根本沒有意義,它只會(huì)讓代碼變得更難讀。
plus: 1694.392ms
parseInt: 3661.403ms
真正慢的是 DOM,DOM 對(duì)外提供了 API,而 JavaScript 可以調(diào)用這些 API,它們兩者就像是使用一座橋梁相連,每次過橋都要被收取大量費(fèi)用,因此應(yīng)該盡量讓減少過橋的次數(shù)。
3.1 為什么 DOM 很慢
談到這里需要對(duì)瀏覽器利用 HTML/CSS/JavaScript 等資源呈現(xiàn)出精彩的頁(yè)面的過程進(jìn)行簡(jiǎn)單說明。瀏覽器在收到 HTML 文檔之后會(huì)對(duì)文檔進(jìn)行解析開始構(gòu)建 DOM (Document Object Model) 樹,進(jìn)而在文檔中發(fā)現(xiàn)樣式表,開始解析 CSS 來構(gòu)建 CSSOM(CSS Object Model)樹,這兩者都構(gòu)建完成后,開始構(gòu)建渲染樹。整個(gè)過程如下:
渲染樹的構(gòu)建過程
在每次修改了 DOM 或者其樣式之后都要進(jìn)行 DOM樹的構(gòu)建,CSSOM 的重新計(jì)算,進(jìn)而得到新的渲染樹。瀏覽器會(huì)利用新的渲染樹對(duì)頁(yè)面進(jìn)行重排和重繪,以及圖層的合并。通常瀏覽器會(huì)批量進(jìn)行重排和重繪,以提高性能。但當(dāng)我們?cè)噲D通過 JavaScript 獲取某個(gè)節(jié)點(diǎn)的尺寸信息的時(shí)候,為了獲得當(dāng)前真實(shí)的信息,瀏覽器會(huì)立刻進(jìn)行一次重排。
3.2 避免強(qiáng)制性同步布局
在 JavaScript 中讀取到的布局信息都是上一幀的信息,如果在 JavaScript 中修改了頁(yè)面的布局,比如給某個(gè)元素添加了一個(gè)類,然后再讀取布局信息。這個(gè)時(shí)候?yàn)榱双@得真實(shí)的布局信息,瀏覽器需要強(qiáng)制性對(duì)頁(yè)面進(jìn)行布局。因此應(yīng)該避免這樣做。
3.3 批量操作 DOM
在必須要進(jìn)行頻繁的 DOM 操作時(shí),可以使用 fastdom 這樣的工具,它的思路是將對(duì)頁(yè)面的讀取和改寫放進(jìn)隊(duì)列,在頁(yè)面重繪的時(shí)候批量執(zhí)行,先進(jìn)行讀取后改寫。因?yàn)槿绻麑⒆x取與改寫交織在一起可能引起多次頁(yè)面的重排。而利用 fastdom 就可以避免這樣的情況發(fā)生。
雖然有了 fastdom 這樣的工具,但有的時(shí)候還是不能從根本上解決問題,比如我最近遇到的一個(gè)情況,與頁(yè)面簡(jiǎn)單的一次交互(輕輕滾動(dòng)頁(yè)面)就執(zhí)行了幾千次 DOM 操作,這個(gè)時(shí)候核心要解決的是減少 DOM 操作的次數(shù)。這個(gè)時(shí)候就要從代碼層面考慮,看看是否有不必要的讀取。
另外一些關(guān)于高效操作 DOM 的方法,可以參見《高性能JavaScript》相關(guān)章節(jié),也可以先參考一下我的讀書筆記 《高性能JavaScript》 (https://github.com/wy-ei/notebook/issues/34 )
4 優(yōu)化渲染性能
瀏覽器通常每秒更新頁(yè)面 60 次,每一幀的時(shí)間就是 16.6ms,為了能讓瀏覽器保持 60幀 的幀率,為了讓動(dòng)畫看起來流暢,需要保證幀率達(dá)到 60fps,因此每一幀的邏輯需要在 16.6ms 內(nèi)完成。
每一幀實(shí)際上都包含下列步驟:
因此,通常 JavaScript 的執(zhí)行時(shí)間不能超過 10ms。
JavaScript:改變?cè)貥邮剑砑釉氐?DOM 中等等
Style:元素的類或者style改變了,這個(gè)時(shí)候需要重新計(jì)算元素的樣式
Layout:需要重新計(jì)算元素的具體尺寸
Paint:將元素的繪制的圖層上
Composite:合并多個(gè)圖層
當(dāng)然也不是說每一幀都會(huì)進(jìn)行這些操作。當(dāng)你的 JavaScript 改變了某個(gè) layout 屬性,比如元素的 width 和height 或者 top 等等,瀏覽器就會(huì)重新計(jì)算布局,并對(duì)整個(gè)頁(yè)面進(jìn)行重排。
如果修改了 background、color 這樣的僅僅會(huì)讓頁(yè)面重繪的屬性,這不會(huì)影響頁(yè)面的布局,瀏覽器會(huì)跳過計(jì)算布局(layout)的過程,只進(jìn)行重繪(paint)。
如果修改了一個(gè)不需要計(jì)算布局也不需要重繪的屬性,那就只會(huì)進(jìn)行圖層的合并,這是代價(jià)最小的修改。從https://csstriggers.com/ 上你可以知道修改那些樣式屬性會(huì)觸發(fā)(Layout,Paint,Composite)中的那些操作。
4.1 將漸變或者會(huì)動(dòng)畫元素放到單獨(dú)的繪制層中
繪制并非在一個(gè)單獨(dú)的畫布上進(jìn)行的,而是多層。因此將那些會(huì)變動(dòng)的元素提升至單獨(dú)的圖層,可以讓他的改變影響到的元素更少。
可以使用 CSS 中的 will-change: transform; 或者 transform: translateZ(0); 這樣來將元素提升至單獨(dú)的圖層中。
使用 Chrome DevTools 來審查圖層
在調(diào)試的時(shí)候你可以在 Chrome DevTools 的 timeline 面板來觀察繪制圖層。當(dāng)然也不是說圖層越多越好,因?yàn)樾略黾右粋€(gè)圖層可能會(huì)耗費(fèi)額外的內(nèi)存。且新增加一個(gè)圖層的目的是為了避免某個(gè)元素的變動(dòng)影響其他元素。
4.2 降低繪制復(fù)雜度
某些屬性的重繪相對(duì)而言更加復(fù)雜,比如 filter、box-shadow 等濾鏡或漸變效果。因此不要濫用這類效果。
5 優(yōu)化 JavaScript 的執(zhí)行
下面提到的 JavaScript 優(yōu)化,并不是說如何讓 JavaScript 執(zhí)行的更快,而是如何讓 JavaScript 更高效地與 DOM 配合。
5.1 使用 requestAnimationFrame 來更新頁(yè)面
我們希望在每一幀剛開始的時(shí)候?qū)?yè)面進(jìn)行更改,目前只有使用 requestAnimationFrame 能夠保證這一點(diǎn)。使用setTimeout 或者 setInterval 來觸發(fā)更新頁(yè)面的函數(shù),該函數(shù)可能在一幀的中間或者結(jié)束的時(shí)間點(diǎn)上調(diào)用,進(jìn)而導(dǎo)致該幀后面需要進(jìn)行的事情沒有完成,引發(fā)丟幀。
使用 setTimeout 可能導(dǎo)致丟幀
requestAnimationFrame 會(huì)將任務(wù)安排在頁(yè)面重繪之前,這保證動(dòng)畫能有足夠的時(shí)間來執(zhí)行 JavaScript 。
5.2 使用 Web Worker 來處理復(fù)雜的計(jì)算
JavaScript 是在單線程的,并且可能會(huì)一直這樣,因此 JavaScript 在執(zhí)行復(fù)雜計(jì)算的時(shí)候很可能會(huì)阻塞線程,導(dǎo)致頁(yè)面假死。但 Web Worker 的出現(xiàn),以另外一種方式給了我們多線程的能力,可以將復(fù)雜計(jì)算放在 worker 中進(jìn)行,當(dāng)計(jì)算完成后,以 postMessage 的形式將結(jié)果傳回來。
對(duì)于單個(gè)函數(shù),因?yàn)?Web Worker 接受一個(gè)腳本的 url 作為參數(shù),使用 URL.createObjectURL 方法,我們可以將一個(gè)函數(shù)的內(nèi)容轉(zhuǎn)換為 url,利用它創(chuàng)建一個(gè) worker。
var workerContent=`
self.onmessage=function(evt){
// ...
// 在這里進(jìn)行復(fù)雜計(jì)算
var result=complexFunc();
// 將結(jié)果傳回
self.postMessage(result);
};`
// 得到 url
var blob=new Blob([workerContent]);
var url=window.URL.createObjectURL(blob);
// 創(chuàng)建 worker
var worker=new Worker(url);
5.3 使用 transform 和 opacity 來完成動(dòng)畫
如今只有對(duì)這兩個(gè)屬性的修改不需要經(jīng)歷 layout 和 paint 過程。
6 優(yōu)化 CSS
CSS 選擇器在匹配的時(shí)候是由右至左進(jìn)行的,因此最后一個(gè)選擇器常被稱為關(guān)鍵選擇器,因?yàn)樽詈笠粋€(gè)選擇越特殊,需要進(jìn)行匹配的次數(shù)越少。要千萬避免使用 *(通用選擇器)作為關(guān)鍵選擇器。因?yàn)樗芷ヅ涞剿性?,進(jìn)而倒數(shù)第二個(gè)選擇器還會(huì)和所有元素進(jìn)行一次匹配。這導(dǎo)致效率很低下。
div p * {}
另外 first-child 這類偽類選擇器也不夠特殊,也要避免將它們作為關(guān)鍵選擇器。關(guān)鍵選擇器越特殊,瀏覽器就能用較少的匹配次數(shù)找到待匹配元素,選擇器性能也就越好。
還有一個(gè)老生常談的注意事項(xiàng),不要使用太多的選擇器。如果還有同學(xué)很悲劇地要兼容低版本 IE,要避免使用 CSS 表達(dá)式,它的性能很差,詳細(xì)內(nèi)容可參見我之前記錄的一篇筆記 《高性能網(wǎng)站建設(shè)指南》筆記(https://github.com/wy-ei/notebook/issues/15 )
7 合理處理腳本和樣式表
如今有了 requirejs,webpack 等工具,可能很少會(huì)在頁(yè)面中加載很多 JavaScript/CSS 代碼了。盡管如此,還是有必要談?wù)勅绾魏侠硖幚砟_本和樣式表。
大多數(shù)人已經(jīng)知道通常要把 JavaScript 放在文檔底部,把 CSS 放在文檔頂部。為什么呢?因?yàn)?JavaScript 會(huì)阻塞頁(yè)面的解析,而外部樣式表會(huì)阻塞頁(yè)面的呈現(xiàn)和 JavaScript 的執(zhí)行。
7.1 CSS阻塞渲染
通常情況下 CSS 被認(rèn)為是阻塞渲染的資源,在CSSOM 構(gòu)建完成之前,頁(yè)面不會(huì)被渲染,放在頂部讓樣式表能夠盡早開始加載。但如果把引入樣式表的 link 放在文檔底部,頁(yè)面雖然能立刻呈現(xiàn)出來,但是頁(yè)面加載出來的時(shí)候會(huì)是沒有樣式的,是混亂的。當(dāng)后來樣式表加載進(jìn)來后,頁(yè)面會(huì)立即進(jìn)行重繪,這也就是通常所說的閃爍了。
7.2 JavaScript 阻塞文檔解析
當(dāng)在 HTML 文檔中遇到 script 標(biāo)簽后控制權(quán)將交給 JavaScript,在 JavaScript 下載并執(zhí)行完成之前,都不會(huì)解析 HTML。因此如果將 JavaScript 放在文檔頂部,恰好這個(gè)時(shí)候 JavaScript 腳本加載的特別慢,用戶將會(huì)等待很長(zhǎng)一段時(shí)間,這段個(gè)時(shí)候 HTML 文檔還沒有解析到 body 部分,頁(yè)面會(huì)是空白的。
另外常常被忽略的事實(shí)是:在瀏覽器沒有下載并解析完成使用 link 引入的 CSS 文件之前,JavaScript 是不會(huì)執(zhí)行的,因?yàn)?JavaScript 中可能需要讀取樣式,而此時(shí)樣式表還沒有加載回來,因此瀏覽器不會(huì)執(zhí)行 JavaScript。可以給 JavaScript 加上 async 標(biāo)記,表示 JavaScript 的執(zhí)行不會(huì)讀取 DOM ,JavaScript 可以不被 CSS 阻塞,可以在空閑時(shí)間立刻執(zhí)行。
綜上所述,你更要保證 CSS 文件加載的足夠快。