Canvas绘制大数据表格:十万行秒开渲染的实践方案
2026/9/9 14:11:25 网站建设 项目流程

做后台系统的人,迟早都会撞上“大数据量表格”这堵墙。最开始我用Element Plus的el-table,几万行数据一铺,页面直接卡成幻灯片,滚动一下浏览器都要问你要不要停止响应。后来换DOM级虚拟滚动,能撑住,但数据量再往上走,比如十万行、几十列的时候,DOM节点的创建和销毁又开始拖后腿。这时候就得换思路——既然DOM扛不住,那干脆不建DOM节点,直接在Canvas上把表格“画”出来。

我在一个数据中台项目里就是这么干的,把表格渲染完全切到Canvas,顺手把表头固定、横向纵向滚动、单元格点击这些交互都实现了。整套方案用Vue3 + TypeScript来做,文本绘制前的测量、截断、格式化这块,我单独抽了一个叫Pretext的模块统一处理。这篇文章就把这套方案从头拆一遍:为什么选Canvas、虚拟滚动怎么算、Pretext做了什么、实际踩过哪些坑,尽量把能直接落地的代码和思路都写出来,希望对正准备做类似表格的朋友有参考价值。

1. 整体设计与选型思路

1.1 先拆需求:这个表格要解决什么问题

动手之前一定先想清楚边界,不然很容易做出一堆用不上的“能力”。我在项目里要的表格不长这样:

  • 数据量:单表最多10万行以上,列数不固定,常见在20到40列。
  • 表头固定:上下滚动时表头始终可见,左右滚动时第一列或前几列固定不移动。
  • 单元格交互:点击单元格能拿到行列索引和原始数据,用于后续跳转、编辑弹窗。
  • 列配置化:列名、列宽、对齐方式、格式化函数都由外部传入,不能写死在组件里。
  • 滚动流畅:低端笔记本上也要保持滚动不掉帧,这是硬指标。

如果数据量只有几千行,老老实实用el-table加个分页就行,没必要上Canvas。但到了十万行这个量级,任何DOM方案都会遇到瓶颈——el-table在数据量上去之后光是生成单元格节点就要几万甚至几十万个,浏览器布局和样式计算直接瘫痪。Canvas方案从一开始就走的是“用绘制代替DOM”的路线,不管数据多少行,屏幕上永远只画可见的那几十行,这是它天然的性能优势。

1.2 为什么选Canvas,而不是继续优化DOM

我知道有些人会问:DOM虚拟滚动不是也能处理大数据量吗?确实,vue-virtual-scroller这类库在几万行场景下表现不错,但它的机制是“动态创建可视区域内的DOM节点,滚动时不断复用”。问题在于:

第一,节点复用逻辑复杂,列数多、行高不固定、单元格内容带图片或富文本时,很容易出现渲染错位;第二,即使只渲染可视区的30行,每行40列就是1200个DOM节点,浏览器对这么多节点的创建、销毁和样式计算依然有成本;第三,DOM节点有比较高的内存占用,每个单元格都要挂监听事件的话,GC压力会很明显。

Canvas的底层思路完全不同:它只管在一个位图上绘制像素,绘制的是文字、线框、色块,不产生任何DOM节点。一次全量重绘的耗时与“要绘制的内容数量”成正比,而虚拟滚动把“要绘制的内容数量”限制在了可视区域以内,所以整体性能非常稳定。缺点是失去DOM语义,不能Ctrl+F搜索页面文本、不能鼠标选中单元格文字、做无障碍适配也麻烦,这个取舍在项目一开始就要跟产品讲清楚。

1.3 Pretext模块是什么,为什么单独抽出来

标题里提到的Pretext,不是某个第三方库,是我在项目里自封装的一个模块,原意是“Pre + Text”,也就是“绘制前的文本处理”。为什么单独抽它?因为Canvas绘制表格,除了画线框,剩下的大部分工作都在处理文字:数字要千分位格式化、超宽文本要截断加省略号、长文本要按列宽度量、不同对齐方式要计算绘制坐标。这些逻辑如果散落在绘制函数里,代码会很快失控。

Pretext模块统一负责三件事:测量文本宽度、截断超长文本、格式化单元格原始值。它内部还做了measureText的缓存,避免同一个文本被反复测量浪费性能。后面第3章我会把Pretext的核心代码完整贴出来,它并不复杂,但确实是整个方案里提升开发效率和渲染稳定性的关键一环。如果你在npm上搜Pretext,搜到的不是我说的这个模块,这里指的是项目里自己封装的文本预处理层。

2. 核心机制详解

2.1 虚拟滚动到底是怎么算的

虚拟滚动的本质很简单:给一个总高度超大的滚动容器,让浏览器的滚动条按真实数据量来显示,但实际只在Canvas上绘制可视区域内的那几行几列。

行方向的计算一次除法就够了。假设每行高度固定为rowHeight,滚动容器的scrollTop表示已经滚过去的距离,那么可视区域的起始行就是Math.floor(scrollTop / rowHeight),结束行是Math.floor((scrollTop + viewportHeight) / rowHeight)。viewportHeight是滚动容器的可视高度,写代码的时候用getBoundingClientRect或者ResizeObserver获取。

列方向稍微麻烦一点,因为列宽不固定。我的做法是提前算好一个columnOffset数组,columnOffset[i]表示第i列的左侧x坐标,也就是前i列宽度之和。然后通过二分查找找到scrollLeft落在哪个区间,再遍历后续列直到超过scrollLeft + viewportWidth。下面是这两个函数的示意:

function getStartColumn(colOffset: number[], scrollLeft: number): number { let left = 0; let right = colOffset.length - 1; while (left <= right) { const mid = (left + right) >> 1; if (colOffset[mid] <= scrollLeft) { left = mid + 1; } else { right = mid - 1; } } return Math.max(0, right); } function getEndColumn(colOffset: number[], startCol: number, scrollLeft: number, viewportWidth: number): number { const maxX = scrollLeft + viewportWidth; let end = startCol; while (end < colOffset.length - 1 && colOffset[end + 1] < maxX) { end++; } return end; }

还有一个点容易被忽略:如果滚动速度很快,可视区域边缘的单元格内容还没来得及渲染,就会出现短暂的空白闪动。解决思路是在可视区域上下各多渲染几行作为缓冲,比如向上多算2行、向下多算2行。这样即使一次滚动跨过了好几个行高,画布上仍然有内容,视觉上就感觉不到白屏。

2.2 Canvas坐标体系与绘制顺序

Canvas的坐标原点在画布左上角,x轴向右、y轴向下,这一点和DOM的定位是一样的。绘制表格时要注意几个陷阱:字号和字体要先设置好才能准确测量文本宽度;fillText都是从文本基线开始画,垂直居中的话要把textBaseline设为middle,再在y坐标上加上行高的一半。

我的绘制顺序是:先画表头背景和表头文字,再画表格体区域的单元格背景、单元格文字,最后统一画网格线。为什么最后画网格线?因为网格线是覆盖在单元格背景和文字上面的,如果先画线,后面填充背景色时会把线盖掉。另外网格线可以按行分段绘制,只画可视区域内的线,不要一次性画整个表格的线,那样纯属浪费时间。

颜色配置我用一个统一的主题对象来管理,包括背景色、表头背景色、文字颜色、分隔线颜色、hover高亮色,这样后续换主题只改一个对象就行。绘制单元格背景时,先判断这一行有没有被hover命中,有就换高亮色;偶数行可以加一个很浅的斑马纹,阅读体验会好很多。

2.3 Pretext文本处理的关键细节

Pretext模块看起来简单,但细节决定成败。首先是字体一致性。你在measureText之前必须设置好ctx.font,而且measure和fill要使用同一套字体,否则测出来的宽度和画出来的宽度不一致,截断位置就会偏。

其次是截断算法。很多人会写个循环逐字符判断,文本一长就慢。我建议用二分法:先测全文本,如果超宽,就在一半的位置截取,同时拼上省略号“…”再测一次,根据结果调整上界或下界。这样最多循环log2(文本长度)次,对长文本非常友好。

第三是缓存。Canvas的measureText虽然快,但一帧里调用几百上千次也会拖慢性能。我在Pretext里用一个Map做缓存,key是“字体样式 + 文本内容”,value是测量出来的宽度。需要注意:字体样式变化时缓存要清空,否则拿到的是旧字体的宽度。

// pretext.ts const widthCache = new Map<string, number>(); export function measureText( ctx: CanvasRenderingContext2D, text: string, fontStyle: string ): number { const key = fontStyle + '|' + text; const cached = widthCache.get(key); if (cached !== undefined) return cached; ctx.font = fontStyle; const width = ctx.measureText(text).width; widthCache.set(key, width); return width; } export function truncateByWidth( ctx: CanvasRenderingContext2D, text: string, maxWidth: number, fontStyle: string ): string { if (measureText(ctx, text, fontStyle) <= maxWidth) return text; let low = 0; let high = text.length; let result = ''; while (low <= high) { const mid = (low + high) >> 1; const candidate = text.slice(0, mid); const width = measureText(ctx, candidate + '…', fontStyle); if (width <= maxWidth) { result = candidate; low = mid + 1; } else { high = mid - 1; } } return result + '…'; }

注意Map缓存如果无限增长,在数据量很大的场景下会占用不少内存,建议在表格组件销毁时调用一次clearCache。

3. 落地实现

3.1 工程搭建与类型准备

项目直接用Vite初始化,模板选择Vue3 + TypeScript。这一步不复杂,但要注意Vite版本和Node版本的兼容性,Node 18以上基本没问题。装依赖的时候只需要vue和vite相关的东西,表格组件本身不依赖任何第三方UI库。

类型是整个组件的地基,先把类型定义写清楚,后面写逻辑会顺很多。我定义的Column类型支持泛型,format函数里能拿到当前行数据,便于做自定义格式化。组件Props也用了泛型,让外部使用组件时能得到完整的类型推导:

// types.ts export interface Column<T = any> { key: string; title: string; width: number; align?: 'left' | 'center' | 'right'; format?: (value: unknown, row: T) => string; } export interface VirtualTableProps<T = any> { data: T[]; columns: Column<T>[]; rowHeight?: number; headerHeight?: number; width?: number | string; height?: number | string; }

TS的Pick和Exclude在这里能派上用场。比如你的数据行有password、internalRemark这类不需要展示的字段,可以用Exclude把需要展示的字段约束出来,让Column的key只能填这些字段名:

type PublicUser = Exclude<keyof UserRow, 'password' | 'internalRemark'>; type UserColumn = Column<Pick<UserRow, PublicUser>>;

3.2 组件主体结构

组件用<script setup lang="ts">写成,核心思路是:一个滚动容器负责产生滚动条和监听滚动,Canvas铺在容器内部绝对定位,不参与文档流滚动。滚动容器的子元素里放一个高宽等于表格总尺寸的占位div,用来撑开滚动条。

<script setup lang="ts"> import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'; import type { Column, VirtualTableProps } from './types'; import { measureText, truncateByWidth } from './pretext'; const props = withDefaults(defineProps<VirtualTableProps<any>>(), { rowHeight: 32, headerHeight: 40, }); const scrollRef = ref<HTMLDivElement | null>(null); const canvasRef = ref<HTMLCanvasElement | null>(null); let ctx: CanvasRenderingContext2D | null = null; let rafId = 0; let scrollTop = 0; let scrollLeft = 0; let viewportWidth = 0; let viewportHeight = 0; </script> <template> <div ref="scrollRef" class="vtable-scroll" @scroll="handleScroll"> <div class="vtable-spacer" :style="{ width: totalWidth + 'px', height: totalHeight + 'px' }" ></div> <canvas ref="canvasRef" class="vtable-canvas"></canvas> </div> </template>

滚动容器要设置overflow: auto,canvas通过CSS绝对定位到滚动容器内部左上角,并且pointer-events设为auto来接收鼠标事件。这里有一个关键点:canvas不能跟着滚动内容一起走,所以我们把它固定住,滚动信息靠scrollTop和scrollLeft驱动重绘。

3.3 初始化与绘制流程

mounted之后先计算可视区域宽高,再初始化Canvas。高分屏的适配必须在初始化的时候做,否则文字和线框会发虚。核心逻辑是:把Canvas的物理像素设置成CSS像素乘以devicePixelRatio,再通过ctx.scale把坐标系缩放回去,这样后面所有绘制代码都按CSS像素来写。

function setupCanvas() { const canvas = canvasRef.value!; const parent = scrollRef.value!; const rect = parent.getBoundingClientRect(); viewportWidth = rect.width; viewportHeight = rect.height; const dpr = window.devicePixelRatio || 1; canvas.width = Math.floor(viewportWidth * dpr); canvas.height = Math.floor(viewportHeight * dpr); canvas.style.width = viewportWidth + 'px'; canvas.style.height = viewportHeight + 'px'; ctx = canvas.getContext('2d'); ctx!.scale(dpr, dpr); }

绘制函数是整个组件的核心。它做的事情很直接:清空画布,计算可视行列范围,绘制表头,绘制主体单元格,绘制网格线。绘制主体时,行方向用Math.floor根据scrollTop算出startRow和endRow,列方向用2.1节的两个函数算出startCol和endCol。然后双重循环,只处理这些行列。

每个单元格的绘制分四步:算坐标、画背景、格式化文本、画文字。文字内容通过Pretext模块统一处理,formatCellValue负责把数字、日期、空值转成展示字符串,truncateByWidth负责超宽截断。绘制文本前一定要设font,并且textBaseline和textAlign要根据列配置设置,否则文字位置会很别扭。

function drawBody(startRow: number, endRow: number, startCol: number, endCol: number) { if (!ctx) return; const { data, columns, rowHeight, headerHeight } = props; ctx.textBaseline = 'middle'; ctx.font = '14px -apple-system, "Segoe UI", Roboto, sans-serif'; for (let r = startRow; r <= endRow; r++) { const y = headerHeight + r * rowHeight - scrollTop; const row = data[r]; let x = 0; for (let c = startCol; c <= endCol; c++) { const col = columns[c]; const rowX = x - scrollLeft; const cellWidth = col.width; // 背景 ctx.fillStyle = r % 2 === 0 ? '#ffffff' : '#f8f9fc'; ctx.fillRect(rowX, y, cellWidth, rowHeight); // 文本 const rawValue = row[col.key]; const text = formatCellValue(rawValue, col); const maxTextWidth = cellWidth - 16; const finalText = truncateByWidth(ctx, text, maxTextWidth, '14px -apple-system, "Segoe UI", Roboto, sans-serif'); ctx.fillStyle = '#1f2329'; ctx.textAlign = col.align || 'left'; const textX = col.align === 'center' ? rowX + cellWidth / 2 : col.align === 'right' ? rowX + cellWidth - 8 : rowX + 8; ctx.fillText(finalText, textX, y + rowHeight / 2); x += cellWidth; } } }

3.4 滚动与点击交互

滚动事件监听在scrollRef这个大容器上。因为滚动事件触发频率非常高,直接用rAF节流——每次滚动先取消上一次未执行的绘制请求,然后在下一次动画帧里执行重绘。这样最多一帧重绘一次,既不会漏掉最终结果,也不会把CPU吃满。

function handleScroll() { const el = scrollRef.value; if (!el) return; scrollTop = el.scrollTop; scrollLeft = el.scrollLeft; if (rafId) cancelAnimationFrame(rafId); rafId = requestAnimationFrame(draw); }

点击事件稍微需要做点数学换算。鼠标事件里的clientX和clientY是相对于浏览器窗口的,先减掉canvas的getBoundingClientRect().left和top,得到canvas内的CSS坐标,再加上scrollLeft和scrollTop,就得到了表格数据坐标系里的坐标。列方向继续减去表头左侧的固定列宽度,再通过columnOffset数组反查列索引;行方向直接用y坐标除以rowHeight向下取整。

function handleCanvasClick(e: MouseEvent) { if (!canvasRef.value) return; const rect = canvasRef.value.getBoundingClientRect(); const clickX = e.clientX - rect.left + scrollLeft; const clickY = e.clientY - rect.top + scrollTop; const rowIndex = Math.floor((clickY - props.headerHeight) / props.rowHeight); if (rowIndex < 0 || rowIndex >= props.data.length) return; const colIndex = findColumnIndex(colOffset.value, clickX); if (colIndex < 0) return; emit('cell-click', { rowIndex, colIndex, row: props.data[rowIndex], column: props.columns[colIndex], }); }

findColumnIndex同样用二分查找,逻辑和getStartColumn相反,这里就不重复贴了。事件别忘了在onBeforeUnmount时移除,canvas和scroll容器都要removeEventListener。

3.5 数据更新与尺寸变化

表格组件不能只接受一次性数据,数据变化要能触发重绘。我直接用watch监听props.data和props.columns,数据变化后先在nextTick里等DOM更新完,再重新计算totalWidth和totalHeight,最后调用一次draw。注意如果表格的总高度变了,scrollRef的scorllTop可能会超过新的最大滚动高度,需要做个clamp。

容器尺寸变化是另一个容易踩坑的地方。窗口缩放时,canvas的物理尺寸不会自动变,需要监听scrollRef的尺寸变化。我用的ResizeObserver,回调里重新拿getBoundingClientRect、重设canvas尺寸并重绘。别忘了把observer在卸载时断掉。

还有一个细节:当数据量特别大时,computed里计算totalWidth和totalHeight的开销不可忽略。我在组件里把这两个值存成了临时变量,在setData和setColumns时同步更新,避免computed每次访问都重新遍历一遍columns或data。实测在大数据量下,这种小优化能减少不少卡顿。

4. 常见问题与排查实录

4.1 高分屏下文字发虚

这个问题凡是第一次用Canvas画表格的人基本都会遇到。不设置devicePixelRatio时,浏览器会把Canvas物理像素和CSS像素一比一映射,在有Retina屏的笔记本上,文字边缘就明显发虚。解决方式就是初始化时按设备像素比放大Canvas尺寸,再用ctx.scale缩回坐标系,3.3节已经写清楚了。

但要注意一个坑:每次重新设置canvas.width或canvas.height,画布内容会被清空,而且context的缩放状态也会重置。所以如果要做尺寸自适应,必须每次调整后重新设置scale,不能只在初始化时做一次。我之前就是在resize回调里改了canvas的宽高,忘了重新scale,结果画出来的内容在窗口缩放后偏小且位置错乱。

4.2 快速滚动时白屏与闪烁

白屏是虚拟表格最影响体验的问题。排查后发现两个原因:一是rAF虽然限制了重绘频率,但滚动事件里scrollTop已经更新了,如果画布内容还没画出来,视觉上就有一小段空白;二是浏览器在Canvas内容更新不及时的情况下,可能会用底色填充画布区域。

解决方案有两层:第一层是在可视区域上下加缓冲行,让绘制范围比可见范围宽一些;第二层是在scroll事件里同步更新一个“需要重绘”的标记,如果上一帧还没有执行,就在同一个scroll事件回调里立即触发一次重绘,保证滚动时画布内容尽量跟上。实测下来,加缓冲行对观感提升最明显。

4.3 文本截断出现半个字或省略号位置不对

出现这个问题的根源,几乎都是measureText用的字体和fillText实际绘制时用的字体不一致。我排查过一个案例:初始化时ctx.font设置的是14px,但截断函数里设置的是13px,导致测出来的宽度偏小,画出来时文字还是比列宽多一点,省略号的位置就歪了。

另外还有一点,中文和英文在同一个字体下宽度差异很大,省略号“…”本身也有宽度,截断时必须把省略号的宽度一起算进去。我在truncateByWidth里每次都测candidate + '…',就是为了把省略号占的宽度提前考虑进去。如果发现截断不准,先检查字体设置是否一致,再看看有没有把“...”当成“…”用——半角省略号宽度和全角省略号是不一样的。

4.4 TS类型推导报错与泛型踩坑

泛型组件在Vue3里有一个容易踩坑的地方:<script setup>下泛型参数需要用props的withDefaults来定义,但withDefaults对泛型类型有时会推断不准确。我的做法是外部包一层函数组件,或者在Props里用any兜底,然后在具体业务组件里再转成具体类型。这样虽然损失了一点类型安全,但组件内部逻辑不会因为类型体操而变得不可维护。

Pick和Exclude在表格场景里最常用来约束Column的key。比如UserRow有id、name、email、password,列配置只允许填展示字段:

type DisplayFields = Exclude<keyof UserRow, 'password' | 'internalRemark'>; type UserColumn = Column<Pick<UserRow, DisplayFields>>;

这里有一个坑容易让人困惑:Pick和Exclude都只能用字面量类型做联合类型,如果DisplayFields来自运行时数据,类型就推导不了。解决方案是把字段白名单定义在类型层,不要试图从运行时数组反向推导。TS的类型系统是编译期的,它管不了运行时的数组内容。

4.5 常见问题速查表

现象可能原因解决方案
文字发虚未处理devicePixelRatio按DPR放大canvas并ctx.scale
滚动白屏绘制范围不足上下各加2行缓冲并同步重绘
截断位置错误测量字体与绘制字体不一致统一用同一font字符串
点击行列错位没有加上scrollLeft/scrollTop坐标换算时补上滚动的量
resize后错乱重置canvas后未恢复scale每次resize后重新scale
数据更新不刷新未watch数据或未调drawwatch后nextTick重绘
长时间表格卡顿measureText缓存无限增长定期清空Pretext缓存

5. 性能实测与后续扩展

5.1 性能表现对比

我在自己的项目里做了几组简单对比,表格数据量分别是1万、5万、10万行,列数固定为30列,行高32px,业务机器是MacBook Pro和一台普通Windows办公本。DOM方案用的el-table,虚拟滚动方案用vue-virtual-scroller,Canvas方案用本文这套实现。

数据量在1万行时,三个方案滚动都还算流畅,但el-table首次渲染耗时明显更高,大概在2到3秒;vue-virtual-scroller首次渲染能压在1秒内,滚动帧率不错。数据量到10万行时差距就拉开了:el-table基本没法用,切页都要卡几秒;vue-virtual-scroller的初始化时间大约1.5秒,滚动过程中偶尔有掉帧;Canvas方案首次绘制大约200毫秒,滚动时通过Chrome Performance面板观察,单帧绘制耗时稳定在2到5毫秒,帧率能维持在60fps附近,内存占用也低很多,因为画布固定就是视口那么大。

这段对比不严谨,但能说明一个趋势:数据量越大,Canvas方案的性能优势越明显。如果你的业务数据量长期在5万行以下,DOM虚拟滚动已经够用;到了10万行以上,Canvas基本是唯一能保持顺滑体验的选择。

5.2 固定列、多行文本与导出图片

这套框架后续扩展空间很大。目前项目里已经加了固定左列功能:固定列不跟随scrollLeft移动,绘制时先画固定列,再画普通列区域,普通列的起始x坐标要加上固定列总宽度。表头的左右固定逻辑类似,处理起来要稍微细心一点,但原理不复杂。

单元格多行文本也是可行的,把单行fillText换成按换行符分割后循环fillText即可,行高需要预计算。如果想导出表格图片,直接用canvas.toDataURL导出当前画布内容,这对做报表分享非常实用,也是Canvas方案独有的好处。再往后还可以做树形表格、单元格编辑、列拖拽排序,核心绘制逻辑保持稳定,扩展模块以插件形式挂上去就行。

5.3 对这套方案的一些个人体会

做了这个项目之后,我对“虚拟表格”这件事有了更实际的理解。它不是一个高不可攀的技术,核心就是虚拟滚动那一套比例换算加上Canvas绘制基本功,难的是各种边界情况的处理。你在项目中一定会遇到高分屏、字体测量、滚动事件频率、resize适配这些问题,每解决一个,都会对浏览器渲染和Canvas底层机制有更深的理解。

我个人在实际操作中的体会是:不要一上来就想着做全功能表格组件,先保证十万行数据能流畅滚动,再慢慢加交互和视觉细节。Pretext这个模块虽然小,但它在整个项目里承担了文本测量、格式化和缓存的责任,让核心绘制函数保持简洁。以后如果要在其他端复用,直接把这个模块单独拎出去就行。我做第一版的时候花了三天,其中大部分时间都耗在调试字体测量和滚动闪烁上,所以这篇文章里我把这些坑尽量都写了出来,希望能帮你少走点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询