Source code address, demo

I had a phone interview yesterday morning and was talking about performance optimization, and then the interviewer asked for a long list. In fact, have done before are just simple paging, but the interviewer asked certainly not this role, he is concerned about the virtual list, probably before the implementation of the effect of the source code, although I have not realized but have some of their own ideas, so blablabla…… , may be due to limited expression ability, also do not know the interviewer understand my meaning πŸ˜‚, so simply achieve and record

When experiencing this effect, I opened the Performance panel and analyzed it, but I was not satisfied with it. Find an implementation on the Internet, restore the scene at that time, look at the picture:

The first half is constantly scrolling through the wheel, the second half is quickly dragging the scroll bar, for this kind of scroll-related function, I was so obsessive…… FPS performance is obvious, there is a red alarm, looking at the CPU chart, do you want to smooth it out?

Long list optimization, itself is an optimization behavior (nonsense), but the optimization function at the same time, the optimization itself can not consider optimization, after some tinkering last night, I finally achieved the following effect:

Again, the first half of the scroll wheel, the second half of the scroll bar quickly drag. But there is still a defect after implementation, to be met in the future when the need to optimize

  • Only fixed-height (and consistent) lists are supported
  • When scrolling is very fast, there will be flicker, especially on mobile devices. I have not thought of any good solution to solve it temporarily. This is related to the scroll event mechanism

Source code based on VUE implementation, here to unify the vocabulary

  • ItemRepresents each subitem of a long list

Train of thought

First of all, what is the goal of writing this feature, or the end result

  • Improve the performance of long list pages
  • In terms of experience, users can’t perceive that you’re using long lists
  • Make this feature componentized (not yet considered)

From the above two points, we have work to do

  1. The main direction to improve performance is to reduce the number of render nodes in long list pages. Before optimization, render the entire page. After optimization, it is better to render only the nodes that the user can see, or as few as possible
  2. The optimized page has the same scroll bar feedback as a regular long list page
  3. The optimized scrolling experience is very close to the native scrolling experience
  4. Pull on loading

I can only think of those for now, but let’s implement them one by one.

The sliding window

Why sliding Windows? Locally, we kept a long list of data, but there is no need for them all to join the view, users only need to also can see within the scope of the current viewport display of data, in this case, we can use a container to store the current user needs to see the data, then show the user data in this container, You can think of the container as a little window that moves and updates the view when the user makes a request to see more data.

So how big is the window span?

  • If it is just the height of the viewport, when moving the window down, the Item at the top of the window needs to be removed, because the user does not need to see it, and then pushes the next data to the bottom of the window. In this way, when the window moves quickly, the update frequency will be very fast
  • If you make the window larger, you can reduce the frequency of updates above, equivalent to throttling, depending on the size of the window

Now, let’s zoom in a little bit, just to see how it works

To do this, if I show 10 pieces of data on a page, I will actually render 20 pieces, and divide the 20 pieces of data into two parts, as the viewable area moves to the edge of the container

  1. If the top edge of the viewable area touches the top edge of the container, fill the bottom Item with the first Item, then fill the top Item with 10 items from the original data, and move the container up 10 Item heights
  2. The opposite is true

The DOM structure of the container looks something like this

<div ref="fragment" class="fragment" :style="{ transform: `translate3d(0, ${translateY}px, 0)` }">
  <template v-for="item in currentViewList">
    <div :key="item.key">
      <! -- item content -->
    </div>
  </template>
</div>
Copy the code
// Raw data
const sourceList = [/ *... * /]

1 / / state
const currentViewList = [...sourceList.slice(20.30), ...sourceList.slice(30.40)]

// State 1 down
currentViewList = [...sourceList.slice(30.40), ...sourceList.slice(40.50)]

// State 1 is up
currentViewList = [...sourceList.slice(10.20), ...sourceList.slice(20.30)]
Copy the code

Translate translation is used here because it reduces unnecessary layouts. Moving the container is a very frequent operation in this implementation, so it is important to consider layout consumption

Scroll event

There are a few things to be clear about the scrolling behavior. First look at the image (what the browser does to render each frame), and if you need to know more about it, you can look it up

  1. Scrolling doesn’t have to be continuous, like quickly dragging a scroll bar
  2. The scroll event is executed before each frame is drawn, has its own throttling effect, and is “synchronized” with each frame. Just make sure that the callback logic is simple and fast enough to not trigger backflow operation, so as not to affect the original smooth scroll effect

The scroll bar

The requirement of scrolling behavior determines the use of native scrolling, which is actually very simple. Since the pulp-loading function needs to be realized, we must put a loading at the bottom. In this case, we can set a paddingTop value for Loading, which is the height of Item multiplied by the length of the list. This makes the scrollbar a real scrollbar

<div ref="fragment" class="fragment" :style="{ transform: `translate3d(0, ${translateY}px, 0)` }">
  <! ---->
</div>
<div class="footer" :style="{ paddingTop: `${loadingTop}px` }">
  <div class="footer-loading">Loading......</div>
</div>
Copy the code

Do you use key?

Then for the Item in the container, according to the characteristics of vDOM Diff algorithm:

  1. In the case of the key, half of them will just be swapped when updated, the other half will be removed, and then half of the DOM will be added. If I drag the scrollbar quickly manually, maybe all of the DOM will be deleted and recreated.
  2. If the key is not set, none of the 20 items will be deleted. In this case, you do not need to recreate the DOM by quickly dragging the scroll bar. However, each Item will be reused locally each time, and the disadvantage is that nodes that can only be moved will also be reused locally

My guess is probably not convincing. After I finished writing, I conducted several tests comparing the two cases, and found that the difference between the two cases is not very big (maybe because of my computer πŸ˜‚). After several tests, the situation looks slightly better without using key

Do not use the key

Use the key

I haven’t actually encountered this requirement in years, so I’m going to choose not to use key rendering here

Critical point judgment

There are several ways to do this, and you can calculate the position of the container relative to the viewport by using getBoundingClientRect in the scroll event. Some of you might be wondering, doesn’t the getBoundingClientRect method trigger backflow? You call this method frequently in a scrolling event. Isn’t that bad for performance? Let’s take a look at two small examples:

// δΎ‹1
setInterval((a)= > {
  console.log(document.body.offsetHeight)
}, 100)

// δΎ‹2
let height = 1000
setInterval((a)= > {
  document.body.style.height = `${height++}px`
  console.log(document.body.offsetHeight)
}, 100)
Copy the code

Obviously, example 1 here does not result in backflow, but example 2 does, because you update the layout-related properties in the current frame and then perform a query that causes the browser to do layout and get the correct values back to you. So we usually say that those attributes that lead to layout, not use will be layout, but how you use it.

So the logic of the critical point is something like this:

const innerHeight = window.innerHeight
const { top, bottom } = fragment.getBoundingClientRect()

if (bottom <= innerHeight) {
  // Go to the last Item, down
}

if (top >= 0) {
  // Go to the first Item, up
}
Copy the code

Note that the up and down logic is not triggered very often as the page scrolls. Down, for example, when the trigger logic downward, translateY value to update the container immediately down p (of about 10 down Item level), for the Item at the same time, after the next frame render the lower edge of the container is back with the visual area below, and then continue to scroll down will trigger again after a distance, it looks like a lazy loading, It’s just synchronized.

Rolling direction

It is only necessary to perform downward logic when scrolling down, and the same is true when scrolling up. To deal with the logic in different directions, we need to figure out the current scrolling direction, which can be done by simply saving the last value

let oldTop = 0
const scrollCallback = (a)= > {
  const scrollTop = getScrollTop(scroller)
  
  if (scrollTop > oldTop) {
    / / down
  } else {
    / / up
  }
    
  oldTop = scrollTop
}
Copy the code

implementation

In conjunction with the previous code, let’s bind the scroll event

const innerHeight = window.innerHeight
// Roll the container
const scroller = window
/ / Item container
const fragment = this.$refs.fragment

let oldTop = 0
const scrollCallback = (a)= > {
  const scrollTop = getScrollTop(scroller)
  const { top, bottom } = fragment.getBoundingClientRect()
  
  if (scrollTop > oldTop) {
    / / down
    if (bottom <= innerHeight) {
      // Get to the last Item
      this.down(scrollTop, bottom) / / implementation}}else {
    / / up
    if (top >= 0) {
      // get to the first Item
      this.up(scrollTop, top) / / implementation
    }
  }

  oldTop = scrollTop
}

scroller.addEventListener('scroll', scrollCallback)
Copy the code

Lazy loading

When processing the scroll bar, we have added the loading tag, so we only need to check whether the loading element appears in the visual area in the scroll event, and trigger the loading logic once it appears. There is a boundary case to consider. Once the load logic is triggered, the original data should be updated when I get the response data. If I’m stuck at the bottom, I need to render the new data automatically; If I scroll up before I get the data, then I don’t need to update the new data to the view after I get the response.

const loadCallback = (a)= > {
  if (this.finished) {
    // No more data
    return
  }
  
  const { y } = loadGuard.getBoundingClientRect()
  
  if (y <= innerHeight) {
    if (this.loading) {
      // The load cannot be repeated
      return
    }
    this.loading = true
    
    // Perform an asynchronous request}}Copy the code

Scroll down

First, we need to do some boundary handling, such as the amount of data in currentViewList that is not enough to scroll down. The main thing to note is that scrolling doesn’t have to be continuous

    down (scrollTop, y) {
      const { size, currentViewList } = this
      const currentLength = currentViewList.length

      if (currentLength < size) {
        // The data is not enough to scroll
        return
      }

      const { sourceList } = this

      if (currentLength === size) {
        // Process the second page separately
        this.currentViewList.push(... sourceList.slice(size, size *2))
        return
      }

      const length = sourceList.length
      const lastKey = currentViewList[currentLength - 1].key

      // It is the current last page, but may be loading new data
      if (lastKey >= length - 1) {
        return
      }

      let startPoint
      const { pageHeight } = this

      if (y < 0) {
        // Drag the scrollbar so that the bottom edge of the container appears directly above the viewable area, in which case the current position is calculated by the height of the list
        const page = (scrollTop - scrollTop % pageHeight) / pageHeight + (scrollTop % pageHeight === 0 ? 0 : 1) - 1
        startPoint = Math.min(page * size, length - size * 2)}else {
        // Continuously scroll down
        startPoint = currentViewList[size].key
      }
      this.currentViewList = sourceList.slice(startPoint, startPoint + size * 2)}Copy the code

Scroll up

Scrolling up is handled similarly to scrolling down, so I’ll just paste the code here.

    up (scrollTop, y) {
      const { size, currentViewList } = this
      const currentLength = currentViewList.length

      if (currentLength < size) {
        return
      }

      const firstKey = currentViewList[0].key

      if (firstKey === 0) {
        return
      }

      let startPoint
      const { sourceList, innerHeight, pageHeight } = this

      if (y > innerHeight) {
        const page = (scrollTop - scrollTop % pageHeight) / pageHeight + (scrollTop % pageHeight === 0 ? 0 : 1) - 1
        startPoint = Math.max(page * size, 0)}else {
        startPoint = currentViewList[0].key - size
      }
      this.currentViewList = sourceList.slice(startPoint, startPoint + size * 2)},Copy the code

To this, these functions have been implemented almost, think carefully, if there is no any library or framework directly operated by the native DOM implementation, should be able to achieve better performance, because they can move more directly and reuse the DOM, and less with a layer of vnode reduce inner consumption, such as, but lost the better maintainability, If you can develop this functionality as a separate plug-in, consider it. If the data is on the local server, it seems possible to discard the sourceList, and the page will explode in memory, resulting in a slightly longer blank screen. The writing is relatively fast, slightly rough, there may be bugs, if there are any bugs, please leave a message.

Source code address, demo