렌더링 최적화 기술 - Virtualization

  • Virtualization 이라는 기술을 통해, 뷰포트의 컴포넌트만 렌더링하여 인터렉션 반응속도를 향상시킵니다.
  • 현재 react-virtualized 라이브러리를 쓰고있으나, 컴포넌트가 무겁고 유효기간이 다 되어가 react-window 라이브러리를 사용하라고 권장하고 있습니다. See: https://github.com/bvaughn/react-virtualized#a-word-about-react-window
  • 하지만 반응형 레이아웃을 고려한 최적화를 위해서는 CellMeasurer 컴포넌트를 사용하고, 이와 결합이 잘 되는 react-virtualized를 사용할 수밖에 없습니다.
  • 관련된 사항을 지속적으로 추적하여 virtualization에 관한 올바른 해답을 찾아야할 것입니다.
import { useInfiniteQuery } from '@tanstack/react-query'
import MobileHeader from '../../layouts/NhLayout/MobileHeader'
import { userPostListQueryKey } from '@/queries/user-queries'
import { NH_BIZ, NH_CATEGORY_NEWS } from '../../main'
import { css } from '@emotion/react'
import {
  WindowScroller as _WindowScroller,
  AutoSizer as _AutoSizer,
  List as _List,
  CellMeasurer as _CellMeasurer,
  CellMeasurerCache,
  WindowScrollerProps,
  AutoSizerProps,
  ListProps,
  CellMeasurerProps,
} from 'react-virtualized'
import { FC, useCallback, useEffect } from 'react'
import { SessionStorage } from '@/utils/storage-utils'
import { color, reset } from '@/styles/mixins'
import Link from 'next/link'
import CommonPostItem from './CommonPostItem'
import Loading from '@/components/user/ui-components/Loading'
import { CircularProgress } from '@mui/material'
import { momentKR } from '@/utils/basic-utils'
import DateSeparator from './DateSeparator'
import NewsPostItem from './NewsPostItem'
import NewChip from '../../components/NewChip'
import Router from 'next/router'
import { postService } from '@/services'

const biz = NH_BIZ

const WindowScroller = _WindowScroller as unknown as FC<WindowScrollerProps>
const AutoSizer = _AutoSizer as unknown as FC<AutoSizerProps>
const List = _List as unknown as FC<ListProps>
const CellMeasurer = _CellMeasurer as unknown as FC<CellMeasurerProps>

const cache = new CellMeasurerCache({
  fixedWidth: true,
  // defaultHeight: 417,
})

const getEnglishLocaleDateStr = (dateStr: string) => {
  return momentKR(dateStr).locale('En').format('YYYY.MM.DD ddd').toUpperCase()
}

const styles = {
  wrapper: css({ paddingBottom: 30 }),
  box: css({}),
}

type Props = {
  categorySlug: string
}

const NhPostList = ({ categorySlug }: Props) => {
  const pageSize = categorySlug === NH_CATEGORY_NEWS ? 10 : 5

  const {
    data: infiniteQueryResult,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteQuery({
    queryKey: [...userPostListQueryKey(biz), 'category-infinite', biz],
    queryFn: ({ pageParam }) =>
      postService.getPostListByCategoryForUser(biz, {
        slug: categorySlug,
        pageSize,
        pageNumber: pageParam,
      }),
    initialPageParam: 0,
    getNextPageParam: (lastPage, allPages) => {
      if (lastPage.result.items.length < pageSize) {
        return undefined
      }

      return allPages.length
    },
  })

  const data = infiniteQueryResult?.pages.map((page) => page.result.items).flat() ?? []

  const rowRenderer: ListProps['rowRenderer'] = ({ index, key, parent, style }) => {
    const prevIndex = index - 1

    const post = data[index]
    const prevPost = data[prevIndex]

    const postDateStr = getEnglishLocaleDateStr(post.publishedAt)
    const prevPostDateStr = prevPost ? getEnglishLocaleDateStr(prevPost.publishedAt) : ''

    return (
      <CellMeasurer cache={cache} parent={parent} key={key} columnIndex={0} rowIndex={index}>
        {({ registerChild }) => (
          <div ref={registerChild as any} style={style}>
            {categorySlug === NH_CATEGORY_NEWS ? (
              <>
                {postDateStr !== prevPostDateStr ? <DateSeparator dateStr={postDateStr} /> : null}
                <Link href={`/nh/post/view/${post.slug}`} css={[reset.link]}>
                  <NewsPostItem
                    post={post}
                    isNew={index === 0 && momentKR(post.publishedAt).isSame(momentKR(), 'day')}
                  />
                </Link>
              </>
            ) : (
              <div css={{ marginTop: 30 }}>
                <Link href={`/nh/post/view/${post.slug}`} css={[reset.link]} data-id={post.id}>
                  <CommonPostItem post={post} isNew={momentKR(post.publishedAt).isAfter(momentKR().subtract(1, 'd'))} />
                </Link>
              </div>
            )}
          </div>
        )}
      </CellMeasurer>
    )
  }

  const handleList = useCallback((node: _List) => {
    if (node) {
      cache.clearAll() // 진입시 셀 재 측정을 위해 캐시 삭제
      node.forceUpdate()
      node.measureAllRows()

      window.setTimeout(() => {
        window.scrollTo(0, SessionStorage.postScrollY)
      }, 0)
    }
  }, [])

  useEffect(() => {
    let lastWindowInnerWidth = window.innerWidth
    const resizeHandler = () => {
      const currentWindowInnerWidth = window.innerWidth

      if (currentWindowInnerWidth !== lastWindowInnerWidth) {
        cache.clearAll()
        lastWindowInnerWidth = currentWindowInnerWidth
      }
    }
    const scrollEventHandler = () => {
      //! homee으로 Pop 시에 스크롤 맨 위로가므로 초기에 스크롤 저장하지 않게 하기위함
      if (window.scrollY !== 0) {
        SessionStorage.postScrollY = window.scrollY
      }
    }

    window.addEventListener('resize', resizeHandler)
    window.addEventListener('scroll', scrollEventHandler)

    return () => {
      window.removeEventListener('resize', resizeHandler)
      window.removeEventListener('scroll', scrollEventHandler)
    }
  }, [])

  if (isLoading) return <Loading pageLoading />

  return (
    <>
      <MobileHeader categorySlug={categorySlug} customPrev={() => Router.push('/nh')} />
      <WindowScroller>
        {({ height, scrollTop, isScrolling, onChildScroll, registerChild }) => (
          <div ref={registerChild as any} css={styles.wrapper}>
            <AutoSizer disableHeight>
              {({ width }) => (
                <>
                  <List
                    ref={handleList}
                    autoHeight
                    height={height}
                    width={width}
                    isScrolling={isScrolling}
                    overscanRowCount={0}
                    onScroll={onChildScroll}
                    scrollTop={scrollTop}
                    rowCount={data.length}
                    rowHeight={cache.rowHeight}
                    rowRenderer={rowRenderer}
                    defferedMeasurementCache={cache}
                    //! Hydration 경고 해제용 See: https://github.com/bvaughn/react-virtualized/issues/1737#issuecomment-1219820104
                    style={{ overflowY: 'auto' }}
                  />
                </>
              )}
            </AutoSizer>
            {hasNextPage ? (
              <button
                type="button"
                disabled={isFetchingNextPage}
                css={[
                  reset.button,
                  {
                    width: '100%',
                    border: `1px solid ${color.BB30}`,
                    borderRadius: 10,
                    padding: `10px 0`,
                    fontSize: 18,
                    lineHeight: 1.5,
                    color: color.BB700,
                    marginTop: 30,
                  },
                ]}
                onClick={() => fetchNextPage()}
              >
                {isFetchingNextPage ? <CircularProgress size="1em" color="inherit" /> : '더 보기'}
              </button>
            ) : null}
          </div>
        )}
      </WindowScroller>
    </>
  )
}
export default NhPostList