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의 데이터만 체크하고 데이터 없는 거만 복사하는 거? 그게 그렇게 오래 안 걸리겠지
라는 생각은 결국 로직을 엎게 됩니다.
진짜 최종체
- 앞의 삽질에서 얻은 교훈으로 로직을 정리 하게 됩니다.
- 생략되는 삽질에 오브젝트 풀이 있는데 해당 내용은 폐기 되었습니다. 이유로는
- vue component의 props를 강제로 바꿀 방법이 없음.
- data를 쓰자니 매번 함수 호출을 해야하고 props성격이 data가 되는 느낌이 싫어서
- 생각보다 create, destroy가 리소스를 잡아먹지 않는다는 점
- 아래는 최종 로직
- 유지해야 할 로직
SetIndex와 @Watch('scrollTop')
- 데이터 추가는
@Watch('listData')가 아닌 AddData(data: any)
로 함수 호출로 변경, 외부 종속성이 심하지만 어쩔 수 없습니다.@Watch('listData')
를 사용하게 될 경우 생각보다 많은 시간 프로그램이 멈추게 되어 해당 로직은 폐기 합니다. - 렌더링 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 });
}
}
내용이 너무 많기도 하고 기억 안 나는 부분도 많아서 두서없이 적긴 했지만 최종 버전만 기억 해주시면 됩니다.
최종 버전은 포스팅 제일 첫 부분에 있는 소스코드 링크에서 확인 해주세요.
정말 이 기능을 만드는 데 오래 걸렸습니다. 시행착오도 많이 겪었고
로직 자체를 만드는 데도 오래 걸렸어요...
사실 이 기능 하나를 못 만들어서 달새 개발이 오랜 기간 멈춰있던 거도 있죠
근데 어쩌다보니 개발에 성공 해서 지금의 달새를 완성하고 공개 한 거 같습니다.
다음에 이 포스팅을 더 보기 좋게 수정을 하던가 해야겟어요
다음에는 또 엄청난 삽질 기록을 들고 오겠습니다.