面试官:能否用原生JS手写一个虚拟列表...啊?你还真能写啊?

13,765 阅读11分钟

扯皮

那是一个风和日丽的下午,作为一名24应届生我像往常一样刷着面经,突然刷到一篇有这样的内容:手写虚拟列表,这几个加粗的大字愣住了,回过神来看了一眼标题,害,字节啊,鼠鼠连简历都过不了,再仔细一看,招的是实习生?点进去看个人信息,好家伙25届的同学,作为24届老人虽然封装过虚拟列表,相关优化也看了不少,定高、不定高、瀑布流虚拟列表能够直接吟唱,但让我现场手撕一个虚拟列表还是有点虚的,看完之后赶紧下来尝试一波,因此有了这篇文章。

正文

关于虚拟列表相关概念以及一些实现方案不再过多介绍,虚拟列表的本质就是用有限的 DOM 渲染 “无限” 的数据,文章的后续内容将默认读者已熟悉虚拟列表的基本实现原理

现在我们把它当作一道普普通通手写题,在面试当中其关键要素有两点:

  • 控制编码时间以及代码量,尽量以最少的代码实现最基本的功能
  • 关于细节的体现更多靠的是而不是

因此我们只实现一个最基本的定高虚拟列表并能够让面试官看到效果,虚拟列表相关的优化以及应用场景等更多的是靠自己现场自由发散。

DOM 基本结构和样式

我们把虚拟列表的实现尽可能简化并以组件的形式搭建,最终只需要三个部分:

  • 列表容器 container
  • 数据列表 list
  • 列表项 item

其他内容均为辅助,为了展示效果这里直接搭建最基本的 DOM 结构并设置其样式:

<div class="container">
  <div class="fs-virtuallist-container">
    <div class="fs-virtuallist-list">
      <!-- <div class="fs-virtuallist-item"></div> -->
    </div>
  </div>
</div>

fs 开头的整体结构可以把它当成一个组件,这里我把 item 进行了注释是因为后续实现会直接通过设置 list 的 innerHTML,先进行占位。 container 作为父组件决定虚拟列表的宽高,因此样式就可以这样设置:

  /* 容器布局并设置具体的宽高 */
  .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;
  }

到此我们就能够看到虚拟列表的基本结构(先写一个单独的 item 看看整体的效果):

基本结构.png

封装虚拟列表类

以类的方式进行封装整体性更强,也方便我们后续编码以及在手写过程中整理思路,我们将其命名为 FsVirtuallist

初始化内部状态

我们要求用户创建实例时传入 containerlist 的选择器,因为内部需要获取 DOM 拿到相关的高度信息,在 constructor 中先初始化内部状态,这里需要的状态见注释:

  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 方法中,其主要功能如下:

  • 获取 container 高度,计算列表最大容纳量
  • container 添加滚动事件
  • 添加模拟数据
  • 渲染视图

这里给出基本的结构代码,最大容纳量这里可以直接计算并保存,剩余的功能后续会逐步实现:

  class FsVirtuallist {
    // constructor(){}...
    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 的固定高度

这里可能会有疑问在于为什么需要向上取整 + 1 ,我们将其分为三种情况:整除、>= 0.5 、< 0.5,其实没有什么好解释的,直接上图看效果:

整除.png

向上取整1.png

向上取整2.png

结论:最大容纳量 = 初始状态列表展示的最大 item 数量 + 1

初始状态指 scrollTop 为 0,展示的最大 item 数量指我们看到的数量(不完整的也算一个)

实际上我们只需要保证需要展示在视图上的 item 项 >= 视图最大容量即可,比如上面高度为 100 时最大容纳量为 5,设置为 6 也可以,但如果小于就会导致列表的留白,计算最大容纳量是正好等于的情况,如果无法理解就无脑设置多一两个也可以(当然也不能过多,否则虚拟列表的意义就不大了,毕竟最大容纳量决定了视图上 DOM 的数量)。

计算 endIndex,renderList

有了最大容纳量 maxCount 以及初始状态 startIndex,我们就能够计算出 endIndex

有了 startIndexendIndex,我们就能够截取数据源来获取需要渲染在视图上的列表 renderList

这里的 endIndex 的设置可以严谨一些,由于后续进行向下滚动时会不断增加 startIndex,但 endIndex 要保证不能超出数据源数组的范围

  class FsVirtuallist {
    // constructor(){}...
    // init(){}...
    computedEndIndex() {
      const end = this.startIndex + this.state.maxCount;
      this.endIndex = this.state.dataSource[end] ? end : this.state.dataSource.length;
    }

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

计算滚动样式 scrollStyle

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

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

注意这里 height 的计算,transform 的偏移量也会变相增加滚动的长度,因此在滚动时 height 需要减去偏移量,否则在滚动时滚动条会不断变小(相当于内容不断增多),而多余的内容都是偏移量造成的留白

  class FsVirtuallist {
    // constructor(){}...
    // init(){}...
    // computedEndIndex(){}...
    // computedRenderList(){}...
    
    computedScrollStyle() {
      const { dataSource, itemHeight } = this.state;
      this.scrollStyle = {
        height: `${dataSource.length * itemHeight - this.startIndex * itemHeight}px`,
        transform: `translate3d(0, ${this.startIndex * itemHeight}px, 0)`,
      };
    }
  }

实现 render 渲染操作

现在基本状态设置已经完成,可以补充真正渲染在视图上的逻辑了,实际上就是两个功能点:

  • renderList 转换为 DOM 渲染在视图
  • 动态设置 list 的样式

不过由于我们是原生手写,不像框架那样只需更改状态数据带动视图渲染,因此每次渲染之前都需要重新计算数据:

  class FsVirtuallist {
    // constructor(){}...
    // init(){}...
    // computedEndIndex(){}...
    // computedRenderList(){}...
    // computedScrollStyle(){}...
    
    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;
    }
  }

计算 startIndex,添加滚动事件

我们在计算 endIndexrenderListscrollStyle 时都依赖了 startIndex,很明显 startIndex 是整个虚拟列表的关键点,但因为初始状态 startIndex 默认值就是 0 ,所以之前没有着重讲解。

startIndex 计算牵扯到滚动事件,每当进行滚动时会产生滚动距离 scrollTop,滚动出去一项我们就需要更新 startIndex

举一个例子 dataSource为 [1, 2, 3, 4, 5, 6],renderList 为 [1, 2, 3],startIndex 为 0 指向第一项 1,当进行滚动第一项已经超出视图范围时,就要把 startIndex 向后移动,也就是 + 1,牵动着 renderList 也进行更新变为 [2, 3, 4]。

可以看到 startIndex 的计算与滚动出的项的个数有关,而滚出项的个数可以通过 scrollTopitemHeight 计算出来。

再次强调我们现在是原生手写没有使用框架,startIndex 被很多变量依赖,当进行改变时我们同样需要更新其他变量的计算,而这些都已经封装到了 render 函数中,重新调用即可。

class FsVirtuallist {
  // constructor(){}...
  // init(){}...
  // computedEndIndex(){}...
  // computedRenderList(){}...
  // computedScrollStyle(){}...
  
  bindEvent() {
    // 注意需要改变 this 指向 -> bind
    this.oContainer.addEventListener("scroll", this.handleScroll.bind(this));
  }
  
  handleScroll() {
    const { scrollTop } = this.oContainer;
    this.startIndex = Math.floor(scrollTop / this.state.itemHeight);
    this.render();
  }
  
  // render() {}...
}

模拟数据

现在虚拟列表的基本操作都已经完成了,为了有数据展示我们增加一个 addData 方法来给 dataSource 中添加数据来模拟真实场景的发送数据请求:

class FsVirtuallist {
  // constructor(){}...
  // init(){}...
  // computedEndIndex(){}...
  // computedRenderList(){}...
  // computedScrollStyle(){}...
  // bindEvent(){}...
  // handleScroll(){}...
  // render() {}...
  
  addData() {
    for (let i = 0; i < 10; i++) {
      this.state.dataSource.push(this.state.dataSource.length + 1);
    }
  }
  
}

加载更多

我们希望实现滚动加载更多的效果,实际上无需通过 DOM 来获取 scrollHeight、scrollTop、clientHeight 来比较计算触底的操作。

可以把这个实现放在 computedEndIndex 中,我们知道 endIndex 当长度正好等于数据源的长度时其实已经到达底部,因此以这个条件判断来获取更多数据再好不过,注意:

如果想要提前表示触底加载数据的话可以把条件设置为 length - 1 或者 - 2

class FsVirtuallist {
  // constructor(){}...
  // init(){}...
  
  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();
    }
  }
  
  // computedRenderList(){}...
  // computedScrollStyle(){}...
  // bindEvent(){}...
  // handleScroll(){}...
  // render() {}...
  // addData() {}...
  
  
}

finish

到此整个最基本的虚拟列表就已经实现了,我们结合之前的 DOM 结构,实例化一个虚拟列表调用 init 来看看实现的效果:

const vList = new FsVirtuallist(".fs-virtuallist-container", ".fs-virtuallist-list");
vList.init();

虚拟列表.gif

优化

现在虽然功能实现了但是性能还是比较差的,原生手写重复计算和执行渲染逻辑确实比不上框架,这里针对于上面实现的代码我提两个优化点。

startIndex 缓存优化

我们注意看滚动的这个细节,右边 DOM 更新情况:

虚拟列表2.gif

实际上没有滚出第一项时 startIndex 不会进行改变,但由于我们在滚动事件中会不断调用 render 函数,造成重复的 innerHTML 更新,很显然这样是不对的。

解决这个问题的方法很简单,我们另存上一次的 startIndex 值,每次滚动时进行比对,没有变化不进行更新即可:

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() {}...

}

现在再来看看效果,注意看右边 DOM 的更新:

虚拟列表3.gif

raf 滚动节流优化

关于滚动事件必然要想到进行节流优化,由于虚拟列表的每次滚动会频繁到视图更新逻辑,如果采用时间戳或者定时器方案并不是最优解,甚至在滚动过快的情况下由于时间间隔的设置会出现白屏问题。

这里需要使用 requestAnimationFrame API,这个 API 相关内容不再过多介绍,我们直接编写一个 rafThrottle 方法,并在 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;
     });
   };
}

}

End

原生 JS 实现的虚拟列表到此就结束了,整个实现核心代码大概 100 行左右,实际上关于虚拟列表的优化还有很多,比如设立缓冲区来缓解白屏问题等,而且实际场景中也大多会使用框架封装组件。这里用 JS 手写主要是为了理清虚拟列表的实现逻辑让面试官能够理解自己的思路,之后再转换为组件就十分容易了。

最后源码奉上:

最后的最后再补充一句,实际上面试当中让手写虚拟列表的情况少之又少,到目前为止我也只看到一篇面经让自己手写实现。

关于虚拟列表的实现原理、优化以及应用场景被问的情况还是比较多的,基本上已经作为应届生必备的技能了,因此还没有了解这块知识的小伙伴一定要自己私底下补充这块内容,也能给自己简历上多加一条亮点。