Skip to content

瀏覽器渲染管線

🎯 核心問題

為什麼有些網頁流暢如絲,有些卻卡成 PPT? 瀏覽器是怎麼把一堆 HTML、CSS、JavaScript 程式碼變成你眼前看到的網頁的?本章將帶你深入瀏覽器的「車間」,理解它的工作流程,從而寫出效能更好的網頁。

這篇文章會帶你學什麼?

章節內容學完能幹嘛
第 1 章為什麼要理解渲染管線理解效能最佳化的必要性
第 2 章渲染管線的五個階段掌握瀏覽器渲染的基本流程
第 3 章構建 DOM 樹和 CSSOM 樹理解 HTML 和 CSS 如何被解析
第 4 章構建渲染樹知道哪些元素會被渲染
第 5 章佈局與重排避免觸發昂貴的佈局計算
第 6 章繪製與重繪減少不必要的繪製操作
第 7 章合成與 GPU 加速利用 GPU 提升動畫效能
第 8 章事件迴圈理解 JavaScript 的執行機制
第 9 章效能最佳化實戰掌握常用的效能最佳化技巧

每一章都從「理解原理」開始,不需要你會手寫最佳化程式碼。遇到效能問題時,隨時回來查就行。


1. 為什麼要理解「渲染管線」?

1.1 從「能跑」到「跑得快」:前端開發的進階之路

剛開始學前端時,我們只關心程式碼「能不能跑」——頁面能顯示出來,按鈕能點擊,就算成功了。但隨著專案變大,使用者變多,你很快會發現一個殘酷的現實:同樣的功能,有人寫的頁面絲般順滑,有人寫的卻卡頓到使用者想摔滑鼠

這就像學開車。新手只關心「車能不能開動」,但老司機會關心「什麼時候該換檔、什麼時候該煞車、怎麼開最省油」。瀏覽器就是你開的那輛「車」,理解它的「工作習性」,你才能開得又快又穩。

🐢 新手思維(只關注功能)

  • 只要頁面能顯示就行
  • 卡頓是瀏覽器的問題
  • 效能最佳化是後期才考慮的事

🚀 進階思維(關注體驗)

  • 流暢度是使用者體驗的核心
  • 理解瀏覽器工作流程
  • 寫程式碼時就考慮效能

理解渲染管線,就是從「能跑」到「跑得快」的關鍵一步。

1.2 一個真實的踩坑故事:為什麼「最佳化」後反而更卡了?

小張的效能踩坑記

小張是一家電商公司的前端工程師,負責最佳化商品詳情頁。這個頁面展示商品資訊時卡得要死,使用者投訴不斷。

小張想:「頁面卡應該是因為 DOM 太多了,我先用 display:none 隱藏起來,修改完再顯示,這樣瀏覽器就不會重複渲染了吧?」

於是他寫了這樣的程式碼:

javascript
// 你以為的「最佳化」
const container = document.getElementById('list')
container.style.display = 'none'  // 先隱藏,應該不會觸發渲染了吧?

for (let i = 0; i < 1000; i++) {
  const item = document.createElement('div')
  item.style.width = Math.random() * 100 + 'px'  // 隨機寬度
  container.appendChild(item)
}

container.style.display = 'block'  // 最後顯示,一次性渲染

結果測試後發現,頁面更卡了!小張懵了:明明已經「最佳化」了,為什麼反而更慢?

後來前端負責人看了程式碼,點出問題所在:雖然元素被隱藏了,但你每次修改 style.width 仍然會觸發瀏覽器的樣式計算和佈局標記,瀏覽器在背景做了大量無用功

正確的做法是用 DocumentFragment 在記憶體中批次操作,最後一次性插入 DOM,只觸發一次渲染。

💡 核心啟示

不了解瀏覽器的工作流程,你可能會「自作聰明」地寫出一堆「最佳化程式碼」,結果反而讓效能更差。理解渲染管線,你才知道哪些操作是昂貴的、哪些是廉價的,從而避免在錯誤的地方用力。


2. 核心概念:什麼是「渲染管線」?

🤔 什麼是「渲染」?

渲染(Rendering),簡單說就是瀏覽器把程式碼「畫」成你看到的網頁的過程。

你可以把它想像成印刷廠印書

  • HTML = 書稿內容(文字、圖片、章節)
  • CSS = 排版要求(字型大小、顏色、間距)
  • JavaScript = 動態修改(作者臨時改稿、調整排版)

瀏覽器拿到這些「材料」後,要經過一道道「工序」,最後才能「印刷」出你看到的網頁。這一系列工序,就是渲染管線(Rendering Pipeline)

為了幫你更好地理解,我們用一家麵包店來比喻瀏覽器的渲染流程。

2.1 用麵包店比喻理解渲染管線

想像你在經營一家麵包店,每天要為顧客製作各種麵包。這個過程中涉及到的環節,與瀏覽器的渲染流程驚人地相似:

階段🥖 麵包店比喻瀏覽器實際工作具體例子
1. 準備食材整理原料清單(麵粉、雞蛋、奶油...)構建 DOM 樹:把 HTML 解析成樹形結構你寫 <div><p>Hello</p></div>,瀏覽器解析成 div→p→"Hello" 的樹
2. 準備配方整理配方卡(每種麵包的配料比例)構建 CSSOM 樹:把 CSS 解析成規則樹你寫 .title { color: red },瀏覽器記錄「.title 的文字是紅色」
3. 制定計劃根據原料和配方,決定今天要做什麼麵包構建渲染樹:合併 DOM 和 CSSOM,只保留可見元素<script> 標籤不顯示,所以不在渲染樹裡
4. 擺放位置把麵包擺到展示櫃,決定每個麵包放哪佈局(Layout):計算每個元素的尺寸和位置算出「這個 div 寬 200px、高 100px,在螢幕的 (50, 50) 位置」
5. 上色裝飾給麵包刷蛋液、撒芝麻、擠奶油繪製(Paint):把元素的顏色、邊框、陰影等「畫」出來把「紅色文字」真正畫到螢幕上
6. 組裝完成把所有麵包層疊在一起,擺成漂亮的樣子合成(Composite):把多個圖層合併成最終畫面GPU 把背景層、文字層、圖片層合併成一張完整畫面

📊 從表格中你能看到什麼?

讓我們逐行解讀這張表,理解渲染管線的每個階段:

階段 1-2(準備階段):瀏覽器先「看懂」你的程式碼。HTML 和 CSS 是分開解析的,因為它們職責不同——HTML 決定「有什麼內容」,CSS 決定「長什麼樣」。

階段 3(合併階段):為什麼要「合併」?因為不是所有 HTML 元素都會顯示(比如 <head><script>),瀏覽器需要把「可見元素」和「它們的樣式」結合在一起,形成一張「施工圖」。

階段 4-5(繪製階段):佈局是「算位置」,繪製是「上顏色」。佈局改變(比如改寬度)會導致繪製,但繪製改變(比如改顏色)不會導致佈局。

階段 6(合成階段):現代瀏覽器的「魔法」。傳統方式是「一次性畫完」(CPU 慢),現代方式是「分層繪製 + GPU 合成」(快),這就是為什麼 transform 動畫比 width 動畫流暢的原因。

2.2 渲染管線的五個階段

🏭渲染管线从代码到像素的五步旅程
想象你在印刷厂工作:稿件要排版、印刷、装订,最后才能变成书本。浏览器渲染网页也一样,HTML 和 CSS 要经过一道道"工序",才能变成屏幕上的画面。
🌲
构建DOM/CSSOM
解析代码
🎨
构建渲染树
合并筛选
📐
布局
计算位置
✏️
绘制
填充颜色
🔮
合成
合并图层
👆 点击上方任意阶段,查看详细解释
💡核心思想:每个阶段各司其职,前面的阶段为后面阶段准备数据。理解这个流程,你就能知道什么时候用什么方式修改页面,才能避免性能问题。

3. 第一階段:構建 DOM 樹和 CSSOM 樹

3.1 為什麼要「樹」化?

🤔 什麼是 DOM?

DOM(Document Object Model,文件物件模型),是瀏覽器把 HTML 文件轉換成的一種樹形結構,方便 JavaScript 操作頁面元素。

你可以把它想像成家譜樹

  • 最頂端是「祖先」(<html>
  • 下面是「子代」(<body><head>
  • 再下面是「孫代」(<div><p><span>

為什麼要轉成樹? 因為樹形結構很方便「尋找」和「修改」。比如你想找到「所有 class 是 title 的元素」,瀏覽器可以在樹上快速搜尋,而不是從一堆亂七八糟的文字裡慢慢找。

瀏覽器拿到 HTML 後,不會馬上顯示,而是要先「理解」它。這個過程分為三步:

第一步:詞法分析——把程式碼拆成「詞」

html
<div class="container">
  <p>Hello World</p>
</div>

瀏覽器看到這段程式碼,會先「拆詞」:

  • <div> → 「開始標籤 div」
  • class="container" → 「屬性 class,值 container」
  • <p> → 「開始標籤 p」
  • Hello World → 「文字內容」
  • </p> → 「結束標籤 p」
  • </div> → 「結束標籤 div」

第二步:語法分析——把「詞」組裝成「節點」

瀏覽器根據 HTML 規則,把這些「詞」組裝成「節點」:

  • 元素節點:<div><p>
  • 屬性節點:class="container"
  • 文字節點:"Hello World"

第三步:構建樹——建立「父子關係」

最後,瀏覽器根據標籤的巢狀關係,構建出樹形結構:

Document(文件根節點)
└── html
    └── body
        └── div.class = "container"
            └── p
                └── "Hello World"

3.2 CSSOM 樹:樣式的「規則手冊」

🤔 什麼是 CSSOM?

CSSOM(CSS Object Model,CSS 物件模型),是瀏覽器把 CSS 規則轉換成的樹形結構,用來計算每個元素的最終樣式。

你可以把它想像成服裝搭配指南

  • 上層規則(body 的字型)會影響下層(所有子元素)
  • 如果有衝突(比如同一元素多個規則指定不同顏色),要按「優先順序」決定用哪個
  • 最終算出每個元素該穿什麼「衣服」

CSSOM 的構建過程和 DOM 類似,但有一個關鍵區別:CSS 是「繼承」和「層疊」的

查看 CSSOM 構建過程

原始 CSS:

css
body {
  font-size: 16px;
  color: #333;
}

.container {
  width: 100%;
  color: red;  /* 會覆蓋 body 的 color */
}

.container p {
  font-weight: bold;
}

構建後的 CSSOM 樹:

StyleSheet
├── body
│   ├── font-size: 16px
│   └── color: #333
└── .container
    ├── width: 100%
    ├── color: red  (優先順序更高,覆蓋 body 的 color)
    └── p
        └── font-weight: bold

3.3 踩坑實錄:為什麼我的 CSS「不生效」?

坑一:CSS 選擇器權重衝突

查看常見錯誤
css
/* 你寫的 CSS */
#header { color: red; }      /* id 選擇器,權重 100 */
.title { color: blue; }     /* class 選擇器,權重 10 */

/* HTML */
<div id="header" class="title">這段文字是什麼顏色?</div>

你以為是藍色,結果是紅色。因為 id 選擇器的權重(100)比 class 選擇器(10)高。

坑二:HTML 標籤沒閉合,瀏覽器「自動修復」

查看瀏覽器如何修復錯誤 HTML
html
<!-- 你寫的 HTML -->
<div>
  <p>這是一段文字
</div>

<!-- 瀏覽器修復後 -->
<div>
  <p>這是一段文字</p>  <!-- 瀏覽器自動幫你閉合標籤 -->
</div>

瀏覽器很「寬容」,會自動修復你的錯誤。但這種寬容是有代價的——瀏覽器需要額外計算來猜測你的意圖,會影響效能

🌲DOM到渲染树浏览器如何构建渲染树
浏览器需要把 HTML 和 CSS 合并成一棵"渲染树"。想象你在组装家具:图纸是 DOM,说明书是 CSSOM,只有结合两者,才能知道每个零件长什么样、放在哪里。
DOM树
<html>
<head>
<style>
<body>
<div>
<span>
<script>
+
CSSOM树
body
div
color: red
span
display: block
script
display: none
=
渲染树
div
span
可见节点
不可见节点(不包含在渲染树中)
💡核心要点:渲染树只包含可见的节点(display: none 的元素会被忽略)。每个渲染树节点都包含对应的 DOM 节点和计算出的样式信息。渲染树构建完成后,浏览器才能进入布局阶段。

4. 第二階段:構建渲染樹

4.1 為什麼需要「渲染樹」?

你可能會問:「已經有了 DOM 樹和 CSSOM 樹,為什麼還要再構建一個渲染樹?直接用 DOM 不行嗎?」

答案是:DOM 樹包含了太多「無用」資訊

比如下面這段 HTML:

html
<html>
<head>
  <title>頁面標題</title>
  <style>/* CSS 程式碼 */</style>
  <script>/* JavaScript 程式碼 */</script>
</head>
<body>
  <div class="container">
    <p>可見內容</p>
  </div>
  <div style="display: none">
    <p>隱藏內容(display:none)</p>
  </div>
</body>
</html>

DOM 樹會包含所有元素

  • <head><title><style><script>(這些不顯示)
  • display: none 的 div(也不顯示)

渲染樹只包含「要畫到螢幕上」的元素

  • 去掉 <head> 及其子元素
  • 去掉 display: none 的 div

4.2 渲染樹的構建規則

瀏覽器在構建渲染樹時,會遵循一套規則:

場景處理方式範例效能影響
display: none完全排除出渲染樹元素及其子元素都不可見✅ 減少渲染工作量
visibility: hidden包含在渲染樹中,但不繪製佔據空間,但完全透明⚠️ 仍需佈局計算
opacity: 0包含在渲染樹中,但透明可互動(能點擊),但看不見⚠️ 仍需佈局計算
不在視埠內包含在渲染樹中,暫不繪製捲動到視埠時才繪製⚠️ 但仍在渲染樹中

📊 從表格中你能看到什麼?

關鍵發現display: none 是唯一「真正省效能」的隱藏方式,因為元素完全不在渲染樹裡,瀏覽器不會為它做任何佈局和繪製工作。

visibility: hiddenopacity: 0 雖然「看不見」,但仍在渲染樹中,瀏覽器仍需計算它們的佈局(佔據空間)。如果你需要「隱藏但不影響佈局」(比如做淡入淡出動畫),可以用 opacity;如果需要「完全隱藏且不佔空間」,用 display: none

4.3 踩坑實錄:為什麼設定了 display:none,頁面還是卡?

❌ 常見誤區:以為 display:none 的元素「不存在」

很多人以為設定 display: none 後,元素就「消失」了,怎麼操作都不會影響效能。這是錯誤的!

雖然 display: none 的元素不在渲染樹中,但你透過 JavaScript 修改它的屬性時,瀏覽器仍需要:

  1. 重新計算樣式(匹配 CSS 規則)
  2. 追蹤變化(為未來顯示做準備)

看下面這個「最佳化」例子:

查看「無效最佳化」的程式碼
javascript
// ❌ 你以為的「最佳化」:先隱藏,修改完再顯示
const container = document.getElementById('list')
container.style.display = 'none'

// 瘋狂操作 DOM
for (let i = 0; i < 1000; i++) {
  const item = document.createElement('div')
  item.style.width = Math.random() * 100 + 'px'  // 改變寬度!
  item.textContent = `Item ${i}`
  container.appendChild(item)
}

container.style.display = 'block'

// 問題:每次修改 style.width,瀏覽器都要重新計算樣式,
// 即使元素是 display:none!

✅ 正確的最佳化姿勢:

javascript
// 使用 DocumentFragment 批次操作
const container = document.getElementById('list')
const fragment = document.createDocumentFragment()  // 虛擬容器

// 所有操作都在記憶體中的 fragment 上進行
for (let i = 0; i < 1000; i++) {
  const item = document.createElement('div')
  item.style.width = Math.random() * 100 + 'px'
  item.textContent = `Item ${i}`
  fragment.appendChild(item)  // 不影響真實 DOM
}

// 一次性插入真實 DOM,只觸發一次渲染
container.appendChild(fragment)

5. 第三階段:佈局與重排

5.1 什麼是「佈局」?

🤔 什麼是佈局(Layout)?

佈局,也叫回流(Reflow),是瀏覽器計算渲染樹中每個元素「在什麼位置、佔多大空間」的過程。

你可以把它想像成裝潢設計師測量房間

  • 先測量每個房間的長寬
  • 決定傢俱擺在哪裡
  • 算出每個傢俱的座標

為什麼佈局很「貴」? 因為一個元素的變化可能影響其他元素。比如你把一個 div 變寬了,它旁邊的 div 可能被擠下去,導致整個頁面重新計算。

5.2 觸發重排的「雷區」

以下是常見的會觸發重排的操作,建議收藏並背誦

類別屬性/操作效能影響替代方案
尺寸width, height, min/max-width/height💀💀💀transform: scale() 代替
位置top, right, bottom, left💀💀💀transform: translate() 代替
邊距margin, padding💀💀transformgap 代替
邊框border-width💀💀盡量避免頻繁修改
內容文字內容變化、圖片載入💀💀預留空間,避免佈局抖動
字型font-size, line-height💀💀💀盡量避免頻繁修改
顯示display 值改變💀💀💀visibilityopacity 代替(如不需要完全隱藏)
查詢offsetWidth, offsetHeight💀💀💀💀💀批次讀取,避免佈局抖動

📊 從表格中你能看到什麼?

關鍵發現

  1. 幾何屬性(寬高位置)最昂貴:它們會觸發完整的佈局計算
  2. 查詢屬性比修改更危險:讀取 offsetWidth強制同步佈局(詳見 5.4 節)
  3. transform 和 opacity 是效能最好的:它們不觸發重排,只觸發合成

5.3 踩坑實錄:為什麼我的動畫卡成 PPT?

坑:用 width 做動畫

查看效能差的動畫程式碼
css
/* ❌ 壞的動畫:觸發重排 */
.box {
  width: 100px;
  transition: width 0.3s;
}

.box:hover {
  width: 200px;  /* 改變寬度會觸發重排! */
}

每一幀動畫都會觸發重排,瀏覽器需要:

  1. 重新計算寬度
  2. 重新計算位置(可能影響其他元素)
  3. 重新繪製

✅ 好的動畫:用 transform

css
/* ✅ 好的動畫:只觸發合成 */
.box {
  width: 100px;
  transform: scaleX(1);
  transition: transform 0.3s;
}

.box:hover {
  transform: scaleX(2);  /* 縮放不觸發重排! */
}

transform 直接由 GPU 處理,不會觸發重排和重繪,動畫絲般順滑。

5.4 效能殺手:強制同步佈局

💀 最危險的效能問題:佈局抖動

強制同步佈局(Forced Synchronous Layout),也叫佈局抖動(Layout Thrashing),是最常見也是最嚴重的效能問題。

它的原因是:JavaScript 在讀取佈局屬性(如 offsetWidth)時,瀏覽器必須立即執行佈局計算,才能返回準確值。

如果你「讀寫交替」,就會導致瀏覽器反覆「佈局→讀取→佈局→讀取」,形成惡性循環。

查看佈局抖動的程式碼
javascript
// ❌ 極壞:讀寫交替,導致佈局抖動
const elements = document.querySelectorAll('.item')

for (let i = 0; i < elements.length; i++) {
  const height = elements[i].offsetHeight  // 讀取 → 強制佈局
  elements[i].style.width = (height * 2) + 'px'  // 寫入 → 標記需要重排
  // 下一次迴圈的讀取又會強制佈局...惡性循環!
}

// 如果有 100 個元素,就會觸發 100 次佈局計算!

✅ 正確的最佳化姿勢:讀寫分離

javascript
const elements = document.querySelectorAll('.item')

// 第一步:批次讀取(先全部讀完)
const heights = []
for (let i = 0; i < elements.length; i++) {
  heights.push(elements[i].offsetHeight)  // 只觸發一次佈局
}

// 第二步:批次寫入(再全部寫)
requestAnimationFrame(() => {
  for (let i = 0; i < elements.length; i++) {
    elements[i].style.width = (heights[i] * 2) + 'px'  // 只觸發一次重排
  }
})
📐布局与重排看看布局计算如何影响页面
盒子
邻居元素
触发阶段:
性能影响:-
是否影响其他元素:-
💡核心要点:布局属性(如 width、margin)会触发重排,影响周围元素的位置。而 transform 只触发合成,在 GPU 上处理,不影响其他元素,性能更好。

6. 第四階段:繪製與重繪

6.1 什麼是「繪製」?

🤔 什麼是繪製(Paint)?

繪製,是瀏覽器把「佈局計算好」的元素真正「畫」到螢幕上的過程。

你可以把它想像成給房間刷漆

  • 佈局階段 = 量尺寸、畫線
  • 繪製階段 = 真正刷漆、貼壁紙

繪製沒有佈局那麼昂貴,但也不便宜。 頻繁繪製仍會影響效能,尤其是複雜元素(陰影、漸層等)。

6.2 觸發重繪的訊號

與重排不同,重繪只涉及「外觀」的改變,不涉及「幾何」的改變:

類別屬性效能影響備註
顏色color, background-color💀最常見的重繪觸發者
背景background-image, background-position💀💀圖片比純色慢
邊框border-color, border-style💀改變邊框顏色/樣式
文字text-decoration, text-shadow💀💀陰影比純文字慢
盒陰影box-shadow💀💀💀複雜的陰影很慢
圓角border-radius💀改變圓角大小
透明度opacity特殊:不觸發重繪,只觸發合成

📊 從表格中你能看到什麼?

關鍵發現opacity 是特殊的!它和 transform 一樣,不會觸發重繪,而是直接觸發合成階段。這就是為什麼用 opacity 做淡入淡出動畫效能最好的原因。

另外,陰影和漸層比重繪更昂貴,因為它們需要複雜的像素計算。如果你的頁面有很多 box-shadow,考慮用偽元素或圖片代替。

6.3 踩坑實錄:為什麼我的 hover 效果卡?

坑:用 box-shadow 做 hover 動畫

查看效能差的 hover 效果
css
/* ❌ 壞的 hover 效果:box-shadow 動畫很慢 */
.card {
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  transition: box-shadow 0.3s;
}

.card:hover {
  box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);  /* 陰影很慢! */
}

box-shadow 需要逐像素計算,動畫時會卡頓。

✅ 好的做法:用 transform 或偽元素

css
/* ✅ 好的 hover 效果:用 transform */
.card {
  transform: translateY(0);
  transition: transform 0.3s, box-shadow 0.3s;
}

.card:hover {
  transform: translateY(-4px);  /* 只在 hover 時改陰影,不做動畫 */
  box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
}
🎨绘制层优化浏览器如何通过分层提升性能
🖼️背景层
📄内容层
卡片
触发新层的 CSS 属性:
transform: translate3d(0,0,0)任何3D变换都会创建新层
opacity配合transition使用时
position: fixed固定定位元素需要独立层
will-change: transform显式提示浏览器创建层
💡核心要点:浏览器把需要动画的元素提升到独立的 GPU 层,这样动画时只需要调整位置和透明度,不需要重绘。但不要滥用,每个层都会占用 GPU 内存。

7. 第五階段:合成與 GPU 加速

7.1 什麼是「合成」?

🤔 什麼是合成(Composite)?

合成,是現代瀏覽器的「魔法」,它把頁面的不同部分分成多個層(Layer),然後利用 GPU(圖形處理器)來並行合成最終的畫面。

你可以把它想像成 Photoshop 的圖層

  • 傳統方式 = 所有東西畫在一層上(CPU 序列,慢)
  • 合成方式 = 分層畫,最後合併(GPU 並行,快)

為什麼合成快? 因為 GPU 擅長處理「圖像合成」這種並行任務,比 CPU 快幾十倍。

7.2 哪些元素會被提升到「合成層」?

瀏覽器會自動將某些元素提升到獨立的合成層。以下是常見的觸發條件:

觸發條件CSS 屬性/值效能影響注意事項
3D 變換transform: translate3d(), rotate3d()✅✅✅動畫效能最佳
硬體加速 hacktransform: translateZ(0)✅✅俗稱「強制 GPU 加速」
透明度動畫opacity 變化(配合動畫)✅✅✅不觸發重繪
固定定位position: fixed避免捲動時重複佈局
Will-Changewill-change: transform, opacity✅✅提前建立層,注意記憶體
Canvas/WebGL<canvas>, WebGL 內容✅✅天然在獨立層中
Video<video>✅✅獨立層,防止相互影響

📊 從表格中你能看到什麼?

關鍵發現transformopacity 是效能最好的動畫屬性,因為它們不觸發重排和重繪,直接觸發合成。這就是為什麼效能最佳化指南總是說「用 transform 和 opacity 做動畫」。

但要注意:每個合成層都要佔用 GPU 記憶體,濫用 translateZ(0) 會導致記憶體爆炸(詳見 7.4 節)。

7.3 踩坑實錄:合成層太多反而卡?

💀 過度最佳化的陷阱

有人聽說「GPU 加速快」,就給所有元素都加 transform: translateZ(0),結果頁面反而更卡了。

問題原因: 每個合成層需要在 GPU 中儲存一份「紋理」(點陣圖),佔用記憶體。如果一個頁面有 100 個合成層,GPU 記憶體可能被撐爆,導致低端裝置崩潰或降級到 CPU 渲染。

查看「過度最佳化」的程式碼
css
/* ❌ 錯誤做法:給所有元素都開啟 GPU 加速 */
.card { transform: translateZ(0); }
.button { transform: translateZ(0); }
.icon { transform: translateZ(0); }
/* ... 100 個元素都加 ... */

/* 結果:GPU 記憶體爆炸,頁面卡死 */

✅ 正確的做法:按需使用

css
/* 策略 1:只給真正需要動畫的元素開啟 */
.card {
  transition: transform 0.3s ease;
}

.card:hover {
  transform: translateY(-5px);  /* 自動建立合成層 */
}

/* 策略 2:用 will-change 提示瀏覽器 */
.card {
  will-change: transform;  /* 提前建立層 */
}

/* 策略 3:動畫結束後移除 */
.card:not(:hover) {
  will-change: auto;  /* 釋放 GPU 記憶體 */
}
🎬合成层演示浏览器渲染的最后阶段 - 图层合成
合成是浏览器渲染的最后一步。想象你在制作PPT动画:你已经准备好了所有图层,现在只需要调整它们的位置、透明度,然后把它们叠在一起显示出来。这就是合成要做的事情。
🖼️背景层
📄内容层
浮层
合成结果
🖼️
📄
💡核心要点:合成阶段在 GPU 上执行,只调整位置、透明度等,不重新绘制像素。因此 transform 和 opacity 动画性能最好,不会触发重排和重绘。

8. 事件迴圈:JavaScript 的「分身術」

🤔 什麼是事件迴圈?

事件迴圈(Event Loop),是 JavaScript 實現「非同步」的機制。因為 JavaScript 是單執行緒的(一次只能做一件事),但它又要處理使用者點擊、網路請求、定時器等多種任務,所以需要一套「排程系統」來管理這些任務。

你可以把它想像成快遞分揀中心

  • Call Stack(呼叫堆疊) = 目前正在處理的快遞
  • Web APIs = 外部合作倉庫(定時器、網路請求等)
  • Callback Queue(回呼佇列) = 待處理的快遞架
  • Event Loop(事件迴圈) = 分揀機器人(不斷檢查「是否可以處理下一個任務」)

8.1 巨集任務與微任務

早期的 JavaScript 只有一套任務佇列。但隨著非同步程式設計變複雜,瀏覽器引入了兩類任務:

類型常見來源優先順序執行時機
巨集任務setTimeout/setInterval、I/O 操作、UI 渲染每個事件迴圈週期執行一個
微任務Promise.thenMutationObserver目前巨集任務結束後,立即清空所有微任務

執行順序的「口訣」

1. 執行目前巨集任務(比如 <script> 整體)
2. 執行過程中產生的所有微任務(Promise.then 等)
   ↳ 微任務可以產生新的微任務,全部清空後才繼續
3. 如果有需要,進行 UI 渲染(重排/重繪)
4. 開啟下一輪事件迴圈,執行下一個巨集任務

8.2 踩坑實錄:Promise 比 setTimeout 快?

❌ 常見誤解:setTimeout(fn, 0) 會「立即」執行

很多人以為 setTimeout(fn, 0) 是「0 毫秒後立即執行」,這是錯誤的理解。

實際上,setTimeout(fn, 0) 的含義是:「至少等待 0 毫秒後,將回呼加入巨集任務佇列」。但它需要等待目前呼叫堆疊清空、微任務佇列清空、可能的 UI 渲染完成後,才能執行。

查看執行順序
javascript
console.log('1. Start')

setTimeout(() => {
  console.log('2. setTimeout callback')
}, 0)

Promise.resolve().then(() => {
  console.log('3. Promise.then')
})

console.log('4. End')

// 你以為的輸出順序:
// 1. Start
// 4. End
// 2. setTimeout callback  ← setTimeout(0) 不是立即嗎?
// 3. Promise.then

// 實際的輸出順序:
// 1. Start
// 4. End
// 3. Promise.then         ← Promise.then 比 setTimeout 先執行!
// 2. setTimeout callback

執行流程圖解:

呼叫堆疊(Call Stack)          巨集任務佇列                  微任務佇列
                              [setTimeout callback]         [Promise.then callback]

1. console.log('1. Start')
   → 輸出: 1. Start

2. setTimeout(fn, 0)
   → 將回呼加入巨集任務佇列      ← [setTimeout callback]

3. Promise.resolve().then()
   → 將回呼加入微任務佇列                                   ← [Promise.then callback]

4. console.log('4. End')
   → 輸出: 4. End

5. 呼叫堆疊清空,檢查微任務佇列
   → 發現 Promise.then 回呼
   → 執行: console.log('3. Promise.then')
   → 輸出: 3. Promise.then

6. 微任務佇列清空
   → 可能需要 UI 渲染(如果有變化)

7. 檢查巨集任務佇列
   → 發現 setTimeout 回呼
   → 執行: console.log('2. setTimeout callback')
   → 輸出: 2. setTimeout callback

💡 核心啟示

微任務比巨集任務「更急」。如果你希望某個操作在「目前程式碼區塊結束後、但 UI 更新前」盡快執行,用 Promise.thenqueueMicrotask

setTimeout(0) 不保證立即執行,它至少會被延遲到目前呼叫堆疊清空、微任務佇列清空之後。

Event Loop: How JavaScript Executes Code

Code queue

1
console.log("1")
Executing
2
setTimeout(() => console.log("2"), 0)
3
console.log("3")
4
fetch("/api").then(() => console.log("4"))
5
console.log("5")

Worker (single thread)

👨‍💻
Running
Run console.log("1")

Task queue

No pending tasks

Output log

Waiting for output...

Execution order: not started

Written order: 1, 2, 3, 4, 5

Code is written top to bottom, but it does not always run top to bottom because async work is delayed until the current code finishes.

🔄宏任务与微任务事件循环中的任务优先级
JavaScript 是单线程的,但可以通过任务队列实现异步。就像餐厅只有一个厨师,但他可以同时处理多个订单:先做VIP订单(微任务),再做普通订单(宏任务)。
主线程(执行栈)
同步代码
任务队列
微任务队列(优先级高)
Promise.then()
queueMicrotask()
宏任务队列(优先级低)
setTimeout()
setInterval()
I/O 操作
代码示例
console.log('1')

setTimeout(() => console.log('2'), 0)  // 宏任务

Promise.resolve().then(() => console.log('3'))  // 微任务

console.log('4')

// 输出顺序:1 → 4 → 3 → 2
💡核心要点:每次宏任务执行完后,会清空所有微任务,然后再执行下一个宏任务。这就是为什么 Promise.then() 比 setTimeout() 先执行。

9. 效能最佳化實戰:讓你的網頁「飛」起來

理解了渲染管線的工作流程後,我們來看看如何最佳化。以下是五個最實用的最佳化技巧。

9.1 黃金法則:避免強制同步佈局

問題:交替讀取和寫入佈局屬性,導致佈局抖動。

查看最佳化前後對比
javascript
// ❌ 極壞:讀寫交替,導致佈局抖動
for (let i = 0; i < elements.length; i++) {
  const height = elements[i].offsetHeight  // 讀取 → 強制佈局
  elements[i].style.height = (height * 2) + 'px'  // 寫入 → 標記需要重排
  // 下一次迴圈的讀取又會強制佈局...惡性循環!
}

// ✅ 極好:先全部讀取,再全部寫入
// 第一步:批次讀取
const heights = []
for (let i = 0; i < elements.length; i++) {
  heights.push(elements[i].offsetHeight)
}

// 第二步:批次寫入
requestAnimationFrame(() => {
  for (let i = 0; i < elements.length; i++) {
    elements[i].style.height = (heights[i] * 2) + 'px'
  }
})

9.2 使用 transform 和 opacity 做動畫

問題:用 widthheightlefttop 做動畫會觸發重排。

查看最佳化前後對比
css
/* ❌ 壞的動畫:觸發重排 */
.box {
  transition: width 0.3s, left 0.3s;
}
.box.moving {
  width: 200px;
  left: 100px;
}

/* ✅ 好的動畫:只觸發合成 */
.box {
  transition: transform 0.3s;
}
.box.moving {
  transform: translateX(100px) scaleX(2);
}

9.3 虛擬捲動:解決大資料列表

問題:列表項數量達到數千時,DOM 節點數量過多導致效能問題。

核心思想:只渲染視埠內可見的列表項(加上少量緩衝),DOM 節點數量固定,與資料總量無關。

渲染性能优化让页面丝滑流畅的秘诀
渲染性能优化的目标是每秒60帧(16.67ms/帧)。就像拍电影,每秒帧数越多,画面越流畅。超过这个时间,用户就会感觉卡顿。
❌ 不好的做法
// 触发重排和重绘
function animate() {
element.style.width = '100px'
element.style.height = '100px'
requestAnimationFrame(animate)
}
性能开销
VS
✅ 优化做法
/* 只触发合成 */
function animate() {
element.style.transform = 'translate3d(0,0,0)'
requestAnimationFrame(animate)
}
性能开销
黄金法则:
1️⃣优先使用 transformopacity 做动画
2️⃣避免频繁读取布局属性(如 offsetWidth)
3️⃣使用 will-change 提前告知浏览器
💡核心要点:渲染路径越长,性能越差。最佳路径是:合成(Composite)> 重绘(Paint)> 布局(Layout)> 样式计算(Style)。尽量让动画停留在"合成"阶段,在 GPU 上完成。
查看虛擬捲動的實作
vue
<template>
  <div class="virtual-list" @scroll="handleScroll">
    <!-- 佔位元素,撐起捲軸 -->
    <div class="phantom" :style="{ height: totalHeight + 'px' }"></div>

    <!-- 實際渲染的列表項 -->
    <div class="content" :style="{ transform: `translateY(${offsetY}px)` }">
      <div
        v-for="item in visibleItems"
        :key="item.id"
        class="item"
        :style="{ height: itemHeight + 'px' }"
      >
        {{ item.name }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const props = defineProps({
  items: Array,
  itemHeight: { type: Number, default: 50 }
})

const scrollTop = ref(0)
const buffer = 5  // 緩衝數量

// 可視區域能顯示多少項
const visibleCount = computed(() => 10)

// 起始索引
const startIndex = computed(() =>
  Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - buffer)
)

// 結束索引
const endIndex = computed(() =>
  Math.min(props.items.length, startIndex.value + visibleCount.value + buffer * 2)
)

// 目前可視的資料
const visibleItems = computed(() =>
  props.items.slice(startIndex.value, endIndex.value)
)

// 總高度
const totalHeight = computed(() => props.items.length * props.itemHeight)

// 偏移量
const offsetY = computed(() => startIndex.value * props.itemHeight)

const handleScroll = (e) => {
  scrollTop.value = e.target.scrollTop
}
</script>

9.4 防抖與節流:減少事件觸發頻率

問題:頻繁觸發的事件(如 scroll、resize)會導致效能問題。

查看防抖與節流的實作
javascript
// 防抖(Debounce):延遲執行,如果在延遲時間內再次觸發,則重新計時
function debounce(fn, delay) {
  let timer = null
  return function (...args) {
    clearTimeout(timer)
    timer = setTimeout(() => fn.apply(this, args), delay)
  }
}

// 節流(Throttle):固定時間間隔執行
function throttle(fn, interval) {
  let lastTime = 0
  return function (...args) {
    const now = Date.now()
    if (now - lastTime >= interval) {
      lastTime = now
      fn.apply(this, args)
    }
  }
}

// 使用範例
window.addEventListener('scroll', debounce(handleScroll, 200))
window.addEventListener('resize', throttle(handleResize, 100))

9.5 延遲載入:延遲載入非關鍵資源

問題:首屏載入太多資源導致頁面開啟慢。

查看延遲載入的實作
javascript
// 圖片延遲載入
const lazyImages = document.querySelectorAll('img[data-src]')

const imageObserver = new IntersectionObserver((entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target
      img.src = img.dataset.src  // 載入真實圖片
      img.removeAttribute('data-src')
      observer.unobserve(img)  // 停止觀察
    }
  })
})

lazyImages.forEach(img => imageObserver.observe(img))

10. 你現在應該能識別的效能問題

理解了瀏覽器的渲染管線後,你應該能識別以下常見的效能問題:

問題程式碼問題所在如何描述給 AI
element.style.width = ...在迴圈中頻繁修改寬度"這裡會觸發多次重排,請改用 transform 或者批次處理"
height = element.offsetHeight在寫入後立即讀取佈局屬性"這是強制同步佈局,請分離讀寫操作"
element.className = ...頻繁修改 class 觸發樣式重新計算"用 classList.add/remove 代替,減少樣式計算"
動畫用 width/left觸發重排和重繪,效能差"改用 transform 和 opacity 做動畫"
給所有元素加 translateZ(0)濫用 GPU 加速導致記憶體爆炸"只給需要動畫的元素開啟 GPU 加速"
列表項 10000 個全渲染DOM 節點過多導致卡頓"實作虛擬捲動,只渲染可見區域"
scroll 事件裡直接操作 DOM觸發頻率太高導致卡頓"用 requestAnimationFrame 或節流最佳化"
box-shadow 做 hover 動畫複雜的陰影計算很慢"改用 transform 或偽元素,避免動畫陰影"

如果你認真讀了每一章的「踩坑實錄」,你還掌握了這些核心概念:

  • 渲染管線五階段:DOM/CSSOM → 渲染樹 → 佈局 → 繪製 → 合成
  • 重排 vs 重繪:重排最昂貴(幾何變化),重繪次之(外觀變化)
  • 強制同步佈局:讀寫交替會導致佈局抖動,必須分離
  • GPU 加速:transform 和 opacity 由 GPU 處理,效能最佳
  • 事件迴圈:JavaScript 是單執行緒的,透過任務佇列實現非同步

這些概念會幫你快速定位效能瓶頸。

💡 遇到效能問題時這樣跟 AI 說

  • "動畫卡頓,檢查是否觸發了重排或重繪"
  • "捲動效能差,可能需要節流或 requestAnimationFrame"
  • "列表資料量大時卡頓,需要虛擬捲動"
  • "頻繁修改樣式導致效能問題,請用 transform 最佳化"

11. 總結:渲染管線最佳化的本質

透過本文的學習,我們可以得出以下核心結論:

從實踐來看:不是最佳化越多越好,而是最佳化越「對位」越好。理解瀏覽器的渲染管線,才能知道在哪裡用力、在哪裡放手。

從成本視角看

  • 大部分效能浪費來自對佈局屬性的頻繁讀寫交替,需要透過讀寫分離、批次處理來解決
  • 複雜的動畫效果如果觸發了重排和重繪,往往源於使用了「錯誤的屬性」,需要透過 transformopacity 來解決
  • 面對大量資料的列表渲染,單純依靠虛擬 DOM 已經不夠,必須結合虛擬捲動等技術

目標是:在給定的瀏覽器和硬體條件下,讓每一個渲染步驟的投入都具備明確的效能收益。


12. 名詞對照表

英文術語中文對照解釋
DOM文件物件模型瀏覽器將 HTML 文件解析後形成的樹形結構,JavaScript 可以透過 DOM API 操作頁面元素
CSSOMCSS 物件模型瀏覽器將 CSS 解析後形成的樹形結構,與 DOM 結合用於計算最終樣式
Render Tree渲染樹由 DOM 樹和 CSSOM 樹合併而成,只包含可見節點,用於後續的佈局計算和繪製
Layout佈局計算渲染樹中每個節點的幾何資訊(位置、大小)的過程,也稱為 Reflow(重排)
Reflow重排/回流當元素的尺寸、位置等幾何屬性發生變化時,瀏覽器需要重新計算佈局的過程
Paint繪製/重繪將佈局計算後的元素樣式(顏色、背景、邊框等)繪製到螢幕上的過程
Repaint重繪當元素的外觀屬性(如顏色、背景)變化但不影響幾何屬性時,觸發的繪製更新
Composite合成將多個繪製層(Layer)合併為最終螢幕圖像的過程,通常在 GPU 上執行
Layer層/合成層瀏覽器為了最佳化渲染而建立的獨立繪製表面,可以單獨變換和合成
Event Loop事件迴圈JavaScript 的非同步執行機制,負責排程巨集任務和微任務的執行
Call Stack呼叫堆疊記錄目前正在執行的 JavaScript 函式的資料結構
Macro Task巨集任務事件迴圈中優先順序較低的任務類型,如 setTimeout、setInterval、I/O 操作等
Micro Task微任務事件迴圈中優先順序較高的任務類型,如 Promise.then、MutationObserver 等
Forced Synchronous Layout強制同步佈局在 JavaScript 中交替讀取和寫入佈局屬性,導致瀏覽器被迫立即執行佈局計算的效能問題
Layout Thrashing佈局抖動頻繁的強制同步佈局導致的效能急劇下降現象
Virtual Scrolling虛擬捲動只渲染視埠內可見列表項的技術,用於最佳化大資料列表的效能
RAF請求動畫幀瀏覽器提供的 API,用於在下一次重繪前執行動畫相關的 JavaScript 程式碼