[Web] 圖片輪播的虛擬渲染,不讓 DOM 隨資料增加
/ 24 min read
Updated:Table of Contents
線上範例
可先操作完整範例,再對照後續的手勢與虛擬渲染實作
圖片輪播要怎麼做,才能滑得自然又能無縫循環?
圖片輪播看起來很簡單:一排卡片,往左滑、往右滑
但如果要把它做成手機上真的好操作的元件,通常很快會遇到幾個問題:
- 使用者橫向拖曳時,頁面還能不能正常上下捲動?
- 拖得不夠遠時,要回彈還是切到下一張?
- 快速甩動時,要不要即使距離不長也換卡?
- 從最後一張滑到第一張時,怎麼避免畫面跳一下?
- 圖片數量很多時,是否要把所有卡片都留在 DOM 裡?
這個專案重現並重構早期網站的圖片輪播互動
實作集中在 src/component/HorizontalScroll.js,使用 React Hooks 管理輪播狀態,使用 Pointer Events 處理手勢,並透過有限的卡片視窗做出視覺上無限的單步輪播
這篇文章整理其中幾個實作決策
先看完整執行順序
在閱讀細節之前,先建立整體順序:
App render → HorizontalScroll 建立狀態、量測容器、產生虛擬卡片 → Swipeable 綁定 Pointer Events
使用者按下 → pointerdown 記錄座標、時間與 pointerId使用者移動 → pointermove 判斷水平或垂直手勢 → 水平時更新 deltaX,整排卡片跟著移動使用者放開 → pointerup 計算距離與速度 → 決定回彈或移動一張 → CSS transition 播放動畫結束 → 更新 activeIndex → deltaX 歸零,重新建立中央卡片周圍的虛擬視窗最重要的觀念是:拖曳中的畫面位移由 deltaX 負責,真正換到哪筆資料由 activeIndex 負責
兩者不會在同一個時間點改變
元件一開始 render 時做了什麼?
App 只負責掛載輪播:
function App() { return ( <div className="App"> <div> <h2>Demo</h2> <HorizontalScroll /> </div> </div> );}真正的起點是 HorizontalScroll
第一次 render 時,它建立下列 state
const [isSwiping, setIsSwiping] = useState(false);const [hasTransition, setHasTransition] = useState(false);const [transitionDuration, setTransitionDuration] = useState(0.6);const [deltaX, setDeltaX] = useState(0);const [selectedVideo, setSelectedVideo] = useState(initialVideos[0].name);const [activeIndex, setActiveIndex] = useState(0);const [galleryLayout, setGalleryLayout] = useState(() => getGalleryLayout(window.innerWidth),);isSwiping:目前是否在水平拖曳hasTransition:整排卡片是否應以 CSS 動畫移動transitionDuration:回彈或換卡動畫秒數deltaX:手勢造成的即時位移selectedVideo:點擊選取的圖片名稱,只影響卡片藍色外框activeIndex:目前中央卡片的邏輯索引galleryLayout:由容器寬度算出的卡片尺寸、間距與虛擬卡片數
其中 galleryLayout 使用 lazy initializer,代表 getGalleryLayout(window.innerWidth) 只會在首次建立 state 時執行,而不是每一次 render 都執行
接著 useLayoutEffect 在瀏覽器繪製前量測實際容器,並使用 ResizeObserver 追蹤後續寬度變動:
const getContainerWidth = () => showcaseRef.current?.getBoundingClientRect().width || window.innerWidth;
const resizeObserver = new ResizeObserver(([entry]) => { updateGalleryLayout(entry.contentRect.width);});
resizeObserver.observe(showcaseRef.current);這個順序代表先以 window.innerWidth 產生可用初值,掛載 DOM 後再改用輪播容器真正的寬度
父層寬度改變時,也會重新計算卡片,而不只依賴視窗 resize
畫面上的卡片是怎麼產生的?
activeIndex 不直接用來 render 一張圖片,而是用來建立有限的虛擬視窗
const centerVirtualIndex = Math.floor(galleryLayout.virtualItemCount / 2);
const virtualVideos = Array.from( { length: galleryLayout.virtualItemCount }, (_, virtualIndex) => { const logicalIndex = activeIndex + virtualIndex - centerVirtualIndex;
return { isBuffer: virtualIndex < virtualItemBuffer || virtualIndex >= galleryLayout.virtualItemCount - virtualItemBuffer, logicalIndex, video: initialVideos[getCircularIndex(logicalIndex, initialVideos.length)], }; },);假設 activeIndex 是 0,中央位置的 logicalIndex 也是 0
中央左方可能是 -1、-2,中央右方可能是 1、2
getCircularIndex 將這些邏輯索引轉換成真實資料索引:
const getCircularIndex = (index, itemCount) => ((index % itemCount) + itemCount) % itemCount;因此 -1 不會取到不存在的陣列項目,而會對應到最後一張圖
這是循環資料的基礎
最後,virtualVideos 會 render 成 VideoElement:
{ virtualVideos.map(({ isBuffer, logicalIndex, video }) => ( <VideoElement dimensions={galleryLayout} isBuffer={isBuffer} isSelected={selectedVideo === video.name} key={logicalIndex} onSelect={setSelectedVideo} value={video} /> ));}isBuffer 的卡片讓拖曳到邊緣時仍有內容可顯示
它們會設為 aria-hidden 且不能被 Tab 聚焦,因此不會造成鍵盤或讀屏工具的重複操作項目
先釐清:這不是一般的橫向捲動列表
最直接的作法,是讓容器使用原生水平捲動:
.list { display: flex; overflow-x: auto;}這種做法在內容列表很合理
但本專案要重現的是「一次切換一張」的輪播互動
拖曳時卡片跟著手指移動,放開後只會停在目前卡片或相鄰卡片,而且可以無限往兩個方向操作
因此,這裡不是讓瀏覽器決定捲動位置,而是由元件自行控制整排卡片的 transform
手勢要自己處理,還是交給瀏覽器?
如果元件只支援滑鼠,可以直接監聽 mouse event
但圖片輪播通常也要支援手機與平板
分別處理 mouse* 和 touch* 事件,會讓邏輯變成兩套
這個專案選擇使用 Pointer Events:
onPointerDown = { handlePointerDown };onPointerMove = { handlePointerMove };onPointerUp = { handlePointerUp };onPointerCancel = { handlePointerCancel };它的好處是滑鼠、手指與觸控筆都會進入同一套流程
接到 pointermove 不代表應該立刻把它當成輪播
使用者也可能只是想向下滑頁面
水平拖曳與頁面垂直捲動,誰應該優先?
這是行動版輪播最容易讓人覺得卡的地方
如果輪播一收到移動事件就攔截,使用者在卡片上向下滑時,頁面會無法捲動
如果完全不處理,又做不出穩定的水平拖曳
這個元件先等待移動超過 dragStartThreshold,再根據 X、Y 位移判斷手勢方向:
const deltaX = endX.current - startX.current;const deltaY = event.clientY - startY.current;
gestureAxis.current = Math.abs(deltaX) >= Math.abs(deltaY) ? "horizontal" : "vertical";只有水平手勢才會觸發輪播更新:
if (gestureAxis.current !== "horizontal") return;同時,輪播外層設定:
touch-action: pan-y;這表示瀏覽器仍可處理垂直平移
換句話說,輪播只負責水平操作,不接管使用者的正常頁面捲動
從按下到拖曳,程式實際怎麼走?
手勢處理包在 Swipeable 元件中
它使用 useRef 儲存手勢期間的暫時資料
const pointerId = useRef(null);const startX = useRef(0);const startY = useRef(0);const endX = useRef(0);const startTime = useRef(0);const isDragging = useRef(false);const gestureAxis = useRef(null);const suppressClick = useRef(false);這些值不使用 state,因為每次 pointermove 更新它們時,不需要重新 render
使用 ref 可以保留資料,同時避免高頻事件造成不必要的 React 更新
第一步:pointerdown 建立本次手勢
const handlePointerDown = useCallback((event) => { if (!event.isPrimary || event.button > 0) return;
pointerId.current = event.pointerId; startX.current = event.clientX; startY.current = event.clientY; endX.current = event.clientX; startTime.current = Date.now(); gestureAxis.current = "pending";
if (event.currentTarget.setPointerCapture) { event.currentTarget.setPointerCapture(event.pointerId); }}, []);這段依序做了幾件事
isPrimary排除第二根手指等非主要指標,event.button > 0排除右鍵與中鍵- 記住
pointerId,之後只接受同一個指標的 move 和 up 事件 - 記錄起點與開始時間,後面用來計算位移
deltaX和速度velocityX - 先把
gestureAxis設為pending,因為按下當下還不知道使用者想水平滑還是垂直滑 setPointerCapture確保指標移出元件邊界時,事件仍送回輪播,不會在半路遺失手勢
第二步:pointermove 決定手勢方向並更新畫面
const handlePointerMove = useCallback((event) => { if (event.pointerId !== pointerId.current) return;
endX.current = event.clientX; const deltaX = endX.current - startX.current; const deltaY = event.clientY - startY.current;
if (gestureAxis.current === "pending") { if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < dragStartThreshold) { return; }
gestureAxis.current = Math.abs(deltaX) >= Math.abs(deltaY) ? "horizontal" : "vertical"; }
if (gestureAxis.current !== "horizontal") return;
if (!isDragging.current) { isDragging.current = true; callbacksRef.current.onSwipeStart(); }
const elapsed = Math.max(Date.now() - startTime.current, 1); callbacksRef.current.onSwiping({ deltaX, velocityX: Math.abs(deltaX) / elapsed, direction: deltaX < 0 ? "left" : "right", });}, []);onSwipeStart() 只會在首次確認水平拖曳時呼叫一次
父元件在這裡關閉前一次 transition,讓卡片在手指移動期間直接跟著走
之後每一次 move 都會呼叫 onSwiping()
父元件不會直接把所有 move 都寫進 state,而是先透過 requestAnimationFrame 節流
const scheduleDeltaX = useCallback((nextDeltaX) => { cancelAnimationFrame(frameId.current); frameId.current = requestAnimationFrame(() => setDeltaX(nextDeltaX));}, []);
const handleSwiping = useCallback( ({ deltaX }) => { scheduleDeltaX( clamp(deltaX, -galleryLayout.itemStep, galleryLayout.itemStep), ); }, [galleryLayout.itemStep, scheduleDeltaX],);clamp 保證一次手勢最多只預覽前一張或下一張
requestAnimationFrame 保證每一個瀏覽器繪製影格最多更新一次 deltaX
deltaX 更新後,ShowcaseList 的 transform 隨之改變:
transform: `translate3d(calc(-${listingWidth / 2}px + ${deltaX}px), 0, 0)`;這就是使用者拖曳時看到卡片跟著手指移動的原因
放開時,到底該換卡還是回彈?
只看拖曳距離不夠
如果使用者很快地往左甩一下,距離可能不長,但互動意圖通常很明確
如果只看速度,慢慢拖很遠又不換卡,也會不符合預期
本專案兩者都看:
const shouldChangeItem = Math.abs(deltaX) >= galleryLayout.itemStep * 0.25 || velocityX >= flingVelocityThreshold;也就是說:
- 移動超過一張卡片的四分之一:切換相鄰卡片
- 距離不足,但速度夠快:仍然切換
- 兩者都不符合:回彈到原本位置
這裡的 itemStep 是卡片寬度加上卡片間距,而不是只有圖片寬度
因此動畫停下來時,卡片間距也能保持一致
第三步:pointerup 計算結果並清理手勢
放開時,Swipeable 會把最後座標交給 finishSwipe
const handlePointerUp = useCallback( (event) => { if (event.pointerId !== pointerId.current) return;
endX.current = event.clientX; finishSwipe(); }, [finishSwipe],);finishSwipe 的責任是整理手勢資料,再把結果交回 HorizontalScroll
const deltaX = endX.current - startX.current;const elapsed = Math.max(Date.now() - startTime.current, 1);const velocityX = Math.abs(deltaX) / elapsed;const didDrag = isDragging.current;
pointerId.current = null;startX.current = 0;startY.current = 0;endX.current = 0;startTime.current = 0;isDragging.current = false;gestureAxis.current = null;suppressClick.current = didDrag;
if (didDrag) { callbacksRef.current.onSwipeEnd({ deltaX, velocityX, direction: deltaX < 0 ? "left" : "right", });}suppressClick.current = didDrag 很重要
瀏覽器往往會在拖曳放開後補送一個 click,下一個 capture 階段會攔截它,避免使用者本來在滑動,卻意外選取卡片
第四步:父元件決定回彈或換卡
handleSwipeEnd 在判斷 shouldChangeItem 後,會先開啟 transition,再依結果執行其中一條路徑
if (!shouldChangeItem) { scheduleDeltaX(0); transitionTimer.current = setTimeout( () => setHasTransition(false), snapDuration * 1000, ); return;}
const nextDeltaX = direction === "left" ? -galleryLayout.itemStep : galleryLayout.itemStep;
scheduleDeltaX(nextDeltaX);pendingDirection.current = direction;transitionTimer.current = setTimeout( commitPendingRotation, snapDuration * 1000,);- 未達門檻:
deltaX回到0,因此卡片回彈 - 達到門檻:
deltaX移到正或負一個itemStep,因此卡片完成一格換頁 pendingDirection暫存方向,因為資料索引必須等動畫完成後才能改transitionTimer在動畫時間結束後呼叫commitPendingRotation
第五步:動畫結束才真正更新資料中心
const commitPendingRotation = useCallback(() => { const direction = pendingDirection.current; if (!direction) return false;
pendingDirection.current = null; setActiveIndex((index) => getCircularIndex( index + (direction === "left" ? 1 : -1), initialVideos.length, ), ); setDeltaX(0); setHasTransition(false); return true;}, []);這裡看起來是動畫後把位移歸零,但不會跳動
activeIndex 已在同一批 state 更新中前進或後退一格,新產生的虛擬卡片視窗剛好接住已經移動完成的畫面
無限輪播的常見做法
要做循環輪播,大致有幾種方向:
- 每次抵達第一張或最後一張,直接跳到另一端
- 複製首尾卡片,移動到副本後再無動畫跳回真正資料
- 建立有限的重複卡片序列,動畫結束後才旋轉邏輯資料
本專案使用第三種
前兩種做法也能實現循環輪播
但第三種能把畫面位移與資料中心索引分開處理,較容易維持單步輪播的狀態一致性
這個專案怎麼做到看起來無限?
畫面上有兩個重要狀態:
const [activeIndex, setActiveIndex] = useState(0);const [deltaX, setDeltaX] = useState(0);activeIndex:目前中央卡片對應的邏輯資料索引deltaX:整排卡片現在被平移多少像素
使用者向左換一張時,流程不是立刻換資料,而是:
1. deltaX 移到 -itemStep,卡片列向左播放 transition2. 動畫完成後,activeIndex 加一3. deltaX 歸零,並關閉 transition4. 重新產生以新 activeIndex 為中心的卡片視窗對使用者來說,畫面只是平順地換到下一張
對程式來說,動畫結束後才提交資料索引,因此可以把位移歸零而不產生視覺跳動
資料索引會透過取模運算回到有效範圍
const getCircularIndex = (index, itemCount) => ((index % itemCount) + itemCount) % itemCount;因此 -1 會變成最後一筆資料,超出資料長度的索引則會回到開頭
邏輯索引可以持續增加或減少,不需要特別處理已經到第一張或最後一張的情況
提到的虛擬渲染,實際在做什麼?
這個專案曾探索 Virtualized Rendering
這裡不是把原始資料完整複製好幾輪,而是只建立目前看得到的卡片,加上左右少量緩衝區
const virtualItemCount = visibleItemCount + virtualItemBuffer * 2;假設畫面需要五張可視卡,左右各留兩張 buffer,DOM 中就只需要九張按鈕
資料即使從八筆增加到八百筆,這個數量仍維持在接近固定的範圍
截圖中的輪播已經移動到第 29、30、31 筆資料,但畫面下方顯示資料集共有 100 筆,目前只渲染 8 張卡片
右側的 DevTools 也能看到,輪播軌道下只有有限數量的 button 元素
因此,卡片內容雖然會隨著 activeIndex 改成後面的資料,DOM 不會因為資料集有 100 筆就產生 100 張卡片
每張虛擬卡會依 activeIndex 算出自己的 logicalIndex,再取得真正應顯示的資料
const logicalIndex = activeIndex + virtualIndex - centerVirtualIndex;const video = initialVideos[getCircularIndex(logicalIndex, initialVideos.length)];這就是虛擬渲染探索,不讓 DOM 規模隨資料筆數線性成長,而是維持一個小型的可見視窗
常見的虛擬渲染與輪播 GitHub 函式庫
自己處理虛擬視窗之前,先分清楚兩種問題:大型列表的捲動虛擬化,以及輪播的滑動、吸附與循環
兩者都會減少畫面外 DOM,但狀態模型和互動責任並不相同
| 函式庫 | 適合的問題 | 與本專案的差異 |
|---|---|---|
| TanStack Virtual | 大型表格、動態高度列表、直向或橫向原生捲動 | 它是 headless virtualizer,負責計算可見範圍與項目位置,手勢與輪播動畫仍要自行組合 |
| react-window | 固定尺寸或可預測尺寸的 React 長列表 | API 簡潔,但核心仍是 scroll container,不直接解決無限輪播與拖曳吸附 |
| Virtua | React、Vue、Solid 等框架的大量資料列表 | 偏向通用的高效列表虛擬化,適合資料量大且要保留原生捲動行為的介面 |
| Swiper | 功能完整的產品輪播,例如 pagination、navigation、effect、autoplay | 已提供 Virtual Slides,適合大量或內容較重的 slide,但設定面與執行期責任也較完整 |
| Embla Carousel | 想保有版面與 UI 控制權的輪播 | 提供拖曳、吸附與 loop,loop 時會重新定位需要調整的 slide,但不以通用列表 virtualizer 為目標 |
| Keen Slider | 客製手勢、breakpoint 與 slider 軌道行為 | 適合將 slider 行為交給函式庫,再由應用程式提供卡片與 UI |
這幾個函式庫沒有誰一定比較好,選擇取決於元件的主要責任
- 資料可能有數千筆,使用者預期的是一般列表捲動,優先考慮 TanStack Virtual、react-window 或 Virtua
- 需求是完整的行銷輪播或內容 slider,且需要 pagination、autoplay、effect 等功能,Swiper 的整合成本通常更低
- 想保留自己的 DOM 結構與視覺設計,只希望取得可靠的拖曳與循環行為,Embla Carousel 或 Keen Slider 較合適
以 Swiper 為例,Virtual Slides 會在大量或內容較重的 slide 時,只保留必要的 slide DOM
TanStack Virtual 則會根據 scroll container、項目尺寸估計值與 overscan,算出當下需要 render 的項目範圍
這兩者都能解少量 DOM 的問題,但前者以輪播為中心,後者以一般捲動列表為中心
為什麼這個專案仍然自己寫?
這個專案不是因為現成函式庫不夠好才寫,而是互動範圍剛好小且明確
它固定一次切換一張,以中央卡片為主,兩側保留預覽,可雙向無限循環,並需要和既有版面及卡片選取狀態緊密配合
自行實作有幾個直接的理由
activeIndex、deltaX、動畫提交時機都由同一個元件掌握,容易驗證拖曳、回彈、換卡之間的狀態轉換- 只需渲染可見卡片與少量 buffer,不需要先引入完整 slider 的 module、樣式與設定模型
touch-action: pan-y、手勢軸向判斷、click suppression 與 pointer capture 都能依頁面捲動規則精準調整- 虛擬卡片的 key 使用
logicalIndex,資料循環和 DOM 視窗的關係可以直接閱讀與除錯 - 輪播內的按鈕、選取框與無障礙屬性屬於既有元件的一部分,不必再處理函式庫 clone 或 loop slide 帶來的語意同步
不過,這個判斷有前提
若未來需求增加為多列、自由拖曳、多種 transition effect、autoplay、縮圖導航、懶載入協調,或多個輪播需要共享成熟行為,應改用 Swiper、Embla Carousel 或 Keen Slider
若輪播改成數千筆資料的可自由橫向捲動資料列,則應將目前的有限視窗策略換成 TanStack Virtual 一類的 scroll virtualizer
自己寫的價值不在於取代函式庫,而在於當需求只需要一小段可控行為時,讓依賴、狀態與除錯面積都維持在必要範圍內
為什麼手機要露出相鄰卡片?
卡片寬度會根據容器重新計算:
const itemWidth = Math.min( maximumItemWidth, Math.max(1, containerWidth - mobileSidePeek * 2),);在小螢幕上,mobileSidePeek 會讓中央卡片兩側各保留一點空間,露出相鄰卡片的一小段
這不只是視覺效果
如果畫面只看得到一張完整圖片,使用者未必知道還有其他內容
露出下一張的一角,相當於用版面告訴使用者這裡可以往旁邊滑
容器尺寸由 ResizeObserver 監聽,而不是只監聽 window.resize
即使輪播所在的欄位寬度因側欄、版面切換或父層改變而變動,卡片尺寸也會重新計算
結語
這個專案是如何把輪播需要的互動責任拆清楚
- Pointer Events 統一處理不同輸入裝置
- 手勢軸向判斷,避免水平輪播阻斷垂直捲動
- 距離與速度共同決定換卡或回彈
- 動畫結束後才旋轉資料索引,維持無縫循環
- 只渲染可見卡片與 buffer,控制 DOM 規模
- 依容器寬度計算版面,並在手機上保留可滑動提示
這些細節合在一起,才讓輪播從可以左右移動,變成手機上可預期、可擴充,也更接近產品需求的互動元件