Mark Ku's Blog
首頁 關於我
解決前端渲染大量資料渲染效能問題 Part's 2- 虛擬捲軸 && 分段渲染
Frontend
解決前端渲染大量資料渲染效能問題 Part's 2- 虛擬捲軸 && 分段渲染
Mark Ku
Mark Ku
April 16, 2020
1 min

定義問題

先前有對大資料渲染進行調察研究,得知瞬間大資料的渲染,會造成瀏覽器卡頓不回應,並得出可以透過分時渲染、捲動式渲染、虛擬列表,解決高併發渲染的問題。

而籃足球比賽比分列表,一遇假日就會有1000~2000 筆的比賽需要同時顯示,且依據使用者期待,不要使用分頁顯示。  

像是雷速網站

虛擬捲軸 VS 分段渲染

虛擬捲軸概念

設計概念,一開始先傳入高度,並將每一筆 item 的 邊界 getBoundingClientRect 結果快取起來,直到渲染出來會重新計算快取高度,透過容器的卷軸事件,css translate3d,操作 y 軸,達到到平移捲動的效果,只有利用實體捲軸的高度及捲動事件,但不是透過實體捲軸顯示,因此這就是為什麼稱之虛擬捲軸的原因。

分段渲染概念

跟虛擬捲軸最大的差異,分段捲軸一開始採用的用的真實的實體捲軸,除buffer item 其餘都僅渲染空 div 支撐捲軸高度,直到捲到 buffer item 時,才開始渲染該區間的元件。

和傳統渲染效能比較

拿 1000 筆左右的數據,資料採用分段渲染和一般炫染效能做比較,因為要渲染的筆數少了,其實可以預期效能差了快1x倍之多。

一般渲染

分段渲染

使用方式

虛擬捲軸

<template>
  <div
    id="virtual-list"
    ref="scroller"
    class="virtual-scroll-list-container"
    @scroll="scrollEvent($event)"
    @touchstart="touchstartHandle"
    @touchmove="touchmoveHandle"
    @touchend="touchendHandle"
  >
    <div
      class="virtual-scroll-list-phantom"
      :style="{ 'min-height': minListHeight + (enableScrollUp ? 40 : 0)+ 'px' }"
    />
    <div
      ref="actualContentRef"
      class="virtual-scroll-list"
      :style="{ transform: getTransform }"
    >
      <div v-show="isShow.isRefresh" ref="refresh" class="refresh">
        <div class="flex justify-center align-center">
          <span class="circle-rotate " />
          <span>
            重新整理
          </span>
        </div>
      </div>
      <slot
        v-for="item in visibleData"
        :start="start"
        :index="getCache(item[uniKey]).index"
        :end="end"
        :uniKey="uniKey"
        :item="item"
        :height="getCache(item[uniKey]).height"
      />
      <div v-show="isShow.isLoading" class="load m-t-4">
        <div class="flex justify-center align-center">
          <span class="circle-rotate " />
          <span>
            加載中
          </span>
        </div>
      </div>
      <span v-if="!isChatMode" class="finished-text">
        <slot name="finishedText">沒有更多內容</slot>
      </span>
    </div>
  </div>
</template>

<script>
import { scrollElementToBottom, debounce } from '@/utils'

export default {
  name: 'VirtualList',
  props: {
    // 所有列表數據
    list: {
      type: Array,
      default: () => []
    },
    // 每項預設的高度
    itemDefaultHeight: {
      type: Number,
      default: 200
    },
    // 唯一值
    uniKey: {
      type: String,
      default: function() {
        return 'seq'
      },
      required: false
    },
    // 可視範圍外多渲染幾筆
    bufferSize: {
      type: Number,
      default: 0
    },
    isChatMode: {
      type: Boolean,
      required: false,
      default: function() {
        return false
      }
    },
    enableScrollDown: {
      // 開啟上拉功能  refresh
      type: Boolean,
      required: false,
      default: function() {
        return false
      }
    },
    enableScrollUp: {
      // 開啟下拉功能 loading
      type: Boolean,
      required: false,
      default: function() {
        return false
      }
    },
    autoLoadMore: {
      // 自動捲到最底就刷新 / 捲到定點,在上拉才刷新
      type: Boolean,
      required: false,
      default: function() {
        return true
      }
    }
  },
  data() {
    return {
      // scrollTop: 0, // 卷軸位址
      lastScrollTop: 0, // 紀錄最後卷軸位址
      isScrolling: false,
      isLoadMoreEnd: false,

      // 列表預估總高度
      minListHeight: 0,
      // 可視區域高度
      screenHeight: 0,
      // 起始索引
      start: 0,
      // 結束索引
      end: null,
      // 快取高度
      cachedPositions: [],
      // 每一項只記算一次動態高度
      calculateOnce: true,
      // 是不是己捲動到最下面
      autoScrollLoaded: false,

      firstRender: false,

      refreshLoginStatus: 'normal', // 組件當前狀態:正常瀏覽模式normal,下拉刷新模式refresh,上拉加載模式loading
      isShow: {
        // 加載動劃控制開關
        isRefresh: false,
        isLoading: false
      },
      startPos: {
        // 手指初始按壓位置
        pageY: 0,
        pageX: 0
      },
      dis: {
        // 手移動距離
        pageY: 0,
        pageX: 0
      },
      last: {
        pageY: 0,
        pageX: 0
      }

    }
  },
  computed: {
    // 預期可視範圍可顯示的列表數
    visibleCount() {
      return Math.ceil(this.screenHeight / this.itemDefaultHeight)
    },
    // 偏移量對應的style
    getTransform() {
      const currentCachedPositions = this.cachedPositions[this.start - 1]

      return `translate3d(0,${
        this.start >= 0 && currentCachedPositions
          ? currentCachedPositions.bottom
          : 0
      }px,0)`
    },
    // 獲取可視範圍的資料筆數
    visibleData() {
      if (this.cachedPositions.length === 0) {
        return []
      }

      return this.list.slice(this.start, this.end + 1)
    }
  },
  watch: {
    list: {
      handler(val) {
        if (val) {
          this.init()
        }

        this.isLoadMoreEnd = false
      },
      immediate: false,
      deep: true
    }
  },
  mounted() {
    this.init()
  },
  // activated生命鉤子在keep-alive被激活時調用
  activated() {
  // 如果曾滾動過,則還原位置
    if (this.lastScrollTop) {
      const page = this.$refs.scroller
      page.scrollTop = this.lastScrollTop
    }
  },

  updated() {
    // 當每一次 component 更新時重新計算一下,目前渲染出來的項目高度,放進 cache 計算
    const that = this

    if (that.$refs.actualContentRef.childElementCount > 0) {
      const childNodes = that.$refs.actualContentRef.childNodes

      that.hasLastNode = false

      childNodes.forEach((node, index) => {
        if (!node || !node.id || node.id.indexOf('-') === -1) {
          return
        }

        const elementIdArray = node.id.split('-')

        if (elementIdArray.length === 2) {
          const elementId = Number(elementIdArray[1])

          if (elementId) {
            const currentCachedPositions = that.cachedPositions.find(
              (x) => x.id === elementId
            )

            if (currentCachedPositions) {
              if (currentCachedPositions.isLast === true) {
                that.hasLastNode = true
              }
            }
            // 每個 item的高度只會重算一次
            if (
              that.calculateOnce &&
              currentCachedPositions.updated &&
              currentCachedPositions.updated === true
            ) {
              return
            }

            const rect = node.getBoundingClientRect()
            const { height } = rect

            const oldHeight = currentCachedPositions.height
            const dValue = oldHeight - height

            if (dValue) {
              currentCachedPositions.bottom -= dValue
              currentCachedPositions.top -= dValue
              currentCachedPositions.height = height
              currentCachedPositions.dValue = dValue
              currentCachedPositions.updated = true
              that.minListHeight -= dValue

              for (
                let i = currentCachedPositions.index;
                i < that.cachedPositions.length;
                i++
              ) {
                const cacheItem = that.cachedPositions[i]

                if (cacheItem) {
                  cacheItem.top -= dValue
                  cacheItem.bottom -= dValue
                }
              }
            }
          }
        }
      })

      if (that.isChatMode) {
        that.$nextTick(function() {
          if (that.firstRender === false) {
            that.firstRender = true
            that.$refs.scroller.scrollTop = that.$refs.scroller.scrollHeight
          } else if (that.autoScrollLoaded === false) {
            scrollElementToBottom('virtual-list')
            if (that.hasLastNode) {
              that.autoScrollLoaded = true
            }
          }
        })
      }
    }
  },

  methods: {
    init() {
      this.autoScrollLoaded = false
      this.initCachedPositions()
      this.initPosition()
      this.screenHeight = this.$el.clientHeight || this.$el.parentElement.clientHeight
      this.scrollEvent()

      // 給 list 預設的高度
      this.minListHeight = this.list.length * this.itemDefaultHeight
    },
    touchstartHandle(e) {
      // 記錄起始位置 和 組件距離window頂部的高度
      this.startPos.pageY = e.touches[0].pageY
      this.startPos.pageX = e.touches[0].pageX
      // 內容頁在可視視窗最頂端或者在指定的位置(父級元素的頂部)
    },
    touchmoveHandle(e) {
      const disY = e.touches[0].pageY - this.startPos.pageY
      const disX = e.touches[0].pageX - this.startPos.pageX
      // for android 預設下拉刷新的問題
      if (this.isAndroid) {
        this.preventAndriodRefreshEevnt(e)
      }

      this.last.pageY = e.changedTouches[0].pageY
      this.last.pageX = e.changedTouches[0].pageX

      if (disX > 100 && !this.isScrolling) {
        this.dis.pageX = disX
        this.$emit('scrollRight')
        this.refreshLoginStatus = 'right'
      } else if (disX < -100 && !this.isScrolling) {
        this.$emit('scrollLeft')
        this.refreshLoginStatus = 'left'
      } else {
        if (this.$refs.scroller.scrollTop <= 0 && disY > 100) {
          this.dis.pageY = disY
          this.refreshLoginStatus = 'refresh'
          this.refreshMove(disY, e)
        } else if (disY < 100) {
          /* //觸發上拉加載 */
          if (this.isShow.isLoading) return
          this.refreshLoginStatus = 'loading'
          this.loadingMove(disY)
        }
      }
    },

    preventAndriodRefreshEevnt(e) {
      // 阻止 android 原生事件
      var direction = e.changedTouches[0].pageY > this.last.pageY ? 1 : -1

      const scrollTop = this.$refs.scroller.scrollTop

      if (direction > 0 && scrollTop <= 0) {
        e.preventDefault()
      }
    },
    loadingMove(dis) {
      // 計算內容頁底部距離可視視窗頂部的距離
      if (this.enableScrollUp && !this.autoLoadMore) {
        const disToTop = this.$refs.actualContentRef.getBoundingClientRect()
          .bottom

        // 計算可視視窗的高度
        const clientHeight = document.documentElement.clientHeight
        if (disToTop <= clientHeight) {
          if (this.refreshLoginStatus === 'loading' && this.dis.pageY < 0) {
            this.isShow.isLoading = true
          }
        }
      }
    },
    refreshMove(dis, e) {
      if (this.enableScrollDown) {
        if (this.isShow.isRefresh) return
        if (this.refreshLoginStatus === 'refresh' && this.dis.pageY > 0) {
          // 下拉刷新成立條件

          this.isShow.isRefresh = true
          // 下拉到一定距離後,內容頁不隨touchmove移動
          this.$refs.actualContentRef.style.transform = `translateY(${
            dis < 8 ? dis : 8
          }px)`

          // for android 預設下拉刷新的問題
          if (this.isAndroid) {
            e.preventDefault()
          }
        }
      }
    },

    touchendHandle(e) {
      if (this.refreshLoginStatus === 'left' || this.refreshLoginStatus === 'right') {
        this.isShow.isRefresh = false
        this.isShow.isLoading = false
      }

      this.refreshLoginStatus === 'refresh' && this.refreshToucnend(e)
      this.refreshLoginStatus === 'loading' && this.loadingTouchend(e)
    },
    refreshToucnend(e) {
      // 加上限定條件,防止不在刷新狀態,後面的代碼執行
      if (!this.isShow.isRefresh) return
      // 必須下拉一定距離,才進行異步加載數據
      this.dis.pageY > 10 && (this.$emit('scrollDown'))

      // 松手後加載動劃消失,並且內容頁回到原位置
      this.isShow.isRefresh = false
      this.$refs.actualContentRef.style.transform = `translateY(0px)`
      this.refreshLoginStatus = 'normal'
    },
    loadingTouchend(e) {
      // 加上限定條件,防止不在刷新狀態,後面的代碼執行
      if (!this.isShow.isLoading) return

      if (this.isLoadMoreEnd === false) {
        this.$emit('scrollUp')

        this.isLoadMoreEnd = true
      }
      this.isShow.isLoading = false
      this.refreshLoginStatus = 'normal'
    },

    getCache(uniId) {
      const cache = this.cachedPositions.find(x => x.id === uniId)
      return cache
    },

    initPosition() {
      if (this.isChatMode) {
        this.end = this.list.length

        var range = this.visibleCount + this.bufferSize

        if (this.end - range < 0) {
          this.start = 0
        } else {
          this.start = this.end - range
        }
      } else {
        this.start = 0
        this.end = this.start + this.visibleCount + this.bufferSize
      }
    },
    // 依照預設每一筆資料都給計算 bottom 及給預設高度
    initCachedPositions() {
      const { itemDefaultHeight } = this
      this.cachedPositions = []
      for (let i = 0; i < this.list.length; ++i) {
        this.cachedPositions[i] = {
          id: this.list[i][this.uniKey],
          index: i,
          height: itemDefaultHeight,
          top: i * itemDefaultHeight,
          bottom: (i + 1) * itemDefaultHeight,
          dValue: 0,
          isLast: i + 1 === this.list.length

        }
      }
    },
    scrollEvent(e) {
      // 當前滾動位置
      const scrollTop = this.$refs.scroller.scrollTop

      // 綁定事件,滾動時,儲存位置到this.scrollTop
      this.lastScrollTop = scrollTop

      // 處理滑動不可以切換tab
      this.isScrolling = true
      var scroll
      clearTimeout(scroll)
      scroll = setTimeout(() => {
        this.isScrolling = false
      }, 100)

      let index = 0
      // 此時的開始索引
      const currentCachePostion = this.cachedPositions.filter(
        (x) => scrollTop < x.bottom
      )[0]

      if (currentCachePostion) {
        index = currentCachePostion.index
      }

      if (index === 0) {
        this.start = 0
        this.end = index + this.visibleCount + this.bufferSize
      }
      // debugger
      // 此時的結束索引
      this.start = index - this.bufferSize
      this.end = index + this.visibleCount + this.bufferSize

      if (this.list.length > 0 && this.end >= this.list.length) {
        // 自動加載
        if (this.enableScrollUp && this.autoLoadMore) {
          this.isShow.isLoading = true
          this.loadingTouchend(e)
        }

        this.end = Math.max(this.list.length, this.visibleCount)
        this.start = Math.min(this.end - this.visibleCount - this.bufferSize, 0)
      }

      if (this.start < 0) this.start = 0 // 起始筆
    },
    debounceScroll(e) {
      debounce(() => { this.scrollEvent(e) }, 16.6) // 60Hz
    }
  }
}
</script>

<style scoped lang="scss">
.virtual-scroll-list-container {
  position: relative;
  overflow: auto;
  height: 100%;
  -webkit-overflow-scrolling: touch;
  // scroll-behavior: smooth;
}

// .virtual-scroll-list-container::-webkit-scrollbar {
//   display: none;
// }

.virtual-scroll-list-phantom {
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  z-index: -1;
}

.virtual-scroll-list {
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  z-index: 998;
}

/*
::-webkit-scrollbar {
    width: 10px;
}

::-webkit-scrollbar-track {
    background-color: darkgrey;
}

::-webkit-scrollbar-thumb {
    box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.2);
} */

.circle-rotate {
  position: relative;
  border: 10px solid #CCC;
  border-right-color: transparent;
  border-radius: 50%;
  width: 20px;
  height: 20px;
  animation: loadingAnimation 0.75s infinite;
}

@keyframes loadingAnimation {
  0% {
    transform: translateX(-50%) rotate(0deg);
  }

  100% {
    transform: translateX(-50%) rotate(360deg);
  }
}

.finished-text {
  display: block;
  padding: 24px 0 32px 0;
  font-size: 22px;
  text-align: center;
  color: $text-grey-darken;
}

.disable-hover {
  pointer-events: none;
}

</style>

使用方式

<VirtualScroller
      :list="news"
      class="news-list-wrapper"
      :item-default-height="100"
      :uni-key="'id'"
      :enable-scroll-up="!isLastPage"
      :auto-load-more="true"
      :enable-scroll-down="true"
      :buffer-size="4"
      @scrollDown="scrollDown"
      @scrollUp="scrollUp"
    >
      <template #default="slotScope">
        <NewsCard
          :id="'news-' + slotScope.item[slotScope.uniKey]"
          :key="'news-' + slotScope.item[slotScope.uniKey]"
          :start="slotScope.start"
          :end="slotScope.end"
          :index="slotScope.index"
          :h="slotScope.height"
          :news="slotScope.item"
          :sport-id="slotScope.item.sportId"
        />
      </template>
</VirtualScroller>

分段捲軸

<template>
  <div
    ref="part-render-container"
    class="part-render-scroll-list-container"
    :style="{ height: minListHeight +'px' }"
  >

    <div
      v-for="(item, index) in list"
      :key="item[uniKey]"
      :[uniKey]="item[uniKey]"
      :index="index"
      class="part-render-item"
      :class="[{ visiable: checkVisible(index) }, itemClass]"
      :style="{ 'min-height': cachedPositions[index].height + 'px' }"
    >
      <transition-group name="fade">
        <slot
          v-if="checkVisible(index)"
          :index="index"
          :uniKey="uniKey"
          :item="item"
          :height="cachedPositions[index].height"
        />
      </transition-group>
    </div>
  </div>
</template>

<script>
export default {
  name: 'VirtualList',
  props: {
    scrollElementId: { // 沒傳預設抓 body
      type: String,
      required: false,
      default: function() {
        return ''
      }
    },
    // 所有列表數據
    list: {
      type: Array,
      default: () => []
    },
    // 每項預設的高度
    itemDefaultHeight: {
      type: Number,
      default: 200
    },
    // 唯一值
    uniKey: {
      type: String,
      default: function() {
        return 'id'
      },
      required: false
    },
    // 可視範圍外多渲染幾筆
    bufferSize: {
      type: Number,
      default: 20
    },
    itemClass: {
      type: String,
      required: false,
      default: function() {
        return ''
      }
    }
  },
  data() {
    return {
      // 列表預估總高度
      minListHeight: 0,
      // 起始索引
      start: 0,
      // 結束索引
      end: null,
      // 快取高度
      cachedPositions: [],
      // 每一項只記算一次動態高度

      defaultTopOffset: 0,
      maxListHeight: 0,
      currentScrollTop: 0,
      currentEleTop: 0,
      currentEleBottom: 0,
      screenHeight: 0,
      scrollElement: null,
      isBodyScroller: false // 是不是 body 的捲軸

    }
  },
  computed: {
    // 一頁預估可以顯示幾筆
    visibleCount() {
      return Math.ceil(this.screenHeight / this.itemDefaultHeight)
    },

    // 己進入預渲染的範圍
    isEnterPreload() {
      const isEnterPreload = this.currentScrollTop + this.screenHeight > this.currentEleTop && this.currentScrollTop - this.screenHeight < this.currentEleBottom

      return isEnterPreload
    }

  },
  watch: {
    list: {
      handler(val) {
        if (
          val &&
          val.length > 0
        ) {
          // 給 list 預設的高度
          this.minListHeight = this.list.length * this.itemDefaultHeight

          this.initCachedPositions()
          var that = this
          that.$nextTick(function() {
            const rect = this.$el.getBoundingClientRect()
            that.defaultTopOffset = rect.top
          })
        }
      },
      immediate: true,
      deep: true
    }
  },
  mounted() {
    this.screenHeight =
        window.innerHeight ||
        document.documentElement.clientHeight ||
        document.body.clientHeight

    this.recalculateCurrentEleBoundary()

    // 給初始值
    this.start = 0

    this.end = this.start + this.visibleCount + this.bufferSize

    this.isBodyScroller = this.scrollElementId.length === 0

    if (this.isBodyScroller) {
      window.addEventListener('scroll', this.handleScroll)
    } else {
      this.getScrollElement().addEventListener('scroll', this.handleScroll, false)
    }
  },

  updated() {
    // 當每一次 component 更新時重新計算一下,目前渲染出來的項目高度,放進 cache 計算

    const that = this
    const el = this.$el

    if (el) {
      const childNodes = el.querySelectorAll('.visiable')

      childNodes.forEach((node, index) => {
        if (!node) {
          return
        }

        const elementIndex = Number(node.getAttribute('index'))

        // 重算高度
        const currentCachedPositions = that.cachedPositions[elementIndex]

        if (currentCachedPositions.updated === true) {
          return
        }

        currentCachedPositions.updated = true

        if (currentCachedPositions) {
          // slot 只能放一筆
          const rect = node.children[0].getBoundingClientRect()
          const { height } = rect

          const oldHeight = currentCachedPositions.height
          const dValue = oldHeight - height

          if (dValue) {
            currentCachedPositions.bottom -= dValue
            currentCachedPositions.top -= dValue
            currentCachedPositions.height = height
            currentCachedPositions.dValue = dValue
            that.minListHeight -= dValue

            // 重算快取 Cache 的 上邊界 & 下邊界
            this.recalculateCache(currentCachedPositions.index, dValue)
          }
        }
      })

      this.recalculateCurrentEleBoundary()
    }
  },
  beforeDestroy() {
    if (this.isBodyScroller) {
      window.removeEventListener('scroll', this.handleScroll)
    } else {
      this.getScrollElement().removeEventListener('scroll', this.handleScroll, false)
    }
  },
  methods: {
    getScrollElement() {
      const scrollElement = document.getElementById(this.scrollElementId)
      if (this.isBodyScroller) {
        return document.documentElement || document.body
      } else {
        if (!scrollElement) {
          console.log('找不到捲軸物件')
        }
        return document.getElementById(this.scrollElementId)
      }
    },
    // 重算快取 Cache 的 上邊界 & 下邊界
    recalculateCache(index, dValue) {
      for (let i = index; i < this.cachedPositions.length; i++) {
        const cacheItem = this.cachedPositions[i]

        if (cacheItem) {
          cacheItem.top -= dValue
          cacheItem.bottom -= dValue
        }
      }
    },
    // 重新計算上邊界和下邊界的距離
    recalculateCurrentEleBoundary() {
      var rect = this.$el.getBoundingClientRect()

      this.currentEleTop = rect.top + this.currentScrollTop
      this.currentEleBottom = rect.top + this.currentScrollTop + rect.height
    },

    checkVisible(nowIndex) {
      if (this.isEnterPreload) {
        return (
          nowIndex >= this.start &&
        nowIndex <= Math.min(this.end, this.list.length)
        )
      } else {
        return false
      }
    },
    handleScroll() {
      this.currentScrollTop = this.getScrollElement().scrollTop

      let index = 0

      // 此時的開始索引
      const currentCachePostion = this.cachedPositions.filter(
        (x) => this.currentScrollTop - this.currentEleTop < x.top
      )[0]

      if (currentCachePostion) {
        index = currentCachePostion.index
      }

      if (index === 0) {
        this.start = 0
        this.end = index + this.visibleCount + this.bufferSize
      }

      this.start = index - this.bufferSize
      this.end = index + this.visibleCount + this.bufferSize

      if (this.start < 0) {
        this.start = 0
      }

      if (this.end > this.list.length) {
        this.end = this.list.length - 1
        this.start = this.end - this.visibleCount - this.bufferSize
      }
    },

    // 依照預設每一筆資料都給計算 bottom 及給預設高度
    initCachedPositions() {
      const { itemDefaultHeight } = this
      this.cachedPositions = []
      for (let i = 0; i < this.list.length; ++i) {
        this.cachedPositions[i] = {
          index: i,
          height: itemDefaultHeight,
          top: i * itemDefaultHeight,
          bottom: (i + 1) * itemDefaultHeight,
          dValue: 0,
          isLast: i === this.list.length - 1,
          updated: false // 曾經渲染過
        }
      }
    }
  }
}
</script>

<style scoped>
.part-render-scroll-list-container {
  box-sizing: border-box;
}

.part-render-item {
  box-sizing: border-box;
}
</style>

使用方式

<PartRenderingScroller :scroll-element-id="'schedule-container'" :list="early.early" :item-default-height="100" uni-key="matchId" class="schedule-list p-l-20 p-r-20" :buffer-size="50">
            <template #default="slotScope">
              <ScheduleCard
                :id="'live-' + slotScope.item[slotScope.uniKey]"
                :key="'live-' + slotScope.item[slotScope.uniKey]"
                :class="{'p-t-20':slotScope.index===0}"
                :start="slotScope.start"
                :end="slotScope.end"
                :index="slotScope.index"
                :h="slotScope.height"
                :sport-id="sportId"
                :schedule="slotScope.item"
                :lottery-type="lotteryType"
              />
            </template>
</PartRenderingScroller>

如果要設計的東西,未來要套虛擬捲軸或分段捲軸時,可能要留意一下

  1. 分段捲軸 Desktop 網站架構是是 body 捲動,而 mobile 架構是 div 在捲動,可以透過傳入 scrollElementId 決定,沒傳則是 body 捲動
  2. list 外容器,不能撐padding ,要由 item 去支撐撐高,item 最外層不能用 margin,因為用程式抓寬高抓不到 margin => 之後或許可以擴充用 window.getComputedStyle 來擴充支援 margin
  3. list-item 的 css 要加 box-sizing: border-box; 不然不會把 padding 算進去
  4. 分段或虛擬捲軸, 結構要用 div ,不能用 ul li 或table 等等,因為虛擬捲軸組件會額外渲染出額外的 div
  5. 為了重算高度,list item 一定要給 id ,並用減號分隔 => “{{type}}-{{id}}”
  6. 儘量避免和其他有用到捲軸的套件整合 better scroller、vant dialog、 swiper,因為每個套件有自己的生命週期,如果一定整合進來,可能自己擴充組件功能會比較不會衝突

實作過程遇到的問題

1. 評估過 vue-virtual-scroller and vue-virtual-scroll-list 簡單功能可以套用,但遇到複雜的情境就不好套用。

原始碼也不易理解及改動,最後決定自己刻了。

2. 而在實作過程中發現,桌面版非滿版網頁,或是需要多個虛擬捲軸同時存在,則使用者體驗不是這麼好,會變成雙出現內外卷軸的情境,對於使用者捲動上來說並不是很好的體驗,

此時情境可能不適合用虛擬捲軸,而是要分段渲染卷軸。

3. 每一筆資料都不同高度,分段元件及虛擬捲軸得支援動態高度。

元件初始化時,先給預設高,並快取起來,直到渲染出來,在去更新高度,postion 的快取。

4. 虛擬捲軸在套聊天室時發現筆數一多,從最舊的資料渲染到最新時,透過自動捲動渲染,顯示時就會變得非常卡頓。

聊天室最後將渲染索引反過來渲染,最先渲染使用者看得到的,直到使用者往上捲動。

5.整合 vue better scroll( 上拉整理、下拉取得資料) 、 swiper

上述都是類似虛擬捲軸機制的套件,如果要整合捲軸類型的套件,最好還是自己擴充虛擬捲軸,不然要解決兩個虛擬捲軸機制在裡面衝突。

同一系列文章


Tags

Mark Ku

Mark Ku

Software Developer

9年以上豐富網站開發經驗,開發過各種網站,電子商務、平台網站、直播系統、POS系統、SEO 優化、金流串接、AI 串接,Infra 出身,帶過幾次團隊,也加入過大團隊一起開發。

Expertise

前端(React)
後端(C#)
網路管理
DevOps
溝通
領導

Social Media

facebook github website

Related Posts

解決前端渲染大量資料渲染效能問題 - 1
解決前端渲染大量資料渲染效能問題 - 1
September 26, 2021
1 min

Quick Links

關於我

Social Media