前端工程师在面试时经常被问的闭包到底是什么?我用打包礼物的例子让你秒懂

什么是虚拟列表?

有了解过按需加载概念的读者或许会很容易理解虚拟列表,虚拟列表的本质就是一种按需加载的设计,我们在浏览列表中的数据时,实际上只需要渲染出可视区域中的列表项即可,而不在可视区域内的列表项其实没必要进行渲染

这样一来无论数据量有多少,管他是十万条还是百万条,最终交给浏览器渲染的只有可视区域那一块的部分数据要渲染而已,相比于一次性渲染大量数据的粗暴方式,能够大幅度降低浏览器的渲染压力,就如下图所示:

Vue3 + TypeScript 实现虚拟列表

我们的目标就是要保证可视区域内能够渲染出对应的列表项,为了方便实现,这里我就直接使用Vue

明确使用到的数据

基于数据驱动的思想,我们先明确一下可能需要用到哪些数据:

首先要维护一下上图中的这几个状态数据:

1.startIdxendIdx用于控制可视区域内展示的数据,会在滚动条发生变化的时候动态变化
2.itemHeight用于描述列表中元素的高度,这里为了简单起见,我们将元素的高度设为一个固定值,之后再考虑如何优化成任意高度的元素渲染
3.visibleAreaHeight则用于记录可视区域的高度,这样我们就可以结合itemHeight知道可视区域内的元素个数了

以上这四个都是作为状态去保存的,但仅有它们还不够,我们还需要一些额外的计算属性数据

虚拟列表总高度

首先我们要知道全部数据渲染之后的一个总高度,这样才能将其设置为我们的占位列表容器元素list-container-phantom的高度

因为我们的真实存放元素的列表容器它的高度应当是确定的,但我们又需要知道全部数据#渲染后的一个总高度,这样才方便我们监听滚动条的滚动事件,根据scrollTop去更改我们的startIdxendIdx从而更新虚拟列表中的数据

那么所有数据渲染后的总高度怎么计算呢?我们在接口返回的数据中能够知道一共有多少条数据,并且我们已经知道每条数据最终渲染后的一个高度itemHeight了,那么总高度就是data.length * itemHeight即可

可视区域中元素个数

为什么需要知道这个数据呢?这是为我们的endIdx准备的,滚动条变化时,startIdx根据scrollTop动态变化,而endIdx只需要在startIdx的基础上加上当前可视区域中的元素个数即可,所以需要用到该数据

而这个数据可以通过visibleAreaHeight / itemHeight获得,因此也将其作为一个计算属性

根据以上描述,我们可以维护一个state去记录startIdxendIdxvisibleAreaHeightitemHeight和接口返回的数据data这五个状态数据

其余的则作为计算属性即可

interface IData {id: numbercontent: string
}

// 可视区域中渲染元素的起始下标
const state = reactive({// 要渲染的数据data: [] as IData[],// 可视区域内的第一个元素在 data 中的下标startIdx: 0,// 可视区域内的最后一个元素在 data 中的下标endIdx: 0,// 元素的高度itemHeight: 24,// 可视区域的高度visibleAreaHeight: 0,
})

// 获取接口数据
interface IResponseData {code: numbermsg: stringdata: IData[]
}
const fetchData = (dataCount = 100000) => {return new Promise<IResponseData>(resolve => {const response: IResponseData = {code: 0,msg: 'success',data: [],}for (let i = 0; i < dataCount; i++) {response.data.push({id: i,content: `content-${i + 1}`,})}setTimeout(() => {resolve(response)}, 300)})
}

const virtualListRef = ref<HTMLDivElement>()

const dataDisplay = computed(() =>state.data.slice(state.startIdx, state.endIdx),
)

// 数据的总高度
const dataTotalHeight = computed(() => state.itemHeight * state.data.length)

// 可视区域中的元素个数
const visualAreaItemCount = computed(() =>Math.ceil(state.visibleAreaHeight / state.itemHeight),
) 

由于虚拟列表中的数据是由startIdxendIdx决定的,所以这里我又加了一个dataDisplay计算属性,在接口返回的数据数组中进行切片即可

组件结构

虽然我们只渲染可视区域中的列表项,但是还是需要知道完整的列表有多高,这样才方便滑动滚动条去按需加载不同的数据,所以这里我们不仅要有一个列表容器,还需要有一个占位用的列表容器,用于形成滚动条

占位列表容器的高度就是最终全部数据渲染完成后的高度,至于这个高度怎么计算可就有讲究了,稍后会讲,先看看页面结构吧

<template><div ref="virtualListRef" class="virtual-list" @scroll="handleScroll"><!-- 占位容器 高度和数据的真实高度一致 用于形成滚动条 phantom: 幻影 --><divclass="list-container-phantom":style="{ height: `${dataTotalHeight}px` }"></div><!-- 真实的列表容器 --><div class="list-container"><divv-for="item in dataDisplay":key="item.id"ref="listItemsRef"class="list-item":data-position-idx="item.id"><span>{{ item.content }}</span></div></div></div>
</template> 

这里我们还需要监听有滚动条的元素的滚动事件,获取它的scrollTop

const handleScroll = (e: UIEvent) => {const scrollTop = (e.target as HTMLDivElement).scrollTopstate.startIdx = ~~(scrollTop / state.itemHeight)state.endIdx = state.startIdx + visualAreaItemCount.value
} 

数据初始化

onMounted(async () => {// 获取接口数据const response = await fetchData()state.data = response.data// 获取虚拟列表容器的高度state.visibleAreaHeight = virtualListRef.value!.clientHeight// 初始化 startIdx 和 endIdxstate.startIdx = 0state.endIdx = state.startIdx + visualAreaItemCount.value
}) 

现在就已经完成了,我们先来看看效果吧~

咦?怎么好像不太对劲,虽然滚动条移动时虚拟列表内的数据是跟着变化了,但是整个列表貌似也随着滚动条在往上移动,这是怎么回事呢?

加入偏移量让虚拟列表整体偏移到容器中央

之所以会出现上图的情况,是因为我们的滚动条并不是由虚拟列表容器产生的,而是虚拟列表的虚拟列表产生的

说起来有点绕,还记得前面组件结构中我们有一个.list-container-phantom元素吗?这个元素用来占位产生一个滚动条,该元素的高度就是所有数据最终渲染后的高度

因此我们拖拽滚动条的时候,实际上可以理解为整个可视区域在移动,而真正渲染数据的那个虚拟列表一直在最顶部

这就导致滚动条往下拉的时候,产生了整个虚拟列表上移的现象,那这很简单,我们让虚拟列表整体跟着滚动条的可视区域一起移动不就好了吗?

为此我们再引入一个虚拟列表在垂直方向上的偏移量状态,通过transform: translateY()让其随着滚动条的滚动而偏移

const state = reactive({// 要渲染的数据data: [] as IData[],// 可视区域内的第一个元素在 data 中的下标startIdx: 0,// 可视区域内的最后一个元素在 data 中的下标endIdx: 0,// 元素的高度itemHeight: 24,// 可视区域的高度visibleAreaHeight: 0,// 可视区域在垂直方向上的偏移量offset: 0,
}) 

offset的值实际上就是滚动条的scrollTop,只需要在滚动条变化时加上对它的更新即可

const handleScroll = (e: UIEvent) => {const scrollTop = (e.target as HTMLDivElement).scrollTopstate.startIdx = ~~(scrollTop / state.itemHeight)state.endIdx = state.startIdx + visualAreaItemCount.valuestate.offset = scrollTop
} 

最后我们再修改一下虚拟列表的样式,加上translateY

<script> // 虚拟列表整体的偏移量
const offsetStyle = computed(() => `${state.offset}px`) </script>
<style scoped lang="scss"> .list-container {// ...transform: translateY(v-bind(offsetStyle));} </style> 

现在的效果如下:

适配元素任意高度

目前我们的虚拟列表只支持固定高度的元素,但实际上我们无法保证每个元素的高度都是itemHeight,因此需要做一些额外的工作去支持渲染任意高度的子元素才行

引入预估高度和位置状态

为了做到这个功能,我们可以先给每个元素设置一个预估高度,并且建立一个缓存表缓存每一个元素的高度信息,当我们需要用到元素的高度时就直接去找缓存表即可

为此我们引入一个新的状态 – positions,用于记录每个元素的高度和位置信息(比如topbottom),在每次加载新的数据的时候就更新这个缓存表,使用的时候则直接使用该缓存表的数据即可

interface IDataItemPosition {id: numberheight: numbertop: numberbottom: number
}

const state = reactive({...// 预估高度estimatedItemHeight: 24,// 记录每个元素的位置信息 -- 包括高度和位置(如 top 和 bottom)positions: [] as IDataItemPosition[],
}) 

接下来我们就需要利用这个positions缓存表,修改之前的实现,首先先看看如何将其初始化

onMounted(async () => {...// 初始化每个元素的位置信息state.positions = state.data.map((item, idx) => ({id: item.id,height: state.estimatedItemHeight,top: idx * state.estimatedItemHeight,bottom: (idx + 1) * state.estimatedItemHeight,}))
}) 

确定列表总高度

前面在元素高度固定的情况下,是通过data.length * itemHeight去确定列表总高度的,那么现在在元素高度不确定的情况下如何确定列表的总高度呢?

由于我们在positions缓存表中记录了元素的bottom,那么最后一个元素的bottom其实就是整个列表的总高度了

// 列表的总高度
const dataTotalHeight = computed(() =>state.positions.length &&state.positions[state.positions.length - 1].bottom,
) 

元素渲染后更新缓存表

由于我们的缓存表中每个元素的高度初始值都是预估高度estimatedItemHeight,真实的高度只有在渲染完成后才能知道,所以在渲染结束后我们需要及时更新缓存表

onUpdated(() => {const items = listItemsRef.valueitems?.forEach((item, idx) => {const height = item.getBoundingClientRect().heightconst positionIdx = Number(item.dataset.positionIdx)const oldHeight = state.positions[positionIdx].heightconst delta = oldHeight - heightif (delta !== 0) {// 渲染后元素的实际高度和原先的预估高度不一致时需要更新缓存表state.positions[positionIdx].bottom -= deltastate.positions[positionIdx].height = height// 当前元素的后续元素的 top 和 bottom 都需要更新for (let i = positionIdx + 1; i < state.positions.length; i++) {state.positions[i].top = state.positions[i - 1].bottomstate.positions[i].bottom -= delta}}})
}) 

滚动时startIdx和offset的更新

const handleScroll = (e: UIEvent) => {const scrollTop = (e.target as HTMLDivElement).scrollTopstate.startIdx =state.positions.find(item => item.bottom > scrollTop)?.id ?? 0state.endIdx = state.startIdx + visualAreaItemCount.valuestate.offset =state.startIdx === 0 ? 0 : state.positions[state.startIdx - 1].bottom
} 

现在就可以针对任意高度的元素进行虚拟列表渲染啦

最后

整理了75个JS高频面试题,并给出了答案和解析,基本上可以保证你能应付面试官关于JS的提问。



有需要的小伙伴,可以点击下方卡片领取,无偿分享

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值