前端性能優化 20 條建議

這些性能優化建議不一定適合所有人,相關建議的參考資料均會在建議後面給出,或者放在文末。

1. 減少 HTTP 請求

一個 HTTP 請求過程:
在這裏插入圖片描述
一個 HTTP 請求需要經歷以上過程,接下來看一個具體的例子:
在這裏插入圖片描述
這是一個 HTTP 請求,請求的文件大小爲 28.4KB。

名詞解釋:

  • Queueing: 在請求隊列中的時間。
  • Stalled: 從TCP 連接建立完成,到真正可以傳輸數據之間的時間差,此時間包括代理協商時間。
  • Proxy negotiation: 與代理服務器連接進行協商所花費的時間。
  • DNS Lookup: 執行DNS查找所花費的時間,頁面上的每個不同的域都需要進行DNS查找。
  • Initial Connection / Connecting: 建立連接所花費的時間,包括TCP握手/重試和協商SSL。
  • SSL: 完成SSL握手所花費的時間。
  • Request sent: 發出網絡請求所花費的時間,通常爲一毫秒的時間。
  • Waiting(TFFB): TFFB 是發出頁面請求到接收到應答數據第一個字節的時間總和,它包含了 DNS 解析時間、 TCP 連接時間、發送 HTTP 請求時間和獲得響應消息第一個字節的時間。
  • Content Download: 接收響應數據所花費的時間。

從這個例子可以看出,真正下載數據的時間佔比爲 13.05 / 204.16 = 6.39%,文件越小,比例越小,文件越大,比例越高。這就是爲什麼要建議將多個小文件合併爲一個大文件,從頁減少 HTTP 請求次數的原因。

參考資料:

2. 使用 HTTP2

HTTP1.x 客戶端需要使用多個連接才能實現併發和縮短延遲;HTTP1.x 不會壓縮請求和響應標頭,從而導致不必要的網絡流量;HTTP1.x 不支持有效的資源優先級,致使底層 TCP 連接的利用率低下等等。

HTTP2 是對之前 HTTP 標準的擴展,它通過支持標頭字段壓縮和在同一連接上進行多個併發交換,讓應用更有效地利用網絡資源,減少感知的延遲時間。具體來說,它可以對同一連接上的請求和響應消息進行交錯發送併爲 HTTP 標頭字段使用有效編碼。

HTTP2 還允許爲請求設置優先級,讓更重要的請求更快速地完成,從而進一步提升性能。

HTTP2 支持了多路複用,HTTP 連接變得十分廉價,之前爲了節省連接數所採用的類似於「資源合併、資源內聯」等優化手段不再需要了。多路複用可以在一個 TCP 連接上建立大量 HTTP 連接,也就不存在 HTTP 連接數限制了,HTTP1.x 中常見的「靜態域名」優化策略不但用不上了,還會帶來負面影響,需要去掉。另外,HTTP2 的頭部壓縮功能也能大幅減少 HTTP 協議頭部帶來的開銷。但是,要等HTTP1.x 完全退出舞臺還需要一段時間。

現在有很多網站已經開始使用 HTTP2 了,例如知乎:
在這裏插入圖片描述
其中 h2 是指 HTTP2 協議,http/1.1 則是指 HTTP1.1 協議。

參考資料:

3. 使用服務端渲染

客戶端渲染: 獲取 HTML 文件,根據需要下載 JavaScript 文件,運行文件,生成 DOM,再渲染。
服務端渲染:服務端返回 HTML 文件,客戶端只需解析 HTML。

  • 優點:首屏渲染快,SEO 好。
  • 缺點:配置麻煩。

參考資料:

4. 靜態資源使用 CDN

內容分發網絡(CDN)是一組分佈在多個不同地理位置的 Web 服務器。我們都知道,當服務器離用戶越遠時,延遲越高。CDN 就是爲了解決這一問題,在多個位置部署服務器,讓用戶離服務器更近,從而縮短請求時間。

參考資料:

5. 將 CSS 放在文件頭部,JavaScript 文件放在底部

所有放在 head 標籤裏的 CSS 和 JS 文件都會堵塞渲染。如果這些 CSS 和 JS 需要加載和解析很久的話,那麼頁面就空白了。所以 JS 文件要放在底部,等 HTML 解析完了再加載 JS 文件。

那爲什麼 CSS 文件還要放在頭部呢?

因爲先加載 HTML 再加載 CSS,會讓用戶第一時間看到的頁面是沒有樣式的、“醜陋”的,爲了避免這種情況發生,就要將 CSS 文件放在頭部了。

另外,JS 文件也不是不可以放在頭部,只要給 script 標籤加上 defer 屬性就可以了,異步下載,延遲執行。

6. 使用字體圖標 iconfont 代替圖片圖標

字體圖標就是將圖標製作成一個字體,使用時就跟字體一樣,可以設置屬性,例如 font-size、color 等等,非常方便。並且字體圖標是矢量圖,不會失真。還有一個優點是生成的文件特別小。

7. 善用緩存,不重複加載相同的資源

爲了避免用戶每次訪問網站都得請求文件,我們可以通過添加 Expires 頭來控制這一行爲。Expires 設置了一個時間,只要在這個時間之前,瀏覽器都不會請求文件,而是直接使用緩存。

不過這樣會產生一個問題,當文件更新了怎麼辦?怎麼通知瀏覽器重新請求文件?

可以通過更新頁面中引用的 URL,讓瀏覽器主動放棄緩存,加載新資源。

具體做法是把 URL 的修改與文件內容關聯起來,也就是說,只有文件內容變化,纔會導致相應 URL 的變更,從而實現文件級別的精確緩存控制。什麼東西與文件內容相關呢?我們會很自然的聯想到利用數據摘要要算法對文件求摘要信息,摘要信息與文件內容一一對應,就有了一種可以精確到單個文件粒度的緩存控制依據了。

參考資料:

8. 壓縮文件

壓縮文件可以減少文件下載時間,讓用戶體驗性更好。

得益於 webpack 和 node 的發展,現在壓縮文件已經非常方便了。

在 webpack 可以使用如下插件進行壓縮:

  • JavaScript:UglifyPlugin
  • CSS :MiniCssExtractPlugin
  • HTML:HtmlWebpackPlugin

其實,我們還可以做得更好。那就是使用 gzip 壓縮。可以通過向 HTTP 請求頭中的 Accept-Encoding 頭添加 gzip 標識來開啓這一功能。當然,服務器也得支持這一功能。

gzip 是目前最流行和最有效的壓縮方法。舉個例子,我用 Vue 開發的項目構建後生成的 app.js 文件大小爲 1.4MB,使用 gzip 壓縮後只有 573KB,體積減少了將近 60%。

附上 webpack 和 node 配置 gzip 的使用方法。

下載插件

npm install compression-webpack-plugin --save-dev
npm install compression

webpack 配置

const CompressionPlugin = require('compression-webpack-plugin');

module.exports = {
  plugins: [new CompressionPlugin()],
}

node 配置

const compression = require('compression')
// 在其他中間件前使用
app.use(compression())

9. 圖片優化

(1). 圖片延遲加載
在頁面中,先不給圖片設置路徑,只有當圖片出現在瀏覽器的可視區域內時,纔去加載真正的圖片,這就是延遲加載。對於圖片很多的網站來說,一次性加載全部圖片,會對用戶體驗造成很大的影響,所以需要使用圖片延遲加載。

首先可以將圖片這樣設置,在頁面不可見時圖片不會加載

<img data-src="https://avatars0.githubusercontent.com/u/22117876?s=460&u=7bd8f32788df6988833da6bd155c3cfbebc68006&v=4">

等頁面可見時,使用 JS 加載圖片

const img = document.querySelector('img')
img.src = img.dataset.src

這樣圖片就加載出來了,完整的代碼可以看一下參考資料。

參考資料:

(2). 響應式圖片
響應式圖片的優點是瀏覽器能夠根據屏幕大小自動加載合適的圖片。

通過 picture 實現

<picture>
	<source srcset="banner_w1000.jpg" media="(min-width: 801px)">
	<source srcset="banner_w800.jpg" media="(max-width: 800px)">
	<img src="banner_w800.jpg" alt="">
</picture>

通過 @media 實現

@media (min-width: 769px) {
	.bg {
		background-image: url(bg1080.jpg);
	}
}
@media (max-width: 768px) {
	.bg {
		background-image: url(bg768.jpg);
	}
}

(3). 調整圖片大小
例如,你有一個 1920 * 1080 大小的圖片,用縮略圖的方式展示給用戶,並且當用戶鼠標懸停在上面時才展示全圖。如果用戶從未真正將鼠標懸停在縮略圖上,則浪費了下載圖片的時間。

所以,我們可以用兩張圖片來實行優化。一開始,只加載縮略圖,當用戶懸停在圖片上時,才加載全圖。

(4). 降低圖片質量
例如 JPG 格式的圖片,100% 的質量和 90% 質量的通常看不出來區別,尤其是用來當背景圖的時候。我經常用 PS 切背景圖時, 將圖片切成 JPG 格式,並且將它壓縮到 60% 的質量,基本上看不出來區別。

除此之外,網上還有很多在線壓縮圖片的網站,大家可以自行搜索。

(5). 儘可能利用 CSS3 效果代替圖片
有很多圖片使用 CSS 效果(漸變、陰影等)就能畫出來,這種情況選擇 CSS3 效果更好。因爲代碼大小通常是圖片大小的幾分之一甚至幾十分之一。

10. 通過 webpack 按需加載 JavaScript 代碼

懶加載或者按需加載,是一種很好的優化網頁或應用的方式。這種方式實際上是先把你的代碼在一些邏輯斷點處分離開,然後在一些代碼塊中完成某些操作後,立即引用或即將引用另外一些新的代碼塊。這樣加快了應用的初始加載速度,減輕了它的總體體積,因爲某些代碼塊可能永遠不會被加載。

如果你使用腳手架來構建項目,一般配置起來非常簡單。具體細節可看一下 webpack 文檔,非常簡單。

參考資料:

11. 減少重繪重排

瀏覽器渲染過程

  1. 解析HTML生成DOM樹。
  2. 解析CSS生成CSSOM規則樹。
  3. 將DOM樹與CSSOM規則樹合併在一起生成渲染樹。
  4. 遍歷渲染樹開始佈局,計算每個節點的位置大小信息。
  5. 將渲染樹每個節點繪製到屏幕。

重排
當改變 DOM 元素位置或大小時,會導致瀏覽器重新生成渲染樹,這個過程叫重排。

重繪
當重新生成渲染樹後,就要將渲染樹每個節點繪製到屏幕,這個過程叫重繪。不是所有的動作都會導致重排,例如改變字體顏色,只會導致重繪。記住,重排會導致重繪,重繪不會導致重排 。

重排和重繪這兩個操作都是非常昂貴的,因爲 JavaScript 引擎線程與 GUI 渲染線程是互斥,它們同時只能一個在工作。

什麼操作會導致重排?

  • 添加或刪除可見的 DOM 元素
  • 元素位置改變
  • 元素尺寸改變
  • 內容改變
  • 瀏覽器窗口尺寸改變

如何減少重排重繪?

  • 用 JavaScript 修改樣式時,最好不要直接寫樣式,而是替換 class 來改變樣式。
  • 如果要對 DOM 元素執行一系列操作,可以將 DOM 元素脫離文檔流,修改完成後,再將它帶回文檔。推薦使用隱藏元素(display:none)或文檔碎片(DocumentFragement),都能很好的實現這個方案。

12. 使用事件委託

事件委託利用了事件冒泡,只指定一個事件處理程序,就可以管理某一類型的所有事件。所有用到按鈕的事件(多數鼠標事件和鍵盤事件)都適合採用事件委託技術, 使用事件委託可以節省內存。

<ul>
  <li>蘋果</li>
  <li>香蕉</li>
  <li>鳳梨</li>
</ul>

// good
document.querySelector('ul').onclick = (event) => {
  const target = event.target
  if (target.nodeName === 'LI') {
    console.log(target.innerHTML)
  }
}

// bad
document.querySelectorAll('li').forEach((e) => {
  e.onclick = function() {
    console.log(this.innerHTML)
  }
}) 

13. 注意程序的局部性

一個編寫良好的計算機程序常常具有良好的局部性,它們傾向於引用最近引用過的數據項附近的數據項,或者最近引用過的數據項本身,這種傾向性,被稱爲局部性原理。有良好局部性的程序比局部性差的程序運行得更快。

局部性通常有兩種不同的形式:

  • 時間局部性:在一個具有良好時間局部性的程序中,被引用過一次的內存位置很可能在不遠的將來被多次引用。
  • 空間局部性 :在一個具有良好空間局部性的程序中,如果一個內存位置被引用了一次,那麼程序很可能在不遠的將來引用附近的一個內存位置。

時間局部性示例

function sum(arry) {
	let i, sum = 0
	let len = arry.length

	for (i = 0; i < len; i++) {
		sum += arry[i]
	}

	return sum
}

在這個例子中,變量sum在每次循環迭代中被引用一次,因此,對於sum來說,具有良好的時間局部性

空間局部性示例

具有良好空間局部性的程序

// 二維數組 
function sum1(arry, rows, cols) {
	let i, j, sum = 0

	for (i = 0; i < rows; i++) {
		for (j = 0; j < cols; j++) {
			sum += arry[i][j]
		}
	}
	return sum
}

空間局部性差的程序

// 二維數組 
function sum2(arry, rows, cols) {
	let i, j, sum = 0

	for (j = 0; j < cols; j++) {
		for (i = 0; i < rows; i++) {
			sum += arry[i][j]
		}
	}
	return sum
}

看一下上面的兩個空間局部性示例,像示例中從每行開始按順序訪問數組每個元素的方式,稱爲具有步長爲1的引用模式。
如果在數組中,每隔k個元素進行訪問,就稱爲步長爲k的引用模式。
一般而言,隨着步長的增加,空間局部性下降。

這兩個例子有什麼區別?區別在於第一個示例是按行掃描數組,每掃描完一行再去掃下一行;第二個示例是按列來掃描數組,掃完一行中的一個元素,馬上就去掃下一行中的同一列元素。

數組在內存中是按照行順序來存放的,結果就是逐行掃描數組的示例得到了步長爲 1 引用模式,具有良好的空間局部性;而另一個示例步長爲 rows,空間局部性極差。

性能測試
運行環境:

  • cpu: i5-7400
  • 瀏覽器: chrome 70.0.3538.110

對一個長度爲9000的二維數組(子數組長度也爲9000)進行10次空間局部性測試,時間(毫秒)取平均值,結果如下:

所用示例爲上述兩個空間局部性示例

步長爲 1 步長爲 9000
124 2316

從以上測試結果來看,步長爲 1 的數組執行時間比步長爲 9000 的數組快了一個數量級。

總結:

  • 重複引用相同變量的程序具有良好的時間局部性
  • 對於具有步長爲 k 的引用模式的程序,步長越小,空間局部性越好;而在內存中以大步長跳來跳去的程序空間局部性會很差

參考資料:

14. if-else 對比 switch

當判斷條件數量越來越多時,越傾向於使用 switch 而不是 if-else。

if (color == 'blue') {

} else if (color == 'yellow') {

} else if (color == 'white') {

} else if (color == 'black') {

} else if (color == 'green') {

} else if (color == 'orange') {

} else if (color == 'pink') {

}

switch (color) {
    case 'blue':

        break
    case 'yellow':

        break
    case 'white':

        break
    case 'black':

        break
    case 'green':

        break
    case 'orange':

        break
    case 'pink':

        break
}

像以上這種情況,使用 switch 是最好的。假設 color 的值爲 pink,則 if-else 語句要進行 7 次判斷,switch 只需要進行一次判斷。
從可讀性來說,switch 語句也更好。從使用時機來說,當條件值大於兩個的時候,使用 switch 更好。

不過,switch 只能用於 case 值爲常量的分支結構,而 if-else 更加靈活。

15. 查找表

當條件語句特別多時,使用 switch 和 if-else 不是最佳的選擇,這時不妨試一下查找表。查找表可以使用數組和對象來構建。

switch (index) {
    case '0':
        return result0
    case '1':
        return result1
    case '2':
        return result2
    case '3':
        return result3
    case '4':
        return result4
    case '5':
        return result5
    case '6':
        return result6
    case '7':
        return result7
    case '8':
        return result8
    case '9':
        return result9
    case '10':
        return result10
    case '11':
        return result11
}

可以將這個 switch 語句轉換爲查找表

const results = [result0,result1,result2,result3,result4,result5,result6,result7,result8,result9,result10,result11]

return results[index]

如果條件語句不是數值而是字符串,可以用對象來建立查找表

const map = {
  red: result0,
  green: result1,
}

return map[color]

16. 避免頁面卡頓

60fps 與設備刷新率

目前大多數設備的屏幕刷新率爲 60 次/秒。因此,如果在頁面中有一個動畫或漸變效果,或者用戶正在滾動頁面,那麼瀏覽器渲染動畫或頁面的每一幀的速率也需要跟設備屏幕的刷新率保持一致。
其中每個幀的預算時間僅比 16 毫秒多一點 (1 秒/ 60 = 16.66 毫秒)。但實際上,瀏覽器有整理工作要做,因此您的所有工作需要在 10 毫秒內完成。如果無法符合此預算,幀率將下降,並且內容會在屏幕上抖動。 此現象通常稱爲卡頓,會對用戶體驗產生負面影響。

在這裏插入圖片描述
假如你用 JavaScript 修改了 DOM,並觸發樣式修改,經歷重排重繪最後畫到屏幕上。如果這其中任意一項的執行時間過長,都會導致渲染這一幀的時間過長,平均幀率就會下降。假設這一幀花了 50 ms,那麼此時的幀率爲 1s / 50ms = 20fps,頁面看起來就像卡頓了一樣。

對於一些長時間運行的 JavaScript,我們可以使用定時器進行切分,延遲執行。

for (let i = 0, len = arry.length; i < len; i++) {
	process(arry[i])
}

假設上面的循環結構由於 process() 複雜度過高或數組元素太多,甚至兩者都有,可以嘗試一下切分。

const todo = arry.concat()
setTimeout(() => {
	process(todo.shift())
	if (todo.length) {
		setTimeout(arguments.callee, 25)
	} else {
		callback(arry)
	}
}, 25)

如果有興趣瞭解更多,可以查看一下高性能JavaScript第 6 章和高效前端:Web高效編程與優化實踐第 3 章。

參考資料:

17. 使用 Web Workers

Web Worker 使用其他工作線程從而獨立於主線程之外,它可以執行任務而不干擾用戶界面。一個 worker 可以將消息發送到創建它的 JavaScript 代碼, 通過將消息發送到該代碼指定的事件處理程序(反之亦然)。

Web Worker 適用於那些處理純數據,或者與瀏覽器 UI 無關的長時間運行腳本。

創建一個新的 worker 很簡單,指定一個腳本的 URI 來執行 worker 線程(main.js):

var myWorker = new Worker('worker.js');
// 你可以通過postMessage() 方法和onmessage事件向worker發送消息。
first.onchange = function() {
  myWorker.postMessage([first.value,second.value]);
  console.log('Message posted to worker');
}

second.onchange = function() {
  myWorker.postMessage([first.value,second.value]);
  console.log('Message posted to worker');
}

在 worker 中接收到消息後,我們可以寫一個事件處理函數代碼作爲響應(worker.js):

onmessage = function(e) {
  console.log('Message received from main script');
  var workerResult = 'Result: ' + (e.data[0] * e.data[1]);
  console.log('Posting message back to main script');
  postMessage(workerResult);
}

onmessage處理函數在接收到消息後馬上執行,代碼中消息本身作爲事件的data屬性進行使用。這裏我們簡單的對這2個數字作乘法處理並再次使用postMessage()方法,將結果回傳給主線程。

回到主線程,我們再次使用onmessage以響應worker回傳的消息:

myWorker.onmessage = function(e) {
  result.textContent = e.data;
  console.log('Message received from worker');
}

在這裏我們獲取消息事件的data,並且將它設置爲result的textContent,所以用戶可以直接看到運算的結果。

不過在worker內,不能直接操作DOM節點,也不能使用window對象的默認方法和屬性。然而你可以使用大量window對象之下的東西,包括WebSockets,IndexedDB以及FireFox OS專用的Data Store API等數據存儲機制。

參考資料:

18. 使用位操作

JavaScript 中的數字都使用 IEEE-754 標準以 64 位格式存儲。但是在位操作中,數字被轉換爲有符號的 32 位格式。即使需要轉換,位操作也比其他數學運算和布爾操作快得多。
取模
由於偶數的最低位爲 0,奇數爲 1,所以取模運算可以用位操作來代替。

if (value % 2) {
	// 奇數
} else {
	// 偶數 
}
// 位操作
if (value & 1) {
	// 奇數
} else {
	// 偶數
}

取反

~~10.12 // 10
~~10 // 10
~~'1.5' // 1
~~undefined // 0
~~null // 0

位掩碼

const a = 1
const b = 2
const c = 4
const options = a | b | c

通過定義這些選項,可以用按位與操作來判斷 a/b/c 是否在 options 中。

// 選項 b 是否在選項中
if (b & options) {
	...
}

19. 不要覆蓋原生方法

無論你的 JavaScript 代碼如何優化,都比不上原生方法。因爲原生方法是用低級語言寫的(C/C++),並且被編譯成機器碼,成爲瀏覽器的一部分。
當原生方法可用時,儘量使用它們,特別是數學運算和 DOM 操作。

20. 降低 CSS 選擇器的複雜性

(1). 瀏覽器讀取選擇器,遵循的原則是從選擇器的右邊到左邊讀取。

看個示例

#block .text p {
	color: red;
}
  1. 查找所有 P 元素。
  2. 查找結果 1 中的元素是否有類名爲 text 的父元素
  3. 查找結果 2 中的元素是否有 id 爲 block 的父元素

(2). CSS 選擇器優先級

內聯 > ID選擇器 > 類選擇器 > 標籤選擇器

根據以上兩個信息可以得出結論。

  1. 選擇器越短越好。
  2. 儘量使用高優先級的選擇器,例如 ID 和類選擇器。
  3. 避免使用通配符 *。

最後要說一句,據我查找的資料所得,CSS 選擇器沒有優化的必要,因爲最慢和慢快的選擇器性能差別非常小。

參考資料:

其他參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章