当前端需要展示的数据量比较大时,比如5万条,如果把全部数据都渲染到界面上,可能出现卡顿,虚拟滚动就是通过计算可见行,只渲染一小部分数据,达到提高性能的目的,下面是用 ai 写的一个vue3版的支持虚拟滚动的大数据表格,使用了 web worker技术,支持行高变化场景,有需要的可以参考下
javascript
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>大数据表格 - CPU 优化版</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/dexie@3.2.4/dist/dexie.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
background: #f5f7fa;
padding: 20px;
}
#app {
max-width: 1400px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-bottom: 16px;
flex-wrap: wrap;
gap: 12px;
}
.header h1 {
font-size: 20px;
color: #1a2332;
}
.header-controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}
.btn-primary {
background: #4f6ef7;
color: white;
}
.btn-primary:hover {
background: #3a56d4;
}
.btn-primary:disabled {
background: #a0b3f0;
cursor: not-allowed;
}
.btn-success {
background: #34c759;
color: white;
}
.btn-success:hover {
background: #28a745;
}
.btn-danger {
background: #ff6b6b;
color: white;
}
.btn-danger:hover {
background: #e55a5a;
}
.btn-warning {
background: #ffa94d;
color: white;
}
.btn-warning:hover {
background: #f59f3e;
}
.btn-outline {
background: transparent;
color: #4f6ef7;
border: 1px solid #4f6ef7;
}
.btn-outline:hover {
background: #4f6ef7;
color: white;
}
.status-bar {
padding: 10px 20px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
font-size: 14px;
color: #4a5568;
}
.status-bar .badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.badge-success {
background: #d4edda;
color: #155724;
}
.badge-warning {
background: #fff3cd;
color: #856404;
}
.badge-danger {
background: #f8d7da;
color: #721c24;
}
.badge-info {
background: #d1ecf1;
color: #0c5460;
}
.progress-bar {
width: 200px;
height: 6px;
background: #e9ecef;
border-radius: 3px;
overflow: hidden;
}
.progress-bar .fill {
height: 100%;
background: linear-gradient(90deg, #4f6ef7, #34c759);
transition: width 0.3s;
border-radius: 3px;
}
/* ===== 表格 ===== */
.table-container {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
overflow: hidden;
position: relative;
}
/* ✅ 使用 will-change 提示浏览器优化 */
.table-scroll {
overflow-y: auto;
overflow-x: auto;
height: 600px;
position: relative;
will-change: scroll-position;
}
.table-scroll::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.table-scroll::-webkit-scrollbar-track {
background: #f1f1f1;
}
.table-scroll::-webkit-scrollbar-thumb {
background: #c1c7cd;
border-radius: 4px;
}
.table-scroll::-webkit-scrollbar-thumb:hover {
background: #a0a7ae;
}
.virtual-container {
position: relative;
width: 100%;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
table-layout: fixed;
}
table colgroup .col-id { width: 60px; }
table colgroup .col-name { width: 18%; }
table colgroup .col-category { width: 12%; }
table colgroup .col-price { width: 12%; }
table colgroup .col-stock { width: 12%; }
table colgroup .col-date { width: 16%; }
table colgroup .col-action { width: 10%; }
thead {
position: sticky;
top: 0;
z-index: 10;
}
thead th {
background: #f8f9fa;
padding: 12px 16px;
text-align: left;
font-weight: 600;
color: #1a2332;
border-bottom: 2px solid #e9ecef;
user-select: none;
position: relative;
}
thead th.sortable {
cursor: pointer;
}
thead th.sortable:hover {
background: #e9ecef;
}
thead th .sort-icon {
margin-left: 4px;
font-size: 12px;
}
/* ✅ GPU 加速 */
tbody tr {
transition: background 0.1s;
will-change: transform;
transform: translateZ(0);
}
tbody tr:hover {
background: #f7f9fc;
}
tbody td {
padding: 8px 16px;
border-bottom: 1px solid #f0f2f5;
color: #2d3748;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
height: 44px;
}
tbody td .badge-info {
background: #d1ecf1;
color: #0c5460;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
display: inline-block;
}
.expand-btn {
background: transparent;
border: none;
cursor: pointer;
font-size: 18px;
color: #4f6ef7;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.2s;
}
.expand-btn:hover {
background: #eef2ff;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #a0aec0;
}
.empty-state .icon {
font-size: 48px;
margin-bottom: 16px;
}
.spacer-row td {
border: none;
padding: 0;
}
.detail-row {
background: #f8faff;
}
.detail-row td {
padding: 12px 16px 16px 16px;
border-bottom: 2px solid #e9ecef;
white-space: normal;
height: auto;
}
.detail-content {
background: white;
padding: 16px;
border-radius: 6px;
border: 1px solid #e9ecef;
font-size: 13px;
line-height: 1.8;
color: #4a5568;
}
.detail-content .label {
font-weight: 600;
color: #1a2332;
display: inline-block;
width: 80px;
}
.detail-content .tag {
display: inline-block;
padding: 1px 8px;
border-radius: 12px;
font-size: 12px;
margin-right: 4px;
}
.tag-blue { background: #dbeafe; color: #1e40af; }
.tag-green { background: #d1fae5; color: #065f46; }
.tag-red { background: #fee2e2; color: #991b1b; }
.tag-yellow { background: #fef3c7; color: #92400e; }
@media (max-width: 768px) {
.header {
flex-direction: column;
align-items: stretch;
}
.header-controls {
justify-content: stretch;
}
.header-controls .btn {
flex: 1;
text-align: center;
font-size: 12px;
padding: 6px 10px;
}
.status-bar {
flex-direction: column;
align-items: stretch;
font-size: 12px;
}
.table-scroll {
height: 400px;
}
table {
font-size: 12px;
}
thead th, tbody td {
padding: 6px 10px;
}
}
</style>
</head>
<body>
<div id="app">
<div class="header">
<h1>📊 CPU 优化版 - 虚拟滚动</h1>
<div class="header-controls">
<button class="btn btn-primary" @click="() => generateData()" :disabled="loading">
🚀 生成测试数据
</button>
<button class="btn btn-success" @click="() => loadAllData()" :disabled="loading || !hasData">
📥 加载全部数据
</button>
<button class="btn btn-danger" @click="() => clearData()" :disabled="!hasData">
🗑️ 清空
</button>
</div>
</div>
<div class="status-bar">
<div>
<span>📦 总记录: <strong>{{ totalCount.toLocaleString() }}</strong></span>
<span style="margin-left: 16px;">📄 可见行: <strong>{{ visibleItems.length }}</strong></span>
<span style="margin-left: 16px;">
状态:
<span class="badge" :class="statusClass">{{ statusText }}</span>
</span>
<span style="margin-left: 16px; font-size: 12px; color: #999;" v-if="allLoaded">
✅ 已全部加载
</span>
</div>
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-size: 12px; color: #888;">{{ progressText }}</span>
<div class="progress-bar" v-if="loading">
<div class="fill" :style="{ width: progress + '%' }"></div>
</div>
<span style="font-size: 12px; color: #888;">⏱️ {{ queryTime }}ms</span>
<span style="font-size: 12px; color: #888; background: #f0f0f0; padding: 2px 8px; border-radius: 4px;">
🔥 CPU: {{ cpuUsage }}%
</span>
</div>
</div>
<div class="table-container">
<!-- ✅ 滚动容器 - ref 直接绑定,不需要 querySelector -->
<div class="table-scroll" ref="scrollContainer" @scroll="onScroll">
<div class="virtual-container">
<table>
<colgroup>
<col class="col-id" />
<col class="col-name" />
<col class="col-category" />
<col class="col-price" />
<col class="col-stock" />
<col class="col-date" />
<col class="col-action" />
</colgroup>
<thead>
<tr>
<th>#</th>
<th>名称</th>
<th>分类</th>
<th style="text-align: right;">价格</th>
<th style="text-align: right;">库存</th>
<th>创建时间</th>
<th style="text-align: center;">操作</th>
</tr>
</thead>
<tbody>
<!-- ✅ 顶部占位 -->
<tr class="spacer-row" v-if="topSpacer > 0">
<td colspan="7" :style="{ height: topSpacer + 'px', padding: 0 }"></td>
</tr>
<!-- ✅ 可见行 -->
<tr
v-for="item in visibleItems"
:key="item.id"
>
<td>{{ item.id }}</td>
<td>{{ item.name }}</td>
<td>
<span class="badge-info">{{ item.category }}</span>
</td>
<td style="text-align: right; font-weight: 600;">
¥{{ item.price.toFixed(2) }}
</td>
<td style="text-align: right;">
<span :style="{
color: (item.stock || 0) < 10 ? '#e53e3e' : '#2d3748'
}">
{{ item.stock }}
</span>
</td>
<td style="font-size: 12px; color: #888;">
{{ formatDate(item.createdAt) }}
</td>
<td style="text-align: center;">
<button
class="expand-btn"
@click="() => toggleExpand(item.id)"
>
{{ expandedSet.has(item.id) ? '🔽' : '▶️' }}
</button>
</td>
</tr>
<!-- ✅ 底部占位 -->
<tr class="spacer-row" v-if="bottomSpacer > 0">
<td colspan="7" :style="{ height: bottomSpacer + 'px', padding: 0 }"></td>
</tr>
<!-- 空状态 -->
<tr v-if="!hasData && !loading">
<td colspan="7">
<div class="empty-state">
<div class="icon">📭</div>
<p>暂无数据,点击 "生成测试数据" 开始</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
// ============================================================
// 1. Web Worker
// ============================================================
const workerCode = `
importScripts('https://unpkg.com/dexie@3.2.4/dist/dexie.js');
const db = new Dexie('BigDataDB');
db.version(1).stores({
items: '++id, name, category, price, stock, createdAt'
});
function generateMockData(count) {
const categories = ['电子产品', '服装服饰', '食品饮料', '家居用品', '图书文具', '运动户外'];
const names = ['商品', '产品', '物品', '货品', '精品', '优品'];
const data = [];
for (let i = 1; i <= count; i++) {
const category = categories[Math.floor(Math.random() * categories.length)];
const name = names[Math.floor(Math.random() * names.length)] + ' ' + i;
data.push({
id: i,
name: name,
category: category,
price: Math.round((Math.random() * 900 + 100) * 100) / 100,
stock: Math.floor(Math.random() * 500),
createdAt: Date.now() - Math.floor(Math.random() * 90 * 24 * 60 * 60 * 1000),
});
}
return data;
}
self.addEventListener('message', async function(e) {
const { action, payload, requestId } = e.data;
try {
let result;
switch (action) {
case 'GENERATE': {
const data = generateMockData(payload.count);
const BATCH_SIZE = 5000;
for (let i = 0; i < data.length; i += BATCH_SIZE) {
const batch = data.slice(i, i + BATCH_SIZE);
await db.items.bulkPut(batch);
const progress = Math.min(100, Math.round((i + batch.length) / data.length * 100));
self.postMessage({ type: 'PROGRESS', requestId, progress });
}
result = { count: data.length };
break;
}
case 'LOAD_ALL': {
const items = await db.items.orderBy('id').toArray();
result = { items, total: items.length };
break;
}
case 'COUNT': {
result = await db.items.count();
break;
}
case 'CLEAR': {
await db.items.clear();
result = { cleared: true };
break;
}
default:
throw new Error('未知操作: ' + action);
}
self.postMessage({ type: 'RESULT', requestId, result });
} catch (error) {
self.postMessage({ type: 'ERROR', requestId, error: error.message });
}
});
self.postMessage({ type: 'READY' });
`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
const workerUrl = URL.createObjectURL(blob);
// ============================================================
// 2. Vue 3 应用
// ============================================================
const { createApp, ref, computed, onMounted, onBeforeUnmount, nextTick } = Vue;
const app = createApp({
setup() {
// ---------- 状态 ----------
const loading = ref(false);
const progress = ref(0);
const progressText = ref('');
const totalCount = ref(0);
const queryTime = ref(0);
const statusText = ref('就绪');
const cpuUsage = ref(0);
const allItems = ref([]);
const allLoaded = ref(false);
const expandedSet = ref(new Set());
const visibleItems = ref([]);
const topSpacer = ref(0);
const bottomSpacer = ref(0);
// ---------- 滚动优化相关 ----------
const scrollContainer = ref(null);
let rafId = null;
let scrollTimer = null;
let isScrolling = false;
let lastStart = -1;
let lastEnd = -1;
// CPU 监控
let cpuMonitorTimer = null;
let updateCount = 0;
let cpuStartTime = 0;
// 固定参数
const ROW_HEIGHT = 44;
const BUFFER_SIZE = 5; // ✅ 减少缓冲区
let containerHeight = 600;
// Worker
let worker = null;
let requestIdCounter = 0;
const pendingRequests = new Map();
// ---------- 计算 ----------
const hasData = computed(() => totalCount.value > 0);
const statusClass = computed(() => {
if (loading.value) return 'badge-warning';
if (totalCount.value > 0) return 'badge-success';
return 'badge-info';
});
// ---------- 辅助 ----------
function formatDate(timestamp) {
if (!timestamp) return '-';
const date = new Date(timestamp);
return date.toLocaleDateString('zh-CN');
}
// ---------- Worker 通信 ----------
function sendToWorker(action, payload = {}) {
return new Promise((resolve, reject) => {
if (!worker) {
reject(new Error('Worker 未初始化'));
return;
}
const requestId = ++requestIdCounter;
pendingRequests.set(requestId, { resolve, reject });
worker.postMessage({ action, payload, requestId });
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
reject(new Error('请求超时'));
}
}, 60000);
});
}
// ---------- ✅ 核心:优化后的虚拟滚动 ----------
function updateVisibleItems() {
const container = scrollContainer.value;
if (!container || allItems.value.length === 0) {
visibleItems.value = [];
topSpacer.value = 0;
bottomSpacer.value = 0;
return;
}
const scrollTop = container.scrollTop;
const height = container.clientHeight || containerHeight;
const data = allItems.value;
const totalRows = data.length;
// 计算可见范围
const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - BUFFER_SIZE);
const end = Math.min(
totalRows,
Math.ceil((scrollTop + height) / ROW_HEIGHT) + BUFFER_SIZE
);
// ✅ 只有范围变化时才更新
if (start === lastStart && end === lastEnd) {
return;
}
lastStart = start;
lastEnd = end;
// 截取数据
visibleItems.value = data.slice(start, end);
// 计算占位高度
topSpacer.value = start * ROW_HEIGHT;
bottomSpacer.value = Math.max(0, (totalRows - end) * ROW_HEIGHT);
// CPU 使用率统计
updateCount++;
}
// ✅ 滚动事件 - 完整的优化方案
function onScroll() {
// 1. 使用 RAF 节流
if (rafId) return;
rafId = requestAnimationFrame(() => {
updateVisibleItems();
rafId = null;
});
// 2. 滚动停止后更新
clearTimeout(scrollTimer);
if (!isScrolling) {
isScrolling = true;
}
scrollTimer = setTimeout(() => {
isScrolling = false;
requestAnimationFrame(() => {
updateVisibleItems();
});
}, 150);
}
// ✅ 刷新虚拟滚动
function refreshVirtualScroll() {
lastStart = -1;
lastEnd = -1;
nextTick(() => {
updateVisibleItems();
});
}
// ---------- 展开/折叠 ----------
function toggleExpand(id) {
if (expandedSet.value.has(id)) {
expandedSet.value.delete(id);
} else {
expandedSet.value.add(id);
}
// 展开/折叠会影响行高,需要刷新
refreshVirtualScroll();
}
// ---------- 数据操作 ----------
async function generateData() {
if (loading.value) return;
const count = 30000;
if (!confirm(`确定生成 ${count.toLocaleString()} 条测试数据吗?`)) return;
loading.value = true;
progress.value = 0;
progressText.value = '正在生成数据...';
statusText.value = '生成中';
allLoaded.value = false;
const startTime = performance.now();
try {
const result = await sendToWorker('GENERATE', { count });
queryTime.value = Math.round(performance.now() - startTime);
totalCount.value = result.count;
statusText.value = `✅ 已生成 ${result.count.toLocaleString()} 条`;
progress.value = 100;
progressText.value = '完成!';
await loadAllData();
} catch (error) {
console.error('生成失败:', error);
statusText.value = '❌ 生成失败';
alert('生成数据失败: ' + error.message);
} finally {
loading.value = false;
}
}
async function loadAllData() {
if (loading.value) return;
if (totalCount.value === 0) {
try {
const count = await sendToWorker('COUNT');
if (count === 0) {
statusText.value = '暂无数据,请先生成';
return;
}
totalCount.value = count;
} catch (e) {
console.warn('读取总数失败:', e);
return;
}
}
loading.value = true;
progressText.value = '正在加载全部数据到内存...';
statusText.value = '加载中';
const startTime = performance.now();
try {
const result = await sendToWorker('LOAD_ALL');
allItems.value = result.items;
totalCount.value = result.total;
allLoaded.value = true;
queryTime.value = Math.round(performance.now() - startTime);
statusText.value = `✅ 已加载 ${result.total.toLocaleString()} 条`;
progressText.value = '';
expandedSet.value = new Set();
if (scrollContainer.value) {
scrollContainer.value.scrollTop = 0;
}
refreshVirtualScroll();
} catch (error) {
console.error('加载失败:', error);
statusText.value = '❌ 加载失败';
} finally {
loading.value = false;
}
}
async function clearData() {
if (!confirm('确定清空所有数据吗?')) return;
loading.value = true;
progressText.value = '正在清空...';
statusText.value = '清空中';
try {
await sendToWorker('CLEAR');
totalCount.value = 0;
allItems.value = [];
visibleItems.value = [];
allLoaded.value = false;
statusText.value = '已清空';
progressText.value = '';
expandedSet.value = new Set();
topSpacer.value = 0;
bottomSpacer.value = 0;
lastStart = -1;
lastEnd = -1;
} catch (error) {
console.error('清空失败:', error);
statusText.value = '❌ 清空失败';
} finally {
loading.value = false;
}
}
// ---------- CPU 监控(模拟) ----------
function startCpuMonitor() {
cpuStartTime = performance.now();
updateCount = 0;
cpuMonitorTimer = setInterval(() => {
const elapsed = (performance.now() - cpuStartTime) / 1000;
if (elapsed > 0) {
// 估算 CPU 使用率(基于更新频率)
const updatesPerSecond = updateCount / elapsed;
// 每帧更新 60 次 = 100% CPU(理论值)
const usage = Math.min(100, Math.round((updatesPerSecond / 60) * 100));
cpuUsage.value = usage;
}
}, 1000);
}
// ---------- 生命周期 ----------
onMounted(() => {
// 创建 Worker
worker = new Worker(workerUrl);
worker.onmessage = (event) => {
const { type, requestId, result, error, progress: prog } = event.data;
if (type === 'READY') {
console.log('✅ Worker 已就绪');
statusText.value = 'Worker 就绪';
return;
}
if (type === 'PROGRESS') {
progress.value = prog;
progressText.value = `生成中... ${prog}%`;
return;
}
if (type === 'RESULT') {
const pending = pendingRequests.get(requestId);
if (pending) {
pending.resolve(result);
pendingRequests.delete(requestId);
}
return;
}
if (type === 'ERROR') {
const pending = pendingRequests.get(requestId);
if (pending) {
pending.reject(new Error(error));
pendingRequests.delete(requestId);
}
return;
}
};
worker.onerror = (error) => {
console.error('Worker 错误:', error);
statusText.value = '❌ Worker 错误';
};
// 初始化滚动容器高度
if (scrollContainer.value) {
containerHeight = scrollContainer.value.clientHeight || 600;
}
// 启动 CPU 监控
startCpuMonitor();
// 尝试恢复数据
setTimeout(async () => {
try {
const count = await sendToWorker('COUNT');
if (count > 0) {
totalCount.value = count;
await loadAllData();
}
} catch (e) {
// 忽略
}
}, 500);
});
onBeforeUnmount(() => {
// 清理所有资源
if (rafId) {
cancelAnimationFrame(rafId);
rafId = null;
}
clearTimeout(scrollTimer);
if (cpuMonitorTimer) {
clearInterval(cpuMonitorTimer);
cpuMonitorTimer = null;
}
if (worker) {
worker.terminate();
worker = null;
}
URL.revokeObjectURL(workerUrl);
});
// ---------- 返回值 ----------
return {
loading,
progress,
progressText,
totalCount,
queryTime,
statusText,
statusClass,
hasData,
allLoaded,
visibleItems,
topSpacer,
bottomSpacer,
scrollContainer,
expandedSet,
cpuUsage,
generateData,
loadAllData,
clearData,
toggleExpand,
onScroll,
formatDate
};
}
});
app.mount('#app');
// ============================================================
// 3. 控制台辅助 - 测试滚动事件频率
// ============================================================
console.log('💡 在控制台运行以下代码测试滚动性能:');
console.log(`
// 测试滚动事件频率
let count = 0;
let lastTime = Date.now();
const container = document.querySelector('.table-scroll');
if (container) {
container.addEventListener('scroll', () => {
count++;
const now = Date.now();
if (now - lastTime > 1000) {
console.log(\`每秒触发 \${count} 次滚动事件\`);
count = 0;
lastTime = now;
}
});
console.log('✅ 已开始监听,滚动试试看');
} else {
console.log('❌ 未找到滚动容器');
}
`);
</script>
</body>
</html>