구리

[기타] vue virtual scroll list 라이브러리 분석 본문

기타

[기타] vue virtual scroll list 라이브러리 분석

guriguriguri 2025. 7. 31. 13:20

vue virtual scroll 라이브러리 관련 문제의 원인을 찾고 해결 방안을 모색하기 위해 라이브러리를 분석한 글입니다.

컴포넌트 마운트 후 scrollToIndex를 통해 하단 요소로 접근시 해당 요소로 스크롤되지 않는 이슈

문제

  • 배열 요소가 1000개이고 virtual scroll을 통해 목록을 노출, 이때 요소별 height는 모두 다름
  • 컴포넌트 마운트 후 test 버튼 클릭시 scrollToIndex를 통해 최하단 (ex: 995) 요소로 접근할 경우, 해당 요소로 스크롤 되지 않음

원인

virtual scroll 컴포넌트 생성시 아래와 같이 동작한다.

1. scrollToIndex

  • 해당 인덱스에 해당되는 요소의 offset을 가져와 변경
  • getOffset 내부적으로 getIndexOffset를 통해 offset을 계산
1. scrollToIndex
var offset = this.virtual.getOffset(index);
this.scrollToOffset(offset);

2. getIndexOffset

  • 스크롤할 인덱스만큼 반복문 돌면서 offset을 계산
  • this.sizes는 각 요소별 높이로 한번 렌더링된 요소들은 this.sizes에 저장되나 그렇지 않은 요소들은 값이 없음
  • 만약 인덱스별 size가 없다면 평균값을 구해서 계산
var offset = 0;
var indexSize = 0;

for (var index = 0; index < givenIndex; index++) {
  // this.__getIndexOffsetCalls++
  indexSize = this.sizes.get(this.param.uniqueIds[index]);
  offset = offset + (typeof indexSize === 'number' ? indexSize : this.getEstimateSize());
} // remember last calculate index


this.lastCalcIndex = Math.max(this.lastCalcIndex, givenIndex - 1);
this.lastCalcIndex = Math.min(this.lastCalcIndex, this.getLastIndex());

이때 첫 마운트 후 하단의 요소들은 렌더링된 적이 없기에 sizes에 height가 저장되지 않아 평균값(getEstimateSize)으로 계산되어 높이가 정확하지 않음

Tanstack virtual은 어떻게 버그가 발생하지 않을까?

해당 라이브러리에선 이슈가 발생하지 않으며 scrollToIndex 호출시 아래와 같이 동작한다.

  1. tryScroll
  • 해당 인덱스의 offset 조회
  • 오프셋 위치로 스크롤 발생
  • requestAnimationFrame을 통해 리렌더링 후 브라우저 스크롤 위치와 첫번째에서 계산한 오프셋이 일치하는지 비교
  • 결과가 일치하지 않다면 특정 횟수만큼 반복하면서 일치할 때까지 비교 (scheduleRetry)
 scrollToIndex = (
    index: number,
    { align: initialAlign = 'auto', behavior }: ScrollToIndexOptions = {},
  ) => {
    if (behavior === 'smooth' && this.isDynamicMode()) {
      console.warn(
        'The `smooth` scroll behavior is not fully supported with dynamic size.',
      )
    }

    index = Math.max(0, Math.min(index, this.options.count - 1))

    let attempts = 0
    const maxAttempts = 10

    const tryScroll = (currentAlign: ScrollAlignment) => {
      if (!this.targetWindow) return

      const offsetInfo = this.getOffsetForIndex(index, currentAlign)
      if (!offsetInfo) {
        console.warn('Failed to get offset for index:', index)
        return
      }
      const [offset, align] = offsetInfo
      this._scrollToOffset(offset, { adjustments: undefined, behavior })

      this.targetWindow.requestAnimationFrame(() => {
        const currentOffset = this.getScrollOffset()
        const afterInfo = this.getOffsetForIndex(index, align)
        if (!afterInfo) {
          console.warn('Failed to get offset for index:', index)
          return
        }

        if (!approxEqual(afterInfo[0], currentOffset)) {
          scheduleRetry(align)
        }
      })
    }

    const scheduleRetry = (align: ScrollAlignment) => {
      if (!this.targetWindow) return

      attempts++
      if (attempts < maxAttempts) {
        if (process.env.NODE_ENV !== 'production' && this.options.debug) {
          console.info('Schedule retry', attempts, maxAttempts)
        }
        this.targetWindow.requestAnimationFrame(() => tryScroll(align))
      } else {
        console.warn(
          `Failed to scroll to index ${index} after ${maxAttempts} attempts.`,
        )
      }
    }

    tryScroll(initialAlign)
  }

즉, 오프셋 계산 후 실제 그려진 DOM과 비교를 통해 실제 오프셋과 캐시된 오프셋과 일치하지 않을 경우 재계산 & 보정 로직이 없기에 vue virtual scroll list에서 에러가 발생한 것이다.

기타

getScrollOffset이 브라우저 스크를 위치를 나타내는 이유?

1. \_willUpdate

  • Virtualizer가 업데이트 될 때 observeElementOffset이라는 함수를 호출하고, 그 콜백 함수 안에서 this.scrollOffset이 새로운 offset으로 갱신
// packages/virtual-core/src/index.ts L529-L542
_willUpdate = () => {
    // ...
    // 스크롤 이벤트 리스너를 설정하는 부분
    this.unsubs.push(
      this.options.observeElementOffset(this, (offset, isScrolling) => {
        this.scrollAdjustments = 0
        this.scrollDirection = isScrolling
          ? this.getScrollOffset() < offset
            ? 'forward'
            : 'backward'
          : null
        this.scrollOffset = offset // ◀ 콜백에서 받은 offset으로 업데이트
        this.isScrolling = isScrolling

        this.maybeNotify()
      }),
    )
  // ...
}

2. observeElementOffset

  • element\['scrollTop'\] 또는 element\['scrollLeft'\]라는, 라우저가 제공하는 실제 DOM 요소의 스크롤 위치 속성에서 직접 값을 가져오고 scrollOffset에 저장
// packages/virtual-core/src/index.ts L146-L195
export const observeElementOffset = <T extends Element>(
  instance: Virtualizer<T, any>,
  cb: ObserveOffsetCallBack, // ◀ 2단계에서 본 콜백 함수
) => {
  const element = instance.scrollElement
  // ...
  const createHandler = (isScrolling: boolean) => () => {
    const { horizontal, isRtl } = instance.options
    offset = horizontal
      ? element['scrollLeft'] // DOM의 실제 scrollLeft 값
      : element['scrollTop']  // DOM의 실제 scrollTop 값
    // ...
    cb(offset, isScrolling) // ◀ 읽어온 실제 DOM 값을 콜백으로 전달
  }
  const handler = createHandler(true)
  // ...
  // scroll 이벤트가 발생할 때마다 handler를 실행하도록 등록
  element.addEventListener('scroll', handler, addEventListenerOptions)
  // ...
}