声明:本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
前言
虚拟滚动
是前端领域的一个很常见的技术方案,它出现在我们工作中的方方面面,面试要考
,业务要用
,性能要优化
, 数据要展示
都都离不开虚拟滚动 的背影
于是,最近闲来无事,浅浅的研究了一下, 不成想技术原理还挺深奥
细究之后,总结为这篇文章,跟这位jym
汇报一下
虚拟滚动有哪些应用场景
说起虚拟滚动的应用场景,我们还是要追溯到问题的本质解答问题。
我们为什么要使用虚拟滚动?
答案很简单,不用虚拟滚动页面他卡啊
有的jym
就开始问了,为啥会卡呢?
那我就要从丘处机路过牛家村开始了
我们知道,页面之所以卡顿是,是因为同时渲染的元素太多了
大家 可以发现,一个select标签,只是简单的渲染上千条数文案数据
他就耗时接近两秒,于是,我们在这两秒内,我们就无法进行任何操作,
具体为什么无法操作,这属于浏览器工作原理的范畴。我们就不再废唾沫星子了
如果有兴趣,可以去这重学前端(五)------谈谈前端性能优化
简而言之,就是单线程
的特性,导致js执行和渲染是互斥的,无法同时进行,只能将js 放入eventloop 队列中,延后执行
于是有了这些片汤话的铺垫,我们就能很简单的得出结论 :大连数据的列表都可以使用虚拟滚动
接下来,他的应用场景就能呼之欲出 了,比如在web页面中,table表格
、list
、 select
、 tree
等通用场景,都是虚拟滚动的需要应用的地方。
虚拟滚动原理
我们在之前的 推导中找到了需要应用的地方, 接下来就该怎样应用了,也就是 虚拟滚动原理
其实虚拟滚动
听起来很玄乎,仿佛是一个高大上的技术方案,其实他的原理很简单,
在目前的行业实践中,主要有两个方向
- 1、根据滑动位置,动态加载内容
- 2、只渲染可视区域的列表项,非可见区域的**不渲染
别急我们一个一个讲
根据滑动位置,动态加载内容
这是一个非常讨巧的方案, 因为他的原理很有意思,你不是说,同时加载卡吗?
那我一点点加载分批来不就解决了吗?
element-plus
Infinite Scroll 就是这种方案,是一个指令插件,使用方式很简单
js
<ul v-infinite-scroll="load" class="infinite-list" style="overflow: auto">
<li v-for="i in count" :key="i" class="infinite-list-item">{{ i }}</li>
</ul>
其实,本质上来说,他是什么类型的插件无所谓,形式不重要,内容才重要!!
我们来简单的研究一下他的内容(也就是源码)
js
// @ts-nocheck
import { nextTick } from 'vue'
import { isFunction } from '@vue/shared'
import { throttle } from 'lodash-unified'
import {
getOffsetTopDistance,
getScrollContainer,
throwError,
} from '@element-plus/utils'
import type { ComponentPublicInstance, ObjectDirective } from 'vue'
// 一些常量
export const SCOPE = 'ElInfiniteScroll'
export const CHECK_INTERVAL = 50
export const DEFAULT_DELAY = 200
export const DEFAULT_DISTANCE = 0
// 默认属性
const attributes = {
delay: {
type: Number,
default: DEFAULT_DELAY,
},
distance: {
type: Number,
default: DEFAULT_DISTANCE,
},
disabled: {
type: Boolean,
default: false,
},
immediate: {
type: Boolean,
default: true,
},
}
// 类型定义
type Attrs = typeof attributes
type ScrollOptions = { [K in keyof Attrs]: Attrs[K]['default'] }
type InfiniteScrollCallback = () => void
type InfiniteScrollEl = HTMLElement & {
[SCOPE]: {
container: HTMLElement | Window
containerEl: HTMLElement
instance: ComponentPublicInstance
delay: number // export for test
lastScrollTop: number
cb: InfiniteScrollCallback
onScroll: () => void
observer?: MutationObserver
}
}
// 获取一下外部传入的属性
const getScrollOptions = (
el: HTMLElement,
instance: ComponentPublicInstance
): ScrollOptions => {
return Object.entries(attributes).reduce((acm, [name, option]) => {
const { type, default: defaultValue } = option
const attrVal = el.getAttribute(`infinite-scroll-${name}`)
let value = instance[attrVal] ?? attrVal ?? defaultValue
value = value === 'false' ? false : value
value = type(value)
acm[name] = Number.isNaN(value) ? defaultValue : value
return acm
}, {} as ScrollOptions)
}
// 销毁 dom 监听
const destroyObserver = (el: InfiniteScrollEl) => {
const { observer } = el[SCOPE]
if (observer) {
observer.disconnect()
delete el[SCOPE].observer
}
}
// 滚动条事件
const handleScroll = (el: InfiniteScrollEl, cb: InfiniteScrollCallback) => {
// 取出实例中的内容
const { container, containerEl, instance, observer, lastScrollTop } =
el[SCOPE]
// 同样的获取属性
const { disabled, distance } = getScrollOptions(el, instance)
// 拿到他的容器高度,滚动的总共高度,举例顶部的举例
const { clientHeight, scrollHeight, scrollTop } = containerEl
// 获取这一次滚动了多少
const delta = scrollTop - lastScrollTop
// 保存当前这次滚动距离,方便下一次计算
el[SCOPE].lastScrollTop = scrollTop
// 判断一些特殊情况,如果往上滑,或者禁用的时候,就不处理
if (observer || disabled || delta < 0) return
let shouldTrigger = false
// 如果绑定的指令就是容器
if (container === el) {
// 计算是否需要执行函数
shouldTrigger = scrollHeight - (clientHeight + scrollTop) <= distance
} else {
// 如果绑定的指令不是容器,那么就用另一种计算方式
const { clientTop, scrollHeight: height } = el
const offsetTop = getOffsetTopDistance(el, containerEl)
shouldTrigger =
scrollTop + clientHeight >= offsetTop + clientTop + height - distance
}
// 如果判断出来的距离需要加载新数据
if (shouldTrigger) {
// 那么就执行下拉获取新数据
cb.call(instance)
}
}
function checkFull(el: InfiniteScrollEl, cb: InfiniteScrollCallback) {
// 从SCOPE 中取出实例
const { containerEl, instance } = el[SCOPE]
// 判断禁用情况
const { disabled } = getScrollOptions(el, instance)
// 如果有禁用 视口高度等于0 等情况,那就直接回退
if (disabled || containerEl.clientHeight === 0) return
// 然后就判断,如果滑动宽度比 视口还小
if (containerEl.scrollHeight <= containerEl.clientHeight) {
// 那就说明可能要执行一次函数了 拉取下一页
cb.call(instance)
} else {
// 否要就要清除监听
// 移除监听的原因是因为,他出现滚动条了,就可以执行滚动事件了
// 不在需要靠监听 dom 变动来解决问题
destroyObserver(el)
}
}
// 核心代码在这
// 指令型插件,很多生命周期
const InfiniteScroll: ObjectDirective<
InfiniteScrollEl,
InfiniteScrollCallback
> = {
// dom初始化执行
async mounted(el, binding) {
//取出回调函数
const { instance, value: cb } = binding
// 兜底判断
if (!isFunction(cb)) {
throwError(SCOPE, "'v-infinite-scroll' binding value must be a function")
}
// 防止没有dom 出问题,用nextTick 处理一下
await nextTick()
// 拿到其中的一些默认配置
const { delay, immediate } = getScrollOptions(el, instance)
// 获取滚动条层的dom容器
const container = getScrollContainer(el, true)
// 判断容器是不是window 因为如果是window的话,就必须啊找到他下头的第一个节点
// 因为window 是不能滚动的
const containerEl =
container === window
? document.documentElement
: (container as HTMLElement)
const onScroll = throttle(handleScroll.bind(null, el, cb), delay)
if (!container) return
// 绑定环境,因为在页面中可能会有很多个虚拟滚动实例
// 所以我们要将每个实例保存起来方便后续取用
// 这里的技巧就是绑定在el上,后续给大家说好处
el[SCOPE] = {
instance,
container,
containerEl,
delay,
cb,
onScroll,
lastScrollTop: containerEl.scrollTop,
}
//immediate 表示是否立即执行加载
if (immediate) {
// 如果立即执行,那么就监听dom 变化
const observer = new MutationObserver(
throttle(checkFull.bind(null, el, cb), CHECK_INTERVAL)
)
// 保存实例
el[SCOPE].observer = observer
// 启动监听,针对当前el 下方的所有dom 变动
observer.observe(el, { childList: true, subtree: true })
// 执行检查函数,主要就是为了判断是不是到底了,包括每次监听dom 变化也是这个原因
// 这个方法就是为了防止我没有盛满容器,有不能出发scroll 事件,从而,用的兜底策略
// 利用监听dom 变化来多次监听从而多次执行获取新内容函数
checkFull(el, cb)
}
// 绑定滑动时间,实时计算距离,是否需要下拉新内容
container.addEventListener('scroll', onScroll)
},
//dom 卸载
unmounted(el) {
if (!el[SCOPE]) return
const { container, onScroll } = el[SCOPE]
container?.removeEventListener('scroll', onScroll)
destroyObserver(el)
},
// dom 更新
async updated(el) {
// 如果没有实例就不管了
if (!el[SCOPE]) {
await nextTick()
} else {
// 如果有的话,要重新检查一下
const { containerEl, cb, observer } = el[SCOPE]
if (containerEl.clientHeight && observer) {
checkFull(el, cb)
}
}
},
}
export default InfiniteScroll
其实上述代码中洋洋洒洒写了这么多,其实主要就干了一个事情
利用滚动条的scroll
事件,判断滚动是否到底,如果到底则动态加载新数据,如此而已
之前我说过,他的优秀之处,不是形式,而是内容,因为他内部做了大量的兼容,以及小妙招对于我们日常的开发大有裨益
- 1、 指令实例保存方式
- 2、 利用MutationObserver保证兜底策略
指令实例保存方式
这是一个非常新颖的方案 ,在这之前,我们知道指令内部是无法保存实例的
,如果当指令初始化之后,指令外部想要使用指令初始化之后的实例,我们大多数人的常规操作,将实例
挂载到全局,而这么一来就会有个问题,如果我有多个实例呢?
所以这个插件的保存实例
方式就很巧妙 ,将内容挂载到 dom
上,既解决了实例保存的问题,有解决了多指令获取实例的问题。
我们想要使用实例的时候只需要
js
//html
<ul ref="infiniteRef" v-infinite-scroll="load" class="infinite-list" style="overflow: auto">
<li v-for="i in count" :key="i" class="infinite-list-item">{{ i }}</li>
</ul>
// js
const infiniteRef = ref(null);
// 从 dom 中取实例
contentRef.value.ElInfiniteScroll.observer()
利用MutationObserver保证兜底策略
MutationObserver
我就不再赘述了,他是一个监听 dom
的 api
但从来没有人会想到利用MutationObserver
去主动更新 dom
这其实就是一个创新升级,希望在我们搬砖
的项目中,可以借鉴。
第二种方案指的是渲染可视区域的列表项,非可见区域的不渲染
只渲染可视区域的列表项,非可见区域的不渲染
上图我们可以发现,不过表格怎么变化,dom
就那么几个
朴实无华,主旨很简单, 分治思想
我们只管当前只一片就行
很多人可能不太理解,那么我就用一个图,来给大家生动的展示一下
尽力了,原谅我骥某人才疏学浅,只能画成这样了,各位jym
凑活看吧
画的虽然有点ugly
,但表达的东西,相信大家都能看懂
我们只是将一部分视口看到的内容展示出来, 其他的假装展示了,反正你也看不见
可接下来问题来了,我展示是展示了,可我要滑动页面,怎么更新视口内容呢?
如何更新视口内容
如何更新视口,其实本质上就是我们如何能让那几十个我们能看得见的 dom
永远在视口处活动就可以
那应该怎么做呢? 我们首先可以确定两点
- 1、一定要监听滚动事件
scroll
- 2、当滚动条滚动的时候,要移动内容永远到视口上来
ok,一拍即合,我们来实现一下
js
<script setup lang="ts">
import { ref } from 'vue'
const paddingTop = ref(0)
const scroll = (e) => {
if (e.target.scrollTop % 200 < 20) {
paddingTop.value = e.target.scrollTop
}
}
</script>
<template>
<div class="warp" @scroll="scroll">
<div class="scroll-box" :style="`padding-top:${paddingTop}px`">
<div class="test-item">这是测试 item</div>
<div class="test-item">这是测试 item</div>
<div class="test-item">这是测试 item</div>
<div class="test-item">这是测试 item</div>
<div class="test-item">这是测试 item</div>
<div class="test-item">这是测试 item</div>
<div class="test-item">这是测试 item</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.warp {
height: 300px;
width: 500px;
border: 1px solid #000;
overflow: auto;
margin-top: 100px;
margin-left: 100px;
.scroll-box {
height: 6000px;
}
}
</style>
以上代码中,就是我们利用滚动事件,事实将内容放到视口中,这里我用的是padding
解决的问题,当然你也可以用 css3的transition
搞定,无所谓。
还是那句话,形式不重要,内容才重要
一图胜千言
有了这个基础,我们就可以进行接下来的一步,动态改变 item
内容,这里我自己写可能有点班门弄斧,我们从网上选取了一个库来参考一下,当然,基于国际惯例,我也臭不要脸的给他 fork
下来并且有详细的注释
有兴趣可以移步virtual-list
接下来我们就来浅浅的分析一下
首先他的使用方式很简单
js
// data-sources 传入数据
// data-component 传入list组件(其实我觉得用插槽更好)
<VirtualList
class="list-dynamic scroll-touch"
:data-key="'id'"
:data-sources="items"
:data-component="Item"
:estimate-size="80"
:item-class="'list-item-dynamic'"
/>
虚拟滚动源码分析
聊起源码分析,很多人总是想将源码净收眼底,这其实是一个看源码的误区
毕竟老话说得好,不能既要又要,因为在源码中的大多数内容都是为了兼容和兜底用的,对于我们的业务帮助可能并不大
我们看源码其实本质上说,就是领会精神(也就是核心原理)
于是,回到当前问题上来也是一样, 我们只需要关注他源码中怎样根据滚动替换内容即可
至于那些兜底和兼容逻辑,随他去吧,因为他只是在当下的场景下的一个不得不的一个做法
放到的的业务中可能并不适用
就好比很多人在看历史的时候,总以为可以以史为鉴
其实,很多人不知道的是所谓的以史为鉴,鉴的不过是自己的偏见
所有的历史事件的发生,都有他不得不发生的理由,盲目瞎学,学的也只是你自己的偏见
额,好像有点跑题了。。。。
我们回到正题,研究他怎么动态更新内容
开源库和我们普通代码的区别就是,他要被别人指指点点
,所以封装一般都成了所有开源库的标配
于是,本库,就直接封装了虚拟滚动的的内容
代码如下:
js
/**
* virtual list core calculating center
*
* @format
*/
const DIRECTION_TYPE = {
FRONT: 'FRONT', // scroll up or left
BEHIND: 'BEHIND', // scroll down or right
}
const CALC_TYPE = {
INIT: 'INIT',
FIXED: 'FIXED', // 固定 item宽度
DYNAMIC: 'DYNAMIC', // 动态 item 宽度
}
const LEADING_BUFFER = 2
// 虚拟滚动实例本质上就是提供了一些封装方法和初始状态
export default class Virtual {
constructor(param, callUpdate) {
// 初始化启动
this.init(param, callUpdate)
}
init(param, callUpdate) {
// 传入参数
this.param = param
// 回调函数
this.callUpdate = callUpdate
// 总展示item,注意是一步步的展示的
this.sizes = new Map()
// 展示的总高度,为了计算平均值的
this.firstRangeTotalSize = 0
// 根据上述变量计算平均高度
this.firstRangeAverageSize = 0
// 上一次的滑动到过的index
this.lastCalcIndex = 0
// 固定的高度的 item的高度
this.fixedSizeValue = 0
// item 类型,是动态高度,还是非动态高度
this.calcType = CALC_TYPE.INIT
// 滑动距离,为了算 padding 的大小
this.offset = 0
// 滑动方向
this.direction = ''
// 创建范围空对象,保存展示的开始展示位置,结束展示位置,
this.range = Object.create(null)
// 先初始化一次
if (param) {
this.checkRange(0, param.keeps - 1)
}
// benchmark test data
// this.__bsearchCalls = 0
// this.__getIndexOffsetCalls = 0
}
destroy() {
this.init(null, null)
}
// 返回当前渲染范围
// 其实就是深拷贝
getRange() {
const range = Object.create(null)
range.start = this.range.start
range.end = this.range.end
range.padFront = this.range.padFront
range.padBehind = this.range.padBehind
return range
}
isBehind() {
return this.direction === DIRECTION_TYPE.BEHIND
}
isFront() {
return this.direction === DIRECTION_TYPE.FRONT
}
// 返回起始索引偏移
getOffset(start) {
return (
(start < 1 ? 0 : this.getIndexOffset(start)) + this.param.slotHeaderSize
)
}
updateParam(key, value) {
if (this.param && key in this.param) {
// if uniqueIds change, find out deleted id and remove from size map
if (key === 'uniqueIds') {
this.sizes.forEach((v, key) => {
if (!value.includes(key)) {
this.sizes.delete(key)
}
})
}
this.param[key] = value
}
}
// 按id保存每个item
saveSize(id, size) {
this.sizes.set(id, size)
//我们假设大小类型在开始时是固定的,并记住第一个大小值
//如果下次提交保存时没有与此不同的大小值
//我们认为这是一个固定大小的列表,否则是动态大小列表
// 他这个套路很巧妙他给每一列的高度判断一下
// 如果相同那么就默认为是相同的高度,如果不同那么默认为不同的高度
if (this.calcType === CALC_TYPE.INIT) {
this.fixedSizeValue = size
this.calcType = CALC_TYPE.FIXED
} else if (
this.calcType === CALC_TYPE.FIXED &&
this.fixedSizeValue !== size
) {
this.calcType = CALC_TYPE.DYNAMIC
// it's no use at all
delete this.fixedSizeValue
}
// 仅计算第一个范围内的平均大小
// 如果是动态高度的情况下
if (
this.calcType !== CALC_TYPE.FIXED &&
typeof this.firstRangeTotalSize !== 'undefined'
) {
// 如果已经获取高度的数据比展示的总数据小的时候才计算
if (
this.sizes.size <
Math.min(this.param.keeps, this.param.uniqueIds.length)
) {
this.firstRangeTotalSize = [...this.sizes.values()].reduce(
(acc, val) => acc + val,
0,
)
// 计算出来一个平均高度
this.firstRangeAverageSize = Math.round(
this.firstRangeTotalSize / this.sizes.size,
)
} else {
// 拿到平均高度了,就干掉总高度
delete this.firstRangeTotalSize
}
}
}
// in some special situation (e.g. length change) we need to update in a row
// try goiong to render next range by a leading buffer according to current direction
handleDataSourcesChange() {
let start = this.range.start
if (this.isFront()) {
start = start - LEADING_BUFFER
} else if (this.isBehind()) {
start = start + LEADING_BUFFER
}
start = Math.max(start, 0)
this.updateRange(this.range.start, this.getEndByStart(start))
}
// when slot size change, we also need force update
handleSlotSizeChange() {
this.handleDataSourcesChange()
}
// 滚动计算范围
handleScroll(offset) {
// 计算方向 也就是是朝上还是朝下滑动
this.direction =
offset < this.offset ? DIRECTION_TYPE.FRONT : DIRECTION_TYPE.BEHIND
// 保存当前offset 距离,为了判断下次是朝上还是朝下
this.offset = offset
if (!this.param) {
return
}
if (this.direction === DIRECTION_TYPE.FRONT) {
// 如果是朝上滑动
this.handleFront()
} else if (this.direction === DIRECTION_TYPE.BEHIND) {
// 如果是朝下滑动
this.handleBehind()
}
}
// ----------- public method end -----------
handleFront() {
const overs = this.getScrollOvers()
// should not change range if start doesn't exceed overs
if (overs > this.range.start) {
return
}
// move up start by a buffer length, and make sure its safety
const start = Math.max(overs - this.param.buffer, 0)
this.checkRange(start, this.getEndByStart(start))
}
handleBehind() {
// 获取偏移量 所对饮的 list
const overs = this.getScrollOvers()
// 如果在缓冲区内滚动,范围不应改变 ,range是在每次滑动出缓冲区的时候更改
if (overs < this.range.start + this.param.buffer) {
return
}
// 也就是当overs 大于当前的缓冲内容了,也就是到头了
//我们就开始启动检查机制,重新确定range
// 其实就是开辟新的缓冲区
this.checkRange(overs, this.getEndByStart(overs))
}
// 根据当前滚动偏移量返回传递值
getScrollOvers() {
// 如果插槽标头存在,我们需要减去它的大小,为了兼容
const offset = this.offset - this.param.slotHeaderSize
if (offset <= 0) {
return 0
}
// 固定高度的 itm 很好办,直接用偏移量除以单独的宽度就行,就能得出挪上去了几个元素
if (this.isFixedType()) {
return Math.floor(offset / this.fixedSizeValue)
}
// 非固定高度就麻烦了
let low = 0
let middle = 0
let middleOffset = 0
let high = this.param.uniqueIds.length
// 接下来就要有一套算法来解决问题了,求偏移了几个
while (low <= high) {
console.log(low, high)
// this.__bsearchCalls++
//他这个算法应该属于二分法,通过二分法去求最接近偏移量的 list条数
// 获取二分居中内容,其实有可能跟总high 一样
middle = low + Math.floor((high - low) / 2)
// 获取居中条数的总偏移量
middleOffset = this.getIndexOffset(middle)
// 如果偏移量,等于当前偏移量
if (middleOffset === offset) {
// 中间 位置数据返回
return middle
// 还是利用二分法去找逐渐缩小距离
} else if (middleOffset < offset) {
low = middle + 1
} else if (middleOffset > offset) {
high = middle - 1
}
}
// 最后是在没找到,就也是无限接近了
// 因为如果只有大于才会给 while 干掉
// 也就是在干掉的一瞬间他一定是最接近 offset 的那个值,并且根据动态高度,所形成的 list 条数
// 之所以-- 是因为 while不行了,所以,我们要回到他行的时候
return low > 0 ? --low : 0
}
//返回给定索引的滚动偏移量,这里可以进一步提高效率吗?
//虽然通话频率很高,但它只是数字的叠加
getIndexOffset(givenIndex) {
// 如果没有就返回 0 偏移量
if (!givenIndex) {
return 0
}
// 初始偏移量
let offset = 0
let indexSize = 0
// 遍历元素内容
for (let index = 0; index < givenIndex; index++) {
// this.__getIndexOffsetCalls++
// 获取他们的高度
indexSize = this.sizes.get(this.param.uniqueIds[index])
// 获取他准确的偏移量,只有前一部分有后续就没有了,所以就要按照前头计算的平均计算量去计算
// 后续如果滑动完了,那么就会找到,能事实更正
offset =
offset +
(typeof indexSize === 'number' ? indexSize : this.getEstimateSize())
}
// 记住上次计算指标 这里计算是为了后续比较的时候用的
// 因为有可能往上滑或者往下滑,所以每次要比较一下取最大值
this.lastCalcIndex = Math.max(this.lastCalcIndex, givenIndex - 1)
// 或者跟总元素个数比较取最小的也就是 lastCalcIndex 不能比总元素数量小,这个math.min
// 之所以要取小,是为了兼容, lastCalcIndex 可能大于最大数量的情况
//console.log(this.lastCalcIndex, this.getLastIndex())
// 经过实践发现,好像前者永远不会大于后者,这个取值好像没用
this.lastCalcIndex = Math.min(this.lastCalcIndex, this.getLastIndex())
return offset
}
// is fixed size type
isFixedType() {
return this.calcType === CALC_TYPE.FIXED
}
// return the real last index
getLastIndex() {
return this.param.uniqueIds.length - 1
}
//在某些情况下,范围被打破,我们需要纠正它
//然后决定是否需要更新到下一个范围
checkRange(start, end) {
const keeps = this.param.keeps
const total = this.param.uniqueIds.length
// 小于keep的数据,全部呈现
// 就是条数太少了,就没有必要搞烂七八糟的计算了
if (total <= keeps) {
start = 0
end = this.getLastIndex()
} else if (end - start < keeps - 1) {
// 如果范围长度小于keeps,则根据end进行校正
start = end - keeps + 1
}
// 如果范围有问题,那么就需要重新更新范围
if (this.range.start !== start) {
this.updateRange(start, end)
}
}
// 设置到新范围并重新渲染
updateRange(start, end) {
this.range.start = start
this.range.end = end
this.range.padFront = this.getPadFront()
this.range.padBehind = this.getPadBehind()
// 通知回调函数
console.log(this.getRange())
this.callUpdate(this.getRange())
}
// 这个其实就是基于他的开始位置,返回一个一定的位置
getEndByStart(start) {
const theoryEnd = start + this.param.keeps - 1
// 也有可能最后算出来的超出了当前的总数据量 ,所以要取小来搞定结束位置
const truelyEnd = Math.min(theoryEnd, this.getLastIndex())
return truelyEnd
}
// 返回总前偏移
getPadFront() {
// 固定高度的
if (this.isFixedType()) {
return this.fixedSizeValue * this.range.start
} else {
// 非固定高度,在方法中用二分法,获取最接近的
return this.getIndexOffset(this.range.start)
}
}
// 计算总高度
getPadBehind() {
// 获取初始 end
const end = this.range.end
// 获取总条数
const lastIndex = this.getLastIndex()
// 如果是 fixed大小
if (this.isFixedType()) {
return (lastIndex - end) * this.fixedSizeValue
}
// 这是非固定高度
if (this.lastCalcIndex === lastIndex) {
//如果之前滑动到过底部则返回精确的偏移量
return this.getIndexOffset(lastIndex) - this.getIndexOffset(end)
} else {
//如果没有,请使用估计值
return (lastIndex - end) * this.getEstimateSize()
}
}
// 获取项目估计大小,兜底策略,防止高度为空的情况,拿他的默认高度
getEstimateSize() {
return this.isFixedType()
? this.fixedSizeValue
: this.firstRangeAverageSize || this.param.estimateSize
}
}
以上代码中,就是对于虚拟滚动的封装,主要有关的就封装了那么几个方法
- 1、保存个更新
range
实时更新 padding - 2、展示保存总数据的高度信息
- 3、兼容固定高度,和固定高度的
item
类型
接下来就很简单了我们初始化这个实例
js
// 初始化虚拟滚动
const installVirtual = () => {
// 获取虚拟滚动所用实例
virtual = new Virtual(
{
slotHeaderSize: 0,
slotFooterSize: 0,
keeps: props.keeps,
estimateSize: props.estimateSize,
buffer: Math.round(props.keeps / 3), // 默认保留三分之一,也就是十条之所以保留三分之一,防止他还没划到地方就更改 padding 出现错误
uniqueIds: getUniqueIdFromDataSources(),
},
// 选区改变,重新生成选区
onRangeChanged,
)
// 获取选区这一步其实有点多此一举了
//range.value = virtual.getRange()
}
// 在组件的初始渲染发生之前被调用。
onBeforeMount(() => {
// 初始化虚拟滚动
installVirtual()
})
监听滚动事件,根据virtual
中的实例
动态改变数据和更改 padding
js
// list核心组件
export default defineComponent({
name: 'VirtualList',
// props 传值
props: VirtualProps,
setup(props, { emit, slots, expose }) {
// 主渲染逻辑
const getRenderSlots = () => {
const slots = []
// 由于在之前 scroll 中更改了 范围的开始和结束
const { start, end } = range.value
const {
dataSources,
dataKey,
itemClass,
itemTag,
itemStyle,
extraProps,
dataComponent,
itemScopedSlots,
} = props
// 开始遍历,当前内容
for (let index = start; index <= end; index++) {
const dataSource = dataSources[index]
if (dataSource) {
const uniqueKey =
typeof dataKey === 'function'
? dataKey(dataSource)
: dataSource[dataKey]
if (typeof uniqueKey === 'string' || typeof uniqueKey === 'number') {
slots.push(
// 传入的内容,将内容放到 item 上,注意这个 item 是传入的
<Item
index={index}
tag={itemTag}
event={EVENT_TYPE.ITEM}
horizontal={isHorizontal}
uniqueKey={uniqueKey}
source={dataSource}
extraProps={extraProps}
component={dataComponent}
scopedSlots={itemScopedSlots}
style={itemStyle}
class={`${itemClass}${
props.itemClassAdd ? ' ' + props.itemClassAdd(index) : ''
}`}
onItemResize={onItemResized}
/>,
)
} else {
console.warn(
`Cannot get the data-key '${dataKey}' from data-sources.`,
)
}
} else {
console.warn(`Cannot get the index '${index}' from data-sources.`)
}
}
return slots
}
// 核心逻辑监听滚动事件
const onScroll = (evt) => {
// 获取距离顶部的距离
const offset = getOffset()
// 获取视口宽度
const clientSize = getClientSize()
// 获取内容总高度
const scrollSize = getScrollSize()
// iOS滚动回弹行为会造成方向错误,解决兼容 bug
if (offset < 0 || offset + clientSize > scrollSize + 1 || !scrollSize) {
return
}
// 处理滚动事件确定数据
virtual.handleScroll(offset)
emitEvent(offset, clientSize, scrollSize, evt)
}
return () => {
// 拿到 props
const {
pageMode,
rootTag: RootTag,
wrapTag: WrapTag,
wrapClass,
wrapStyle,
headerTag,
headerClass,
headerStyle,
footerTag,
footerClass,
footerStyle,
} = props
// 动态的更改 paddingtop 和 paddingbottom
// 注意这个距离顶部的距离,和距离底部的距离,是根据在滑动的时候动态算出来的
const { padFront, padBehind } = range.value!
const paddingStyle = {
padding: isHorizontal
? `0px ${padBehind}px 0px ${padFront}px`
: `${padFront}px 0px ${padBehind}px`,
}
const wrapperStyle = wrapStyle
? Object.assign({}, wrapStyle, paddingStyle)
: paddingStyle
const { header, footer } = slots
// jsx
return (
<RootTag ref={root} onScroll={!pageMode && onScroll}>
{/* header slot */}
{header && (
<Slot
class={headerClass}
style={headerStyle}
tag={headerTag}
event={EVENT_TYPE.SLOT}
uniqueKey={SLOT_TYPE.HEADER}
onSlotResize={onSlotResized}
>
{header()}
</Slot>
)}
{/* main list */}
<WrapTag class={wrapClass} style={wrapperStyle}>
// 核心展示逻辑
{getRenderSlots()}
</WrapTag>
{/* footer slot */}
{footer && (
<Slot
class={footerClass}
style={footerStyle}
tag={footerTag}
event={EVENT_TYPE.SLOT}
uniqueKey={SLOT_TYPE.FOOTER}
onSlotResize={onSlotResized}
>
{footer()}
</Slot>
)}
{/* an empty element use to scroll to bottom */}
<div
ref={shepherd}
style={{
width: isHorizontal ? '0px' : '100%',
height: isHorizontal ? '100%' : '0px',
}}
/>
</RootTag>
)
}
},
})
以上核心代码中,主要就做了两件小事
- 1、 监听 scroll 事件,更改
virtual
实例的内容 - 2、 根据
virtual
的内容动态更改展示数据
打完收工
最后
这两种方案虽然都能提升性能,但各有千秋,因为,前者是无法规避 vue
内部的 diff
计算的 js
损耗
而后者,是无法规避每次滑动的渲染损耗
所以两瓶毒药,大家可自己斟酌,如果自己斟酌不了
那就问领导!!!,如果他也搞不定,那他还当什么领导,让我来
源码分析完了,如果有看不明白的 jy
可以给我私信
希望跟各位jym
共同进步!