🌐Vue 3 表格效能優化實戰:用 throttle、watcher 去重與 CSS Sticky 解決頁面卡住
- 取得連結
- X
- 以電子郵件傳送
- 其他應用程式
摘要
這篇文章用「軟體工程師白話講解」的方式,帶你一步一步理解一個真實案例:我們有個用 Vue 3 + Pinia 寫的資料表頁面,資料量一大就會「卡住」。為了維持所有原本功能(版本切換、下拉篩選、搜尋、分組底色、匯出 Excel、假水平滾動條、Sticky 表頭),我們做了幾個關鍵調整:
-
只註冊一次跨頁共用的篩選
watcher, -
用 throttle(節流)控制高頻操作(捲動、套用 sticky、視窗縮放),
-
把初始化(計算唯一值/預設篩選)跟 watcher 拆開,避免重複綁定。
結果:功能不變、體驗滑順、頁面不再卡。
目錄
-
問題背景:為什麼頁面會卡?
-
問題根因:重複的 watcher + 高頻率 DOM 操作
-
解法總覽(工程師視角)
-
關鍵修改一:只註冊一次 Pinia 共用篩選 watcher
-
關鍵修改二:初始化流程去副作用(
initUniqueAndFilters簡化) -
關鍵修改三:用 throttle 控制
applySticky、捲動同步與resize -
Sticky 表頭建議:能用 CSS 就別用 JS
-
完整流程(從載入版本到顯示表格)
-
常見坑位與排查清單
-
迷你 FAQ
-
SEO 附加:建議的 Meta Title/Description 與結構化資料
1) 問題背景:為什麼頁面會卡?
頁面是典型的資料表工具:
-
從後端拉不同「計劃版本」的資料
-
支援多欄位篩選(且有共用篩選條件要同步到 Pinia)
-
可搜尋、分組交替底色
-
可匯出 Excel
-
有假水平滾動條
-
表頭/前幾欄 sticky 固定
當資料量與互動事件(捲動、搜尋、篩選)同時上來,頁面會卡。
2) 問題根因:重複的 watcher + 高頻率 DOM 操作
-
在初始化函式裡重複註冊到 Pinia 的
watcher,載入版本、切換版本、回復快取都會「再綁一次」,導致 N 倍重覆觸發。 -
scroll、resize、套 sticky這類行為每次都做昂貴計算或 DOM 操作,沒有節流或防抖,在大量資料時特別吃力。
3) 解法總覽(工程師視角)
-
watcher 去重:共用篩選 watcher 只在
setup()最上層綁一次。 -
初始化去副作用:
initUniqueAndFilters()僅計算唯一值 & 預設 filters,不再偷偷註冊任何 watcher。 -
節流:將
applySticky、scroll 同步、resize 事件都包throttle。 -
優先 CSS:Sticky 表頭盡量交給 CSS(
position: sticky),JS 只做必要輔助。 -
換版/回存:清空舊 filters,依新資料重建 unique values/filters,最後再套 sticky。
4) 關鍵修改一:只註冊一次 Pinia 共用篩選 watcher
目的:避免每次切版或重新初始化又重綁 watcher。
// 只在 setup 綁一次
const commonFilterKeys = ["SITE","APPL","CUSTOMER_NAME","SEQ_NO","APS_PLAN_NO"]
commonFilterKeys.forEach((key) => {
// 方向A:Pinia -> 本頁 filters
watch(
() => store.globalCommonFilters[key],
(nv) => {
if (JSON.stringify(filters.value[key]) !== JSON.stringify(nv)) {
filters.value[key] = [...nv]
}
},
{ deep: true }
)
// 方向B:本頁 filters -> Pinia
watch(
() => filters.value[key],
(nv) => {
if (JSON.stringify(store.globalCommonFilters[key]) !== JSON.stringify(nv)) {
store.setGlobalCommonFilter(key, nv)
}
},
{ deep: true }
)
})
重點:這段放在
setup
最上層,不要放在
initUniqueAndFilters()
或
fetchPlanData()
裡面。
5) 關鍵修改二:初始化流程去副作用(initUniqueAndFilters
簡化)
目的:只負責「算唯一值」與「給預設 filters」,不做其他隱性動作。
function initUniqueAndFilters() {
// 1) 計算唯一值
tableHeaders.value.forEach((hdr) => {
uniqueValues.value[hdr] = [...new Set(mappedTableData.value.map(r => r[hdr]))]
})
// 2) 若尚未初始化 filters -> 預設全選;共用欄位同步到 Pinia
if (!Object.keys(filters.value).length) {
tableHeaders.value.forEach((hdr) => {
filters.value[hdr] = commonFilterKeys.includes(hdr)
? (store.globalCommonFilters[hdr].length
? [...store.globalCommonFilters[hdr]]
: [...uniqueValues.value[hdr]]
)
: [...uniqueValues.value[hdr]]
if (commonFilterKeys.includes(hdr)) {
store.setGlobalCommonFilter(hdr, filters.value[hdr])
}
})
}
}
6) 關鍵修改三:用
throttle 控制
applySticky、捲動同步與
resize
目的:避免在高頻事件中大量重複計算。
function throttle(fn, wait = 100) {
let inThrottle = false
return (...args) => {
if (!inThrottle) {
fn(...args)
inThrottle = true
setTimeout(() => (inThrottle = false), wait)
}
}
}
const syncScrollTable = throttle(() => { /* 同步底部滾動 */ }, 50)
const syncScrollBottom = throttle(() => { /* 同步表格滾動 */ }, 50)
const stickyThrottled = throttle(applySticky, 200)
window.addEventListener('resize', throttle(applySticky, 300))
watch(searchTerm, throttle(applySticky, 300))
watch(filteredData, throttle(applySticky, 300))
7) Sticky 表頭建議:能用 CSS 就別用 JS
最便宜的方式是直接在 th 用:
thead th {
position: sticky;
top: 0;
z-index: 10;
}
如果要多欄同時 sticky(像左側欄位固定),JS 只做「計算左偏移量」那一小段,其餘交給瀏覽器布局:
async function applySticky() {
await nextTick()
if (!tableRef.value) return
const ths = tableRef.value.querySelectorAll('thead th')
let offset = 0
ths.forEach((th) => {
th.style.position = 'sticky'
th.style.left = `${offset}px`
th.style.zIndex = '10'
offset += th.offsetWidth
})
}
8) 完整流程(從載入版本到顯示表格)
-
載入版本清單 → 使用者選擇版本
-
拉取資料 → 轉大寫 Key → 依
SEQ_NO → APS_PLAN_NO排序 -
設定
tableHeaders→ 清空舊filters -
呼叫
initUniqueAndFilters()→ 計算唯一值、預設全選 -
nextTick()後 套用 sticky(已節流) -
綁定事件:scroll 同步(節流)、window resize(節流)
-
搜尋/篩選變動 → 重新貼 sticky(節流)
-
存到 Pinia:
store.setPageData(...)以利返回頁面時快速回復
9) 常見坑位與排查清單
-
卡頓來自重複 watcher:檢查
init/fetch是否會「再綁一次」。 -
高頻事件未節流:
scroll、resize、sticky都需要throttle。 -
初始化做太多事:
initUniqueAndFilters只做「唯一值/預設值」,不要再綁 watcher。 -
切換版本忘記清
filters:導致新資料和舊篩選不一致,後續計算額外增加。 -
大量 DOM 重排:能靠 CSS 的 sticky 就別用 JS 逐格設定樣式。
-
搜尋過於暴力:
Object.values(row).join(' ')簡單但花成本,資料更大時可考慮加入欄位白名單或前置索引。
10) 迷你 FAQ
Q1:為什麼不用 debounce 而用 throttle?
A:scroll/resize
這類事件希望「持續可回饋」,throttle
會固定間隔執行一次,比起
debounce(結束才觸發)更順手。
Q2:資料很多時還是卡,怎麼辦?
A:考慮虛擬清單(virtualized table)或分頁,根本解法是減少一次渲染的 DOM
節點數。
Q3:為何要「watcher 去重」?
A:每多綁一次
watcher,就多一份「變更時要跑的工作」。切版/返回頁面若不小心重綁,事件風暴跟著來。
11) SEO 附加:Meta 與結構化資料
建議 Meta Title
Vue 3 表格效能優化:Throttle、Watcher 去重與 CSS Sticky,解決頁面卡住完整實戰
建議 Meta Description
真實案例教學:用 Vue 3 + Pinia 實作大型資料表,透過 watcher 去重、節流 throttle 與 CSS sticky,解決捲動卡頓、版本切換慢等問題,功能不變體驗更流暢。
JSON-LD(Article 結構化資料)
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "TechArticle",
"headline": "Vue 3 表格效能優化實戰:用 throttle、watcher 去重與 CSS Sticky 解決頁面卡住",
"description": "用 Vue 3 + Pinia 優化大型表格:watcher 去重、事件節流、CSS sticky,保持功能完整並消除卡頓。",
"author": { "@type": "Person", "name": "軟體工程師的白話筆記" },
"keywords": "Vue 3, Pinia, 效能優化, throttle, debounce, sticky, 表格, 前端",
"inLanguage": "zh-Hant"
}
</script>
關鍵片段(可直接複製使用)
1) watcher 只綁一次
commonFilterKeys.forEach((key) => {
watch(() => store.globalCommonFilters[key], (nv) => {
if (JSON.stringify(filters.value[key]) !== JSON.stringify(nv)) {
filters.value[key] = [...nv]
}
}, { deep: true })
watch(() => filters.value[key], (nv) => {
if (JSON.stringify(store.globalCommonFilters[key]) !== JSON.stringify(nv)) {
store.setGlobalCommonFilter(key, nv)
}
}, { deep: true })
})
2) 初始化不含 watcher
function initUniqueAndFilters() {
tableHeaders.value.forEach((hdr) => {
uniqueValues.value[hdr] = [...new Set(mappedTableData.value.map(r => r[hdr]))]
})
if (!Object.keys(filters.value).length) {
tableHeaders.value.forEach((hdr) => {
filters.value[hdr] = commonFilterKeys.includes(hdr)
? (store.globalCommonFilters[hdr].length
? [...store.globalCommonFilters[hdr]]
: [...uniqueValues.value[hdr]])
: [...uniqueValues.value[hdr]]
if (commonFilterKeys.includes(hdr)) {
store.setGlobalCommonFilter(hdr, filters.value[hdr])
}
})
}
}
3) 節流高頻事件
const syncScrollTable = throttle(() => {
if (bottomScroll.value && tableContainer.value) {
bottomScroll.value.scrollLeft = tableContainer.value.scrollLeft
}
}, 50)
const syncScrollBottom = throttle(() => {
if (bottomScroll.value && tableContainer.value) {
tableContainer.value.scrollLeft = bottomScroll.value.scrollLeft
}
}, 50)
const stickyThrottled = throttle(applySticky, 200)
window.addEventListener('resize', throttle(applySticky, 300))
結語
效能優化不必然是大動作重寫。這次案例中,我們維持所有功能不變,只做了三件事:watcher 去重、事件節流、初始化去副作用,就能把「卡住」變成「順暢」。如果你也有類似的 Vue 表格頁面,照著本文的步驟檢查,往往就能快速見效。
留言
張貼留言