Canvas 高性能表格组件技术详解

0 阅读20分钟

Canvas 高性能表格组件技术详解

前言

在前端开发中,当需要渲染大量数据的表格时,传统的 DOM 渲染方案会遇到严重的性能瓶颈。每个单元格都是一个 DOM 节点,当数据量达到数万行时,页面会变得卡顿甚至崩溃。

本文将深入剖析一个基于 Canvas 的高性能虚拟表格组件 —— CanvasTable,带你理解其设计原理、实现细节和性能优化策略。


一、为什么选择 Canvas 渲染表格?

1.1 DOM 方案的瓶颈

传统 DOM 表格渲染存在以下问题:

问题说明
DOM 节点过多10000 行 × 10 列 = 100000 个 DOM 节点
内存占用高每个 DOM 节点都携带大量属性和方法
重排重绘代价大滚动时频繁触发布局计算
事件绑定开销需要为大量元素绑定事件或使用事件委托

1.2 Canvas 方案的优势

Canvas 本质上是一块「画布」,所有内容都是像素级绘制:

  • 单一 DOM 节点:不管多少数据,Canvas 始终是一个 <canvas> 元素
  • 按需绘制:只绘制可视区域内的单元格(虚拟滚动)
  • 高效更新:通过 requestAnimationFrame 批量更新
  • 分层渲染:不同更新频率的内容分开绘制,减少重绘范围

1.3 架构设计图

┌─────────────────────────────────────────────────────────┐
│                    CanvasTable 组件                      │
├─────────────────────────────────────────────────────────┤
│  ┌─────────────────────────────────────────────────┐   │
│  │             Interaction Layer (z-index: 3)       │   │
│  │             选中框、Hover 高亮效果               │   │
│  ├─────────────────────────────────────────────────┤   │
│  │             Content Layer (z-index: 2)           │   │
│  │             单元格文本、表头、冻结列             │   │
│  ├─────────────────────────────────────────────────┤   │
│  │             Base Layer (z-index: 1)              │   │
│  │             背景色、斑马纹、网格线               │   │
│  └─────────────────────────────────────────────────┘   │
│  ┌─────────────────────────────────────────────────┐   │
│  │             Scroll Container (z-index: 4)        │   │
│  │             滚动事件捕获 + 原生滚动条            │   │
│  └─────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘

二、Template 结构解析

<template>
    <div class="canvas-container">
        <div ref="containerRef" class="canvas-table-container" :style="containerStyle">
            <!-- 底层 Canvas:背景、网格线(很少变化) -->
            <canvas ref="baseCanvasRef" class="layer-base" />
​
            <!-- 中层 Canvas:单元格内容(滚动时更新) -->
            <canvas ref="contentCanvasRef" class="layer-content" />
​
            <!-- 顶层 Canvas:选区、hover(频繁更新) -->
            <canvas ref="interactionCanvasRef" class="layer-interaction" />
​
            <!-- 滚动占位层(产生原生滚动条) -->
            <div ref="scrollerRef" class="scroll-container" @scroll="handleScroll">
                <div class="scroll-phantom" :style="phantomStyle" />
            </div>
​
            <!-- 编辑输入框(点击单元格时显示) -->
            <input v-if="editingCell" ref="editorRef" v-model="editValue" 
                   class="cell-editor" :style="editorStyle" />
        </div>
    </div>
</template>

2.1 四层结构详解

层级元素职责z-index
Base Layer<canvas class="layer-base">绘制背景、斑马纹、网格线1
Content Layer<canvas class="layer-content">绘制单元格内容、表头、冻结列2
Interaction Layer<canvas class="layer-interaction">绘制选中效果、悬停高亮3
Scroll Container<div class="scroll-container">捕获滚动事件、显示原生滚动条4

2.2 滚动幽灵层原理

Canvas 本身不会产生滚动条,我们需要一个「幽灵层」来撑开滚动区域:

<div ref="scrollerRef" class="scroll-container">
    <div class="scroll-phantom" :style="phantomStyle" />
</div>
  • scroll-container: 设置 overflow: auto,会根据内部内容产生滚动条
  • scroll-phantom: 一个空的 div,其尺寸等于表格的总宽高(totalWidth × totalHeight
  • 用户滚动时,我们监听 scroll 事件,获取 scrollLeftscrollTop,然后重新绘制 Canvas
const phantomStyle = computed(() => ({
    width: `${totalWidth.value}px`,   // 所有列宽之和
    height: `${totalHeight.value}px`  // 数据行数 × 行高 + 表头高度
}))

2.3 为什么使用原生滚动条?

  • 一致的用户体验:原生滚动条符合用户习惯
  • 移动端支持:原生滚动支持触摸滑动和惯性滚动
  • 无需自实现:不用手动计算滚动条位置和拖拽逻辑

三、Props 与类型定义

3.1 Column 接口

interface Column {
    key: string              // 数据字段名
    title: string            // 表头显示文字
    width: number            // 列宽(像素)
    align?: 'left' | 'center' | 'right'  // 文本对齐方式
    formatter?: (value: any, row: any) => string  // 格式化函数
    editable?: boolean       // 是否可编辑
}

3.2 Props 参数说明

参数类型默认值说明
columnsColumn[]-列配置数组(必填)
dataany[]-数据源(必填)
widthnumber800表格宽度
heightnumber400表格高度
rowHeightnumber36行高
headerHeightnumber40表头高度
frozenColumnsnumber1冻结列数量
themePartial<ThemeConfig>{}主题配置

3.3 ThemeConfig 主题配置

interface ThemeConfig {
    headerBgColor: string    // 表头背景色,默认 #f5f7fa
    headerTextColor: string  // 表头文字颜色,默认 #606266
    cellBgColor: string      // 单元格背景色,默认 #ffffff
    cellTextColor: string    // 单元格文字颜色,默认 #303133
    borderColor: string      // 边框颜色,默认 #ebeef5
    hoverColor: string       // 悬停颜色,默认 rgba(64, 158, 255, 0.1)
    selectedColor: string    // 选中颜色,默认 rgba(64, 158, 255, 0.15)
}

四、计算属性详解

4.1 totalWidth - 总宽度

const totalWidth = computed(() =>
    props.columns.reduce((sum, col) => sum + col.width, 0)
)

将所有列的宽度累加,得到表格的总宽度。这个值用于:

  • 设置幽灵层的宽度
  • 计算水平滚动范围

4.2 totalHeight - 总高度

const totalHeight = computed(() =>
    props.data.length * props.rowHeight + props.headerHeight
)

计算公式:数据行数 × 行高 + 表头高度

4.3 frozenWidth - 冻结列宽度

const frozenWidth = computed(() => {
    let width = 0
    for (let i = 0; i < props.frozenColumns && i < props.columns.length; i++) {
        const column = props.columns[i]
        if (column) width += column.width
    }
    return width
})

冻结列的总宽度,用于:

  • 确定冻结区域的绘制范围
  • 判断点击位置是否在冻结区域

4.4 editorStyle - 编辑框定位

const editorStyle = computed(() => {
    if (!editingCell.value) return {}
​
    const { row, col } = editingCell.value
    const column = props.columns[col]
    if (!column) return {}
​
    let x = getColumnX(col)
    const y = row * props.rowHeight + props.headerHeight
​
    // 冻结列外的单元格需要减去滚动偏移
    if (col >= props.frozenColumns) {
        x -= scrollLeft.value
    }
​
    return {
        left: `${x}px`,
        top: `${y - scrollTop.value}px`,
        width: `${column.width}px`,
        height: `${props.rowHeight}px`
    }
})

编辑框需要精确覆盖在被编辑的单元格上方,关键点:

  • 计算单元格的 X 坐标(通过 getColumnX
  • 计算单元格的 Y 坐标(行号 × 行高 + 表头高度
  • 非冻结列需要减去 scrollLeft,所有行需要减去 scrollTop

五、生命周期与初始化

5.1 Canvas 初始化

onMounted(() => {
    if (!containerRef.value) return
​
    dpr = window.devicePixelRatio || 1
​
    initCanvas()
    bindEvents()
    renderAll()
})
高清屏适配
function initCanvas() {
    const canvases = [
        { ref: baseCanvasRef.value, key: 'base' as const },
        { ref: contentCanvasRef.value, key: 'content' as const },
        { ref: interactionCanvasRef.value, key: 'interaction' as const }
    ]
​
    canvases.forEach(({ ref: canvas, key }) => {
        if (!canvas) return
​
        // 设置 Canvas 实际尺寸(物理像素)
        canvas.width = props.width * dpr
        canvas.height = props.height * dpr
​
        // 设置 Canvas 显示尺寸(CSS 像素)
        canvas.style.width = `${props.width}px`
        canvas.style.height = `${props.height}px`
​
        const ctx = canvas.getContext('2d')
        if (ctx) {
            // 缩放上下文,使绘制内容自动适配高清屏
            ctx.scale(dpr, dpr)
            contexts[key] = ctx
        }
    })
}

为什么需要这样处理?

在 Retina 等高清屏上,devicePixelRatio 通常为 2 或 3。如果 Canvas 的实际尺寸等于 CSS 尺寸,内容会显得模糊。解决方案:

  1. Canvas 的 width/height 属性设为 CSS 尺寸的 dpr
  2. 通过 CSS 将 Canvas 缩小到原始尺寸
  3. 在绑定上下文时 scale(dpr, dpr),这样绘制代码无需关心 dpr

5.2 事件绑定与解绑

function bindEvents() {
    const scroller = scrollerRef.value
    if (!scroller) return
​
    scroller.addEventListener('click', handleClick)
    scroller.addEventListener('dblclick', handleDoubleClick)
    scroller.addEventListener('mousemove', handleMouseMove)
    scroller.addEventListener('mouseleave', handleMouseLeave)
}
​
function unbindEvents() {
    const scroller = scrollerRef.value
    if (!scroller) return
​
    scroller.removeEventListener('click', handleClick)
    scroller.removeEventListener('dblclick', handleDoubleClick)
    scroller.removeEventListener('mousemove', handleMouseMove)
    scroller.removeEventListener('mouseleave', handleMouseLeave)
}

重要: 在 onUnmounted 中必须解绑事件,否则会造成内存泄漏。

5.3 数据监听

watch(() => props.data, () => {
    markDirty('base', 'content', 'interaction')
    requestRender()
}, { deep: true })
​
watch(() => props.columns, () => {
    markDirty('base', 'content', 'interaction')
    requestRender()
}, { deep: true })

当数据或列配置变化时,标记所有层为「脏」,请求重新渲染。


六、核心渲染方法详解

6.1 渲染流程图

        ┌──────────────────────┐
        │   requestRender()    │
        │  (RAF 节流控制)       │
        └──────────┬───────────┘
                   │
                   ▼
        ┌──────────────────────┐
        │     renderAll()      │
        └──────────┬───────────┘
                   │
                   ▼
        ┌──────────────────────┐
        │ calculateVisibleRange│
        │   计算可视范围        │
        └──────────┬───────────┘
                   │
        ┌──────────┼──────────┐
        │          │          │
        ▼          ▼          ▼
   ┌─────────┐ ┌─────────┐ ┌─────────────┐
   │ Base    │ │ Content │ │ Interaction │
   │ Layer   │ │ Layer   │ │ Layer       │
   ├─────────┤ ├─────────┤ ├─────────────┤
   │背景     │ │单元格   │ │选中框       │
   │网格线   │ │表头     │ │悬停高亮     │
   │         │ │冻结列   │ │             │
   └─────────┘ └─────────┘ └─────────────┘

6.2 脏标记与按需渲染

const dirtyFlags = {
    base: true,
    content: true,
    interaction: true
}
​
function markDirty(...layers: Array<'base' | 'content' | 'interaction'>) {
    layers.forEach(layer => {
        dirtyFlags[layer] = true
    })
}
​
function renderAll() {
    const range = calculateVisibleRange()
​
    if (dirtyFlags.base) {
        renderBaseLayer(range)
        dirtyFlags.base = false
    }
​
    if (dirtyFlags.content) {
        renderContentLayer(range)
        dirtyFlags.content = false
    }
​
    if (dirtyFlags.interaction) {
        renderInteractionLayer()
        dirtyFlags.interaction = false
    }
}

不同操作触发不同层的重绘

操作BaseContentInteraction
初始化
滚动
数据变化
鼠标悬停--
选中单元格--
编辑完成--

6.3 calculateVisibleRange - 可视范围计算

function calculateVisibleRange(): VisibleRange {
    const { rowHeight, headerHeight, columns, data } = props
    const bodyHeight = props.height - headerHeight
​
    // 行范围计算
    const startRow = Math.floor(scrollTop.value / rowHeight)
    const endRow = Math.min(
        startRow + Math.ceil(bodyHeight / rowHeight) + 2,  // +2 作为缓冲
        data.length
    )
​
    // 列范围计算
    let accWidth = 0
    let startCol = 0
    let endCol = columns.length
​
    for (let i = 0; i < columns.length; i++) {
        const column = columns[i]
        if (!column) continue
​
        const colWidth = column.width
​
        // 找到第一个可见列
        if (accWidth + colWidth > scrollLeft.value && startCol === 0) {
            startCol = Math.max(0, i - 1)  // -1 作为缓冲
        }
​
        // 找到最后一个可见列
        if (accWidth > scrollLeft.value + props.width) {
            endCol = i + 1
            break
        }
​
        accWidth += colWidth
    }
​
    return { startRow, endRow, startCol, endCol }
}

计算原理图解

        scrollTop
            │
            ▼
   ┌────────────────────────────────┐
   │        不可见区域               │ row 0 ~ startRow-1
   ├────────────────────────────────┤ ◄── startRow
   │                                │
   │        可视区域                 │ 需要绘制的行
   │        (bodyHeight)            │
   │                                │
   ├────────────────────────────────┤ ◄── endRow
   │        不可见区域               │ endRow ~ data.length
   └────────────────────────────────┘
​
   scrollLeft
       │
       ▼
  ┌────┬────────────────────┬────┐
  │不可│      可视区域       │不可│
  │见  │     (props.width)   │见  │
  │    │                    │    │
  └────┴────────────────────┴────┘
   col col                   col
   0~  startCol              endCol~
   start-1                   length

6.4 renderBaseLayer - 底层渲染

function renderBaseLayer(range: VisibleRange) {
    const ctx = contexts.base
    if (!ctx) return
​
    ctx.clearRect(0, 0, props.width, props.height)
​
    drawBackground(ctx, range)
    drawGridLines(ctx, range)
}
背景绘制(含斑马纹)
function drawBackground(ctx: CanvasRenderingContext2D, range: VisibleRange) {
    const { startRow, endRow, startCol, endCol } = range
    const { rowHeight, headerHeight, columns } = props
​
    for (let row = startRow; row < endRow; row++) {
        let x = getColumnX(startCol) - scrollLeft.value
        const y = row * rowHeight + headerHeight - scrollTop.value
​
        // 奇偶行不同颜色,形成斑马纹
        const bgColor = row % 2 === 0 ? theme.value.cellBgColor : ZEBRA_COLOR
​
        for (let col = startCol; col < endCol; col++) {
            const column = columns[col]
            if (!column) continue
​
            ctx.fillStyle = bgColor
            ctx.fillRect(x, y, column.width, rowHeight)
            x += column.width
        }
    }
}
网格线绘制
function drawGridLines(ctx: CanvasRenderingContext2D, range: VisibleRange) {
    const { startRow, endRow, startCol, endCol } = range
    const { rowHeight, headerHeight, columns } = props
​
    ctx.strokeStyle = theme.value.borderColor
    ctx.lineWidth = 1
​
    // 水平线:每行底部画一条线
    ctx.beginPath()
    for (let row = startRow; row <= endRow; row++) {
        // +0.5 确保线条在像素边界上,避免模糊
        const y = row * rowHeight + headerHeight - scrollTop.value + 0.5
        ctx.moveTo(0, y)
        ctx.lineTo(props.width, y)
    }
    ctx.stroke()
​
    // 垂直线:每列右侧画一条线
    ctx.beginPath()
    let x = getColumnX(startCol) - scrollLeft.value + 0.5
    for (let col = startCol; col <= endCol; col++) {
        ctx.moveTo(x, headerHeight)
        ctx.lineTo(x, props.height)
        const column = columns[col]
        x += column?.width || 0
    }
    ctx.stroke()
}

为什么要 +0.5?

Canvas 的坐标系是以像素中心为原点的。当你在整数坐标画 1px 的线时,线条会横跨两个像素,导致模糊。加 0.5 后,线条正好落在像素边界上,显得清晰。

整数坐标(模糊):      +0.5 坐标(清晰):
   │▓▓│                    │██│
   │▓▓│                    │  │

6.5 renderContentLayer - 内容层渲染

function renderContentLayer(range: VisibleRange) {
    const ctx = contexts.content
    if (!ctx) return
​
    ctx.clearRect(0, 0, props.width, props.height)
​
    drawCells(ctx, range)
    drawHeader(ctx, range)
​
    if (props.frozenColumns > 0) {
        drawFrozenColumns(ctx, range)
    }
}
单元格内容绘制
function drawCells(ctx: CanvasRenderingContext2D, range: VisibleRange) {
    const { startRow, endRow, startCol, endCol } = range
    const { rowHeight, headerHeight, columns, data } = props
​
    ctx.fillStyle = theme.value.cellTextColor
    ctx.font = DEFAULT_FONT
    ctx.textBaseline = 'middle'  // 垂直居中
​
    for (let row = startRow; row < endRow; row++) {
        const rowData = data[row]
        if (!rowData) continue
​
        let x = getColumnX(startCol) - scrollLeft.value
​
        for (let col = startCol; col < endCol; col++) {
            const column = columns[col]
            if (!column) continue
​
            const value = rowData[column.key]
            const cellY = row * rowHeight + headerHeight - scrollTop.value
​
            // 获取显示文本
            let displayText = value?.toString() || ''
            if (column.formatter) {
                displayText = column.formatter(value, rowData)
            }
​
            // 文本截断
            displayText = ellipsisText(ctx, displayText, column.width - CELL_PADDING * 2)
​
            // 计算文本 X 坐标
            ctx.fillStyle = theme.value.cellTextColor
            const textX = getTextX(x, column.width, column.align)
            const textY = cellY + rowHeight / 2  // 垂直居中
​
            ctx.textAlign = column.align || 'left'
            ctx.fillText(displayText, textX, textY)
​
            x += column.width
        }
    }
}
表头绘制
function drawHeader(ctx: CanvasRenderingContext2D, range: VisibleRange) {
    const { startCol, endCol } = range
    const { headerHeight, columns } = props
​
    // 背景
    ctx.fillStyle = theme.value.headerBgColor
    ctx.fillRect(0, 0, props.width, headerHeight)
​
    // 文字
    ctx.fillStyle = theme.value.headerTextColor
    ctx.font = HEADER_FONT  // 加粗字体
    ctx.textBaseline = 'middle'
    ctx.textAlign = 'center'
​
    let x = getColumnX(startCol) - scrollLeft.value
​
    for (let col = startCol; col < endCol; col++) {
        const column = columns[col]
        if (!column) continue
​
        const textX = x + column.width / 2  // 水平居中
        const textY = headerHeight / 2       // 垂直居中
​
        ctx.fillText(column.title, textX, textY)
        x += column.width
    }
​
    // 底部边框
    ctx.strokeStyle = theme.value.borderColor
    ctx.lineWidth = 1
    ctx.beginPath()
    ctx.moveTo(0, headerHeight + 0.5)
    ctx.lineTo(props.width, headerHeight + 0.5)
    ctx.stroke()
}
冻结列绘制

冻结列的关键在于:它们不随水平滚动而移动

function drawFrozenColumns(ctx: CanvasRenderingContext2D, range: VisibleRange) {
    const { startRow, endRow } = range
    const { rowHeight, headerHeight, columns, data, frozenColumns } = props
​
    // 1. 清空冻结区域(覆盖之前绘制的内容)
    ctx.clearRect(0, 0, frozenWidth.value, props.height)
​
    // 2. 绘制冻结列的背景
    for (let row = startRow; row < endRow; row++) {
        const y = row * rowHeight + headerHeight - scrollTop.value
        const bgColor = row % 2 === 0 ? theme.value.cellBgColor : ZEBRA_COLOR
        ctx.fillStyle = bgColor
        ctx.fillRect(0, y, frozenWidth.value, rowHeight)
    }
​
    // 3. 绘制冻结列的内容
    // ... (代码省略,与普通单元格类似,但 x 从 0 开始,不减 scrollLeft)
​
    // 4. 绘制冻结列的表头
    ctx.fillStyle = theme.value.headerBgColor
    ctx.fillRect(0, 0, frozenWidth.value, headerHeight)
    // ...
​
    // 5. 右侧阴影效果(滚动时显示)
    if (scrollLeft.value > 0) {
        const gradient = ctx.createLinearGradient(
            frozenWidth.value, 0,           // 起点
            frozenWidth.value + 10, 0       // 终点
        )
        gradient.addColorStop(0, 'rgba(0, 0, 0, 0.08)')
        gradient.addColorStop(1, 'rgba(0, 0, 0, 0)')
        ctx.fillStyle = gradient
        ctx.fillRect(frozenWidth.value, 0, 10, props.height)
    }
​
    // 6. 绘制网格线
    // ...
}

冻结列与普通列的坐标差异

类型X 坐标计算Y 坐标计算
普通列getColumnX(col) - scrollLeftrow * rowHeight + headerHeight - scrollTop
冻结列getColumnX(col)(不减 scrollLeft)row * rowHeight + headerHeight - scrollTop

6.6 renderInteractionLayer - 交互层渲染

function renderInteractionLayer() {
    const ctx = contexts.interaction
    if (!ctx) return
​
    ctx.clearRect(0, 0, props.width, props.height)
​
    drawHover(ctx)      // 先绘制悬停效果
    drawSelection(ctx)  // 再绘制选中效果(覆盖在悬停之上)
}
选中效果绘制
function drawSelection(ctx: CanvasRenderingContext2D) {
    if (!selectedCell.value) return
​
    const { row, col } = selectedCell.value
    const { rowHeight, headerHeight, columns, frozenColumns } = props
​
    const column = columns[col]
    if (!column) return
​
    let x = getColumnX(col)
    const y = row * rowHeight + headerHeight
​
    // 非冻结列需要减去滚动偏移
    if (col >= frozenColumns) {
        x -= scrollLeft.value
    }
​
    const canvasY = y - scrollTop.value
    const width = column.width
    const height = rowHeight
​
    // 选中背景
    ctx.fillStyle = theme.value.selectedColor
    ctx.fillRect(x, canvasY, width, height)
​
    // 选中边框(内缩 1px,避免覆盖网格线)
    ctx.strokeStyle = SELECTION_BORDER_COLOR
    ctx.lineWidth = 2
    ctx.strokeRect(x + 1, canvasY + 1, width - 2, height - 2)
}
悬停效果绘制
function drawHover(ctx: CanvasRenderingContext2D) {
    // 如果没有悬停行,或悬停行与选中行相同,不绘制
    if (hoverRow.value < 0 || selectedCell.value?.row === hoverRow.value) return
​
    const { rowHeight, headerHeight } = props
    const y = hoverRow.value * rowHeight + headerHeight - scrollTop.value
​
    ctx.fillStyle = theme.value.hoverColor
    ctx.fillRect(0, y, props.width, rowHeight)  // 整行高亮
}

七、事件处理机制

7.1 坐标转换:getCellFromEvent

Canvas 只是一张图片,本身无法知道用户点击了哪个单元格。我们需要根据鼠标坐标计算出单元格位置:

function getCellFromEvent(e: MouseEvent): { row: number; col: number; isHeader: boolean } {
    const canvas = interactionCanvasRef.value
    if (!canvas) return { row: -1, col: -1, isHeader: false }
​
    // 1. 获取 Canvas 相对于视口的位置
    const rect = canvas.getBoundingClientRect()
​
    // 2. 计算鼠标在 Canvas 上的坐标
    const canvasX = e.clientX - rect.left
    const canvasY = e.clientY - rect.top
​
    const { headerHeight, rowHeight, columns, data, frozenColumns } = props
​
    // 3. 判断是否在表头区域
    const isHeader = canvasY < headerHeight
​
    // 4. 计算行号
    let row = -1
    if (!isHeader) {
        // 加上 scrollTop 得到数据坐标
        const dataY = canvasY - headerHeight + scrollTop.value
        row = Math.floor(dataY / rowHeight)
        if (row < 0 || row >= data.length) row = -1
    }
​
    // 5. 计算列号
    let col = -1
    const isFrozenArea = canvasX < frozenWidth.value
​
    // 冻结区域不需要加 scrollLeft
    const dataX = isFrozenArea ? canvasX : canvasX + scrollLeft.value
​
    let accWidth = 0
    for (let i = 0; i < columns.length; i++) {
        const column = columns[i]
        if (!column) continue
​
        accWidth += column.width
        if (dataX < accWidth) {
            col = i
            break
        }
    }
​
    return { row, col, isHeader }
}

7.2 点击处理

function handleClick(e: MouseEvent) {
    const { row, col, isHeader } = getCellFromEvent(e)
​
    if (row === -1 || col === -1) return
​
    const column = props.columns[col]
    if (!column) return
​
    if (isHeader) {
        emit('header-click', col, column)
    } else {
        selectedCell.value = { row, col }
        emit('cell-click', row, col, props.data[row])
        markDirty('interaction')  // 只需重绘交互层
        requestRender()
    }
}

7.3 双击编辑

function handleDoubleClick(e: MouseEvent) {
    const { row, col, isHeader } = getCellFromEvent(e)
​
    if (row === -1 || col === -1 || isHeader) return
​
    const column = props.columns[col]
    if (!column) return
​
    if (column.editable !== false) {
        editingCell.value = { row, col }
        const rowData = props.data[row]
        editValue.value = rowData?.[column.key]?.toString() || ''
​
        // 等待 DOM 更新后聚焦输入框
        nextTick(() => {
            editorRef.value?.focus()
            editorRef.value?.select()
        })
    }
​
    emit('cell-double-click', row, col, props.data[row])
}

八、性能优化专题

8.1 Canvas 分层渲染

这是本组件最核心的优化策略。不同内容的更新频率差异很大:

层级内容更新频率
Base Layer背景、网格线极低(resize 时)
Content Layer文字内容中等(滚动时)
Interaction Layer选中、悬停高(鼠标移动时)

优化效果:当用户移动鼠标时,只需要清空并重绘 Interaction Layer,背景和文字内容保持不变。这比每次都重绘整个 Canvas 快得多。

8.2 requestAnimationFrame 节流

function requestRender() {
    if (rafId) return  // 如果已有待执行的渲染请求,跳过
​
    rafId = requestAnimationFrame(() => {
        renderAll()
        rafId = null
    })
}

为什么使用 RAF?

  • 浏览器会在下一帧开始前执行 RAF 回调
  • 多次调用 requestRender() 只会执行一次渲染
  • 与屏幕刷新率同步,避免无效渲染

8.3 文本截断二分查找

原始实现(O(n²) 复杂度):

// ❌ 低效实现
function ellipsisText(text: string, maxWidth: number): string {
    let truncated = text
    while (ctx.measureText(truncated + '...').width > maxWidth) {
        truncated = truncated.slice(0, -1)  // 每次删除一个字符
    }
    return truncated + '...'
}

优化实现(O(n log n) 复杂度):

// ✅ 二分查找优化
function ellipsisText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string {
    if (ctx.measureText(text).width <= maxWidth) return text
​
    const ellipsis = '...'
    const ellipsisWidth = ctx.measureText(ellipsis).width
​
    if (maxWidth <= ellipsisWidth) return ellipsis
​
    // 二分查找最佳截断位置
    let left = 0
    let right = text.length
    let result = ''
​
    while (left < right) {
        const mid = Math.floor((left + right + 1) / 2)
        const truncated = text.slice(0, mid)
​
        if (ctx.measureText(truncated).width + ellipsisWidth <= maxWidth) {
            result = truncated
            left = mid
        } else {
            right = mid - 1
        }
    }
​
    return result + ellipsis
}

性能对比(假设文本长度 100):

方法measureText 调用次数
逐字符删除最多 100 次
二分查找最多 7 次 (log₂100 ≈ 7)

8.4 常量提取

// ✅ 避免魔法数字,便于维护和复用
const CELL_PADDING = 8
const DEFAULT_FONT = '14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
const HEADER_FONT = 'bold 14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
const ZEBRA_COLOR = '#fafafa'
const SELECTION_BORDER_COLOR = 'rgba(64, 158, 255, 0.8)'

8.5 事件解绑

onUnmounted(() => {
    if (rafId) cancelAnimationFrame(rafId)
    unbindEvents()  // 移除事件监听,防止内存泄漏
})

演示

lovegif_1770702484032.gif

总结

Canvas 高性能表格的核心技术要点:

  1. 虚拟滚动:只渲染可视区域内的单元格
  2. 分层渲染:按更新频率分离为 Base/Content/Interaction 三层
  3. RAF 节流:使用 requestAnimationFrame 批量更新
  4. 高清屏适配:通过 devicePixelRatio 处理 Retina 屏幕
  5. 坐标转换:正确处理滚动偏移和冻结列的坐标差异
  6. 算法优化:文本截断使用二分查找

通过这些技术,Canvas 表格可以轻松处理 10 万+ 行数据,同时保持 60fps 的流畅滚动体验。


附录:暴露的方法

defineExpose({
    // 滚动到指定位置
    scrollTo: (left?: number, top?: number) => { ... },
    
    // 滚动到指定行
    scrollToRow: (rowIndex: number) => { ... },
    
    // 强制刷新
    refresh: () => { ... }
})

使用示例

<script setup>
const tableRef = ref()
​
// 滚动到第 100 行
tableRef.value?.scrollToRow(100)
​
// 滚动到指定位置
tableRef.value?.scrollTo(200, 500)
​
// 强制刷新
tableRef.value?.refresh()
</script>
​
<template>
    <CanvasTable ref="tableRef" :columns="columns" :data="data" />
</template>

完整代码


<template>
    <div class="canvas-container">
        <div ref="containerRef" class="canvas-table-container" :style="containerStyle">
            <!-- 底层 Canvas:背景、网格线(很少变化) -->
            <canvas ref="baseCanvasRef" class="layer-base" />

            <!-- 中层 Canvas:单元格内容(滚动时更新) -->
            <canvas ref="contentCanvasRef" class="layer-content" />

            <!-- 顶层 Canvas:选区、hover(频繁更新) -->
            <canvas ref="interactionCanvasRef" class="layer-interaction" />

            <!-- 滚动占位层(产生原生滚动条) -->
            <div ref="scrollerRef" class="scroll-container" @scroll="handleScroll">
                <div class="scroll-phantom" :style="phantomStyle" />
            </div>

            <!-- 编辑输入框(点击单元格时显示) -->
            <input v-if="editingCell" ref="editorRef" v-model="editValue" class="cell-editor" :style="editorStyle"
                @blur="finishEdit" @keydown.enter="finishEdit" @keydown.escape="cancelEdit" />
        </div>
    </div>
</template>

<script lang="ts" setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'

// ==================== 类型定义 ====================

/** 列配置接口 */
interface Column {
    key: string
    title: string
    width: number
    align?: 'left' | 'center' | 'right'
    formatter?: (value: any, row: any) => string
    editable?: boolean
}

/** 组件 Props 接口 */
interface Props {
    columns: Column[]
    data: any[]
    width?: number
    height?: number
    rowHeight?: number
    headerHeight?: number
    frozenColumns?: number
    theme?: Partial<ThemeConfig>
}

/** 主题配置接口 */
interface ThemeConfig {
    headerBgColor: string
    headerTextColor: string
    cellBgColor: string
    cellTextColor: string
    borderColor: string
    hoverColor: string
    selectedColor: string
}

/** 可视范围接口 */
interface VisibleRange {
    startRow: number
    endRow: number
    startCol: number
    endCol: number
}

/** Canvas 层级上下文 */
interface CanvasContexts {
    base: CanvasRenderingContext2D | null
    content: CanvasRenderingContext2D | null
    interaction: CanvasRenderingContext2D | null
}

// ==================== 常量定义 ====================

/** 单元格内边距 */
const CELL_PADDING = 8

/** 默认字体 */
const DEFAULT_FONT = '14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
const HEADER_FONT = 'bold 14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'

/** 斑马纹颜色 */
const ZEBRA_COLOR = '#fafafa'

/** 选中边框颜色 */
const SELECTION_BORDER_COLOR = 'rgba(64, 158, 255, 0.8)'

// ==================== Props 定义 ====================

const props = withDefaults(defineProps<Props>(), {
    width: 800,
    height: 400,
    rowHeight: 36,
    headerHeight: 40,
    frozenColumns: 1,
    theme: () => ({})
})

// ==================== 事件定义 ====================

const emit = defineEmits<{
    (e: 'cell-click', row: number, col: number, data: any): void
    (e: 'cell-double-click', row: number, col: number, data: any): void
    (e: 'header-click', col: number, column: Column): void
    (e: 'selection-change', selection: any): void
    (e: 'scroll', scrollLeft: number, scrollTop: number): void
}>()

// ==================== 默认主题 ====================

const defaultTheme: ThemeConfig = {
    headerBgColor: '#f5f7fa',
    headerTextColor: '#606266',
    cellBgColor: '#ffffff',
    cellTextColor: '#303133',
    borderColor: '#ebeef5',
    hoverColor: 'rgba(64, 158, 255, 0.1)',
    selectedColor: 'rgba(64, 158, 255, 0.15)'
}

const theme = computed(() => ({ ...defaultTheme, ...props.theme }))

// ==================== DOM 引用 ====================

const containerRef = ref<HTMLDivElement>()
const baseCanvasRef = ref<HTMLCanvasElement>()
const contentCanvasRef = ref<HTMLCanvasElement>()
const interactionCanvasRef = ref<HTMLCanvasElement>()
const scrollerRef = ref<HTMLDivElement>()
const editorRef = ref<HTMLInputElement>()

// ==================== 状态 ====================

const scrollLeft = ref(0)
const scrollTop = ref(0)
const hoverRow = ref(-1)
const selectedCell = ref<{ row: number; col: number } | null>(null)
const editingCell = ref<{ row: number; col: number } | null>(null)
const editValue = ref('')

// ==================== Canvas 上下文 ====================

const contexts: CanvasContexts = {
    base: null,
    content: null,
    interaction: null
}
let dpr = 1
let rafId: number | null = null

// 脏标记:标识哪些层需要重绘
const dirtyFlags = {
    base: true,
    content: true,
    interaction: true
}

// ==================== 计算属性 ====================

/** 总宽度 */
const totalWidth = computed(() =>
    props.columns.reduce((sum, col) => sum + col.width, 0)
)

/** 总高度 */
const totalHeight = computed(() =>
    props.data.length * props.rowHeight + props.headerHeight
)

/** 容器样式 */
const containerStyle = computed(() => ({
    width: `${props.width}px`,
    height: `${props.height}px`
}))

/** 幽灵层样式(撑开滚动区域) */
const phantomStyle = computed(() => ({
    width: `${totalWidth.value}px`,
    height: `${totalHeight.value}px`
}))

/** 冻结列宽度 */
const frozenWidth = computed(() => {
    let width = 0
    for (let i = 0; i < props.frozenColumns && i < props.columns.length; i++) {
        const column = props.columns[i]
        if (column) width += column.width
    }
    return width
})

/** 编辑框样式 */
const editorStyle = computed(() => {
    if (!editingCell.value) return {}

    const { row, col } = editingCell.value
    const column = props.columns[col]
    if (!column) return {}

    let x = getColumnX(col)
    const y = row * props.rowHeight + props.headerHeight

    // 如果在冻结列外,减去滚动偏移
    if (col >= props.frozenColumns) {
        x -= scrollLeft.value
    }

    return {
        left: `${x}px`,
        top: `${y - scrollTop.value}px`,
        width: `${column.width}px`,
        height: `${props.rowHeight}px`
    }
})

// ==================== 生命周期 ====================

onMounted(() => {
    if (!containerRef.value) return

    dpr = window.devicePixelRatio || 1

    initCanvas()
    bindEvents()
    renderAll()
})

onUnmounted(() => {
    if (rafId) cancelAnimationFrame(rafId)
    unbindEvents()
})

// ==================== 数据监听 ====================

watch(() => props.data, () => {
    markDirty('base', 'content', 'interaction')
    requestRender()
}, { deep: true })

watch(() => props.columns, () => {
    markDirty('base', 'content', 'interaction')
    requestRender()
}, { deep: true })

// ==================== 初始化方法 ====================

/**
 * 初始化所有 Canvas 层
 */
function initCanvas() {
    const canvases = [
        { ref: baseCanvasRef.value, key: 'base' as const },
        { ref: contentCanvasRef.value, key: 'content' as const },
        { ref: interactionCanvasRef.value, key: 'interaction' as const }
    ]

    canvases.forEach(({ ref: canvas, key }) => {
        if (!canvas) return

        // 设置 Canvas 尺寸(处理高清屏)
        canvas.width = props.width * dpr
        canvas.height = props.height * dpr
        canvas.style.width = `${props.width}px`
        canvas.style.height = `${props.height}px`

        const ctx = canvas.getContext('2d')
        if (ctx) {
            ctx.scale(dpr, dpr)
            contexts[key] = ctx
        }
    })
}

/**
 * 绑定事件
 */
function bindEvents() {
    const scroller = scrollerRef.value
    if (!scroller) return

    scroller.addEventListener('click', handleClick)
    scroller.addEventListener('dblclick', handleDoubleClick)
    scroller.addEventListener('mousemove', handleMouseMove)
    scroller.addEventListener('mouseleave', handleMouseLeave)
}

/**
 * 解绑事件(防止内存泄漏)
 */
function unbindEvents() {
    const scroller = scrollerRef.value
    if (!scroller) return

    scroller.removeEventListener('click', handleClick)
    scroller.removeEventListener('dblclick', handleDoubleClick)
    scroller.removeEventListener('mousemove', handleMouseMove)
    scroller.removeEventListener('mouseleave', handleMouseLeave)
}

// ==================== 渲染控制 ====================

/**
 * 标记脏层
 */
function markDirty(...layers: Array<'base' | 'content' | 'interaction'>) {
    layers.forEach(layer => {
        dirtyFlags[layer] = true
    })
}

/**
 * 请求渲染(使用 RAF 节流)
 */
function requestRender() {
    if (rafId) return

    rafId = requestAnimationFrame(() => {
        renderAll()
        rafId = null
    })
}

/**
 * 渲染所有脏层
 */
function renderAll() {
    const range = calculateVisibleRange()

    if (dirtyFlags.base) {
        renderBaseLayer(range)
        dirtyFlags.base = false
    }

    if (dirtyFlags.content) {
        renderContentLayer(range)
        dirtyFlags.content = false
    }

    if (dirtyFlags.interaction) {
        renderInteractionLayer()
        dirtyFlags.interaction = false
    }
}

// ==================== 可视范围计算 ====================

/**
 * 计算可视范围
 */
function calculateVisibleRange(): VisibleRange {
    const { rowHeight, headerHeight, columns, data } = props
    const bodyHeight = props.height - headerHeight

    // 行范围(多渲染2行作为缓冲)
    const startRow = Math.floor(scrollTop.value / rowHeight)
    const endRow = Math.min(
        startRow + Math.ceil(bodyHeight / rowHeight) + 2,
        data.length
    )

    // 列范围
    let accWidth = 0
    let startCol = 0
    let endCol = columns.length

    for (let i = 0; i < columns.length; i++) {
        const column = columns[i]
        if (!column) continue

        const colWidth = column.width

        if (accWidth + colWidth > scrollLeft.value && startCol === 0) {
            startCol = Math.max(0, i - 1)
        }

        if (accWidth > scrollLeft.value + props.width) {
            endCol = i + 1
            break
        }

        accWidth += colWidth
    }

    return { startRow, endRow, startCol, endCol }
}

// ==================== 底层渲染(背景、网格线) ====================

/**
 * 渲染底层:背景和网格线
 */
function renderBaseLayer(range: VisibleRange) {
    const ctx = contexts.base
    if (!ctx) return

    ctx.clearRect(0, 0, props.width, props.height)

    drawBackground(ctx, range)
    drawGridLines(ctx, range)
}

/**
 * 绘制背景(含斑马纹)
 */
function drawBackground(ctx: CanvasRenderingContext2D, range: VisibleRange) {
    const { startRow, endRow, startCol, endCol } = range
    const { rowHeight, headerHeight, columns } = props

    for (let row = startRow; row < endRow; row++) {
        let x = getColumnX(startCol) - scrollLeft.value
        const y = row * rowHeight + headerHeight - scrollTop.value

        // 斑马纹效果
        const bgColor = row % 2 === 0 ? theme.value.cellBgColor : ZEBRA_COLOR

        for (let col = startCol; col < endCol; col++) {
            const column = columns[col]
            if (!column) continue

            ctx.fillStyle = bgColor
            ctx.fillRect(x, y, column.width, rowHeight)
            x += column.width
        }
    }
}

/**
 * 绘制网格线
 */
function drawGridLines(ctx: CanvasRenderingContext2D, range: VisibleRange) {
    const { startRow, endRow, startCol, endCol } = range
    const { rowHeight, headerHeight, columns } = props

    ctx.strokeStyle = theme.value.borderColor
    ctx.lineWidth = 1

    // 水平线
    ctx.beginPath()
    for (let row = startRow; row <= endRow; row++) {
        const y = row * rowHeight + headerHeight - scrollTop.value + 0.5
        ctx.moveTo(0, y)
        ctx.lineTo(props.width, y)
    }
    ctx.stroke()

    // 垂直线
    ctx.beginPath()
    let x = getColumnX(startCol) - scrollLeft.value + 0.5
    for (let col = startCol; col <= endCol; col++) {
        ctx.moveTo(x, headerHeight)
        ctx.lineTo(x, props.height)
        const column = columns[col]
        x += column?.width || 0
    }
    ctx.stroke()
}

// ==================== 中层渲染(单元格内容) ====================

/**
 * 渲染中层:单元格内容、表头、冻结列
 */
function renderContentLayer(range: VisibleRange) {
    const ctx = contexts.content
    if (!ctx) return

    ctx.clearRect(0, 0, props.width, props.height)

    drawCells(ctx, range)
    drawHeader(ctx, range)

    if (props.frozenColumns > 0) {
        drawFrozenColumns(ctx, range)
    }
}

/**
 * 绘制单元格内容
 */
function drawCells(ctx: CanvasRenderingContext2D, range: VisibleRange) {
    const { startRow, endRow, startCol, endCol } = range
    const { rowHeight, headerHeight, columns, data } = props

    ctx.fillStyle = theme.value.cellTextColor
    ctx.font = DEFAULT_FONT
    ctx.textBaseline = 'middle'

    for (let row = startRow; row < endRow; row++) {
        const rowData = data[row]
        if (!rowData) continue

        let x = getColumnX(startCol) - scrollLeft.value

        for (let col = startCol; col < endCol; col++) {
            const column = columns[col]
            if (!column) continue

            const value = rowData[column.key]
            const cellY = row * rowHeight + headerHeight - scrollTop.value

            // 获取显示文本
            let displayText = value?.toString() || ''
            if (column.formatter) {
                displayText = column.formatter(value, rowData)
            }

            // 文本截断(使用二分查找优化)
            displayText = ellipsisText(ctx, displayText, column.width - CELL_PADDING * 2)

            // 绘制文本
            ctx.fillStyle = theme.value.cellTextColor
            const textX = getTextX(x, column.width, column.align)
            const textY = cellY + rowHeight / 2

            ctx.textAlign = column.align || 'left'
            ctx.fillText(displayText, textX, textY)

            x += column.width
        }
    }
}

/**
 * 绘制表头
 */
function drawHeader(ctx: CanvasRenderingContext2D, range: VisibleRange) {
    const { startCol, endCol } = range
    const { headerHeight, columns } = props

    // 背景
    ctx.fillStyle = theme.value.headerBgColor
    ctx.fillRect(0, 0, props.width, headerHeight)

    // 文字
    ctx.fillStyle = theme.value.headerTextColor
    ctx.font = HEADER_FONT
    ctx.textBaseline = 'middle'
    ctx.textAlign = 'center'

    let x = getColumnX(startCol) - scrollLeft.value

    for (let col = startCol; col < endCol; col++) {
        const column = columns[col]
        if (!column) continue

        const textX = x + column.width / 2
        const textY = headerHeight / 2

        ctx.fillText(column.title, textX, textY)
        x += column.width
    }

    // 底部边框
    ctx.strokeStyle = theme.value.borderColor
    ctx.lineWidth = 1
    ctx.beginPath()
    ctx.moveTo(0, headerHeight + 0.5)
    ctx.lineTo(props.width, headerHeight + 0.5)
    ctx.stroke()
}

/**
 * 绘制冻结列
 */
function drawFrozenColumns(ctx: CanvasRenderingContext2D, range: VisibleRange) {
    const { startRow, endRow } = range
    const { rowHeight, headerHeight, columns, data, frozenColumns } = props

    // 清空冻结区域
    ctx.clearRect(0, 0, frozenWidth.value, props.height)

    // 绘制背景
    for (let row = startRow; row < endRow; row++) {
        const y = row * rowHeight + headerHeight - scrollTop.value
        const bgColor = row % 2 === 0 ? theme.value.cellBgColor : ZEBRA_COLOR
        ctx.fillStyle = bgColor
        ctx.fillRect(0, y, frozenWidth.value, rowHeight)
    }

    // 绘制内容
    ctx.fillStyle = theme.value.cellTextColor
    ctx.font = DEFAULT_FONT
    ctx.textBaseline = 'middle'

    for (let row = startRow; row < endRow; row++) {
        const rowData = data[row]
        if (!rowData) continue

        let x = 0
        const y = row * rowHeight + headerHeight - scrollTop.value

        for (let col = 0; col < frozenColumns && col < columns.length; col++) {
            const column = columns[col]
            if (!column) continue

            const value = rowData[column.key]
            let displayText = value?.toString() || ''

            if (column.formatter) {
                displayText = column.formatter(value, rowData)
            }

            displayText = ellipsisText(ctx, displayText, column.width - CELL_PADDING * 2)

            const textX = getTextX(x, column.width, column.align)
            ctx.textAlign = column.align || 'left'
            ctx.fillText(displayText, textX, y + rowHeight / 2)

            x += column.width
        }
    }

    // 绘制表头
    ctx.fillStyle = theme.value.headerBgColor
    ctx.fillRect(0, 0, frozenWidth.value, headerHeight)

    ctx.fillStyle = theme.value.headerTextColor
    ctx.font = HEADER_FONT
    ctx.textAlign = 'center'

    let headerX = 0
    for (let col = 0; col < frozenColumns && col < columns.length; col++) {
        const column = columns[col]
        if (!column) continue

        ctx.fillText(column.title, headerX + column.width / 2, headerHeight / 2)
        headerX += column.width
    }

    // 右侧阴影效果
    if (scrollLeft.value > 0) {
        const gradient = ctx.createLinearGradient(frozenWidth.value, 0, frozenWidth.value + 10, 0)
        gradient.addColorStop(0, 'rgba(0, 0, 0, 0.08)')
        gradient.addColorStop(1, 'rgba(0, 0, 0, 0)')
        ctx.fillStyle = gradient
        ctx.fillRect(frozenWidth.value, 0, 10, props.height)
    }

    // 绘制网格线
    ctx.strokeStyle = theme.value.borderColor
    ctx.lineWidth = 1

    // 垂直线
    ctx.beginPath()
    let lineX = 0.5
    for (let col = 0; col <= frozenColumns && col < columns.length; col++) {
        ctx.moveTo(lineX, 0)
        ctx.lineTo(lineX, props.height)
        const column = columns[col]
        lineX += column?.width || 0
    }
    ctx.stroke()

    // 水平线
    ctx.beginPath()
    for (let row = startRow; row <= endRow; row++) {
        const lineY = row * rowHeight + headerHeight - scrollTop.value + 0.5
        ctx.moveTo(0, lineY)
        ctx.lineTo(frozenWidth.value, lineY)
    }
    ctx.stroke()
}

// ==================== 顶层渲染(交互状态) ====================

/**
 * 渲染顶层:选中效果和悬停效果
 */
function renderInteractionLayer() {
    const ctx = contexts.interaction
    if (!ctx) return

    ctx.clearRect(0, 0, props.width, props.height)

    drawHover(ctx)
    drawSelection(ctx)
}

/**
 * 绘制选中效果
 */
function drawSelection(ctx: CanvasRenderingContext2D) {
    if (!selectedCell.value) return

    const { row, col } = selectedCell.value
    const { rowHeight, headerHeight, columns, frozenColumns } = props

    const column = columns[col]
    if (!column) return

    let x = getColumnX(col)
    const y = row * rowHeight + headerHeight

    // 非冻结列需要减去滚动偏移
    if (col >= frozenColumns) {
        x -= scrollLeft.value
    }

    const canvasY = y - scrollTop.value
    const width = column.width
    const height = rowHeight

    // 选中背景
    ctx.fillStyle = theme.value.selectedColor
    ctx.fillRect(x, canvasY, width, height)

    // 选中边框
    ctx.strokeStyle = SELECTION_BORDER_COLOR
    ctx.lineWidth = 2
    ctx.strokeRect(x + 1, canvasY + 1, width - 2, height - 2)
}

/**
 * 绘制悬停效果
 */
function drawHover(ctx: CanvasRenderingContext2D) {
    if (hoverRow.value < 0 || selectedCell.value?.row === hoverRow.value) return

    const { rowHeight, headerHeight } = props
    const y = hoverRow.value * rowHeight + headerHeight - scrollTop.value

    ctx.fillStyle = theme.value.hoverColor
    ctx.fillRect(0, y, props.width, rowHeight)
}

// ==================== 事件处理 ====================

/**
 * 滚动处理
 */
function handleScroll(e: Event) {
    const target = e.target as HTMLDivElement
    scrollLeft.value = target.scrollLeft
    scrollTop.value = target.scrollTop

    emit('scroll', scrollLeft.value, scrollTop.value)

    // 滚动时只需要重绘内容层和交互层
    markDirty('base', 'content', 'interaction')
    requestRender()
}

/**
 * 点击事件
 */
function handleClick(e: MouseEvent) {
    const { row, col, isHeader } = getCellFromEvent(e)

    if (row === -1 || col === -1) return

    const column = props.columns[col]
    if (!column) return

    if (isHeader) {
        emit('header-click', col, column)
    } else {
        selectedCell.value = { row, col }
        emit('cell-click', row, col, props.data[row])
        markDirty('interaction')
        requestRender()
    }
}

/**
 * 双击事件(进入编辑)
 */
function handleDoubleClick(e: MouseEvent) {
    const { row, col, isHeader } = getCellFromEvent(e)

    if (row === -1 || col === -1 || isHeader) return

    const column = props.columns[col]
    if (!column) return

    if (column.editable !== false) {
        editingCell.value = { row, col }
        const rowData = props.data[row]
        editValue.value = rowData?.[column.key]?.toString() || ''

        nextTick(() => {
            editorRef.value?.focus()
            editorRef.value?.select()
        })
    }

    emit('cell-double-click', row, col, props.data[row])
}

/**
 * 鼠标移动
 */
function handleMouseMove(e: MouseEvent) {
    const { row } = getCellFromEvent(e)

    if (hoverRow.value !== row) {
        hoverRow.value = row
        markDirty('interaction')
        requestRender()
    }
}

/**
 * 鼠标离开
 */
function handleMouseLeave() {
    hoverRow.value = -1
    markDirty('interaction')
    requestRender()
}

/**
 * 完成编辑
 */
function finishEdit() {
    if (!editingCell.value) return

    const { row, col } = editingCell.value
    const column = props.columns[col]
    if (!column) return

    // 更新数据
    props.data[row][column.key] = editValue.value

    editingCell.value = null
    markDirty('content')
    requestRender()
}

/**
 * 取消编辑
 */
function cancelEdit() {
    editingCell.value = null
}

// ==================== 工具方法 ====================

/**
 * 从事件获取单元格位置
 */
function getCellFromEvent(e: MouseEvent): { row: number; col: number; isHeader: boolean } {
    const canvas = interactionCanvasRef.value
    if (!canvas) return { row: -1, col: -1, isHeader: false }

    const rect = canvas.getBoundingClientRect()
    const canvasX = e.clientX - rect.left
    const canvasY = e.clientY - rect.top

    const { headerHeight, rowHeight, columns, data, frozenColumns } = props

    // 是否在表头
    const isHeader = canvasY < headerHeight

    // 计算行
    let row = -1
    if (!isHeader) {
        const dataY = canvasY - headerHeight + scrollTop.value
        row = Math.floor(dataY / rowHeight)
        if (row < 0 || row >= data.length) row = -1
    }

    // 计算列
    let col = -1
    const isFrozenArea = canvasX < frozenWidth.value
    const dataX = isFrozenArea ? canvasX : canvasX + scrollLeft.value

    let accWidth = 0
    for (let i = 0; i < columns.length; i++) {
        const column = columns[i]
        if (!column) continue

        accWidth += column.width
        if (dataX < accWidth) {
            col = i
            break
        }
    }

    return { row, col, isHeader }
}

/**
 * 获取列 X 坐标
 */
function getColumnX(colIndex: number): number {
    let x = 0
    for (let i = 0; i < colIndex && i < props.columns.length; i++) {
        const column = props.columns[i]
        if (column) x += column.width
    }
    return x
}

/**
 * 获取文本 X 坐标
 */
function getTextX(cellX: number, cellWidth: number, align?: string): number {
    switch (align) {
        case 'center': return cellX + cellWidth / 2
        case 'right': return cellX + cellWidth - CELL_PADDING
        default: return cellX + CELL_PADDING
    }
}

/**
 * 文本截断(使用二分查找优化)
 */
function ellipsisText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string {
    if (ctx.measureText(text).width <= maxWidth) return text

    const ellipsis = '...'
    const ellipsisWidth = ctx.measureText(ellipsis).width

    if (maxWidth <= ellipsisWidth) return ellipsis

    // 二分查找最佳截断位置
    let left = 0
    let right = text.length
    let result = ''

    while (left < right) {
        const mid = Math.floor((left + right + 1) / 2)
        const truncated = text.slice(0, mid)

        if (ctx.measureText(truncated).width + ellipsisWidth <= maxWidth) {
            result = truncated
            left = mid
        } else {
            right = mid - 1
        }
    }

    return result + ellipsis
}

// ==================== 暴露方法 ====================

defineExpose({
    /** 滚动到指定位置 */
    scrollTo: (left?: number, top?: number) => {
        if (left !== undefined && scrollerRef.value) {
            scrollerRef.value.scrollLeft = left
        }
        if (top !== undefined && scrollerRef.value) {
            scrollerRef.value.scrollTop = top
        }
    },

    /** 滚动到指定行 */
    scrollToRow: (rowIndex: number) => {
        if (scrollerRef.value) {
            scrollerRef.value.scrollTop = rowIndex * props.rowHeight
        }
    },

    /** 强制刷新 */
    refresh: () => {
        markDirty('base', 'content', 'interaction')
        requestRender()
    }
})
</script>

<style scoped>
.canvas-table-container {
    position: relative;
    overflow: hidden;
    background: #fff;
    border: 1px solid #ebeef5;
    border-radius: 4px;
}

/* Canvas 层级样式 */
.layer-base,
.layer-content,
.layer-interaction {
    position: absolute;
    top: 0;
    left: 0;
    pointer-events: none;
}

.layer-base {
    z-index: 1;
}

.layer-content {
    z-index: 2;
}

.layer-interaction {
    z-index: 3;
}

/* 滚动容器 */
.scroll-container {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    overflow: auto;
    z-index: 4;
}

.scroll-phantom {
    pointer-events: none;
}

/* 编辑器 */
.cell-editor {
    position: absolute;
    z-index: 10;
    padding: 0 8px;
    border: 2px solid #409eff;
    border-radius: 0;
    outline: none;
    font-size: 14px;
    font-family: inherit;
    box-sizing: border-box;
    background: #fff;
}
</style>