- 无限滚动/分页
无限滚动:监听滚动事件,当用户滚动到底部附近时,加载下一批数据并追加到列表尾部。
优点:实现简单,对用户无缝体验好。
缺点:已渲染的 DOM 会不断累积,长时间滚动后性能依然会下降。需要自行实现“回到顶部”等功能。
实现:监听 scroll 事件 + 计算滚动位置,或使用 Intersection Observer API 监听一个“哨兵”元素。
分页器:
优点:实现极其简单,能精确控制 DOM 数量,内存管理最佳。
缺点:交互体验不连贯,每次翻页都有刷新感。
适用场景:列表、表格、图片流等。不适用于需要快速定位到数据中间某一部分的场景。
- 分片渲染(时间分片)
当单次渲染大量 DOM 不可避免,但又不适合用虚拟列表时(例如复杂图表、瀑布流),可以使用时间分片。
核心原理:利用 setTimeout、setInterval 或 requestAnimationFrame 将渲染任务分割成多个小任务,在浏览器的多个空闲帧中分批执行,避免单次长任务阻塞主线程导致页面卡死。
优点:能缓解单次渲染的卡顿,允许用户进行交互。
缺点:总渲染时间变长,用户会看到数据一条条“蹦”出来,体验不佳。本质上并未减少最终渲染的 DOM 总数。
function renderData(data) {
let index = 0;
const chunkSize = 50; // 每批渲染的数量
function renderChunk() {
for (let i = 0; i < chunkSize && index < data.length; i++, index++) {
// 创建并插入DOM...
//DocumentFragment文档片段
}
if (index < data.length) {
requestAnimationFrame(renderChunk);
}
}
renderChunk();
}
- 虚拟滚动
这是处理大规模数据的“黄金标准”。其核心思想是:只渲染当前可视区域(Viewport)及附近缓冲区的数据,非可视区域的数据用空白占位符(Padding)代替。
核心原理:
计算滚动位置:监听容器滚动事件,计算出当前滚动距离 scrollTop。
计算索引范围:根据 scrollTop、容器高度、每一项的预估高度,计算出当前应该显示的数据的起始索引(startIndex)和结束索引(endIndex)。
截取数据:从完整数据源中截取 [startIndex, endIndex]范围内的数据子集。
设置占位:在列表头部和尾部分别设置一个空的 div 作为占位,其高度等于被截取掉的所有项的高度之和,从而保持滚动条高度与实际数据总量一致。
渲染:只渲染这个数据子集对应的 DOM。
优点:
无论总数据量多大(十万、百万级),同时存在的真实 DOM 数量基本恒定(几十个),性能极佳。
保持了流畅滚动的交互体验。
缺点:
实现复杂,需要考虑动态高度的计算、滚动条抖动等问题。
对需要精确获取行高、快速定位到某一行等高级功能支持有挑战。
开源库
React: react-window
Vue: vue-virtual-scroller
通用:@tanstack/virtual-core
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>虚拟滚动完整示例</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
header {
text-align: center;
margin-bottom: 30px;
color: white;
}
h1 {
font-size: 2.5rem;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.subtitle {
font-size: 1.2rem;
opacity: 0.9;
}
.demo-container {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 30px;
}
.demo-card {
flex: 1;
min-width: 300px;
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
overflow: hidden;
}
.card-header {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
padding: 20px;
}
.card-header h2 {
font-size: 1.5rem;
margin-bottom: 5px;
}
.card-body {
padding: 0;
}
.controls {
padding: 20px;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}
.control-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.control-group label {
font-weight: 500;
color: #495057;
}
input[type="number"], select, button {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
}
input[type="number"] {
width: 120px;
}
button {
background: #4facfe;
color: white;
border: none;
cursor: pointer;
transition: all 0.3s;
font-weight: 500;
}
button:hover {
background: #3a9bfe;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(79, 172, 254, 0.3);
}
.stats {
display: flex;
justify-content: space-between;
padding: 15px 20px;
background: #f8f9fa;
border-top: 1px solid #e9ecef;
font-size: 13px;
color: #666;
}
.stat-item {
display: flex;
flex-direction: column;
}
.stat-value {
font-weight: 600;
color: #333;
font-size: 16px;
}
/* 列表容器样式 */
.list-container {
height: 400px;
overflow-y: auto;
position: relative;
}
.list-scroll {
position: relative;
will-change: transform;
}
.list-item {
position: absolute;
left: 0;
right: 0;
border-bottom: 1px solid #e9ecef;
transition: all 0.2s;
}
.list-item:hover {
background: #f8f9fa;
transform: scale(1.005);
}
.item-content {
padding: 15px 20px;
display: flex;
align-items: center;
}
.item-index {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-right: 15px;
flex-shrink: 0;
}
.item-text {
flex: 1;
}
.item-title {
font-weight: 500;
margin-bottom: 4px;
color: #333;
}
.item-subtitle {
font-size: 12px;
color: #666;
}
.performance-monitor {
background: #1a1a1a;
color: #00ff00;
font-family: 'Courier New', monospace;
padding: 15px;
border-radius: 6px;
margin-top: 20px;
font-size: 13px;
line-height: 1.6;
}
.comparison-section {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
}
.comparison-section h2 {
color: #333;
margin-bottom: 20px;
text-align: center;
}
.comparison-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.comparison-item {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
}
.comparison-item h3 {
color: #4facfe;
margin-bottom: 10px;
}
.tech-stack {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 20px;
}
.tech-tag {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
}
/* 滚动条样式 */
.list-container::-webkit-scrollbar {
width: 8px;
}
.list-container::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.list-container::-webkit-scrollbar-thumb {
background: #4facfe;
border-radius: 4px;
}
.list-container::-webkit-scrollbar-thumb:hover {
background: #3a9bfe;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>虚拟滚动完整示例</h1>
<p class="subtitle">高性能大数据列表渲染解决方案</p>
</header>
<div class="demo-container">
<!-- 虚拟滚动示例 -->
<div class="demo-card">
<div class="card-header">
<h2>虚拟滚动列表</h2>
<p>只渲染可见区域,支持海量数据</p>
</div>
<div class="controls">
<div class="control-group">
<label>数据量:</label>
<input type="number" id="dataCount" value="10000" min="100" max="1000000" step="1000">
<button onclick="updateData()">更新数据</button>
<select id="themeSelect" onchange="changeTheme(this.value)">
<option value="default">默认主题</option>
<option value="dark">深色主题</option>
<option value="colorful">多彩主题</option>
</select>
</div>
</div>
<div class="list-container" id="virtualListContainer">
<!-- 列表内容由JS动态生成 -->
</div>
<div class="stats">
<div class="stat-item">
<span>总数据量</span>
<span class="stat-value" id="totalCount">0</span>
</div>
<div class="stat-item">
<span>可见项目</span>
<span class="stat-value" id="visibleCount">0</span>
</div>
<div class="stat-item">
<span>渲染时间</span>
<span class="stat-value" id="renderTime">0ms</span>
</div>
<div class="stat-item">
<span>FPS</span>
<span class="stat-value" id="fps">60</span>
</div>
</div>
</div>
<!-- 传统滚动对比 -->
<div class="demo-card">
<div class="card-header">
<h2>传统滚动对比</h2>
<p>渲染所有DOM元素</p>
</div>
<div class="controls">
<div class="control-group">
<label>对比数据量:</label>
<input type="number" id="compareCount" value="1000" min="100" max="10000" step="100">
<button onclick="updateCompareData()">更新对比</button>
</div>
</div>
<div class="list-container" id="normalListContainer">
<!-- 传统列表内容 -->
</div>
<div class="stats">
<div class="stat-item">
<span>DOM元素数</span>
<span class="stat-value" id="domCount">0</span>
</div>
<div class="stat-item">
<span>内存占用</span>
<span class="stat-value" id="memoryUsage">0MB</span>
</div>
<div class="stat-item">
<span>性能评分</span>
<span class="stat-value" id="performanceScore">-</span>
</div>
</div>
</div>
</div>
<!-- 性能监控 -->
<div class="performance-monitor" id="performanceMonitor">
<div>🎯 虚拟滚动性能监控</div>
<div>├─ 可见区域: <span id="visibleArea">0-0</span></div>
<div>├─ 缓冲区大小: <span id="bufferSize">0</span></div>
<div>├─ 滚动位置: <span id="scrollPosition">0px</span></div>
<div>└─ 上次渲染: <span id="lastRender">0ms</span></div>
</div>
<!-- 技术对比 -->
<div class="comparison-section">
<h2>技术对比</h2>
<div class="comparison-grid">
<div class="comparison-item">
<h3>虚拟滚动</h3>
<p><strong>优点:</strong></p>
<ul>
<li>⚡ 高性能,支持百万级数据</li>
<li>💾 内存占用低</li>
<li>🚀 滚动流畅,无卡顿</li>
<li>📱 移动端友好</li>
</ul>
<p><strong>缺点:</strong></p>
<ul>
<li>🔧 实现相对复杂</li>
<li>🔍 快速滚动时可能出现空白</li>
<li>📏 需要预估项目高度</li>
</ul>
</div>
<div class="comparison-item">
<h3>传统滚动</h3>
<p><strong>优点:</strong></p>
<ul>
<li>🎯 实现简单</li>
<li>🔧 兼容性好</li>
<li>📊 精确高度控制</li>
<li>⚡ 小数据量时性能好</li>
</ul>
<p><strong>缺点:</strong></p>
<ul>
<li>🐌 大数据量时性能差</li>
<li>💥 内存占用高</li>
<li>📱 移动端卡顿明显</li>
<li>⚡ 首次加载慢</li>
</ul>
</div>
<div class="comparison-item">
<h3>适用场景</h3>
<p><strong>使用虚拟滚动:</strong></p>
<ul>
<li>📈 大数据列表/表格</li>
<li>💬 聊天记录</li>
<li>📱 长列表Feed流</li>
<li>🎮 游戏排行榜</li>
</ul>
<p><strong>使用传统滚动:</strong></p>
<ul>
<li>📄 静态内容页面</li>
<li>📊 小数据量列表</li>
<li>🔧 需要精确控制的项目</li>
<li>📱 移动端简单列表</li>
</ul>
</div>
</div>
<div class="tech-stack">
<span class="tech-tag">JavaScript</span>
<span class="tech-tag">DOM 操作</span>
<span class="tech-tag">性能优化</span>
<span class="tech-tag">Intersection Observer</span>
<span class="tech-tag">RequestAnimationFrame</span>
<span class="tech-tag">滚动优化</span>
</div>
</div>
</div>
<script>
// ==================== 虚拟滚动类 ====================
class VirtualScroll {
constructor(container, options = {}) {
this.container = container;
this.options = {
itemHeight: 60,
bufferSize: 5,
renderItem: null,
...options
};
this.data = [];
this.visibleItems = [];
this.isScrolling = false;
this.scrollTimer = null;
this.lastScrollTop = 0;
// 性能监控
this.frameCount = 0;
this.lastFrameTime = performance.now();
this.fps = 60;
this.init();
this.bindEvents();
this.startFPSMonitor();
}
init() {
// 创建滚动容器
this.scrollContainer = document.createElement('div');
this.scrollContainer.className = 'list-scroll';
this.container.appendChild(this.scrollContainer);
// 设置容器样式
this.container.style.position = 'relative';
this.container.style.overflowY = 'auto';
// 设置滚动容器尺寸
this.updateScrollHeight();
}
setData(data) {
this.data = data;
this.updateScrollHeight();
this.render();
}
updateScrollHeight() {
const totalHeight = this.data.length * this.options.itemHeight;
this.scrollContainer.style.height = totalHeight + 'px';
}
getVisibleRange() {
const scrollTop = this.container.scrollTop;
const clientHeight = this.container.clientHeight;
const startIndex = Math.max(0, Math.floor(scrollTop / this.options.itemHeight) - this.options.bufferSize);
const endIndex = Math.min(
this.data.length - 1,
Math.floor((scrollTop + clientHeight) / this.options.itemHeight) + this.options.bufferSize
);
return { startIndex, endIndex };
}
render() {
const startTime = performance.now();
const { startIndex, endIndex } = this.getVisibleRange();
const visibleData = this.data.slice(startIndex, endIndex + 1);
// 更新性能监控
this.updatePerformanceMonitor(startIndex, endIndex);
// 清除现有内容
this.scrollContainer.innerHTML = '';
// 渲染可见项目
visibleData.forEach((item, index) => {
const absoluteIndex = startIndex + index;
const top = absoluteIndex * this.options.itemHeight;
const itemElement = document.createElement('div');
itemElement.className = 'list-item';
itemElement.style.top = top + 'px';
itemElement.style.height = this.options.itemHeight + 'px';
if (this.options.renderItem) {
itemElement.innerHTML = this.options.renderItem(item, absoluteIndex);
} else {
itemElement.innerHTML = this.defaultRenderItem(item, absoluteIndex);
}
this.scrollContainer.appendChild(itemElement);
});
// 更新统计信息
this.updateStats(startTime, visibleData.length);
}
defaultRenderItem(item, index) {
const colors = ['#667eea', '#764ba2', '#f093fb', '#4facfe', '#00f2fe'];
const color = colors[index % colors.length];
return `
<div class="item-content">
<div class="item-index" style="background: ${color}">
${index + 1}
</div>
<div class="item-text">
<div class="item-title">项目 #${index + 1}</div>
<div class="item-subtitle">ID: ${item.id} | 值: ${item.value}</div>
</div>
</div>
`;
}
bindEvents() {
// 防抖滚动处理
this.container.addEventListener('scroll', () => {
this.isScrolling = true;
this.lastScrollTop = this.container.scrollTop;
if (this.scrollTimer) {
clearTimeout(this.scrollTimer);
}
this.scrollTimer = setTimeout(() => {
this.isScrolling = false;
}, 100);
this.render();
// 更新滚动位置显示
document.getElementById('scrollPosition').textContent =
Math.round(this.container.scrollTop) + 'px';
});
// 窗口大小变化时重新渲染
window.addEventListener('resize', () => {
this.render();
});
}
updateStats(startTime, visibleCount) {
const renderTime = performance.now() - startTime;
document.getElementById('totalCount').textContent =
this.data.length.toLocaleString();
document.getElementById('visibleCount').textContent =
visibleCount.toLocaleString();
document.getElementById('renderTime').textContent =
renderTime.toFixed(2) + 'ms';
document.getElementById('fps').textContent =
Math.round(this.fps);
document.getElementById('lastRender').textContent =
renderTime.toFixed(2) + 'ms';
}
updatePerformanceMonitor(startIndex, endIndex) {
document.getElementById('visibleArea').textContent =
`${startIndex + 1}-${endIndex + 1}`;
document.getElementById('bufferSize').textContent =
this.options.bufferSize;
}
startFPSMonitor() {
const calculateFPS = () => {
const now = performance.now();
this.frameCount++;
if (now >= this.lastFrameTime + 1000) {
this.fps = this.frameCount;
this.frameCount = 0;
this.lastFrameTime = now;
}
requestAnimationFrame(calculateFPS);
};
requestAnimationFrame(calculateFPS);
}
destroy() {
this.container.removeEventListener('scroll', () => {});
window.removeEventListener('resize', () => {});
}
}
// ==================== 传统滚动类 ====================
class NormalScroll {
constructor(container) {
this.container = container;
this.data = [];
}
setData(data) {
this.data = data;
this.render();
}
render() {
const startTime = performance.now();
this.container.innerHTML = '';
this.data.forEach((item, index) => {
const itemElement = document.createElement('div');
itemElement.className = 'list-item';
itemElement.style.position = 'relative';
itemElement.style.height = '60px';
const colors = ['#667eea', '#764ba2', '#f093fb', '#4facfe', '#00f2fe'];
const color = colors[index % colors.length];
itemElement.innerHTML = `
<div class="item-content">
<div class="item-index" style="background: ${color}">
${index + 1}
</div>
<div class="item-text">
<div class="item-title">项目 #${index + 1}</div>
<div class="item-subtitle">ID: ${item.id} | 值: ${item.value}</div>
</div>
</div>
`;
this.container.appendChild(itemElement);
});
const renderTime = performance.now() - startTime;
// 更新统计信息
document.getElementById('domCount').textContent =
this.data.length.toLocaleString();
document.getElementById('performanceScore').textContent =
this.calculatePerformanceScore(renderTime, this.data.length);
// 估算内存占用
const memoryEstimate = (this.data.length * 0.2).toFixed(1);
document.getElementById('memoryUsage').textContent =
memoryEstimate + 'MB';
}
calculatePerformanceScore(renderTime, dataCount) {
if (dataCount <= 1000) return '优秀 ⭐⭐⭐⭐⭐';
if (dataCount <= 5000) return renderTime < 1000 ? '良好 ⭐⭐⭐⭐' : '一般 ⭐⭐⭐';
return renderTime < 2000 ? '及格 ⭐⭐' : '较差 ⭐';
}
}
// ==================== 工具函数 ====================
function generateData(count) {
const data = [];
for (let i = 0; i < count; i++) {
data.push({
id: `ITEM_${String(i + 1).padStart(6, '0')}`,
value: Math.floor(Math.random() * 10000),
timestamp: new Date(Date.now() - Math.random() * 10000000000)
});
}
return data;
}
// ==================== 全局变量 ====================
let virtualScroll = null;
let normalScroll = null;
// ==================== 初始化 ====================
function init() {
const virtualContainer = document.getElementById('virtualListContainer');
const normalContainer = document.getElementById('normalListContainer');
// 初始化虚拟滚动
virtualScroll = new VirtualScroll(virtualContainer, {
itemHeight: 60,
bufferSize: 3,
renderItem: (item, index) => {
const colors = ['#667eea', '#764ba2', '#f093fb', '#4facfe', '#00f2fe'];
const color = colors[index % colors.length];
const theme = document.getElementById('themeSelect').value;
let bgColor = color;
let textColor = 'white';
if (theme === 'dark') {
bgColor = '#333';
textColor = '#fff';
} else if (theme === 'colorful') {
const hue = (index * 137) % 360;
bgColor = `hsl(${hue}, 70%, 60%)`;
}
return `
<div class="item-content">
<div class="item-index" style="background: ${bgColor}; color: ${textColor}">
${index + 1}
</div>
<div class="item-text">
<div class="item-title">项目 #${index + 1}</div>
<div class="item-subtitle">ID: ${item.id} | 值: ${item.value}</div>
</div>
</div>
`;
}
});
// 初始化传统滚动
normalScroll = new NormalScroll(normalContainer);
// 生成初始数据
const initialCount = parseInt(document.getElementById('dataCount').value);
const compareCount = parseInt(document.getElementById('compareCount').value);
updateVirtualData(initialCount);
updateNormalData(compareCount);
}
// ==================== 数据更新函数 ====================
function updateVirtualData(count) {
const data = generateData(count);
virtualScroll.setData(data);
}
function updateNormalData(count) {
const data = generateData(count);
normalScroll.setData(data);
}
function updateData() {
const count = parseInt(document.getElementById('dataCount').value);
updateVirtualData(count);
}
function updateCompareData() {
const count = parseInt(document.getElementById('compareCount').value);
updateNormalData(count);
}
function changeTheme(theme) {
const container = document.getElementById('virtualListContainer');
const items = container.querySelectorAll('.list-item');
items.forEach((item, index) => {
const indexElem = item.querySelector('.item-index');
if (indexElem) {
if (theme === 'dark') {
indexElem.style.background = '#333';
indexElem.style.color = 'white';
} else if (theme === 'colorful') {
const hue = (index * 137) % 360;
indexElem.style.background = `hsl(${hue}, 70%, 60%)`;
indexElem.style.color = 'white';
} else {
const colors = ['#667eea', '#764ba2', '#f093fb', '#4facfe', '#00f2fe'];
indexElem.style.background = colors[index % colors.length];
indexElem.style.color = 'white';
}
}
});
}
// ==================== 性能测试函数 ====================
function runPerformanceTest() {
const testSizes = [100, 1000, 10000, 50000, 100000];
const results = [];
testSizes.forEach(size => {
console.log(`\n测试数据量: ${size}`);
// 虚拟滚动测试
const virtualData = generateData(size);
const virtualStart = performance.now();
virtualScroll.setData(virtualData);
const virtualTime = performance.now() - virtualStart;
// 传统滚动测试
const normalData = generateData(Math.min(size, 10000)); // 限制传统滚动数据量
const normalStart = performance.now();
normalScroll.setData(normalData);
const normalTime = performance.now() - normalStart;
results.push({
size,
virtualTime,
normalTime,
performanceRatio: normalTime / virtualTime
});
console.log(`虚拟滚动: ${virtualTime.toFixed(2)}ms`);
console.log(`传统滚动: ${normalTime.toFixed(2)}ms`);
console.log(`性能比: ${(normalTime / virtualTime).toFixed(1)}x`);
});
return results;
}
// ==================== 页面加载完成 ====================
document.addEventListener('DOMContentLoaded', () => {
init();
// 添加性能测试按钮
const controls = document.querySelector('.controls');
const testButton = document.createElement('button');
testButton.textContent = '运行性能测试';
testButton.onclick = () => {
const results = runPerformanceTest();
console.table(results);
alert('性能测试完成!请查看控制台输出。');
};
controls.appendChild(testButton);
// 添加重置按钮
const resetButton = document.createElement('button');
resetButton.textContent = '重置到默认';
resetButton.onclick = () => {
document.getElementById('dataCount').value = 10000;
document.getElementById('compareCount').value = 1000;
document.getElementById('themeSelect').value = 'default';
updateData();
updateCompareData();
changeTheme('default');
};
controls.appendChild(resetButton);
// 添加滚动到底部按钮
const scrollButton = document.createElement('button');
scrollButton.textContent = '滚动到底部';
scrollButton.onclick = () => {
const container = document.getElementById('virtualListContainer');
container.scrollTop = container.scrollHeight;
};
controls.appendChild(scrollButton);
});
// ==================== 内存监控 ====================
function updateMemoryUsage() {
if (window.performance && performance.memory) {
const memory = performance.memory;
const usedMB = (memory.usedJSHeapSize / 1024 / 1024).toFixed(1);
const totalMB = (memory.totalJSHeapSize / 1024 / 1024).toFixed(1);
console.log(`内存使用: ${usedMB}MB / ${totalMB}MB`);
}
}
// 定期更新内存使用情况
setInterval(updateMemoryUsage, 5000);
</script>
</body>
</html>
- canvas
对于超大规模数据可视化(如十万个散点图、地图标记、关系图谱),DOM 的性能瓶颈是无法突破的。此时需要用更底层的图形技术。
Canvas 2D: 通过绘制 API 直接控制像素,性能远超 DOM。适用于图表、热力图等。
WebGL: 利用 GPU 进行高性能图形计算,适用于 3D 场景、粒子系统、海量几何图形的渲染。
优点:
极限性能,可渲染百万级数据点。
图形绘制能力强大。
缺点:
实现复杂,需要图形学知识。
完全脱离 DOM 生态:无法使用 CSS、无法方便地绑定事件、文本渲染和 SEO 不友好。通常需要自建一套交互和布局系统。
开源库:
图表/可视化: ECharts, AntV G2, D3.js(部分结合 Canvas)
WebGL 引擎: Three.js, Babylon.js
<!DOCTYPE html>
<html>
<head>
<title>Canvas 大数据表格</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { padding: 20px; background: #f0f2f5; }
.container {
width: 800px;
height: 500px;
border: 2px solid #1890ff;
background: white;
margin: 0 auto;
position: relative;
overflow: hidden;
}
#info {
margin-top: 10px;
padding: 10px;
background: white;
border: 1px solid #ddd;
}
.controls {
text-align: center;
margin: 10px 0;
padding: 10px;
}
button {
margin: 0 5px;
padding: 8px 16px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #108ee9;
}
</style>
</head>
<body>
<h2 style="text-align: center; margin-bottom: 10px; color: #1890ff;">
Canvas 大数据表格
</h2>
<div class="controls" id="controls"></div>
<div class="container" id="tableContainer"></div>
<div id="info">状态: 初始化中...</div>
<script>
// ==================== 滚动条类 ====================
class Scrollbar {
constructor(viewport, canvas, renderer) {
this.viewport = viewport;
this.canvas = canvas;
this.renderer = renderer;
this.isDraggingH = false;
this.isDraggingV = false;
this.isHoveredH = false;
this.isHoveredV = false;
this.dragStartX = 0;
this.dragStartY = 0;
this.dragScrollStartX = 0;
this.dragScrollStartY = 0;
// 滚动条样式
this.scrollbarSize = 12;
this.trackColor = '#f5f5f5';
this.thumbColor = '#c1c1c1';
this.thumbHoverColor = '#a8a8a8';
this.thumbActiveColor = '#888888';
this.cornerColor = '#e8e8e8';
// 初始化尺寸
this.verticalVisible = false;
this.horizontalVisible = false;
this.availableWidth = 0;
this.availableHeight = 0;
this.bindEvents();
}
// 计算滚动条尺寸
updateDimensions(data, columns) {
if (!data || data.length === 0) return;
const viewport = this.viewport;
const canvas = this.canvas;
const scrollbarSize = this.scrollbarSize;
const totalRows = data.length;
const totalCols = columns.length;
// 计算内容总尺寸
const contentHeight = totalRows * viewport.rowHeight + viewport.headerHeight;
const contentWidth = totalCols * viewport.colWidth;
// 检查是否需要滚动条
this.verticalVisible = contentHeight > canvas.height;
this.horizontalVisible = contentWidth > canvas.width;
// 可用区域(减去滚动条占用的空间)
this.availableWidth = canvas.width - (this.verticalVisible ? scrollbarSize : 0);
this.availableHeight = canvas.height - (this.horizontalVisible ? scrollbarSize : 0);
// 垂直滚动条尺寸
if (this.verticalVisible) {
this.vTrackHeight = this.availableHeight;
this.vThumbHeight = Math.max(
scrollbarSize * 2,
(canvas.height / contentHeight) * this.vTrackHeight
);
this.vThumbTop = (viewport.scrollTop / contentHeight) * this.vTrackHeight;
this.vThumbY = Math.max(0, Math.min(this.vThumbTop, this.vTrackHeight - this.vThumbHeight));
}
// 水平滚动条尺寸
if (this.horizontalVisible) {
this.hTrackWidth = this.availableWidth;
this.hThumbWidth = Math.max(
scrollbarSize * 2,
(canvas.width / contentWidth) * this.hTrackWidth
);
this.hThumbLeft = (viewport.scrollLeft / contentWidth) * this.hTrackWidth;
this.hThumbX = Math.max(0, Math.min(this.hThumbLeft, this.hTrackWidth - this.hThumbWidth));
}
}
// 绘制滚动条
draw(ctx, data, columns) {
if (!data || data.length === 0) return;
this.updateDimensions(data, columns);
const canvas = this.canvas;
const width = canvas.width;
const height = canvas.height;
const scrollbarSize = this.scrollbarSize;
// 绘制角落填充
if (this.verticalVisible && this.horizontalVisible) {
ctx.fillStyle = this.cornerColor;
ctx.fillRect(
width - scrollbarSize,
height - scrollbarSize,
scrollbarSize,
scrollbarSize
);
}
// 绘制垂直滚动条
if (this.verticalVisible) {
// 轨道
ctx.fillStyle = this.trackColor;
ctx.fillRect(
width - scrollbarSize,
0,
scrollbarSize,
this.vTrackHeight
);
// 滑块
const thumbColor = this.isDraggingV ? this.thumbActiveColor :
(this.isHoveredV ? this.thumbHoverColor : this.thumbColor);
ctx.fillStyle = thumbColor;
// 圆角滑块
this.drawRoundedRect(
ctx,
width - scrollbarSize + 2,
this.vThumbY + 2,
scrollbarSize - 4,
this.vThumbHeight - 4,
4
);
ctx.fill();
}
// 绘制水平滚动条
if (this.horizontalVisible) {
// 轨道
ctx.fillStyle = this.trackColor;
ctx.fillRect(
0,
height - scrollbarSize,
this.hTrackWidth,
scrollbarSize
);
// 滑块
const thumbColor = this.isDraggingH ? this.thumbActiveColor :
(this.isHoveredH ? this.thumbHoverColor : this.thumbColor);
ctx.fillStyle = thumbColor;
// 圆角滑块
this.drawRoundedRect(
ctx,
this.hThumbX + 2,
height - scrollbarSize + 2,
this.hThumbWidth - 4,
scrollbarSize - 4,
4
);
ctx.fill();
}
}
// 绘制圆角矩形
drawRoundedRect(ctx, x, y, width, height, radius) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
// 绑定事件
bindEvents() {
// 事件委托给主控制器处理
}
// 检查是否点击了滚动条
checkClick(x, y) {
if (!this.viewport.data || this.viewport.data.length === 0) return null;
const canvas = this.canvas;
const scrollbarSize = this.scrollbarSize;
// 检查垂直滚动条
if (this.verticalVisible) {
const thumbRect = {
x: canvas.width - scrollbarSize + 2,
y: this.vThumbY + 2,
width: scrollbarSize - 4,
height: this.vThumbHeight - 4
};
if (this.pointInRect(x, y, thumbRect)) {
return { type: 'v-thumb' };
}
const trackRect = {
x: canvas.width - scrollbarSize,
y: 0,
width: scrollbarSize,
height: this.vTrackHeight
};
if (this.pointInRect(x, y, trackRect)) {
return { type: 'v-track', y: y };
}
}
// 检查水平滚动条
if (this.horizontalVisible) {
const thumbRect = {
x: this.hThumbX + 2,
y: canvas.height - scrollbarSize + 2,
width: this.hThumbWidth - 4,
height: scrollbarSize - 4
};
if (this.pointInRect(x, y, thumbRect)) {
return { type: 'h-thumb' };
}
const trackRect = {
x: 0,
y: canvas.height - scrollbarSize,
width: this.hTrackWidth,
height: scrollbarSize
};
if (this.pointInRect(x, y, trackRect)) {
return { type: 'h-track', x: x };
}
}
return null;
}
// 检查悬停状态
checkHover(x, y) {
this.isHoveredV = false;
this.isHoveredH = false;
if (!this.viewport.data || this.viewport.data.length === 0) return;
const canvas = this.canvas;
const scrollbarSize = this.scrollbarSize;
if (this.verticalVisible) {
const thumbRect = {
x: canvas.width - scrollbarSize + 2,
y: this.vThumbY + 2,
width: scrollbarSize - 4,
height: this.vThumbHeight - 4
};
this.isHoveredV = this.pointInRect(x, y, thumbRect);
}
if (this.horizontalVisible) {
const thumbRect = {
x: this.hThumbX + 2,
y: canvas.height - scrollbarSize + 2,
width: this.hThumbWidth - 4,
height: scrollbarSize - 4
};
this.isHoveredH = this.pointInRect(x, y, thumbRect);
}
}
// 开始拖动
startDrag(type, x, y) {
if (type === 'v-thumb') {
this.isDraggingV = true;
this.dragStartY = y;
this.dragScrollStartY = this.viewport.scrollTop;
} else if (type === 'h-thumb') {
this.isDraggingH = true;
this.dragStartX = x;
this.dragScrollStartX = this.viewport.scrollLeft;
}
}
// 处理拖动
handleDrag(x, y) {
if (this.isDraggingV) {
const deltaY = y - this.dragStartY;
const totalRows = this.viewport.data.length;
const contentHeight = totalRows * this.viewport.rowHeight + this.viewport.headerHeight;
const scrollRatio = deltaY / this.vTrackHeight;
this.viewport.scrollTop = this.dragScrollStartY + (scrollRatio * contentHeight);
this.viewport.clampScroll();
return true;
}
if (this.isDraggingH) {
const deltaX = x - this.dragStartX;
const totalCols = this.viewport.columns.length;
const contentWidth = totalCols * this.viewport.colWidth;
const scrollRatio = deltaX / this.hTrackWidth;
this.viewport.scrollLeft = this.dragScrollStartX + (scrollRatio * contentWidth);
this.viewport.clampScroll();
return true;
}
return false;
}
// 处理轨道点击
handleTrackClick(type, pos) {
if (type === 'v-track') {
const totalRows = this.viewport.data.length;
const contentHeight = totalRows * this.viewport.rowHeight + this.viewport.headerHeight;
const clickRatio = pos / this.vTrackHeight;
this.viewport.scrollTop = clickRatio * contentHeight;
this.viewport.clampScroll();
return true;
}
if (type === 'h-track') {
const totalCols = this.viewport.columns.length;
const contentWidth = totalCols * this.viewport.colWidth;
const clickRatio = pos / this.hTrackWidth;
this.viewport.scrollLeft = clickRatio * contentWidth;
this.viewport.clampScroll();
return true;
}
return false;
}
// 停止拖动
stopDrag() {
this.isDraggingV = false;
this.isDraggingH = false;
}
// 检查点是否在矩形内
pointInRect(x, y, rect) {
return x >= rect.x &&
x <= rect.x + rect.width &&
y >= rect.y &&
y <= rect.y + rect.height;
}
// 获取鼠标样式
getCursorStyle(x, y) {
const clickResult = this.checkClick(x, y);
if (clickResult && (clickResult.type === 'v-thumb' || clickResult.type === 'h-thumb')) {
return 'pointer';
}
if (this.isHoveredV || this.isHoveredH || this.isDraggingV || this.isDraggingH) {
return 'pointer';
}
return 'default';
}
}
// ==================== 视口类 ====================
class SimpleViewport {
constructor(canvasWidth, canvasHeight) {
this.scrollTop = 0;
this.scrollLeft = 0;
this.width = canvasWidth;
this.height = canvasHeight;
this.rowHeight = 30;
this.colWidth = 100;
this.headerHeight = 40;
this.data = null;
this.columns = null;
this.onRender = null;
}
// 计算最大滚动范围
getMaxScroll() {
if (!this.data || !this.columns) {
return { maxTop: 0, maxLeft: 0 };
}
const totalHeight = this.data.length * this.rowHeight + this.headerHeight;
const totalWidth = this.columns.length * this.colWidth;
return {
maxTop: Math.max(0, totalHeight - this.height),
maxLeft: Math.max(0, totalWidth - this.width)
};
}
// 限制滚动范围
clampScroll() {
const maxScroll = this.getMaxScroll();
this.scrollTop = Math.max(0, Math.min(this.scrollTop, maxScroll.maxTop));
this.scrollLeft = Math.max(0, Math.min(this.scrollLeft, maxScroll.maxLeft));
}
}
// ==================== 渲染器类 ====================
class SimpleRenderer {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.data = [];
this.columns = [];
this.scrollbar = null;
}
setScrollbar(scrollbar) {
this.scrollbar = scrollbar;
}
drawDataTable(data, columns, viewport) {
this.data = data;
this.columns = columns;
const ctx = this.ctx;
const canvas = this.canvas;
const width = canvas.width;
const height = canvas.height;
// 清空并绘制背景
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
if (!data || data.length === 0) {
this.drawTest();
return;
}
const headerHeight = viewport.headerHeight;
const rowHeight = viewport.rowHeight;
const colWidth = viewport.colWidth;
// 获取滚动条可用区域
const scrollbarSize = this.scrollbar?.scrollbarSize || 0;
const availableWidth = width - (this.scrollbar?.verticalVisible ? scrollbarSize : 0);
const availableHeight = height - (this.scrollbar?.horizontalVisible ? scrollbarSize : 0);
// 计算可见区域
const startRow = Math.max(0, Math.floor(viewport.scrollTop / rowHeight));
const endRow = Math.min(data.length - 1,
Math.floor((viewport.scrollTop + availableHeight - headerHeight) / rowHeight)
);
const startCol = Math.max(0, Math.floor(viewport.scrollLeft / colWidth));
const endCol = Math.min(columns.length - 1,
Math.floor((viewport.scrollLeft + availableWidth) / colWidth)
);
// 绘制表头
this.drawHeader(ctx, columns, viewport, startCol, endCol, availableWidth, headerHeight);
// 绘制数据行
if (endRow >= startRow) {
for (let r = startRow; r <= endRow; r++) {
const rowY = headerHeight + r * rowHeight - viewport.scrollTop;
const rowData = data[r];
// 行背景
ctx.fillStyle = r % 2 === 0 ? '#ffffff' : '#f8f9fa';
ctx.fillRect(0, rowY, availableWidth, rowHeight);
// 单元格数据
ctx.fillStyle = '#333';
ctx.font = '13px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let c = startCol; c <= endCol; c++) {
if (c >= columns.length) break;
const colX = c * colWidth - viewport.scrollLeft;
if (colX + colWidth < 0 || colX > availableWidth) continue;
const column = columns[c];
const value = rowData[column.field];
// 文本裁剪
ctx.save();
ctx.beginPath();
ctx.rect(colX + 5, rowY + 1, colWidth - 10, rowHeight - 2);
ctx.clip();
const text = this.formatValue(value, column);
ctx.fillText(text, colX + 8, rowY + rowHeight/2);
ctx.restore();
}
}
// 绘制网格线
this.drawGridLines(ctx, viewport, startRow, endRow, startCol, endCol,
data.length, columns.length, availableWidth, headerHeight);
}
// 绘制滚动条
if (this.scrollbar) {
this.scrollbar.draw(ctx, data, columns);
}
// 更新信息
this.updateInfo(data.length, startRow, endRow, startCol, endCol, viewport);
}
drawHeader(ctx, columns, viewport, startCol, endCol, width, headerHeight) {
const colWidth = viewport.colWidth;
// 表头背景
ctx.fillStyle = '#1890ff';
ctx.fillRect(0, 0, width, headerHeight);
// 表头文字
ctx.fillStyle = 'white';
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let c = startCol; c <= endCol; c++) {
if (c >= columns.length) break;
const colX = c * colWidth - viewport.scrollLeft;
if (colX + colWidth < 0 || colX > width) continue;
const column = columns[c];
ctx.fillText(
column.title || column.field,
colX + colWidth/2,
headerHeight/2
);
// 列分隔线
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(colX + colWidth + 0.5, 5);
ctx.lineTo(colX + colWidth + 0.5, headerHeight - 5);
ctx.stroke();
}
// 表头底部边框
ctx.strokeStyle = '#108ee9';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, headerHeight + 0.5);
ctx.lineTo(width, headerHeight + 0.5);
ctx.stroke();
}
drawGridLines(ctx, viewport, startRow, endRow, startCol, endCol,
totalRows, totalCols, width, headerHeight) {
const rowHeight = viewport.rowHeight;
const colWidth = viewport.colWidth;
ctx.strokeStyle = '#e8e8e8';
ctx.lineWidth = 1;
// 水平线
for (let r = Math.max(startRow, 0); r <= Math.min(endRow + 1, totalRows - 1); r++) {
const y = headerHeight + r * rowHeight - viewport.scrollTop + 0.5;
const startX = Math.max(startCol, 0) * colWidth - viewport.scrollLeft;
const endX = Math.min(endCol + 1, totalCols - 1) * colWidth - viewport.scrollLeft;
ctx.beginPath();
ctx.moveTo(startX, y);
ctx.lineTo(endX, y);
ctx.stroke();
}
// 垂直线
for (let c = Math.max(startCol, 0); c <= Math.min(endCol + 1, totalCols - 1); c++) {
const x = c * colWidth - viewport.scrollLeft + 0.5;
const startY = headerHeight + Math.max(startRow, 0) * rowHeight - viewport.scrollTop;
const endY = headerHeight + Math.min(endRow, totalRows - 1) * rowHeight - viewport.scrollTop;
ctx.beginPath();
ctx.moveTo(x, startY);
ctx.lineTo(x, endY);
ctx.stroke();
}
}
drawTest() {
const ctx = this.ctx;
const width = this.canvas.width;
const height = this.canvas.height;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = '#1890ff';
ctx.font = 'bold 20px Arial';
ctx.textAlign = 'center';
ctx.fillText('Canvas 表格演示', width/2, height/2 - 20);
ctx.fillStyle = '#666';
ctx.font = '16px Arial';
ctx.fillText('点击上方按钮加载数据', width/2, height/2 + 20);
}
formatValue(value, column) {
if (value == null) return '';
if (typeof value === 'number') {
if (column.format === 'int') {
return Math.floor(value).toLocaleString();
}
if (column.format === 'percent') {
return (value * 100).toFixed(1) + '%';
}
return value.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
}
if (typeof value === 'boolean') {
return value ? '✓' : '✗';
}
const text = String(value);
return text.length > 15 ? text.substring(0, 15) + '...' : text;
}
updateInfo(totalRows, startRow, endRow, startCol, endCol, viewport) {
const info = document.getElementById('info');
if (info) {
info.innerHTML = `
<strong>表格状态:</strong> ${totalRows.toLocaleString()} 行数据 |
可见行: ${startRow+1}-${endRow+1} (${endRow-startRow+1}行) |
可见列: ${startCol+1}-${endCol+1} (${endCol-startCol+1}列) |
滚动: (${Math.round(viewport.scrollTop)}, ${Math.round(viewport.scrollLeft)}) |
提示: 可以使用滚动条、鼠标滚轮或拖动表格
`;
}
}
}
// ==================== 主控制器 ====================
class SimpleTable {
constructor(containerId) {
this.container = document.getElementById(containerId);
if (!this.container) {
throw new Error(`找不到容器: ${containerId}`);
}
// 创建 Canvas
this.canvas = document.createElement('canvas');
this.container.appendChild(this.canvas);
// 设置 Canvas 尺寸
this.updateCanvasSize();
// 创建视口
this.viewport = new SimpleViewport(this.canvas.width, this.canvas.height);
// 创建渲染器
this.renderer = new SimpleRenderer(this.canvas);
// 创建滚动条
this.scrollbar = new Scrollbar(this.viewport, this.canvas, this.renderer);
this.renderer.setScrollbar(this.scrollbar);
// 设置渲染回调
this.viewport.onRender = () => this.render();
// 绑定事件
this.bindEvents();
// 初始绘制
this.render();
console.log('表格初始化完成');
}
updateCanvasSize() {
const rect = this.container.getBoundingClientRect();
this.canvas.width = rect.width;
this.canvas.height = rect.height;
this.canvas.style.width = '100%';
this.canvas.style.height = '100%';
this.canvas.style.display = 'block';
if (this.viewport) {
this.viewport.width = rect.width;
this.viewport.height = rect.height;
}
}
bindEvents() {
// 鼠标滚轮事件
this.canvas.addEventListener('wheel', this.handleWheel.bind(this));
// 鼠标事件
this.isDraggingTable = false;
this.dragStart = { x: 0, y: 0 };
this.scrollStart = { x: 0, y: 0 };
this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
this.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
this.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
this.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this));
// 窗口大小变化
window.addEventListener('resize', () => {
this.updateCanvasSize();
this.render();
});
}
handleWheel(e) {
e.preventDefault();
// 平滑滚动
const scrollSpeed = 0.5;
const deltaX = e.deltaX * scrollSpeed;
const deltaY = e.deltaY * scrollSpeed;
// 更新滚动位置
this.viewport.scrollLeft += deltaX;
this.viewport.scrollTop += deltaY;
this.viewport.clampScroll();
this.render();
}
handleMouseDown(e) {
e.preventDefault();
const rect = this.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 先检查是否点击了滚动条
const scrollbarClick = this.scrollbar.checkClick(x, y);
if (scrollbarClick) {
if (scrollbarClick.type === 'v-thumb' || scrollbarClick.type === 'h-thumb') {
// 开始拖动滚动条滑块
this.scrollbar.startDrag(scrollbarClick.type, x, y);
} else if (scrollbarClick.type === 'v-track' || scrollbarClick.type === 'h-track') {
// 点击轨道,快速滚动
this.scrollbar.handleTrackClick(scrollbarClick.type, scrollbarClick.x || scrollbarClick.y);
this.render();
}
} else {
// 点击表格区域,开始拖动表格
this.isDraggingTable = true;
this.dragStart = { x: e.clientX, y: e.clientY };
this.scrollStart = {
x: this.viewport.scrollLeft,
y: this.viewport.scrollTop
};
this.canvas.style.cursor = 'grabbing';
}
}
handleMouseMove(e) {
e.preventDefault();
const rect = this.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 更新悬停状态
this.scrollbar.checkHover(x, y);
// 设置鼠标样式
const cursor = this.scrollbar.getCursorStyle(x, y);
this.canvas.style.cursor = this.isDraggingTable ? 'grabbing' : cursor;
// 处理滚动条拖动
if (this.scrollbar.isDraggingV || this.scrollbar.isDraggingH) {
if (this.scrollbar.handleDrag(x, y)) {
this.render();
}
return;
}
// 处理表格拖动
if (this.isDraggingTable) {
const deltaX = e.clientX - this.dragStart.x;
const deltaY = e.clientY - this.dragStart.y;
this.viewport.scrollLeft = this.scrollStart.x - deltaX;
this.viewport.scrollTop = this.scrollStart.y - deltaY;
this.viewport.clampScroll();
this.render();
}
}
handleMouseUp() {
this.scrollbar.stopDrag();
this.isDraggingTable = false;
// 重置鼠标样式
const rect = this.canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
this.scrollbar.checkHover(x, y);
const cursor = this.scrollbar.getCursorStyle(x, y);
this.canvas.style.cursor = cursor;
}
handleMouseLeave() {
this.scrollbar.stopDrag();
this.isDraggingTable = false;
this.canvas.style.cursor = 'default';
}
setData(data, columns) {
this.data = data;
this.columns = columns;
// 更新视口中的数据和列引用
this.viewport.data = data;
this.viewport.columns = columns;
this.render();
}
render() {
if (this.renderer) {
this.renderer.drawDataTable(this.data, this.columns, this.viewport);
}
}
scrollToTop() {
this.viewport.scrollTop = 0;
this.viewport.scrollLeft = 0;
this.render();
}
}
// ==================== 工具函数 ====================
function generateTestData(rows, cols) {
const data = [];
const firstNames = ['张', '李', '王', '刘', '陈', '杨', '赵', '黄', '周', '吴'];
const lastNames = ['伟', '芳', '娜', '秀英', '敏', '静', '丽', '强', '磊', '洋'];
const cities = ['北京', '上海', '广州', '深圳', '杭州', '成都', '武汉', '南京', '西安', '重庆'];
const products = ['手机', '电脑', '平板', '电视', '冰箱', '空调', '洗衣机', '相机', '耳机', '手表'];
for (let i = 0; i < rows; i++) {
const row = {
id: i + 1,
name: firstNames[i % firstNames.length] + lastNames[i % lastNames.length],
age: Math.floor(Math.random() * 50) + 18,
city: cities[i % cities.length],
product: products[i % products.length],
sales: Math.random() * 10000,
profit: Math.random() * 2000,
growth: Math.random() - 0.3,
completed: Math.random() > 0.5,
score: Math.floor(Math.random() * 100)
};
// 添加更多列以确保出现水平滚动条
for (let j = 9; j < cols; j++) {
row[`col${j}`] = `数据${i+1}-${j+1}-${Math.random().toString(36).substr(2, 5)}`;
}
data.push(row);
}
return data;
}
function generateColumns(cols) {
const baseColumns = [
{ field: 'id', title: 'ID', width: 80, format: 'int' },
{ field: 'name', title: '姓名', width: 120 },
{ field: 'age', title: '年龄', width: 80, format: 'int' },
{ field: 'city', title: '城市', width: 100 },
{ field: 'product', title: '产品', width: 120 },
{ field: 'sales', title: '销售额', width: 120 },
{ field: 'profit', title: '利润', width: 120 },
{ field: 'growth', title: '增长率', width: 100, format: 'percent' },
{ field: 'completed', title: '完成', width: 80 },
{ field: 'score', title: '评分', width: 80, format: 'int' }
];
const columns = baseColumns.slice(0, Math.min(cols, baseColumns.length));
for (let i = baseColumns.length; i < cols; i++) {
columns.push({
field: `col${i}`,
title: `列 ${i + 1}`,
width: 150 // 增加列宽以确保出现水平滚动条
});
}
return columns;
}
function createButton(text, onClick) {
const btn = document.createElement('button');
btn.textContent = text;
btn.onclick = onClick;
return btn;
}
// ==================== 页面初始化 ====================
document.addEventListener('DOMContentLoaded', function() {
let table;
try {
table = new SimpleTable('tableContainer');
// 添加控制按钮
const controls = document.getElementById('controls');
controls.appendChild(createButton('加载 1K 数据 (8列)', () => {
const data = generateTestData(1000, 8);
const cols = generateColumns(8);
table.setData(data, cols);
}));
controls.appendChild(createButton('加载 10K 数据 (12列)', () => {
const data = generateTestData(10000, 12);
const cols = generateColumns(12);
table.setData(data, cols);
}));
controls.appendChild(createButton('加载 50K 数据 (20列)', () => {
const data = generateTestData(50000, 20);
const cols = generateColumns(20);
table.setData(data, cols);
}));
controls.appendChild(createButton('滚动到顶部', () => {
table.scrollToTop();
}));
controls.appendChild(createButton('清空数据', () => {
table.setData([], []);
}));
// 添加功能说明
const tip = document.createElement('div');
tip.style.cssText = 'margin-top: 10px; color: #666; font-size: 12px;';
tip.innerHTML = '功能: 1.鼠标滚轮滚动 2.拖动表格区域 3.拖动滚动条滑块 4.点击轨道快速滚动';
controls.appendChild(tip);
// 初始加载更多列的数据以确保出现水平滚动条
setTimeout(() => {
const data = generateTestData(1000, 15);
const cols = generateColumns(15);
table.setData(data, cols);
}, 100);
} catch (error) {
console.error('初始化错误:', error);
document.getElementById('info').innerHTML = `
<strong style="color: red;">错误:</strong> ${error.message}
`;
}
});
</script>
</body>
</html>
- canvas+ 瓦片技术
瓦片技术(也称分块渲染)是一种将大画布分割成多个小"瓦片"(tiles)进行独立渲染和管理的技术,常用于地图、大图像、大数据可视化等场景。
多级瓦片:根据缩放级别选择合适的瓦片大小
将大的画布或数据集分割成多个固定大小的矩形块(瓦片),每个瓦片可以:
独立渲染:只渲染可见区域的瓦片
独立缓存:渲染过的瓦片可以缓存复用
按需加载:只在需要时才渲染或加载
并行处理:多个瓦片可以并行渲染
-
Skia+WebAssembly
Skia 是 Google 开发的 2D 图形引擎,WebAssembly 是高性能的 Web 二进制格式。它们的结合为 Web 图形渲染带来了革命性的性能提升。
Skia 是一个跨平台的 2D 图形库,被 Chrome、Firefox、Flutter、Android 等使用
主要特点:
硬件加速渲染(OpenGL、Vulkan、Metal)
丰富的图形 API(路径、渐变、滤镜、文本)
高性能的文本渲染(支持复杂文本布局)
跨平台(Windows、macOS、Linux、Android、iOS)
开源(BSD 许可证)
WebAssembly (Wasm) 是低级的类汇编语言,在浏览器中以接近原生速度运行
主要优势:
接近原生的性能(比 JS 快 5-10 倍)
紧凑的二进制格式
内存安全沙箱
跨平台(所有现代浏览器支持)
评论区