본문으로 건너뛰기

Marko Virtual

@tanstack/marko-virtual은 TanStack Virtual용 Marko 6 어댑터입니다. 자동으로 검색되는 두 개의 Marko 태그를 통해 행, 열, 그리드 가상화를 제공합니다.

  • <virtualizer> — 요소 기반 스크롤(행, 열, 그리드)
  • <window-virtualizer> — 전체 페이지/창 스크롤

패키지를 설치하면 Marko 컴파일러가 태그를 자동으로 검색합니다. .marko 파일에서 import할 필요가 없습니다.

각 태그는 자체 닫기 형식으로 사용하며 태그 변수를 노출합니다(<virtualizer/v/> 형식으로 작성). 이후 마크업을 직접 구성하고 v.virtualItemsv.totalSize를 읽어 보이는 행을 렌더링합니다.

설치

npm install @tanstack/marko-virtual

행 가상화

<div/scrollEl
style="height: 400px; width: 400px; overflow-y: auto; position: relative;"
>
<virtualizer/v
count=10000
estimateSize=() => 35
getScrollElement=() => scrollEl()
/>
<div style=`height: ${v.totalSize}px; width: 100%; position: relative`>
<for|item| of=v.virtualItems>
<div
style=`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: ${item.size}px;
transform: translateY(${item.start}px);
`
>
Row ${item.index}
</div>
</for>
</div>
</div>

열 가상화

동일한 태그에 horizontal=true를 사용합니다.

<div/scrollEl
style="width: 400px; height: 100px; overflow-x: auto; position: relative;"
>
<virtualizer/v
count=10000
estimateSize=() => 100
horizontal=true
getScrollElement=() => scrollEl()
/>
<div style=`width: ${v.totalSize}px; height: 100%; position: relative`>
<for|item| of=v.virtualItems>
<div
style=`
position: absolute;
top: 0;
left: 0;
height: 100%;
width: ${item.size}px;
transform: translateX(${item.start}px);
`
>
Column ${item.index}
</div>
</for>
</div>
</div>

그리드 가상화

하나는 행에, 다른 하나는 열에 사용하는 두 개의 <virtualizer> 태그가 동일한 스크롤 요소를 공유하도록 구성합니다. 각 태그는 자체 태그 변수를 반환합니다. 각 버추얼라이저가 자체 요소를 확인할 수 있도록 getScrollElement를 화살표 함수(() => ref())로 전달합니다.

<div/scrollEl
style="height: 500px; width: 500px; overflow: auto; position: relative;"
>
<virtualizer/rowV
count=10000
estimateSize=() => 35
getScrollElement=() => scrollEl()
/>
<virtualizer/colV
count=200
estimateSize=() => 100
horizontal=true
getScrollElement=() => scrollEl()
/>
<div style=`height: ${rowV.totalSize}px; width: ${colV.totalSize}px; position: relative`>
<for|row| of=rowV.virtualItems>
<for|col| of=colV.virtualItems>
<div
style=`
position: absolute;
top: 0;
left: 0;
width: ${col.size}px;
height: ${row.size}px;
transform: translateX(${col.start}px) translateY(${row.start}px);
`
>
Cell ${row.index}, ${col.index}
</div>
</for>
</for>
</div>
</div>

창 가상화

컨테이너가 아니라 전체 페이지가 스크롤될 때는 <window-virtualizer>를 사용합니다.

<window-virtualizer/v
count=10000
estimateSize=() => 35
/>
<div style=`height: ${v.totalSize}px; position: relative`>
<for|item| of=v.virtualItems>
<div
style=`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: ${item.size}px;
transform: translateY(${item.start}px);
`
>
Row ${item.index}
</div>
</for>
</div>

동적/가변 항목 크기

높이를 알 수 없는 항목의 경우 measureElement<script>로 구동되는 ref로 사용하여 렌더링 후 각 요소를 측정합니다.

<div/scrollEl style="height: 400px; overflow-y: auto">
<virtualizer/v
count=data.length
estimateSize=() => 50
getScrollElement=() => scrollEl()
/>
<div style=`height: ${v.totalSize}px; position: relative`>
<for|item| of=v.virtualItems>
<div/el
data-index=item.index
style=`position: absolute; top: 0; width: 100%; transform: translateY(${item.start}px)`>
<script() {
// re-run when the item changes; measureElement reads the rendered
// height and feeds it back to the virtualizer
const _key = item.key
if (el() && v.measureElement) v.measureElement(el())
}/>
${data[item.index].text}
</div>
</for>
</div>
</div>

태그 변수 레퍼런스

두 태그 모두 자체 닫기 형식이며 동일한 태그 변수 구조를 노출합니다. <virtualizer/v/>로 (또는 원하는 이름으로) 캡처하고 해당 속성을 v.property 형식으로 읽습니다.

속성타입설명
virtualItemsVirtualItem[]현재 보이는 가상 항목
totalSizenumber스크롤할 수 있는 전체 크기(px) — 내부 컨테이너의 height(열의 경우 width)로 설정합니다.
range{ startIndex: number; endIndex: number } | null보이는 인덱스 창(오버스캔 제외)입니다. 창이 생기기 전까지는 null입니다. 활성 스티키 헤더와 같은 값을 도출할 때 유용합니다.
measureElement(el: Element | null) => void동적 항목 크기 측정을 위한 ref 콜백
scrollToIndex(index: number, options?: ScrollToOptions) => void인덱스로 항목에 명령형 스크롤을 수행합니다. 기본값 align: 'auto'는 최소한으로 스크롤합니다. 아래로 이동할 때는 항목이 뷰포트 끝에 놓이고, 위로 이동할 때는 시작 지점(scrollPaddingStart 아래)에 맞춰지며, 이미 완전히 보이는 항목은 이동하지 않습니다. 항상 시작 지점에 맞추려면 { align: 'start' }를 전달합니다.
scrollToOffset(offset: number, options?: ScrollToOptions) => void픽셀 오프셋으로 명령형 스크롤을 수행합니다.
measure() => void측정된 모든 크기를 버리고 전체를 다시 측정합니다(너비/글꼴 변경 후).
resizeItem(index: number, size: number) => voidDOM 측정 없이 항목 하나의 크기를 직접 설정합니다.
scrollToEnd(options?: { behavior?: ScrollBehavior }) => void목록의 맨 끝으로 스크롤합니다.
isAtEnd(threshold?: number) => boolean스크롤 위치가 끝에 있거나 끝에서 threshold px 이내인지 나타냅니다. 마운트 전에는 false입니다.
getDistanceFromEnd() => number현재 스크롤 위치와 끝 사이의 픽셀 거리입니다. 마운트 전에는 Infinity입니다.

<virtualizer> 입력 레퍼런스

프로퍼티타입기본값설명
countnumber필수항목 수
getScrollElement() => Element | null필수스크롤 컨테이너를 반환합니다.
estimateSize(index: number) => number() => 50예상 항목 크기(px)
overscannumber5보이는 영역 너머에 렌더링할 항목 수
horizontalbooleanfalse가로 방향으로 가상화합니다(열).
paddingStartnumber첫 번째 항목 앞의 패딩
paddingEndnumber마지막 항목 뒤의 패딩
scrollPaddingStartnumberscrollToIndex의 시작 스크롤 패딩
scrollPaddingEndnumberscrollToIndex의 끝 스크롤 패딩
gapnumber항목 사이의 간격(px)
lanesnumber1메이슨리 레이아웃의 레인 수
initialOffsetnumber | (() => number)서버 슬라이스의 스크롤 오프셋(px)입니다. 스크롤 위치(딥 링크/복원)에서 서버 렌더링합니다. 요소에만 사용할 수 있습니다. SSR을 참고합니다.
initialRect{ width: number; height: number }서버에서 렌더링되는 슬라이스의 뷰포트 힌트(SSR)입니다. 설정하면 서버가 처음 보이는 행을 그립니다. 클라이언트에서만 채우려면 생략합니다. SSR을 참고합니다.
getItemKey(index: number) => number | string | bigint인덱스순서를 변경해도 캐시된 측정값이 유지되도록 하는 항목별 안정적인 식별자
rangeExtractor(range: Range) => number[]defaultRangeExtractor보이는 범위에 적용하는 훅입니다. 추가 인덱스(예: 고정된 스티키 헤더)를 렌더링된 창에 강제로 포함합니다. defaultRangeExtractor@tanstack/virtual-core에서 가져와 조합합니다.
indexAttributestring'data-index'measureElement에 사용할 항목 인덱스를 담는 DOM 속성입니다. 동일한 요소(그리드 셀)를 측정하는 두 인스턴스에는 서로 다른 속성을 지정합니다.
initialMeasurementsCacheVirtualItem[]측정 캐시를 초기화할 사전 측정 항목(일반 데이터)
anchorTo'start' | 'end''start'창을 목록 끝에 고정합니다(최신 항목에 고정된 채팅). 클라이언트 동작에만 영향을 주므로 서버 슬라이스의 위치를 정하지는 않습니다. 서버 슬라이스 위치에는 initialOffset을 사용합니다.
followOnAppendboolean | ScrollBehaviorfalseanchorTo="end"인 경우 항목이 추가될 때 끝에 고정된 상태를 유지합니다.
scrollEndThresholdnumber1여전히 "끝에 있음"으로 간주할 끝과의 최대 거리(px)
scrollMarginnumber0동일한 스크롤러에서 목록 위에 다른 콘텐츠가 있을 때 스크롤 영역 상단으로부터 목록까지의 오프셋(px)입니다. 이 경우 item.start 값에 이 여백이 포함되므로 목록을 기준으로 항목 위치를 지정할 때는 여백을 빼야 합니다(창 예제 참고).
enabledbooleantrue비활성화 스위치입니다. false는 일시 정지가 아닙니다. 버추얼라이저가 관찰을 중단하고 측정값을 지운 다음 다시 활성화될 때까지 빈 창을 렌더링합니다.
isRtlbooleanfalse오른쪽에서 왼쪽으로 진행하는 가로 목록
isScrollingResetDelaynumber150마지막 스크롤 이벤트 후 "사용자가 스크롤 중"인 상태가 끝날 때까지의 시간(ms)
useScrollendEventbooleanfalse네이티브 scrollend 이벤트를 isScrollingResetDelay 타이머 대신 사용합니다.
useAnimationFrameWithResizeObserverbooleanfalseResizeObserver 측정값을 애니메이션 프레임 단위로 배칭합니다(크기 조절 부하가 클 때 "ResizeObserver loop" 콘솔 오류를 방지합니다).
laneAssignmentMode'estimate' | 'measured''estimate'메이슨리/다중 레인에서 예상 크기 또는 측정된 크기를 기준으로 항목을 레인에 할당합니다.
useCachedMeasurementsbooleanfalse기본 측정기가 DOM을 읽는 대신 캐시된 크기(또는 예상 크기)를 반환하게 합니다. 항목 크기를 이미 알고 있을 때 크기를 고정합니다.
debugbooleanfalse상세 엔진 로깅
measureElement(element, entry, instance) => numberborder-box 측정기요소에서 항목 크기를 읽는 방식을 바꿉니다(예: 여백을 포함하거나 자식 요소를 측정).

<window-virtualizer> 입력 레퍼런스

<virtualizer>와 동일하지만 getScrollElement를 허용하지 않습니다. 스크롤 요소는 항상 window입니다. <virtualizer>와 달리 다음 두 가지 기본값이 변경됩니다.

  • horizontal을 허용하며(페이지가 가로로 스크롤됨) 기본값은 false입니다.
  • initialOffset의 기본값은 클라이언트에서 현재 창 스크롤 위치(window.scrollY, 또는 window.scrollX(horizontal일 때))이고 서버에서는 0입니다. 이를 재정의하려면 숫자를 전달합니다. 예를 들어 initialRect와 함께 사용하면 특정 스크롤 위치의 슬라이스를 서버에서 렌더링할 수 있습니다(SSR 참고).

scrollMargin은 window-virtualizer의 대표적인 옵션입니다. 창에서 스크롤되는 목록은 페이지의 맨 위에서 시작하는 경우가 거의 없으므로, 문서 상단에서 목록까지의 오프셋을 전달하고 위치를 지정할 때 item.start에서 이 값을 뺍니다(창 예제에서는 마운트 시 offsetTop으로 측정합니다).

자체 스크롤 핸들러 내부에서는 버추얼라이저가 아닌 요소를 읽습니다

버추얼라이저는 onMount에서 스크롤 리스너를 연결하지만 마크업의 핸들러는 그보다 이른 하이드레이션 시점에 연결됩니다. 따라서 각각의 스크롤 이벤트에서 자체 핸들러가 먼저 실행되며, 이때 버추얼라이저에는 여전히 이전 이벤트의 오프셋이 들어 있습니다. 그러므로 v.isAtEnd()v.getDistanceFromEnd()onScroll 내부에서 호출하면 한 이벤트 이전 상태를 가리킵니다(맨 위로 이동해도 여전히 "끝에 있음"으로 보고할 수 있습니다). 대신 요소에서 계산합니다. 산술식은 동일합니다.

<div/scrollEl onScroll() {
const el = scrollEl()
if (!el) return
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight <= 80
}>

그 밖의 모든 곳(클릭 핸들러, 이펙트, <script> 블록)에서는 v.isAtEnd()와 관련 메서드가 현재 상태를 나타내므로 안전하게 사용할 수 있습니다.

버추얼라이저의 생명 주기는 스크롤 요소의 생명 주기와 일치해야 합니다

<virtualizer>는 스크롤 요소와 동일한 조건부 스코프에 선언합니다. 스크롤 컨테이너가 언마운트된 후 다시 마운트될 수 있다면(<if>, 라우트 전환), 태그를 동일한 조건문 안에 배치합니다.

<if=show>
<virtualizer/v count=1000 estimateSize=() => 35 getScrollElement=() => scrollEl()/>
<div/scrollEl class="list">...</div>
</if>

태그는 onMount에서 활성 버추얼라이저를 생성하고 입력 중 하나가 변경될 때만 getScrollElement를 다시 읽습니다. 입력 변경 없이 교체된 스크롤 요소를 태그는 감지하지 못합니다. Marko의 컴파일 타임 반응성은 getScrollElement thunk를 거쳐 템플릿 스코프 내부까지 추적할 수 없습니다. 따라서 조건문 외부에 계속 마운트된 태그는 제거된 요소에 바인딩된 상태로 남고, 다시 마운트된 후 아무것도 렌더링하지 않습니다. 태그를 같은 위치에 배치하면 태그의 생명 주기가 요소를 따릅니다. 언마운트하면 인스턴스가 해제되고 다시 마운트하면 새 인스턴스가 생성됩니다. (참고: 새 요소는 오프셋 0에서 시작하므로 다시 마운트할 때 스크롤 위치가 초기화됩니다.)

SSR

두 태그 모두 서버에서 <if=mounted> 가드 없이 렌더링되며, 클라이언트 측 onMount에서 관찰 기능이 활성화된 버추얼라이저를 생성합니다. 두 가지 모드가 있습니다.

클라이언트 채우기(기본값). initialRect가 없으면 서버는 빈 컨테이너를 렌더링합니다. 마운트 시 클라이언트가 스크롤 요소를 측정하고 보이는 행을 채웁니다.

<div/scrollEl style="height: 400px; overflow-y: auto; position: relative;">
<virtualizer/v
count=10000
estimateSize=() => 35
getScrollElement=() => scrollEl()
/>
<div style=`height: ${v.totalSize}px; position: relative`>
<for|item| of=v.virtualItems>
<div style=`position: absolute; top: 0; width: 100%; height: ${item.size}px; transform: translateY(${item.start}px)`>
Row ${item.index}
</div>
</for>
</div>
</div>

서버 슬라이스(initialRect). 뷰포트 힌트를 전달하면 서버가 처음 보이는 행을 HTML에 그리므로, 클라이언트가 재개되기 전의 최초 페인트에도 실제 콘텐츠가 존재합니다.

<virtualizer/v
count=people.length
estimateSize=() => 48
getScrollElement=() => scrollEl()
initialRect=({ width: 800, height: 400 })
/>

initialRect는 측정값이 아니라 힌트입니다. 서버에는 실제 뷰포트가 없으므로 이 크기를 사용해 슬라이스를 계산하며, 마운트 시 클라이언트가 실제 스크롤 요소를 다시 측정하고 제어를 넘겨받습니다. 슬라이스를 위해 생성된 인스턴스는 일시적입니다. 활성 상태의 어떤 것도 직렬화되지 않으며, 일반 데이터(항목 위치와 전체 크기)만 전달되고 재개 시 동일하게 다시 계산됩니다. 전체 가져오기 → 직렬화 → 재개 → 슬라이스 흐름은 SSR 데이터 가져오기 예제에 나와 있습니다.

참고(JavaScript 미사용): 서버 슬라이스만으로 JavaScript 없는 페이지가 만들어지지는 않습니다. 버추얼라이저가 플레이스홀더가 있는 <await> 안에 있으면(데이터 가져오기 예제처럼) Marko는 기다린 콘텐츠를 순서와 다르게 스트리밍하고 작은 인라인 스크립트로 표시합니다. JavaScript를 비활성화하면 그려진 행이 HTML에는 있지만 페이지는 빈 화면으로 렌더링됩니다. 스크립트 없이 표시하려면 순서대로 렌더링해야 합니다(플레이스홀더 없음).

스크롤 복원(initialOffset, 요소 전용). 목록을 맨 위가 아닌 특정 스크롤 위치(딥 링크 또는 복원된 스크롤)에서 서버 렌더링하려면 initialOffsetinitialRect와 함께 전달합니다. 서버는 해당 오프셋 주변의 슬라이스를 그립니다(overscan을 포함하므로 처음 그려진 행은 처음 보이는 행보다 몇 행 위에 있습니다). 스크롤 컨테이너의 scrollTop은 HTML에서 선언적으로 설정할 수 없으므로, 행의 위치를 맞추기 위해 마운트 시 클라이언트에서 복원합니다.

<div/scrollEl class="list">
<virtualizer/v
count=people.length
estimateSize=() => 48
getScrollElement=() => scrollEl()
initialRect=({ width: 800, height: 400 })
initialOffset=(100 * 48)
/>
<!-- rows … -->
</div>
<lifecycle onMount() { const el = scrollEl(); if (el) el.scrollTop = 100 * 48 }/>

<window-virtualizer>에서는 오프셋을 window.scrollY(브라우저 스크롤 복원)에서 가져오므로 initialOffset은 별도 prop이 아닙니다.

스트리밍: <await> 내부의 태그

위 SSR 모드는 Marko의 스트리밍과 그대로 조합됩니다. 버추얼라이저를 <await> 안에 배치하면 모든 동작이 스트리밍된 청크별로 유지됩니다.

<try>
<@placeholder>
<p>Loading people…</p>
</@placeholder>
<@catch|err|>
<p>Failed to load: ${(err as Error).message}</p>
</@catch>

<await|people| value=fetchPeople()>
<div/scrollEl class="scroll-container">
<virtualizer/v
count=people.length
estimateSize=() => 48
getScrollElement=() => scrollEl()
initialRect=({ width: 800, height: 400 })
/>
<!-- sizer + rows exactly as usual -->
</div>
</await>
</try>

전송 과정에서 서버는 페이지 셸(플레이스홀더 포함)을 즉시 플러시하고, fetchPeople()이 완료되면 기다린 하위 트리를 나중 청크로 스트리밍합니다. initialRect가 설정된 경우 서버에서 그린 행도 포함됩니다. 스트리밍된 각 청크는 독립적으로 재개됩니다. 이 목록의 활성 버추얼라이저는 페이지의 나머지 부분을 기다리지 않고 해당 청크가 도착할 때 마운트됩니다. 추가 설정은 필요하지 않습니다. 완료된 데이터로 기다린 하위 트리 안에서 슬라이스를 계산하므로 서버와 재개된 클라이언트는 구조상 일치합니다. (위의 JavaScript 미사용 참고 사항이 적용됩니다. 스트리밍된 콘텐츠는 스크립트로 표시됩니다.)

SSR 데이터 가져오기 예제는 클라이언트에서 렌더링한 행을 사용하는 스트리밍 패턴을 보여 주고, 서버 슬라이스 예제는 서버에서 그린 행을 사용하는 패턴을 보여 줍니다. 해당 테스트 스위트에는 플레이스홀더가 먼저 플러시되고 그려진 행이 나중 청크로 도착하는지 확인하는 전송 수준 어설션이 포함되어 있습니다.