現如今,在呈現一個頁面時,在瀏覽器中會打開眾多進程,包括瀏覽器、渲染、插件、GPU、網絡等進程。
  瀏覽器進程負責存儲、界面、下載等管理。在渲染進程中,運行著熟知的主線程、合成線程、JavaScript 解釋器、排版引擎等。
  而呈現一個頁面大致可分為 4 個步驟:
1. 瀏覽器進程處理用戶在地址欄的輸入,然后將 URL 發送給網絡進程。
2. 網絡進程發送 URL 請求,在接收到響應數據后進行解析,接著轉發給瀏覽器進程。
3. 瀏覽器進程收到響應后,發送“提交導航”消息到渲染進程。
4. 渲染進程開始接收網絡進程發送的數據,并進行文檔渲染。
  基于上述步驟可以聯想到,呈現的優化分為兩部分:資源和渲染。
  像上一節的[圖像](https://www.cnblogs.com/strick/p/17080155.html)其實也屬于資源部分,只是內容比較多就單獨創建了章節。
  本文所用的示例代碼已上傳至[Github](https://github.com/pwstrick/pe)。
## 一、資源
  HTTP Archive 關于 2022 年頁面大小的[報告](https://almanac.httparchive.org/en/2022/page-weight)指出,按大小升序后,排在中間位置的移動頁面大概有 70 個請求。
  包括 22 個圖像、21 個腳本、7 個 CSS以及 2 個 HTML,腳本和 CSS 占了 40% 的請求。
  除了對這些資源進行尺寸優化之外,還可以對它們的加載進行優化。
**1)優先級**
  瀏覽器會給不同資源給予不同的請求優先級。
  以 Chrome 為例,分為多個等級,包括 Highest 、High、Low 和 Lowest 等,如下圖所示。
:-: 
  HTML 和 head 元素中的 CSS 優先級是最高的,head 元素中的腳本是高優先級,異步請求的腳本是低優先級。
  若優先級不符合預期,可以通過一些配置修改優先級,例如為 script 元素聲明 async/defer,它的優先級就會變成低。
  在 img 元素中,新增了一個[fetchPriority](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/fetchPriority)屬性(如下所示),當值是 high 時,意味著這是一張重要的圖像,瀏覽器會提升優先級立即開始請求。
~~~html
<img src="hero.png" fetchpriority="high" />
~~~
**2)link 元素**
  link 元素常用來加載 CSS 文件,但它還支持些其他功能,接下來會一一介紹。
  當 link 的 rel 屬性值為[preload](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/preload)時,就能預加載資源,如下所示。
~~~html
<link rel="preload" href="demo.js" as="script" />
~~~
  as 屬性是告知瀏覽器加載的資源類型,包括 style、script、font、image 等。
  預加載可提升資源的優先級,不過當資源在幾秒后未使用時,瀏覽器會發出告警。
  2023-11-20 高版本的瀏覽器已經原生支持 JavaScript 模塊化(即 import 語法),這意味著可以在瀏覽器內直接基于模塊編寫 JavaScript 而不用編譯和打包。
  但是模塊依賴項會帶來加載問題,因為瀏覽器需要先等待模塊加載,然后才能找到其依賴項。
  從 Chrome 66 開始,rel 屬性支持一個新的關鍵字:[modulepreload](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/modulepreload),解決上述問題。
  它用于聲明預加載模塊,也就是預加載依賴項,以便瀏覽器提前知道相關文件。
  當 link 的 rel 屬性值為[preconnect](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/preconnect)時,就能預連接站點,如下所示。
~~~html
<link rel="preconnect" href="https://www.pwstrick.com" />
~~~
  另一個與連接相關的類型是[dns-prefetch](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/dns-prefetch)(如下所示),用來處理 DNS 查詢,即 DNS 預解析。
~~~html
<link rel="dns-prefetch" href="https://www.pwstrick.com" />
~~~
  當 link 的 rel 屬性值為[prefetch](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/prefetch)時,就能預提取資源,如下所示。
~~~html
<link rel="prefetch" href="demo.js" />
~~~
  預提取會讓資源的優先級降為最低,用于讓某些非關鍵資源提前請求,可為用戶的下一步交互做準備。
  2023-03-23 當 link 的 rel 屬性值為[prerender](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/prerender)時,就能預渲染指定的網站,如下所示。
~~~html
<link rel="prerender" href="https://www.pwstrick.com" />
~~~
  不過,該參數的兼容性有限,Safari 和 Firefox 都不支持,如下圖所示。
:-: 
  有個名為[Tachyon](https://fasterthanlight.net/)的開源庫,基于 prerender,對頁面之間的導航進行了提速。
  在用戶將鼠標移動到鏈接時,會通過創建 link 元素,并賦予 prerender,實現指定地址的預渲染。
**3)script 元素**
  延遲(defer)和異步(async)的出現是為了解決 script 元素阻塞 HTML 解析的問題,下圖描繪了 script 元素的 3 種運行機制。
:-: 
  第一行是默認的運行機制,在解析HTML文檔時,一遇到 script 元素就停止解析,改成下載外部腳本,然后執行腳本,執行完后才會繼續解析。
  第二行是使用了 defer 屬性后的運行機制,HTML 文檔的解析和外部腳本的下載是同時進行的,解析完后才會執行腳本。
  第三行是使用了async 屬性后的運行機制,HTML 文檔的解析和外部腳本的下載也是同時進行,但下載完后就開始執行腳本,執行完后才會繼續解析。
  2023-11-20 有個庫叫 [Partytown.js](https://github.com/BuilderIO/partytown),比較有意思,可以將第三方腳本遷移到 Web Worker 中執行,防止阻塞主線程,不過這還只是個測試性的庫。
**4)數據預請求**
  在客戶端的 WebView 中,每次請求后端接口大概要花 100~200ms,如果把這段時間省下來,那么也能減少白屏時間。
  數據預請求是將請求時機由業務發起提前到用戶點擊時,并行發送數據請求,縮短數據等待時間,如下圖所示。
:-: 
  這種改造需要客戶端配合,現在簡單介紹下我們公司當時實現的方案,流程圖如下所示。
:-: 
  首屏數據的接口信息,可以通過一些配置關聯起來,比如一個單獨的配置接口。
  客戶端在拿到數據后,就會緩存到一個全局變量中,等待腳本讀取。
  注意,到底是客戶端先拿到數據,還是網頁先拿到,這個無法確定,并且預請求只能以 get 方法通信。
  具體的實現方案如下:
* 客戶端分析出當前 URL 中的路徑和參數,其中 refresh 參數(有的話)是一個時間戳(秒),這個參數用來控制客戶端是否需要重新請求配置接口。
* 當分析的 URL 參數中無 refresh 字段時,訪問 https://xxx.com/settings 接口,并將URL路徑、客戶端默認帶的參數(包含用戶ID等)和 URL 本身的參數全部傳遞過來(如下所示),然后本地緩存。
~~~
https://xxx.com/settings?path=game%2Fstrick&uid=xxxxx&refresh=1618451992
~~~
* 客戶端會將 settings 接口的響應數據緩存到本地,而 key 就是當前 URL,也就是說 URL 不變的話,默認就不會去請求 settings 接口。若要穿透緩存,那么加上 refresh 參數,賦一個與之前不同的值即可。
* settings 接口返回的 JSON 格式,包含 urls 字段(如下所示),是個數組,由接口集合組成,已經拼接好參數。
~~~
{
"urls": [
"http://xxx.com/xx/xx?id=2",
"http://xxx.com/yy/yy?uid=1"
]
}
~~~
* 客戶端將讀取到的數據注入到 WebView 的全局對象中,可以用全局變量同步讀取,名字可自行約定,例如叫 TheLClientResponse,讀取方式:window.TheLClientResponse,JSON 格式如下,其中 key 是 api 的路徑,如果無數據可以返回 null。
~~~
{
"xx/xx": {
code: 0,
msg: "test",
data: {
list: []
}
},
"yy/yy": {
code: 0,
msg: "test",
data: {
list: []
}
}
}
~~~
**5)字體**
  CSS3 提供了[@font-face](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face)規則允許為網頁指定自定義字體,其聲明和使用如下所示。
~~~css
@font-face {
font-family: "iconfont";
src: url("../font/iconfont.woff2") format("woff2"),
url("../font/iconfont.woff") format("woff"),
url("../font/iconfont.ttf") format("truetype");
}
.iconfont {
font-family: "iconfont";
}
~~~
  上述字體來源于[iconfont](https://www.iconfont.cn/),為了兼容性考慮,往往會提供多個格式的字體。
  其中 ttf 是一種未壓縮的格式,另外兩種內部都做過壓縮。在 2022 年大概有[75%~78%](https://almanac.httparchive.org/en/2022/fonts#performance)的網頁在使用 woff2 格式的字體。
  使用字體除了改變文字外形之外,還有一種普遍用法是用來顯示 icon 小圖標。
  2023-11-27 因為這樣就能讓 icon 成為矢量圖,所以縮放既不會變形,也不會影響流量。
  CSS3 提供了[font-display](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display)屬性用于指定字體的渲染方式,在 @font-face 中聲明,2022 年用的最多的值是 swap。
  swap 會讓文字先按瀏覽器默認的字體展示,當字體加載完成后,再將其替換掉。在慢網中,會看到字體的前后變化。
  所以應該盡快加載字體,才能讓用戶享受到最優的體驗。
  瀏覽器在解析 CSS 文件時,并不會馬上下載 @font-face 中的字體文件。
  只有當發現 HTML 中有非空節點使用該字體時,才會開始下載。
  如果要提早下載,那么可以使用預加載,如下所示。
~~~html
<link rel="preload" href="../../assets/font/dakai.woff2" as="font" crossorigin="anonymous"/>
~~~
  crossorigin 屬性是必填的,表示允許跨域,若省略,就會有告警。
  還有一種優化方法是提取字體的子集(即有選擇性的將需要的字符組合在一起),減小字體文件的尺寸,像圖標就比較適合這樣自定義。
## 二、渲染過程
  瀏覽器的渲染過程大致可分為 8 個階段,如[下圖](https://cabulous.medium.com/how-browser-works-part-i-process-and-thread-f63a9111bae9)所示。
:-: 
  下面的 1~5 步涉及主線程(main thread),6~8 步涉及合成線程(compositor thread)。
1. 將 HTML 解析成 DOM 樹,并將其存儲在內存中,同時下載解析到的資源。
2. 將 CSS 解析成樣式表(style sheets),即生成 CSSOM,在此階段會計算節點樣式,并把相對的值和單位都轉換成像素。
3. 通過 DOM 和樣式表生成布局樹(layout tree),在此階段會計算元素的尺寸和坐標,并且在樹中不包含隱藏元素,但會包含 CSS 中創建的內容。
4. 對布局樹進行分層,生成分層樹(layer tree),可控制繪畫順序,裁剪元素內容,CSS 中的 transform、z-index、will-change 等屬性都與層相關。
5. 通過布局樹和分層樹生成繪制列表,并將其提交給合成線程。
6. 通過繪制列表和圖層生成圖塊(tile),因為渲染所有圖塊會比較昂貴,所以會劃分優先級,例如視口中的可見圖塊優先級會高。
7. 圖塊在提交到光柵化(raster)線程池后,會被轉移到 GPU 中,加速光柵化處理,即轉換成位圖(bitmap),最終結果會存儲在 GPU 內存中。
8. GPU 將位圖傳送回合成線程后,就會生成合成幀,處理完所有位圖后,合成器線程向瀏覽器發送 Draw Quad 命令,開始在屏幕上顯示頁面。
  雖然這 8 個階段的執行過程比較復雜,但是在現代瀏覽器中,它們會在 1/60 秒(即 16.67 毫秒)內完成,下圖描述了整個渲染過程。
:-: 
  優化渲染過程的核心就是縮短某個階段的執行時間,或者直接跳過某些階段。
**1)流式渲染**
  HTTP/1.1 協議支持分塊傳輸編碼(chunked transfer encoding),允許服務器將網頁數據分成多塊后再進行傳輸。
  在響應頭中設置 Transfer-Encoding: chunked 就會啟用分塊傳輸編碼的響應格式。
  瀏覽器在知道 HTML 會被流式返回后,就不用等到 HTML 下載完成后再開始解析了。
  不過,目前流行的客戶端渲染(Client Side Render)其實并不需要專門的流式渲染,因為 HTML 的內容本來就少。
  若改成服務端渲染(Server Side Render),那就可根據實際情況進行流式渲染的優化了。
  具體的實現過程,本文不再贅述,可參考網上相關的方案,例如 Vue SSR 指南中的[流式渲染](https://v2.ssr.vuejs.org/zh/guide/streaming.html)。
**2)DOM**
  HTML 在被解析時,一旦遇到 JavaScript,那么就會被阻塞,如下圖所示。
:-: 
  當遇到外部腳本時,還會停止 DOM 樹的構建,轉由網絡進程去請求 JavaScript 腳本地址。
  CSS 本身并不會阻塞 DOM 樹的構建,但在與 JavaScript 結合使用時,會出現阻塞。
  在下面的示例中,JavaScript 會修改 demo.css 文件中的樣式。
~~~html
<link rel="stylesheet" href="demo.css" />
<div id='root'>內容</div>
<script>
const root = document.getElementById('root');
root.style.color = 'red';
</script>
~~~
  主線程在執行腳本之前,需要先計算節點樣式(即解析 CSS 文件),因此 DOM 樹就無法被繼續構建了。
  若要優化 DOM 樹的構建,除了盡量避免上述不科學的寫法之外,還可以從兩方面入手:減少關鍵資源請求的數量和大小。
  所謂關鍵資源(key resource),更確切的說就是網頁首屏的核心資源,沒有它們,那么首屏將無法正確的呈現。
  減少資源的請求數量可以通過 2 個方法:
* 將 CSS 或 JavaScript 內聯到 HTML 結構中,例如移動端的屏幕適配腳本就比較適合內聯。
* 腳本元素可以增加 async 或 defer 的標記,具體可以參考上一節的 script 元素。
  關鍵資源的大小除了進行壓縮外,就是只提取首屏需要的代碼。
  將其他部分的代碼合并到另一個文件,待需要時再加載,或者使用上一節所說的預提取。
**3)重排和重繪**
  重排(reflow)也叫回流,是指修改元素的幾何屬性后引起的重新渲染,涉及 7 個階段,如下圖所示,修改了元素的高度。
:-: 
  觸發重排的情況有添加或刪除可見的元素、修改位置、邊距或內容等。
  重繪(repaint)是指修改元素的背景顏色后引起的重新渲染,但與重排不同,重繪將直接進入 Paint 階段,如下圖所示。
:-: 
  重排和重繪都會降低渲染性能,因為它們都發生在主線程中,并且布局、分層和繪制 3 個階段的計算過程比較昂貴。
  當在腳本中獲取元素的尺寸、位置等排版相關的信息時,就有可能觸發強制重排,例如調用 offsetTop、clientWidth、getComputedStyle() 等屬性或方法。
  優化它們的方式包括使用 cssText 或 CSS 類修一次性修改多個 CSS 屬性,批量修改 DOM,例如使用文檔片段 fragment、先隱藏元素再顯示等。
  在眾多的 CSS 屬性中,有兩個 CSS 屬性(transform 和 opacity)可以避開重排和重繪,直接進入合成階段。
  例如用 transform 屬性實現的元素變化,就不會占用主線程,而是由合成線程處理,如下圖所示。
:-: 
  值得一提的是,早期在腳本中實現動畫,都會借助定時器,但定時器無法精確的配置動畫幀之間的時間間隔。
  按屏幕刷新率為每秒 60 次計算,那么理論上每幀的間隔約等于是 16.67 毫秒。
  但實際情況比較復雜,間隔不一定是這個值,有可能出現丟幀,從而造成動畫不夠平滑流暢。
  為了解決動畫問題,瀏覽器提供了[requestAnimationFrame()](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame)方法,在每一幀的開始執行配置的回調。
  注意,只有當瀏覽器 GPU 生成位圖和屏幕顯示位圖保持同步時,才會觸發 requestAnimationFrame() 的回調。
  在下面的示例中,讓絕對定位的 span 元素通過 requestAnimationFrame() 向右偏移。
~~~html
<span id='container' style="position:absolute">內容</span>
<script>
let left = 0;
const frame = () => {
const container = document.getElementById('container');
container.style.left = `${left++}px`;
if (left > 100) return;
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
</script>
~~~
  注意,requestAnimationFrame() 也是運行在主線程中,如果主線程繁忙,那么也有可能延遲回調,造成動畫的卡頓。
  并且如果其回調比較耗時(超過一幀),那么就會阻礙后續的任務。
**4)離屏渲染**
  2023-11-20 離屏渲染相當于開辟一個緩沖區,將屏幕外的內容提前繪制好,在滾動或需要時就能直接呈現對應內容。
  一種實現方式是維護兩個 Canvas,當前渲染的 Canvas 與隱藏的緩存 Canvas 兩者交替繪制,這不僅復雜,并且還要消耗更多的計算資源。
  另一種是使用 [OffscreenCanvas](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas) 實現真正的離屏,這是一個實驗中的新功能,提升 Canvas 在 2D/3D 繪圖時的渲染性能和使用體驗。
  與 Canvas 只能在主線程中執行不同,OffscreenCanvas 既可以在主線程中執行,也可以在 Web Worker 中執行,這讓不影響主線程的離屏渲染成為可能。
## 總結
  本文的第一章節詳細描述了資源的優化,并在開篇指出資源都存在著優先級,瀏覽器會按優先級進行請求。
  預加載可提升資源的優先級,預提取可降低資源的優先級,預連接可提前進行 TCP 連接或 DNS 查詢。
  script 元素有延遲和異步兩種運行機制,可有效地防止 HTML 解析的阻塞。
  數據預請求需要與客戶端配合,本文給出了一份解決方案可供參考。
  自定義字體在頁面開發中有著廣泛的應用,常用的優化手段是預加載和減小尺寸。
  在第二章節中詳細分析了瀏覽器的渲染過程,這個過程大致可分為 8 個階段。
  圍繞這些階段,引出了流式渲染、DOM 樹構建的優化。
  在重排和重繪中,詳細說明了它們影響的階段,并且列舉了觸發原因,以及優化手段。
  最后提到了合成動畫,并且對比了 JavaScript 動畫的兩種實現方式。
*****
> 原文出處:
[博客園-前端性能精進](https://www.cnblogs.com/strick/category/2267607.html)
[知乎專欄-前端性能精進](https://www.zhihu.com/column/c_1610941255021780992)
已建立一個微信前端交流群,如要進群,請先加微信號freedom20180706或掃描下面的二維碼,請求中需注明“看云加群”,在通過請求后就會把你拉進來。還搜集整理了一套[面試資料](https://github.com/pwstrick/daily),歡迎閱讀。

推薦一款前端監控腳本:[shin-monitor](https://github.com/pwstrick/shin-monitor),不僅能監控前端的錯誤、通信、打印等行為,還能計算各類性能參數,包括 FMP、LCP、FP 等。
- ES6
- 1、let和const
- 2、擴展運算符和剩余參數
- 3、解構
- 4、模板字面量
- 5、對象字面量的擴展
- 6、Symbol
- 7、代碼模塊化
- 8、數字
- 9、字符串
- 10、正則表達式
- 11、對象
- 12、數組
- 13、類型化數組
- 14、函數
- 15、箭頭函數和尾調用優化
- 16、Set
- 17、Map
- 18、迭代器
- 19、生成器
- 20、類
- 21、類的繼承
- 22、Promise
- 23、Promise的靜態方法和應用
- 24、代理和反射
- HTML
- 1、SVG
- 2、WebRTC基礎實踐
- 3、WebRTC視頻通話
- 4、Web音視頻基礎
- CSS進階
- 1、CSS基礎拾遺
- 2、偽類和偽元素
- 3、CSS屬性拾遺
- 4、浮動形狀
- 5、漸變
- 6、濾鏡
- 7、合成
- 8、裁剪和遮罩
- 9、網格布局
- 10、CSS方法論
- 11、管理后臺響應式改造
- React
- 1、函數式編程
- 2、JSX
- 3、組件
- 4、生命周期
- 5、React和DOM
- 6、事件
- 7、表單
- 8、樣式
- 9、組件通信
- 10、高階組件
- 11、Redux基礎
- 12、Redux中間件
- 13、React Router
- 14、測試框架
- 15、React Hooks
- 16、React源碼分析
- 利器
- 1、npm
- 2、Babel
- 3、webpack基礎
- 4、webpack進階
- 5、Git
- 6、Fiddler
- 7、自制腳手架
- 8、VSCode插件研發
- 9、WebView中的頁面調試方法
- Vue.js
- 1、數據綁定
- 2、指令
- 3、樣式和表單
- 4、組件
- 5、組件通信
- 6、內容分發
- 7、渲染函數和JSX
- 8、Vue Router
- 9、Vuex
- TypeScript
- 1、數據類型
- 2、接口
- 3、類
- 4、泛型
- 5、類型兼容性
- 6、高級類型
- 7、命名空間
- 8、裝飾器
- Node.js
- 1、Buffer、流和EventEmitter
- 2、文件系統和網絡
- 3、命令行工具
- 4、自建前端監控系統
- 5、定時任務的調試
- 6、自制短鏈系統
- 7、定時任務的進化史
- 8、通用接口
- 9、微前端實踐
- 10、接口日志查詢
- 11、E2E測試
- 12、BFF
- 13、MySQL歸檔
- 14、壓力測試
- 15、活動規則引擎
- 16、活動配置化
- 17、UmiJS版本升級
- 18、半吊子的可視化搭建系統
- 19、KOA源碼分析(上)
- 20、KOA源碼分析(下)
- 21、花10分鐘入門Node.js
- 22、Node環境升級日志
- 23、Worker threads
- 24、低代碼
- 25、Web自動化測試
- 26、接口攔截和頁面回放實驗
- 27、接口管理
- 28、Cypress自動化測試實踐
- 29、基于Electron的開播助手
- Node.js精進
- 1、模塊化
- 2、異步編程
- 3、流
- 4、事件觸發器
- 5、HTTP
- 6、文件
- 7、日志
- 8、錯誤處理
- 9、性能監控(上)
- 10、性能監控(下)
- 11、Socket.IO
- 12、ElasticSearch
- 監控系統
- 1、SDK
- 2、存儲和分析
- 3、性能監控
- 4、內存泄漏
- 5、小程序
- 6、較長的白屏時間
- 7、頁面奔潰
- 8、shin-monitor源碼分析
- 前端性能精進
- 1、優化方法論之測量
- 2、優化方法論之分析
- 3、瀏覽器之圖像
- 4、瀏覽器之呈現
- 5、瀏覽器之JavaScript
- 6、網絡
- 7、構建
- 前端體驗優化
- 1、概述
- 2、基建
- 3、后端
- 4、數據
- 5、后臺
- Web優化
- 1、CSS優化
- 2、JavaScript優化
- 3、圖像和網絡
- 4、用戶體驗和工具
- 5、網站優化
- 6、優化閉環實踐
- 數據結構與算法
- 1、鏈表
- 2、棧、隊列、散列表和位運算
- 3、二叉樹
- 4、二分查找
- 5、回溯算法
- 6、貪心算法
- 7、分治算法
- 8、動態規劃
- 程序員之路
- 大學
- 2011年
- 2012年
- 2013年
- 2014年
- 項目反思
- 前端基礎學習分享
- 2015年
- 再一次項目反思
- 然并卵
- PC網站CSS分享
- 2016年
- 制造自己的榫卯
- PrimusUI
- 2017年
- 工匠精神
- 2018年
- 2019年
- 前端學習之路分享
- 2020年
- 2021年
- 2022年
- 2023年
- 2024年
- 日志
- 2020