The purpose of the plug-in

When the page has a large amount of data, such as thousands of data to render, the DOM will scroll too much and cause the phenomenon of stalling. At this time, this plug-in can dynamically render the DOM of the visual area, and calculate and change the data displayed in the visual area in real time when scrolling.

The principle of

How many items are displayed on the page is determined by the height of the visible area and the height of each item in the items (itemSize, which can be height or width of a horizontal slide). The items that can be displayed are wrapped in a pool array for rendering, and the pool array is dynamically modified as the page scrolls. In order to minimize the cost of scrolling, out-of-range views in the pool will be recycled to the reuse pool. New views in the pool will be taken from the reuse pool first, and those that are not reused will be added.

Data flows through the page

In order to achieve the purpose of dynamic rendering and DOM reuse, the following three pools for storing corresponding items are mainly maintained.

  • pool: Displays the current pageView of the pool, which stores the data to be rendered on the current page. That is, the pool is used in tempalte rendering.
<div
    v-for="view of pool"
    :key="view.nr.id"
    :style="ready ? { transform: `translate${direction === 'vertical' ? 'Y' : 'X'}(${view.position}px)` } : null"
    class="vue-recycle-scroller__item-view"
    :class="{ hover: hoverKey === view.nr.key }"
    @mouseenter="hoverKey = view.nr.key"
    @mouseleave="hoverKey = null"
>
Copy the code
  • $_viewsEach time addView adds a view, it will not only put the view in the pool, but also put a copy in the views.The data dictionary makes it easy to find the viewWhen the page is scrolling, it will fetch the view between startIndex and endIndex. Each view will go to the views first, which is more efficient than traversing the pool. If the view is found, it means that the current view has been in the visual area, and at this time, it can be directly displayed in the reuse views. If it is not found in views, it means it is a new view, then first go to the reuse pool according to the type, find the reuse, can not find the addView, after the new views will be added.
  • $_unusedViews: Reuse pool, stores views that are not visible by type. Each scroll first out of view to the unusedViews, after the lost. Do a visual traversal between startIndex and endIndex. When a new view appears, look for it in unusedViews first, and then take it out. If you can’t find it, go addView

The following is the initialization of the data during initialization

created () {
    // Record the start index when the refresh is complete
    this.$_startIndex = 0
    // Record the end index of the flush
    this.$_endIndex = 0
    // All views displayed on the page correspond to the pool to facilitate quick search
    this.$_views = new Map(a)// Reuse pool: store unused views based on their type
    this.$_unusedViews = new Map(a)// Indicates whether the stream is rolling
    this.$_scrollDirty = false
    // Record where the start value was last rolled
    this.$_lastUpdateScrollPosition = 0

    // In SSR mode, we also prerender the same number of item for the first render
    // to avoir mismatch between server and client templates
    if (this.prerender) {
      this.$_prerender = true
      this.updateVisibleItems(false)}},Copy the code

The principle of

The main principle of the whole plug-in is focused on updateVisibleItems(view refresh function), which is triggered by initialization, page scrolling, page resize, and so on. The total process is divided into four steps:

  1. Calculate the visual range: After obtaining the scroll information, first calculate the index range of the items to be displayed in the visual area, that is, startIndex and endIndex.

    • Gets the start and end values for the currently displayed section and determines whether enough scrolling has been done. If the scroll is small, the items displayed in the visual area do not change and do not need to be refreshed.
    GetScroll is calculated based on scrollerTop, etc
    const scroll = this.getScroll()
    
    // Skip update if use hasn't scrolled enough
        if (checkPositionDiff) {
          // Check that the current scrolling range does not exceed the itemSize set, that is, there is no more than one view. In this case, there is no need to change the pool, so no update operation is performed this time
          let positionDiff = scroll.start - this.$_lastUpdateScrollPosition
          if (positionDiff < 0) positionDiff = -positionDiff
          if ((itemSize === null && positionDiff < minItemSize) || positionDiff < itemSize) {
            return {
              continuous: true,}}}// Refresh the position information after this scroll
        this.$_lastUpdateScrollPosition = scroll.start
    
        // Calculate the offset. The default buffer is 200 and can be customized
        const buffer = this.buffer
        scroll.start -= buffer
        scroll.end += buffer
    
        // Variable size mode
        // Highly variable mode
        // Since the height of each item is not fixed, you cannot directly use scroll. Start to get startIndex. So through the dichotomy, quickly find the first view that appears in the visible area, that is, startIndex.
        // Since the calculation attribute has cached all the size records of variable height, the purpose of the binary search is equivalent to finding the index of sizes. The accumulator of the index is less than the accumulator of the index. If the accumulator of index+1 is greater than Scroll. start, it is startIndex that is just slid into the visible area
        if (itemSize === null) {
          let h
          let a = 0
          let b = count - 1
          // Record the starting point of binary search here
          let i = ~~(count / 2)
          let oldI
    
          // Searching for startIndex
          do {
            oldI = i
            h = sizes[i].accumulator
            if (h < scroll.start) {
              // The minimum value is set to I
              a = i
            } else if (i < count - 1 && sizes[i + 1].accumulator > scroll.start) {
              // Note If both I and I +1 are out of range, set the maximum value to I and continue searching
              b = i
            }
            // Continue to divide
            i = ~~((a + b) / 2)}while(i ! == oldI) i <0 && (i = 0)
          startIndex = i
    
          // For container style
          totalSize = sizes[count - 1].accumulator
    
          // Searching for endIndex
          // Find the endIndex that just exceeded
          for (endIndex = i; endIndex < count && sizes[endIndex].accumulator < scroll.end; endIndex++);
          if (endIndex === -1) {
            endIndex = items.length - 1
          } else {
            endIndex++
            // Bounds
            endIndex > count && (endIndex = count)
          }
        } else {
          // Fixed size mode
          // Fixed height: calculate the startIndex and endIndex of a fixed itemSize based on the scrolling distance
          startIndex = ~~(scroll.start / itemSize)
          endIndex = Math.ceil(scroll.end / itemSize)
    
          // Bounds
          startIndex < 0 && (startIndex = 0)
          endIndex > count && (endIndex = count)
    
          totalSize = count * itemSize
        }
      }
    
      if (endIndex - startIndex > config.itemsLimit) {
        this.itemsLimitError()
      }
    
      // refresh the total height of items. The totalSize will be given to the height of the outer box in order to create a scrollbar
      this.totalSize = totalSize
    
    Copy the code
    • For variable heights, an SIZES table is preferentially maintained for the computed attribute, with the cumulative size of the corresponding index recorded. The purpose of this operation is to get the sum of sizes based on the index, instead of having to recalculate each time.
    sizes () {
      // If itemSize is not provided, enter variable size mode
      if (this.itemSize === null) {
        const sizes = {
          '1': { accumulator: 0}},const items = this.items
        const field = this.sizeField
        const minItemSize = this.minItemSize
        let computedMinSize = 10000
        let accumulator = 0
        let current
        for (let i = 0, l = items.length; i < l; i++) {
          current = items[i][field] || minItemSize
          if (current < computedMinSize) {
            computedMinSize = current
          }
          accumulator += current
          sizes[i] = { accumulator, size: current }
        }
        // eslint-disable-next-line
        this.$_computedMinItemSize = computedMinSize
        return sizes
      }
      return[]}Copy the code
  2. View reclamation: Traverses the views in the pool and determines that the index of the view is beyond the startIndex and endIndex range. Then the view is reclaimed by the unuseView function and placed in the unusedViews pool. (In this case, putting in the reuse pool is only a reference to the corresponding element in the pool. The number of elements in the pool is not changed, but the attributes of the corresponding element are changed

if (this.$_continuous ! == continuous) {if (continuous) {
        // If it is not continuous, the page has a big change, initialize the data
        views.clear()
        unusedViews.clear()
        for (let i = 0, l = pool.length; i < l; i++) {
            // Recycle the currently displayed view
            view = pool[i]
            this.unuseView(view)
        }
    }
    this.$_continuous = continuous
} else if (continuous) {
    // This is a continuous slide, traversing the collection pool
    for (let i = 0, l = pool.length; i < l; i++) {
        view = pool[i]
        if (view.nr.used) {
            // Update view item index
            if (checkItem) {
                view.nr.index = items.findIndex(
                item= > keyField ? item[keyField] === view.item[keyField] : item === view.item,
                )
            }

            // Check if index is still in visible range
            // If index is out of range, then recycle
            if (
                view.nr.index === -1 ||
                view.nr.index < startIndex ||
                view.nr.index >= endIndex
            ) {
                this.unuseView(view)
            }
        }
    }
}
Copy the code

The following is the implementation of unuseView:

unuseView (view, fake = false) {
    // Put the view into the cache pool according to the category of the view
    const unusedViews = this.$_unusedViews
    const type = view.nr.type
    // Store by type, and reuse by type
    let unusedPool = unusedViews.get(type)
    if(! unusedPool) { unusedPool = [] unusedViews.set(type, unusedPool) } unusedPool.push(view)if(! fake) {// At this point, set the view reset position (so that the view is not visible) and used to false
        view.nr.used = false
        view.position = -9999
        this.$_views.delete(view.nr.key)
    }
}
Copy the code
  1. Update view: Iterate between startIndex and endIndex, fetching one item at a time, starting to wrap the item and flushing it to the pool.
    • Look in the views dictionary according to the item. If found, the current view is still visible, only scrolling, then directly reuse the view.
    • If it is not found in views, go to unusedViews to find out whether there is a reusable view. If there is a reusable view, use the reusable view and modify the item, key, index and other attributes of the view. In addition, the corresponding dictionary in views is reconfigured to facilitate subsequent lookup.
    • If no view is found in unusedViews, no view is reused. Call addView to add a new view. The view adds an item attribute associated with the items, position attribute to transform style, add used, key, ID, index, and so on. Add a view to the pool and add a dictionary to the views.
let item, type, unusedPool
let v
// Iterate within the visible area
for (let i = startIndex; i < endIndex; i++) {
  item = items[i]
  const key = keyField ? item[keyField] : item
  if (key == null) {
    throw new Error(`Key is ${key} on item (keyField is '${keyField}') `)}// if the item is found in the views dictionary, the current view is still visible, but only scrolling, then reuse the view directly.
  view = views.get(key)

  // If size does not exist, then the height does not exist and will not be added to pool, because it will not be displayed
  if(! itemSize && ! sizes[i].size) {if (view) this.unuseView(view)
    continue
  }

  // No view assigned to item
  // 3.2 If no view is found in views, go to unusedViews to find whether there is a reusable view. If there is a reusable view, use the reusable view and modify the item, key, index and other attributes of the view. In addition, the corresponding dictionary in views is reconfigured to facilitate subsequent lookup.
  if(! view) { type = item[typeField] unusedPool = unusedViews.get(type)if (continuous) {
      // Reuse existing view
      // Find the views available in the reuse pool according to the type, modify the index, etc
      if (unusedPool && unusedPool.length) {
        view = unusedPool.pop()
        view.item = item
        view.nr.used = true
        view.nr.index = i
        view.nr.key = key
        view.nr.type = type
      } else {
        // If the multiplexing pool does not exist, add it
        // 3.3 If no view is found in unusedViews, no view is reused. Call addView to add a new view. The view adds an item attribute associated with the items, position attribute to transform style, add used, key, ID, index, and so on. Add a view to the pool and add a dictionary to the views.
        view = this.addView(pool, i, item, key, type)
      }
    } else {
      // Use existing view
      // We don't care if they are already used
      // because we are not in continous scrolling
      // Since it is not continuous sliding, there is no crossover, so do not consider the problem of use occupancy, and directly start from the first of the corresponding reuse pool
      v = unusedIndex.get(type) || 0

      if(! unusedPool || v >= unusedPool.length) { view =this.addView(pool, i, item, key, type)
        this.unuseView(view, true)
        unusedPool = unusedViews.get(type)
      }

      view = unusedPool[v]
      view.item = item
      view.nr.used = true
      view.nr.index = i
      view.nr.key = key
      view.nr.type = type
      unusedIndex.set(type, v + 1)
      v++
    }
    // Put it in the views pool, where it corresponds to the dictionary for subsequent lookup
    views.set(key, view)
  } else {
    // If the current view already exists, re-use it
    view.nr.used = true
    view.item = item
  }

  // Update position
  // Refresh the view location
  if (itemSize === null) {
    view.position = sizes[i - 1].accumulator
  } else {
    view.position = i * itemSize
  }
}

// Record the local index
this.$_startIndex = startIndex
this.$_endIndex = endIndex
Copy the code

Add a new view to addView when the reuse pool is not available:

addView (pool, index, item, key, type) {
const view = {
  item,
  position: 0,}const nonReactive = {
  id: uid++,
  // The index here corresponds to the index in the incoming source data, which facilitates reordering of views after reuse
  index,
  used: true,
  key,
  type,
}
Object.defineProperty(view, 'nr', {
  configurable: false.value: nonReactive,
})
// Add a new view to the pool
pool.push(view)
return view
}
Copy the code
  1. Sort view: After the above processing is complete, the pool may be unordered, because the pool is multiplexed, so you need to sort. Calling the sortViews method will reorder the view according to the index value in the pool.
clearTimeout(this.$_sortTimer)
this.$_sortTimer = setTimeout(this.sortViews, 300)

// Implementation of sortViews
sortViews () {
  this.pool.sort((viewA, viewB) = > viewA.nr.index - viewB.nr.index)
}
Copy the code

conclusion

$_unusedViews $_views $_views $_views The use of $_unusedViews makes it not every time to delete the data of the pool to achieve the purpose of rendering, looking back at my usual development, similar to rolling, round casting and other processing methods, a certain range of source data is directly truncated to the pool to achieve the purpose of refreshing, the effect is realized, but there is room for optimization. The use of sizes and $_views is designed to reduce complexity. It is more elegant to pre-store a map, or pre-store an accumulated value, when we are doing things like traversal, findIndex, etc. It also greatly reduces the complexity of traversal logic every time we refresh the view.