├── .vscode └── settings.json ├── README.md ├── VirtualScroller.js ├── index.html ├── index.js └── style.css /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "liveServer.settings.port": 5501 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # virtual-scroller 2 | 3 | **Online demo: https://codesteppe.github.io/virtual-scroller/** -------------------------------------------------------------------------------- /VirtualScroller.js: -------------------------------------------------------------------------------- 1 | function throttle(fn, wait) { 2 | let lastTime = 0; 3 | let timer; 4 | return function (...args) { 5 | function run() { 6 | const now = new Date().valueOf(); 7 | if (now - lastTime > wait) { 8 | fn.apply(this, args); 9 | lastTime = now; 10 | } 11 | } 12 | if (timer) { 13 | clearTimeout(timer); 14 | } 15 | timer = setTimeout(run, wait); 16 | run(); 17 | } 18 | } 19 | 20 | class VirtualScroller { 21 | constructor({ 22 | element, 23 | height, 24 | rowHeight, 25 | pageSize, 26 | buffer, 27 | renderItem, 28 | loadMore 29 | }) { 30 | if (typeof element === 'string') { 31 | this.scroller = document.querySelector(element); 32 | } else if (element instanceof HTMLElement) { 33 | this.scroller = element; 34 | } 35 | 36 | if (!this.scroller) { 37 | throw new Error('Invalid element'); 38 | } 39 | 40 | if (!height || (typeof height !== 'number' && typeof height !== 'string')) { 41 | throw new Error('invalid height value'); 42 | } 43 | 44 | if (!rowHeight || typeof rowHeight !== 'number') { 45 | throw new Error('rowHeight should be a number'); 46 | } 47 | 48 | if (typeof renderItem !== 'function') { 49 | throw new Error('renderItem is not a function'); 50 | } 51 | 52 | if (typeof loadMore !== 'function') { 53 | throw new Error('renderItem is not a function'); 54 | } 55 | 56 | // set props 57 | this.height = height; 58 | this.rowHeight = rowHeight; 59 | this.pageSize = typeof pageSize === 'number' && pageSize > 0 ? pageSize : 50; 60 | this.buffer = typeof buffer === 'number' && buffer >= 0 ? buffer : 10; 61 | this.renderItem = renderItem; 62 | this.loadMore = loadMore; 63 | this.data = []; 64 | 65 | // create content box 66 | const contentBox = document.createElement('div'); 67 | this.contentBox = contentBox; 68 | this.scroller.append(contentBox); 69 | 70 | this.scroller.style.height = typeof height === 'number' ? height + 'px' : height; 71 | 72 | this.#loadInitData(); 73 | this.scroller.addEventListener('scroll', throttle(this.#handleScroll, 150)); 74 | } 75 | 76 | #topHiddenCount = 0; 77 | #bottomHiddenCount = 0; 78 | #scrollTop = 0; 79 | #paddingTop = 0; 80 | #paddingBottom = 0; 81 | #lastVisibleItemIndex = 0; 82 | 83 | #loadInitData() { 84 | const scrollerRect = this.scroller.getBoundingClientRect(); 85 | const minCount = Math.ceil(scrollerRect.height / this.rowHeight); 86 | const page = Math.ceil(minCount / this.pageSize); 87 | const newData = this.loadMore(page * this.pageSize); 88 | this.data.push(...newData); 89 | this.#renderNewData(newData); 90 | } 91 | 92 | #renderRow(item) { 93 | const rowContent = this.renderItem(item); 94 | const row = document.createElement('div'); 95 | row.dataset.index = item 96 | row.style.height = this.rowHeight + 'px'; 97 | row.appendChild(rowContent) 98 | return row; 99 | } 100 | 101 | #renderNewData(newData) { 102 | newData.forEach(item => { 103 | this.contentBox.append(this.#renderRow(item)); 104 | }); 105 | } 106 | 107 | #handleScroll = (e) => { 108 | const { clientHeight, scrollHeight, scrollTop } = e.target; 109 | if (scrollHeight - (clientHeight + scrollTop) < 40) { 110 | console.log('load more'); 111 | const newData = this.loadMore(this.pageSize); 112 | this.data.push(...newData); 113 | } 114 | const direction = scrollTop > this.#scrollTop ? 1 : -1; 115 | this.#toggleTopItems(direction); 116 | this.#toggleBottomItems(direction); 117 | this.#scrollTop = scrollTop; 118 | console.log({ 119 | direction, 120 | topHiddenCount: this.#topHiddenCount, 121 | lastVisibleItemIndex: this.#lastVisibleItemIndex 122 | }); 123 | } 124 | 125 | #toggleTopItems = (direction) => { 126 | const { scrollTop } = this.scroller; 127 | const firstVisibleItemIndex = Math.floor(scrollTop / this.rowHeight); 128 | const firstExistingItemIndex = Math.max(0, firstVisibleItemIndex - this.buffer); 129 | const rows = this.contentBox.children; 130 | // replace invisible top items with padding top 131 | if (direction === 1) { 132 | for (let i = this.#topHiddenCount; i < firstExistingItemIndex; i++) { 133 | if (rows[0]) rows[0].remove(); 134 | } 135 | } 136 | // restore hidden top items 137 | if (direction === -1) { 138 | for (let i = this.#topHiddenCount - 1; i >= firstExistingItemIndex; i--) { 139 | const item = this.data[i]; 140 | const row = this.#renderRow(item); 141 | this.contentBox.prepend(row); 142 | } 143 | } 144 | this.#topHiddenCount = firstExistingItemIndex; 145 | this.#paddingTop = this.#topHiddenCount * this.rowHeight; 146 | this.contentBox.style.paddingTop = this.#paddingTop + 'px'; 147 | } 148 | 149 | #toggleBottomItems = (direction) => { 150 | const { scrollTop, clientHeight } = this.scroller; 151 | const lastVisibleItemIndex = Math.floor((scrollTop + clientHeight) / this.rowHeight); 152 | const lastExistingItemIndex = lastVisibleItemIndex + this.buffer; 153 | this.#lastVisibleItemIndex = lastVisibleItemIndex; 154 | const rows = [...this.contentBox.children]; 155 | // replace invisible bottom items with padding bottom 156 | if (direction === -1) { 157 | for (let i = lastExistingItemIndex + 1; i <= this.data.length; i++) { 158 | const row = rows[i - this.#topHiddenCount]; 159 | if (row) row.remove(); 160 | } 161 | } 162 | // restore hidden bottom items 163 | if (direction === 1) { 164 | for (let i = this.#topHiddenCount + rows.length; i <= lastExistingItemIndex; i++) { 165 | const item = this.data[i]; 166 | if (!item) break; 167 | const row = this.#renderRow(item); 168 | this.contentBox.append(row); 169 | } 170 | } 171 | this.#bottomHiddenCount = Math.max(0, this.data.length - (this.#topHiddenCount + this.contentBox.children.length) - this.buffer); 172 | this.#paddingBottom = this.#bottomHiddenCount * this.rowHeight; 173 | this.contentBox.style.paddingBottom = this.#paddingBottom + 'px'; 174 | } 175 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | 8 |