🌐Vue 3 表格效能優化實戰:用 throttle、watcher 去重與 CSS Sticky 解決頁面卡住

 

摘要

這篇文章用「軟體工程師白話講解」的方式,帶你一步一步理解一個真實案例:我們有個用 Vue 3 + Pinia 寫的資料表頁面,資料量一大就會「卡住」。為了維持所有原本功能(版本切換、下拉篩選、搜尋、分組底色、匯出 Excel、假水平滾動條、Sticky 表頭),我們做了幾個關鍵調整:

  1. 只註冊一次跨頁共用的篩選 watcher

  2. throttle(節流)控制高頻操作(捲動、套用 sticky、視窗縮放),

  3. 把初始化(計算唯一值/預設篩選)跟 watcher 拆開,避免重複綁定。
    結果:功能不變、體驗滑順、頁面不再卡。




目錄

  1. 問題背景:為什麼頁面會卡?

  2. 問題根因:重複的 watcher + 高頻率 DOM 操作

  3. 解法總覽(工程師視角)

  4. 關鍵修改一:只註冊一次 Pinia 共用篩選 watcher

  5. 關鍵修改二:初始化流程去副作用(initUniqueAndFilters 簡化)

  6. 關鍵修改三:用 throttle 控制 applySticky、捲動同步與 resize

  7. Sticky 表頭建議:能用 CSS 就別用 JS

  8. 完整流程(從載入版本到顯示表格)

  9. 常見坑位與排查清單

  10. 迷你 FAQ

  11. SEO 附加:建議的 Meta Title/Description 與結構化資料




1) 問題背景:為什麼頁面會卡?

頁面是典型的資料表工具:

  • 從後端拉不同「計劃版本」的資料

  • 支援多欄位篩選(且有共用篩選條件要同步到 Pinia)

  • 可搜尋、分組交替底色

  • 可匯出 Excel

  • 有假水平滾動條

  • 表頭/前幾欄 sticky 固定

當資料量與互動事件(捲動、搜尋、篩選)同時上來,頁面會卡。




2) 問題根因:重複的 watcher + 高頻率 DOM 操作

  • 在初始化函式裡重複註冊到 Pinia 的 watcher,載入版本、切換版本、回復快取都會「再綁一次」,導致 N 倍重覆觸發

  • scrollresize套 sticky 這類行為每次都做昂貴計算或 DOM 操作,沒有節流或防抖,在大量資料時特別吃力。




3) 解法總覽(工程師視角)

  1. watcher 去重:共用篩選 watcher 只在 setup() 最上層綁一次。

  2. 初始化去副作用initUniqueAndFilters() 僅計算唯一值 & 預設 filters,不再偷偷註冊任何 watcher。

  3. 節流:將 applyStickyscroll 同步resize 事件都包 throttle

  4. 優先 CSS:Sticky 表頭盡量交給 CSS(position: sticky),JS 只做必要輔助。

  5. 換版/回存:清空舊 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) 完整流程(從載入版本到顯示表格)

  1. 載入版本清單 → 使用者選擇版本

  2. 拉取資料 → 轉大寫 Key → 依 SEQ_NO → APS_PLAN_NO 排序

  3. 設定 tableHeaders清空舊 filters

  4. 呼叫 initUniqueAndFilters() → 計算唯一值、預設全選

  5. nextTick()套用 sticky(已節流)

  6. 綁定事件:scroll 同步(節流)、window resize(節流)

  7. 搜尋/篩選變動 → 重新貼 sticky(節流)

  8. 存到 Piniastore.setPageData(...) 以利返回頁面時快速回復


9) 常見坑位與排查清單

  • 卡頓來自重複 watcher:檢查 init/fetch 是否會「再綁一次」。

  • 高頻事件未節流scrollresizesticky 都需要 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 表格頁面,照著本文的步驟檢查,往往就能快速見效。

留言

這個網誌中的熱門文章

🔍Vue.js 專案錯誤排查:解決 numericFields is not defined 與合併儲存格邏輯最佳化

🔎EF Core 連 Oracle 出現 ORA-00600 [kpp_concatq:2] 的完整排錯指南(含 EF Core ToString/CultureInfo 錯誤)

🛠【ASP.NET Core + Oracle】解決 ORA-00904 "FALSE": 無效的 ID 錯誤與資料欄位動態插入顯示問題