花了72小时,把手上这个代号叫libtv的无限画布组件翻来覆去压了三轮,最后一晚盯着数据表格静坐了十几分钟。整个测试过程比预期中要折磨得多,但真正让我沉默的不是数字本身,而是三套渲染方案在10万级元素面前,全都露出了各自的底裤。先说结论:无限画布的性能天花板,从来不在渲染后端选哪个,而在你如何看待"数据"这两个字。这篇文章把72小时里做了什么、为什么这么做、踩了哪些坑,以及最后怎么从数据里找到优化方向,完整记录下来,给正在做或者准备做类似画布方案的同行一个参考。
1. 无限画布压力测试到底在测什么
1.1 无限画布的"无限"根本不来自渲染层
无限画布这个概念,用过Figma、Miro或者tldraw的人都不陌生。它模拟的是一块没有边界的物理白板,用户可以用鼠标拖拽、滚轮缩放,在任意位置放置内容。但技术实现上和"无限"两个字完全不沾边。任何渲染引擎能处理的像素都是有限的,所谓无限,靠的是一套"相机"逻辑:整个画布维持一个世界坐标系,屏幕只是这个坐标系上的一个可移动、可缩放的视口。每次平移和缩放,本质上是视口在世界坐标系里的位置和缩放比例发生了变化。
理解了这套模型,就会得出一个核心推论:你永远不需要渲染"所有元素",只需要渲染"视口内的元素"。这也是为什么小规模画布Demo跑起来个个流畅,因为数据量小时,裁剪逻辑根本不会被触发,所有元素堆在一起也才几百个节点,浏览器闭着眼也能画完。可一旦数据量来到1万、10万,裁剪策略、空间索引、命中测试、增量更新这些工程问题全部浮出水面。所以做无限画布压力测试,测的不是渲染引擎的极限,而是整个数据组织和调度体系在"看似无限"的数据量下能不能继续维持交互流畅度。
1.2 libtv 是什么,我要验证什么问题
libtv是我这边长期维护的一个内部无限画布组件,功能上参考了开源社区这类产品的主流设计:支持矩形、线条、文本、图片占位等基础元素,提供框选、拖拽、多选、缩放、图层管理这些常规操作。组件从设计之初就预留了三种渲染后端的接口:基于DOM transform的方案、基于Canvas 2D的方案、基于WebGL的方案。日常小数据量内部试用时,三种方案肉眼看不出区别,切换后端只是配置文件里改一个字段的事。但团队里一直有争议,到底哪个方案能扛住真实业务里那种动辄几万甚至几十万节点的白板数据。这个争议拖了很久,因为谁都没有真正把数据量推到那个量级去验证过。
所以这轮压力测试的目标非常明确:分别用三种渲染后端,在同一批测试用例下跑到20万元素量级,对比FPS、长任务、内存增长、交互响应延迟等核心指标,找出各自的瓶颈点,并且为后续优化提供可量化的依据。整个测试断断续续跑了72小时,其实不是持续跑,而是分三个阶段各花了一整天,因为每一轮跑完都要改脚本、修数据、重新校准指标,拖延了非常多的额外时间。
1.3 为什么 JMeter 压测工具救不了这个场景
这里必须说清楚一个很容易被误解的地方。很多人一听到"压力测试"四个字,第一反应就是JMeter。搜索的时候也会看到大量jmeter接口压测教程,甚至还有不少网站压测工具源码。这些工具确实在服务端领域非常成熟,但拿它们来测无限画布,属于用错了尺子。JMeter核心能力是模拟大量HTTP并发请求,验证的是服务器接口的吞吐量、响应时间、错误率,它看不到浏览器内部的渲染管线,更拿不到一帧画面的绘制耗时。你要测的是"画布元素在用户的Chrome里画出来卡不卡",JMeter连浏览器环境都没有,自然无从谈起。
不是说这类工具没用。如果你的无限画布应用背后有协作接口、存储接口,用JMeter去压后端是合理的,那属于服务端承载能力验证。但前端画布性能是另一层东西,必须用浏览器自动化工具直接驱动真实渲染环境采集数据。这也是为什么这轮测试我从头到尾用的都是Playwright脚本,配合Chrome DevTools Protocol和Performance API来收集指标。另外也要提一句,某些所谓CC压测类工具主要目的就是制造大量请求冲击服务,和我们要做的渲染性能验证完全是两条路,前者测的是服务端的韧性,后者测的是客户端的渲染质量,千万不要混为一谈。
2. 测试方案设计:指标、场景和工具链
2.1 五个核心指标:不是只有 FPS 一个数
很多性能测试文章喜欢把FPS当成唯一的衡量标准,但实际跑过压测的人都知道,FPS均值有时候会骗人。画面可能大部分时间都很流畅,中间突然卡了500毫秒,均值只掉几个点,用户体验却已经崩了。所以这轮测试我同时采了五个维度的数据。
第一是FPS均值以及FPS最低值,最低值比均值更能暴露问题。第二是长任务次数,浏览器主线程只要出现超过50毫秒的连续任务就会被PerformanceObserver标记为Long Task,长任务越密集,代表页面越容易出现可感知的卡顿。第三是内存增长曲线,尤其在Canvas和WebGL方案里,显存和堆内存变化非常关键,采集方式是通过Chrome DevTools Protocol的Memory域定期拿堆快照。第四是交互响应延迟,这里具体定义为用户触发框选、缩放、平移等操作到画面完成一帧反馈的时间差。第五是主线程耗时分布,把脚本开销、样式计算、绘制、合成在Performance面板里的时间占比拆开,方便定位瓶颈到底在哪一层。
单看FPS的话,WebGL方案在10万节点下也能跑得很好看,但把长任务和内存数据铺开,问题就藏不住了。所以这五个指标是配套的,任何一个单拎出来都可能得出错误结论。
2.2 六类压测场景与数据规模阶梯
测试场景设计上,我没有一上来就往10万节点硬塞,而是按数据规模分了阶梯,并且每个规模下都跑了六种不同的交互操作。规模阶梯是:空画布、1千、5千、1万、5万、10万、20万。六种交互操作分别是:视口平移、滚轮缩放、框选局部元素、拖动单个元素、批量新增节点、批量删除节点。每轮操作都固定执行时长和操作轨迹,保证不同后端之间的数据可对比。
这里有一个非常重要的细节,就是测试数据不能是纯矩形堆叠。真实业务画布里的元素形状是混杂的,有文本、有图片占位、有连线和箭头。如果全用矩形,Canvas 2D和WebGL会把它们当作简单图元处理,性能会好看很多,但掩盖了真实场景下文本排版和复杂路径绘制带来的额外开销。所以我构造了一份混合数据集:60%的矩形、25%的文本块、10%的连线、5%的图片占位,并且它们的分布不是均匀的,而是按业务习惯在画布中央区域更密集、四周稀疏一些,这样更贴近用户实际使用的分布特征。
每个规模阶梯跑完一轮交互操作后,脚本会强制等待2秒再采集指标,确保上一轮操作的动画和异步任务已经结束,避免指标互相污染。这个细节看起来不起眼,实际上对数据准确性影响极大,后面我会专门展开说。
2.3 测试工具链:从 Playwright 到 CDP 指标采集
工具链方面,核心是Playwright + TypeScript。选择Playwright而不是Puppeteer,主要原因是它对多浏览器支持更好,后续如果要复测Safari或Firefox不用重写脚本。测试中每个用例都通过Playwright启动独立的Chromium实例,加载libtv的测试页面,然后通过page.evaluate注入探针脚本收集指标。
这里给一个最基础的FPS探针写法,跑画布压测的脚本里基本都用这个思路:
// 注入到测试页面的 FPS 探针 let frameCount = 0; let lastReportTime = performance.now(); let peakInterval = 0; let totalInterval = 0; let sampleCount = 0; function onFrame(now) { const delta = now - lastFrameTime; lastFrameTime = now; if (delta > peakInterval) { peakInterval = delta; } totalInterval += delta; sampleCount++; frameCount++; if (now - lastReportTime >= 1000) { const avgFps = Math.round(1000 / (totalInterval / sampleCount)); window.__fpsReport = { avgFps, peakIntervalMs: Math.round(peakInterval), longTasks: window.__longTaskCount || 0 }; totalInterval = 0; sampleCount = 0; peakInterval = 0; lastReportTime = now; } requestAnimationFrame(onFrame); } requestAnimationFrame(onFrame); window.__longTaskCount = 0; new PerformanceObserver((list) => { window.__longTaskCount += list.getEntries().length; }).observe({ entryTypes: ['longtask'] });注意这里统计的是帧间隔而不是每秒帧数,因为通过requestAnimationFrame回调计算FPS时,如果浏览器掉帧严重,回调次数会同步减少,直接用"回调次数/时间"算出来的FPS反而会虚高。用帧间隔的平均值和峰值来判断卡顿更可靠。
3. 72小时实测:三套方案的真实数据
3.1 第一天:DOM transform 方案翻车实录
第一阶段的测试对象是DOM transform方案。这套方案实现起来最直观,每个画布元素都是一个真实的DOM节点,画布整体变形通过设置transform属性来驱动。小数据量下它的开发效率和调试体验都很好,CSS的交互能力也现成可用。但我心里其实有预期,DOM方案在数据量上去之后大概率撑不住,只是没想到翻车来得这么快。
数据是这样的:1千节点时平均FPS稳定在58左右,表现很好;5千节点开始出现零星掉帧,平均FPS降到46;到1万节点时,平均FPS只有27,而最低FPS已经掉到了个位数。最惨的是框选场景,用户拖拽出选框的每一帧,都需要对候选元素做一次几何相交判断,同时修改被选中元素的样式类名。当画布上有1万多个DOM节点时,每一次框选移动都会触发大规模样式重算,框选过程几乎是在一格一格跳着走。
内存方面,DOM方案在10万节点时已经膨胀到接近1GB的堆内存,实际渲染的节点数其实只有视口内的几百个,但所有DOM对象都常驻在文档里,浏览器光是维护这棵巨型DOM树的样式和布局结构就消耗了大量资源。到20万节点时,页面基本处于"能打开但没法治"的状态,点击任何按钮都要等好几秒才有响应,测试那台M1 MacBook Pro的风扇直接起飞。
这组数据验证了一个老生常谈的判断:DOM方案只适合几百到几千节点的轻量画布,它的瓶颈不是绘制本身,而是DOM对象数量超过一定阈值后,浏览器对节点树的管理成本呈指数级上升。而且这种方案的卡顿很难通过优化代码逻辑来救,除非做节点虚拟化,让视口外的DOM节点真正从文档里卸载,但那样方案的复杂度就已经接近Canvas了。
3.2 第二天:Canvas 2D 方案让我看到了希望,也看到了天花板
第二阶段的测试对象是Canvas 2D方案。所有元素统一绘制在一个或多个Canvas图层上,渲染循环里根据视口位置对元素做裁剪,只绘制视口内的对象。这套方案的预期是能支撑到5万到10万节点,实际测试结果也基本符合预期,但它暴露出来的问题比DOM方案更隐蔽。
数据层面,5万节点时Canvas 2D平均FPS能到53,最低也维持在30以上。10万节点时平均FPS降到38,框选操作的响应延迟从5万节点的220毫秒涨到了800毫秒。这里的主要瓶颈是每帧绘制指令过多:即便做了视口裁剪,一个动辄覆盖1/4屏幕的缩放操作后,视口内仍然可能有2万到3万个元素要在一帧内重新绘制,Canvas 2D的逐图元绘制方式在这么多指令面前开始力不从心。20万节点时平均FPS只有21,而且在连续缩放操作后会出现主线程长时间阻塞的情况,最长一次长任务接近900毫秒,表现就是用户转动滚轮后画面突然停住,然后"啪"一下跳到目标位置。
更让人纠结的是内存。Canvas 2D方案在2倍高DPI屏上默认会创建一个物理像素4倍于CSS像素面积的画布缓冲,一个常见尺寸的视口对应到物理像素就是8K级别的绘制表面,内存占用会迅速放大。测试中Canvas 2D方案在10万节点时总内存已经到了1.6GB,其中相当一部分是浏览器为Canvas分配的后备缓冲。而且这个方案还有一个隐藏问题:命中测试。用户点击某个元素时,Canvas 2D没有现成的DOM节点可以做事件命中,需要自己遍历视口内所有元素的几何信息做点选判断。元素一多,命中测试本身就变成了性能热点。
也就是说,Canvas 2D把压力从渲染层转移到了指令调度和内存管理上。它的极限大概在5万节点左右,再往上就需要很多额外的优化手段,比如多Canvas分层、离屏缓存、命中测试加速,否则体验会迅速劣化。
3.3 第三天:WebGL 方案和那个让我沉默的结论
第三阶段测试WebGL方案,这是我最期待也最失望的一个环节。WebGL的渲染性能理论上远强于Canvas 2D,因为它把绘制压力交给了GPU。实测数据确实好看:20万节点时平均FPS还能到55,即便框选操作也能稳定在40以上。如果只盯着FPS,WebGL方案似乎就是终极答案。
但我沉默的地方在于另外两个数据。第一个是显存和内存消耗:20万节点的场景下,WebGL需要上传所有节点的几何数据、颜色数据、变换矩阵到GPU缓冲区,仅顶点数据就占用了接近800MB显存,再加上纹理、索引缓冲,整体GPU内存占用到了1.5GB级别。在用户真实设备上,尤其是只有集成显卡的办公笔记本,这个量级的内存分配很可能直接触发GPU进程崩溃或者白屏。测试中我用一台配置较低的Windows笔记本复测时,就复现了两次标签页崩溃。
第二个是CPU侧的瓶颈。WebGL虽然把绘制丢给了GPU,但每一帧之前仍然需要在CPU侧做视口裁剪、矩阵计算、状态提交,当元素数量超过10万时,CPU侧的准备时间越来越长。即便在M1 Pro上,一帧中CPU耗时也经常超过80毫秒,这意味着即使GPU绘制只花了5毫秒,整帧依然卡顿。WebGL方案的性能分布不是均匀的,而是呈现"清晰、清晰、突然卡一下"的模式,这种体验比持续的轻微掉帧更让人难受。
72小时的数据全部跑完,我把三种方案的曲线放在同一个表格里:DOM方案死得最干脆,Canvas 2D在5万节点以上开始挣扎,WebGL看起来最强却死于资源不合理消耗。真正让我沉默的结论是:这三种方案的性能差距,远没有"数据规模上量之后带来的工程复杂度"差距大。无论选哪一种后端,10万节点的画布要稳定运行,都必须解决数据索引、视口裁剪、增量更新、资源回收这些与渲染后端无关的问题。而这些问题,恰恰是最难做、最不性感、也最容易被项目排期忽视的部分。
4. 从压测结果反推的优化路径
4.1 渲染层:视口裁剪、空间索引与分层绘制
压力测试最大的价值不是证明"哪个方案不行",而是告诉你优化该往哪个方向打。第一刀必须砍向数据组织。目前三套方案都做了基础的视口矩形裁剪,但基础裁剪只能排除完全在视口外的元素,对那种超大元素、跨视口元素的处理并不好。更合理的做法是引入空间索引结构,我在后续优化中用了网格索引,把世界坐标系按固定大小切成网格,每个网格记录落在其中的元素ID列表。查询视口内元素时,只需要遍历与视口相交的那几个网格,而不是遍历全量元素做矩形相交判断。这个改动让命中测试和视口裁剪的时间复杂度从O(N)降到了O(网格数)。
第二刀是分层绘制。Canvas 2D方案里,把所有元素画在同一个Canvas上,意味着任何交互操作后都需要重绘全部可见元素。但实际场景里,画布元素可以分为静态层和动态层:静态层是不常变化的内容,比如底图、已经落定的图形,可以离线绘制到独立的离屏Canvas上,只有它们发生变化时才重绘;动态层是正在被拖拽、缩放、高亮的元素,每帧都跟随交互更新。用户交互时,只需要把静态层的离屏Canvas整体绘制一次,再在上面叠加绘制动态层,成本会低很多。这个思路在WebGL方案里同样适用,可以用多个渲染纹理层来模拟。
4.2 交互层:坐标换算、事件节流与增量更新
无限画布的交互层是另一个容易被低估的瓶颈。用户看到的是一次平移,背后的计算链路是:鼠标事件坐标 -> 屏幕坐标转世界坐标 -> 更新视口矩阵 -> 通知所有元素重绘。如果这个链路上有任何一个环节对全量数据做了遍历或者触发了全量重绘,都会导致交互卡死。测试中发现,DOM方案在框选场景的卡顿,很大程度就是因为在每次mousemove里都对视图模型做了全量遍历,然后同步更新了选中元素的样式状态。
优化思路是三层:第一层,所有视图模型的更新都改为增量方式,只标记受影响的数据范围,不触发全量diff;第二层,把可见元素的属性变更收集起来,通过requestAnimationFrame合并在同一帧统一渲染,避免mousemove事件每次触发都立刻操作视图;第三层,事件处理中的坐标换算不要每次从DOM读取offsetWidth之类的布局属性,而是在resize时缓存好这些值,避免强制同步布局。这些改动不算复杂,但对稳定性提升非常明显。
4.3 内存与GC:压力测试暴露的隐形杀手
72小时里最耗时间的不是跑测试,而是排查内存问题。Canvas 2D和WebGL方案都出现了内存随时间持续增长的现象,一开始以为一定存在引用泄漏,逐段排查后发现罪魁祸首有两个。第一个是对象池设计缺失,拖拽、缩放、框选这类高频操作中,每一帧都在创建新的矩形对象、矩阵对象、事件对象,这些对象很快变成垃圾等待回收,主线程频繁触发GC导致帧率波动。解决方案是建立对象池,复用临时对象,避免每帧产生大量小对象。
第二个是纹理和缓冲的无界增长。WebGL方案在高频更新时,如果每次元素变化都创建新的缓冲对象而非复用已有缓冲,GPU显存就会一点点涨上去。Canvas 2D方案里,如果离屏Canvas不断变大而不缩放,内存也会持续膨胀。这里要给所有离屏渲染层设一个最大尺寸约束,超过阈值就缩放重建,避免为覆盖超大视口而无限扩大Canvas面积。内存优化的最终目标不是"不占内存",而是让内存占用在场景切换后回到稳定基线,而不是一路只增不减。
5. 常见问题与排查技巧实录
5.1 为什么 FPS 数据看着漂亮,实际体验还是卡
这是我在测试中最常遇到的问题,也是很多人对性能优化产生误解的根源。FPS均值是60,但用户就是觉得卡,原因在于均值掩盖了方差。如果一秒钟内前500毫秒跑满60帧,后500毫秒卡成10帧,平均值是30帧上下,看着还能接受,但用户的真实感受是画面明显地一顿一顿。所以判断画布性能时,务必同时看两个数据:最小帧间隔和长任务次数。帧间隔突刺超过100毫秒,用户就一定能感知到卡顿。我在探针脚本里记录的peakIntervalMs就是这个用途,它比avgFps敏感得多。
5.2 偶发性卡顿怎么定位
偶发卡顿最坑的地方在于不好复现,往往是特定数据规模加特定操作序列叠加出来的。我的排查套路是三步走:第一步,在Playwright脚本里精确复现操作序列,把平移轨迹、缩放倍率、停留时间全部固定;第二步,用page.startTracing开启浏览器性能追踪,采集包含主线程任务的trace文件;第三步,打开Chrome的Performance面板加载trace,用火焰图找到超过50毫秒的长任务,看它落在哪个函数。绝大多数偶发卡顿最终都能定位到GC、字符串拼接、数组排序这类容易被忽略的普通代码上。
5.3 低端机怎么模拟,数据才可信
如果只在高端开发机上做压测,数据参考价值有限。我的建议是至少找一台低端机器做基线对照。没有实机的话,用Chrome的手动降级方式也能模拟:在启动参数里加上--disable-gpu可以关闭GPU加速,把绘制完全压到CPU上;DevTools的Performance面板里可以设置CPU降速倍数(4倍、6倍、20倍),模拟低端处理器的计算能力。不过要记住,CPU降速模拟的只是计算性能,内存带宽、显存容量这些指标还是模拟不了,有条件还是要拿真机复测一轮。这次测试里WebGL方案在低端笔记本上复现的崩溃,就是在DevTools模拟中完全没有出现的。
5.4 画布压测脚本的三个常见坑
第一,不要用setInterval去采样FPS。浏览器对后台页面和低力度定时器会有节流策略,setInterval在页面主线程繁忙时会被跳过,导致采样结果失真,用requestAnimationFrame回调来统计帧间隔才是可靠做法。
第二,必须先预热再采样。画布首次渲染时,数据初始化、纹理上传、索引构建这些一次性开销会拖慢前几百毫秒的帧率,如果直接把这部分算进统计,所有方案的成绩都会被低估。我的做法是让画布加载完数据后,在测试操作前先静置3秒,等所有异步任务完成,再开始操作和指标采集。
第三,测试数据不要全部使用矩形,形状不能太规则。真实画布中文本块的开销远大于矩形,文本排版、字体渲染、矢量路径拆解都会消耗CPU。纯矩形的测试数据集会让Canvas 2D方案的成绩虚高至少20%,这一点在跨方案对比时尤其关键。
另外说一个很多人忽视的点:测试脚本本身要避免对指标产生干扰。如果每个操作步骤间没有固定等待时间,脚本执行速度就会波动,导致不同用例的数据不可比。我在脚本里所有操作之间统一固定了500毫秒的等待,并关闭了所有动画和过渡效果,确保每次测试的节奏完全一致。这些细节看起来琐碎,但压力测试的价值完全建立在数据可信度之上,数据不可信,结论就全是空中楼阁。
跑了这轮压测之后,我对无限画布的性能问题有了更清醒的认知。以前选型时争论的是Canvas还是WebGL,现在回头看,这个争论的优先级其实没那么高。真正决定画布能不能撑住大规模数据量的,是工程上有没有把空间索引做好、有没有控制内存增长、有没有把交互事件链路做干净。我在实际测试中最深的一次感受是,WebGL方案在FPS上赢得很漂亮,但换到用户视角的稳定性和资源占用,反而成了最不省心的一种选择。如果你也要做类似方向,我的建议是先想清楚目标数据量级,再倒推渲染方案和优化深度,不要一开始就在某个渲染技术上押上全部赌注。