想惊艳众人?那就来看下这款不定高的虚拟滚动吧


theme: channing-cyan

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

前言

hello,小伙伴们好呀,我是小羽同学~

性能优化系列文章又更新啦。

小伙伴们在面试的时候,是不是会碰到面试官这样子刁难你呢?

面试官:如果现在有一个这样子的需求,让你渲染一个长列表,这个列表的数据量很大,可能会有上万条的数据,如果直接渲染那么多得dom节点会导致页面的卡顿,这时候你会怎么处理这个问题呢?

你:easy,做下分页就可以了呀

面试官:如果咱们现在不允许分页呢?想实现的是手机端的淘宝商品列表那种效果。

你:哦,那咱们可以使用分页请求+模块化的懒渲染呀(ps:模块化懒渲染其实就是上篇文章中的模块化懒加载的改版,具体可以看这篇文章——《原来懒加载有这些玩法,你确定不看看?》

面试官:嗯,这种方案主要是将卡顿的时间延后了,它确实可以处理列表初始化渲染卡顿的问题,但是注意哦,咱们讨论的上万条数据的场景,当数据量上来后,还是会卡顿的。

你:这样子呀,那咱们可以使用虚拟滚动(虚拟列表)

面试官:那你可以说下,虚拟滚动的原理吗?以及如何避免/减少虚拟滚动快速滚动时的白屏问题?

你:emmm,这个我不懂,我都是直接用第三方库的。

面试官:好的,你的情况我这边都已经了解了,可以先回去了,如果有进展的话咱们尽快会联系你。

等你走后,面试官:只会调用库的渣渣,随便换个前端来都可以。没有一点核心竞争力,pass!!!

img

虚拟滚动是什么

虚拟滚动,又称虚拟列表。简单点来说,就是只渲染可视区域列表项,非可视区域的列表项则直接不渲染,当咱们的列表滚动到对应位置的时候才将对应的列表项渲染出来。它通常应用在那些需要渲染大量数据的场景中,如商品列表。

这就是虚拟滚动的基本概念应用场景,那对于虚拟滚动的话,目前市面上基本上实现的都是定高的虚拟滚动,对于不定高的虚拟滚动却好像还没有太多的解决方案。

定高的虚拟滚动

那咱们先来说说定高的虚拟滚动怎么实现的吧。

假设目前咱们有1w条数据, 每个列表项的高度是100,咱可视区域的高度为500,那么咱们就只会显示5条数据,当咱们滚动到scrollTop15000的时候,50000/500 = 100,即从100开始往后渲染5条数据即可。

如下图:

image-20221127161143124

小伙伴们还记得面试官除了问过虚拟滚动是如何实现外,还问过咱们如何避免或者减少虚拟滚动在快速滚动时候的白屏问题吗?

对于这个问题,咱们通常会预留2-3页缓冲页面,即多渲染一些在可视区域外的数据,从而减少这种白屏的问题。

然后小羽看到知乎上有一个不错的方案,主要是分页+缓存页面,这样子的虚拟滚动的会更好,这里也是直接使用代码做一些小的调整。

import { createRef, useEffect, useState } from 'react';
import { getData } from '@/helper';
import './index.less';
​
export default function LimitVirtualList() {
  // 已知item高度、需要与item css高度保持一致
  const itemHeight = 100;
  const pageSize = 10;
  // 原始数据源
  const [dataSource, setDataSource] = useState([]);
  const scrollRef = createRef<HTMLDivElement>();
  const [totalCount, setTotalCount] = useState(0);
  const [beforeCount, setBeforeCount] = useState(0);
  const [pageNum, setPageNum] = useState(1);
  // 真正渲染的数据
  const [showDataSource, setShowDataSource] = useState([]);
​
  useEffect(() => {
    init();
  }, []);
​
  useEffect(() => {
    sliceShowDataSource();
  }, [pageNum, dataSource.length]);
​
  // 数据初始化
  const init = () => {
    setDataSource(getData());
  };
​
  // 获取需要展示的数据
  const sliceShowDataSource = () => {
    const { showDataSource, beforeCount, totalCount } = getRenderData({
      pageNum: pageNum,
      pageSize: pageSize,
      dataSource: dataSource,
    });
    setShowDataSource(showDataSource);
    setBeforeCount(beforeCount);
    setTotalCount(totalCount);
  };
​
  // 获取最大页数
  const getMaxPageNum = () => {
    return getPageNum({
      scrollTop:
        scrollRef.current.scrollHeight - scrollRef.current.clientHeight,
      pageSize: pageSize,
      itemHeight: itemHeight,
    });
  };
​
  // 1、监听用户scroll事件
  // 2、实时计算页码
  // 3、如果页码发生改变,进行数据切片,重新渲染数据
  // 4、如果页码没有发生改变,保持不动
  const onScroll = () => {
    const maxPageNum = getMaxPageNum();
    const scrollPageNum = getPageNum({
      scrollTop: scrollRef.current.scrollTop,
      pageSize: pageSize,
      itemHeight: itemHeight,
    });
    const currPageNum = Math.min(scrollPageNum, maxPageNum);
    // 如果当前页数保持不变
    if (currPageNum === pageNum) return;
    setPageNum(currPageNum);
  };
​
  // 计算分页
  const getPageNum = ({ scrollTop, pageSize, itemHeight }) => {
    const pageHeight = pageSize * itemHeight;
    return Math.max(Math.floor(scrollTop / pageHeight), 1);
  };
​
  // 数据切片
  const getRenderData = ({ pageNum, pageSize, dataSource }) => {
    const startIndex = (pageNum - 1) * pageSize;
    // 这里+2:想要保证顺畅的滑动,快速滑动不白屏,需要至少预留3页数据,前+中+后
    const endIndex = Math.min((pageNum + 2) * pageSize, dataSource.length);
    return {
      showDataSource: dataSource.slice(startIndex, endIndex),
      // 前置数量
      beforeCount: startIndex,
      totalCount: dataSource.length,
    };
  };
​
  return (
    <div className='limit-virtual-list'>
      {/* 容器层:固定高度 */}
      <div className='scroll' ref={scrollRef} onScroll={onScroll}>
        {/* 滚动层:实际滚动区域高度 */}
        <div style={{ height: `${totalCount * itemHeight}px` }}>
          {/* 通过translateY撑起滚动条 */}
          <div
            className='inner'
            style={{ transform: `translateY(${beforeCount * itemHeight}px)` }}>
            {/* 列表层:实际渲染的数据 */}
            {showDataSource.map((item, index) => {
              return (
                <div className='scroll-item' key={index}>
                  <p>{item.words}</p>
                  <p>{item.content}</p>
                </div>
              );
            })}
          </div>
        </div>
      </div>
    </div>
  );
}
​
​
.limit-virtual-list{
  .scroll {
    height: calc(~'100vh - 80px');
    width: 100%;
    overflow: auto;
    background: #fff;
    padding: 20px;
  }
  
  .scroll-item {
    height: 80px;
    margin-bottom: 20px;
    padding: 0;
    border: 1px solid #eee;
  }
}
​

效果如下图,这里是固定每屏显示10条,并且缓存前后两页,即一共会显示30个dom节点:

定高的虚拟滚动

通过chrome performance查看普通列表和虚拟滚动的性能,在js生成2w条数据渲染的情况下,普通列表需要接近5s的时间,而虚拟滚动仅需要500ms

image-20221127165606083

image-20221127164521410

不定高的虚拟滚动

OK,咱们前面简单的介绍了一下定高的虚拟滚动,并且使用了知乎上的代码来实现了demo。

定高的虚拟滚动的原理其实很简单,就是直接展示(滚动的高度/每项的高度) *展示的数量即可。

但是现在问题来了,咱们的产品经理提需求了,每项的高度由数据自动撑开,不要那种固定高度的。那这样的话,咱们原来得那套定高的虚拟滚动方案就无法使用了啊。

img

没关系,小羽会出手滴!

对于定高的虚拟滚动的话,咱们由于可以计算出对应高度,然后直接显示对应的item节点。那对于不定高的话,咱们是不是可以换一个思路呢?

咱们可以记录每一个item的高度缓存到对象中,当咱们滚动的时候只需要寻找对应的高度即可。

但是item的高度怎么获取呢?从后端直接拿?

虽然可行,但是这样子的话每个item的高度和宽度都是不可变化的,对屏幕的适配来说是十分不友好的。

那还有什么办法呢?

小羽肝了很久,得到如下的一种不定高的虚拟滚动方案

首先咱们预估一下每个item的defaultHeight(默认高度,如果没有这个高度的话,会导致滚动条只有一点点,看起来会很别扭),滚动到对应位置的时候,渲染并将高度记录到咱们的cache对象中。随着滚动条的滚动,咱们会陆续更新每一个item的高度,使得滚动条的高度越来越真实

图解如下:

image-20221127163707861

具体实现如下,不确定的高度目前是通过生成一个随机高度空白div来撑开item的高度,如果有图片这种异步获取资源,建议固定图片的宽高,然后利用object-fit来处理图片被拉伸的问题。

import { useEffect, useRef, useState } from 'react';
import Item from './item';
​
const VirtualScroll = ({
  list,
  containerHeight,
  defaultHeight = 100,
  bufferSize = 20,
  children,
}) => {
  const [state, setState] = useState({
    startOffset: 0,
    endOffset: 0,
    visibleData: [],
  });
  const [anchorItem, setAnchorItem] = useState({
    index: 0, // 锚点元素的索引值
    top: 0, // 锚点元素的顶部距离第一个元素的顶部的偏移量(即 startOffset)
    bottom: 0, // 锚点元素的底部距离第一个元素的顶部的偏移量
  });
  const scrollDataRef = useRef({
    startIndex: 0,
    endIndex: 0,
    scrollTop: 0,
  });
  const cacheRef = useRef({});
  const visibleCountRef = useRef(0);
  const containerRef = useRef(null);
  const wrapperRef = useRef(null);
  const anchorItemRef = useRef({
    index: 0,
    top: 0,
    bottom: 0,
  });
​
  const changeState = (data) => {
    const newState = {
      ...state,
      ...data,
    };
    setState(newState);
  };
​
  // 更新锚点位置
  const changeAnchorItem = (data) => {
    const newAnchorItem = {
      ...anchorItem,
      ...data,
    };
    anchorItemRef.current = newAnchorItem;
    setAnchorItem(newAnchorItem);
  };
​
  // 缓存位置数据
  const cachePosition = (node, index) => {
    const rect = node.getBoundingClientRect();
    const wrapperRect = wrapperRef.current.getBoundingClientRect();
    const top = rect.top - wrapperRect.top;
    cacheRef.current[index] = {
      index,
      top,
      bottom: top + rect.height,
    };
  };
​
  // 处理滚动事件
  const handleScroll = (e) => {
    const curScrollTop = containerRef.current.scrollTop;
    if (
      (curScrollTop > scrollDataRef.current.scrollTop &&
        curScrollTop > anchorItemRef.current.bottom) ||
      (curScrollTop < scrollDataRef.current.scrollTop &&
        curScrollTop < anchorItemRef.current.top)
    ) {
      updateBoundaryIndex(curScrollTop);
      updateVisibleData();
    }
    scrollDataRef.current.scrollTop = curScrollTop;
  };
​
  // 计算 startIndex 和 endIndex
  const updateBoundaryIndex = (scrollTop) => {
    scrollTop = scrollTop || 0;
    // 用户正常滚动下,根据 scrollTop 找到新的锚点元素位置
    const newAnchorItem =
      cacheRef.current[
        Object.keys(cacheRef.current)?.find(
          (key) => cacheRef.current[key].bottom >= scrollTop,
        )
      ];
    if (!newAnchorItem) {
      return;
    }
    changeAnchorItem({ ...newAnchorItem });
    scrollDataRef.current.startIndex = newAnchorItem.index;
    scrollDataRef.current.endIndex =
      newAnchorItem.index + visibleCountRef.current;
  };
​
  // 更新渲染的数据
  const updateVisibleData = () => {
    const visibleData = list.slice(
      scrollDataRef.current.startIndex,
      scrollDataRef.current.endIndex,
    );
    changeState({
      startOffset: anchorItemRef.current.top,
      endOffset: (list.length - scrollDataRef.current.endIndex) * defaultHeight,
      visibleData,
    });
  };
​
  useEffect(() => {
    const containerRect = containerRef.current.getBoundingClientRect();
    // 计算可渲染的元素个数
    visibleCountRef.current =
      Math.ceil(containerRect.height / defaultHeight) + bufferSize;
    scrollDataRef.current.endIndex =
      scrollDataRef.current.startIndex + visibleCountRef.current;
    updateVisibleData();
    list.map((item, index) => {
      cacheRef.current[index] = {
        index: index,
        top: index * defaultHeight,
        bottom: index * defaultHeight + defaultHeight,
      };
    });
    containerRef.current.addEventListener('scroll', handleScroll, false);
    return () => {
      containerRef.current?.removeEventListener('scroll', handleScroll, false);
    };
  }, []);
​
  const { startOffset, endOffset, visibleData } = state;
  return (
    <div
      style={{ height: containerHeight, overflow: 'auto' }}
      ref={containerRef}>
      <div className='wrapper' ref={wrapperRef}>
        <div
          style={{
            paddingTop: `${startOffset}px`,
            paddingBottom: `${endOffset}px`,
          }}>
          {visibleData.map((item, index) => {
            return (
              <Item
                cachePosition={cachePosition}
                key={scrollDataRef.current.startIndex + index}
                item={item}
                index={scrollDataRef.current.startIndex + index}>
                {children}
              </Item>
            );
          })}
        </div>
      </div>
    </div>
  );
};
export default VirtualScroll;
 

使用方式如下:

import React, { useState, useEffect } from 'react';
import { getData } from '@/helper';
import VirtualScroll from '@/components/VirtualScroll';
import './index.less';
​
export default function NotLimitVirtualList() {
  const [list, setList] = useState([]);
  const init = () => {
    setList(getData());
  };
  useEffect(() => {
    init();
  }, []);
​
  return (
    <>
      {list.length > 0 && (
        <VirtualScroll list={list} containerHeight={'100%'}>
          {(item) => (
            <div className='list-item'>
              <p>{item.words}</p>
              <p>{item.content}</p>
              <div style={{ height: item.height }}></div>
            </div>
          )}
        </VirtualScroll>
      )}
    </>
  );
}
​

效果如下:

不定高的虚拟滚动

小结

本文主要以一个常见的面试场景,引入并介绍了虚拟滚动概念应用场景以及如何优化虚拟滚动中的白屏问题,然后使用知乎上的代码实现了定高的虚拟滚动demo,接着介绍了如何使用cache实现一个不定高的虚拟滚动。希望可以在小伙伴们日常的开发面试中,可以提供到一些帮助

如果看这篇文章后,感觉有收获的小伙伴们可以点赞+收藏哦~

img

如果想和小羽交流技术可以加下

喜欢就支持一下吧
点赞8 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容