2022 前端效能優化最佳實踐

佩奇烹飪家發表於2022-04-25

背景

隨著業務的不斷迭代,專案日漸壯大,為了給使用者提供更優的體驗,效能優化是前端開發避不開的話題。一個優秀的網站必然是擁有豐富功能的同時具有比較塊的響應速度,想必我們瀏覽網頁時都更喜歡絲般順滑的感受。

最近在學習整理前端效能優化方面的知識,看了很多的文章,感覺文章多了比較零散,學習效率不高,所以在閱讀和學習其他優秀部落格文章的同時自己做了整理和歸納,與大家一起學習和共勉。

本文相關圖文內容多來自於稀土掘金社群,引用參考文獻出處均在文章末尾顯著標註。

如有侵權,聯絡刪除

一、效能優化的本質

效能優化的目的,就是為了提供給使用者更好的體驗,這些體驗包含這幾個方面:展示更快互動響應快頁面無卡頓情況

更詳細的說,就是指,在使用者輸入url到站點完整把整個頁面展示出來的過程中,通過各種優化策略和方法,讓頁面載入更快;在使用者使用過程中,讓使用者的操作響應更及時,有更好的使用者體驗。

對於前端工程師來說,要做好效能優化,需要理解瀏覽器載入和渲染的本質。理解了本質原理,才能更好的去做優化。

二、雅虎效能優化軍規

雅虎軍規是雅虎的開發人員在總結了網站的不合理部分後,提出的優化網站效能提高的一套方法規則,非常適合初學者繞過這些坎。非常簡要,供大家參考使用,希望對你們以後的開發過程中有所幫助。
ceb0a32c52214eb882e7f6a8712c33fb~tplv-k3u1fbpfcp-zoom-in-crop-mark-1304-0-0-0.awebp.webp

三、效能優化指標

3.1 以使用者為中心的效能指標

  • First Paint 首次繪製(FP)
    這個指標用於記錄頁面第一次繪製畫素的時間,如顯示頁面背景色。

    FP不包含預設背景繪製,但包含非預設的背景繪製。
  • First contentful paint 首次內容繪製 (FCP)
    LCP是指頁面開始載入到最大文字塊內容或圖片顯示在頁面中的時間。如果 FP 及 FCP 兩指標在 2 秒內完成的話我們的頁面就算體驗優秀。
  • Largest contentful paint 最大內容繪製 (LCP)
    用於記錄視窗內最大的元素繪製的時間,該時間會隨著頁面渲染變化而變化,因為頁面中的最大元素在渲染過程中可能會發生改變,另外該指標會在使用者第一次互動後停止記錄。官方推薦的時間區間,在 2.5 秒內表示體驗優秀
  • First input delay 首次輸入延遲 (FID)
    首次輸入延遲,FID(First Input Delay),記錄在 FCP 和 TTI 之間使用者首次與頁面互動時響應的延遲。
  • Time to Interactive 可互動時間 (TTI)
    首次可互動時間,TTI(Time to Interactive)。這個指標計算過程略微複雜,它需要滿足以下幾個條件:

    1. 從 FCP 指標後開始計算
    2. 持續 5 秒內無長任務(執行時間超過 50 ms)且無兩個以上正在進行中的 GET 請求
    3. 往前回溯至 5 秒前的最後一個長任務結束的時間

    對於使用者互動(比如點選事件),推薦的響應時間是 100ms 以內。那麼為了達成這個目標,推薦在空閒時間裡執行任務不超過 50ms( W3C 也有這樣的標準規定),這樣能在使用者無感知的情況下響應使用者的互動,否則就會造成延遲感。

  • Total blocking time 總阻塞時間 (TBT)
    阻塞總時間,TBT(Total Blocking Time),記錄在 FCP 到 TTI 之間所有長任務的阻塞時間總和。
  • Cumulative layout shift 累積佈局偏移 (CLS)
    累計位移偏移,CLS(Cumulative Layout Shift),記錄了頁面上非預期的位移波動。頁面渲染過程中突然插入一張巨大的圖片或者說點選了某個按鈕突然動態插入了一塊內容等等相當影響使用者體驗的網站。這個指標就是為這種情況而生的,計算方式為:位移影響的面積 * 位移距離。

3.2 三大核心指標(Core Web Vitals)

Google 在20年五月提出了網站使用者體驗的三大核心指標
370fbf493d5346aaab90ec248847830f~tplv-k3u1fbpfcp-zoom-in-crop-mark-1304-0-0-0.awebp.webp

3.2.1 Largest Contentful Paint (LCP)

LCP 代表了頁面的速度指標,雖然還存在其他的一些體現速度的指標,但是上文也說過 LCP 能體現的東西更多一些。一是指標實時更新,資料更精確,二是代表著頁面最大元素的渲染時間,通常來說頁面中最大元素的快速載入能讓使用者感覺效能還挺好。

那麼哪些元素可以被定義為最大元素呢?

  • <img> 標籤
  • <image> 在svg中的image標籤
  • <video> video標籤
  • CSS background url()載入的圖片
  • 包含內聯或文字的塊級元素

9191cc8b7fb646d1a09ef0f5343192bd~tplv-k3u1fbpfcp-zoom-in-crop-mark-1304-0-0-0.awebp.webp

線上測量工具

實驗室工具

原生的JS API測量
LCP還可以用JS API進行測量,主要使用PerformanceObserver介面,目前除了IE不支援,其他瀏覽器基本都支援了。

new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    console.log('LCP candidate:', entry.startTime, entry);
  }
}).observe({type: 'largest-contentful-paint', buffered: true});

如何優化LCP
LCP可能被這四個因素影響:

  • 服務端響應時間
  • Javascript和CSS引起的渲染卡頓
  • 資源載入時間
  • 客戶端渲染

3.2.2 First Input Delay (FID)

FID 代表了頁面的互動體驗指標,畢竟沒有一個使用者希望觸發互動以後頁面的反饋很遲緩,互動響應的快會讓使用者覺得網頁挺流暢。
24bc54dfcc9a4d15a21a697b53a4a420~tplv-k3u1fbpfcp-zoom-in-crop-mark-1304-0-0-0.awebp.webp

這個指標其實挺好理解,就是看使用者互動事件觸發到頁面響應中間耗時多少,如果其中有長任務發生的話那麼勢必會造成響應時間變長。推薦響應使用者互動在 100ms 以內.

ec01e1658b6c45a29b8e6b67d87aea8a~tplv-k3u1fbpfcp-zoom-in-crop-mark-1304-0-0-0.awebp.webp

線上測量工具

原生的JS API測量

new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    const delay = entry.processingStart - entry.startTime;
    console.log('FID candidate:', delay, entry);
  }
}).observe({type: 'first-input', buffered: true});

如何優化FID
FID可能被這四個因素影響:

  • 減少第三方程式碼的影響
  • 減少Javascript的執行時間
  • 最小化主執行緒工作
  • 減小請求數量和請求檔案大小

3.2.3 Cumulative Layout Shift (CLS)

CLS 代表了頁面的穩定指標,它能衡量頁面是否排版穩定。尤其在手機上這個指標更為重要,因為手機螢幕挺小,CLS值一大的話會讓使用者覺得頁面體驗做的很差。CLS的分數在0.1或以下,則為Good。

bb714f81002d4fd1a72b949e0a1089e5~tplv-k3u1fbpfcp-zoom-in-crop-mark-1304-0-0-0.awebp.webp

瀏覽器會監控兩楨之間發生移動的不穩定元素。佈局移動分數由2個元素決定:impact fractiondistance fraction

layout shift score = impact fraction * distance fraction

下面例子中,豎向距離更大,該元素相對適口移動了25%的距離,所以distance fraction是0.25。所以佈局移動分數是 0.75 * 0.25 = 0.1875
4d0b53199754470aad33d14fda9be670~tplv-k3u1fbpfcp-zoom-in-crop-mark-1304-0-0-0.awebp.webp

但是要注意的是,並不是所有的佈局移動都是不好的,很多web網站都會改變元素的開始位置。只有當佈局移動是非使用者預期的,才是不好的

換句話說,當使用者點選了按鈕,佈局進行了改動,這是ok的,CLS的JS API中有一個欄位hadRecentInput,用來標識500ms內是否有使用者資料,視情況而定,可以忽略這個計算。

線上測量工具

實驗室工具

原生的JS API測量
let cls = 0;

new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    if (!entry.hadRecentInput) {
      cls += entry.value;
      console.log(‘Current CLS value:’, cls, entry);
    }
  }
}).observe({type: ‘layout-shift’, buffered: true});

如何優化CLS
我們可以根據這些原則來避免非預期佈局移動:

  • 圖片或視屏元素有大小屬性,或者給他們保留一個空間大小,設定width、height,或者使用 unsized-media feature policy
  • 不要在一個已存在的元素上面插入內容,除了相應使用者輸入。
  • 使用animation或transition而不是直接觸釋出局改變。

3.3 效能工具:工欲善其事,必先利其器

Google開發的 所有工具 都支援Core Web Vitals的測量。工具如下:

工具:思考與總結
我們該如何選擇?如何使用好這些工具進行分析?

  • 首先我們可以使用Lighthouse,在本地進行測量,根據報告給出的一些建議進行優化;
  • 釋出之後,我們可以使用PageSpeed Insights去看下線上的效能情況;
  • 接著,我們可以使用Chrome User Experience Report API去撈取線上過去28天的資料;
  • 發現資料有異常,我們可以使用DevTools工具進行具體程式碼定位分析;
  • 使用Search Console’s Core Web Vitals report檢視網站功能整體情況;
  • 使用Web Vitals擴充套件方便的看頁面核心指標情況;

四、HTTP中的效能優化

4.1 HTTP 1.1

HTTP/1.1中大多數的網站效能優化技術都是減少向伺服器發起的HTTP請求數。瀏覽器可以同時建立有限個TCP連線,而通過這些連線下載資源是一個線性的流程:一個資源的請求響應返回後,下一個請求才能傳送。這被稱為線頭阻塞。

在HTTP/1.1中,Web開發者往往將整個網站的所有CSS都合併到一個檔案。類似的,JavaScript也被壓縮到了一個檔案,圖片被合併到了一張雪碧圖上。合併CSS、JavaScript和圖片極大地減少了HTTP的請求數,在HTTP/1.1中能獲得顯著的效能提升。

存在的問題:
為了儘可能減少請求數,需要做合併檔案、雪碧圖、資源內聯等優化工作,但是這無疑造成了單個請求內容變大延遲變高的問題,且內嵌的資源不能有效地使用快取機制。

4.2 HTTP/2.0的優勢

4.2.1 二進位制分幀傳輸

幀是資料傳輸的最小單位,以二進位制傳輸代替原本的明文傳輸,原本的報文訊息被劃分為更小的資料幀。

原來Headers + Body的報文格式如今被拆分成了一個個二進位制的幀,用Headers幀存放頭部欄位,Data幀存放請求體資料。分幀之後,伺服器看到的不再是一個個完整的 HTTP 請求報文,而是一堆亂序的二進位制幀。這些二進位制幀不存在先後關係,因此也就不會排隊等待,也就沒有了 HTTP 的隊頭阻塞問題。

4.2.2 多路複用(MultiPlexing)

通訊雙方都可以給對方傳送二進位制幀,這種二進位制幀的雙向傳輸的序列,也叫做流(Stream)。HTTP/2 用流來在一個 TCP 連線上來進行多個資料幀的通訊,這就是多路複用的概念。

在一個 TCP 連線上,我們可以向對方不斷髮送幀,每幀的 Stream Identifier 標明這一幀屬於哪個流,然後在對方接收時,根據 Stream Identifier 拼接每個流的所有幀組成一整塊資料。 把 HTTP/1.1 每個請求都當作一個流,那麼多個請求變成多個流,請求響應資料分成多個幀,不同流中的幀交錯地傳送給對方,這就是 HTTP/2 中的多路複用。

流的概念實現了單連線上多請求 - 響應並行,解決了線頭阻塞的問題,減少了 TCP 連線數量和 TCP 連線慢啟動造成的問題。所以 http2 對於同一域名只需要建立一個連線,而不是像 http/1.1 那樣建立 6~8 個連線

4.2.3 服務端推送(Server Push)

在 HTTP/2 當中,伺服器已經不再是完全被動地接收請求,響應請求,它也能新建 stream 來給客戶端傳送訊息,當 TCP 連線建立之後,比如瀏覽器請求一個 HTML 檔案,伺服器就可以在返回 HTML 的基礎上,將 HTML 中引用到的其他資原始檔一起返回給客戶端,減少客戶端的等待。

Server-Push 主要是針對資源內聯做出的優化,相較於 http/1.1 資源內聯的優勢:

  • 客戶端可以快取推送的資源
  • 客戶端可以拒收推送過來的資源
  • 推送資源可以由不同頁面共享
  • 伺服器可以按照優先順序推送資源

4.2.4 Header 壓縮(HPACK)

使用 HPACK 演算法來壓縮首部內容

4.3 HTTP/2 Web優化最佳實踐

HTTP/2的優化需要不同的思維方式。Web開發者應該專注於網站的快取調優,而不是擔心如何減少HTTP請求數。通用的法則是,傳輸輕量、細粒度的資源,以便獨立快取和並行傳輸。

http-2-multiplexing.png

4.3.1 停止合併檔案

在HTTP/2中合併檔案不再是一項最佳實踐。雖然合併依然可以提高壓縮率,但它帶來了代價高昂的快取失效。即使有一行CSS改變了,瀏覽器也會強制重新載入你 所有的 CSS宣告。

另外,你的網站不是所有頁面都使用了合併後的CSS或JavaScript檔案中的全部宣告或函式。被快取之後倒沒什麼關係,但這意味著在使用者第一次訪問時這些不必要的位元組被傳輸、處理、執行了。HTTP/1.1中請求的開銷使得這種權衡是值得的,而在HTTP/2中這實際上減慢了頁面的首次繪製。

Web開發者應該更加專注於快取策略優化,而不是壓縮檔案。將經常改動和不怎麼改動的檔案分離開來,就可以儘可能利用CDN或者使用者瀏覽器快取中已有的內容。

4.3.2 停止內聯資源

內聯資源是檔案合併的一個特例。它指的是將CSS樣式表、外部的JavaScript檔案和圖片直接嵌入HTML頁面中。

4.3.3 停止細分域名

細分域名是讓瀏覽器建立更多TCP連線的通常手段。瀏覽器限制了單個伺服器的連線數量,但是通過將網站上的資源切分到幾個域上,你可以獲得額外的TCP連線。它避免了線頭阻塞,但也帶來了顯著的代價。

細分域名在HTTP/2中應該避免。每個細分的域名都會帶來額外的DNS查詢、TCP連線和TLS握手(假設伺服器使用不同的TLS證照)。在HTTP/1.1中,這個開銷通過資源的並行下載得到了補償。但在HTTP/2中就不是這樣了:多路複用使得多個資源可以在一個連線中並行下載。同時,類似於資源內聯,域名細分破壞了HTTP/2的流優先順序,因為瀏覽器不能跨域比較優先順序。

4.4 一些最佳實踐依然有效

幸運的是,HTTP/2沒有改變所有的Web優化方式。一些HTTP/1.1中的最佳實踐在HTTP/2中依然有效。剩下的文章討論了這些技巧,無論你在HTTP/1.1還是HTTP/2優化都能用上。

4.4.1 減少DNS查詢時間

在瀏覽器可以請求網站資源之前,它需要通過域名系統(DNS)獲得你的服務端IP地址。直到DNS響應前,使用者看到的都是白屏。HTTP/2優化了Web瀏覽器和伺服器之間的通訊方式,但它不會影響域名系統的效能。
因為DNS查詢的開銷可能會很昂貴,尤其是當你從根名字伺服器開始查詢時,最小化網站使用的DNS查詢數仍然是一個明智之舉。使用HTML頭部的<link rel=‘dns-prefetch’ href=‘…’ />可以幫助你提前獲取DNS記錄,但這不是萬能的解決方案。

4.4.2 靜態資源使用CDN

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

4.4.3利用瀏覽器快取

你可以進一步利用內容分發網路,將資源儲存在使用者的本地瀏覽器快取中,除了產生一個304 Not Modified響應之外,這避免了任何形式的資料在網路上傳輸。

4.4.4 最小化HTTP請求大小

儘管HTTP/2的請求使用了多路複用技術,線上纜上傳輸資料仍然需要時間。同時,減少需要傳輸的資料規模同樣會帶來好處。在請求端,這意味著儘可能多地最小化cookie、URL和查詢字串的大小。

4.4.5 最小化HTTP響應大小

當然了,另一端也是這樣。作為Web開發者,你會希望服務端的響應儘可能的小。你可以最小化HTML、CSS和JavaScript檔案,優化影像,並通過gzip壓縮資源。

4.4.6 減少不必要的重定向

HTTP 301和302重定向在遷移到新平臺或者重新設計網站時難以避免,但如有可能應該被去除。重定向會導致一圈額外的瀏覽器到服務端往返,這會增加不必要的延遲。 你應該特別留意重定向鏈,上面需要多個重定向才能到達目的地址。
像301和302這樣的服務端重定向雖不理想,但也不是世界上最糟的事情。它們可以在本地被快取,所以瀏覽器可以識別重定向URL,並且避免不必要的往返。元標籤中的重新整理(如<meta http-equiv=“refresh”…)在另一方面開銷更大,因為它們無法被快取,而且在特定瀏覽器中存在效能問題。

五、程式碼壓縮

5.1 開啟 gzip 壓縮

gzipGNUzip 的縮寫,最早用於 UNIX 系統的檔案壓縮。HTTP 協議上的 gzip 編碼是一種用來改進 web 應用程式效能的技術,Web 伺服器和客戶端(瀏覽器)必須共同支援 gzip。目前主流的瀏覽器,Chrome,firefox,IE等都支援該協議。常見的伺服器如 Apache,Nginx,IIS 同樣支援,gzip壓縮效率非常高,通常可以達到 70% 的壓縮率,也就是說,如果你的網頁有 30K,壓縮之後就變成了 9K 左右

5.1.1 Nginx 配置

gzip  on;
gzip_min_length 1k;
gzip_buffers 4 16k;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/javascript application/x-javascript application/xml application/json;
gzip_vary on;
gzip_disable "MSIE [1-6]\.";

配置好重新啟動Nginx,當看到請求響應頭中有 Content-Encoding: gzip,說明傳輸壓縮配置已經生效,此時可以看到我們請求檔案的大小已經壓縮很多。

5.1.2 Node 服務端

以下我們以服務端使用我們熟悉的 express 為例,開啟 gzip 非常簡單,相關步驟如下:

  • 安裝:
npm install compression —save
  • 新增程式碼邏輯:
var compression = require('compression');
var app = express();
app.use(compression())
  • 重啟服務,觀察網路皮膚裡面的 response header,如果看到如下紅圈裡的欄位則表明 gzip 開啟成功:

image.png

5.2 Webpack 壓縮

在 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())

六、JavaScript中的效能優化

6.1 不要覆蓋原生方法

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

6.2 使用事件委託(簡化DOM操作)

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

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

6.3 JS動畫

儘量避免新增大量的JS動畫,CSS3動畫和 Canvas 動畫都比 JS 動畫效能好。
使用requestAnimationFrame來代替setTimeoutsetInterval,因為requestAnimationFrame可以在正確的時間進行渲染,setTimeoutsetInterval無法保證渲染時機。不要在定時器裡面繫結事件。

6.4 節流和防抖

6.4.1 防抖(debounce)

// 在事件被觸發n秒後再執行回撥,如果在這n秒內又被觸發,則重新計時。
function debounce(func, delay) {
    let time = null;
    return function (...args) {
        const context = this;
        if (time) {
            clearTimeout(time);
        }
        time = setTimeout(() => {
            func.call(context, ...args);
        }, delay);
    };
}

6.4.2 節流(throttle)

// 規定在一個單位時間內,只能觸發一次函式。如果這個單位時間內觸發多次函式,只有一次生效。
function throttle(func, delay) {
    let prevTime = Date.now();
    return function (...args) {
        const context = this;
        let curTime = Date.now();
        if (curTime - prevTime > delay) {
            prevTime = curTime;
            func.call(context, ...args);
        }
    };
}

七、頁面渲染優化

Webkit 渲染引擎流程:

  • 處理 HTML 並構建 DOM 樹
  • 處理 CSS 構建 CSS 規則樹(CSSOM)
  • 接著JS 會通過 DOM Api 和 CSSOM Api 來操作 DOM Tree 和 CSS Rule Tree 將 DOM Tree 和 CSSOM Tree 合成一顆渲染樹 Render Tree。
  • 根據渲染樹來佈局,計算每個節點的位置
  • 呼叫 GPU 繪製,合成圖層,顯示在螢幕上

v2-219c89392774bcc81bac826f8e51cccd_1440w.png

ad32522392eb4da8a49667a1df04b062~tplv-k3u1fbpfcp-zoom-in-crop-mark-1304-0-0-0.awebp.webp

7.1 避免CSS、JS阻塞

7.1.1 CSS 的阻塞

我們提到 DOM 和 CSSOM 合力才能構建渲染樹。這一點會給效能造成嚴重影響:預設情況下,CSS 是阻塞的資源。瀏覽器在構建 CSSOM 的過程中,不會渲染任何已處理的內容,即便 DOM 已經解析完畢了

只有當我們開始解析 HTML 後、解析到 link 標籤或者 style 標籤時,CSS 才登場,CSSOM 的構建才開始。 很多時候,DOM 不得不等待 CSSOM。因此我們可以這樣總結:

CSS 是阻塞渲染的資源。需要將它儘早、儘快地下載到客戶端,以便縮短首次渲染的時間。儘早(將 CSS 放在 head 標籤裡)和儘快(啟用 CDN 實現靜態資源載入速度的優化)

7.1.2 JS 的阻塞

JS 的作用在於修改,它幫助我們修改網頁的方方面面:內容、樣式以及它如何響應使用者互動。這“方方面面”的修改,本質上都是對 DOM 和 CSSDOM 進行修改。因此 JS 的執行會阻止 CSSOM,在我們不作顯式宣告的情況下,它也會阻塞 DOM。

JS 不僅可以讀取和修改DOM 屬性,還可以讀取和修改CSSOM 屬性,存在阻塞的 CSS 資源時, 瀏覽器會延遲 JS 的執行和 Render Tree 構建。

JS 引擎是獨立於渲染引擎存在的。我們的 JS 程式碼在文件的何處插入,就在何處執行。當 HTML 解析器遇到一個 script 標籤時,它會暫停渲染過程,將控制權交給 JS 引擎。JS 引擎對內聯的 JS 程式碼會直接執行,對外部 JS 檔案還要先獲取到指令碼、再進行執行。等 JS 引擎執行完畢,瀏覽器又會把控制權還給渲染引擎,繼續 CSSOM 和 DOM 的構建。 因此與其說是 JS 把 CSS 和 HTML 阻塞了,不如說是 JS 引擎搶走了渲染引擎的控制權。

  1. 現代瀏覽器會並行載入 JS 檔案。
  2. 載入或者執行JS時會阻塞對標籤的解析,也就是阻塞了DOM 樹的形成,只有等到JS執行完畢,瀏覽器才會繼續解析標籤。沒有DOM樹,瀏覽器就無法渲染,所以當載入很大的JS檔案時,可以看到頁面很長時間是一片空白

之所以會阻塞對標籤的解析是因為載入的 JS 中可能會建立,刪除節點等,這些操作會對 DOM 樹產生影響,如果不阻塞,等瀏覽器解析完標籤生成 DOM樹後,JS 修改了某些節點,那麼瀏覽器又得重新解析,然後生成 DOM 樹,效能比較差。

實際使用時,可以遵循下面3個原則:

  • CSS 資源優於 JavaScript 資源引入
  • JS 應儘量少影響 DOM 的構建

7.1.3 改變 JS 阻塞的方式

defer(延緩)模式

defer 方式載入 script, 不會阻塞 HTML 解析,等到 DOM 生成完畢且 script 載入完畢再執行 JS。

<script defer></script>
async(非同步)模式

async 屬性表示非同步執行引入的 JS,載入時不會阻塞 HTML解析,但是載入完成後立馬執行,此時仍然會阻塞 load 事件。

<script async></script>

E03A9F1E-DC8A-4CCF-99E7-44CC30C92306.png

從應用的角度來說,一般當我們的指令碼與 DOM 元素和其它指令碼之間的依賴關係不強時,我們會選用 async;當指令碼依賴於 DOM 元素和其它指令碼的執行結果時,我們會選用defer

7.2 使用字型圖示 iconfont 代替圖片圖示

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

7.3 降低 CSS 選擇器的複雜性

瀏覽器讀取選擇器,遵循的原則是從選擇器的右邊到左邊讀取。看個示例:

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

CSS 選擇器優先順序

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

根據以上兩個資訊可以得出結論:

  1. 減少巢狀。後代選擇器的開銷是最高的,因此我們應該儘量將選擇器的深度降到最低(最高不要超過三層),儘可能使用類來關聯每一個標籤元素
  2. 關注可以通過繼承實現的屬性,避免重複匹配重複定義
  3. 儘量使用高優先順序的選擇器,例如 ID 和類選擇器。
  4. 避免使用萬用字元,只對需要用到的元素進行選擇

7.4 減少重繪和迴流

7.4.1 重繪 (Repaint)

當頁面中元素樣式的改變並不影響它在文件流中的位置時(例如:color、background-color、visibility等),瀏覽器會將新樣式賦予給元素並重新繪製它,這個過程稱為重繪

7.4.2 迴流 (Reflow)

當Render Tree中部分或全部元素的尺寸、結構、或某些屬性發生改變時,瀏覽器重新渲染部分或全部文件的過程稱為迴流。

迴流必將引起重繪,重繪不一定會引起迴流,迴流比重繪的代價要更高。

7.4.3 如何避免

CSS

  • 避免使用table佈局。
  • 儘可能在DOM樹的最末端改變class。
  • 避免設定多層內聯樣式。
  • 將動畫效果應用到position屬性為absolute或fixed的元素上。
  • 避免使用CSS表示式(例如:calc())。

JavaScript

  • 避免頻繁操作樣式,最好一次性重寫style屬性,或者將樣式列表定義為class並一次性更改class屬性。
  • 避免頻繁操作DOM,建立一個documentFragment,在它上面應用所有DOM操作,最後再把它新增到文件中。
  • 也可以先為元素設定display: none,操作結束後再把它顯示出來。因為在display屬性為none的元素上進行的DOM操作不會引發迴流和重繪。
  • 避免頻繁讀取會引發迴流/重繪的屬性,如果確實需要多次使用,就用一個變數快取起來。
  • 對具有複雜動畫的元素使用絕對定位,使它脫離文件流,否則會引起父元素及後續元素頻繁迴流。

7.5 使用 flexbox 而不是較早的佈局模型

在早期的 CSS 佈局方式中我們能對元素實行絕對定位、相對定位或浮動定位。而現在,我們有了新的佈局方式 flexbox ,它比起早期的佈局方式來說有個優勢,那就是效能比較好。

7.6 圖片資源優化

7.6.1 使用雪碧圖

雪碧圖的作用就是減少請求數,而且多張圖片合在一起後的體積會少於多張圖片的體積總和,這也是比較通用的圖片壓縮方案

7.6.2 降低圖片質量

壓縮方法有兩種,一是通過線上網站進行壓縮,二是通過 webpack 外掛 image-webpack-loader。它是基於 imagemin 這個 Node 庫來實現圖片壓縮的。

使用很簡單,我們只要在file-loader之後加入 image-webpack-loader 即可:

npm i -D image-webpack-loader

webpack 配置如下

// config/webpack.base.js
// ...
module: {
    rules: [
        {
            test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
            use: [
                {
                    loader: 'file-loader',
                    options: {
                        name: '[name]_[hash].[ext]',
                        outputPath: 'images/'
                    }
                },
                {
                    loader: 'image-webpack-loader',
                    options: {
                        // 壓縮 jpeg 的配置
                        mozjpeg: {
                            progressive: true,
                            quality: 65
                        },
                        // 使用 imagemin**-optipng 壓縮 png,enable: false 為關閉
                        optipng: {
                            enabled: false
                        },
                        // 使用 imagemin-pngquant 壓縮 png
                        pngquant: {
                            quality: '65-90',
                            speed: 4
                        },
                        // 壓縮 gif 的配置
                        gifsicle: {
                            interlaced: false
                        },
                        // 開啟 webp,會把 jpg 和 png 圖片壓縮為 webp 格式
                        webp: {
                            quality: 75
                        }
                    }
                }
            ]
        }
    ];
}
// ...

7.6.3 圖片懶載入

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

7.6.4 使用CSS3 代替圖片

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

7.6.5 使用 webp 格式的圖片

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

八、Webpack 優化

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

Babel 外掛會在將 ES6 程式碼轉換成 ES5 程式碼時會注入一些輔助函式,例如下面的 ES6 程式碼:

class HelloWebpack extends Component{...}

這段程式碼再被轉換成能正常執行的 ES5 程式碼時需要以下兩個輔助函式:

babel-runtime/helpers/createClass  // 用於實現 class 語法
babel-runtime/helpers/inherits  // 用於實現 extends 語法    

在預設情況下, Babel 會在每個輸出檔案中內嵌這些依賴的輔助函式程式碼,如果多個原始碼檔案都依賴這些輔助函式,那麼這些輔助函式的程式碼將會出現很多次,造成程式碼冗餘。為了不讓這些輔助函式的程式碼重複出現,可以在依賴它們時通過 require('babel-runtime/helpers/createClass') 的方式匯入,這樣就能做到只讓它們出現一次。babel-plugin-transform-runtime 外掛就是用來實現這個作用的,將相關輔助函式進行替換成匯入語句,從而減小 babel 編譯出來的程式碼的檔案大小。

首先,安裝 babel-plugin-transform-runtime

npm install babel-plugin-transform-runtime —save-dev

然後,修改 .babelrc 配置檔案為:

"plugins": [
    "transform-runtime"
]

如果要看外掛的更多詳細內容,可以檢視babel-plugin-transform-runtime詳細介紹

8.2 提取公共程式碼

如果專案中沒有去將每個頁面的第三方庫和公共模組提取出來,則專案會存在以下問題:

  • 相同的資源被重複載入,浪費使用者的流量和伺服器的成本。
  • 每個頁面需要載入的資源太大,導致網頁首屏載入緩慢,影響使用者體驗。

所以我們需要將多個頁面的公共程式碼抽離成單獨的檔案,來優化以上問題 。Webpack 內建了專門用於提取多個Chunk 中的公共部分的外掛 CommonsChunkPlugin,我們在專案中 CommonsChunkPlugin 的配置如下:

// 所有在 package.json 裡面依賴的包,都會被打包進 vendor.js 這個檔案中。
new webpack.optimize.CommonsChunkPlugin({
  name: 'vendor',
  minChunks: function(module, count) {
    return (
      module.resource &&
      /\.js$/.test(module.resource) &&
      module.resource.indexOf(
        path.join(__dirname, '../node_modules')
      ) === 0
    );
  }
}),
// 抽取出程式碼模組的對映關係
new webpack.optimize.CommonsChunkPlugin({
  name: 'manifest',
  chunks: ['vendor']
})

如果要看外掛的更多詳細內容,可以檢視 CommonsChunkPlugin 的 詳細介紹

8.3 模板預編譯

當使用 DOM 內模板或 JavaScript 內的字串模板時,模板會在執行時被編譯為渲染函式。通常情況下這個過程已經足夠快了,但對效能敏感的應用還是最好避免這種用法。
預編譯模板最簡單的方式就是使用 單檔案元件 ——相關的構建設定會自動把預編譯處理好,所以構建好的程式碼已經包含了編譯出來的渲染函式而不是原始的模板字串。
如果你使用 webpack,並且喜歡分離 JavaScript 和模板檔案,你可以使用 vue-template-loader ,它也可以在構建過程中把模板檔案轉換成為 JavaScript 渲染函式。

8.4 提取元件的 CSS

當使用單檔案元件時,元件內的 CSS 會以 style 標籤的方式通過 JavaScript 動態注入。這有一些小小的執行時開銷,如果你使用服務端渲染,這會導致一段 “無樣式內容閃爍 (fouc) ” 。將所有元件的 CSS 提取到同一個檔案可以避免這個問題,也會讓 CSS 更好地進行壓縮和快取。
查閱這個構建工具各自的文件來了解更多:

8.5 按需載入程式碼

通過 Vue 寫的單頁應用時,可能會有很多的路由引入。當打包構建的時候,JS 包會變得非常大,影響載入。如果我們能把不同路由對應的元件分割成不同的程式碼塊,然後當路由被訪問的時候才載入對應的元件,這樣就更加高效了。這樣會大大提高首屏顯示的速度,但是可能其他的頁面的速度就會降下來。
專案中路由按需載入(懶載入)的配置:

const Foo = () => import('./Foo.vue')
const router = new VueRouter({
  routes: [
    { path: '/foo', component: Foo }
  ]
})

九、Vue專案效能優化

9.1 合理使用v-ifv-show

v-if真正 的條件渲染,因為它會確保在切換過程中條件塊內的事件監聽器和子元件適當地被銷燬和重建;也是惰性的:如果在初始渲染時條件為假,則什麼也不做——直到條件第一次變為真時,才會開始渲染條件塊。
v-show 就簡單得多, 不管初始條件是什麼,元素總是會被渲染,並且只是簡單地基於 CSS 的 display 屬性進行切換。
所以,v-if 適用於在執行時很少改變條件,不需要頻繁切換條件的場景;v-show 則適用於需要非常頻繁切換條件的場景。

9.2 合理使用watchcomputed

computed: 是計算屬性,依賴其它屬性值,並且 computed 的值有快取,只有它依賴的屬性值發生改變,下一次獲取 computed 的值時才會重新計算 computed 的值;
watch: 更多的是「觀察」的作用,類似於某些資料的監聽回撥 ,每當監聽的資料變化時都會執行回撥進行後續操作;

運用場景:

  • 當我們需要進行數值計算,並且依賴於其它資料時,應該使用 computed,因為可以利用 computed 的快取特性,避免每次獲取值時,都要重新計算;
  • 當我們需要在資料變化時執行非同步開銷較大的操作時,應該使用 watch,使用 watch 選項允許我們執行非同步操作 ( 訪問一個 API ),限制我們執行該操作的頻率,並在我們得到最終結果前,設定中間狀態。這些都是計算屬性無法做到的。

9.3 v-for 遍歷必須為 item 新增 key,且避免同時使用 v-if

9.3.1 v-for 遍歷必須為 item 新增 key

在列表資料進行遍歷渲染時,需要為每一項 item 設定唯一 key 值,方便 Vue.js 內部機制精準找到該條列表資料。當 state 更新時,新的狀態值和舊的狀態值對比,較快地定位到 diff 。

9.3.2 v-for 遍歷避免同時使用 v-if

v-forv-if 優先順序高,如果每一次都需要遍歷整個陣列,將會影響速度,尤其是當之需要渲染很小一部分的時候,必要情況下應該替換成 computed 屬性。

推薦:

<ul>
  <li
    v-for="user in activeUsers"
    :key="user.id">
    {{ user.name }}
  </li>
</ul>
computed: {
  activeUsers: function () {
    return this.users.filter(function (user) {
     return user.isActive
    })
  }
}

不推薦:

<ul>
  <li
    v-for="user in users"
    v-if="user.isActive"
    :key="user.id">
    {{ user.name }}
  </li>
</ul>

9.4 長列表效能優化

Vue 會通過 Object.defineProperty 對資料進行劫持,來實現檢視響應資料的變化,然而有些時候我們的元件就是純粹的資料展示,不會有任何改變,我們就不需要 Vue 來劫持我們的資料,在大量資料展示的情況下,這能夠很明顯的減少元件初始化的時間,那如何禁止 Vue 劫持我們的資料呢?可以通過 Object.freeze 方法來凍結一個物件,一旦被凍結的物件就再也不能被修改了。

export default {
  data: () => ({
    users: {}
  }),
  async created() {
    const users = await axios.get("/api/users");
    this.users = Object.freeze(users);
  }
};

9.5 事件的銷燬

Vue 元件銷燬時,會自動清理它與其它例項的連線,解綁它的全部指令及事件監聽器,但是僅限於元件本身的事件。 如果在 JS 內使用 addEventListener 等方式是不會自動銷燬的,我們需要在元件銷燬時手動移除這些事件的監聽,以免造成記憶體洩露,如:

created() {
  addEventListener('click', this.click, false)
},
beforeDestroy() {
  removeEventListener('click', this.click, false)
}

9.6 圖片資源懶載入

對於圖片過多的頁面,為了加速頁面載入速度,所以很多時候我們需要將頁面內未出現在可視區域內的圖片先不做載入,等到滾動到可視區域後再去載入。這樣對於頁面載入效能上會有很大的提升,也提高了使用者體驗。

9.7 路由懶載入

Vue 是單頁面應用,可能會有很多的路由引入 ,這樣使用 webpcak 打包後的檔案很大,當進入首頁時,載入的資源過多,頁面會出現白屏的情況,不利於使用者體驗。如果我們能把不同路由對應的元件分割成不同的程式碼塊,然後當路由被訪問的時候才載入對應的元件,這樣就更加高效了。這樣會大大提高首屏顯示的速度,但是可能其他的頁面的速度就會降下來。
路由懶載入:

const Foo = () => import(‘./Foo.vue’)
const router = new VueRouter({
  routes: [
    { path: ‘/foo’, component: Foo }
  ]
})

9.8 第三方外掛的按需引入

我們在專案中經常會需要引入第三方外掛,如果我們直接引入整個外掛,會導致專案的體積太大,我們可以藉助 babel-plugin-component ,然後可以只引入需要的元件,以達到減小專案體積的目的。以下為專案中引入 element-ui 元件庫為例:

  1. 首先,安裝 babel-plugin-component
npm install babel-plugin-component -D
  1. 然後,將 .babelrc 修改為:
{
  "presets": [["es2015", { "modules": false }]],
  "plugins": [
    [
      "component",
      {
        "libraryName": "element-ui",
        "styleLibraryName": "theme-chalk"
      }
    ]
  ]
}
  1. 在 main.js 中引入部分元件:
import Vue from ‘vue’;
import { Button, Select } from ‘element-ui’;

 Vue.use(Button)
 Vue.use(Select)

9.9 優化無限列表效能

如果你的應用存在非常長或者無限滾動的列表,那麼需要採用 視窗化 的技術來優化效能,只需要渲染少部分割槽域的內容,減少重新渲染元件和建立 dom 節點的時間。 你可以參考以下開源專案 vue-virtual-scroll-listvue-virtual-scroller 來優化這種無限列表的場景的。

9.10 服務端渲染 SSR or 預渲染

如果你的專案的 SEO 和 首屏渲染是評價專案的關鍵指標,那麼你的專案就需要服務端渲染來幫助你實現最佳的初始載入效能和 SEO,具體的 Vue SSR 如何實現,可以參考作者的另一篇文章《 Vue SSR 踩坑之旅 》。如果你的 Vue 專案只需改善少數營銷頁面(例如 /, /about, /contact 等)的 SEO,那麼你可能需要預渲染,在構建時 (build time) 簡單地生成針對特定路由的靜態 HTML 檔案。優點是設定預渲染更簡單,並可以將你的前端作為一個完全靜態的站點,具體你可以使用 prerender-spa-plugin 就可以輕鬆地新增預渲染 。

十、服務端渲染

10.1 適用場景

以下兩種情況 SSR 可以提供很好的場景支援

  • 需更好的支援 SEO
    優勢在於同步。搜尋引擎爬蟲是不會等待非同步請求資料結束後再抓取資訊的,如果 SEO 對應用程式至關重要,但你的頁面又是非同步請求資料,那 SSR 可以幫助你很好的解決這個問題。
  • 需更快的到達時間
    優勢在於慢網路和執行緩慢的裝置場景。傳統 SPA 需完整的 JS 下載完成才可執行,而SSR 伺服器渲染標記在服務端渲染 html 後即可顯示,使用者會更快的看到首屏渲染頁面。如果首屏渲染時間轉化率對應用程式至關重要,那可以使用 SSR 來優化。

10.2 不適用場景

以下三種場景 SSR 使用需要慎重

  • 同構資源的處理
    劣勢在於程式需要具有通用性。結合 Vue 的鉤子來說,能在 SSR 中呼叫的生命週期只有 beforeCreatecreated,這就導致在使用三方 API 時必須保證執行不報錯。在三方庫的引用時需要特殊處理使其支援服務端和客戶端都可執行。
  • 部署構建配置資源的支援
    劣勢在於執行環境單一。程式需處於 node.js server 執行環境。
  • 伺服器更多的快取準備
    劣勢在於高流量場景需採用快取策略。應用程式碼需在雙端執行解析,cpu 效能消耗更大,負載均衡和多場景快取處理比 SPA 做更多準備。

十一、快取優化

171912ba3b63f863~tplv-t2oaga2asx-zoom-in-crop-mark-1304-0-0-0.awebp.webp

11.1 瀏覽器快取策略

快取的意義就在於減少請求,更多地使用本地的資源,給使用者更好的體驗的同時,也減輕伺服器壓力。所以,最佳實踐,就應該是儘可能命中強快取,同時,能在更新版本的時候讓客戶端的快取失效。

在更新版本之後,如何讓使用者第一時間使用最新的資原始檔呢?機智的前端們想出了一個方法,在更新版本的時候,順便把靜態資源的路徑改了,這樣,就相當於第一次訪問這些資源,就不會存在快取的問題了

image.png

entry:{
    main: path.join(__dirname,'./main.js'),
    vendor: ['react', 'antd']
},
output:{
    path:path.join(__dirname,'./dist'),
    publicPath: '/dist/',
    filname: 'bundle.[chunkhash].js'
}

綜上所述,我們可以得出一個較為合理的快取方案:

  • HTML:使用協商快取。
  • CSS、JS和圖片:使用強快取,檔案命名帶上hash值。

11.2 檔名雜湊

Webpack 給我們提供了三種雜湊值計算方式,分別是hashchunkhashcontenthash。那麼這三者有什麼區別呢?

  • hash:跟整個專案的構建相關,構建生成的檔案hash值都是一樣的,只要專案裡有檔案更改,整個專案構建的hash值都會更改。
  • chunkhash:根據不同的入口檔案(Entry)進行依賴檔案解析、構建對應的chunk,生成對應的hash值。
  • contenthash:由檔案內容產生的hash值,內容不同產生的contenthash值也不一樣。
    顯然,我們是不會使用第一種的。改了一個檔案,打包之後,其他檔案的hash都變了,快取自然都失效了。這不是我們想要的。

chunkhashcontenthash的主要應用場景是什麼呢?在實際在專案中,我們一般會把專案中的 CSS 都抽離出對應的 CSS 檔案來加以引用。如果我們使用chunkhash,當我們改了CSS 程式碼之後,會發現 CSS 檔案hash值改變的同時,JS 檔案的hash值也會改變。這時候,contenthash就派上用場了。

十二、關於效能監控的思考

我們在做效能優化的時候,常常會通過各種線上打點,來收集使用者資料,進行效能分析。沒錯,這是一種監控手段,更精確的說,這是一種”事後”監控手段。

”事後”監控固然重要,但我們也應該考慮”事前”監控,否則,每次釋出一個需求後,去線上看資料。咦,發現資料下降了,然後我們去查程式碼,去查資料,去查原因。這樣效能優化的同學永遠處於”追趕者”的角色,永遠跟在屁股後面查問題。

舉個例子,我們可以這樣去做”事前”監控。

建立流水線機制。流水線上如何做呢?

  • Lighthouse CIPageSpeed Insights API :把Lighthouse或PageSpeed Insights API整合到CI流水線中,輸出報告分析。
  • PuppeteerPlaywright :使用E2E自動化測試工具整合到流水線模擬使用者操作,得到Chrome Trace Files,也就是我們平常錄製Performance後,點選左上角下載的檔案。Puppeteer和Playwright底層都是基於 Chrome DevTools Protocol

05945C01-F747-4EAF-BA26-C0680D9F26C9.png

Chrome Trace Files:根據 規則 分析Trace檔案,可以得到每個函式執行的時間。如果函式執行時間超過了一個臨界值,可以丟擲異常。如果一個函式每次的執行時間都超過了臨界值,那麼就值得注意了。但是還有一點需要思考的是:函式執行的時間是否超過臨界值固然重要,但更重要的是這是不是使用者的輸入響應函式,與使用者體驗是否有關。

8a33152fe5b04536bda2745d532334ce~tplv-k3u1fbpfcp-zoom-in-crop-mark-1304-0-0-0.awebp.webp

  • 輸出報告。定義異常臨界值。如果異常過多,考慮是否卡釋出流程。

十三、參考資料

Web Vitals
Aerotwist - The Anatomy of a Frame
Performance Timeline - Web API 介面參考 | MDN
Getting Around the Chromium Source Code Directory Structure
前端效能優化 24 條建議(2020) - 掘金
【前端優化】首屏載入 9.2s 壓縮至 3.6 s - 掘金
async vs defer attributes - Growing with the Web
我的前端效能優化知識體系 - 掘金
前端效能優化三部曲(載入篇)
全鏈路前端效能優化(歡迎收藏) - 掘金
效能優化到底應該怎麼做 - 掘金
『Webpack系列』—— 路由懶載入的原理 - 掘金
前端快取最佳實踐 - 掘金
『前端優化』—— Vue專案效能優化 - 掘金
服務端渲染SSR及實現原理 - 掘金
Vue 專案效能優化 — 實踐指南(網上最全 / 詳細) - 掘金
Vue專案Webpack優化實踐,構建效率提高50% - 掘金


我是佩奇烹飪家,年輕的前端攻城獅,愛專研,愛技術,愛分享。
個人筆記,整理不易,感謝閱讀點贊關注收藏
文章有任何問題歡迎大家指出,也歡迎大家一起交流學習!

相關文章