面试官让我手写一个虚拟列表,我只能说一百行搞定~

983 阅读7分钟

在众多项目难点与亮点中,虚拟列表一直都是所有前端必备的一个技术。虽然没有太多的含金量,但是胜在在业务上对人的体验感比其他技术手段都好。

本文将使用最少的代码,给你带来原生虚拟列表的优化与实现。

搭建一个架子

<style>
  /* 布局容器并设置具体的宽高 */
  .container {
    width: 600px;
    height: 600px;
    margin: 100px auto;
    border: 1px solid red;
  }

  /* 作为虚拟列表组件宽高由父组件决定,注意这里需要保证垂直方向有滚动条 */
  .fs-virtuallist-container {
    width: 100%;
    height: 100%;
    /* 必须有这个属性 */
    overflow-y: auto;
  }

  /* list 高度会用 JS 设置动态样式 */
  .fs-virtuallist-list {
    width: 100%;
  }

  /* item 固定高度即可,其他样式仅为了做展示 */
  .fs-virtuallist-item {
    width: 100%;
    height: 100px;
    box-sizing: border-box;
    border: 1px solid #000;
    text-align: center;
    font-size: 20px;
    line-height: 100px;
  }
</style>
<!-- 布局容器 -->
<div class="container">
  <!-- UI容器(虚拟列表组件本身) -->
  <div class="fs-virtuallist-container">
    <div class="fs-virtuallist-list">
      <!-- <div class="fs-virtuallist-item"></div> -->
    </div>
  </div>
</div>

封装一个虚拟列表类

该类命名为:FsVirtuallist,并且我们补充该类必备的各种状态与属性

class FsVirtuallist {
  constructor(containerSelector, listSelector) {
    this.state = {
      dataSource: [], // 模拟数据源
      itemHeight: 100, // 固定 item 高度
      viewHeight: 0, // container 高度
      maxCount: 0, // 虚拟列表视图最大容纳量
    };
    this.scrollStyle = {}; // list 动态样式(高度,偏移)
    this.startIndex = 0; // 当前视图列表在数据源中的起始索引
    this.endIndex = 0; // 当前视图列表在数据源中的末尾索引
    this.renderList = []; // 渲染在视图上的列表项
    // 根据用户传入的选择器获取 DOM 并保存
    this.oContainer = document.querySelector(containerSelector);
    this.oList = document.querySelector(listSelector);
  }
  init() {} // 初始化虚拟列表
  computedEndIndex() {} // 计算结束索引
  computedRenderList() {} // 计算被渲染列表
  computedScrollStyle() {} // 计算滚动样式
  render() {} // 渲染函数
  handleScroll() {} // 	计算开始索引并渲染
  bindEvent() {} // 绑定滚动事件并触发handleScroll函数
  addData() {} // 添加数据源
}
const fv = new FsVirtuallist(".fs-virtuallist-container", ".fs-virtuallist-list");
fv.init();

根据上述类中的各种属性和方法,我们来一步步完善虚拟列表类

初始化虚拟列表

init方法中,我们主要要完成如下几个功能:

  • 获取 container 高度,计算列表最大容纳量
  • container元素,绑定滚动事件
  • 添加模拟数据
  • 初始化渲染视图
init() {
  this.state.viewHeight = this.oContainer.offsetHeight;
  // 计算列表最大容纳量
  this.state.maxCount = Math.ceil(this.state.viewHeight / this.state.itemHeight) + 1;
  this.bindEvent(); // 绑定滚动事件
  this.addData(); // 添加模拟数据
  this.render(); // 初始化渲染视图
}

计算列表最大容量

container 的高度除以 item 的固定高度得出的值,然后向上取整并加一。

Q:为什么要向上取整并加一?

A:我们分为三种情况:

  1. 整除的情况下:

红框(容器):高400px

黑框(子项):高100px

最大容量为:400/100 + 1 = 5

画板

  1. =0.5的情况下:

红框(容器):高400px

黑框(子项):高150px

最大容量为:400/150 ≈ 3

3+ 1 = 4

画板

  1. <0.5的情况下:

红框(容器):高400px

黑框(子项):高120px

最大容量为:400/120 ≈ 4

4+ 1 = 5

画板

从根本上的原因来说,是因为但用户滚动到最后一个的时候时,没有第五个会导致列表留白,不好看~

当然,换句话说,多留几个也可以~

计算列表的开始索引与结束索引

要在列表中渲染数据源中对应的数据,我们需要找到一个渲染范围。

渲染范围为:[startIndex(开始索引),endIndex(结束索引)]

开始索引 = 列表顶部距离父元素的距离 / 每一个子项的高度

结束索引 = 开始索引 + 最大容量

computedEndIndex() {
  // 计算结束索引
  const end = this.startIndex + this.state.maxCount;
  this.endIndex = this.state.dataSource[end] ? end : this.state.dataSource.length; 
  // 滚动加载更多(下文解释)
  // if (this.endIndex === this.state.dataSource.length) {
  //   this.addData();
  // }
}
handleScroll() {
  const { scrollTop } = this.oContainer;
  this.startIndex = Math.floor(scrollTop / this.state.itemHeight); // 计算开始索引
  this.render();
}

得出被渲染数据列表

根据渲染范围:[startIndex(开始索引),endIndex(结束索引)]

来取出数据源中的数据。

computedRenderList() {
  this.renderList = this.state.dataSource.slice(this.startIndex, this.endIndex);
}

计算滚动样式

虚拟列表需要在用少量的 DOM 情况下也要给用户营造滚动的假象,当 list 的高度超出 container自然会出现滚动条,而 list 最初的高度就是数据源的 length * item 高度

现在有了滚动效果,但是我们还要保证数据列表的正确展示,这也是虚拟列表原理的核心原理,用户向下滚动而列表整体向上移动,每滚动出一项我们立即设置列表样式将其压回初始状态,同时更新列表数据。

压回操作可以用 translate 或者 padding,这里我们采用 translate3d

computedScrollStyle() {
  const { dataSource, itemHeight } = this.state;
  // 下压高度为: 已经展示的元素数量高度的和(下压的高度可以类比为滚动条的上半部分的高度)
  const transform = this.startIndex * itemHeight;
  // 剩余高度为: 总高度 - 已经展示的元素高度(这个值可以类比为滚动条的下半部分高度加上滚动条灰色部分的高度)
  const height = dataSource.length * itemHeight - transform; 
  this.scrollStyle = {
    transform: `translate3d(0, ${transform}px, 0)`,
    height: `${height}px`,
  };
}

计算得出的transform值与height值,可以类比到滚动条部分的下图位置。两者值的比列是与滚动条呈现出的比列是一样的。

滚动条灰色部分:所代表的就是显示的部分

绿色部分:所代表的就是滑块偏移的距离(我们甚至可以控制transform来让列表定位到指定部分)

蓝色部分:所代表的就是目前数据源*子项高度的值,也就是没有显示数据加上当前展示的数据

实现渲染函数

基本状态全部高度,开渲染视图

render() {
  this.computedEndIndex();
  this.computedRenderList();
  this.computedScrollStyle();
  const template = this.renderList.map((i) => `<div class="fs-virtuallist-item">${i}</div>`).join("");
  const { height, transform } = this.scrollStyle;
  this.oList.innerHTML = template;
  this.oList.style.height = height;
  this.oList.style.transform = transform;
}

绑定滚动事件

现在我们来绑定滚动事件,每滚动一次便触发一次渲染

bindEvent() {
  // 注意需要改变 this 指向 -> bind
  this.oContainer.addEventListener("scroll", this.handleScroll.bind(this));
}

此时,已基本完成了所有的工作,但是我们还没有完成触底加载的数据的工作现在完成一下

触底加载数据

触底加载数据我们可以根据结束索引来完成,当计算出结束索引等于数据源最后一个值时,加载数据即可

computedEndIndex() {
  const end = this.startIndex + this.state.maxCount;
  this.endIndex = this.state.dataSource[end] ? end : this.state.dataSource.length;
  // 滚动加载更多
  if (this.endIndex === this.state.dataSource.length) {
    this.addData();
  }
}

现在完成了我们虚拟列表的所有工作,打开浏览器看看效果吧~

缓存开始索引优化渲染

根据上述实现,我们能显而易见的看见,鼠标每滚动一下都会触发一次渲染。即便是在一个子项的内部进行滚动,也会触发渲染。因此我们可以缓存开始索引,当开始索引没有发生改变时就不触发渲染。

class FsVirtuallist {
 constructor(containerSelector, listSelector) {
   // ...
   this.lastStart = -1; // 保存上一次的 startIndex,初始化不能与 startIndex 一样,故设置为 -1
   // ...     
 }
 
 // init(){}...
 // computedEndIndex(){}...
 // computedRenderList(){}...
 // computedScrollStyle(){}...
 // bindEvent(){}...
 
 handleScroll() {
   const { scrollTop } = this.oContainer;
   this.startIndex = Math.floor(scrollTop / this.state.itemHeight);
   // 比对更新
   if (this.startIndex !== this.lastStart) this.render();
   // 保存上一次的 startIndex
   this.lastStart = this.startIndex;
 }
 
 // render() {}...
 // addData() {}...

}

Raf函数滚动节流优化渲染

从所周知,虚拟列表的每次滚动会频繁到视图更新逻辑。我们直接把这些计算任务放在浏览器渲染之前,把空间留给其他计算任务上。即可带来相当程度的收益~

使用 requestAnimationFrame API,并在 bindEvent 中进行处理:

class FsVirtuallist {
  // constructor() {}...
  // init(){}...
  // computedEndIndex(){}...
  // computedRenderList(){}...
  // computedScrollStyle(){}...

  bindEvent() {
    this.oContainer.addEventListener("scroll", this.rafThrottle(this.handleScroll.bind(this)));
  }

  // handleScroll() {}...
  // render() {}...
  // addData() {}...

  rafThrottle(fn) {
    let lock = false;
    return function (...args) {
      window.requestAnimationFrame(() => {
        if (lock) return;
        lock = true;
        fn.apply(this, args);
        lock = false;
      });
    };
  }

}

设立缓冲区解决白屏问题

当用户滚动过快时,就会出现加载数据的速度跟不上用户滚动的速度。一般来说有两种解决方案:

  1. 网络提速
  2. 设立缓冲区

网络提速这个方法,我们就不用考虑了。让用户花钱买网速,怎么想都不可能嘛

我们只有设立缓冲区这一种办法了,而方法也很简单。就是把渲染的列表变长,一次性多渲染几个数据,让用户先多看几个。在同时多添加几个数据进入。

我们只需要在计算结束索引时,把结束索引向后多设置几个即可

Q:为什么不同时给开始索引向上多设置几个?

A: 因为向上滚动必然是已经加载出来的数据,必然是网络请求结束后拿到了的值,必然是存在于缓存中的,因此只需要拿值渲染即可~

      computedEndIndex() {
        const memory = 3; // 设置多渲染3个子项
        const end = this.startIndex + this.state.maxCount + memory;
        this.endIndex = this.state.dataSource[end] ? end : this.state.dataSource.length;
        // 滚动加载更多
        if (this.endIndex === this.state.dataSource.length) {
          this.addData();
        }
      }

Q:为什么不设置一个比较大的值?

A: 其实也可以,但是这样就会触发下面的addData函数,然后发网络请求取值。

这就是本文的所有内容咯,感谢白哥 - 掘金的大力支持~