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事件,获取scrollLeft和scrollTop,然后重新绘制 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 参数说明
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
columns | Column[] | - | 列配置数组(必填) |
data | any[] | - | 数据源(必填) |
width | number | 800 | 表格宽度 |
height | number | 400 | 表格高度 |
rowHeight | number | 36 | 行高 |
headerHeight | number | 40 | 表头高度 |
frozenColumns | number | 1 | 冻结列数量 |
theme | Partial<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 尺寸,内容会显得模糊。解决方案:
- Canvas 的
width/height属性设为 CSS 尺寸的dpr倍 - 通过 CSS 将 Canvas 缩小到原始尺寸
- 在绑定上下文时
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
}
}
不同操作触发不同层的重绘:
| 操作 | Base | Content | Interaction |
|---|---|---|---|
| 初始化 | ✓ | ✓ | ✓ |
| 滚动 | ✓ | ✓ | ✓ |
| 数据变化 | ✓ | ✓ | ✓ |
| 鼠标悬停 | - | - | ✓ |
| 选中单元格 | - | - | ✓ |
| 编辑完成 | - | ✓ | - |
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) - scrollLeft | row * 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() // 移除事件监听,防止内存泄漏
})
演示
总结
Canvas 高性能表格的核心技术要点:
- 虚拟滚动:只渲染可视区域内的单元格
- 分层渲染:按更新频率分离为 Base/Content/Interaction 三层
- RAF 节流:使用 requestAnimationFrame 批量更新
- 高清屏适配:通过 devicePixelRatio 处理 Retina 屏幕
- 坐标转换:正确处理滚动偏移和冻结列的坐标差异
- 算法优化:文本截断使用二分查找
通过这些技术,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>