- Batch scroll renders with requestAnimationFrame to avoid multiple DOM updates per frame - Add will-change, contain and content-visibility CSS for smoother compositing - Add cache-bust to virtual-scroll.js
168 lines
5.7 KiB
JavaScript
168 lines
5.7 KiB
JavaScript
/**
|
|
* virtual-scroll.js — Lightweight vanilla-JS virtual scroll helper.
|
|
* Supports <div> containers and <tbody> tables.
|
|
*
|
|
* Usage:
|
|
* var vs = new VirtualScroll({
|
|
* container: document.getElementById('myTableBody'),
|
|
* rowHeight: 48,
|
|
* buffer: 5,
|
|
* renderRow: function(item, index) { return '<tr>...</tr>'; }
|
|
* });
|
|
* vs.setData(arrayOfItems);
|
|
*/
|
|
(function(window) {
|
|
'use strict';
|
|
|
|
function VirtualScroll(opts) {
|
|
this.container = opts.container;
|
|
this.rowHeight = opts.rowHeight || 48;
|
|
this.buffer = opts.buffer || 5;
|
|
this.renderRow = opts.renderRow || function() { return ''; };
|
|
this.emptyHtml = opts.emptyHtml || '';
|
|
this.data = [];
|
|
this._scrollHandler = this._onScroll.bind(this);
|
|
this._resizeHandler = this._onResize.bind(this);
|
|
this._isTbody = this.container.tagName === 'TBODY';
|
|
this._rafId = null;
|
|
this._pendingRender = false;
|
|
this._init();
|
|
}
|
|
|
|
VirtualScroll.prototype._init = function() {
|
|
var c = this.container;
|
|
if (!this._isTbody) {
|
|
c.style.overflowY = 'auto';
|
|
c.style.position = 'relative';
|
|
if (!c.style.maxHeight && !c.style.height) {
|
|
c.style.maxHeight = '60vh';
|
|
}
|
|
} else {
|
|
// For tbody, scroll is on the parent element (table wrapper)
|
|
var table = c.closest('table');
|
|
if (table) {
|
|
var wrapper = table.parentElement;
|
|
if (wrapper && wrapper.classList.contains('vs-container')) {
|
|
wrapper.addEventListener('scroll', this._scrollHandler, { passive: true });
|
|
}
|
|
}
|
|
}
|
|
window.addEventListener('resize', this._resizeHandler, { passive: true });
|
|
};
|
|
|
|
VirtualScroll.prototype.setData = function(data) {
|
|
this.data = data || [];
|
|
this._render();
|
|
};
|
|
|
|
VirtualScroll.prototype.refresh = function() {
|
|
this._render();
|
|
};
|
|
|
|
VirtualScroll.prototype._onScroll = function() {
|
|
this._scheduleRender();
|
|
};
|
|
|
|
VirtualScroll.prototype._onResize = function() {
|
|
this._scheduleRender();
|
|
};
|
|
|
|
VirtualScroll.prototype._scheduleRender = function() {
|
|
if (this._pendingRender) return;
|
|
this._pendingRender = true;
|
|
var self = this;
|
|
this._rafId = requestAnimationFrame(function() {
|
|
self._rafId = null;
|
|
self._pendingRender = false;
|
|
self._render();
|
|
});
|
|
};
|
|
|
|
VirtualScroll.prototype._getScrollTop = function() {
|
|
if (this._isTbody) {
|
|
var table = this.container.closest('table');
|
|
if (table) {
|
|
var wrapper = table.parentElement;
|
|
if (wrapper && wrapper.classList.contains('vs-container')) {
|
|
return wrapper.scrollTop;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
return this.container.scrollTop;
|
|
};
|
|
|
|
VirtualScroll.prototype._getContainerHeight = function() {
|
|
if (this._isTbody) {
|
|
var table = this.container.closest('table');
|
|
if (table) {
|
|
var wrapper = table.parentElement;
|
|
if (wrapper && wrapper.classList.contains('vs-container')) {
|
|
return wrapper.clientHeight;
|
|
}
|
|
}
|
|
return 600;
|
|
}
|
|
return this.container.clientHeight;
|
|
};
|
|
|
|
VirtualScroll.prototype._render = function() {
|
|
var data = this.data;
|
|
var rowH = this.rowHeight;
|
|
var buffer = this.buffer;
|
|
|
|
if (!data.length) {
|
|
this.container.innerHTML = this.emptyHtml;
|
|
return;
|
|
}
|
|
|
|
var scrollTop = this._getScrollTop();
|
|
var containerHeight = this._getContainerHeight();
|
|
var startIdx = Math.max(0, Math.floor(scrollTop / rowH) - buffer);
|
|
var endIdx = Math.min(data.length, Math.ceil((scrollTop + containerHeight) / rowH) + buffer);
|
|
|
|
// Build new HTML
|
|
var html = '';
|
|
if (this._isTbody) {
|
|
var topSpacerHeight = startIdx * rowH;
|
|
if (topSpacerHeight > 0) {
|
|
html += '<tr style="height:' + topSpacerHeight + 'px;" aria-hidden="true"><td colspan="99" style="padding:0;border:0;"></td></tr>';
|
|
}
|
|
for (var i = startIdx; i < endIdx; i++) {
|
|
html += this.renderRow(data[i], i);
|
|
}
|
|
var bottomSpacerHeight = (data.length - endIdx) * rowH;
|
|
if (bottomSpacerHeight > 0) {
|
|
html += '<tr style="height:' + bottomSpacerHeight + 'px;" aria-hidden="true"><td colspan="99" style="padding:0;border:0;"></td></tr>';
|
|
}
|
|
} else {
|
|
for (var j = startIdx; j < endIdx; j++) {
|
|
html += this.renderRow(data[j], j);
|
|
}
|
|
}
|
|
|
|
// Use a DocumentFragment approach via innerHTML to avoid flicker:
|
|
// Setting innerHTML on tbody is the fastest way, but we can reduce
|
|
// perceived flicker by ensuring the container has contain: paint
|
|
// and by batching via rAF (done in _scheduleRender).
|
|
this.container.innerHTML = html;
|
|
};
|
|
|
|
VirtualScroll.prototype.destroy = function() {
|
|
if (this._isTbody) {
|
|
var table = this.container.closest('table');
|
|
if (table) {
|
|
var wrapper = table.parentElement;
|
|
if (wrapper && wrapper.classList.contains('vs-container')) {
|
|
wrapper.removeEventListener('scroll', this._scrollHandler);
|
|
}
|
|
}
|
|
} else {
|
|
this.container.removeEventListener('scroll', this._scrollHandler);
|
|
}
|
|
window.removeEventListener('resize', this._resizeHandler);
|
|
};
|
|
|
|
window.VirtualScroll = VirtualScroll;
|
|
})(window);
|