Skip to content

Latest commit

 

History

History
331 lines (252 loc) · 11.3 KB

虚拟列表.md

File metadata and controls

331 lines (252 loc) · 11.3 KB

虚拟列表原理解析

前言

虚拟列表介绍

虚拟列表是解决大数据量渲染列表的一种方案。原理简述就是只渲染当前视口内的列表项。初次接触容易联想到浏览器的栅格化(raster),但是实现原理和类似于懒加载的栅格化不同,它始终只渲染当前视口的内容,而不是即将看到哪,就增加那块部分的渲染。

通常,解决大数据量渲染问题有两种方案,一种是采用时间分片,另一种就是本文要谈的虚拟列表。在正文开始前,不妨简单了解一下第一种方案,时间分片

在什么优化方案都不用的单次渲染中,随着数据量的增多,页面会越来越卡顿。一次性把全部的数据都渲染了,导致了性能问题。思索一下,其实不需要把所有的数据都在第一时间渲染出来,只需要保障首屏的数据一次呈现,后面的逐渐渲染,不就看不出来卡顿了吗。这个渐渐渲染的方案就叫做时间分片

时间分片实现方案简述

直接说目前的最优方案,用requestAnimationFrame api,在浏览器空闲帧的间隙去渲染。 贴一段代码理解一下:

//需要插入的容器
let ul = document.getElementById('container');
// 插入十万条数据
let total = 100000;
// 一次插入 20 条
let once = 20;
//总页数
let page = total/once
//每条记录的索引
let index = 0;
//循环加载数据
function loop(curTotal,curIndex){
    if(curTotal <= 0){
        return false;
    }
    //每页多少条
    let pageCount = Math.min(curTotal , once);
    window.requestAnimationFrame(function(){
        for(let i = 0; i < pageCount; i++){
            let li = document.createElement('li');
            li.innerText = curIndex + i + ' : ' + ~~(Math.random() * total)
            ul.appendChild(li)
        }
        loop(curTotal - pageCount,curIndex + pageCount)
    })
}
loop(total,index);

todo: 使用setTimeout会导致闪屏,原因是时间循环机制导致的执行时间不确定,以及屏幕刷新率的影响

未来的优化更新: DocumentFragment 可以用append方法加入元素样式表的计算,性能更好

虚拟列表与时间分片的对比

听上去按照时间逐渐分片渲染已经是很不错的方案了,但是数据量如果更大更多,用户大概率看不到下面的数据。这时候渲染的性能就浪费了,有些不划算。 因此,更优秀的虚拟列表方案来了,看到哪,渲染到哪,无敌~。但是虚拟列表进行渲染的时候也有一定的前置条件,不然也就没有时间分片渲染什么事了。

原理

虚拟列表初次渲染时,只渲染当前可视区域的列表项。当发生滚动时,通过计算不断的更新列表项的Dom,展示滚动后的Dom,整体随着滚动条的滚出呈现高速更新的状态。

核心的原理就是如何根据滚动条计算,得到应该渲染哪些列表项。实现计算的前提有以下几点:

  1. 固定的可视内容区域(高度固定) 后文简称 curHeight
  2. 列表每一项的高度已知(本文中他们是相同的)。后文简称 itemHeight
  3. 一个额外的用来滚动的元素。它的高度是所有列表项高度 * 列表项的数量 后文简称 allHeight

大致的html结构如下:

<div class="list">
    <div class="list-scroll-placeholder"></div>
    <div class="list-content">
      <!-- item-1 -->
      <!-- item-2 -->
      <!-- ...... -->
      <!-- item-n -->
    </div>
</div>
  • list-scroll-placeholder表示需要滚动的占位元素,高度是allHeight
  • list-content 表示列表渲染的主体,也就是可视内容区域,高度是curHeight。其中的每一项高度是itemHeight
  • scrollTop 表示当前滚动距离顶部的高度
  • startIndex endIndex 分别表示渲染列表项的开头索引和结束索引

有了这些信息,我们尝试着进行一下滚动的计算。

开始索引和结束索引的计算

首先初始的scrollTop为0,随着滚动条的滚动,渲染列表的开始索引结束索引也跟着一起变化。

脑补一下可以想到 startIndex 始终等于 scrollTop 除以 itemHeight

我们向下取整得到开始索引startIndex === Math.floor(scrollTop / itemHeight)

/** 滚动距离顶部的距离 */
const scrollTop = e.target.scrollTop;
startIndex === Math.floor(scrollTop / ITEM_HEIGHT);

由于endIndex等于startIndex加上中间渲染的列表项数。

因此向上取整得到结束索引endIndex === startIndex + Math.ceil(curHeight / itemHeight)

/** 每一项的高度 */
const ITEM_HEIGHT = 40;
/** 可是区域的高度 */
const CUR_HEIGHT = 650;
/** 列表项的数量 */
const ITEM_NUM = Math.floor(CUR_HEIGHT / ITEM_HEIGHT);
endIndex === startIndex + ITEM_NUM;

有了这些计算逻辑,就已经可以实现一个简易的虚拟列表了!

实现

编写一个测试列表

/** 模拟列表数据 */
const BASE_ARR = Array.from(
  { length: 500 },
  (_, k) => `${BASE_ITEM.content}---第${k}次`
);

以react为例,根据起始索引和结束索引,可以维护出一个实际渲染的数组

  /** 实际渲染的数组 */
  const realList = useMemo(() => {
    return BASE_ARR.slice(startIndex, startIndex + ITEM_NUM);
  }, [startIndex]);

增加监听滚动条的逻辑

 document.querySelector(".list").addEventListener("scroll", (e) => {
    const scrollTop = e.target.scrollTop;
    const newIndex = Math.floor(scrollTop / ITEM_HEIGHT);
    setStartIndex(newIndex);
  });

将这个数组和占位滚动条渲染到页面上,虚拟列表的简易版就出现了

<div className="wrap">
    <div
      className="list"
      style={{
        height: `${CUR_HEIGHT}px`,
      }}
    >
      {/* 占位滚动区域,高度为allHeight */}
      <div
        className="list-scroll-placeholder"
        style={{ height: `${BASE_ARR?.length * 40}px` }}
      />
      {/* 可视区域,高度固定为curHeight */}
      <div className="list-content">
        {/* 每一项的高度,高度为itemHeight */}
        {realList.map((item, index) => (
          <div className="list-item" key={index}>
            {item.content}-第{index + startIndex}次
          </div>
        ))}
      </div>
    </div>
</div>

优化

虽然简易版的列表已经”虚拟“起来了,但是很明显,它的更新频率非常高,性能不客观。于是开始学习网上大神们的优化方案

更通用的更新、更好的阅读性

在以上实现的demo中,我们是预设了一些固定的值去计算使用。

但是在增加了后续一些优化后,我们会发现需要控制的值变多了。(放在这里是因为声明了变量,照顾后续的阅读体验)

这时候个人想法将是一些值改为props传参,毕竟大部分都是可配置的。

网上给出了我不曾设想的方案,原来useState也可以变成响应式的...

在demo2中,增加了响应式的状态管理 useReactive

  const state = useReactive({
    data: [], //渲染的数据
    scrollAllHeight: "650px", // 容器的初始高度
    listHeight: 0, //列表高度
    itemHeight: 0, // 子组件的高度
    renderCount: 0, // 需要渲染的数量
    bufferCount: 6, // 缓冲的个数
    start: 0, // 起始索引
    end: 0, // 终止索引
    currentOffset: 0, // 偏移量
  });

在页面渲染时,通过计算取更新state里的值(计算原理是一样的)

  useEffect(() => {
    // 子列表高度
    const ItemHeight = 40;
    // 容器的高度
    const scrollAllHeight = allRef.current.offsetHeight;
    // 列表高度
    const listHeight = ItemHeight * list.length;
    //渲染节点的数量
    const renderCount = Math.ceil(scrollAllHeight / ItemHeight) + state.bufferCount;
    state.renderCount = renderCount;
    state.end = renderCount + 1;
    state.listHeight = listHeight;
    state.itemHeight = ItemHeight;
    state.data = list.slice(state.start, state.end);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [allRef]);

监听逻辑设置

以上我们的监听滚动条计算,是借助了useEffect,这种情况下如果组件渲染了两次,它就执行创建了两个”监听“。

使用useEventListener可以避免这种情况(内部借助了useRef,阻止了多次设置监听)

  useEventListener(
    "scroll",
    () => {
      // 顶部高度
      const { scrollTop } = scrollRef.current;
      state.start = Math.floor(scrollTop / state.itemHeight);
      state.end = Math.floor(
        scrollTop / state.itemHeight + state.renderCount + 1
      );
      state.currentOffset = scrollTop - (scrollTop % state.itemHeight);
    },
    { target: scrollRef }
  );

升级版useMemo,利用useCreation优化缓存列表

据说相当于升级版的useMemo

听着牛逼唬人,其实就是用useRef的特性不重复render了呗

  useCreation(() => {
    state.data = list.slice(state.start, state.end);
  }, [state.start]);

useCreation源码

import type { DependencyList } from 'react';
import { useRef } from 'react';
import depsAreSame from '../utils/depsAreSame';

export default function useCreation<T>(factory: () => T, deps: DependencyList) {
  const { current } = useRef({
    deps,
    obj: undefined as undefined | T,
    initialized: false,
  });
  if (current.initialized === false || !depsAreSame(current.deps, deps)) {
    current.deps = deps;
    current.obj = factory();
    current.initialized = true;
  }
  return current.obj as T;
}

增加了缓存节点

优化前的版本很粗糙,可以看出随着滚动条滚动,整个列表都在高速更新。网上给出了缓存节点 的方案,原理很像懒加载,在没显示的区域预留几个缓存节点。

很牛,自己没想到这一点

增加它的原理很简单,只要在计算的时候多计算几个缓存节点就行了

 //渲染节点的数量
 const renderCount = Math.ceil(scrollAllHeight / ItemHeight) + state.bufferCount;
 // state.bufferCount 即为缓存节点的数量,它的数量是自定义的

更多的可优化的方向

  1. 下拉滚动加载请求数据

触底的时候触发请求,将数据拼接到list当中

  useEventListener('scroll', () => {
    
    // 顶部高度
    const { clientHeight, scrollHeight } = scrollRef.current
    // 滚动条距离的高度
    const button = scrollHeight - clientHeight - scrollTop
    if(button === 0 && onRequest){
      onRequest()
    }
  }, scrollRef)
  1. 不定高的虚拟列表

不定高很麻烦,因为无法计算出每个高度的情况,导致列表的整体高度偏移量都无法正常的计算

解决思路:

  • 第一种,将ItemHeight作为参数传递过来,我们可以根据传递数组来控制,但这种情况需要我们提前将列表的高度算出来,算每个子列表的高度很麻烦,其次这个高度还要根据屏幕的大小去变化,这个方法明显不适合
  • 第二种,预算高度,我们可以假定子列表的高度也就是虚假高度(initItemHeight),当我们渲染的时候,在更新对应高度,这样就可以解决子列表高度的问题

参考链接原文中有实例

<完>

优化方案参考: https://juejin.cn/post/7121551701731409934#heading-16