This article is committed to the virtual scroll realization principle that xiao Bai can understand, step by step in-depth comparison and optimization of the implementation plan, the content is easy to understand, but the length may be longer. If you just want to get an idea of how to implement it, go straight to the graphics or skip to the end of the article.

Let’s just get started.

Why virtual scrolling

Imagine if you had 100,000 pieces of data to show. Let’s try to load it all up. Let’s test chrome’s performance features again and get the following image:

Render time is 4.5s and DOM nodes are 550,000!! Although scrolling is ok in Chrome, it should be optimized. But safari doesn’t open it.

It’s telling us that brute force is not going to work, we need something else, and some people have said we can do paging. But in some situations (or under pressure from product managers), such as contact lists and chat lists, scrolling interaction is still needed.

And even for a normal-size list (hundreds or thousands), the performance gains from using virtual scrolling are noticeable.

And in Google’s Lighthouse development feature, it says:

  • Have more than 1,500 nodes total.
  • Have a depth greater than 32 nodes.
  • Have a parent node with more than 60 child nodes.

This indicates that too many DOM nodes can affect page performance and gives the recommended maximum number of nodes.

The basic idea

Ok, now that we’ve determined that we need to optimize the rendering performance of long lists, the next question is, how do we do that?

From the example we tested above, the Rendering process of the long list is the most time-consuming, Rendering, and browser Rendering. And as we’ve seen, it generates ten thousand DOM nodes. These are the main causes of poor performance. If we could get the browser rendering time down to ms, it would be much smoother. And how to reduce this time? The answer is to have the browser render only the visible DOM nodes.

On-demand rendering

We have a huge amount of data, but we can only see a dozen or two at a time, so why render them all at once? When we render only as many DOM nodes as we can see at the same time, the browser will have very, very few nodes to render, greatly reducing the rendering time!

As shown above, we render only the elements 3, 4, 5, and 6 that are visible in the viewable area, leaving the rest unrendered.

Simulated rolling

We only render visible elements, which means we don’t have native scrolling. We need to simulate scrolling behavior, a scrolling list as the user scrolls the pulley or slides the screen. Our scrolllist here is not really a scrolllist, but rather a re-rendering of the visible list elements based on the position of the scroll.

When the operation is done over a long enough time span, it looks like it’s rolling.

This is a bit like animating frames, where every time the user slides and the offset changes, we render a new list element based on that offset. Just like playing the animation frame by frame, when the interval between the two frames is small enough, the animation looks smooth and almost like scrolling.

Code implementation

Yes, the above two can basically display and scroll long lists on demand, so let’s go ahead and implement it. First let’s look at what we have: a parent element for the list (viewport element), and an array of lists.

Then we implement the first idea: render on demand. We only render elements that are visible to the viewport. There are a few problems:

  1. How many list elements can viewports render?

We already know the height of the viewport (the height of the parent element), assuming the offset is 0 and we start rendering from the first element, how many list elements can it hold? Here we need to set a height for each list element. Find the first list element whose total height exceeds the viewport height by summation height calculation.As you can see from the figure above, if each element is 30px and the viewport height is 100px, then by summing up the viewport can see at most the fourth element.

  1. How do I know which elements to render?

When the user is not scrolling, the offset is 0, and we know to start rendering from the first element. Which element should the user start rendering from after scrolling x pixels?The first thing we need to do is record the total scrolling distance of the list of user actionsvirtualOffsetAnd then we get that by adding the height from the first elementheightSumwhenheightSumthanvirtualOffsetWhen large, the last element to add height is the first element that the viewport needs to render! In the picture we see that the first element is 3. And!!! As you can see from the diagram, 3 is not completely visible, it’s shifted up by some distance, let’s call it thetarenderOffset. Its calculation formula is as follows:RenderOffset = virtualOffset - (heightSum - height of element 3). You can see from this that we need an element to wrap around the list element so that the whole thing is offset. And from question 1, we know that we need to render 3,4,5,6, and the important thing here is to subtractrenderOffset.

  1. How do I render list elements the way I want?

For each list element, we call a function itemElementGenerator to create the DOM, which takes the corresponding list item as an argument and returns a DOM element. This DOM element is loaded into the viewport element as a list element.

OK, let’s go straight to the code!

1. Constructor, we first determine the parameters we need.

class VirtualScroll {
  constructor({ el, list, itemElementGenerator, itemHeight }) {
    this.$list = el // Viewport element
    this.list = list // List data to display
    this.itemHeight = itemHeight // The height of each list element
    this.itemElementGenerator = itemElementGenerator // A DOM generator for list elements}}Copy the code

For convenience, we assume that each element has the same height. Of course, they can be different. Then we need to do some initialization.

2. Perform initialization

class VirtualScroll {
  constructor({ el, list, itemElementGenerator, itemHeight }) {
    // ...
    this.mapList()
    this.initContainer()
  }
  initContainer() {
    this.containerHeight = this.$list.clientHeight
    this.$list.style.overflow = "hidden"
  }
  mapList() {
    this._list = this.list.map((item, i) = > ({
      height: this.itemHeight,
      index: i,
      item: item,
    }))
  }
}
Copy the code

We record the height of the viewport element, and then convert the incoming list data into a data structure that we can easily calculate.

3. Listening events

class VirtualScroll {
  constructor(/ *... * /) {
    // ...
    this.bindEvents()
  }
  bindEvents() {
    let y = 0
    const updateOffset = (e) = > {
      e.preventDefault()
      y += e.deltaY
    }
    this.$list.addEventListener("wheel", updateOffset)
  }
}
Copy the code

We listen for the viewport’s scroll wheel event, which has a property called deltaY that records the direction of the scroll wheel and the amount of scroll. Down is positive, up is negative.

4. Render the list

class VirtualScroll {
  render(virtualOffset) {
    const headIndex = findIndexOverHeight(this._list, virtualOffset)
    const tailIndex = findIndexOverHeight(this._list, virtualOffset + this.containerHeight)

    this.renderOffset = offset - sumHeight(this._list, 0, headIndex)

    this.renderList = this._list.slice(headIndex, tailIndex + 1)

    const $listWp = document.createElement("div")
    this.renderList.forEach((item) = > {
      const $el = this.itemElementGenerator(item)
      $listWp.appendChild($el)
    })
    $listWp.style.transform = `translateY(-The ${this.renderOffset}px)`
    this.$list.innerHTML = ' '
    this.$list.appendChild($listWp)
  }
}
Copy the code
// Find the first sequence whose cumulative height is greater than the specified height
export function findIndexOverHeight(list, offset) {
  let currentHeight = 0
  for (let i = 0; i < list.length; i++) {
    const { height } = list[i]
    currentHeight += height

    if (currentHeight > offset) {
      return i
    }
  }

  return list.length - 1
}

// Get the cumulative height of a segment in the list
export function sumHeight(list, start = 0, end = list.length) {
  let height = 0
  for (let i = start; i < end; i++) {
    height += list[i].height
  }

  return height
}
Copy the code

Here our rendering method mainly relies on the user’s total scroll volume virtualOffset, each of which corresponds to a fixed render frame. We compute the visible sublist first, and then the offset. Finally, the DOM is generated from the sublist, replacing the DOM in the viewport element.

5. View update

With both the scroll record and the render method implemented, the last step is simply to execute the render method when the scroll record changes.

class VirtualScroll {
  constructor(/ *... * /) {
    // ...
    this._virtualOffset = 0
    this.virtualOffset = this._virtualOffset
  }
  set virtualOffset(val) {
    this._virtualOffset = val
    this.render(val)
  }
  get virtualOffset() {
    return this._virtualOffset
  }
  initContainer($list) {
    // ...
+   this.contentHeight = sumHeight(this._list)
  }
  bindEvents() {
    let y = 0
+   const scrollSpace = this.contentHeight - this.containerHeight
    const updateOffset = (e) = > {
      e.preventDefault()
      y += e.deltaY
+     y = Math.max(y, 0)
+     y = Math.min(y, scrollSpace)
+     this.virtualOffset = y
    }
    this.$list.addEventListener("wheel", updateOffset)
  }
}
Copy the code

OK, at this point, our virtual scroll has achieved the basic functionality. Thanks for watching, see you next 👋!

The performance test

First, let’s take a look at whether the basic functionality solves the problem of large data loads.

We tested it again using the Chrome Performance page. Just two silky words! From the image we can see that the rendering time has been reduced from 4.5s to 5ms!! Let’s open it in Safari and try it. Success! Compared to the original, 100,000 data, Safari can not open ah.

Of course, this is just the initial render, and given our approach to render frames, there must be performance issues when scrolling.

We tested its scrolling performance after 10 seconds of continuous scrolling and found that the script execution time was 40% too long. Rendering/drawing time has also increased significantly. But the good thing is that the FPS on the page is actually pretty good with this amount of resource consumption, around 50-70, making the picture smooth without stuttering.

Performance optimization

Now that you know about scrolling performance, I think you know the problem. We rerender the entire list each time the wheel event is triggered. And the wheel triggers on the touchpad at a very high rate!

So let’s see how we can optimize these problems.

  1. First of all, the event trigger frequency, we need to do some throttling.
  2. We need to control the frequency of re-rendering every time we scroll. It’s too expensive.

Event throttling

To put it simply, reduce the frequency of function calls caused by event firing. Of course, here we are throttling only the high-cost functions.

class VirtualScroll {
   bindEvents() {
    let y = 0
    const scrollSpace = this.contentHeight - this.containerHeight
    const recordOffset = (e) = > {
      e.preventDefault()
      y += e.deltaY
      y = Math.max(y, 0)
      y = Math.min(y, scrollSpace)
    }
    const updateOffset = () = > {
      this.virtualOffset = y
    }
    const _updateOffset = throttle(updateOffset, 16)

    this.$list.addEventListener("wheel", recordOffset)
    this.$list.addEventListener("wheel", _updateOffset)
  }
}
Copy the code

You can see that we have stripped out updating virtualOffset because it involves the Render operation. But record offsets we can always fire. So we throttle down the frequency with which we update virtualOffset.

When we set the interval to 16ms, we tested again and got the following results:

You can see that script execution time is cut in half and render/redraw time is reduced accordingly. You can see that the effect is quite noticeable, but when the FPS drops to around 30, the scrolling becomes less smooth. But there is no obvious lag.

List the cache

Even if we reduce the frequency of events, the render interval is still too short to keep scrolling smoothly. So how do I make the render interval longer? This means that between re-renders, users can scroll without re-rendering.

The solution is to pre-render a few more list elements before and after the visual list. This allows us to offset the rendered elements rather than re-render them for a small amount of scrolling, and re-render them when scrolling exceeds the cached elements.

Changing the style properties of the list is much less costly than re-rendering.

The pink area is our cache area, and when scrolling in this area we only need to change the translateY of the list. Note that we don’t use the Y and margin-top attributes here, because transform has a better animation experience.

class VirtualScroll {
  constructor(/ *... * /) {
    // ...
    this.cacheCount = 10
    this.renderListWithCache = []
  }
  render(virtualOffset) {
    const headIndex = findIndexOverHeight(this._list, virtualOffset)
    const tailIndex = findIndexOverHeight(this._list, virtualOffset + this.containerHeight)

    let renderOffset
    
    // The current scroll distance is still in the cache
    if (withinCache(headIndex, tailIndex, this.renderListWithCache)) {
      // Just change translateY
      const headIndexWithCache = this.renderListWithCache[0].index
      renderOffset = virtualOffset - sumHeight(this._list, 0, headIndexWithCache)
      this.$listInner.style.transform = `translateY(-${renderOffset}px)`
      return
    }

    // The following is basically the same as before, but the list has added before and after cache elements
    const headIndexWithCache = Math.max(headIndex - this.cacheCount, 0)
    const tailIndexWithCache = Math.min(tailIndex + this.cacheCount, this._list.length)

    this.renderListWithCache = this._list.slice(headIndexWithCache, tailIndexWithCache)

    renderOffset = virtualOffset - sumHeight(this._list, 0, headIndex)

    renderDOMList.call(this, renderOffset)

    function renderDOMList(renderOffset) {
      this.$listInner = document.createElement("div")
      this.renderListWithCache.forEach((item) = > {
        const $el = this.itemElementGenerator(item)
        this.$listInner.appendChild($el)
      })
      this.$listInner.style.transform = `translateY(-${renderOffset}px)`
      this.$list.innerHTML = ""
      this.$list.appendChild(this.$listInner)
    }

    function withinCache(currentHead, currentTail, renderListWithCache) {
      if(! renderListWithCache.length)return false

      const head = renderListWithCache[0]
      const tail = renderListWithCache[renderListWithCache.length - 1]
      const withinRange = (num, min, max) = > num >= min && num <= max

      return withinRange(currentHead, head.index, tail.index) && withinRange(currentTail, head.index, tail.index)
    }
  }
}
Copy the code

We set the cache size to approximately double that of the visual element and tested it to get the following image:

Script execution time was cut by nearly half, and rendering time was also reduced.

The optimization results

We went from 40% script execution time to 13% now. The result is quite remarkable, but there is still more room for optimization. For example, we now use the whole list to replace, but there are many identical or similar DOM in the middle, so we can reuse some DOM to reduce the DOM creation time.

The progress bar

With the progress bar, it’s really easy. There are a couple of things to be aware of here.

  1. Since the progress bar is proportionally too small, we need to give a minimum height.
  2. When you drag the progress bar, you only need to update it proportionallyvirtualOffsetCan.
  3. Of course, dragging the progress bar also requires event throttling.

Train of thought to sort out

  1. Listen for scroll events/touch events to record the total offset of the list.
  2. Evaluates the visual element starting index of the list based on the total offset.
  3. Render elements from the initial index to the bottom of the viewport.
  4. When the total offset is updated, the list of visual elements is rerendered.
  5. Add buffer elements before and after the visual element list.
  6. When scrolling is small, modify the offset of the visual element list directly.
  7. When there is a large amount of scrolling (such as dragging a scrollbar), the entire list is re-rendered.
  8. Event throttling.

My little station (not suitable for PC)

Source – VirtualScroll. Js

History of hits

  1. How to complete a Business page in 10 minutes – the art of Vue packaging
  2. Axios source code analysis