🌐首頁秒開、其餘慢慢來:用「漸進式載入」把大檔影像瀏覽變順暢(含完整範例與最佳化心法)

 TL;DR:別一次把 N × M 張頁面全拉完才顯示。改成「先顯示第一頁」,其餘頁面在背景持續抓,每抓完一張就立即渲染。這樣首屏秒開、操作不卡、使用者體驗直線上升。本文用全新、與任何既有專案無關的程式碼示範,並附上錯誤處理、併發控制、快取與中止載入等實務細節。


為什麼要「先渲染一頁」?

大量影像(或頁面)檢視常見痛點:

  1. 首屏過慢:一次丟出大量請求,使用者在空白畫面乾等。

  2. 瀏覽遲滯:等全部回來再渲染,主執行緒忙到不行。

  3. 邊看邊載更符合人性:多數人只想先看第一頁,其他頁慢慢補上即可。

解法:把載入流程「分段化」

  • 第一步:只要第一頁 → 立刻顯示

  • 第二步:背景依序拉剩下的頁面

  • 第三步:每成功下載一頁就立刻渲染(或更新縮圖、頁碼、進度條)


核心概念(白話)

  • 輸出資料結構pages = [{ index, dataUrl }]

  • 渲染函式renderPage(currentIndex) 只負責把目前的頁面貼到 Viewer

  • 載入策略loadFirstThenStream(docId)

    1. 拿第一頁 → 立刻 renderPage(0)

    2. 其餘頁用 for…of 或受控並行去抓

    3. 每抓完一張pages.push(newPage)renderPage(pages.length - 1)


範例一:最小可行版本(完全與任何專案無關)

範例端點(假設):
  • GET /api/docs/:docId/manifest → 回傳 { totalPages: number }
  • GET /api/docs/:docId/page/:pageNo → 回傳 { ok: true, image: "data:image/png;base64,..." }
<!-- Viewer 區塊(簡化版) --> <div id="viewer" aria-live="polite"></div> <div id="meta"> <span id="pageIndicator">0 / 0</span> <input id="jumpInput" type="number" min="1" value="1" /> <button id="jumpBtn">跳轉</button> </div>
// app.js(以原生 JS 示範) const state = { pages: [], // [{ index, dataUrl }] isStreaming: false, currentIndex: 0, controller: null // AbortController for cancel }; const elViewer = document.getElementById('viewer'); const elIndicator = document.getElementById('pageIndicator'); const elJumpInput = document.getElementById('jumpInput'); const elJumpBtn = document.getElementById('jumpBtn'); function renderPage(idx) { if (idx < 0 || idx >= state.pages.length) return; state.currentIndex = idx; const { dataUrl } = state.pages[idx]; elViewer.innerHTML = `<img src="${dataUrl}" alt="第 ${idx + 1} 頁影像">`; elIndicator.textContent = `${idx + 1} / ${state.pages.length}`; elJumpInput.max = String(state.pages.length); elJumpInput.value = String(idx + 1); } async function fetchJson(url, opts = {}) { const res = await fetch(url, opts); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } /** 先載入第一頁、秒開;其餘頁再串流補上 */ async function loadFirstThenStream(docId) { // 中斷先前的串流(如果使用者換文件) if (state.controller) state.controller.abort(); state.controller = new AbortController(); const { signal } = state.controller; state.pages = []; state.isStreaming = true; elViewer.innerHTML = `<div style="padding:8px">載入中…</div>`; elIndicator.textContent = `0 / 0`; try { // 1) 拿到總頁數 const manifest = await fetchJson(`/api/docs/${docId}/manifest`, { signal }); const total = manifest.totalPages || 0; if (total <= 0) throw new Error('此文件沒有可用頁面'); // 2) 先抓第 1 頁,秒開 const first = await fetchJson(`/api/docs/${docId}/page/1`, { signal }); if (!first.ok || !first.image) throw new Error('第一頁下載失敗'); state.pages.push({ index: 1, dataUrl: first.image }); renderPage(0); // 立即顯示 // 設定上限,避免 jump 超界 elJumpInput.max = String(total); // 3) 其餘頁面:依序抓 & 立即渲染最新頁 for (let p = 2; p <= total; p++) { try { const data = await fetchJson(`/api/docs/${docId}/page/${p}`, { signal }); if (data.ok && data.image) { state.pages.push({ index: p, dataUrl: data.image }); renderPage(state.pages.length - 1); // **關鍵:每抓完立即渲染** } else { console.warn('頁面下載失敗', p); } } catch (e) { if (signal.aborted) return; // 使用者切換/中止 console.error('下載錯誤', p, e); } } } catch (err) { elViewer.innerHTML = `<div style="color:#c00;padding:8px">載入失敗:${err.message}</div>`; } finally { state.isStreaming = false; } } // 跳轉功能 elJumpBtn.addEventListener('click', () => { const to = Number(elJumpInput.value); if (Number.isNaN(to)) return; renderPage(to - 1); }); // 初始化示範:載入 doc-123 loadFirstThenStream('doc-123');

這段程式做到了什麼?

  • 首屏秒開:第一頁到手就渲染

  • 持續增量:後續頁面逐一加入 state.pages

  • 每抓一張就顯示renderPage(state.pages.length - 1)

  • 可隨時中止AbortController 避免資源浪費


範例二:加入「受控併發」讓載入更快但不塞爆

同樣與任何現有專案無關,只示範技巧。

思路:一次開 K 條下載任務(例如 4 條),每條完成再補下一頁,直到載完。


/** 受控併發下載(K=4)+即時渲染 */
async function streamWithConcurrency(docId, k = 4) { if (state.controller) state.controller.abort(); state.controller = new AbortController(); const { signal } = state.controller; state.pages = []; state.isStreaming = true; elViewer.innerHTML = `<div style="padding:8px">載入中…</div>`; elIndicator.textContent = `0 / 0`; try { const { totalPages: total } = await fetchJson(`/api/docs/${docId}/manifest`, { signal }); if (total <= 0) throw new Error('沒有頁面'); // 先拉第一頁 const first = await fetchJson(`/api/docs/${docId}/page/1`, { signal }); if (!first.ok || !first.image) throw new Error('第一頁失敗'); state.pages.push({ index: 1, dataUrl: first.image }); renderPage(0); elJumpInput.max = String(total); // 準備剩餘頁碼佇列 const queue = Array.from({ length: total - 1 }, (_, i) => i + 2); // 工作者(同時最多 k 個) async function worker() { while (queue.length && !signal.aborted) { const p = queue.shift(); try { const data = await fetchJson(`/api/docs/${docId}/page/${p}`, { signal }); if (data.ok && data.image) { state.pages.push({ index: p, dataUrl: data.image }); // 渲染最新頁 renderPage(state.pages.length - 1); } } catch (e) { if (signal.aborted) return; console.error('頁面下載錯誤', p, e); } } } // 啟動 k 個工作者並等待 await Promise.all(Array.from({ length: Math.min(k, queue.length) }, worker)); } catch (err) { elViewer.innerHTML = `<div style="color:#c00;padding:8px">載入失敗:${err.message}</div>`; } finally { state.isStreaming = false; } } // 使用:streamWithConcurrency('doc-456', 4);

優點

  • 加快整體完成時間

  • 不會一次開上百個請求塞爆瀏覽器/網路

  • 照樣維持「抓一張、顯示一張」


進階技巧與實務整理

1) 使用者切換文件時要中止舊任務

  • AbortController 是你的好朋友

  • 手機網路不穩時,避免僵死請求耗盡資源

2) 記憶體管理

  • 影像很多很大?考慮:

    • 只保留「目前頁附近」的緩衝(例如 ±5 頁),其他用 Object URL / Cache API 策略

    • 改用 PDF viewer(瀏覽器內建 / 第三方)做分頁流式載入

3) 失敗重試與退避

  • 下載失敗時 setTimeout + 指數退避(100ms → 200ms → 400ms)

  • 重試次數(例如最多 3 次)要可設定

4) UX 細節

  • 進度提示已載入 X / Y、細小 Skeleton UI

  • 鍵盤操作:← / → 切頁、Home/End 首末頁

  • 可及性 (a11y)aria-live="polite" 告知頁面更新

5) 快取

  • 網頁端可用 caches.open('doc-cache'),Key 用 docId+pageNo

  • 後端可做 CDN / ETag / Cache-Control

  • 文件未變更就直接從快取還原,省流量更快


常見 QA

Q1:為什麼不是全部同時載?
同時開過多請求會拖慢首屏,且容易打滿頻寬、導致卡頓。受控併發或序列下載更穩定。

Q2:可以邊載邊顯示縮圖嗎?
可以。後端提供小尺寸與原尺寸兩個端點,前端先載小圖顯示,再替換大圖(漸進式增量清晰)。

Q3:要不要換成 Service Worker?
如果需要離線瀏覽、背景同步、跨頁共享快取,Service Worker 很值得。但本文解法不依賴 SW,也能拿到 80% 以上的體驗提升。


範例三:極簡版 API(伺服器端,僅示意)

下列僅為示意,你可以用任意後端(Node、Go、Java、.NET…)實作,只要符合前述回應格式即可。


// Node.js Express(示意)
const express = require('express'); const app = express(); // 假資料:一份文件有 12 頁 app.get('/api/docs/:docId/manifest', (req, res) => { res.json({ totalPages: 12 }); }); app.get('/api/docs/:docId/page/:page', (req, res) => { const { page } = req.params; // 實務上請從儲存(S3/本地/PDF render)取圖,轉為 DataURL const fakePngDataUrl = `data:image/png;base64,${Buffer.from(`PNG-PAGE-${page}`).toString('base64')}`; res.json({ ok: true, image: fakePngDataUrl }); }); app.listen(8080, () => console.log('mock api on :8080'));

結語

先顯示第一頁、其餘慢慢補,是處理重量級影像/頁面瀏覽最直接有效的 UX/效能升級:

  • 首屏時間顯著縮短

  • 使用者不再面對空白與卡頓

  • 架構彈性佳:可以逐步加入受控併發、快取、重試與中止

把本文的「先首屏、後串流、每張立即渲染」設計套進你的檢視器,你會立刻感受到滑順的差異。必要時再配合後端預先轉檔或快取,整體體驗就能到位。

留言

這個網誌中的熱門文章

🔍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 錯誤與資料欄位動態插入顯示問題