🎯 方案背景

在处理大数据量列表渲染时,传统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虚拟列表方案通过以下核心技术实现了极致性能:

  1. 零DOM操作:完全基于Canvas绘制,避免DOM性能瓶颈
  2. 虚拟滚动:只渲染可视区域,与数据量无关的O(1)复杂度
  3. 高DPI适配:完美支持Retina等高分辨率屏幕
  4. 事件映射:精确的坐标到数据项的映射算法
  5. 内存优化:无DOM节点创建,内存占用极低
  6. 性能监控:实时性能指标,便于优化调试

这套方案为大数据量列表渲染提供了终极解决方案,特别适合企业级应用中的数据展示场景。通过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