Vue 響應式性能優化
Vue 的響應式系統讓資料變化可以自動驅動畫面更新,但響應式不是免費的。資料越大、層級越深、依賴越多、更新越頻繁,依賴收集與觸發更新的成本就越容易變成性能瓶頸。
一句話回答:Vue 響應式性能優化的核心,是不要把所有資料都做成深層響應式,也不要讓一次狀態變化牽動過大的依賴範圍;常見手段包含使用 shallowRef、shallowReactive、markRaw,避免不必要的 deep watch,合理區分 computed 與 watch,拆分狀態,並讓大型資料走分頁、索引或後端查詢。
一、 必考觀念
1. 響應式成本來自哪裡?
Vue 的響應式大致可以拆成兩件事:
讀取資料:收集依賴
修改資料:觸發依賴更新以 Vue 3 的 Proxy 思路簡化理解:
const state = reactive({
count: 0,
})
effect(() => {
console.log(state.count)
})
state.count++當 effect 裡讀取 state.count,Vue 會記錄「這段副作用依賴 count」。當 state.count 被修改,Vue 就能找到相關 effect 並安排更新。
這套機制很好用,但成本會出現在:
- 深層物件需要被代理。
- 大量屬性被讀取時會收集大量依賴。
- 大量 effect、computed、watch 同時依賴同一份資料。
- 一次更新觸發過大的組件子樹。
- deep watch 需要遍歷整個物件。
2. Vue 2 和 Vue 3 的差異
Vue 2 主要使用 Object.defineProperty 攔截物件屬性的 getter / setter。
限制是:
- 初始化時要走訪物件屬性。
- 新增屬性、刪除屬性需要特別處理。
- 陣列變更需要攔截部分方法。
Vue 3 改用 Proxy,可以攔截更多操作:
- 新增屬性。
- 刪除屬性。
in。ownKeys。- Map / Set 等集合型資料。
但這不代表 Vue 3 沒有成本。大型深層資料只要被大量讀取、追蹤或遍歷,仍然會有性能壓力。
面試時可以這樣說:
Vue 3 的 Proxy 讓響應式能力更完整,也避免 Vue 2 新增屬性不響應等限制;但響應式追蹤本身仍有成本,尤其是大型深層物件、deep watch、頻繁更新的大列表,都需要控制依賴範圍。
3. 響應式性能和渲染性能是連在一起的
響應式資料變了,最後通常會導致組件更新。
資料變化
-> trigger effect
-> 組件重新 render
-> VNode Diff
-> DOM patch所以頁面卡頓時,不一定是 DOM 太多,也可能是響應式資料設計太粗。
例如:
- 整個頁面共用一個巨大 reactive store。
- 任意欄位變化都讓很多組件依賴被觸發。
- 每個 row 都 watch 同一個大物件。
- deep watch 一份幾萬筆列表。
- computed 裡每次都 sort/filter 巨大資料。
延伸讀:Vue 渲染性能優化。
二、 不要把所有資料都深層 reactive
1. 深層 reactive 適合會被細粒度更新的資料
適合放進 reactive 的資料:
- 表單狀態。
- UI 狀態。
- 小型業務物件。
- 需要追蹤多個欄位變化的資料。
例如:
const form = reactive({
name: '',
email: '',
role: 'user',
})這種資料量小、欄位會被 UI 細粒度綁定,用深層 reactive 很合理。
2. 大型不可變資料可以用 shallowRef
如果資料很大,但通常是整包替換,而不是深層修改,可以用 shallowRef。
import { shallowRef } from 'vue'
const users = shallowRef<User[]>([])
async function loadUsers() {
users.value = await fetchUsers()
}shallowRef 只追蹤 .value 這一層。
也就是:
users.value = nextUsers // 會觸發更新
users.value[0].name = 'Alice' // 不會因深層欄位變化自動觸發這適合:
- 大型列表。
- 搜尋結果。
- 後端回來的不可變資料。
- 只需要整包替換的資料。
3. 淺層資料物件可以用 shallowReactive
shallowReactive 只讓第一層屬性響應式,巢狀物件不會被深層轉換。
import { shallowReactive } from 'vue'
const state = shallowReactive({
loading: false,
options: largeOptionsObject,
})當你只關心 state.loading、state.options 這種第一層引用變化,而不需要追蹤 options 裡每個欄位,shallowReactive 會比深層 reactive 更克制。
4. 第三方實例用 markRaw
圖表、地圖、編輯器、表格套件通常會建立自己的 class instance。
這類物件通常不需要 Vue 代理:
import { markRaw, shallowRef } from 'vue'
const chart = shallowRef<Chart | null>(null)
function initChart(el: HTMLElement) {
chart.value = markRaw(createChart(el))
}適合 markRaw 的東西:
- ECharts instance。
- Map instance。
- Editor instance。
- WebGL / canvas 相關物件。
- 很大的靜態設定物件。
重點是:不要讓 Vue 追蹤它不需要追蹤的東西。
三、 避免不必要的 deep watch
1. deep watch 的成本
deep: true 會讓 Vue 深度追蹤物件內部變化。
watch(
form,
() => {
saveDraft(form)
},
{ deep: true }
)如果 form 很小,這沒有太大問題。
但如果你 deep watch 一個巨大列表:
watch(
users,
() => {
syncUsers(users.value)
},
{ deep: true }
)資料越大,遍歷與依賴追蹤成本越高;任何深層欄位變化都可能觸發 watcher,後續副作用也容易變重。
2. 優先監聽必要欄位
與其 deep watch 整個物件,不如監聽真正需要的欄位。
watch(
() => form.email,
email => {
validateEmail(email)
}
)監聽多個欄位:
watch(
() => [form.name, form.email],
([name, email]) => {
validateProfile(name, email)
}
)這樣依賴範圍更小,意圖也更清楚。
3. 大型資料改用版本號或事件
如果你只需要知道「整份資料換了」,不需要追蹤每個欄位變化,可以用版本號。
const users = shallowRef<User[]>([])
const usersVersion = ref(0)
function replaceUsers(nextUsers: User[]) {
users.value = nextUsers
usersVersion.value++
}
watch(usersVersion, () => {
rebuildUserIndex(users.value)
})這比 deep watch 整份大型資料更可控。
4. watch 的副作用要能取消
watch 常用來處理非同步副作用,例如請求。
watch(
() => keyword.value,
async (keyword, _, onCleanup) => {
const controller = new AbortController()
onCleanup(() => {
controller.abort()
})
results.value = await search(keyword, {
signal: controller.signal,
})
}
)這不是單純性能問題,也能避免舊請求晚回來覆蓋新資料。
延伸讀:watch 與 computed 的區別。
四、 computed、watch、watchEffect 的選擇
1. computed 適合衍生資料
computed 有快取。依賴不變時,多次讀取會回傳快取結果。
const activeUsers = computed(() => {
return users.value.filter(user => user.active)
})適合:
- 從既有狀態推導資料。
- 模板需要讀取的結果。
- 過濾、格式化、統計等純計算。
但 computed 也不是免費的。如果依賴是五萬筆資料,每次 users 換整包都會重新計算。
2. watch 適合副作用
watch 適合「資料變了,要做某件事」。
例如:
- 發請求。
- 寫入 localStorage。
- 上報 analytics。
- 操作第三方套件。
- 與 router 或外部狀態同步。
watch(
() => route.query.page,
page => {
loadPage(Number(page || 1))
}
)如果只是要得到一個衍生值,不要用 watch 把結果塞到另一個 ref,computed 更自然。
3. watchEffect 適合依賴簡單的副作用
watchEffect 會自動收集同步執行過程中讀到的依賴。
watchEffect(() => {
document.title = `${unreadCount.value} unread`
})它適合依賴少、邏輯短的副作用。
如果邏輯很長,或你希望精準控制依賴來源,用 watch 會更清楚。
4. 避免用 watch 製造狀態鏈
不好的做法:
watch(price, value => {
tax.value = value * 0.05
})
watch(tax, value => {
total.value = price.value + value
})這種狀態鏈容易變得難追蹤,也可能觸發多次更新。
更好的做法:
const tax = computed(() => price.value * 0.05)
const total = computed(() => price.value + tax.value)衍生資料用 computed,副作用才用 watch。
五、 狀態拆分與更新頻率
1. 高頻狀態和低頻狀態分開
例如一個後台頁面有:
- 搜尋輸入。
- 表格資料。
- 勾選狀態。
- 統計圖表。
- 權限資訊。
- 使用者偏好設定。
不要全部塞在同一個大物件裡,讓每次輸入都牽動一整包狀態。
比較好的做法是按更新頻率拆分:
const keyword = ref('')
const selectedIds = ref(new Set<string>())
const tableRows = shallowRef<Row[]>([])
const userPreferences = reactive({
pageSize: 20,
density: 'compact',
})高頻變化的狀態要小,低頻大資料可以淺層或整包替換。
2. Store 不要變成全域大物件
Pinia 或 Vuex 很方便,但不要把所有東西都放進同一個 store。
問題可能是:
- 很多頁面依賴同一個巨大 store。
- 任意欄位變化讓大量 computed 重新評估。
- store 裡同時混合列表、表單、UI、快取與權限。
比較好的拆法:
- 按 domain 拆 store。
- 高頻 UI 狀態留在局部組件。
- 大型查詢結果可以用淺層 ref 或快取層。
- 不要讓全站都依賴同一個大 getter。
3. Props 要保持穩定
如果父組件每次 render 都建立新物件,子組件就更容易被牽動。
不理想:
<UserRow
v-for="user in users"
:key="user.id"
:options="{ compact: true, editable: canEdit }"
:user="user"
/>每次 render 都會建立新的 options 物件。
可以改成 computed 或常量:
const rowOptions = computed(() => ({
compact: true,
editable: canEdit.value,
}))<UserRow
v-for="user in users"
:key="user.id"
:options="rowOptions"
:user="user"
/>這不是所有情況都必要,但在大型列表或重型子組件中值得注意。
六、 大型資料處理
1. 不要每次 render 都 filter / sort / map 大資料
不建議:
<UserRow
v-for="user in users.filter(user => user.active).sort(sortByName)"
:key="user.id"
:user="user"
/>每次 render 都可能重新過濾和排序。
更好的做法是放進 computed:
const activeUsers = computed(() => {
return users.value
.filter(user => user.active)
.toSorted(sortByName)
})但如果資料非常大,computed 也可能很重。這時要考慮:
- 後端分頁與排序。
- 建立索引。
- Web Worker。
- 搜尋條件防抖。
- 分批處理。
- 虛擬列表只渲染可視區域。
2. 搜尋輸入要防抖
使用者每打一個字就對五萬筆資料 filter,會很容易卡。
const keyword = ref('')
const debouncedKeyword = ref('')
let timer: ReturnType<typeof setTimeout> | null = null
watch(keyword, value => {
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
debouncedKeyword.value = value
}, 250)
})再用 debouncedKeyword 去觸發搜尋或 computed。
真正大型的資料搜尋,通常應該交給後端或索引,而不是每次在前端全量掃描。
3. 大型資料和渲染策略要一起看
響應式優化只能減少資料追蹤成本,但如果畫面仍然一次渲染大量 DOM,還是會卡。
所以大型列表通常要同時處理:
- 資料層:後端分頁、索引、快取、淺層 ref。
- 計算層:computed、memo、worker、防抖。
- 渲染層:虛擬列表、分頁、降低 row 複雜度。
延伸讀:Vue 渲染性能優化。
七、 追問題庫
Q1:Vue 3 用 Proxy 了,還需要關心響應式性能嗎?
需要。
Proxy 解決了 Vue 2 的很多限制,但依賴收集、觸發更新、深層資料讀取、deep watch、大型 computed 仍然有成本。Vue 3 更強,但不是無成本。
Q2:什麼情況用 shallowRef?
當資料很大,但你只關心整體引用變化,不需要追蹤內部每個欄位時。
例如大型列表、後端查詢結果、不可變資料、第三方資料模型。更新時用整包替換,避免深層響應式追蹤。
Q3:什麼情況用 markRaw?
當某個物件不需要 Vue 代理,甚至被代理可能有副作用時。
常見是第三方 class instance,例如圖表、地圖、編輯器、播放器、WebGL 物件。這些物件通常由套件自己管理狀態,Vue 只需要保存引用。
Q4:deep watch 為什麼可能很貴?
因為 deep watch 需要深度遍歷物件來追蹤內部依賴。資料越大、層級越深、變化越頻繁,成本越高。
更好的方式是監聽必要欄位,或用版本號、事件、整包替換來表達資料變化。
Q5:computed 一定比 method 快嗎?
不一定。
computed 的優勢是依賴不變時有快取。如果每次依賴都會變,或 computed 本身處理超大型資料,它仍然會重新計算。
method 每次 render 都會執行;computed 依賴不變會復用快取。要看使用場景。
八、 實作題
題目:一個頁面載入 5 萬筆資料後,任何操作都很卡,你怎麼排查?
可以這樣回答:
- 先用 Performance 看卡在 JS、render、Layout 還是資料處理。
- 檢查 5 萬筆資料是否被放進深層
reactive。 - 如果資料主要整包替換,改用
shallowRef保存列表。 - 檢查是否有 deep watch 監聽整份列表。
- 檢查 computed 是否每次對 5 萬筆資料 filter / sort / map。
- 搜尋、篩選、排序是否能交給後端或建立索引。
- 高頻輸入加防抖,避免每個字都全量計算。
- 檢查畫面是否一次渲染 5 萬筆 DOM,必要時用虛擬列表或分頁。
- 檢查 store 是否過大,任意欄位變化是否觸發大量組件更新。
- 優化後重新量測 long task、更新耗時、記憶體與渲染節點數。
重點是:這不是單一 API 能解決的問題,要同時處理響應式追蹤、資料計算與渲染策略。
九、 資深視角
1. 響應式資料要有邊界感
不是所有資料都應該交給 Vue 深層追蹤。
可以問自己:
- 這份資料需要欄位級響應嗎?
- 還是只需要知道整份資料換了?
- 是否會被很多組件讀取?
- 是否會頻繁更新?
- 是否是第三方實例或外部狀態?
如果答案是「只需要保存引用」,就不一定要深層 reactive。
2. 大型資料優化通常要改資料流
如果前端拿 5 萬筆資料,然後每次輸入都在本地 filter/sort,再一次渲染出來,問題不在 Vue,而在資料流設計。
更成熟的方案可能是:
- 後端分頁。
- 後端搜尋與排序。
- 前端建立索引。
- Web Worker 處理重計算。
- 虛擬列表降低 DOM。
- 用快取避免重複請求。
Vue API 只能幫你降低部分成本,不能替代資料策略。
3. 優化要保留可維護性
shallowRef、markRaw、版本號、手動觸發更新都會讓資料流更偏手動。
這些工具有價值,但也增加心智負擔:
- 深層修改不會自動更新。
- 團隊需要知道何時整包替換。
- 狀態更新規則要更清楚。
- 測試要覆蓋資料更新路徑。
資深回答要能說出:我不是為了用工具而用工具,而是在資料量和更新頻率真的需要時才用。
總結
| 問題 | 回答重點 |
|---|---|
| 響應式成本來自哪裡 | 依賴收集、觸發更新、深層代理、deep watch、大型 computed |
| 大型資料怎麼存 | 不需要深層追蹤時用 shallowRef / shallowReactive |
| 第三方實例怎麼處理 | 用 markRaw 避免 Vue 代理 |
| deep watch 怎麼替代 | 監聽必要欄位、版本號、事件、整包替換 |
| computed / watch 怎麼選 | 衍生資料用 computed,副作用用 watch |
| 資深取捨 | 控制依賴範圍,同時注意資料流與可維護性 |
一句完整的面試回答可以是:
Vue 響應式性能優化我會先看資料是否真的需要深層響應式。小型表單或 UI 狀態用 reactive 很合理,但大型列表、後端查詢結果、不可變資料可以用
shallowRef或shallowReactive,第三方實例用markRaw避免代理。接著會檢查是否有 deep watch、大型 computed、模板中重複 filter/sort/map,以及 store 是否過大導致依賴範圍太廣。衍生資料用 computed,副作用用 watch,高頻輸入加防抖,大型資料交給後端分頁、索引或 Web Worker。最後還要搭配虛擬列表或分頁,因為響應式優化不能解決一次渲染大量 DOM 的問題。