skip to content
BlogZzz

[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)],
};
},
);

假設 activeIndex0,中央位置的 logicalIndex 也是 0

中央左方可能是 -1-2,中央右方可能是 12

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);
}
}, []);

這段依序做了幾件事

  1. isPrimary 排除第二根手指等非主要指標,event.button > 0 排除右鍵與中鍵
  2. 記住 pointerId,之後只接受同一個指標的 move 和 up 事件
  3. 記錄起點與開始時間,後面用來計算位移 deltaX 和速度 velocityX
  4. 先把 gestureAxis 設為 pending,因為按下當下還不知道使用者想水平滑還是垂直滑
  5. 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 更新中前進或後退一格,新產生的虛擬卡片視窗剛好接住已經移動完成的畫面

無限輪播的常見做法

要做循環輪播,大致有幾種方向:

  1. 每次抵達第一張或最後一張,直接跳到另一端
  2. 複製首尾卡片,移動到副本後再無動畫跳回真正資料
  3. 建立有限的重複卡片序列,動畫結束後才旋轉邏輯資料

本專案使用第三種

前兩種做法也能實現循環輪播

但第三種能把畫面位移與資料中心索引分開處理,較容易維持單步輪播的狀態一致性

這個專案怎麼做到看起來無限?

畫面上有兩個重要狀態:

const [activeIndex, setActiveIndex] = useState(0);
const [deltaX, setDeltaX] = useState(0);
  • activeIndex:目前中央卡片對應的邏輯資料索引
  • deltaX:整排卡片現在被平移多少像素

使用者向左換一張時,流程不是立刻換資料,而是:

1. deltaX 移到 -itemStep,卡片列向左播放 transition
2. 動畫完成後,activeIndex 加一
3. deltaX 歸零,並關閉 transition
4. 重新產生以新 activeIndex 為中心的卡片視窗

對使用者來說,畫面只是平順地換到下一張

對程式來說,動畫結束後才提交資料索引,因此可以把位移歸零而不產生視覺跳動

資料索引會透過取模運算回到有效範圍

const getCircularIndex = (index, itemCount) =>
((index % itemCount) + itemCount) % itemCount;

因此 -1 會變成最後一筆資料,超出資料長度的索引則會回到開頭

邏輯索引可以持續增加或減少,不需要特別處理已經到第一張或最後一張的情況

提到的虛擬渲染,實際在做什麼?

這個專案曾探索 Virtualized Rendering

這裡不是把原始資料完整複製好幾輪,而是只建立目前看得到的卡片,加上左右少量緩衝區

const virtualItemCount = visibleItemCount + virtualItemBuffer * 2;

假設畫面需要五張可視卡,左右各留兩張 buffer,DOM 中就只需要九張按鈕

資料即使從八筆增加到八百筆,這個數量仍維持在接近固定的範圍

資料集有 100 筆,但輪播軌道只渲染有限卡片

截圖中的輪播已經移動到第 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,不直接解決無限輪播與拖曳吸附
VirtuaReact、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 的問題,但前者以輪播為中心,後者以一般捲動列表為中心

為什麼這個專案仍然自己寫?

這個專案不是因為現成函式庫不夠好才寫,而是互動範圍剛好小且明確

它固定一次切換一張,以中央卡片為主,兩側保留預覽,可雙向無限循環,並需要和既有版面及卡片選取狀態緊密配合

自行實作有幾個直接的理由

  • activeIndexdeltaX、動畫提交時機都由同一個元件掌握,容易驗證拖曳、回彈、換卡之間的狀態轉換
  • 只需渲染可見卡片與少量 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 規模
  • 依容器寬度計算版面,並在手機上保留可滑動提示

這些細節合在一起,才讓輪播從可以左右移動,變成手機上可預期、可擴充,也更接近產品需求的互動元件