後端工程師救一個慢到爆的後台列表頁:從 8.3 秒到 1.4 秒
Web Development·2026年3月25日·9 分鐘閱讀

後端工程師救一個慢到爆的後台列表頁:從 8.3 秒到 1.4 秒

我是寫 Go 出身的後端,前端是後來被推上去的——團隊那陣子缺人,一個 Vue 寫的營運後台沒人顧,我就接了。接手第一週就被一個頁面震撼到:那是後台最常用的「訂單管理列表」,營運每天開幾百次,而它從點進去到能操作,要等 8 秒以上。

我用 Chrome DevTools 的 Performance 面板實際錄了三次,取中位數:首次有意義繪製(FCP)落在 3.1 秒,可互動時間(TTI)是 8.3 秒。也就是說營運點進去之後,畫面上看起來有東西了,但他想點任何按鈕都點不動,要再等五秒。這篇就講我怎麼一層一層把這 8.3 秒拆開、找出真正的兇手、最後壓到 1.4 秒。沒有箴言,只有我那兩週實際做的事。

TTI 前後對比:優化前 8.3 秒(bundle 4.1s + API 串行 2.7s + 渲染 1.5s),優化後 1.4 秒
TTI 前後對比:優化前 8.3 秒(bundle 4.1s + API 串行 2.7s + 渲染 1.5s),優化後 1.4 秒

先講結論:三個兇手,沒有一個是「框架重繪」

後端工程師救一個慢到爆的後台列表頁:從 8.3 秒到 1.4 秒:本文架構

我接手前,前一手留下的 commit 訊息寫著「優化 computed 快取、加 memo」。我看了一下,他花了不少力氣在元件渲染上,但 TTI 完全沒動。原因很簡單:這個頁面慢,根本不是渲染慢。

我把 8.3 秒拆成三段,每一段都有一個明確的兇手:

  • 一張沒壓縮的去背 PNG banner,單張 2.4 MB,卡住首屏
  • 一支把整個後台所有頁面打包在一起的肥 bundle,主檔 JS 壓縮後 1.8 MB
  • 一個列表 API 一次回 3000 多筆、每筆還塞了完整的巢狀關聯資料,光是 JSON 解析加渲染就把主執行緒鎖死好幾秒

你會發現,三個裡面有兩個是「資料」問題,不是「前端寫法」問題。這也是我後來最想講的一件事:很多前端慢,根子在 API 跟資料量,不在元件。

兇手一:那張 2.4 MB 的 banner

我從 Network 面板按傳輸大小排序,第一個跳出來的就是它——後台頂部有一張行銷活動 banner,設計師給的是去背 PNG,原始尺寸 2400 寬,檔案 2.4 MB。它擋在首屏,瀏覽器要等它下載解碼才算「載入完成」。

這個最好修。我做了三件事:

  • 改格式:去背其實用 WebP 就好,視覺幾乎沒差,2.4 MB 直接掉到 180 KB
  • 改尺寸:實際顯示寬度只有 728,我請設計師出 2 倍圖(1456 寬)就夠 Retina 用,沒必要 2400
  • 加寬高與延遲載入:我給這張圖明確的寬高避免版面位移(CLS),並讓真正的主角——訂單列表先渲染

光這一張圖,FCP 就從 3.1 秒掉到 1.9 秒。這是整個優化裡 CP 值最高的一步——改一張圖的格式,半天不到,省一秒多。

順帶一提,我後來在這個專案統一加了一條 CI 檢查:任何進到 repo 的圖片超過 300 KB 就讓 build 紅燈。因為我很清楚,這種事修一次沒用,下個月設計師又會塞一張 3 MB 的進來。優化要能擋住回退才算數。

兇手二:1.8 MB 的肥 bundle

FCP 解決了,但 TTI 還是六秒多。我去看 Coverage 面板——這個工具會告訴你載入的 JS 有多少在當前頁面根本沒被執行。結果很難看:首頁載進來的 JS,有將近 70% 是沒用到的。

原因是整個後台是一個傳統的 SPA,所有路由的元件全部靜態 import 進同一包。訂單頁、報表頁、權限設定頁、那一堆圖表函式庫,全部塞在主檔裡。使用者只是想看訂單列表,卻被迫下載整個後台的程式碼。

修法就是路由層級的程式碼分割(route-based code splitting)。Vue Router 裡把同步 import 改成動態 import,每個路由變成一個獨立的 chunk,使用者進哪頁才載哪頁的程式碼。

這裡有個我踩到的取捨。最肥的其實是圖表函式庫(一個很重的 charting library),它只有報表頁用得到,但因為某個共用的 header 元件裡偷偷 import 了它的一個小工具函式,導致它被打包進共用 chunk,怎麼拆都拆不掉。我花了大概一個下午用 bundle analyzer 把這條依賴鏈找出來,把那個工具函式換成一行自己寫的程式,圖表函式庫才終於只留在報表頁。

主檔 JS 從 1.8 MB 壓到 540 KB。TTI 從六秒多掉到 3.5 秒左右。bundle analyzer 那張樹狀圖我建議每個做前端的人都該定期看一次,你常常會發現一些你以為早就沒在用、卻還躺在主包裡的東西。

兇手三:一次回 3000 筆的 API(這才是大魔王)

到這裡 TTI 還有 3.5 秒,而且我發現一件怪事:圖跟 bundle 都修好了,但每次切換篩選條件、列表重新載入時,整個頁面還是會卡死大約兩到三秒,連滑鼠都不動。這不是載入問題,是執行階段的問題。

我又錄了一次 Performance,這次看的是主執行緒。那兩三秒的卡頓,一段是黃色的 Scripting,一段是紫色的 Rendering,兩段都很長。我去看那支列表 API 的回應,瞬間懂了——它一次回傳 3247 筆訂單,而且每一筆訂單物件裡,都還內嵌了完整的客戶資料、完整的商品明細陣列、完整的物流軌跡。一筆訂單的 JSON 就有好幾 KB,整包回應壓縮前超過 20 MB。

這是我用後端視角看前端最直接的一次。前端開發者可能會想「列表卡頓,那來做虛擬列表吧」——虛擬列表確實能解決渲染那段,但解不掉前面那段:瀏覽器光是把 20 MB 的 JSON parse 成物件、跑前端的篩選排序,主執行緒就先鎖死了。虛擬列表治的是渲染,治不了資料量本身。

真正的根因在 API。這支 API 是早年某個前端為了「前端篩選比較快」要求後端一次給全部,後端就真的一次給全部,連分頁都沒做。前端拿到三千筆,在記憶體裡自己 filter、自己 sort、自己分頁。資料少的時候沒事,量一上來就崩。

因為我自己就是後端,這次我直接從兩邊一起改:

  • 後端加上真正的分頁,一次回 50 筆,篩選與排序的條件用 query 參數傳給後端,讓資料庫去做(資料庫本來就有索引,做這個比前端在記憶體裡快太多)
  • 列表 API 的回應瘦身:列表頁根本不需要每筆訂單的完整物流軌跡和商品明細,那是點進詳情頁才要的。我把列表的回應砍到只剩列表會顯示的欄位,單筆從好幾 KB 變成幾百 bytes
  • 前端配合改成伺服器端分頁,並把篩選改成送 API 而不是在前端記憶體裡硬篩

改完,列表 API 的回應從 20 MB 變成大約 40 KB,那個切換篩選會卡死兩三秒的問題直接消失,因為前端再也不用 parse 跟處理那一大包了。

那虛擬列表呢?我最後沒做

講到長清單,很多人第一反應就是虛擬列表(virtual list,只渲染畫面上看得到的那幾筆)。我本來也準備做,但做完伺服器端分頁之後我停下來想了一下:現在一頁只有 50 筆,50 個 DOM 列渲染起來毫無壓力,DevTools 量下來渲染時間個位數毫秒。為了 50 筆引進一套虛擬列表的函式庫,去處理它跟我們現有的可變列高、展開明細、鍵盤導覽的各種相容問題,根本不划算。

這就是我想說的取捨:虛擬列表是給「真的必須一次呈現上千筆」的場景用的,比如一個不能分頁的即時監控面板。我們的場景用分頁解掉了資料量,虛擬列表就變成一個增加複雜度卻沒帶來價值的東西。能用更簡單的架構解決,我就不會為了用某個技巧而用它。

最後的數字,跟我學到的

兩週下來,這個頁面的數字是這樣的:

  • FCP:3.1 秒 → 1.9 秒 → 最終 0.9 秒(圖修完是 1.9,bundle 拆完再往下掉)
  • TTI:8.3 秒 → 1.4 秒
  • 主檔 JS:1.8 MB → 540 KB
  • 列表 API 回應:20 MB → 40 KB

我特別想強調 TTI 那條:從 8.3 秒到 1.4 秒,貢獻最大的不是任何一個前端框架的技巧,是「把 API 改對」。前一手在 memo 跟 computed 上花的力氣不能說沒用,但他選錯了戰場——這個頁面的渲染從來就不是主要瓶頸。

我做這行最大的體會是:前端效能問題,很多時候照鏡子照出來的是後端跟資料設計的問題。一個前端工程師看到列表卡,手上的工具就是虛擬列表、memo、debounce,這些都對,但如果根因是 API 一次吐 20 MB,這些工具頂多把症狀壓住,治不了病。而一個能同時看懂兩端的人,會先問一句「這個 API 為什麼要回這麼多資料」——這一問,往往就省掉前端一半的功夫。所以如果你也是被推上前端的後端,別急著去學一堆前端的效能技巧,先把你最熟的那個視角帶過去:去看 Network 面板裡那支最肥的請求,答案常常就在那裡。

留言討論

有想法、有不同經驗、或想糾正我?歡迎在下面留言,免註冊,填個暱稱就能留。

相關文章