Vue.js에서 동적 높이 아이템을 렌더링하는 가상 스크롤 구현기

정말 많은 시도를 해보았고 많은 수정을 거친 최종 코드는 아래의 링크입니다
ScrollPanelBase.ts
ScrollItem.ts

내가 필요한 기능

  • 당연히 동적 높이가 적용 되는 가상 스크롤
  • 특정 아이템으로 스크롤이 이동 가능 해야 함
  • 타입스크립트가 지원 돼야 함

라이브러리 탐색

  • 두 종류의 라이브러리가 있었으나 동작을 하지 않거나 특정 아이템으로 스크롤 이동이 가능해야 함이 안 된다던가
    타입스크립트가 지원 돼야 함이 안 된다던가 모든 조건을 불만족 시킴

많은 코드 분석

  • 동적 가상스크롤을 지원하는 라이브러리의 코드가 다 공개 되어 있어 해당 라이브러리 및 동적 가상 스크롤 예제들의 코드를 분석 시작, 상당히 많은 수의 코드를 봤고... 내가 필요한 거에 맞는 로직을 찾음

달새의 특이사항

  • 채팅처럼 메시지가 아래로 쌓이는 방식이 아닌 위에 쌓이는 방식, 트위터가 그런 방식이죠

기본 로직

  • 해당 로직은 설계 초기부터 핵심 로직으로 변하지 않음
//스크롤 아이템 데이터
interface ScrollItem<T> { 
  data: T; // 내부 데이터
  height: number; // 스크롤 아이템 자체의 높이 
  scrollTop: number; // 스크롤 아이템의 scrollTop 위치
  key: string; // 데이터를 찾기 쉽게 하기 위한 key 값
}
list: ScrollItem[] = [];
const minHeight = 40;//스크롤 아이템의 최소 높이입니다. 
//스크롤 아이템이 렌더링 되면서 리사이즈 이벤트 발생 
Resized(key: string, oldHeight: number, newHeight: number){
  //key로 해당 리사이즈 이벤트가 일어난 index를 찾습니다. 
  //index가 5라고 가정 const idx = 5; 
  //oldHeight와 newHeight의 차이를 구합니다. 이 값을 idx 다음부터 모든 아이템의 scrollTop에 더해줄 거예요 
  const moveY = newHeight - oldHeight;
  for(let i = idx ; i < list.length - 1; i++){ 
    list[i].scrollTop += moveY; 
  }
}
  • 화면 상에 표시 되는 top 아이템의 index와 bottom 아이템의 index를 구해야 합니다.
    이 값을 구하는 방식은 두 가지 방식이 사용 됩니다.
    top 아이템은 scrollTop으로 이진탐색을 진행 하고
    bottom 아이템은 top아이템 index + 스크롤 패널 height / minHeight

간단하게 그림으로 봅시다

이게 기본 생성 된 스크롤 영역입니다.

여기서 2번 아이템의 높이가 60으로 바뀝니다.

index 3번부터 scrollTop이 모두 20씩 올라갔습니다.
이제 새 아이템이 추가 됩니다.

기본적인 로직은 여기까지입니다.

여기부터 삽질의 역사입니다.

스크롤의 ScrollTop이 바뀔 때 렌더링 index를 계산 하기

구현 방식

  • 스크롤 패널의 ScrollTop이 바뀔 때마다 렌더링 할 index를 계산하여 이전 index와 다를 경우 재 랜더링
//ScrollTop이 변경 되어 index가 변경 됨
//아이템이 추가 될 때에도 index가 변경 되어야 함. 기존 0번 아이템이 1번이 되기 때문에...

//이벤트 조건: ScrollTop이 변경 되거나 아이템이 추가 되었을 경우
async SetVisibleData() {
  //this.listData : 원본 데이터
  //this.state.listVisible : 렌더링 할 데이터
  if (this.listData.length === 0) return;
  this.state.listVisible = this.listData.slice(this.state.startIndex, this.state.endIndex);
}

문제점

  • 그렇다. index가 바뀔 때마다 전체를 새로 렌더링을 한다!
    리소스는 심각한... 문제까진 안 되는데 화면이 깜빡이는 문제가 발생 합니다.

결국 ScrollTop이 바뀔 떄 렌더링 index 계산하는 방식은 폐기

  • 이후로 많은 시행 착오가 있었지만 큰 맥락으로 기억이 안 납니다... 기억 나면 다시 추가 하는 거로

완전체 동적 가상스크롤 로직까지 최적화 삽질

props로 받는 listData가 없어지기까지

  • vuex에 등록 되어 있는 listData를 props로 받아서 진행 했을 당시 로직
class State {
  scrollTop = 0;
  startIndex = 0;
  endIndex = 50;
  translateY = 0;
  selectKey = '';
  index = 0;
}

class StateData<T> {
  setKey: Set<string>;
  listData: M.ScrollItem<T>[];
  listVisible: M.ScrollItem<any>[] = [];
  constructor() {
    this.setKey = new Set();
    this.listData = [];
  }
}
export class ScrollPanelBase extends Vue {
  state = new State();
  stateData = new StateData();
  @Watch('listData', { immediate: true, deep: true })
  OnChangeListData(newVal: any[]) {
    this.CreateScrollData();
    this.SetIndex();
  }
 CreateScrollData() {
    //props listData가 갱신 될 때만 호출
    const list = (this.listData as unknown) as any;
    if (!list) return;
    const minHeight = moduleUI.minHeight;
    for (let i = 0, len = list.length; i < len; i++) {
      const current = list[i];
      if (!this.stateData.setKey.has(current.id_str)) {
        this.stateData.setKey.add(current.id_str);
        const prev = this.stateData.listData[i - 1];
        const scrollTop = prev ? prev.scrollTop + prev.height : i * minHeight;
        const item: M.ScrollItem<any> = {
          data: current,
          key: current.id_str,
          height: minHeight,
          isResized: true,
          scrollTop: scrollTop
        };
        this.stateData.listData.splice(i, 0, item);
      }
    }
  }
  SetIndex() {
    this.state.scrollTop = this.scrollPanel.scrollTop;
    let scrollTop = this.state.scrollTop;
    if (scrollTop < 0) {
      scrollTop = 0;
    }
    let startIndex = this.BinarySearch(this.stateData.listData, scrollTop);
    startIndex -= 5; //버퍼
    if (startIndex < 0) startIndex = 0;
    if (this.scrollPanel.scrollTop === 0) {
      startIndex = 0;
    }
    this.state.startIndex = startIndex;
    this.state.endIndex = startIndex + Math.floor(this.$el.clientHeight / moduleUI.minHeight);
    if (this.isSmallTweet) {
      this.state.endIndex += 10;
    }
    this.stateData.listVisible = this.stateData.listData.slice(this.state.startIndex, this.state.endIndex);
    this.CreateComponent();
  }
}
  • 위 로직의 핵심은 Vuex의 listData가 바뀔 경우 어느 listData 전체를 루프 돌면서 내부 데이터에 복제하는 짓입니다.

  • for문 1000번 정도 돌고 set의 데이터만 체크하고 데이터 없는 거만 복사하는 거? 그게 그렇게 오래 안 걸리겠지
    라는 생각은 결국 로직을 엎게 됩니다.

진짜 최종체

  • 앞의 삽질에서 얻은 교훈으로 로직을 정리 하게 됩니다.
  • 생략되는 삽질에 오브젝트 풀이 있는데 해당 내용은 폐기 되었습니다. 이유로는
    1. vue component의 props를 강제로 바꿀 방법이 없음.
    2. data를 쓰자니 매번 함수 호출을 해야하고 props성격이 data가 되는 느낌이 싫어서
    3. 생각보다 create, destroy가 리소스를 잡아먹지 않는다는 점
  • 아래는 최종 로직
    1. 유지해야 할 로직 SetIndex와 @Watch('scrollTop')
    2. 데이터 추가는 @Watch('listData')가 아닌 AddData(data: any)로 함수 호출로 변경, 외부 종속성이 심하지만 어쩔 수 없습니다. @Watch('listData')를 사용하게 될 경우 생각보다 많은 시간 프로그램이 멈추게 되어 해당 로직은 폐기 합니다.
    3. 렌더링 dom은 필요 시 create하고 렌더링 영역에서 벗어났을 경우 destroy
//ScrollPanelBase.ts
class StatePool {
  listBench: Vue[] = [];
  constructor() {
    this.listBench = [];//렌더링 할 컴포넌트입니다.
  }
}
@Component
export class ScrollPanelBase extends Vue {
  statePool = new StatePool();
  CreateComponent() {//데이터가 추가 되거나 scrollTop이 변하면서 index가 변할 경우 호출 됩니다.
    //렌더링용 데이터 추가
    const keysBench = this.statePool.listBench.map(x => x.$props['data'].key);
    this.listVisible.forEach(item => {
      if (keysBench.includes(item.key)) {
        return true;
      }
      const selected = item.key === this.stateData.listData[this.state.index].key;
      const component = new ScrollItem({
        propsData: { data: item, itemType: this.itemType, selected: selected }
      });
      component.$on('on-resize', this.OnResizeTweet); //리사이즈 이벤트를 처리하기 위해 추가
      component.$vuetify = this.$vuetify; //뷰티파이를 사용 중이기에 할당 해줍니다.
      this.statePool.listBench.push(component);

      const div = document.createElement('div');
      div.id = item.key;

      this.scrollPort.appendChild(div);

      component.$mount(div);
      //이 부분을 조심 해야 합니다. div를 새로 만들고 해당 div에 mount해야 컴포넌트가 해당 div를 대체합니다.
      this.$children.push(component);
      //다음 현재 ScrollPanel의 자식으로 컴포넌트를 추가 해줍니다.
    });
    //이 아래는 렌더링 영역 밖으로 나간 컴포넌트를 삭제하는 로직입니다.
    const keysVisible = this.listVisible.map(x => x.key);
    for (let i = 0; i < this.statePool.listBench.length; ) {
      if (keysVisible.includes(this.statePool.listBench[i].$props['data'].key)) {
        i++;
      } else {
        const destroy = this.statePool.listBench[i];
        this.statePool.listBench.splice(i, 1);
        destroy.$el.remove();
        destroy.$destroy();
      }
    }
  }
}

개발 조건에 있던 특정 아이템으로 스크롤이 이동 가능 해야 함의 소스 코드

  //외부에서 호출
  //키보드 위, 아래, 맨 위로(홈키), 맨 아래로(엔드키) 등 특정 아이템으로 이동해야 할 경우 모두 이 로직을 거칩니다.
  //해당 함수를 통해서만 스크롤 이동이 되기에 스크롤을 내린 상태에서 아이템이 추가 되어도 보고 있는 렌더링 화면이 고정이 됩니다.
  ScrollToIndex(newVal: number) {
    if (newVal === -1) return;
    this.state.index = newVal;
    const selectData = this.stateData.listData[newVal];
    if (!selectData) return;
    this.state.selectKey = selectData.key;
    const idx = this.listVisible.findIndex(x => x.key === selectData.key);
    if (idx === -1) {
      this.scrollPanel.scrollTo({ top: selectData.scrollTop });
      return;
    }
    const component = this.statePool.listBench.find(x => x.$props.data.key === selectData.key);
    if (!component) return;
    const tweetPos = component.$el.getBoundingClientRect();
    const panelPos = this.scrollPanel.getBoundingClientRect();
    const tweetBottom = tweetPos.y + tweetPos.height;
    const panelBottom = panelPos.y + panelPos.height;
    if (tweetBottom > panelBottom) {
      //내려가는 로직
      const top = tweetBottom - panelBottom;
      this.scrollPanel.scrollTo({ top: this.state.scrollTop + top });
    } else if (tweetPos.top < panelPos.y) {
      //올라가는 로직
      this.scrollPanel.scrollTo({ top: selectData.scrollTop });
    }
  }

내용이 너무 많기도 하고 기억 안 나는 부분도 많아서 두서없이 적긴 했지만 최종 버전만 기억 해주시면 됩니다.
최종 버전은 포스팅 제일 첫 부분에 있는 소스코드 링크에서 확인 해주세요.

정말 이 기능을 만드는 데 오래 걸렸습니다. 시행착오도 많이 겪었고
로직 자체를 만드는 데도 오래 걸렸어요...
사실 이 기능 하나를 못 만들어서 달새 개발이 오랜 기간 멈춰있던 거도 있죠
근데 어쩌다보니 개발에 성공 해서 지금의 달새를 완성하고 공개 한 거 같습니다.

다음에 이 포스팅을 더 보기 좋게 수정을 하던가 해야겟어요
다음에는 또 엄청난 삽질 기록을 들고 오겠습니다.

+ Recent posts