当前端需要展示的数据量比较大时比如5万条如果把全部数据都渲染到界面上可能出现卡顿虚拟滚动就是通过计算可见行只渲染一小部分数据达到提高性能的目的下面是用 ai 写的一个vue3版的支持虚拟滚动的大数据表格web worker主要用来生成数据跟虚拟滚动关系不大支持行高变化场景有需要的可以参考下!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / title大数据表格 - CPU 优化版/title script srchttps://unpkg.com/vue3/dist/vue.global.js/script script srchttps://unpkg.com/dexie3.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 idapp div classheader h1 CPU 优化版 - 虚拟滚动/h1 div classheader-controls button classbtn btn-primary click() generateData() :disabledloading 生成测试数据 /button button classbtn btn-success click() loadAllData() :disabledloading || !hasData 加载全部数据 /button button classbtn btn-danger click() clearData() :disabled!hasData ️ 清空 /button /div /div div classstatus-bar div span 总记录: strong{{ totalCount.toLocaleString() }}/strong/span span stylemargin-left: 16px; 可见行: strong{{ visibleItems.length }}/strong/span span stylemargin-left: 16px; 状态: span classbadge :classstatusClass{{ statusText }}/span /span span stylemargin-left: 16px; font-size: 12px; color: #999; v-ifallLoaded ✅ 已全部加载 /span /div div styledisplay: flex; align-items: center; gap: 12px; span stylefont-size: 12px; color: #888;{{ progressText }}/span div classprogress-bar v-ifloading div classfill :style{ width: progress % }/div /div span stylefont-size: 12px; color: #888;⏱️ {{ queryTime }}ms/span span stylefont-size: 12px; color: #888; background: #f0f0f0; padding: 2px 8px; border-radius: 4px; CPU: {{ cpuUsage }}% /span /div /div div classtable-container !-- ✅ 滚动容器 - ref 直接绑定不需要 querySelector -- div classtable-scroll refscrollContainer scrollonScroll div classvirtual-container table colgroup col classcol-id / col classcol-name / col classcol-category / col classcol-price / col classcol-stock / col classcol-date / col classcol-action / /colgroup thead tr th#/th th名称/th th分类/th th styletext-align: right;价格/th th styletext-align: right;库存/th th创建时间/th th styletext-align: center;操作/th /tr /thead tbody !-- ✅ 顶部占位 -- tr classspacer-row v-iftopSpacer 0 td colspan7 :style{ height: topSpacer px, padding: 0 }/td /tr !-- ✅ 可见行 -- tr v-foritem in visibleItems :keyitem.id td{{ item.id }}/td td{{ item.name }}/td td span classbadge-info{{ item.category }}/span /td td styletext-align: right; font-weight: 600; ¥{{ item.price.toFixed(2) }} /td td styletext-align: right; span :style{ color: (item.stock || 0) 10 ? #e53e3e : #2d3748 } {{ item.stock }} /span /td td stylefont-size: 12px; color: #888; {{ formatDate(item.createdAt) }} /td td styletext-align: center; button classexpand-btn click() toggleExpand(item.id) {{ expandedSet.has(item.id) ? : ▶️ }} /button /td /tr !-- ✅ 底部占位 -- tr classspacer-row v-ifbottomSpacer 0 td colspan7 :style{ height: bottomSpacer px, padding: 0 }/td /tr !-- 空状态 -- tr v-if!hasData !loading td colspan7 div classempty-state div classicon/div p暂无数据点击 生成测试数据 开始/p /div /td /tr /tbody /table /div /div /div /div script // // 1. Web Worker // const workerCode importScripts(https://unpkg.com/dexie3.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
跟AI学一手之虚拟滚动
发布时间:2026/6/23 22:53:00
当前端需要展示的数据量比较大时比如5万条如果把全部数据都渲染到界面上可能出现卡顿虚拟滚动就是通过计算可见行只渲染一小部分数据达到提高性能的目的下面是用 ai 写的一个vue3版的支持虚拟滚动的大数据表格web worker主要用来生成数据跟虚拟滚动关系不大支持行高变化场景有需要的可以参考下!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / title大数据表格 - CPU 优化版/title script srchttps://unpkg.com/vue3/dist/vue.global.js/script script srchttps://unpkg.com/dexie3.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 idapp div classheader h1 CPU 优化版 - 虚拟滚动/h1 div classheader-controls button classbtn btn-primary click() generateData() :disabledloading 生成测试数据 /button button classbtn btn-success click() loadAllData() :disabledloading || !hasData 加载全部数据 /button button classbtn btn-danger click() clearData() :disabled!hasData ️ 清空 /button /div /div div classstatus-bar div span 总记录: strong{{ totalCount.toLocaleString() }}/strong/span span stylemargin-left: 16px; 可见行: strong{{ visibleItems.length }}/strong/span span stylemargin-left: 16px; 状态: span classbadge :classstatusClass{{ statusText }}/span /span span stylemargin-left: 16px; font-size: 12px; color: #999; v-ifallLoaded ✅ 已全部加载 /span /div div styledisplay: flex; align-items: center; gap: 12px; span stylefont-size: 12px; color: #888;{{ progressText }}/span div classprogress-bar v-ifloading div classfill :style{ width: progress % }/div /div span stylefont-size: 12px; color: #888;⏱️ {{ queryTime }}ms/span span stylefont-size: 12px; color: #888; background: #f0f0f0; padding: 2px 8px; border-radius: 4px; CPU: {{ cpuUsage }}% /span /div /div div classtable-container !-- ✅ 滚动容器 - ref 直接绑定不需要 querySelector -- div classtable-scroll refscrollContainer scrollonScroll div classvirtual-container table colgroup col classcol-id / col classcol-name / col classcol-category / col classcol-price / col classcol-stock / col classcol-date / col classcol-action / /colgroup thead tr th#/th th名称/th th分类/th th styletext-align: right;价格/th th styletext-align: right;库存/th th创建时间/th th styletext-align: center;操作/th /tr /thead tbody !-- ✅ 顶部占位 -- tr classspacer-row v-iftopSpacer 0 td colspan7 :style{ height: topSpacer px, padding: 0 }/td /tr !-- ✅ 可见行 -- tr v-foritem in visibleItems :keyitem.id td{{ item.id }}/td td{{ item.name }}/td td span classbadge-info{{ item.category }}/span /td td styletext-align: right; font-weight: 600; ¥{{ item.price.toFixed(2) }} /td td styletext-align: right; span :style{ color: (item.stock || 0) 10 ? #e53e3e : #2d3748 } {{ item.stock }} /span /td td stylefont-size: 12px; color: #888; {{ formatDate(item.createdAt) }} /td td styletext-align: center; button classexpand-btn click() toggleExpand(item.id) {{ expandedSet.has(item.id) ? : ▶️ }} /button /td /tr !-- ✅ 底部占位 -- tr classspacer-row v-ifbottomSpacer 0 td colspan7 :style{ height: bottomSpacer px, padding: 0 }/td /tr !-- 空状态 -- tr v-if!hasData !loading td colspan7 div classempty-state div classicon/div p暂无数据点击 生成测试数据 开始/p /div /td /tr /tbody /table /div /div /div /div script // // 1. Web Worker // const workerCode importScripts(https://unpkg.com/dexie3.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