1. 程式人生 > 實用技巧 >阿里前端大神:效能優化12條建議

阿里前端大神:效能優化12條建議

效能優化是把雙刃劍,有好的一面也有壞的一面。好的一面就是能提升網站效能,壞的一面就是配置麻煩,或者要遵守的規則太多。並且某些效能優化規則並不適用所有場景,需要謹慎使用,請讀者帶著批判性的眼光來閱讀本文。

本文相關的優化建議的引用資料出處均會在建議後面給出,或者放在文末。

1. 減少 HTTP 請求

一個完整的 HTTP 請求需要經歷 DNS 查詢,TCP 握手,瀏覽器發出 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

HTTP2 相比 HTTP1.1 有如下幾個優點:

解析速度快

伺服器解析 HTTP1.1 的請求時,必須不斷地讀入位元組,直到遇到分隔符 CRLF 為止。而解析 HTTP2 的請求就不用這麼麻煩,因為 HTTP2 是基於幀的協議,每個幀都有表示幀長度的欄位。

多路複用

HTTP1.1 如果要同時發起多個請求,就得建立多個 TCP 連線,因為一個 TCP 連線同時只能處理一個 HTTP1.1 的請求。

在 HTTP2 上,多個請求可以共用一個 TCP 連線,這稱為多路複用。同一個請求和響應用一個流來表示,並有唯一的流 ID 來標識。 多個請求和響應在 TCP 連線中可以亂序傳送,到達目的地後再通過流 ID 重新組建。

首部壓縮

HTTP2 提供了首部壓縮功能。

例如有如下兩個請求:

:authority: unpkg.zhimg.com
:method: GET
:path: /[email protected]/dist/zap.js
:scheme: https
accept: */*
accept-encoding: gzip, deflate, br
accept-language: zh-CN,zh;q=0.9
cache-control: no-cache
pragma: no-cache
referer: https://www.zhihu.com/
sec-fetch-dest: script
sec-fetch-mode: no-cors
sec-fetch-site: cross-site
user-agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36
:authority: zz.bdstatic.com
:method: GET
:path: /linksubmit/push.js
:scheme: https
accept: */*
accept-encoding: gzip, deflate, br
accept-language: zh-CN,zh;q=0.9
cache-control: no-cache
pragma: no-cache
referer: https://www.zhihu.com/
sec-fetch-dest: script
sec-fetch-mode: no-cors
sec-fetch-site: cross-site
user-agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36

從上面兩個請求可以看出來,有很多資料都是重複的。如果可以把相同的首部儲存起來,僅傳送它們之間不同的部分,就可以節省不少的流量,加快請求的時間。

HTTP/2 在客戶端和伺服器端使用“首部表”來跟蹤和儲存之前傳送的鍵-值對,對於相同的資料,不再通過每次請求和響應傳送。

下面再來看一個簡化的例子,假設客戶端按順序傳送如下請求首部:

Header1:foo
Header2:bar
Header3:bat

當客戶端傳送請求時,它會根據首部值建立一張表:

索引首部名稱
62 Header1 foo
63 Header2 bar
64 Header3 bat

如果伺服器收到了請求,它會照樣建立一張表。 當客戶端傳送下一個請求的時候,如果首部相同,它可以直接傳送這樣的首部塊:

62 63 64

伺服器會查詢先前建立的表格,並把這些數字還原成索引對應的完整首部。

優先順序

HTTP2 可以對比較緊急的請求設定一個較高的優先順序,伺服器在收到這樣的請求後,可以優先處理。

流量控制

由於一個 TCP 連線流量頻寬(根據客戶端到伺服器的網路頻寬而定)是固定的,當有多個請求併發時,一個請求佔的流量多,另一個請求佔的流量就會少。流量控制可以對不同的流的流量進行精確控制。

伺服器推送

HTTP2 新增的一個強大的新功能,就是伺服器可以對一個客戶端請求傳送多個響應。換句話說,除了對最初請求的響應外,伺服器還可以額外向客戶端推送資源,而無需客戶端明確地請求。

例如當瀏覽器請求一個網站時,除了返回 HTML 頁面外,伺服器還可以根據 HTML 頁面中的資源的 URL,來提前推送資源。

現在有很多網站已經開始使用 HTTP2 了,例如知乎:

其中 h2 是指 HTTP2 協議,http/1.1 則是指 HTTP1.1 協議。

參考資料:

3. 使用服務端渲染

客戶端渲染: 獲取 HTML 檔案,根據需要下載 JavaScript 檔案,執行檔案,生成 DOM,再渲染。

服務端渲染:服務端返回 HTML 檔案,客戶端只需解析 HTML。

  • 優點:首屏渲染快,SEO 好。
  • 缺點:配置麻煩,增加了伺服器的計算壓力。

參考資料:

4. 靜態資源使用 CDN

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

CDN 原理

當用戶訪問一個網站時,如果沒有 CDN,過程是這樣的:

  1. 瀏覽器要將域名解析為 IP 地址,所以需要向本地 DNS 發出請求。
  2. 本地 DNS 依次向根伺服器、頂級域名伺服器、許可權伺服器發出請求,得到網站伺服器的 IP 地址。
  3. 本地 DNS 將 IP 地址發回給瀏覽器,瀏覽器向網站伺服器 IP 地址發出請求並得到資源。

如果使用者訪問的網站部署了 CDN,過程是這樣的:

  1. 瀏覽器要將域名解析為 IP 地址,所以需要向本地 DNS 發出請求。
  2. 本地 DNS 依次向根伺服器、頂級域名伺服器、許可權伺服器發出請求,得到全域性負載均衡系統(GSLB)的 IP 地址。
  3. 本地 DNS 再向 GSLB 發出請求,GSLB 的主要功能是根據本地 DNS 的 IP 地址判斷使用者的位置,篩選出距離使用者較近的本地負載均衡系統(SLB),並將該 SLB 的 IP 地址作為結果返回給本地 DNS。
  4. 本地 DNS 將 SLB 的 IP 地址發回給瀏覽器,瀏覽器向 SLB 發出請求。
  5. SLB 根據瀏覽器請求的資源和地址,選出最優的快取伺服器發回給瀏覽器。
  6. 瀏覽器再根據 SLB 發回的地址重定向到快取伺服器。
  7. 如果快取伺服器有瀏覽器需要的資源,就將資源發回給瀏覽器。如果沒有,就向源伺服器請求資源,再發給瀏覽器並快取在本地。

參考資料:

5. 將 CSS 放在檔案頭部,JavaScript 檔案放在底部

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

那為什麼 CSS 檔案還要放在頭部呢?

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

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

6. 使用字型圖示 iconfont 代替圖片圖示

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

壓縮字型檔案

使用fontmin-webpack外掛對字型檔案進行壓縮(感謝前端小偉提供)。

參考資料:

7. 善用快取,不重複載入相同的資源

為了避免使用者每次訪問網站都得請求檔案,我們可以通過新增 Expires 或 max-age 來控制這一行為。Expires 設定了一個時間,只要在這個時間之前,瀏覽器都不會請求檔案,而是直接使用快取。而 max-age 是一個相對時間,建議使用 max-age 代替 Expires 。

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

可以通過更新頁面中引用的資源連結地址,讓瀏覽器主動放棄快取,載入新資源。

具體做法是把資源地址 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 大小的圖片,用縮圖的方式展示給使用者,並且當用戶滑鼠懸停在上面時才展示全圖。如果使用者從未真正將滑鼠懸停在縮圖上,則浪費了下載圖片的時間。

所以,我們可以用兩張圖片來實行優化。一開始,只加載縮圖,當用戶懸停在圖片上時,才載入大圖。還有一種辦法,即對大圖進行延遲載入,在所有元素都載入完成後手動更改大圖的 src 進行下載。

(4). 降低圖片質量

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

壓縮方法有兩種,一是通過 webpack 外掛image-webpack-loader,二是通過線上網站進行壓縮。

以下附上 webpack 外掛image-webpack-loader的用法。

npm i -D image-webpack-loader

webpack 配置

{
  test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
  use:[
    {
    loader: 'url-loader',
    options: {
      limit: 10000, /* 圖片大小小於1000位元組限制時會自動轉成 base64 碼引用*/
      name: utils.assetsPath('img/[name].[hash:7].[ext]')
      }
    },
    /*對圖片進行壓縮*/
    {
      loader: 'image-webpack-loader',
      options: {
        bypassOnDebug: true,
      }
    }
  ]
}

(5). 儘可能利用 CSS3 效果代替圖片

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

參考資料:

(6). 使用 webp 格式的圖片

WebP 的優勢體現在它具有更優的影象資料壓縮演算法,能帶來更小的圖片體積,而且擁有肉眼識別無差異的影象質量;同時具備了無損和有損的壓縮模式、Alpha 透明以及動畫的特性,在 JPEG 和 PNG 上的轉化效果都相當優秀、穩定和統一。

參考資料:

10. 通過 webpack 按需載入程式碼,提取第三庫程式碼,減少 ES6 轉為 ES5 的冗餘程式碼

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

根據檔案內容生成檔名,結合 import 動態引入元件實現按需載入

通過配置 output 的 filename 屬性可以實現這個需求。filename 屬性的值選項中有一個 [contenthash],它將根據檔案內容創建出唯一 hash。當檔案內容發生變化時,[contenthash] 也會發生變化。

output: {
    filename: '[name].[contenthash].js',
    chunkFilename: '[name].[contenthash].js',
    path: path.resolve(__dirname, '../dist'),
},

提取第三方庫

由於引入的第三方庫一般都比較穩定,不會經常改變。所以將它們單獨提取出來,作為長期快取是一個更好的選擇。 這裡需要使用 webpack4 的 splitChunk 外掛 cacheGroups 選項。

optimization: {
    runtimeChunk: {
        name: 'manifest' // 將 webpack 的 runtime 程式碼拆分為一個單獨的 chunk。
    },
    splitChunks: {
        cacheGroups: {
            vendor: {
                name: 'chunk-vendors',
                test: /[\\/]node_modules[\\/]/,
                priority: -10,
                chunks: 'initial'
            },
            common: {
                name: 'chunk-common',
                minChunks: 2,
                priority: -20,
                chunks: 'initial',
                reuseExistingChunk: true
            }
        },
    }
},
  • test: 用於控制哪些模組被這個快取組匹配到。原封不動傳遞出去的話,它預設會選擇所有的模組。可以傳遞的值型別:RegExp、String和Function;
  • priority:表示抽取權重,數字越大表示優先順序越高。因為一個 module 可能會滿足多個 cacheGroups 的條件,那麼抽取到哪個就由權重最高的說了算;
  • reuseExistingChunk:表示是否使用已有的 chunk,如果為 true 則表示如果當前的 chunk 包含的模組已經被抽取出去了,那麼將不會重新生成新的。
  • minChunks(預設是1):在分割之前,這個程式碼塊最小應該被引用的次數(譯註:保證程式碼塊複用性,預設配置的策略是不需要多次引用也可以被分割)
  • chunks (預設是async) :initial、async和all
  • name(打包的chunks的名字):字串或者函式(函式可以根據條件自定義名字)

減少 ES6 轉為 ES5 的冗餘程式碼

Babel 轉化後的程式碼想要實現和原來程式碼一樣的功能需要藉助一些幫助函式,比如:

class Person {}

會被轉換為:

"use strict";

function _classCallCheck(instance, Constructor) {
  if (!(instance instanceof Constructor)) {
    throw new TypeError("Cannot call a class as a function");
  }
}

var Person = function Person() {
  _classCallCheck(this, Person);
};

這裡_classCallCheck就是一個helper函式,如果在很多檔案裡都聲明瞭類,那麼就會產生很多個這樣的helper函式。

這裡的@babel/runtime包就聲明瞭所有需要用到的幫助函式,而@babel/plugin-transform-runtime的作用就是將所有需要helper函式的檔案,從@babel/runtime包引進來:

"use strict";

var _classCallCheck2 = require("@babel/runtime/helpers/classCallCheck");

var _classCallCheck3 = _interopRequireDefault(_classCallCheck2);

function _interopRequireDefault(obj) {
  return obj && obj.__esModule ? obj : { default: obj };
}

var Person = function Person() {
  (0, _classCallCheck3.default)(this, Person);
};

這裡就沒有再編譯出helper函式classCallCheck了,而是直接引用了@babel/runtime中的helpers/classCallCheck

安裝

npm i -D @babel/plugin-transform-runtime @babel/runtime

使用在.babelrc檔案中

"plugins": [
        "@babel/plugin-transform-runtime"
]

參考資料:

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)
  }
}) 

同時,我還從這位阿里大神手裡薅到一份阿里內部資料。

有需要的點選這裡免費領取資料PDF

篇幅有限,僅展示部分內容

如果你需要這份完整版資料pdf,【點選我】就可以了。

希望大家明年的金三銀四面試順利,拿下自己心儀的offer!