阅读视图

发现新文章,点击刷新页面。

Canvas高性能Table架构深度解析

表格组件是数据展示的核心组件之一。传统的DOM表格在处理大量数据时往往面临性能瓶颈,而基于Canvas的虚拟表格则能够在保持流畅交互的同时处理海量数据。

最近刷论坛发现了一个小而美的基于canvas的表格库(e-virt-table),于是研究了一下具体实现原理。

整体架构设计

核心类结构

EVirtTable作为整个表格系统的入口类,采用了模块化的设计思路:

export default class EVirtTable {
    private scroller: Scroller;      // 滚动控制
    private header: Header;          // 表头管理
    private body: Body;              // 表体渲染
    private footer: Footer;          // 表尾处理
    private selector: Selector;      // 选择器
    private autofill: Autofill;      // 自动填充
    private tooltip: Tooltip;        // 提示框
    private editor: Editor;          // 编辑器
    private empty: Empty;            // 空状态
    private overlayer: Overlayer;    // 覆盖层
    private contextMenu: ContextMenu; // 右键菜单
    private loading: Loading;        // 加载状态
    ctx: Context;                    // 上下文管理
}

这种设计将复杂的表格功能拆分为独立的模块,每个模块专注于特定的职责

高性能优化策略

1. 帧调度与绘制节流

采用了智能的绘制调度机制来避免不必要的重绘:

// 节流绘制表格
this.ctx.on('draw', throttle(() => {
    this.draw();
}, () => this.ctx.drawTime));

// 节流绘制视图
this.ctx.on('drawView', throttle(() => {
    this.draw(true);
}, () => this.ctx.drawTime));

draw 方法使用 requestAnimationFrame 进行帧同步,并将绘制流程和自动高度计算拆分到不同帧执行:

draw(ignoreOverlayer = false) {
    requestAnimationFrame(() => {
        const startTime = performance.now();
        // 第一帧:核心绘制
        this.header.update();
        this.footer.update();
        this.body.update();
        this.ctx.paint.clear();
        this.body.draw();
        this.footer.draw();
        this.header.draw();
        this.scroller.draw();
        
        if (!ignoreOverlayer) {
            this.overlayer.draw();
        }
        
        // 第二帧:自动高度计算,避免影响首帧性能
        requestAnimationFrame(() => {
            this.body.updateAutoHeight();
        });
        
        // 性能监控
        const endTime = performance.now();
        const drawTime = Math.round(endTime - startTime);
        this.ctx.drawTime = drawTime * this.ctx.config.DRAW_TIME_MULTIPLIER;
    });
}

2. 虚拟滚动与可见性裁剪

行级虚拟化

Body.ts 中的 update 方法使用二分查找算法快速定位可视区域内的行:

// 基于滚动位置计算可见行范围
const startRowIndex = this.getStartRowIndex();
const endRowIndex = this.getEndRowIndex();
this.renderRows = this.data.slice(startRowIndex, endRowIndex + 1);

列级可见性判断

Header.ts的 update 方法过滤出可见的列:

// 筛选可见的表头单元格
this.renderCenterCellHeaders = this.centerCellHeaders.filter(item => 
    item.isHorizontalVisible() && item.isVerticalVisible()
);
this.renderFixedCellHeaders = this.fixedCellHeaders.filter(item => 
    item.isHorizontalVisible() && item.isVerticalVisible()
);

3. 分层绘制与遮盖控制

采用分层绘制策略最小化过度绘制:

// Body.ts - 分层绘制顺序
draw() {
    this.renderRows.forEach(row => {
        // 1. 绘制非固定单元格容器
        row.drawContainer();
        // 2. 绘制非固定单元格内容
        row.drawCenter();
        // 3. 绘制固定单元格容器
        row.drawFixedContainer();
        // 4. 绘制固定单元格内容
        row.drawFixed();
    });
    // 5. 绘制固定列阴影
    this.drawFixedShadow();
    // 6. 绘制提示线
    this.drawTipLine();
}

4. 文本测量与缓存优化

文本分行缓存

Paint类维护了文本缓存映射:

export class Paint {
    private textCacheMap = new Map<string, string[]>();
    
    private wrapText(text: string, maxWidth: number, cacheTextKey = ''): string[] {
        // 缓存命中检查
        if (cacheTextKey && this.textCacheMap.has(cacheTextKey)) {
            return this.textCacheMap.get(cacheTextKey) || [''];
        }
        
        // 文本分行计算
        const lines = this.calculateTextLines(text, maxWidth);
        
        // 缓存结果
        if (cacheTextKey) {
            this.textCacheMap.set(cacheTextKey, lines);
        }
        
        return lines;
    }
}

5. 高DPI与像素对齐

Canvas缩放处理

Body.ts中的初始化方法处理高DPI显示:

init() {
    const dpr = window.devicePixelRatio || 1;
    const canvasWidth = this.ctx.stageWidth * dpr;
    const canvasHeight = this.ctx.stageHeight * dpr;
    
    // 设置Canvas实际尺寸
    canvasElement.width = Math.round(canvasWidth);
    canvasElement.height = Math.round(canvasHeight);
    
    // 设置CSS显示尺寸
    const cssWidth = Math.round((canvasElement.width / dpr) * 10000) / 10000;
    const cssHeight = Math.round((canvasElement.height / dpr) * 10000) / 10000;
    this.ctx.canvasElement.setAttribute('style', `height:${cssHeight}px;width:${cssWidth}px;`);
    
    // 缩放绘制上下文
    this.ctx.paint.scale(dpr);
}

像素对齐优化

Paint.ts中的绘制方法使用 -0.5 位移对齐像素网格:

drawRect(x: number, y: number, width: number, height: number, options: RectOptions) {
    // -0.5 解决1px边框模糊问题
    this.ctx.rect(x - 0.5, y - 0.5, width, height);
}

drawLine(points: number[], options: LineOptions) {
    this.ctx.moveTo(points[0] - 0.5, points[1] - 0.5);
    for (let i = 2; i < points.length; i += 2) {
        this.ctx.lineTo(points[i] - 0.5, points[i + 1] - 0.5);
    }
}

6. 覆盖层与DOM最小化

采用Canvas主绘制 + DOM覆盖层的混合架构:

// 主要视觉内容在Canvas上绘制
this.body.draw();
this.footer.draw();
this.header.draw();

// 覆盖层DOM仅在需要时渲染
if (!ignoreOverlayer) {
    this.overlayer.draw(); // 限频更新
}

draw(true) 路径跳过覆盖层绘制,降低交互时的卡顿风险。

7. 数据层映射缓存

Database类维护了多种映射缓存:

export default class Database {
    private rowKeyMap = new Map<string, any>();           // 行键映射
    private colIndexKeyMap = new Map<number, string>();   // 列索引映射
    private headerMap = new Map<string, CellHeader>();    // 表头映射
    private rowIndexRowKeyMap = new Map<number, string>(); // 行索引到行键
    private rowKeyRowIndexMap = new Map<string, number>(); // 行键到行索引
    private selectionMap = new Map<string, SelectionMap>(); // 选择状态
    private expandMap = new Map<string, boolean>();       // 展开状态
    private validationErrorMap = new Map<string, ValidateResult>(); // 验证错误
    private positions: Position[] = [];                   // 虚拟滚动位置
}

这些缓存大幅提高了数据查询效率,避免了重复计算。

8. 图标预处理与缓存

Icons类在首次启动时将SVG转换为 HTMLImageElement 并缓存:

createImageFromSVG(svgContent: string, color: string): HTMLImageElement {
    const cacheKey = `${svgContent}_${color}`;
    
    if (this.icons.has(cacheKey)) {
        return this.icons.get(cacheKey)!;
    }
    
    // SVG转换为Image对象
    const img = new Image();
    const svgBlob = new Blob([coloredSvg], { type: 'image/svg+xml' });
    const url = URL.createObjectURL(svgBlob);
    img.src = url;
    
    // 缓存结果
    this.icons.set(cacheKey, img);
    return img;
}

绘制时直接使用 drawImage 方法,避免重复解析SVG。

性能监控与自适应调优

动态节流调整

根据实际绘制耗时动态调整节流延迟:

const drawTime = Math.round(endTime - startTime);
this.ctx.drawTime = drawTime * this.ctx.config.DRAW_TIME_MULTIPLIER;

throttle函数支持动态延迟计算:

export function throttle(func: Function, delayFunc: () => number) {
    let lastCalledTime = 0;
    let timeoutId: number | null = null;
    
    return function(...args: any[]) {
        const now = Date.now();
        const delay = delayFunc(); // 动态计算延迟
        
        if (now - lastCalledTime >= delay) {
            func.apply(this, args);
            lastCalledTime = now;
        } else if (!timeoutId) {
            timeoutId = window.setTimeout(() => {
                func.apply(this, args);
                lastCalledTime = Date.now();
                timeoutId = null;
            }, delay - (now - lastCalledTime));
        }
    };
}

这个Canvas高性能表格架构通过以下核心优化实现了卓越的性能:

  1. 虚拟滚动:只渲染可见区域,支持海量数据
  2. 分层绘制:最小化过度绘制,提高渲染效率
  3. 智能缓存:文本、图标、数据映射多层缓存
  4. 帧调度:自适应节流控制,保证交互流畅性
  5. 高DPI优化:像素对齐,保证清晰度
  6. 混合架构:Canvas绘制 + DOM覆盖层,兼顾性能与功能

Canvas实现协同电影选座

技术栈

React + Canvas + Socket.IO + Express

效果图

image.png

架构设计

┌─────────────────┐    WebSocket    ┌─────────────────┐
│   React Client  │ ←──────────────→ │  Express Server │
│                 │                 │                 │
│ ┌─────────────┐ │                 │ ┌─────────────┐ │
│ │   Canvas    │ │                 │ │  Socket.IO  │ │
│ │  Renderer   │ │                 │ │   Handler   │ │
│ └─────────────┘ │                 │ └─────────────┘ │
│                 │                 │                 │
│ ┌─────────────┐ │                 │ ┌─────────────┐ │
│ │ Socket.IO   │ │                 │ │ Seat Data   │ │
│ │   Client    │ │                 │ │  Manager    │ │
│ └─────────────┘ │                 │ └─────────────┘ │
└─────────────────┘                 └─────────────────┘

核心功能实现

1. Canvas渲染

设备像素比适配

现代设备的高分辨率屏幕(如Retina显示器)会导致Canvas绘制的图像显得模糊。我们通过适配设备像素比来解决这个问题:

const drawSeatMap = useCallback(() => {
  if (!seatData || !canvasRef.current) return;

  const canvas = canvasRef.current;
  const ctx = canvas.getContext('2d');
  
  // 获取设备像素比
  const dpr = window.devicePixelRatio || 1;
  
  // 获取Canvas的显示尺寸
  const rect = canvas.getBoundingClientRect();
  const displayWidth = rect.width;
  const displayHeight = rect.height;
  
  // 设置Canvas的实际像素尺寸
  canvas.width = displayWidth * dpr;
  canvas.height = displayHeight * dpr;
  
  // 缩放绘图上下文以匹配设备像素比
  ctx.scale(dpr, dpr);
  
  // 设置Canvas的CSS尺寸
  canvas.style.width = displayWidth + 'px';
  canvas.style.height = displayHeight + 'px';
}, [seatData, hoveredSeat, userId]);

这种处理方式确保了在各种设备上都能获得清晰的显示效果。

座位布局

采用了符合真实影院布局的设计:

// 座位配置常量
const SEAT_SIZE = 30;        // 座位大小
const SEAT_SPACING = 35;     // 座位间距
const ROW_SPACING = 40;      // 行间距
const CANVAS_PADDING = 50;   // 画布边距
const AISLE_WIDTH = 20;      // 过道宽度

// 绘制单个座位
const drawSeat = (ctx, row, seat, seatInfo, seatId) => {
  // 计算座位位置,第6座后添加过道
  const x = CANVAS_PADDING + seat * SEAT_SPACING + (seat >= 6 ? AISLE_WIDTH : 0);
  const y = CANVAS_PADDING + 60 + row * ROW_SPACING;

  // 根据座位状态确定颜色
  let color = SEAT_COLORS.available;
  if (seatInfo.status === 'occupied') {
    color = SEAT_COLORS.occupied;
  } else if (seatInfo.status === 'selected') {
    color = seatInfo.selectedBy === userId ? 
      SEAT_COLORS.selected : SEAT_COLORS.selectedByOther;
  } else if (hoveredSeat === seatId) {
    color = SEAT_COLORS.hover;
  }

  // 绘制圆角矩形座位
  ctx.fillStyle = color;
  ctx.beginPath();
  ctx.roundRect(x, y, SEAT_SIZE, SEAT_SIZE, 5);
  ctx.fill();
};

多状态定义

定义了五种座位状态,每种状态都有独特的视觉表现:

const SEAT_COLORS = {
  available: '#4CAF50',      // 可选 - 绿色
  selected: '#2196F3',       // 已选 - 蓝色
  occupied: '#F44336',       // 已售 - 红色
  selectedByOther: '#FF9800', // 他人已选 - 橙色
  hover: '#81C784'           // 悬停 - 浅绿色
};

2. 实时协作

WebSocket通信

使用Socket.IO实现双向实时通信:

客户端连接管理:

useEffect(() => {
  const newSocket = io('http://localhost:3001');
  setSocket(newSocket);

  // 接收初始座位数据
  newSocket.on('seatData', (data) => {
    setSeatData(data);
  });

  // 监听座位状态更新
  newSocket.on('seatUpdated', ({ seatId, seat }) => {
    setSeatData(prev => ({
      ...prev,
      seats: {
        ...prev.seats,
        [seatId]: seat
      }
    }));
  });

  return () => newSocket.close();
}, []);

服务端事件处理:

io.on('connection', (socket) => {
  console.log('User connected:', socket.id);
  
  // 发送当前座位数据
  socket.emit('seatData', seatData);
  
  // 处理座位选择
  socket.on('selectSeat', (data) => {
    const { seatId, userId } = data;
    const seat = seatData.seats[seatId];
    
    // 业务逻辑验证
    if (seat.status === 'occupied') {
      socket.emit('error', { message: 'Seat is already occupied' });
      return;
    }
    
    // 切换选择状态
    if (seat.status === 'selected' && seat.selectedBy === userId) {
      seat.status = 'available';
      seat.selectedBy = null;
    } else {
      seat.status = 'selected';
      seat.selectedBy = userId;
    }
    
    // 广播给所有客户端
    io.emit('seatUpdated', { seatId, seat });
  });
});

用户会话管理

每个用户获得唯一标识符,确保座位选择的准确归属:

const [userId] = useState(() => Math.random().toString(36).substr(2, 9));

当用户断开连接时,系统自动清理其选择的座位:

socket.on('disconnect', () => {
  console.log('User disconnected:', socket.id);
  
  // 清除该用户选择的座位
  Object.keys(seatData.seats).forEach(seatId => {
    const seat = seatData.seats[seatId];
    if (seat.status === 'selected' && seat.selectedBy === socket.id) {
      seat.status = 'available';
      seat.selectedBy = null;
      io.emit('seatUpdated', { seatId, seat });
    }
  });
});

3. 交互

像素级点击检测

实现了精确的鼠标事件处理,支持像素级的点击检测:

const handleCanvasClick = (event) => {
  if (!seatData || !socket) return;

  const canvas = canvasRef.current;
  const rect = canvas.getBoundingClientRect();
  const x = event.clientX - rect.left;
  const y = event.clientY - rect.top;

  // 遍历所有座位进行碰撞检测
  for (let row = 0; row < seatData.rows; row++) {
    for (let seat = 0; seat < seatData.seatsPerRow; seat++) {
      const seatX = CANVAS_PADDING + seat * SEAT_SPACING + (seat >= 6 ? AISLE_WIDTH : 0);
      const seatY = CANVAS_PADDING + 60 + row * ROW_SPACING;

      if (x >= seatX && x <= seatX + SEAT_SIZE && 
          y >= seatY && y <= seatY + SEAT_SIZE) {
        const seatId = `${row}-${seat}`;
        const seatInfo = seatData.seats[seatId];

        if (seatInfo && seatInfo.status !== 'occupied') {
          socket.emit('selectSeat', { seatId, userId });
        }
        return;
      }
    }
  }
};

实时悬停效果

实现了流畅的鼠标悬停效果,提供即时的视觉反馈:

const handleCanvasMouseMove = (event) => {
  if (!seatData) return;

  const canvas = canvasRef.current;
  const rect = canvas.getBoundingClientRect();
  const x = event.clientX - rect.left;
  const y = event.clientY - rect.top;

  let foundSeat = null;

  // 查找鼠标悬停的座位
  for (let row = 0; row < seatData.rows; row++) {
    for (let seat = 0; seat < seatData.seatsPerRow; seat++) {
      const seatX = CANVAS_PADDING + seat * SEAT_SPACING + (seat >= 6 ? AISLE_WIDTH : 0);
      const seatY = CANVAS_PADDING + 60 + row * ROW_SPACING;

      if (x >= seatX && x <= seatX + SEAT_SIZE && 
          y >= seatY && y <= seatY + SEAT_SIZE) {
        foundSeat = `${row}-${seat}`;
        break;
      }
    }
    if (foundSeat) break;
  }

  if (foundSeat !== hoveredSeat) {
    setHoveredSeat(foundSeat);
  }
};

4. 数据管理与API

RESTful API

// 获取座位数据
app.get('/api/seats', (req, res) => {
  res.json(seatData);
});

// 选择座位
app.post('/api/seats/select', (req, res) => {
  const { seatId, userId } = req.body;
  // 业务逻辑处理...
});

// 购买座位
app.post('/api/seats/book', (req, res) => {
  const { seatIds, userId } = req.body;
  
  const bookedSeats = [];
  const errors = [];
  
  seatIds.forEach(seatId => {
    const seat = seatData.seats[seatId];
    if (seat.status === 'selected' && seat.selectedBy === userId) {
      seat.status = 'occupied';
      seat.selectedBy = null;
      bookedSeats.push(seat);
    } else {
      errors.push(`Seat ${seatId} is not selected by you`);
    }
  });
  
  if (errors.length > 0) {
    return res.status(400).json({ errors });
  }
  
  res.json({ success: true, bookedSeats });
});

状态管理策略

采用React Hooks进行客户端状态管理:

const [seatData, setSeatData] = useState(null);
const [socket, setSocket] = useState(null);
const [hoveredSeat, setHoveredSeat] = useState(null);

// 获取选中座位信息
const getSelectedSeatsInfo = () => {
  if (!seatData) return [];
  
  return Object.values(seatData.seats)
    .filter(seat => seat.status === 'selected' && seat.selectedBy === userId)
    .sort((a, b) => a.row - b.row || a.seat - b.seat);
};
❌