🎯 方案背景
在处理大数据量列表渲染时,传统DOM方案面临严重性能瓶颈:
传统方案痛点
- DOM操作开销巨大:10万条数据创建10万个DOM节点,内存占用高
- 重排重绘频繁:滚动时大量DOM操作导致页面卡顿
- CSS高度限制:超出1600万像素会被浏览器裁剪
- 内存泄漏风险:大量DOM节点难以有效回收
Canvas方案优势
- ✅ 零DOM操作:直接像素级绘制,避免DOM重排重绘
- ✅ 极致性能:百万级数据依然60FPS流畅滚动
- ✅ 内存优化:无DOM节点创建,内存占用降低90%+
- ✅ 无高度限制:理论支持无限长度列表
🏗️ 核心架构设计
1. 类结构设计
1class CanvasVirtualList {
2 constructor(canvas, options = {}) {
3
4 this.canvas = canvas;
5 this.ctx = canvas.getContext('2d');
6 this.container = canvas.parentElement;
7
8
9 this.itemHeight = options.itemHeight || 50;
10 this.padding = options.padding || 10;
11 this.fontSize = options.fontSize || 14;
12 this.bufferSize = 5;
13
14
15 this.data = [];
16 this.scrollTop = 0;
17 this.containerHeight = 0;
18 this.totalHeight = 0;
19 this.visibleStart = 0;
20 this.visibleEnd = 0;
21
22
23 this.renderTime = 0;
24 this.lastRenderTime = 0;
25 }
26}
2. 初始化流程
1init() {
2 this.setupCanvas();
3 this.bindEvents();
4 this.setupScrollbar();
5}
🎨 关键技术实现
1. Canvas高DPI适配
1setupCanvas() {
2 const rect = this.container.getBoundingClientRect();
3 const dpr = window.devicePixelRatio || 1;
4
5 this.containerHeight = rect.height;
6 this.canvas.width = (rect.width - 12) * dpr;
7 this.canvas.height = rect.height * dpr;
8
9 this.canvas.style.width = (rect.width - 12) + 'px';
10 this.canvas.style.height = rect.height + 'px';
11
12 this.ctx.scale(dpr, dpr);
13 this.ctx.font = `${this.fontSize}px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif`;
14}
技术要点:
- 物理像素 = CSS像素 × devicePixelRatio
- 通过
ctx.scale(dpr, dpr)
确保高DPI屏幕清晰度 - 动态计算容器尺寸,支持响应式布局
2. 虚拟滚动核心算法
1calculateVisibleRange() {
2 const start = Math.floor(this.scrollTop / this.itemHeight);
3 const visibleCount = Math.ceil(this.containerHeight / this.itemHeight);
4
5
6 this.visibleStart = Math.max(0, start - this.bufferSize);
7 this.visibleEnd = Math.min(
8 this.data.length - 1,
9 start + visibleCount + this.bufferSize
10 );
11}
算法优势:
- 只计算可视区域内的项目索引
- 缓冲区机制减少滚动时的重新渲染
- 时间复杂度O(1),与数据量无关
3. 高性能渲染引擎
1render() {
2 const startTime = performance.now();
3
4
5 this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
6
7
8 for (let i = this.visibleStart; i <= this.visibleEnd; i++) {
9 if (i >= this.data.length) break;
10
11 const item = this.data[i];
12 const y = i * this.itemHeight - this.scrollTop;
13
14
15 if (y + this.itemHeight < 0 || y > this.containerHeight) continue;
16
17 this.renderItem(item, i, y);
18 }
19
20 this.renderTime = performance.now() - startTime;
21}
性能优化策略:
- 视口裁剪:跳过不在可视区域的项目
- 批量绘制:减少Canvas API调用次数
- 性能监控:实时统计渲染耗时
4. 精细化项目渲染
1renderItem(item, index, y) {
2 const isEven = index % 2 === 0;
3
4
5 this.ctx.fillStyle = isEven ? '#ffffff' : '#f8fafc';
6 this.ctx.fillRect(0, y, this.canvas.width, this.itemHeight);
7
8
9 this.ctx.strokeStyle = '#e2e8f0';
10 this.ctx.lineWidth = 1;
11 this.ctx.beginPath();
12 this.ctx.moveTo(0, y + this.itemHeight);
13 this.ctx.lineTo(this.canvas.width, y + this.itemHeight);
14 this.ctx.stroke();
15
16
17 this.ctx.fillStyle = '#1e293b';
18 this.ctx.textBaseline = 'middle';
19
20 const textY = y + this.itemHeight / 2;
21 const leftPadding = this.padding;
22
23
24 this.ctx.fillStyle = '#64748b';
25 this.ctx.fillText(`#${index + 1}`, leftPadding, textY);
26
27
28 this.ctx.fillStyle = '#1e293b';
29 const mainText = typeof item === 'object' ?
30 (item.title || item.name || JSON.stringify(item)) :
31 String(item);
32 this.ctx.fillText(mainText, leftPadding + 60, textY);
33
34
35 if (typeof item === 'object' && item.subtitle) {
36 this.ctx.fillStyle = '#64748b';
37 this.ctx.fillText(item.subtitle, leftPadding + 300, textY);
38 }
39}
🎮 交互体验设计
1. 完整的事件处理
1bindEvents() {
2
3 this.container.addEventListener('wheel', (e) => {
4 e.preventDefault();
5 this.handleScroll(e.deltaY);
6 });
7
8
9 this.canvas.addEventListener('keydown', (e) => {
10 switch(e.key) {
11 case 'ArrowUp': this.handleScroll(-this.itemHeight); break;
12 case 'ArrowDown': this.handleScroll(this.itemHeight); break;
13 case 'PageUp': this.handleScroll(-this.containerHeight); break;
14 case 'PageDown': this.handleScroll(this.containerHeight); break;
15 case 'Home': this.scrollTo(0); break;
16 case 'End': this.scrollTo(this.totalHeight); break;
17 }
18 });
19
20
21 this.canvas.addEventListener('click', (e) => {
22 const rect = this.canvas.getBoundingClientRect();
23 const y = e.clientY - rect.top;
24 const index = Math.floor((this.scrollTop + y) / this.itemHeight);
25
26 if (index >= 0 && index < this.data.length) {
27 this.onItemClick(index, this.data[index]);
28 }
29 });
30}
2. 自定义滚动条实现
1setupScrollbar() {
2 this.scrollbar = this.container.querySelector('.scrollbar');
3 this.scrollbarThumb = this.container.querySelector('.scrollbar-thumb');
4
5
6 let isDragging = false;
7 let startY = 0;
8 let startScrollTop = 0;
9
10 this.scrollbarThumb.addEventListener('mousedown', (e) => {
11 isDragging = true;
12 startY = e.clientY;
13 startScrollTop = this.scrollTop;
14 document.addEventListener('mousemove', onMouseMove);
15 document.addEventListener('mouseup', onMouseUp);
16 });
17
18 const onMouseMove = (e) => {
19 if (!isDragging) return;
20
21 const deltaY = e.clientY - startY;
22 const scrollbarHeight = this.scrollbar.offsetHeight;
23 const thumbHeight = this.scrollbarThumb.offsetHeight;
24 const maxScroll = this.totalHeight - this.containerHeight;
25 const scrollRatio = deltaY / (scrollbarHeight - thumbHeight);
26
27 this.scrollTo(startScrollTop + scrollRatio * maxScroll);
28 };
29}
📊 性能优化策略
1. 内存管理优化
1setData(data) {
2 this.data = data;
3 this.totalHeight = data.length * this.itemHeight;
4 this.updateScrollbar();
5 this.calculateVisibleRange();
6 this.render();
7 this.updateStats();
8}
9
10
11updateStats() {
12 const memoryUsage = (this.data.length * 100) / (1024 * 1024);
13 document.getElementById('memoryUsage').textContent =
14 `${memoryUsage.toFixed(2)}MB`;
15}
2. 渲染性能监控
1render() {
2 const startTime = performance.now();
3
4
5
6 this.renderTime = performance.now() - startTime;
7 this.lastRenderTime = Date.now();
8}
3. 响应式适配
1window.addEventListener('resize', () => {
2 this.setupCanvas();
3 this.render();
4});
💡 使用场景与建议
✅ 适合使用Canvas方案的场景
- 数据量 > 10万条
- 对滚动性能要求极高
- 列表项样式相对统一
- 内存使用敏感的应用
❌ 不适合的场景
- 需要复杂HTML结构
- 大量表单交互
- 丰富的CSS样式需求
- SEO要求较高的页面
🔧 快速集成
1. 基础使用
1<div class="list-container">
2 <canvas id="listCanvas"></canvas>
3 <div class="scrollbar">
4 <div class="scrollbar-thumb"></div>
5 </div>
6</div>
7
8<script>
9const canvas = document.getElementById('listCanvas');
10const virtualList = new CanvasVirtualList(canvas, {
11 itemHeight: 50,
12 padding: 15,
13 fontSize: 14
14});
15
16
17const data = Array.from({length: 100000}, (_, i) => ({
18 id: i,
19 title: `项目 ${i + 1}`,
20 subtitle: `描述信息 ${i + 1}`
21}));
22
23virtualList.setData(data);
24</script>
2. 自定义配置
1const virtualList = new CanvasVirtualList(canvas, {
2 itemHeight: 60,
3 padding: 20,
4 fontSize: 16,
5 bufferSize: 10,
6
7
8 renderItem: (item, index, y) => {
9
10 }
11});
🎯 技术总结
Canvas虚拟列表方案通过以下核心技术实现了极致性能:
- 零DOM操作:完全基于Canvas绘制,避免DOM性能瓶颈
- 虚拟滚动:只渲染可视区域,与数据量无关的O(1)复杂度
- 高DPI适配:完美支持Retina等高分辨率屏幕
- 事件映射:精确的坐标到数据项的映射算法
- 内存优化:无DOM节点创建,内存占用极低
- 性能监控:实时性能指标,便于优化调试
这套方案为大数据量列表渲染提供了终极解决方案,特别适合企业级应用中的数据展示场景。通过Canvas的像素级控制能力,实现了媲美原生应用的流畅体验。
🔍 深度技术解析
1. 坐标映射算法
Canvas中的点击事件需要精确映射到对应的数据项:
1handleClick(e) {
2 const rect = this.canvas.getBoundingClientRect();
3 const y = e.clientY - rect.top;
4
5
6 const index = Math.floor((this.scrollTop + y) / this.itemHeight);
7
8 if (index >= 0 && index < this.data.length) {
9 this.onItemClick(index, this.data[index]);
10 }
11}
2. 滚动同步机制
Canvas滚动与传统DOM滚动的同步实现:
1handleScroll(deltaY) {
2
3 const newScrollTop = Math.max(0, Math.min(
4 this.scrollTop + deltaY,
5 this.totalHeight - this.containerHeight
6 ));
7
8 if (newScrollTop !== this.scrollTop) {
9 this.scrollTo(newScrollTop);
10 }
11}
12
13scrollTo(scrollTop) {
14 this.scrollTop = scrollTop;
15 this.calculateVisibleRange();
16 this.updateScrollbar();
17 this.render();
18 this.updateStats();
19}
3. 缓冲区优化策略
1calculateVisibleRange() {
2 const start = Math.floor(this.scrollTop / this.itemHeight);
3 const visibleCount = Math.ceil(this.containerHeight / this.itemHeight);
4
5
6 this.visibleStart = Math.max(0, start - this.bufferSize);
7 this.visibleEnd = Math.min(
8 this.data.length - 1,
9 start + visibleCount + this.bufferSize
10 );
11}
缓冲区的作用:
- 减少滚动时的白屏现象
- 提供更流畅的滚动体验
- 平衡性能与用户体验
🛠️ 扩展功能实现
1. 搜索过滤功能
1class CanvasVirtualListWithSearch extends CanvasVirtualList {
2 constructor(canvas, options) {
3 super(canvas, options);
4 this.filteredData = [];
5 this.searchQuery = '';
6 }
7
8 search(query) {
9 this.searchQuery = query.toLowerCase();
10 this.applyFilter();
11 }
12
13 applyFilter() {
14 if (!this.searchQuery) {
15 this.filteredData = this.data;
16 } else {
17 this.filteredData = this.data.filter(item => {
18 const searchText = typeof item === 'object' ?
19 JSON.stringify(item).toLowerCase() :
20 String(item).toLowerCase();
21 return searchText.includes(this.searchQuery);
22 });
23 }
24
25 this.totalHeight = this.filteredData.length * this.itemHeight;
26 this.scrollTo(0);
27 }
28}
2. 多选功能实现
1class CanvasVirtualListWithSelection extends CanvasVirtualList {
2 constructor(canvas, options) {
3 super(canvas, options);
4 this.selectedIndices = new Set();
5 }
6
7 handleClick(e) {
8 const index = this.getIndexAtPosition(e);
9
10 if (e.ctrlKey || e.metaKey) {
11
12 if (this.selectedIndices.has(index)) {
13 this.selectedIndices.delete(index);
14 } else {
15 this.selectedIndices.add(index);
16 }
17 } else if (e.shiftKey && this.selectedIndices.size > 0) {
18
19 const lastSelected = Math.max(...this.selectedIndices);
20 const start = Math.min(index, lastSelected);
21 const end = Math.max(index, lastSelected);
22
23 for (let i = start; i <= end; i++) {
24 this.selectedIndices.add(i);
25 }
26 } else {
27
28 this.selectedIndices.clear();
29 this.selectedIndices.add(index);
30 }
31
32 this.render();
33 this.onSelectionChange(Array.from(this.selectedIndices));
34 }
35
36 renderItem(item, index, y) {
37 const isSelected = this.selectedIndices.has(index);
38
39
40 if (isSelected) {
41 this.ctx.fillStyle = '#3b82f6';
42 this.ctx.fillRect(0, y, this.canvas.width, this.itemHeight);
43 }
44
45
46 super.renderItem(item, index, y);
47 }
48}
📈 性能优化进阶
1. 渲染节流优化
1class OptimizedCanvasVirtualList extends CanvasVirtualList {
2 constructor(canvas, options) {
3 super(canvas, options);
4 this.renderRequested = false;
5 }
6
7 requestRender() {
8 if (!this.renderRequested) {
9 this.renderRequested = true;
10 requestAnimationFrame(() => {
11 this.render();
12 this.renderRequested = false;
13 });
14 }
15 }
16
17 handleScroll(deltaY) {
18
19 const newScrollTop = Math.max(0, Math.min(
20 this.scrollTop + deltaY,
21 this.totalHeight - this.containerHeight
22 ));
23
24 if (newScrollTop !== this.scrollTop) {
25 this.scrollTop = newScrollTop;
26 this.calculateVisibleRange();
27 this.updateScrollbar();
28 this.requestRender();
29 }
30 }
31}
2. 文本测量缓存
1class CachedCanvasVirtualList extends CanvasVirtualList {
2 constructor(canvas, options) {
3 super(canvas, options);
4 this.textMetricsCache = new Map();
5 }
6
7 measureText(text) {
8 if (this.textMetricsCache.has(text)) {
9 return this.textMetricsCache.get(text);
10 }
11
12 const metrics = this.ctx.measureText(text);
13 this.textMetricsCache.set(text, metrics);
14 return metrics;
15 }
16
17 truncateText(text, maxWidth) {
18 const cacheKey = `${text}_${maxWidth}`;
19 if (this.textMetricsCache.has(cacheKey)) {
20 return this.textMetricsCache.get(cacheKey);
21 }
22
23 let truncated = text;
24 while (this.measureText(truncated).width > maxWidth && truncated.length > 0) {
25 truncated = truncated.slice(0, -1);
26 }
27
28 if (truncated.length < text.length) {
29 truncated = truncated.slice(0, -3) + '...';
30 }
31
32 this.textMetricsCache.set(cacheKey, truncated);
33 return truncated;
34 }
35}
🎨 样式定制指南
1. 主题系统
1const themes = {
2 light: {
3 background: '#ffffff',
4 alternateBackground: '#f8fafc',
5 text: '#1e293b',
6 secondaryText: '#64748b',
7 border: '#e2e8f0',
8 selected: '#3b82f6'
9 },
10 dark: {
11 background: '#1e293b',
12 alternateBackground: '#334155',
13 text: '#f1f5f9',
14 secondaryText: '#94a3b8',
15 border: '#475569',
16 selected: '#3b82f6'
17 }
18};
19
20class ThemedCanvasVirtualList extends CanvasVirtualList {
21 constructor(canvas, options) {
22 super(canvas, options);
23 this.theme = themes[options.theme || 'light'];
24 }
25
26 renderItem(item, index, y) {
27 const isEven = index % 2 === 0;
28 const isSelected = this.selectedIndices?.has(index);
29
30
31 if (isSelected) {
32 this.ctx.fillStyle = this.theme.selected;
33 } else {
34 this.ctx.fillStyle = isEven ? this.theme.background : this.theme.alternateBackground;
35 }
36
37 this.ctx.fillRect(0, y, this.canvas.width, this.itemHeight);
38
39
40 this.ctx.fillStyle = isSelected ? '#ffffff' : this.theme.text;
41
42 }
43}
2. 自定义渲染器
1class CustomRendererCanvasList extends CanvasVirtualList {
2 constructor(canvas, options) {
3 super(canvas, options);
4 this.customRenderer = options.customRenderer;
5 }
6
7 renderItem(item, index, y) {
8 if (this.customRenderer) {
9
10 const context = {
11 ctx: this.ctx,
12 item,
13 index,
14 y,
15 width: this.canvas.width,
16 height: this.itemHeight,
17 isSelected: this.selectedIndices?.has(index),
18 isEven: index % 2 === 0
19 };
20
21 this.customRenderer(context);
22 } else {
23 super.renderItem(item, index, y);
24 }
25 }
26}
27
28
29const customRenderer = (context) => {
30 const { ctx, item, y, width, height, isSelected } = context;
31
32
33 ctx.fillStyle = isSelected ? '#ff6b6b' : '#4ecdc4';
34 ctx.fillRect(0, y, width, height);
35
36
37 ctx.beginPath();
38 ctx.arc(20, y + height/2, 8, 0, Math.PI * 2);
39 ctx.fillStyle = '#ffffff';
40 ctx.fill();
41
42
43 ctx.fillStyle = '#ffffff';
44 ctx.font = 'bold 16px Arial';
45 ctx.fillText(item.title, 40, y + height/2);
46};
🚀 完整示例代码
1<!DOCTYPE html>
2<html lang="zh-CN">
3<head>
4 <meta charset="UTF-8">
5 <title>Canvas虚拟列表完整示例</title>
6 <style>
7 .list-container {
8 position: relative;
9 width: 800px;
10 height: 600px;
11 margin: 20px auto;
12 border: 1px solid #e2e8f0;
13 border-radius: 8px;
14 overflow: hidden;
15 }
16
17 #listCanvas {
18 display: block;
19 cursor: pointer;
20 }
21
22 .scrollbar {
23 position: absolute;
24 right: 0;
25 top: 0;
26 width: 12px;
27 height: 100%;
28 background: #f3f4f6;
29 }
30
31 .scrollbar-thumb {
32 position: absolute;
33 width: 100%;
34 background: #9ca3af;
35 border-radius: 6px;
36 cursor: pointer;
37 }
38 </style>
39</head>
40<body>
41 <div class="list-container">
42 <canvas id="listCanvas"></canvas>
43 <div class="scrollbar">
44 <div class="scrollbar-thumb"></div>
45 </div>
46 </div>
47
48 <script>
49
50
51
52 const canvas = document.getElementById('listCanvas');
53 const virtualList = new CanvasVirtualList(canvas, {
54 itemHeight: 50,
55 padding: 15,
56 fontSize: 14
57 });
58
59
60 const data = Array.from({length: 100000}, (_, i) => ({
61 id: i,
62 title: `列表项 ${i + 1}`,
63 subtitle: `这是第${i + 1}个项目的描述信息`,
64 value: Math.floor(Math.random() * 1000)
65 }));
66
67 virtualList.setData(data);
68 </script>
69</body>
70</html>
个人笔记记录 2021 ~ 2025