前言
在开发项目的过程中,偶尔会遇到很大的数据,然后设计图上又是列表还不分页的情况。为此研究了下虚拟滚动的方案。虚拟滚动大致的思路是当你往下滚动,但最后一个计算的元素出现的时候,替换上面不见了的DOM元素,将它们从渲染的HTML中剔除,同理往上滚动,一个计算的元素出现在最上面的时候,表明需要加载上面的元素信息,并隐藏下面的DOM元素。演示地址
具体思路
-
页面结构,当然只需要一个DIV元素就可以。然后将手动生成DOM元素添加我们需要虚拟滚动的DOM元素中。
-
代码结构,利用JS的Class来创建一个对象,然后对象里做操作方法。这样把dom和JS操作分开,有利于代码的维护。
-
对象上属性的考虑需要哪些呢?
- 需要虚拟滚动监听的dom元素 element
- 需要虚拟滚动监听的dom的高度 height
- 虚拟滚动列表的每一行高度 rowHeight,用来计算需要加载多少个数据
- 新加载数据的个数 pageSize
- 页面滚动加载新的dom元素,那么dom渲染需要时间,为此加一个缓存区域,提前渲染出需要的dom元素
- 每一行渲染的回调函数 renderItem
- 加载更多的回调函数 loadMore
那么对应的html编写就是
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>虚拟滚动</title> <!-- 一个自己的样式 --> <link rel="stylesheet" href="./style.css"> </head> <body> <div id="virtual-scroller"></div> <script src="./VirtualScroller.js"></script> <script src="./index.js"></script> </body> </html>
目前主要一个对象需要这几个属性。对应代码的编写就如下
// VirtualScroller.js class VirtualScroller{ constructor({ element, height, rowHeight, buffer, renderItem, loadMore }){ } }
构造函数中传入需要的对象属性值,然后构造函数检验值的正确性,并赋值
// VirtualScroller.js class VirtualScroller{ constructor({ element, height, rowHeight, buffer, renderItem, loadMore }){ if (typeof element === 'string') { this.scroller = document.querySelector(element); } else if (element instanceof HTMLElement) { this.scroller = element; } if (!this.scroller) { throw new Error('Invalid element'); } if (!height || (typeof height !== 'number' && typeof height !== 'string')) { throw new Error('invalid height value'); } if (!rowHeight || typeof rowHeight !== 'number') { throw new Error('rowHeight should be a number'); } if (typeof renderItem !== 'function') { throw new Error('renderItem is not a function'); } if (typeof loadMore !== 'function') { throw new Error('renderItem is not a function'); } // set props this.height = height; this.rowHeight = rowHeight; this.pageSize = typeof pageSize === 'number' && pageSize > 0 ? pageSize : 50; this.buffer = typeof buffer === 'number' && buffer >= 0 ? buffer : 10; this.renderItem = renderItem; this.loadMore = loadMore; this.data = []; } }
这个时候来看看如何生成一个对象
// index.js let scroller = new VirtualScroller({ element: '#virtual-scroller', height: '100vh', rowHeight: 60, // px pageSize: 100, buffer: 10, renderItem: function (dataItem) { const div = document.createElement('div'); div.classList.add('row-content'); div.textContent = dataItem; return div; }, loadMore: function (pageSize) { const data = []; for (let i = 0; i < pageSize; i++) { const dataItem = `当前元素下标${this.data.length + i}`; data.push(dataItem); } return data; } })
赋值操作弄好之后,还需要再每次创建新对象的时候就创建一个dom元素来包裹渲染rowItem
constructor(){ ...... // 每次创建新的对象的时候就创建一个dom元素来包裹渲染rowItem const contentBox = document.createElement('div'); this.contentBox = contentBox; this.scroller.append(contentBox); }
还需要每次新建对象的的时候先渲染一批数据,然后还要再上面创建的包裹层中添加滚动监听。相当于虚拟dom的根节点。然后挂载到需要的dom节点上。
constructor(){ ...... this.#loadInitData(); this.scroller.addEventListener('scroll', throttle(this.#handleScroll, 150)); }
这里就遇到了2个函数一个初始化加载函数,渲染第一次渲染的数据,第二个是防抖函数用来处理滚动的性能,第三个就是每次滚动到指定位置就渲染的数据的渲染函数
class VirtualScroller{ ...... #loadInitData () { // 拿到被挂载的dom元素,获取它的显示高度,然后看最少需要渲染多少个数据,然后把个数放入到回调函数中获取数据 const scrollerRect = this.scroller.getBoundingClientRect(); const minCount = Math.ceil(scrollerRect.height / this.rowHeight); // const page = Math.ceil(minCount / this.pageSize); // const newData = this.loadMore(page * this.pageSize); // const page = Math.ceil(minCount / this.pageSize); const newData = this.loadMore(minCount); this.data.push(...newData); // 拿到了数据之后就是渲染挂载到指定的contentBox容器上。 this.#renderNewData(newData); } //渲染每一行,把它包裹在一个div中,并且这个dom元素也可以设置一些数据还不干扰到renderItem渲染的dom #renderRow (item) { const rowContent = this.renderItem(item); const row = document.createElement('div'); row.dataset.index = item row.style.height = this.rowHeight + 'px'; row.appendChild(rowContent) return row; } #renderNewData (newData) { newData.forEach(item => { this.contentBox.append(this.#renderRow(item)); }); } #handleScroll = (e) => { const { clientHeight, scrollHeight, scrollTop } = e.target; if (scrollHeight - (clientHeight + scrollHeight) < 40) { // 到底加载更多 const newData = this.loadMore(this.pageSize); this.data.push(...newData) } //记录当前的滚动距离,然后对比上一次保存的距离,知道了向上滚动还是向下滚动 const direction = scrollTop > this.#scrollTop ? 1 : -1 this.#toggleTopItems(direction) this.#toggleBottomItems(direction) this.#scrollTop = scrollTop; } //替换上面的dom #toggleTopItems = (direction) => { const { scrollTop } = this.scroller; const firstVisibleItemIndex = Math.floor(scrollTop / this.rowHeight); const firstExistingItemIndex = Math.max(0, firstVisibleItemIndex - this.buffer); const rows = this.contentBox.children; // 替换上面不可见的元素 if (direction === 1) { for (let i = this.#topHiddenCount; i < firstExistingItemIndex; i++) { if (rows[0]) rows[0].remove(); } } // 恢复上面隐藏的元素 if (direction === -1) { for (let i = this.#topHiddenCount - 1; i >= firstExistingItemIndex; i--) { const item = this.data[i]; const row = this.#renderRow(item); this.contentBox.prepend(row); } } this.#topHiddenCount = firstExistingItemIndex; this.#paddingTop = this.#topHiddenCount * this.rowHeight; this.contentBox.style.paddingTop = this.#paddingTop + 'px'; } //替换下面的dom #toggleBottomItems = (direction) => { const { scrollTop, clientHeight } = this.scroller; const lastVisibleItemIndex = Math.floor((scrollTop + clientHeight) / this.rowHeight); const lastExistingItemIndex = lastVisibleItemIndex + this.buffer; this.#lastVisibleItemIndex = lastVisibleItemIndex; const rows = [...this.contentBox.children]; // 替换下面不可见的元素 if (direction === -1) { for (let i = lastExistingItemIndex + 1; i <= this.data.length; i++) { const row = rows[i - this.#topHiddenCount]; if (row) row.remove(); } } // 恢复下面不可见的元素 if (direction === 1) { for (let i = this.#topHiddenCount + rows.length; i <= lastExistingItemIndex; i++) { const item = this.data[i]; if (!item) break; const row = this.#renderRow(item); this.contentBox.append(row); } } this.#bottomHiddenCount = Math.max(0, this.data.length - (this.#topHiddenCount + this.contentBox.children.length) - this.buffer); this.#paddingBottom = this.#bottomHiddenCount * this.rowHeight; this.contentBox.style.paddingBottom = this.#paddingBottom + 'px'; } }
最主要的函数操作是
this.#toggleTopItems(direction); this.#toggleBottomItems(direction)
这2个函数,他们主要就是替换消失在屏幕展示区域中的dom元素。是代码的核心。
评论区