长列表无限滚动原理分析及其实现

问题背景: 一个300000条记录的列表怎么展示(不要问我为什么是300000条,因为500000条笔者的电脑跑不出来)?直接无脑v-for? 菜鸟都知道肯定不行。

<template>
  <div>
    <div class="item" v-for="item in list" :key="item.key">
      {{item.text}}
    </div>
  </div>
</template>
<script>
export default {
  data() {
    return {
      list: [],
      forStartTime: 0,
      forEndTime: 0,
      randerEndTime: 0
    }
  },
  created() {
    let list = []
    this.forStartTime = +new Date()
    console.log('for循环开始时间戳:', this.forStartTime);
    for (let i = 0; i < 300000; i++) {
      list.push({
        key: i,
        text: i + 1
      })
    }
    this.forEndTime = +new Date()
    console.log('for循环结束时间戳:', this.forEndTime);
    console.log('for循环时间:', (this.forEndTime - this.forStartTime).toFixed(0));
    this.list = list
  },
  mounted(){
    this.$nextTick(()=>{
      this.randerEndTime = +new Date()
      console.log('渲染时间:',this.randerEndTime - this.forEndTime);
    })
  }
}
</script>
<style>
</style>

让我们来看看渲染效果如何呢?

image.png

上面这个例子中,我们通过for循环生成一个长度为300000的数组,用户会感受到页面渲染的速度很慢,页面出现了较长时间的白屏,这样的用户体验肯定的不行的。(这里的渲染时间还只是我们可以在代码中拿到真实dom,浏览器还是白屏的情况,没有真实渲染完成,真正的渲染时间实际比这个长的多) 况且这里我们v-for的dom结构非常非常简单,我们稍微新增1个dom节点其他条件保持不变,再试试:

<template>
  <div>
    <div class="item" v-for="item in list" :key="item.key">
      {{item.text}}
      <span>1</span>
    </div>
  </div>
</template>

结果如何呢:

image.png

从上面的实验我们不难看出, 在dom结构复杂一点的情况下, 使用长list对于页面的渲染所造成的阻塞几乎是灾难性的。

为了解决上面的问题,无限滚动就来了!!!

原理

永远都只渲染用户当前可视区域之内的dom节点

那么问题来了,开发者怎么样去知道当前用户滚动的位置需要展示哪一些dom呢?
这里大致分为两类来看:

  • 所有item的高度固定;
  • item的高度不固定;

具体实现

item高度固定

假设我们屏幕的可见区域的高度为500px,而列表项的高度为50px,则此时我们在屏幕中最多只能看到10个列表项,那么在首次渲染的时候,我们只需加载10条即可,在列表滚动时,滚动条距顶部的位置为150px,则我们可得知在可见区域内的列表项为第4项第13项,如下图:

image.png

变量定义:

  • 计算当前可视区域起始数据索引(startIndex)
  • 计算当前可视区域结束数据索引(endIndex)
  • 计算当前可视区域的数据,并渲染到页面中(domList)
  • 计算startIndex对应的数据在整个列表中的偏移位置startOffset并设置到列表上

由于只是对可视区域内的列表项进行渲染,所以为了保持列表容器的高度并可正常的触发滚动,将Html结构设计成如下结构:

<div class="infinite-list-container">
    <div class="infinite-list-phantom"></div>
    <div class="infinite-list">
      <!-- item-1 -->
      <!-- item-2 -->
      <!-- ...... -->
      <!-- item-n -->
    </div>
</div>
  • nfinite-list-container 为可视区域的容器
  • infinite-list-phantom 为容器内的占位,高度为总列表高度,用于形成滚动条
  • infinite-list 为列表项的渲染区域

接着,监听infinite-list-container的scroll事件,获取滚动位置scrollTop

  • 假定可视区域高度固定,称之为screenHeight
  • 假定列表每项高度固定,称之为itemSize
  • 假定列表数据称之为list
  • 假定当前滚动位置称之为scrollTop

则可推算出:

  • 列表总高度realHeight= list.length * itemSize
  • 可显示的列表项数domListLen = Math.ceil(screenHeight / itemSize)
  • 数据的起始索引startIndex = Math.floor(scrollTop / itemSize)
  • 数据的结束索引endIndex = startIndex + domListLen
  • 列表显示数据为domList= list.slice(startIndex,endIndex)

当滚动后,由于渲染区域相对于可视区域已经发生了偏移,此时我需要获取一个偏移量startOffset,通过样式控制将渲染区域偏移至可视区域中。

  • 偏移量startOffset = scrollTop - (scrollTop % itemSize);
最终代码

VirtualList.vue 虚拟滚动组件(item固定高度版)

<template>
  <div class="infinite-list-container" ref="virtualList" @scroll="scroll">
    <div class="infinite-list-phantom" :style="{height: realHeight + 'px'}"></div>
    <div class="infinite-list" :style="{transform: 'translateY('+ offsetTop +'px)'}">
      <div class="item" :style="{height: itemSize + 'px'}" v-for="item in domList" :key="item.key">{{item.text}}</div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'VirtualList',
  props: {
    list: {
      type: Array,
      default() {
        return []
      }
    },
    itemSize: {
      type: Number,
      default: 100
    },
    extraCount: {
      type: Number,
      default: 5
    }
  },
  computed: {
    // list所有节点高度和(用来撑起滚动条)
    realHeight() {
      return this.list.length * this.itemSize
    },
    // 真实渲染的dom数据list
    domList() {
      let startIndex = this.startIndex - this.topExtraCount
      let endIndex = this.endIndex + this.bottomExtraCount
      return this.list.slice(startIndex, endIndex)
    },
    domListLen() {
      return Math.ceil(this.screenHeight / this.itemSize)
    },
    topExtraCount() {
      return Math.min(this.startIndex, this.extraCount)
    },
    bottomExtraCount() {
      return Math.min(this.list.length - this.endIndex, this.extraCount)
    }
  },
  data() {
    return {
      screenHeight: 0, // 可视区高度
      startIndex: 0, // 可视区首个item索引
      endIndex: 0, // 可视区最一个item索引
      offsetTop: 0 // 滚动时,为了保持dom块保持在视口,外层盒子移动的举例
    }
  },
  created() {

  },
  mounted() {
    this.screenHeight = this.$refs.virtualList.clientHeight
    this.endIndex = this.startIndex + this.domListLen
  },
  methods: {
    scroll(e) {
      let scrollTop = e.target.scrollTop
      this.startIndex = Math.floor(scrollTop / this.itemSize)
      this.endIndex = this.startIndex + this.domListLen
      this.offsetTop = Math.max(scrollTop - (scrollTop % this.itemSize) - (this.extraCount * this.itemSize), 0)
      // console.log(this.offsetTop)
    }
  }
}
</script>

<style scoped lang="scss">
.infinite-list-container {
  height: 100%;
  overflow: auto;
  position: relative;
  .infinite-list {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
  }
  .item {
    box-sizing: border-box;
    border: 1px solid red;
  }
}
</style>

使用:

<template>
  <div class="test">
    <virtualList :list="list" :loadData="loadData">
      <template #default="{item}">
        <div>{{item}}</div>
      </template>
    </virtualList>
  </div>
</template>

<script>
import virtualList from './components/virtualList-guding'
const generateString = length => Array(length).fill('').map((v) => '文字').join('')

let list = []
for (let i = 0; i < 5000; i++) {
  list.push({
    key: i,
    text: (i + 1) + generateString(Math.ceil(Math.random() * 100))
  })
}
export default {
  name: 'Test',
  components: { virtualList },
  data() {
    return {
      list
    }
  },
  computed: {},
  created() {},
  methods: {
    loadData() {
      return new Promise((res, req) => {
        let len = this.list.length
        // 模拟异步数据
        setTimeout(() => {
          for (let i = 0; i < 50; i++) {
            this.list.push({
              key: len + i,
              text: (i + 1) + '动态' + generateString(Math.ceil(Math.random() * 100))
            })
          }
          res()
        }, 1000)
      })
    }
  }
}
</script>

<style scoped lang="scss"></style>

item高度不固定

如果item高度不固定呢?类似下图:

image.png

定义positions数组,用于列表项渲染后存储每一项的高度以及位置信息,每一项的初始高度我们一个高度:estimatedItemSize,positions如下:

this.positions = [
  // {
	//   index: 0, // 索引
  //   top:0,    // 元素顶部距离列表最顶部距离
  //   bottom:100,   // 元素底部距离列表最顶部距离
  //   height:100   // 元素高度,初始为estimatedItemSize,渲染完之后设置成真实dom高度
  // }
];

由于需要当前列表渲染完成之后获取dom高度,这里我们写在updated钩子里

updated() {
    if (this.loading) { return }
    let nodes = this.$refs.virtualListItems
		// 拿到真实dom之后修改positons数组
    nodes.forEach(node => {
      let rect = node.getBoundingClientRect()
      let height = rect.height
      let index = parseInt(node.id)
      let oldHeight = this.positions[index].height
      let dValue = oldHeight - height
      // 存在差值
      if (dValue) {
        this.positions[index].bottom = this.positions[index].bottom - dValue
        this.positions[index].height = height
        for (let k = index + 1; k < this.positions.length; k++) {
          this.positions[k].top = this.positions[k - 1].bottom
          this.positions[k].bottom = this.positions[k].bottom - dValue
        }
      }
    })
    // 这个避免直接在updated里面修改data
    this.$refs.realHeight.style.height =
      (this.positions && this.positions.length > 0
        ? this.positions[this.positions.length - 1].bottom
        : 0) + 'px'
    this.$refs.list.style.transform = `translate3d(0,${
      this.positions[this.start].top
    }px,0)`
    // this.offsetTop = this.positions[this.start].top
  },

另外,在item固定高度时,我们通过高度算出的开始索引。这里我们可以根据positons里的top字段及bottom字段获取:

let startObj = this.positions.find(item => {
	return scrollTop >= item.top && scrollTop <= item.bottom
})
this.start = startObj ? startObj.key : 0

最终代码:

<template>
  <div class="virtualList" ref="virtualList" @scroll="scroll">
    <div class="realHeight" ref="realHeight">
    </div>
    <div class="list" ref="list">
      <div
        class="item"
        ref="virtualListItems"
        :id="item.key"
        v-for="item in domList"
        :key="item.key"
      >
        <slot :item="item"></slot>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'VirtualList',
  props: {
    // list数据
    list: {
      type: Array,
      default() {
        return []
      }
    },
    // 分页加载函数,需要返回一个promise,不传则不分页
    loadData: {
      type: Function
    },
    // 初始item高度
    estimateSize: {
      type: Number,
      default: 50
    },
    // 距离底部多少个item是触发加载
    lastNumberLoad: {
      type: Number,
      default: 3
    }
  },
  computed: {
    domList() {
      return this.list.slice(this.start, this.end)
    },
    domListLen() {
      return Math.ceil(this.caet / this.estimateSize)
    }
  },
  data() {
    return {
      caet: 0,
      start: 0,
      end: 0,
      offsetTop: 0,
      positions: [],
      loading: false
    }
  },
  created() {
    this.positions = this.list.map((item, index) => ({
      key: index,
      height: this.estimateSize,
      top: index * this.estimateSize,
      bottom: (index + 1) * this.estimateSize
    }))
  },
  updated() {
    if (this.loading) { return }
    let nodes = this.$refs.virtualListItems
    nodes.forEach(node => {
      let rect = node.getBoundingClientRect()
      let height = rect.height
      let index = parseInt(node.id)
      let oldHeight = this.positions[index].height
      let dValue = oldHeight - height
      // 存在差值
      if (dValue) {
        console.log('index', index)
        this.positions[index].bottom = this.positions[index].bottom - dValue
        this.positions[index].height = height

        for (let k = index + 1; k < this.positions.length; k++) {
          this.positions[k].top = this.positions[k - 1].bottom
          this.positions[k].bottom = this.positions[k].bottom - dValue
        }
      }
    })
    // 这个避免直接在updated里面修改data
    this.$refs.realHeight.style.height =
      (this.positions && this.positions.length > 0
        ? this.positions[this.positions.length - 1].bottom
        : 0) + 'px'
    this.$refs.list.style.transform = `translate3d(0,${
      this.positions[this.start].top
    }px,0)`
    // this.offsetTop = this.positions[this.start].top
  },
  mounted() {
    this.caet = this.$refs.virtualList.clientHeight
    this.end = this.start + this.domListLen
  },
  methods: {
    async scroll(e) {
      let scrollTop = e.target.scrollTop
      if (this.loading) {
        // 这里loading继续渲染是为了解决,滚动到倒数第lastNumberLoad时,列表下部空白,需要继续滚动才能触发加载
        this.updateView(scrollTop)
        return
      }
      // 需要分页 && 滚动距离 + 外层盒子高度 >= 倒数第lastNumberLoad个item的bottom
      if (typeof this.loadData === 'function' && scrollTop + this.caet >= this.positions[Math.max(this.positions.length - this.lastNumberLoad, 0)].bottom) {
        this.loading = true
        await this.loadData()
        this.positions = this.list.map((item, index) => {
          if (this.positions[index]) {
            return this.positions[index]
          } else {
            return {
              key: index,
              height: this.estimateSize,
              top: index * this.estimateSize,
              bottom: index * this.estimateSize
            }
          }
        })
      }
      this.$nextTick(() => {
        this.loading = false
        this.updateView(scrollTop)
      })
    },
    updateView(scrollTop) {
      let startObj = this.positions.find(item => {
        return scrollTop >= item.top && scrollTop <= item.bottom
      })
      this.start = startObj ? startObj.key : 0
      this.end = this.start + this.domListLen
      // this.offsetTop = this.positions[this.start].top
      this.$refs.list.style.transform = `translate3d(0,${
        this.positions[this.start].top
      }px,0)`
    }
  }
}
</script>

<style scoped lang="scss">
.virtualList {
  width: 100%;
  height: 100%;
  overflow-x: hidden;
  overflow-y: auto;
  position: relative;
  .list {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
  }
  .item {
    box-sizing: border-box;
    border: 1px solid red;
    font-size: 20px;
  }
}
</style>

作者: 快落的小海疼

来源: 长列表无限滚动原理分析及其实现

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值