How to Optimize Performance with Large Datasets Using Virtual Scrolling in Element UI Table
Virtual scrolling reduces the DOM footprint of Element UI's <el-table> from thousands of nodes to roughly 10–30 visible rows by slicing the data array and feeding only the viewport subset to the table component.
Element UI's table component renders every item in the data prop to the DOM, which creates severe performance bottlenecks when handling large datasets. Because the library does not include a built-in virtual list implementation, you must wrap the table with a virtualization layer that leverages the existing scroll infrastructure in packages/table/src/table.vue. This technique maintains full table functionality—fixed columns, sorting, and selection—while keeping the DOM size constant regardless of total row count.
The DOM Bottleneck in Standard Table Rendering
By default, <el-table> creates a full <tr> element for every entry in your dataset. In packages/table/src/table.vue, the component binds native scroll listeners to this.bodyWrapper inside the bindEvents() method, but it does not truncate the rendered output. Each row generates <td> cells, event listeners for selection and hover states, and potential tooltip wrappers. When data exceeds a few hundred items, layout recalculations and Vue’s component diffing degrade frame rates.
The scroll handling logic resides in the onScroll method (lines 421–433), which synchronizes fixed column positions and updates the scrollLeft offset. This same event hook can be leveraged by a virtual scroll wrapper to determine which slice of data to display.
How Virtual Scrolling Integrates with Element UI's Architecture
A virtual scroll implementation works by intercepting the scroll events that Element UI already listens for and replacing the data prop with a computed slice. Since the table’s layout calculations are isolated from row count changes, this approach remains performant.
Scroll Event Handling and Fixed Column Synchronization
The bodyWrapper element emits native scroll events that the table uses to align fixed columns and headers. When you wrap the table in a virtual scroll container, you reuse this existing pipeline:
- The wrapper listens to
scrollon the same DOM node thatbindEvents()attaches to - The
onScrollhandler intable.vuecontinues to sync fixed column shadows without modification - You feed a subset of the full dataset to the
dataprop declared intypes/table.d.ts(lines 40–48)
Because the table component itself is unaware that the data has been sliced, all native features—hover states, row selection, and column resizing—continue to function normally.
Layout Stability During Data Slicing
Element UI separates layout concerns into packages/table/src/table-layout.js. The TableLayout class calculates column widths via updateColumnsWidth() (lines 32–94) and tracks scrollbar visibility through the scrollX and scrollY flags. Crucially, these calculations depend only on column definitions and container dimensions, not on the number of rows currently rendered.
This architectural separation means that updating the data prop to show a different window of rows does not trigger expensive layout recalculations. The DOM cost stays O(visibleRows) rather than O(totalRows).
Implementation Strategies
You can implement virtual scrolling either by importing a specialized library or by writing a lightweight custom wrapper. Both approaches rely on the same principle: maintain a spacer element that sets the total scroll height while passing only the visible slice to <el-table>.
Using vue-virtual-scroll-list
The vue-virtual-scroll-list package handles viewport calculations and buffer management. You render a single <el-table> instance per visible row (or per small batch) inside the virtual list’s item slot.
<template>
<virtual-list
:size="rowHeight"
:keeps="visibleCount"
:data-key="'id'"
:data-sources="data"
class="el-table-virtual"
>
<template #default="{ item }">
<el-table
:data="[item]"
:row-key="rowKey"
style="width: 100%"
border
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="Name" />
<el-table-column prop="value" label="Value" />
</el-table>
</template>
</virtual-list>
</template>
<script>
import VirtualList from 'vue-virtual-scroll-list'
export default {
components: { VirtualList },
props: {
data: Array,
rowKey: { type: String, default: 'id' }
},
data() {
return {
rowHeight: 48,
visibleCount: 12
}
}
}
</script>
This method isolates each row in its own table instance, which simplifies height calculations but increases component overhead. For better performance with medium-sized datasets, render multiple rows per virtual item by passing an array slice to :data.
Building a Custom Virtual Scroll Wrapper
A zero-dependency wrapper gives you full control over scroll behavior. You calculate the visible index range based on scrollTop and maintain a spacer div to preserve the native scrollbar’s total height.
<template>
<div class="el-table-wrapper" ref="wrapper" @scroll="onScroll">
<el-table
:data="visibleRows"
:row-key="rowKey"
style="width: 100%"
border
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="Name" />
<el-table-column prop="value" label="Value" />
</el-table>
<div :style="{ height: totalHeight + 'px' }" class="spacer"></div>
</div>
</template>
<script>
export default {
props: {
data: Array,
rowKey: { type: String, default: 'id' },
rowHeight: { type: Number, default: 48 }
},
data() {
return {
scrollTop: 0
}
},
computed: {
totalHeight() {
return this.data.length * this.rowHeight
},
startIndex() {
return Math.floor(this.scrollTop / this.rowHeight)
},
visibleRows() {
const end = this.startIndex + this.visibleCount
return this.data.slice(this.startIndex, end)
},
visibleCount() {
return Math.ceil(this.$refs.wrapper?.clientHeight / this.rowHeight) + 3
}
},
methods: {
onScroll(e) {
this.scrollTop = e.target.scrollTop
}
}
}
</script>
<style scoped>
.el-table-wrapper {
height: 600px;
overflow-y: auto;
position: relative;
}
.spacer {
width: 1px;
position: absolute;
top: 0;
left: 0;
}
</style>
The spacer element ensures the scrollbar thumb size reflects the total dataset, while visibleRows keeps the actual DOM limited to the viewport plus a small buffer. Because the wrapper attaches @scroll to the same container that Element UI’s bindEvents() method targets, fixed column synchronization continues to work automatically.
Performance Best Practices
When implementing virtual scrolling with Element UI Table, optimize the following:
- Set
row-key: Defined intypes/table.d.ts(lines 100–104), this property enables Vue’skeyattribute on rows, preventing unnecessary component destruction and recreation during scroll updates. - Disable
reserve-selection: If you do not need to persist selection states across data changes, disabling this property reduces memory overhead from tracking selected rows that are not in the DOM. - Minimize cell renderers: Avoid heavy components like tooltips or complex computed properties inside table cells, as even virtualized rows pay the cost of these renderers.
- Fixed column considerations: The
layout-observer.jsfile (lines 58–64) adjusts header widths when vertical scrollbars appear. Ensure your wrapper does not hide the scrollbar from the layout observer, or column alignment may drift.
Summary
- Element UI Table renders the entire
dataarray to the DOM by default, causing linear performance degradation as datasets grow. - Virtual scrolling intercepts the native scroll events already handled in
packages/table/src/table.vueand projects only a slice of data into the viewport. TableLayoutinpackages/table/src/table-layout.jsrecalculates column widths independently of row count, ensuring scroll performance remains constant.- You can implement virtualization using external libraries like
vue-virtual-scroll-listor a custom wrapper with a height-based spacer element. - Always define
row-keyto optimize Vue’s diffing algorithm and keep event listener counts minimal by avoiding heavy cell templates.
Frequently Asked Questions
Does Element UI Table have built-in virtual scrolling?
No. The component renders every row present in the data prop. To handle large datasets efficiently, you must implement a virtual scroll wrapper that passes only the visible subset of rows to the table, as the source code in packages/table/src/table.vue does not include virtualization logic.
Will virtual scrolling break fixed columns or headers?
No. Fixed column synchronization relies on the onScroll handler in table.vue (lines 421–433), which responds to scroll events on bodyWrapper. As long as your virtual scroll wrapper emits or proxies these scroll events on the same container, the table’s internal logic continues to align fixed column shadows and headers correctly.
Do column widths recalculate when I scroll through the dataset?
No. The updateColumnsWidth() method in packages/table/src/table-layout.js only runs when column definitions or container dimensions change. Since virtual scrolling updates the data prop without altering columns, layout calculations remain O(columns) and do not scale with the number of rows scrolled.
What is the optimal buffer size for visible rows?
Calculate the buffer as Math.ceil(containerHeight / rowHeight) + 2 or +3 to account for partial rows at the edges. For a standard row height of 48px and a 600px container, this results in approximately 14–15 rendered rows, which keeps the DOM lightweight while preventing blank gaps during fast scrolling.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →