通过 Vue 实现无限滚动列表(二)—— 代码逻辑与 Demo

上一篇介绍了无限列表实现的简单思路,下面说一下代码的实现逻辑。

主要逻辑通过一个 ScrollManager 类来完成,在这个类中,会根据滚动条高度计算当前需要渲染的 items 以及上下需要支撑起来的高度。该类中主要由如下这些方法:

下面代码注释中,cell 与 item 为同一个东西,都是列表中的一项,因为是先完成的代码后添加的注释,所以请不要在意这些细节:)

class ScrollManager {
    // 构造器方法
    constructor ( {
    list, // 待渲染的列表数据 Array
    scrollViewHeight, // 滚动视图的高度,即滚动区域可见部分的高度
    cellHeight, // 每个 item 的高度,如果设置了该值则认为是固定高度列表
    cellCacheNumber, // 上下两方缓冲的item数量
    firstRenderNumber // 动态高度时单屏初次渲染的列表数量
  } ) { ... }

    // 初始化滚动列表
  // 计算首屏需要渲染的items和缓冲items
  initScroll () { ... }

    // 滚动时更新数据
  // 根据滚动条高度计算已经划出屏幕并且不再需要渲染的items
  // 更新需要渲染的items和缓冲items
  // 并更新列表上方和下方需要支撑起的高度
  updateScroll (scrollTop) { ... }

    // 内部调用的调整items相关数据的方法
  // 包括已经不需要渲染的items和需要渲染的items
  _adjustCells () { ... }

    // 动态高度时根据已缓存的cell高度计算平均高度,方法接受当前渲染的cells的高度数组
  // 对已经渲染过的cell高度进行缓存,保证上方的支撑高度计算准确
  // 对未渲染过的cell高度进行预估,保证下方的支撑高度尽量靠近实际高度
  // 调整整个滑动列表的总高度
  updateCellHeight (cellsHeightInfo) { ... }

  // 获取待渲染的items及相关数据
  getRenderInfo () { ... }
}

当然,上面这个类完全可以脱离 Vue 来使用,那这个类是怎么使用的呢?

// 1. 实例化 ScrollManager 类
const manager = new ScrollManager({ ... })

// 2. 实例化完成后,通过 getRenderInfo 获取首次渲染的数据
let renderList = manager.getRenderInfo()

// 3. 需要注意的是,当列表重新渲染后可能会引发滚动条位置的改变,所以需要在页面完成渲染后重新将滚动条定位到准确的位置
// $scrollElement 为滚动列表容器
// lastScrollTop 为上一次滚动后的滚动条高度,初始值为 0
// 该值需要在每次触发滚动事件时进行更新
$scrollElement.scrollTop = lastScrollTop

// 4. 对于高度不定的列表来说,需要在渲染完成后调用更新cell高度的方法
manager.updateCellHeight([cellHeight1, cellHeight2, ...])
// 可以通过下面的方式获取到cell的高度值
// $cell 为单个cell节点
let height = $cell.getBoundingClientRect().height

// 5. 最重要的一点是,需要监听滚动列表容器的滚动时间,监听到滚动后触发 manager 的更新列表方法并更新 lastScrollTop
// 然后重复执行 2 3 4 步
$scrollElement.onScroll = () => {
  lastScrollTop = this.$refs.$scroll.scrollTop
  manager.updateScroll(lastScrollTop)
    // TODO 2,3,4
} 

以上就是整个 demo 的代码逻辑了,并不负责。当然,暂时还有一些功能并没有去实现,比如上一篇说到的一些问题。这篇再提出两个问题:

当快速滚动列表时,或突然将高度定位到某一点时,对于不定高度的列表来说,由于上面的列表项还未来得及渲染和计算高度,此时会出现比较大的bug,上下支撑和整体高度计算都会出现比较大甚至很大的误差。
如果只是用在移动端,滚动速度并不会很快,所以在移动端使用时并不会出现明显的bug.

当列表中的数据更新时(如从原来的 20 项变为 30 项),此时需要对所有的渲染数据进行更新,包括上下撑起的高度、总高度以及不定高度列表的 cellHeight,同时还要保证滚动条的位置不变,即使新增了数据,用户看到的依然是未新增之前的内容。
这一点比较容易实现,但是由于时间原因并没有去完成。感兴趣的小伙伴可以自己完成以下。

次人君的Vue组件库

正文内容到此结束,下面附上整个demo的源码,不想去 github 看的小伙伴可以直接看这里

Demo文件 Scroll.vue

<template>
<InfiniteScroll :list="cells" :scrollViewHeight="736">
  <div slot="cell" slot-scope="props"
  :style="props.cell.style">{{props.cell.text}}</div>
</InfiniteScroll>
</template>

<script>
import InfiniteScroll from '@/src/infiniteScroll/InfiniteScroll'

export default {
  name: 'Scroll',
  components: { InfiniteScroll },
  computed: {
    cells () {
      return new Array(1000).fill(1).map((item, index) => {
        return {
          style: {
            height: Math.floor(Math.random() * 100 + 100) + 'px',
            // height: '100px',
            color: '#ffffff',
            fontSize: '30px',
            background: this.getRandomColor()
          },
          text: '#' + (index + 1)
        }
      })
    }
  },
  methods: {
    getRandomColor () {
      const colors = new Array(3).fill(1).map(item => Math.floor(Math.random() * 255))
      return `rgb(${colors.join(',')})`
    }
  }
}
</script>

无限滚动组件文件 InfiniteScroll.vue

<template>
<div class="t-scroll"
     ref="$scroll"
     :style="{ height: this.scrollViewHeight + 'px' }"
     @scroll.passive="onScroll">
  <div class="t-scroll-padding-top" :style="{height: scrollData.paddingTop + 'px'}"></div>
    
    <div ref="$cell" v-for="item in scrollData.displayCells">
      <slot name="cell" :cell="item"></slot>
    </div>

  <div class="t-scroll-padding-bottom" :style="{height: scrollData.paddingBottom + 'px'}"></div>
</div>
</template>

<script>
import ScrollManager from './ScrollManager'

let manager
let lastScrollTop = 0
let heightFixed = true

export default {
  name: 'InfiniteScroll',
  props: {
    scrollViewHeight: {
      type: Number,
      required: true
    },

    list: {
      type: Array,
      required: true
    },
    // cell缓存数量 即不在可视区域内的预加载数量
    cellCacheNumber: {
      type: Number,
      default: 3
    },
    // cell高度值 如果为0或不传则为动态高度 不为0则为固定高度
    cellHeight: {
      type: Number,
      default: 0
    },

  },
  data () {
    return {
      scrollData: {
        scrollHeight: 0,
        paddingTop: 0,
        paddingBottom: 0,

        displayCells: []
      }
    }
  },
  
  methods: {
    initScrollManager () {
      manager = new ScrollManager({
        list: this.list,
        scrollViewHeight: this.scrollViewHeight,
        cellHeight: this.cellHeight,
        cellCacheNumber: this.cellCacheNumber,
        firstRenderNumber: 10
      })
    },

    updateScrollRender () {
      this.scrollData = manager.getRenderInfo()
      this.$forceUpdate()
      // 更新完成后矫正滚动条位置
      this.$nextTick(() => {
        this.$refs.$scroll.scrollTop = lastScrollTop
        if (!heightFixed) manager.updateCellHeight(
          this.$refs.$cell.map(item => item.getBoundingClientRect().height)
        )
      })
    },


    onScroll () {
      lastScrollTop = this.$refs.$scroll.scrollTop
      manager.updateScroll(lastScrollTop)
      this.updateScrollRender()
    }


  },
  watch: {
    list () {
      manager.updateList(this.list)
    }
  },
  mounted () {
    if (!this.cellHeight) heightFixed = false
    this.initScrollManager()
    this.updateScrollRender()
  }
}
</script>

<style scoped>
.t-scroll  {
  position: relative;
  background: #eeeeee;
  overflow: scroll;
}
.t-scroll-cell {
  color: #ffffff;
  font-size: 30px;
  font-weight: bolder;
}
</style>

无限滚动类文件 ScrollManager.js

export default class ScrollManager {
  // 构造器方法
  constructor ( {
    list, // 待渲染的列表数据 Array
    scrollViewHeight, // 滚动视图的高度,即滚动区域可见部分的高度
    cellHeight, // 每个 item 的高度,如果设置了该值则认为是固定高度列表
    cellCacheNumber, // 上下两方缓冲的item数量
    firstRenderNumber // 动态高度时单屏初次渲染的列表数量
  } ) {
    // 滚动可视区域与滚动列表高度
    this.scrollViewHeight = this.scrollHeight = scrollViewHeight
    // cell平均高度 等于0则为动态高度
    this.cellHeight = cellHeight
    this.heightFixed = cellHeight ? true : false
    // 预加载的cell数量
    this.cellCacheNumber = cellCacheNumber || 3
    // 单屏渲染数量
    this.renderNumber = firstRenderNumber || 10

    // 滚动区域上下撑开的高度
    this.paddingTop = this.paddingBottom = 0
    // cell的高度数据缓存,只在不固定高度时有效
    this.heightCache = new Array(list ? list.length : 0).fill(this.cellHeight)
    // 渲染列表
    this.list = list
    // 待渲染列表
    this.displayCells = []
    // 当前待渲染列表的第一个元素为在全部列表中的位置
    this.passedCells = 0
    // 当前渲染的cells的总高度
    this.currentCellsTotalHeight = 0

    this.initScroll()
  }

  // 初始化滚动列表
  // 计算首屏需要渲染的items和缓冲items
  initScroll () {
    if (this.heightFixed) { // cell高度固定时,校正滑动区域总高度,计算单屏渲染的cell数量及底部支撑高度
      this.scrollHeight = this.list.length * this.cellHeight
      this.renderNumber = Math.ceil(this.scrollViewHeight / this.cellHeight)
      this.displayCells = this.list.slice(0, this.renderNumber + this.cellCacheNumber * 2)
      this.paddingBottom = this.scrollHeight - this.displayCells.length * this.cellHeight
    } else { // cell高度不固定时,渲染初次加载的单屏cell数量
      this.displayCells = this.list.slice(0, this.renderNumber + this.cellCacheNumber * 2)
    }
  }

  // 滚动时更新数据
  // 根据滚动条高度计算已经划出屏幕并且不再需要渲染的items
  // 更新需要渲染的items和缓冲items
  // 并更新列表上方和下方需要支撑起的高度
  updateScroll (scrollTop) {
    if (this.heightFixed) {
      this.passedCells = Math.floor(scrollTop / this.cellHeight)

      this._adjustCells()
      
      this.currentCellsTotalHeight = this.displayCells.length * this.cellHeight
      this.paddingTop = this.passedCells * this.cellHeight 
    } else {
      let passedCellsHeight = 0
      for (let i = 0; i < this.heightCache.length; i++) {
        
        if (scrollTop >= passedCellsHeight) this.passedCells = i
        else break
        passedCellsHeight += this.heightCache[i] ? this.heightCache[i] : this.cellHeight
      }
      
      this._adjustCells()

      this.paddingTop = this.heightCache.reduce((sum, height, index) => {
        if (index < this.passedCells) return sum + height
        return sum
      }, 0)
    }
    this.paddingBottom = this.scrollHeight - this.paddingTop - this.currentCellsTotalHeight
    if (this.paddingBottom < 0) this.paddingBottom = 0
  }

  // 内部调用的调整items相关数据的方法
  // 包括已经不需要渲染的items和需要渲染的items
  _adjustCells () {
    this.passedCells = this.passedCells > this.cellCacheNumber ? this.passedCells - this.cellCacheNumber : 0
    this.displayCells = this.list.slice(this.passedCells, this.renderNumber + this.cellCacheNumber * 2 + this.passedCells)
  }

  // 动态高度时根据已缓存的cell高度计算平均高度,方法接受当前渲染的cells的高度数组
  // 对已经渲染过的cell高度进行缓存,保证上方的支撑高度计算准确
  // 对未渲染过的cell高度进行预估,保证下方的支撑高度尽量靠近实际高度
  // 调整整个滑动列表的总高度
  updateCellHeight (cellsHeightInfo) {
    if (this.heightFixed) return

    // 更新平均cell高度
    this.currentCellsTotalHeight = cellsHeightInfo.reduce((sum, height) => sum + height, 0)
    this.cellHeight = Math.round(this.currentCellsTotalHeight / cellsHeightInfo.length)
    this.renderNumber = Math.ceil(this.scrollViewHeight / this.cellHeight)
    // 保存已知cell的高度信息
    this.heightCache.splice(this.passedCells, cellsHeightInfo.length, ...cellsHeightInfo)
    // 预估滑动区域总高度
    this.scrollHeight = this.heightCache.reduce((sum, height) => {
      if (height) return sum + height
      return sum + this.cellHeight
    }, 0)
  }

  // 获取待渲染的items及相关数据
  getRenderInfo () {
    return {
      scrollHeight: this.scrollHeight,
      paddingTop: this.paddingTop,
      paddingBottom: this.paddingBottom,
      displayCells: this.displayCells
    }
  }
}
扫码关注微信公众号【前端程序员的斜杠青年进化录】
微信扫码,给我赞赏一下~
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容

  • 目录 UI组件 开发框架 实用库 服务端 辅助工具 应用实例 Demo示例 UI组件 element★13489 ...
    余生社会阅读 19,654评论 7 233
  • 转载 :OpenDiggawesome-github-vue 是由OpenDigg整理并维护的Vue相关开源项目库...
    果汁密码阅读 23,083评论 8 124
  • 这本书是育儿书,作者李雪。 书中最吸引我的章节有如下章节: 一 睡眠训练,给孩子的一生涂上灰暗底色 “婴儿哭泣,...
    谁的孤独是一颗眼泪阅读 393评论 0 0
  • 我常在想,像我这样的二流小本女,民营房地产奋斗八年 ,跳槽到一家top500的美资,还没来及彻底享受资本主义的各种...
    子骐阅读 243评论 0 0
  • 用力的活着,坚韧的活着。 我告诉自己不能输。 击垮自己不可以,外面的声音再嘈杂。 自己有信念。 坚持自己!
    风烟云雨阅读 209评论 0 0