Virtual Scroll Exploration and Encapsulation

1 Introduction

what is 虚拟滚动it 虚拟滚动It is to control the creation and destruction of dom in the large list through js, only create 可视区域dom, 非可视区域的domnot create. In this way, when rendering data in a large list, only a small number of doms are created to improve performance.

2. Classification

In virtual scrolling technology, virtual scrolling can be divided into 定高虚拟滚动and 非定高虚拟滚动. Fixed height means that each list element has a fixed height, and non-fixed height means that the height of each list element changes dynamically. The implementation of fixed-height virtual scrolling is relatively easy and has high performance; the failure of non-fixed-height virtual scrolling is a bit more complicated, and its performance is worse than that of fixed-height virtual scrolling. Whether it is fixed-height virtual scrolling or non-fixed-height virtual scrolling, it is a classification of virtual scrolling technology 对于大数据渲染都有很大的性能提升.

Let's analyze the implementation of two virtual scrolling technologies step by step, and encapsulate them into commonly used components. The chosen technology stack is vue, and it is also very convenient to change to react or angular.

3. Fixed height virtual scrolling

3.1 Packaging ideas

The structure of virtual scrolling is as follows:
insert image description here

The virtual scroll consists of three parts, 渲染容器container, , 渲染数据listand 撑开滚动条的容器clientHeightRef. The rendering container is the area we need to render, the rendering data is the data in the visible area, and the container with the scroll bar open represents the height of all the data rendered.

Since we only render the data in the visible area, the scroll bar height of the rendering container is incorrect, 需要撑开滚动条的容器来撑开实际的高度.

The html structure is as follows:

<div class="virtual-list">
      <!-- 这里是用于撑开高度,出现滚动条用 -->
      <div class="list-view-phantom" ref="clientHeightRef" :style="{ height: list.length*itemHeight + 'px' }"></div>
      <ul v-if="list.length > 0" class="option-warp" ref="contentRef">
        <li
          :style="{ height: itemHeight + 'px' }"
          class="option"
          v-for="(item, index) in virtualRenderData"
          :key="index"
        >
          {
   
   {item}}
        </li>
      </ul>
  </div>

The height of each piece of data is this.itemHeight=10, the height of the rendering container is this.containerHeight=300, then the data that needs to be rendered for one screen is count=Math.ceil(this.containerHeight / this.itemHeight).

Suppose our required data list is as follows:

const list = [
    {id:1,name:1},
    {id:2,name:3},
    ....
]

Then the height of the container that stretches the scroll bar is this.list.length*this.itemHeight.

We add an event that monitors scrolling to the rendering container, mainly to obtain the current scrolling scrollTopand update the data of the rendered visible area. As follows, we encapsulate a data function that updates the rendered visible area:

const update = function(scrollTop = 0){
    this.$nextTick(() => {
        // 获取当前可展示数量
        const count = Math.ceil(this.containerHeight / this.itemHeight)
        const start = Math.floor(scrollTop / this.itemHeight)
        // 取得可见区域的结束数据索引
        const end = start + count
        // 计算出可见区域对应的数据,让 Vue.js 更新
        this.virtualRenderData = this.list.slice(start, end)
    })
}

When the scroll bar is scrolling, we need listto intercept the data that the current rendering container can just render, so that it looks like it is really scrolling. Although the above scrolling function has updated the data of the rendered visible area, when we scroll, we will find that the content block is scrolled to the top, and it disappears when we scroll again. This is because the scroll bar is extended by the container that stretches the scroll bar. The height of the rendered content is only the height of the container, so it will only appear at the top, and it will not move when scrolling. The effect is as follows:
insert image description here

So as we roll and roll, we still need to 将渲染内容往对应的方向偏移. For example, the distance in the y direction of the offset is the distance of scrollTop,

this.$refs.contentRef.style.webkitTransform = `translate3d(0, ${
      
      scrollTop * this.itemHeight}px, 0)`

In this way, when scrolling, the rendered content is always kept at the top of the rendering container, as if it really scrolls with the scroll bar.

But in fact, this effect is not good, because what we update 颗粒度is divided by each piece of data, not according to scrollTop, so the offset of the rendered content also needs to 每一条数据的颗粒度be updated according to, the code is as follows:

const update = function(scrollTop = 0){
    this.$nextTick(() => {
        // 获取当前可展示数量
        const count = Math.ceil(this.containerHeight / this.itemHeight)
        const start = Math.floor(scrollTop / this.itemHeight)
        // 取得可见区域的结束数据索引
        const end = start + count
        // 计算出可见区域对应的数据,让 Vue.js 更新
        this.virtualRenderData = this.list.slice(start, end)
        + this.$refs.contentRef.style.webkitTransform = `translate3d(0, ${start * this.itemHeight}px, 0)`
    })
}

The above code can basically meet the basic needs, but when we scroll faster, there will be an instant blank at the bottom of the rendering area, because the dom is not rendered in time, and the reason is that we only render just one screen of data.

In order to reduce the appearance of blank space, we should pre-render several pieces of data bufferCount, adding 渲染缓存区间:

const update = function(scrollTop = 0){
    this.$nextTick(() => {
        // 获取当前可展示数量
        const count = Math.ceil(this.containerHeight / this.itemHeight)
        const start = Math.floor(scrollTop / this.itemHeight)
        // 取得可见区域的结束数据索引
        + const end = start + count + bufferCount
        // 计算出可见区域对应的数据,让 Vue.js 更新
        this.virtualRenderData = this.list.slice(start, end)
        this.$refs.contentRef.style.webkitTransform = `translate3d(0, ${start * this.itemHeight}px, 0)`
    })
}

3.2 Complete code and demo address

Address of fixed-height virtual scroll demo: https://atdow.github.io/learning-conclusion/#/packages-examples/virtual-list

Set height virtual scroll code address: https://github.com/atdow/learning-conclusion/tree/master/src/packages/virtual-list

4. Non-height-fixed virtual scrolling

4.1 Packaging ideas

After reading the fixed-height virtual scrolling above, we already have a basic understanding of virtual scrolling technology. For this 非定高虚拟滚动, the biggest problem that needs to be solved is 每一条需要渲染的数据的高度是不确定that it is difficult for us to determine how many pieces of data need to be rendered on one screen.

In order to determine how many pieces of data need to be rendered on one screen, we need to assume that the height of each piece of data to be rendered is a hypothetical value, define estimatedItemHeight=40an array for storing the height of each piece of rendering data itemHeightCache=[], and define an array for storing the distance from the top of each piece of rendering data An array of itemTopCache=[](used to improve performance, will be explained later), and a variable that defines the height of the scrolling container when the scroll bar is opened scrollBarHeight.

Suppose our required data list is as follows:

const list = [
    {id:1,name:1},
    {id:2,name:3},
    ....
]

Let's initialize first itemHeightCache、itemTopCache和scrollBarHeight:

const estimatedTotalHeight = this.list.reduce((pre, current, index) => {
    
    
        // 给每一项一个虚拟高度
        this.itemHeightCache[index] = {
    
     isEstimated: true, height: this.estimatedItemHeight }
        // 给每一项距顶部的虚拟高度
        this.itemTopCache[index] = index === 0 ? 0 : this.itemTopCache[index - 1] + this.estimatedItemHeight
        return pre + this.estimatedItemHeight
      }, 0)
// 列表总高
this.scrollBarHeight = estimatedTotalHeight

With the above initialization data, we can proceed for the first time 假设渲染:

// 更新数据函数
const update = function() {
    
    
      const startIndex = this.getStartIndex()
      // 如果是奇数开始,就取其前一位偶数
      if (startIndex % 2 !== 0) {
    
    
        this.startIndex = startIndex - 1
      } else {
    
    
        this.startIndex = startIndex
      }
      this.endIndex = this.getEndIndex()
      this.visibleList = this.list.slice(this.startIndex, this.endIndex)
      // 移动渲染区域
      if (this.$refs.contentRef) {
    
    
        this.$refs.contentRef.style.webkitTransform = `translate3d(0, ${
      
      this.itemTopCache[this.startIndex]}px, 0)`
      }
}

// 获取开始索引
cont getStartIndex = function() {
    
    
      const scrollTop = this.scrollTop
      // 每一项距顶部的距离
      const arr = this.itemTopCache
      let index = -1
      let left = 0,
        right = arr.length - 1,
        mid = Math.floor((left + right) / 2)
      // 判断 有可循环项时进入
      while (right - left > 1) {
    
    
        /*
        二分法:拿每一次获得到的 距顶部距离 scrollTop 同 获得到的模拟每个列表据顶部的距离作比较。
        arr[mid] 为虚拟列高度的中间项
        不断while 循环,利用二分之一将数组分割,减小搜索范围
        直到最终定位到 目标index 值
      */
        // 目标数在左侧
        if (scrollTop < arr[mid]) {
    
    
          right = mid
          mid = Math.floor((left + right) / 2)
        } else if (scrollTop > arr[mid]) {
    
    
          // 目标数在右侧
          left = mid
          mid = Math.floor((left + right) / 2)
        } else {
    
    
          index = mid
          return index
        }
      }
      index = left
      return index
}

// 获取结束索引
const getEndIndex = function() {
    
    
      const clientHeight = this.$refs.scrollbarRef?.clientHeight //渲染容器高度
      let itemHeightTotal = 0
      let endIndex = 0
      for (let i = this.startIndex; i < this.dataList.length; i++) {
    
    
        if (itemHeightTotal < clientHeight) {
    
    
          itemHeightTotal += this.itemHeightCache[i].height
          endIndex = i
        } else {
    
    
          break
        }
      }
      endIndex = endIndex
      return endIndex
}

update函数It is used to update the data that needs to be rendered. The core logic is to obtain the start index getStartIndexand end index of the intercepted data .getEndIndex移动被渲染数据容器

When the scroll bar is scrolling, we will scrollTopsave it. At this time itemTopCache, we will get scrollTopthe closest index from it, which is the starting index of the data we need to intercept. Because itemTopCachewhat is stored is the distance from the top of each piece of data, so just fetch it directly, which is why we need to store it first itemTopCache. Because when scrolling, we have itemTopCacheto use the binary search method, otherwise we have to itemHeightCachetraverse from the beginning to the end one by one to compare and search, which is easy to cause lag when the amount of data is large.

getEndIndexThe core is itemHeightCacheto take the data one by one from (the array that stores the height of each piece of rendering data), from startIndexthe beginning to the height that just fills the rendering container, and then we can get the last index of our intercepted data (actually this is Not perfect, continue to explain later).

The technique of moving the rendered data container is similar to the height-fixed virtual scroll above, so I won’t explain too much here.

itemHeightCache、itemTopCache和scrollBarHeightAfter initialization , we can manually call updatethe function once for the first rendering (this.update()), using the preset assumed values.

Before talking about updating, we need to define subcomponents, which are containers for each piece of rendered data. In this way, when the data is updated and rendered (the exposure indexand heightparameters need to be notified), it can be obtained 真实的dom的高度and notified to update itemHeightCache、itemTopCache和scrollBarHeight. The update logic is as follows:

const updateItemHeight = function({
     
      index, height }) {
    
    
      // 每次创建的时候都会抛出事件,因为没有处理异步的情况,所以必须每次高度变化都需要更新
      // dom元素加载后得到实际高度 重新赋值回去
      this.itemHeightCache[index] = {
    
     isEstimated: false, height: height }
      // 重新确定列表的实际总高度
      this.scrollBarHeight = this.itemHeightCache.reduce((pre, current) => {
    
    
        return pre + current.height
      }, 0)
      // 更新itemTopCache
      const newItemTopCache = [0]
      for (let i = 1, l = this.itemHeightCache.length; i < l; i++) {
    
    
        // 虚拟每项距顶部高度 + 实际每项高度
        newItemTopCache[i] = this.itemTopCache[i - 1] + this.itemHeightCache[i - 1].height
      }
      // 获得每一项距顶部的实际高度
      this.itemTopCache = newItemTopCache
}

After the dom is updated, the required rendering data calculated by the initial predetermined value is actually rendered. At this time, we can call the updatefunction again to update the data again, and the automatic update makes up for the data that needs to be rendered to render a real screen.

const updateItemHeight = function({
     
      index, height }) {
    
    
      // 每次创建的时候都会抛出事件,因为没有处理异步的情况,所以必须每次高度变化都需要更新
      // dom元素加载后得到实际高度 重新赋值回去
      this.itemHeightCache[index] = {
    
     isEstimated: false, height: height }
      // 重新确定列表的实际总高度
      this.scrollBarHeight = this.itemHeightCache.reduce((pre, current) => {
    
    
        return pre + current.height
      }, 0)
      // 更新itemTopCache
      const newItemTopCache = [0]
      for (let i = 1, l = this.itemHeightCache.length; i < l; i++) {
    
    
        // 虚拟每项距顶部高度 + 实际每项高度
        newItemTopCache[i] = this.itemTopCache[i - 1] + this.itemHeightCache[i - 1].height
      }
      // 获得每一项距顶部的实际高度
      this.itemTopCache = newItemTopCache
      + this.update() // 自动更新
}

When scrolling, store scrollTop, manually call updatethe function, it will be updated automatically, the whole process is as follows:

insert image description here

The html structure is as follows:

 <div class="virtual-list-dynamic-height" ref="scrollbarRef" @scroll="onScroll">
    <div class="list-view-phantom" :style="{ height: scrollBarHeight + 'px' }"></div>
    <!-- 列表总高 -->
    <ul ref="contentRef">
      <Item
        v-for="item in visibleList"
        :data="item.data"
        :index="item.index"
        :key="item.index"
        @update-height="updateItemHeight"
      >
        {
   
   {item}}
      </Item>
    </ul>
</div>

The difference from fixed-height virtual scrolling is that subcomponents need to be defined and passed to subcomponent indexindexes at the same time. The data format that visibleListneeds to be defined as will be stored so that it can be obtained when the subcomponent is updated .[{index:xxx,data:xxx}]indexindex

4.2 Tuning

In the above code, the basic non-fixed height virtual scrolling can basically be realized, but it still cannot deal with complex situations.

Let's take an extreme example: when the real height of a piece of data is 200, the real height of other data is 10, and the height of the rendering container is 300. After the first hypothetical render and after updating ours itemHeightCache、itemTopCache和scrollBarHeight, we'll get something like this. The data rendered in the rendering container is the first piece of data and the remaining 9pieces of data, just enough to render a screen of data, so there is no problem.

When the scroll bar scrolls, the distance we have scrolled should be 20pxobtained , because the data closest to the top is the first data, which will cause a blank area at the bottom. When scrolling , what is obtained is the same , the principle is the same as above, and the blank area caused by the lower part will be terrifying .startIndex020px80pxstartIndex080px
insert image description here

bufferCountIn order to solve the blank area, it is not enough to rely on buffer rendering . Even if bufferCountit is given 4, four more pieces of data cannot fill the blank area. It is easy to cause performance problems if it is increased bufferCount, and it is not sure bufferCounthow much is appropriate. So getEndIndexthe logic that needs to be adjusted is no longer from startIndexgetting to just filling the rendering area, but from startIndexgetting to just filling the rendering area +statIndex的高度. In this way, no matter startIndex的高度how much it is, we can fill the entire rendering container, because the maximum height of the blank area is startIndex的高度. At the same time, we can endIndexadd it to the top bufferCountto achieve the perfect effect.

// 获取结束索引
const getEndIndex = function() {
    
    
      + const whiteHeight = this.scrollTop - this.itemTopCache[this.startIndex] // 出现留白的高度
      const clientHeight = this.$refs.scrollbarRef?.clientHeight //渲染容器高度
      let itemHeightTotal = 0
      let endIndex = 0
      for (let i = this.startIndex; i < this.dataList.length; i++) {
    
    
        + if (itemHeightTotal < clientHeight+whiteHeight) {
    
    
          itemHeightTotal += this.itemHeightCache[i].height
          endIndex = i
        } else {
    
    
          break
        }
      }
      + endIndex = endIndex + bufferCount
      return endIndex
}

3.3 Complete code and demo address

Non-fixed height virtual scroll demo address: https://atdow.github.io/learning-conclusion/#/packages-examples/virtual-list-dynamic-height

Unfixed height virtual scrolling code address: https://github.com/atdow/learning-conclusion/tree/master/src/packages/VirtualListDynamicHeight

4 Summary

With the non-fixed-height virtual scrolling component, isn’t it possible to deal with various situations, why do you need to make a fixed-height virtual scrolling component?

In the above encapsulation ideas, we can clearly know that the non-fixed-height virtual scroll component is rendered with assumed values, and will be updated after the actual rendering, while everything in the fixed-height virtual scroll is determined. Therefore, the advantage of fixed-height virtual scrolling is that it has higher performance than non-fixed-height virtual scrolling. The disadvantage is that it can only deal with the situation where each piece of rendering data is fixed.

Fixed height virtual scrolling:

  • Advantages: performance is higher than non-fixed height virtual scrolling
  • Disadvantage: It can only be applied to scenes where the height of each piece of rendering data is fixed

Non-fixed height virtual scrolling:

  • Advantages: lower performance than fixed-height virtual scrolling
  • Disadvantages: Can be applied to scenes where the height of each piece of rendering data is dynamic

Guess you like

Origin blog.csdn.net/weixin_50096821/article/details/129233510