Vue 渲染性能優化
Vue 應用卡頓時,問題不一定在打包或網路。很多時候是畫面更新本身太重:一次渲染太多 DOM、父層更新牽動太多子層、列表 key 不穩定、computed 或 watch 讓更新範圍變大,或第三方套件造成頻繁 layout。
一句話回答:Vue 渲染性能優化的核心,是減少不必要的更新、縮小每次更新影響的範圍,並避免一次建立或操作過多 DOM;具體手段包含穩定 key、合理使用 v-if / v-show、拆分組件、大型列表虛擬化、v-memo / v-once、事件代理,以及避免 DOM 讀寫交錯。
一、 必考觀念
1. 渲染慢通常慢在哪?
一個 Vue 頁面更新,大致會經過:
狀態變化
-> 觸發響應式更新
-> 組件 render
-> 產生新 VNode
-> Diff / patch
-> 真實 DOM 更新
-> 瀏覽器 Style / Layout / Paint / Composite所以「渲染慢」可能來自不同位置:
| 位置 | 常見原因 |
|---|---|
| 響應式觸發 | 狀態設計太粗、深層資料太大、watch 太多 |
| render 階段 | 模板太重、列表太大、方法在模板中反覆計算 |
| Diff 階段 | key 不穩定、列表大量移動、子樹太大 |
| DOM patch | 新增、刪除、移動太多真實 DOM |
| 瀏覽器渲染 | Layout / Paint 成本高、DOM 讀寫交錯 |
面試時不要只說「Vue Diff 很慢」。Diff 只是其中一段,很多卡頓其實是資料、組件邊界或 DOM 數量造成的。
2. 優化方向:少渲染、小範圍、延後做
渲染性能可以用三句話理解:
少渲染:不需要更新的東西不要更新
小範圍:需要更新時,只更新必要子樹
延後做:不在當下需要的東西延後建立或載入對應到 Vue:
- 少渲染:
v-once、v-memo、computed 快取、避免不必要的父層更新。 - 小範圍:拆分組件、狀態下沉、穩定 props、穩定 key。
- 延後做:
v-if、異步組件、虛擬列表、非首屏模組延後 mount。
3. 先用工具確認是不是渲染問題
如果使用者說「頁面很卡」,不要直接改程式。
先看:
- Chrome DevTools Performance 裡是否有 long task。
- long task 裡是 scripting、rendering 還是 painting。
- 更新時是否有大量 Layout / Recalculate Style。
- Vue Devtools 裡哪些組件在頻繁更新。
- 一次更新涉及多少 DOM 節點。
- 列表是否一次渲染太多資料。
如果瓶頸在 API 或圖片,渲染優化效果會有限;如果瓶頸在 DOM 節點數或組件更新範圍,才是這篇要處理的主戰場。
二、 縮小更新範圍
1. 拆分組件邊界
父組件狀態變化時,子組件是否需要跟著更新,要看資料依賴與組件邊界。
壞情況是:一個大頁面把所有狀態、列表、彈窗、篩選條件、圖表都放在同一層,任何一個狀態改變都可能讓很大的子樹重新 render。
更好的做法是把更新頻率不同的區塊拆開:
<template>
<SearchPanel v-model="keyword" />
<UserTable :users="filteredUsers" />
<SummaryChart :summary="summary" />
<EditDialog v-if="editingUser" :user="editingUser" />
</template>拆分不是為了檔案好看,而是讓每個組件只關心自己需要的資料。
2. 狀態放在更靠近使用者的地方
如果某個狀態只有一個小組件需要,不一定要放在很高的父層。
例如 dropdown open 狀態:
<!-- 不一定需要由整個頁面管理 -->
<DropdownMenu />讓 DropdownMenu 自己管理 opened,可以避免父層因為開關 dropdown 而重新 render 其他大型區塊。
這種做法常被稱為狀態下沉:狀態放在真正需要它的最近共同位置。
3. 避免模板中反覆做重計算
模板每次 render 都會重新執行其中的表達式或方法呼叫。
不建議:
<li
v-for="user in users"
:key="user.id"
>
{{ formatUserName(user) }}
</li>如果 formatUserName 很輕,問題不大;但如果它做了排序、過濾、複雜格式化,就會在每次 render 時重複執行。
可以先用 computed 整理:
const displayUsers = computed(() => {
return users.value.map(user => ({
...user,
displayName: `${user.department} - ${user.name}`,
}))
})<li
v-for="user in displayUsers"
:key="user.id"
>
{{ user.displayName }}
</li>延伸讀:Vue computed 的實現原理。
三、 條件渲染與顯示切換
1. v-if 適合延後建立重型區塊
v-if 條件為 false 時,不會建立對應 DOM 與元件。
<HeavyChart v-if="showChart" />適合:
- 初始不顯示。
- 不常切換。
- 建立成本高。
- 不需要保留內部狀態。
例如圖表、富文本編輯器、大型表單、低頻 Modal,都可以用 v-if 避免一開始就 mount。
2. v-show 適合頻繁切換
v-show 會保留 DOM,只切換 display。
<TabPanel v-show="activeTab === 'profile'" />適合:
- 頻繁切換。
- 建立成本不高。
- 需要保留 DOM 或元件狀態。
Tab、Dropdown、Tooltip、展開收合,通常可以考慮 v-show。
3. 不要把 v-if / v-show 當絕對規則
常見口訣是:
切換少用 v-if
切換多用 v-show但真實專案還要看:
- 元件 mount 成本。
- DOM 子樹大小。
- 是否需要保留狀態。
- 是否涉及權限與安全。
- 初始渲染是否會拖慢首屏。
延伸讀:v-if 與 v-show 的區別。
四、 列表渲染優化
1. key 要穩定且能代表資料身份
動態列表要使用穩定 key:
<UserRow
v-for="user in users"
:key="user.id"
:user="user"
/>不要在會插入、刪除、排序的列表中使用 index 當 key:
<!-- 不建議用在動態列表 -->
<UserRow
v-for="(user, index) in users"
:key="index"
:user="user"
/>原因是 index 代表位置,不代表資料身份。列表順序一變,DOM 或元件狀態就可能錯位,Diff 也更難正確判斷哪些節點可以復用。
延伸讀:Vue 中 key 的作用、Vue 2.x 與 Vue 3.x 渲染器 Diff 算法。
2. 先過濾資料,再渲染列表
不建議把 v-if 和 v-for 寫在同一個節點上:
<!-- 不建議 -->
<UserRow
v-for="user in users"
v-if="user.active"
:key="user.id"
:user="user"
/>更好的做法是先用 computed 得到要渲染的資料:
const activeUsers = computed(() => {
return users.value.filter(user => user.active)
})<UserRow
v-for="user in activeUsers"
:key="user.id"
:user="user"
/>這樣模板更乾淨,也避免每一輪渲染都在節點層做條件判斷。
3. 大型列表要用虛擬列表或分頁
如果一次渲染幾千、幾萬筆資料,Vue Diff 再好也救不了 DOM 數量。
常見策略:
- 分頁。
- 分段載入。
- 虛擬列表。
- 只渲染可視區域。
- 降低每一列的組件複雜度。
虛擬列表的核心是:
資料有 10000 筆
畫面只顯示 20 筆
DOM 只維持可視區域附近的少量節點
滾動時替換資料與位置這是在改變渲染策略,不是微調某個 Vue API。
4. 列表中的事件可以考慮事件代理
如果每一列、每個按鈕都綁事件,資料量很大時會增加記憶體與更新成本。
可以把事件放在共同父層:
<ul @click="handleListClick">
<li
v-for="user in users"
:key="user.id"
:data-id="user.id"
>
<button data-action="edit">Edit</button>
<button data-action="delete">Delete</button>
</li>
</ul>function handleListClick(event: MouseEvent) {
const target = event.target as HTMLElement
const action = target.dataset.action
const item = target.closest('[data-id]') as HTMLElement | null
if (!action || !item) return
const id = item.dataset.id
if (action === 'edit') {
editUser(id)
}
if (action === 'delete') {
deleteUser(id)
}
}不是所有列表都需要事件代理。只有在資料量大、事件很多、更新頻繁時,它才比較值得。
五、 Vue 內建渲染優化能力
1. v-once
v-once 會讓節點只渲染一次,後續更新跳過它。
<section v-once>
<h1>{{ title }}</h1>
<p>{{ description }}</p>
</section>適合:
- 靜態說明文字。
- 不會變的條款內容。
- 初始化後不需要更新的區塊。
但不要濫用。如果資料未來可能變,v-once 會讓畫面不更新,造成維護風險。
2. v-memo
v-memo 是 Vue 3 的優化指令,用來根據依賴陣列跳過子樹更新。
<UserRow
v-for="user in users"
:key="user.id"
v-memo="[user.id, user.selected]"
:user="user"
/>只有當 memo 陣列裡的值變了,這個子樹才需要重新更新。
適合:
- 大列表。
- 每一列渲染成本高。
- 大多數列資料不變,只有少數列狀態變。
不適合:
- 小型列表。
- 更新邏輯不清楚。
- 依賴陣列很難維護。
v-memo 是精準工具,不是每個 v-for 都要加。
3. 編譯器已經做了很多優化
Vue 3 的性能不只靠 runtime Diff,也靠 compiler。
編譯器會做:
- 靜態提升。
- Patch Flags。
- Block Tree。
- 事件快取。
所以很多普通模板不需要手動優化。你真正要注意的是:資料量很大、更新頻率很高、DOM 子樹很重、第三方套件很重的地方。
延伸讀:Vue Compiler 的實現原理。
六、 DOM 與瀏覽器渲染成本
1. 避免 DOM 讀寫交錯
壞例子:
for (const item of items) {
item.style.width = `${container.offsetWidth}px`
}這段程式在迴圈裡讀 layout,再寫 style,可能讓瀏覽器反覆同步計算 layout。
比較好的做法:
const width = container.offsetWidth
for (const item of items) {
item.style.width = `${width}px`
}核心原則:
先批次讀
再批次寫延伸讀:瀏覽器渲染管線與 DOM 操作成本。
2. 動畫優先使用 transform 與 opacity
會影響 Layout 的屬性比較容易造成卡頓:
widthheighttopleftmarginpadding
動畫更常優先考慮:
transformopacity
例如:
.panel-enter-active,
.panel-leave-active {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.panel-enter-from,
.panel-leave-to {
transform: translateY(8px);
opacity: 0;
}這不是絕對保證,但作為實務原則很常見。
3. 第三方套件要特別觀察
圖表、地圖、表格、拖拉排序、富文本編輯器常常直接操作 DOM。
整合這些套件時要注意:
- 是否每次 reactive 變化都重新初始化。
- 是否可以只更新資料,不重建實例。
- 是否需要
markRaw保存第三方實例。 - 是否會大量測量 DOM 尺寸。
- 是否需要在
onBeforeUnmount清理事件與實例。
渲染卡頓不一定是 Vue 本身,很多時候是 Vue 和第三方 DOM 操作互相牽動。
七、 追問題庫
Q1:Vue 頁面更新卡頓,第一步看什麼?
先用 Performance 錄製操作,看卡在 JS、Layout、Paint 還是網路。
如果是 JS long task,再看是組件 render、computed/watch、資料處理或第三方套件。如果是 Layout/Paint,再看 DOM 數量、CSS 與 DOM 讀寫交錯。
Q2:為什麼大列表不能只靠 Diff?
Diff 能減少不必要的 DOM 操作,但不能消除大量 DOM 本身的成本。
如果畫面上真的存在一萬個節點,瀏覽器仍然要管理它們的樣式、佈局、繪製與事件。這時應該用分頁或虛擬列表,減少真實 DOM 數量。
Q3:v-memo 和 computed 差在哪?
computed 快取的是一段衍生資料或計算結果。
v-memo 影響的是模板子樹更新:當依賴陣列沒有變時,Vue 可以跳過這個子樹的更新。
兩者解決的層級不同,一個偏資料計算,一個偏渲染更新。
Q4:拆分組件一定會提升性能嗎?
不一定。
拆分組件主要是為了縮小更新範圍與提升可維護性。如果拆得太碎,也可能增加 props 傳遞、組件層級與理解成本。
比較合理的做法是按「更新頻率、資料依賴、業務邊界」拆分,而不是為了性能把每一行都拆成組件。
Q5:什麼時候需要事件代理?
當列表很大、每列有很多可點擊元素,且事件綁定數量明顯變多時,可以考慮事件代理。
如果只是幾十筆資料,直接在子元素上綁事件通常更清楚,沒必要為了微小收益犧牲可讀性。
八、 實作題
題目:一個 Vue 表格有 10000 筆資料,滾動和勾選都很卡,你怎麼優化?
可以這樣回答:
- 先用 Performance 確認卡在 JS、Layout 還是 Paint。
- 檢查是否一次渲染 10000 筆 DOM。如果是,優先改成虛擬列表或分頁。
- 確認每列是否有穩定 key,不使用 index key。
- 檢查勾選狀態是否放在過高父層,導致整張表都更新。
- 將每列拆成獨立組件,讓更新集中在被勾選的 row。
- 如果是 Vue 3,可以評估在 row 上使用
v-memo。 - 檢查每列是否有重型 computed、watch 或格式化函數。
- 大量按鈕事件可以視情況改成事件代理。
- 如果表格套件會測量 DOM,檢查是否造成強制同步佈局。
- 優化後再次量測 long task、FPS、更新耗時與 DOM 數量。
這題的關鍵答案是:先減少真實 DOM 數量,再縮小更新範圍,最後才做細節微調。
九、 資深視角
1. 渲染優化要先抓大頭
優先級通常是:
- 是否一次渲染太多 DOM。
- 是否有穩定 key。
- 是否父層更新牽動過大子樹。
- 是否有大量重複計算。
- 是否有 DOM 讀寫交錯。
- 是否真的需要
v-memo、v-once、手寫 render function。
很多時候,改成虛擬列表的收益遠大於在模板上加很多細碎優化。
2. 不要犧牲可維護性換小幅性能
例如:
- 到處加
v-memo,但依賴陣列很難維護。 - 過度事件代理,讓事件來源難追蹤。
- 為了少一次 render,把狀態拆到很難理解。
- 過早手寫 render function。
資深工程師要能判斷:這個頁面是否真的值得增加這些複雜度。
3. 渲染性能最後會回到資料結構
如果後端資料沒有穩定 id、列表狀態和資料混在一起、父層每次都重新建立整包 props,前端渲染就很難優化。
所以渲染性能不只是 Vue API 問題,也包含:
- 資料是否有穩定身份。
- 狀態是否能局部更新。
- 列表是否可以分頁或窗口化。
- UI 是否真的需要同時顯示所有內容。
- 第三方套件是否支援局部更新。
真正成熟的回答,是從「少操作 DOM」走到「重新設計資料與渲染策略」。
總結
| 問題 | 回答重點 |
|---|---|
| 渲染慢怎麼排查 | 用 Performance / Vue Devtools 看 long task、組件更新、Layout/Paint |
| 怎麼減少更新 | 拆分組件、狀態下沉、computed 快取、v-once、v-memo |
| 列表怎麼優化 | 穩定 key、先過濾再渲染、虛擬列表、分頁、降低 row 複雜度 |
| DOM 成本怎麼控 | 減少節點、批次讀寫、動畫用 transform/opacity |
| 資深取捨 | 先抓大頭,避免為了小幅性能增加大量複雜度 |
一句完整的面試回答可以是:
Vue 渲染性能優化我會先量測,確認卡在 JS render、Diff、DOM patch 還是瀏覽器 Layout/Paint。具體上會先看是否一次渲染太多 DOM,大列表優先用分頁或虛擬列表;列表更新要有穩定 key,避免 index key;再看父層狀態是否牽動太大子樹,可以透過拆分組件、狀態下沉、computed 快取縮小更新範圍。對靜態區塊可以用
v-once,大型列表少數狀態變化可評估v-memo。如果有直接 DOM 操作或第三方套件,要避免讀寫交錯造成強制同步佈局。最後會重新量測更新耗時與 long task,確認優化真的有效。