Back:高性能渲染十万条数据
定高:item 高度固定且已知
代码
export interface VirtualListOpts<T> {
dataSource: T[] // 数据源
itemHeight: number // 固定 item 高度
containerSelector: string | HTMLElement // 容器 DOM
listSelector: string | HTMLElement // 列表 DOM
getRenderDom: (item: T) => HTMLElement // 渲染列表项的 DOM
loadMoreThreshold?: number // 加载更多的阈值, 单位:项,默认为 0, 即滚动到最后 1 项才会触发
loadMore?: () => any // 加载更多
}
export default class VirtualList<T> {
private _dataSource: VirtualListOpts<T>['dataSource'] // 数据源
private _itemHeight: VirtualListOpts<T>['itemHeight'] // 固定 item 高度
private _containerDOM: HTMLElement // 容器 DOM
private _listDOM: HTMLElement // 列表 DOM
private _getRenderDom: VirtualListOpts<T>['getRenderDom'] // 渲染列表项的 DOM
private _loadMoreThreshold: number // 加载更多的阈值
private _loadMore: VirtualListOpts<T>['loadMore'] // 加载更多
private _containerHeight: number // 容器高度
private _maxCount: number // 虚拟列表视图最大容纳量
private _lastStartIdx = 0 // 上一次渲染时的起始索引
private _startIdx = 0 // 当前视图列表在数据源中的起始索引
private get _endIdx(): number {
// 当前视图列表在数据源中的末尾索引
return Math.min(this._startIdx + this._maxCount, this._dataSource.length)
}
private get _renderList(): T[] {
// 渲染在视图上的列表项
return this._dataSource.slice(this._startIdx, this._endIdx)
}
private get _listStyle(): Record<string, string> {
// 列表需要动态计算的样式
const { _dataSource, _startIdx, _endIdx, _itemHeight } = this
// 注意这里 height 的计算,transform 的偏移量也会变相增加滚动的长度,
// 因此在滚动时 height 需要减去偏移量,否则在滚动时滚动条会不断变小(相当于内容不断增多),
// 而多余的内容都是偏移量造成的留白
return {
height: `${_dataSource.length * _itemHeight - _startIdx * _itemHeight}px`,
transform: `translateY(${_startIdx * _itemHeight}px)`,
}
}
constructor(opts: VirtualListOpts<T>) {
const { dataSource, itemHeight, containerSelector, listSelector, getRenderDom, loadMoreThreshold, loadMore } = opts
const containerDOM = typeof containerSelector === 'string' ? (document.querySelector(containerSelector) as HTMLElement) : containerSelector
const listDOM = typeof listSelector === 'string' ? (document.querySelector(listSelector) as HTMLElement) : listSelector
if (!containerDOM || !listDOM) throw new Error('containerDOM or listDOM is not found')
// 初始化数据
this._dataSource = dataSource
this._itemHeight = itemHeight
this._containerDOM = containerDOM
this._listDOM = listDOM
this._getRenderDom = getRenderDom
this._loadMoreThreshold = loadMoreThreshold || 0
this._loadMore = loadMore
this._containerHeight = containerDOM.offsetHeight
// +1 是为了无论滚动到哪都能填充满 container
this._maxCount = Math.ceil(this._containerHeight / this._itemHeight) + 1
// 初始化渲染
this._containerDOM.addEventListener('scroll', this._onScroll)
this._render()
}
destroy() {
this._containerDOM.removeEventListener('scroll', this._onScroll)
this._listDOM.innerHTML = ''
this._containerDOM = null as any
this._listDOM = null as any
}
/**
* 虚拟列表视图最大容纳量
*/
get maxCount(): number {
return this._maxCount
}
private _onScroll = () => {
const { scrollTop } = this._containerDOM
this._startIdx = Math.floor(scrollTop / this._itemHeight)
if (this._startIdx !== this._lastStartIdx) {
// 性能优化:只在起始索引变化时重新渲染
this._render()
if (this._endIdx >= this._dataSource.length - this._loadMoreThreshold) {
this._loadMore?.()
}
}
this._lastStartIdx = this._startIdx
}
private _render() {
const items = this._renderList.map(this._getRenderDom)
const fragment = document.createDocumentFragment()
for (const item of items) {
fragment.appendChild(item)
}
this._listDOM.innerHTML = ''
this._listDOM.appendChild(fragment)
for (const key in this._listStyle) {
if (Object.prototype.hasOwnProperty.call(this._listStyle, key)) {
this._listDOM.style[key] = this._listStyle[key]
}
}
}
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
.container {
width: 600px;
height: 600px;
margin: 100px auto;
border: 1px solid red;
}
.VirtualList-container {
width: 100%;
height: 100%; /* 需要设置具体高度 */
overflow-y: auto; /* 需要保证垂直方向有滚动条 */
}
.VirtualList-list {
width: 100%;
/* height 和 transform 通过 JS 动态计算 */
}
.VirtualList-item {
width: 100%;
height: 100px;
box-sizing: border-box;
border: 1px solid #000;
text-align: center;
font-size: 20px;
line-height: 100px;
}
</style>
</head>
<body>
<div class="container">
<div class="VirtualList-container">
<div class="VirtualList-list">
<!-- <div class="VirtualList-item"></div> -->
<!-- VirtualList-item 通过 JS 动态插入 -->
</div>
</div>
</div>
<script type="module">
import VirtualList from './VirtualList.js'
const dataSource = Array.from({ length: 20 }, (_, i) => `item ${i + 1}`)
new VirtualList({
dataSource,
itemHeight: 100,
containerSelector: '.VirtualList-container',
listSelector: '.VirtualList-list',
getRenderDom: item => {
const div = document.createElement('div')
div.className = 'VirtualList-item'
div.innerHTML = item
return div
},
loadMoreThreshold: 3,
loadMore: () => {
for (let i = 0; i < 20; i++) {
dataSource.push(`item ${dataSource.length + 1}`)
}
},
})
</script>
</body>
</html>
优化
设立缓冲区来缓解白屏问题