This article has participated in the good article call order activity, click to see: back end, big front end double track submission, 20,000 yuan prize pool for you to challenge!

This article is about the long list of rendering performance optimization study notes, here to make a summary and share, there are shortcomings but also hope to make corrections ~

There are two main optimization strategies for the long list:

  • Slice rendering (split rendering time through the browser EventLoop mechanism, known as EventLoop)
  • Virtual list (render viewable area only)

Before formal optimization, let’s introduce some basic concepts that can be used:

Processes and Threads

A process is an independent unit of the system for resource allocation and scheduling. A process contains multiple threads. People often say that JS is single-threaded, is that the main process of JS is single-threaded.

Rendering process

The rendering process contains the following threads:

  • GUI rendering thread (page rendering)
  • JS engine thread (execute JS script)
  • Event-triggered thread (EventLoop polling processing thread)
  • Events (onClick), timers, and Ajax also start independent threads

Note that the GUI rendering thread and JS engine thread are mutually exclusive. The JS engine thread is single threaded, which means the main thread is shared.

EventLoop in the browser

Shard rendering

Simple long list to observe the render time

Let’s write a list of 10000 simple pieces of data and print the time. Note that this time is the time for the JS statement to execute, not the time for the browser to render.

<ul id="list"></ul>
<script type="text/javascript">
  const time = Date.now()
  for (let i = 0; i < 100000; i++) {
    const li = document.createElement('li')
    li.innerText = i
    list.appendChild(li)
  }
  console.log(Date.now() - time) / / 160
</script>
Copy the code

In order to get the render time of the browser, according to the EventLoop above, a macro task is executed each time the GUI is rendered, so we can add a timer (macro task) after the render is completed to get the render time.

setTimeout(() = > {
  console.log(Date.now() - time)
}, 0) / / 2800
Copy the code

The effect is as follows:



As you can see, the js execution time is 160 ms, and the total rendering time is 2801 ms. This is because newer versions of the browser are optimized to wait for the for loop to complete before inserting a DOM node into the page, avoiding frequent rearrangements and redraws.

Perform fragmentation optimization

const time = Date.now()
/** * index: record where the loop went * id: what was added to li */
let index = 0, id = 0
function load() {
  index += 50
  if (index < 10000) {
    requestAnimationFrame(() = > { // Use requestAnimationFrame (also a macro task) instead of setTimeout to perform better
      const fragment = document.createDocumentFragment() // Internet Explorer requires document fragmentation, which is usually not required
      for (let i = 0; i < 50; i++) {
        const li = document.createElement('li')
        li.innerText = id++
        fragment.appendChild(li)
      }
      list.appendChild(fragment)
    })
    load()
  }
}
load()
console.log(Date.now() - time)
setTimeout(() = > {
  console.log(Date.now() - time)
})
Copy the code

Because the requestAnimationFrame or timer is a macro task, the associated callback is executed after each GUI rendering, thus adding 50 Li nodes at a time to achieve the purpose of fragment loading. Now the load time is as follows:



It’s actually a lot faster than before the sharding load. But there is a problem with this approach: it can lead to too many DOM elements on the page, which still tends to stall. To solve this problem, use the second optimization strategy, described below.

Virtual list (render only the currently visible area)

Virtual list optimization can be divided into two cases according to whether the height of each item in the list is fixed:

Item height fixed

For code reusability, we will encapsulate a VirtualList component, putting the code first (based on VUE 2.6)

The page app.vue (parent component) that uses the long list of components

// App.vue
<template>
  <div id="app">
    <virtual-list :size="40" :keeps="8" :arrayData="list">
      <template #default="{ item }">
        <div style="height: 40px; border: 1px solid cadetblue;">
          {{ item.value }}
        </div>
      </template>
    </virtual-list>
  </div>
</template>

<script>
import VirtualList from './components/VirtualList.vue'
const list = []
for (let i = 0; i < 1000; i++) {
  list.push({
    id: i,
    value: i
  })
}
export default {
  name: 'App'.components: {
    VirtualList
  },
  data() {
    return { list }
  }
}
</script>

<style lang="less">
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
</style>
Copy the code

Long list component Virtuallist.vue

// VirtualList.vue<template> <! -- Display area --><div class="wrap" ref="wrap" @scroll="handleScroll">
    <! -- To display the scroll bar -->
    <div ref="scrollHeight"></div>
    <! -- Display content -->
    <div class="visible-wrap" :style="{transform: `translateY(${offset}px)`}">
      <div v-for="item in visibleData" :key="item.id" :id="item.id">
        <slot :item="item"></slot>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'VirtualList'.props: {
    size: Number.keeps: Number.arrayData: Array
  },
  data() {
    return {
      start: 0.end: this.keeps,
      offset: 0 // The offset of the list contents}},computed: {
    visibleData() {
      return this.arrayData.slice(this.start, this.end)
    }
  },
  mounted() {
    this.$refs.scrollHeight.style.height = this.arrayData.length * this.size + 'px'
    this.$refs.wrap.style.height = this.keeps * this.size + 'px'
  },
  methods: {
    handleScroll() {
      const scrollTop = this.$refs.wrap.scrollTop
      // Calculate the number of items from which the render starts, subtracting 1 because the rendered data starts from the 0th item
      this.start = Math.ceil(scrollTop / this.size) - 1> =0 ? Math.ceil(scrollTop / this.size) - 1 : 0
      this.end = this.start + this.keeps
      // To keep the rendered list visible while scrolling up (down), move the list down (up)
      this.offset = this.start * this.size
    }
  }
}
</script>


<style scoped lang="less">
.wrap {
  position: relative;
  overflow-y: scroll;
}

.visible-wrap {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
}
</style>
Copy the code

explain

  • The data source for the list is a simple list array of length 1000
  • Our goal is to pass only three values to the VirtualList component to make it work:
    • size: The height of each item
    • keeps: Wants to show a few pieces of data
    • arrayData: Tabular data
  • The VirtualList component must have three parts:
    • Outermost container area. The height is fixed, the scroll bar appears beyond the area, and the height is incomingsizeonkeeps;
    • The height area of the list should be, which is the total height of the list if it is all rendered. Because only renderkeepsIf you specify the number of bars, there is no scrollbar or the scrollbar cannot predict the total length of the list, so use a div with the height of the total length of the list to make the scrollbar display correctly.
    • What to show. The data presented should be total dataarrayDataA certain part of. Presented dataitemYou have to pass it to the parent, where you use it, and that’s where you use the slot.
  • When scrolling through the list (handleScrollTrigger), we need to timely update the data that should be displayed based on the scrolling distance:
    • onscrollYou handle scrolling events in the content area inside the object, so you are listening to the most external wrap container of fixed height.
    • As shown in the figure below: the blue rectangle is the visible area, assuming the incomingkeepsFor 3, when scrolling the list (red rectangle), the rendered list area, which is 3itemThe area (dark blue and green rectangle) will also scroll. If you only change the rendering, that is, start rendering from Item1 based on the scrolling distance, then item1 will replace Item0, which is outside the visible area and cannot be seen.



So you need to look at what’s already rolled out of the viewable areaitemThe number of PI and each termitemThe product of the heights of (offset), and the distance of the movement isthis.start * this.size. Note:offsetIn most cases, is not equal toscrollTopThe value of the.

Note: The scrollTop value of an element is a measure of the distance from the top of the element’s content to its viewport visible content. When the contents of an element do not produce a vertical scroll bar, its scrollTop value is 0.

When scrolling, if only part of the first rendered item is displayed, the bottom part of the visible area will be empty, as shown in the figure below. You can see that the area on the right side of the scroll bar with a height greater than 23 is empty:



The solution is to render more items forward and after the original number of rendered items, then decide which data to rendervisibleDataI’m going to change the calculation of theta, which was definedstartendTo mark which part to cut, the code looks like this:

visibleData() {
  return this.arrayData.slice(this.start, this.end)
}
Copy the code

}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}} Class =”visible-wrap” div =”visible-wrap” div =”visible-wrap” div =”visible-wrap” div =”visible-wrap” div =”visible-wrap” div =”visible-wrap” div =”visible-wrap” div =”visible-wrap” div =”visible-wrap” div In order to see item 1 right at the top of the visible area; Class =”visible-wrap” div (” class=”visible-wrap”); “class=”visible-wrap” div (” class=”visible-wrap”)

Item height is not fixed

Next comes the more common case of each item being highly uncertain in real projects, and again, the code:

App.vue

This is not much of a change from the previous case where the item height was fixed, so just use mockJS to randomly generate paragraph text and simulate the case where each item height was not fixed:

import Mock from 'mockjs'

const list = []
for (let i = 0; i < 1000; i++) {
  list.push({
    id: i,
    value: Mock.Random.cparagraph( 5.10 ) // Generate a random Chinese paragraph with 5 ~ 10 sentences})}Copy the code

Pass a parameter variable to the component, and adjust the style of each item accordingly:

<virtual-list :size="30" :keeps="8" :arrayData="list" :variable="true">
  <template #default="{ item }">
    <div style="border: 1px solid cadetblue; padding: 20px;">
      {{ item.id}}. {{ item.value }}
    </div>
  </template>
</virtual-list>
Copy the code

VirtualList.vue

The complete code looks like this (the style is omitted) :

// VirtualList.vue<template> <! -- Display area --><div class="wrap" ref="wrap" @scroll="scrollFn">
    <! -- To display the scroll bar -->
    <div ref="scrollHeight"></div>
    <! -- Display content -->
    <div class="visible-wrap" :style="{transform: `translateY(${offset}px)`}">
      <div v-for="item in visibleData" :key="item.id" :id="item.id" ref="items">
        <slot :item="item"></slot>
      </div>
    </div>
  </div>
</template>

<script>
import throttle from 'lodash/throttle'
export default {
  name: 'VirtualList'.props: {
    size: Number.// The height or estimated height of each item
    keeps: Number.// The number of rendered items
    arrayData: Array.// Total list data
    variable: Boolean // The height of each term is fixed
  },
  data() {
    return {
      start: 0.// Show the start item
      end: this.keeps, // End item of display (not included)
      offset: 0 // The offset of the list contents}},computed: {
    visibleData() {
      Math.min is used to prevent this. Keeps from going before or after it
      const prevCount = Math.min(this.start, this.keeps) // Render more items before the number of items shown
      this.prevCount = prevCount // Use the handleScroll method
      const renderStart = this.start -  prevCount // The actual render start item
      const nextCount = Math.min(this.arrayData.length - this.end, this.keeps) // Render more items after the number of items shown
      const renderEnd = this.end +  nextCount // The actual end of the render item, because slice does not include the end parameter
      return this.arrayData.slice(renderStart, renderEnd) 
    }
  },
  created() {
    this.scrollFn = throttle(this.handleScroll, 200, { 'leading': false})},mounted() {
    // Calculate the height of the list if it were all rendered
    this.$refs.scrollHeight.style.height = this.arrayData.length * this.size + 'px'
    // Calculate the height of the visible area
    this.$refs.wrap.style.height = this.keeps * this.size + 'px'
    // Cache the height of each item
    this.cacheListPosition()
  },
  updated() {
    this.$nextTick(() = > {
      // Make sure the DOM is updated
      const domArr = this.$refs.items
      if(! (domArr && domArr.length >0)) return // Return if there is no DOM
      domArr.forEach(item= > {
        const { height } = item.getBoundingClientRect() // Get the height of each node
        // Update the data cached in positionListArr by getting the id of the node, which corresponds to the index
        const id = item.getAttribute('id') // Get the id of the node first
        const oldHeight = this.positionListArr[id].height // Get the original height of the corresponding item in the cache array
        const difference = oldHeight - height
        if (difference) { // If the height changes, update the height and bottom of this item (height change does not affect top)
          this.positionListArr[id].height = height
          this.positionListArr[id].bottom = this.positionListArr[id].bottom - difference
          // Each of the following items should be adjusted accordingly
          for (let i = id + 1; i < this.positionListArr.length; i++) {
            if (this.positionListArr[i]) {
              this.positionListArr[i].top = this.positionListArr[i - 1].bottom // The top of the latter term is equal to the bottom of the former term
              this.positionListArr[i].bottom = this.positionListArr[i].bottom - difference
            }
          }
        }
      })
      // Recalculate the height of the list if it is all rendered
      this.$refs.scrollHeight.style.height = this.positionListArr[this.positionListArr.length - 1].bottom + 'px'})},methods: {
    handleScroll() {
      const scrollTop = this.$refs.wrap.scrollTop // Scroll bar scroll distance
      if (this.variable) { // The height of each term is not fixed
        this.start = this.getStartIndex(scrollTop) // Get the index of the first item shown
        this.end = this.start + this.keeps
        PositionListArr [this.start-this.prevcount] [this.start-this.prevcount] [this.start-this.prevcount]]
        For example, when scrolling back from the bottom, the value of this.prevCount might be equal to the value of the previous scroll, but greater than the current value of this.start
        this.offset = this.positionListArr[this.start - this.prevCount] ? this.positionListArr[this.start - this.prevCount].top : 0 
      } else { // Each term has a fixed height
        // Retrieve the index of the item that started the display, subtracting 1 because the display starts at the 0th item
        this.start = Math.ceil(scrollTop / this.size) - 1> =0 ? Math.ceil(scrollTop / this.size) - 1 : 0
        this.end = this.start + this.keeps
        // While scrolling up (down), move the list down (up) in order to keep the displayed list visible
        this.offset = (this.start - this.prevCount) * this.size
      }
    },
  
    getStartIndex(scrollTop) {
      // Use dichotomy to find the bottom value of positionListArr
      let start = 0.// positionListArr's first subscript
      end = this.positionListArr.length - 1.// positionListArr = positionListArr
      // Use Temp to store the final value returned if the exact value is not available, because the value of the scrolling distance may not be equal to the positionListArr array
      // The bottom value of any item, so we have to store the closest value
      temp = null
      while (start <= end) {
        let midIndex = parseInt(start + (end - start) / 2), // The index of the middle term can also be used instead of parseInt math. floor
        midVal = this.positionListArr[midIndex].bottom // The bottom value of the middle term
        if (scrollTop === midVal) {
          // If the value of the scroll is equal to the bottom value of the middle item, then start should be the next item of the middle item
          return midIndex + 1
        } else if (scrollTop < midVal) {
          // The value of the scroll is less than the bottom value of the middle item:
          end = midIndex - 1 // Move end to the front of the middle term
          if (temp === null || temp > midIndex ) // temp > midIndex ensures that the target value range is smaller and smaller
            temp = midIndex
        } else if (scrollTop > midVal) {
          // The value of the scroll is greater than the bottom value of the middle item:
          start = midIndex + 1 // Move start to the item after the middle one
          if (temp === null || temp < midIndex )
            temp = midIndex
        }
      }
      return temp
    },
    
    cacheListPosition() {
      // Cache the height, top value and bottom value of each item in the data array
      // Note: () => ({}) is how to write the returned object
      this.positionListArr = this.arrayData.map((item, index) = > ({
        height: this.size,
        top: index * this.size,
        bottom: (index + 1) * this.size
      }))
    }
  }
}
</script>
Copy the code

explain

Arraydata.length * this.size + ‘px’ is not appropriate because the height size of each item is not certain. Before starting the recalculation, let’s introduce the dichotomy:

dichotomy

  • Use prerequisite: The array has been sorted in ascending order
  • The basic principle of: First, the value to be searched is compared with the midValue of the array, whenstart <= end
    1. ifvalue < midValue,end = mid - 1, you just need to continue looking in the first half of the array
    2. ifvalue = midValue, the match is successful, and the search is complete
    3. ifvalue > midValue,start = mid + 1, you just need to continue the search in the second half of the array
    4. ifwhileNot found at the end of the loopvalueTo return to- 1
const arr = [-1.5.6.12. ]const start = 0,
end = arr.length -1,
mid = start + (end - start) / 2
Copy the code

Note: In the dichotomy, mid = start + (end-start) / 2 is used instead of the simpler formula mid = (start + end) / 2 to prevent overflows. Because the start + end value might be greater than the maximum number that JS can represent. (End-start may also overflow if start < 0 or end < 0)

Recalculate start using dichotomy

  1. After the page loads, the data array for each itemheight.topbottomDo a cache for the value ofsizeThe height of the scrollbar is not accurate), stored in the arraypositionListArr;
  2. Start with the dichotomy to find how far our page scrollsscrollTopCorresponding to thepositionListArrIn which termbottomThe value of the. The reason we use dichotomies is because we’re going to recalculate each term based on the real DOMheight.topbottomAnd then every termsizeIt might not be the same;
  3. After theendoffsetThe calculation principle is the same asitemIt’s the same thing with a fixed height.

After the page is updated

After the page is rendered, the real DOM is obtained, and the data cached in positionListArr is corrected to update the height of the scroll bar. This code uses the ref and getBoundingClientRect information:

  • ifrefIs written in the bookv-forThe reference information will be an array of DOM nodes or component instances.
  • Element.getBoundingClientRect()Method returns the size of the element and its position relative to the viewport, exceptwidthheightOther properties are computed relative to the upper left corner of the view window, as shown below:

Add throttling and effect display

Scroll throttle

Finally, add a throttling effect to the handleScroll to improve performance:

  1. Install lodash:npm i --save lodash
  2. Introduced in Virtuallist.vue:import throttle from 'lodash/throttle'
  3. Use: New definitionscrollFnMethod, substitution<div class="wrap" ref="wrap" @scroll="handleScroll">In thehandleScroll

rendering

One More Thing

Optimizing long list rendering using CSS alone

I recently saw a new CSS property, Content-visibility. You can use this property to render only the content in the current visible window area and skip the content that is not on screen. But the current compatibility is extremely poor, can only say the future is foreseeable ~