Implementing a Vue Hook for Virtual List Rendering to Optimize Large Data Tables
This article explains how to create a reusable Vue hook that implements a virtual list, enabling efficient rendering of tens of thousands of rows by rendering only the visible items and handling both fixed‑height and variable‑height scenarios with caching and scroll calculations.
Preface
When a colleague reported that a page rendering tens of thousands of rows was extremely slow while the backend API responded quickly, the task of optimizing the page was assigned.
1: Quick Fix – Infinite Scroll
The initial solution was to render only a small batch (e.g., 20 rows) and load more when the scroll reaches the bottom. Although this reduced the initial rendering lag, interaction lag (e.g., opening a modal from a table row) still appeared after loading a few thousand rows.
Table as a Slow Element
Rendering a table requires significant layout calculations, making it a "slow element" when dealing with large data sets; pagination alone cannot prevent interaction lag because the total number of rendered rows does not actually decrease.
2: Virtual List
To guarantee that the number of rendered items never grows, only the items within the viewport and a small buffer are rendered – the core idea of a virtual list.
Virtual List – Fixed Height
Three aspects must be considered:
Scroll simulation – render a fixed number of items per scroll.
Render correct content – ensure the visible items match the data source.
Render data within the viewport – keep rendered items inside the visible area.
Key components:
Scroll container – a fixed or max height element that provides scrolling.
Actual height container – an element whose height equals the sum of all item heights to create the scroll space.
Translate container – the element that is shifted using transform: translateY(scrollTop) to display the correct slice of data.
Virtual List – Variable Height
When item heights vary, the exact height of each item and the total height cannot be known upfront. The solution involves:
Using nextTick to obtain the rendered height of items after they appear in the DOM.
Caching each item's height (keyed by its index) to compute the total height.
Calculating the start index by accumulating cached heights until the accumulated height exceeds scrollTop .
Code Example – useVirtualList Hook
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
import type { Ref } from "vue";
interface Config {
data: Ref
; // data source
scrollContainer: string; // selector for scroll container
actualHeightContainer: string; // selector for height‑spacer element
translateContainer: string; // selector for offset element
itmeContainer: string; // selector for each item
itemHeight: number; // approximate item height
size: number; // number of items rendered per batch
}
type HtmlElType = HTMLElement | null;
export default function useVirtualList(config: Config) {
let actualHeightContainerEl: HtmlElType = null,
translateContainerEl: HtmlElType = null,
scrollContainerEl: HtmlElType = null;
onMounted(() => {
actualHeightContainerEl = document.querySelector(config.actualHeightContainer);
scrollContainerEl = document.querySelector(config.scrollContainer);
translateContainerEl = document.querySelector(config.translateContainer);
});
let dataSource: any[] = [];
watch(() => config.data.value, (newVla) => {
dataSource = newVla;
updateRenderData(0);
});
const updateActualHeight = () => {
let actualHeight = 0;
dataSource.forEach((_, i) => {
actualHeight += getItemHeightFromCache(i);
});
actualHeightContainerEl!.style.height = actualHeight + "px";
};
const RenderedItemsCache: any = {};
const updateRenderedItemCache = (index: number) => {
const shouldUpdate = Object.keys(RenderedItemsCache).length < dataSource.length;
if (!shouldUpdate) return;
nextTick(() => {
const Items: HTMLElement[] = Array.from(document.querySelectorAll(config.itmeContainer));
Items.forEach((el) => {
if (!RenderedItemsCache[index]) {
RenderedItemsCache[index] = el.offsetHeight;
}
index++;
});
updateActualHeight();
});
};
const getItemHeightFromCache = (index: number | string) => {
const val = RenderedItemsCache[index];
return val === void 0 ? config.itemHeight : val;
};
const actualRenderData: Ref
= ref([]);
const updateRenderData = (scrollTop: number) => {
let startIndex = 0;
let offsetHeight = 0;
for (let i = 0; i < dataSource.length; i++) {
offsetHeight += getItemHeightFromCache(i);
if (offsetHeight >= scrollTop) {
startIndex = i;
break;
}
}
actualRenderData.value = dataSource.slice(startIndex, startIndex + config.size);
updateRenderedItemCache(startIndex);
updateOffset(offsetHeight - getItemHeightFromCache(startIndex));
};
const updateOffset = (offset: number) => {
translateContainerEl!.style.transform = `translateY(${offset}px)`;
};
const handleScroll = (e: any) => {
updateRenderData(e.target.scrollTop);
};
onMounted(() => {
scrollContainerEl?.addEventListener("scroll", handleScroll);
});
onBeforeUnmount(() => {
scrollContainerEl?.removeEventListener("scroll", handleScroll);
});
return { actualRenderData };
}Usage examples show how to configure the hook for a plain ul list, an el-table component, or any custom list structure, specifying selectors for the scroll container, height container, translate container, and item selector.
Conclusion
By encapsulating the virtual‑list logic into a reusable hook, developers can apply it to various component libraries (List, Select, Table) or custom components without being tied to a specific UI component, achieving smooth performance even with tens of thousands of rows.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.