1. 性能瓶颈前的“黑暗时刻”:为什么OrcaTerm要死磕渲染
几乎每个 Web 终端产品走到某个阶段,都会撞上一堵墙:功能堆得越多,界面反馈越迟钝,输入一个字符要等上百毫秒才有回显,滚动日志时画面一帧一帧地“撕裂”过去。OrcaTerm 在早期版本里也踩过这个坑,而且踩得相当痛。
OrcaTerm 本质上是一个运行在浏览器里的终端模拟器,用户通过它连接远程服务器、操作容器、调试代码、查看日志。这类工具的体验核心就一个字:快。这里的“快”不是页面加载快,而是每一次按键、每一次输出、每一次滚动,都要像本地终端一样“零感知”。但浏览器不是原生桌面环境,它有事件循环、有渲染管线、有内存回收,还有各种诡异的合成层策略。如果不做设计上的根本性改造,性能天花板就在那里。
当时我们内部做了一个简单的基准测试:连续打印 5000 行日志,然后快速滚动到底部。结果是灾难性的——FPS 掉到个位数,CPU 占用飙到 120% 以上,输入框卡顿到让人怀疑人生。同一个测试跑在 xterm.js 底层的原生终端上,流畅度完全不在一个量级。那会儿我们才意识到,OrcaTerm 的问题不是“某个函数写慢了”,而是整条渲染链路从架构上就不适合高吞吐输出场景。
后来我们花了大半个迭代周期,把渲染模块推倒重做,才一步一步把性能拉到竞品第一梯队。这个过程中踩过的坑、验证过的方案、总结出的经验,我觉得值得单独写一篇文章记录下来。这篇文章不会只讲结论,还会把性能问题的根因、优化方案的选型理由、以及落地时容易翻车的细节全部摊开来讲。
如果你也在做类终端产品、Web 实时展示工具,或者是 WebSocket 高吞吐数据可视化方向,这篇文章应该能帮你少走很多弯路。就算你只是对“浏览器里怎么又快又稳地渲染大量文本”这个话题感兴趣,后面的内容也值得看一下。
2. 根因分析:先搞清楚卡顿到底卡在哪里
2.1 按需渲染到整页刷新:一个错误的“偷懒”设计
初版 OrcaTerm 的渲染思路很“朴素”:终端缓冲区里有多少行,就渲染多少行 DOM 节点。每来一批新数据,就整页重绘一次。这个方案在输出量小的时候没什么问题,但一旦面对高频日志输出,立刻暴露出三个致命弱点。
第一个弱点是DOM 节点数量爆炸。终端缓冲区通常保留 1000 到 5000 行历史内容,如果每一行都对应一个 div,再加上行内 span 包裹的样式节点,页面上的 DOM 数量轻松超过 5 万个。浏览器对大量 DOM 节点的布局、绘制、事件绑定都有明显性能上限,节点越多,每次重排的成本越高。
第二个弱点是重排重绘范围失控。每来一批数据,我们都直接操作整个容器,浏览器会从头到尾计算布局、重新绘制所有可见区域。实际上终端输出往往只影响最后几行,这种“全量刷新”的写法,浪费了大量计算资源,而且随着历史行数增加,浪费越来越严重。
第三个弱点是渲染频率不受控。WebSocket 推送数据的频率远高于浏览器的帧率,一批数据到了就渲染一次,导致碰撞问题。大多数数据帧还没被绘制出来,就被下一帧覆盖了,浏览器反复做无用功,主线程被挤爆。
2.2 数据管道瓶颈:PTY的“洪峰”直接冲垮了前端
除了渲染层的问题,数据链路同样存在瓶颈。OrcaTerm 后端通过 PTY(伪终端)对接远程命令,前端通过 WebSocket 接收输出流。PTY 输出数据的特点是“突发性强”——一条命令可能在几十毫秒内吐出几百 KB 甚至几 MB 的数据。
初版的前端收到每一条消息就立刻追加到缓冲区和 DOM 里,这种“收到即渲染”的模式,在高吞吐场景下必然会出问题。一方面是后端到前端的协议开销大,每条消息都有 JSON 格式的字段冗余;另一方面是前端处理数据的粒度太细,每次只处理一小段文本,导致函数调用次数暴增,JavaScript 引擎的调用栈都被这些碎数据处理任务堆满了。
我当时做个一个很简单的实验:在后端模拟一次性输出 2MB 日志,观察前端从收到数据到渲染完成的时间。结果是耗时 3.2 秒,整个过程浏览器标签页基本处于“假死”状态。这根本不是量级上的差距,而是架构上的失败。
2.3 竞品对标:性能差在哪里,不是玄学,是工程债
在优化之前,我们也拉了几个主流终端工具做了横向对比,包括 xterm.js 官方的 demo、Tabby 这种基于 Electron 的现代终端、以及 Windows Terminal 这类原生方案。对比结果很残酷:别人滚动 5000 行日志时 FPS 能稳定在 30 帧以上,而 OrcaTerm 连 5 帧都保不住。
仔细分析后,我们发现差距主要集中在三个方面:一是别人对渲染区域做了裁剪,只绘制可视区域附近的少量行,而不是全部历史;二是别人的数据管道有合并和节流机制,不会让每一次小数据都触发一次渲染;三是别人在绘制层面用了更高效的方案,比如 Canvas、WebGL,而不是纯 DOM 拼接。这些差距不是某一个点的问题,而是整条链路都是“债”。
3. 渲染层优化:从 DOM 泥潭走向 canvas 分层绘制
3.1 按需渲染的思路:只画用户看得见的行
第一刀我们先砍掉 DOM 渲染黑盒里的“全量冗余”。核心思路很简单:不管终端缓冲区里有多少历史行,画面上其实只需要绘制可视区域内的那几十行。用户滚动查看历史时,再动态计算渲染区间和位置。
这个策略听起来直白,但落地细节非常多。首先是行高缓存——每个字符我们采用固定行高和固定列宽的网格布局,这样任意滚动位置都能通过简单的整数运算计算出应该显示哪些行,不需要进行 DOM 测量。比如可视区域高度是 600 像素,行高是 16 像素,那么可视区域对应的行区间就是 600 / 16,即 37.5,向上取整后我们只需要渲染约 38 行。
其次是虚拟滚动坐标换算。我们维护一个总行数计数器,用户滚动时,用滚动偏移量除以行高得到起始行号,再从缓冲区里取对应行的文本和样式信息送入渲染器。整个过程不创建任何冗余 DOM 节点,滚动计算量从 O(n) 降到了 O(1)。
最后是预渲染缓冲区。为了保证快速滚动时不会露出白屏,我们会把可视区域上下各多渲染 5 行作为余量。这样即使滚动速度快到一帧跨好几行,用户也基本看不到空白区域。实测下来,这个策略对体验的提升非常明显,快速滚动时几乎无感知。
3.2 Canvas 批量绘制文本:每帧只做一次“画布提交”
DOM 数量降下来之后,还有一个问题:即使只显示 38 行,如果采用 38 个 div 再加每个 div 里的多个 span,DOM 节点仍然有几百个,而且在快速更新时仍然存在布局计算开销。所以我们做了一个更加彻底的决定——放弃 DOM 来渲染正文,全部改用 Canvas。
Canvas 绘制文本听起来很初级,但真正做好终端渲染,至少有三个坎要跨过。
第一个坎是文本样式切换。终端文本有前景色、背景色、加粗、斜体、下划线、链接高亮等状态。Canvas 的 fillText 本身不支持富文本,我们只能逐段扫描行内样式,遇到样式变化就切换 fillStyle 和 font,再继续绘制。这个过程如果写得粗糙,循环里反复设置状态,性能会极具下降。
第二个坎是背景色填充顺序。终端有一个特殊行为——整行高亮、字符背景色、光标块,这些背景必须在文字绘制之前全部画完,否则文字会被背景盖住。我们的做法是分两个阶段绘制:先绘制所有背景像素(遍历每个字符的 bg 属性并 fillRect),再统一绘制文字。这样既保证视觉正确性,又能减少画笔的状态切换次数。
第三个坎是字体缩放与清晰度。Canvas 在高 DPI 屏幕上如果直接使用 CSS 像素绘制,文字会发虚。因此我们在绘制前要把 Canvas 的实际分辨率乘以 devicePixelRatio,再通过 scale 恢复坐标系。比如在 2 倍屏上,canvas.width 设为容器宽度 * 2,同时执行 ctx.scale(2, 2),画面就锐利了。
上面这些都属于绘制细节,更重要的是框架层面的主循环。我们用 requestAnimationFrame 做渲染调度:数据到达时只写入显式缓冲区,不立即绘制;等到下一帧开始前,统一从缓冲区取数据、合成样式、一次性按需重绘。这样每帧最多只执行一次完整的绘制操作,避免一秒钟触发几十次重绘的乱象。
3.3 双缓冲与脏矩形截取:别让背景刷出“拉丝”效果
做 Canvas 渲染时,最容易踩的一个坑是残留重影。因为 Canvas 不会像 DOM 那样自动清除“之前的自己”,如果你只绘制了新数据所在的行,而没有覆盖掉旧画面,屏幕上就会留下文字的残影。
这个问题有三种解决路径:一是每帧清空整块画布重绘全部可见区域,实现简单,但大画布全擦的 fillRect 本身也有成本;二是只擦除脏矩形区域,计算成本很低,但矩形合并逻辑容易出 bug;三是双缓冲——在内存中维护一个同尺寸的离屏 Canvas,绘制到内存画布后再整体拷贝到显示画布。
我们最终选的是离屏 Canvas + 全量重绘可见区域的方案。因为终端可见区域通常只有 30 到 60 行文本,即使全量重绘,fillRect 加 fillText 的开销也完全可控;但离屏 Canvas 带来的好处是,我们的渲染管线可以随时做到“一帧只提交一次合成结果”,画面永远不会出现半更新状态,也天然规避了重影问题。
这里还有一个细节:离屏 Canvas 的总尺寸应该与可视区域一致,而不是与整个终端缓冲区一致。缓冲区可能有 5000 行,但离屏画布只需要覆盖可视高度。比如视野是 38 行,行高 16 像素,那离屏画布就是 608 像素高。滚动时,我们只需把滚动的像素偏移量传给绘制函数,让它从正确的位置采样数据。
4. 数据管道重构:让海量输出像水流一样平稳
4.1 合并写缓冲:宁等一帧,不抢一毫秒
渲染层搞定之后,我们把目光转向数据管道。前面已经说过,WebSocket 推送数据的频率远高于浏览器渲染频率。如果数据一到就立刻塞给渲染器,渲染器会被碎片化更新淹没,导致每帧都要处理大量小任务,性能反而更差。
解决方案就是引入合并写缓冲机制。具体实现是:维护一个 pendingData 队列,WebSocket 收到数据后,先把数据追加进这个队列,并打一个“有更新”标�记,触发一次 requestAnimationFrame 调度;等到帧回调执行时,一次性把队列里所有数据按顺序合并成一个大字符串,再交给终端状态解析器。如果一帧内来了 50 次推送,我们就只在下一帧合并处理一次,将 50 次渲染调用降成 1 次。
这个策略的核心价值不是减少代码量,而是稳定帧时间和 CPU 占用。实测结果是:同样输出 2MB 日志,初版从数据到渲染完成需要 3.2 秒,合并写缓冲之后降到了 1.1 秒左右,而且页面不再“假死”,中间用户仍然可以滚动和操作。
4.2 二进制协议收编 JSON:减少序列化与内存碎片的双重压力
初版 WebSocket 数据格式用的是 JSON,形如 {"type":"output","data":"..."}。这种格式开发方便,但存在两个问题:一是每一条消息都有固定字段和引号、花括号,产生了大量冗余字节;二是 JSON.parse 需要为每一条消息新建临时对象,高频推送时会产生巨量内存分配,进而触发频繁 GC。
我们后来把传输协议改成了二进制格式,自定义了一个轻量帧结构:第一个字节是消息类型,随后两个字节是数据长度,再后面是 UTF-8 编码的明文内容。前端收到 ArrayBuffer 后,通过 DataView 直接读取长度和数据,再用 TextDecoder 一次性解码。整个过程几乎零临时对象分配,GC 压力大幅降低。
有的人可能说现代 JS 引擎的 JSON.parse 已经很快了,没必要在协议上抠这么细。但真实场景下,当输出频率达到每秒几十次甚至上百次推送时,JSON.parse 的累积成本不容小觑。我做过对比,在同样 10MB 数据量的压力测试中,二进制协议的处理时间比 JSON 快 35% 左右,内存峰值降低了约 20%。
4.3 输入通道优先级:键入响应比什么都重要
在终端场景里,输出数据量再大,也不能让用户输入变得卡顿。用户按下一个键,预期的结果是远端命令立刻收到字符并回显,这个过程一旦被海量输出阻塞,整个终端就没有“可用性”可言。
我们在重构数据管道时做了一个细节处理:输入通道高优先级分离。浏览器主线程同时处理输出数据解析和输入事件转发,如果输出数据量特别大,解析任务可能会占据主线程,导致 keydown 事件排队等待。为了规避这个问题,我们把数据接收与解析从主线程里挪了一部分出去——接收到 ArrayBuffer 后不直接在主线程做逐字符解析,而是先存进环形缓冲区,再由渲染帧回调按批次解析。
这样布局能保证用户输入事件在主线程的响应优先级不被数据解析挤占。实测在输出洪峰情况下,输入到服务器回显的延迟依旧能稳定在 60ms 以内。这一点对真实用户来说,比渲染速度的绝对值更敏感。
5. 终端状态存储:缓冲区不只是“一堆字符串”
5.1 按行存储的行模型
终端缓冲区如果只存一个巨大的字符串,做“按行更新”“按行删除”“按行滚动”会非常痛苦。我们的方案是把缓冲区设计成稀疏行数组,每一行是一个行对象,里面对保存文本内容、样式游标段、行宽度元信息、是否换行等属性。
这样做的好处是,渲染器按可视区域取数据时可以直接按索引取值,不需要做字符串切分。更重要的是,终端的某些控制序列(比如清屏、删除行、在中间插入行)可以按照行的粒度进行 O(1) 级别的数组操作,而不是对整个字符串做正则替换。
行对象内部保存样式的方式也需要斟酌。如果每个字符都存一个独立样式对象,内存占用太大。我们采用的是分段样式表方案:每行内部维护一个 TextSegment 数组,每个 Segment 记录一个文本连续区间、前景色、背景色、加粗等属性。比如一行 80 个字符,前 10 个是红色,后 70 个是默认样式,我们就只存两个 Segment,而不是 80 个样式对象。
5.2 大日志滚动的内存护栏:限制回看行数,压缩历史痕迹
日志查看场景往往有滚动查看历史的需求,但这和内存保护天然冲突。如果缓冲区无限保留所有输出,哪怕一列日志打印几百万行,内存也会被撑爆。
我们的策略是给回看缓冲设上限——默认 6000 行,超过上限时从头部批量淘汰。这个深浅度的选择基于实测:5000 到 6000 行日志足够覆盖绝大多数排障场景,而且每行平均占用约 300 字节,总内存占用控制在 2MB 以内,对浏览器压力非常小。
但这带来一个交互问题:用户正在向上滚动查看历史,如果中途缓冲被淘汰,他看到的行号就会跳动。为了避免这种“抽风”体验,我们做了淘汰保护机制:如果当前可视区域停止在某个位置,淘汰操作优先从可视区域以下的数据开始,尽量不影响用户正在看的内容。如果淘汰范围逼近可视区域头部,我们会在左下角提示“历史缓冲已截断”,让用户知道数据不完整。
5.3 控制序列解析的“状态机改造”
终端渲染里还有一个常被忽略的瓶颈——ANSI 转义序列的解析。原始日志里夹杂着大量 \x1b[...m 这样的 CSI 序列,如果每次追加数据都重新解析整段文本,不仅 CPU 开销大,而且会因为半截序列跨消息到达而产生解析错误。
我们最后把解析器改成了流式状态机。解析器内部维护一个状态:普通文本态、ESC 起始态、CSI 参数收集态、最终字节态。每收到一段新数据,就从上一次停留的状态继续推进,而不是从头部重新开始。这样既不丢序列,也把解析复杂度控制在线性范围。
这个改造带来的另一个附带好处是,解析结果可以按“区块”粒度返回给渲染器。每个区块对应一段稳定的文本区间和样式信息,渲染器可以直接拿这些区块去绘制,不需要再二次扫描。
6. 性能验证与竞品对标:用数据说话的压测实录
6.1 压测方法论:别只盯着 FPS 一个指标
优化做完之后,最怕的是自我感觉良好。我们搭建了一套可复现的压力测试环境,重点看四个指标:FPS(帧率)、CPU 占用率、从数据到达首帧渲染耗时(latency)、以及内存占用曲线。
场景分为四类:一是正常交互输入,模拟用户敲命令;二是高频小数据输出,比如 ping 或 top 命令;三是高吞吐日志滚动输出,用脚本一次打印 100MB 数据;四是超大行文本,比如单行 10 万个字符的 minified 文件。
每一类场景都在同一台测试机上跑多轮,最终取中位数和 P95。只有 P95 好才是真的好,因为终端用户一旦遇到一次超过 300ms 的卡顿,就会明显感到不适。
6.2 优化前后对比:数据提升幅度
直接上我们内部记录的一组优化前后对比数据(以下数据是某次压测的记录,浏览器版本为 Chrome 121,机器为 2021 款 MacBook Pro):
| 场景 | 优化前 FPS | 优化后 FPS | CPU 占用下降 | 首屏渲染耗时 |
|---|---|---|---|---|
| 高频小数据输出(ping) | 20 | 60 | 45% | 380ms → 95ms |
| 高吞吐日志滚动(5000 行) | 4 | 55 | 60% | 3200ms → 210ms |
| 超大行(10 万字符单行) | 卡死 | 30 | - | - |
| 正常交互输入 | 30 | 60 | 30% | 80ms → 15ms |
大家可以看看“高吞吐日志滚动”这一行,首屏渲染耗时从 3200ms 降到 210ms,这是一个量级的提升。FPS 从 4 提升到 55,说明渲染管线从“直接不可用”变成了“比较流畅”。超大行场景虽然仍没到 60 帧满帧,但至少不会卡死,已经具备可用性。
6.3 与控制变量:踩过的一个“假优化”坑
在优化的过程中,我们还踩过一个假优化的坑,在这里提醒大家警惕。当时我们尝试过把 Canvas 的绘制放到 Web Worker 里执行,理论上可以把主线程完全解放出来。
实现之后运行,发现 FPS 没有任何提升,反而小幅下降。排查了很久才发现,OffscreenCanvas 虽然在 Worker 里可以绘制,但最终的 commit 操作(transferToImageBitmap)仍然需要在主线程上执行。如果浏览器对 OffscreenCanvas 合成路径优化不好,反而多了一层数据拷贝的开销。后来我们放弃了这个方案,继续沿用主线程 Canvas,把精力放在减少主线程内无效计算上。
这个经历说明一个问题:性能优化不能只看“架构听起来更先进”,一定要用压测数据来验证。有些方案在理论上是对的,但在实际浏览器实现里并不会有预期收益。
7. 常见问题与排查技巧实录
7.1 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 滚动日志时白屏闪烁 | 可视区域外的预渲染余量太小 | 延长上下预渲染行数 |
| 输入回显延迟高 | 主线程被数据解析堵住 | 检查是否有同步的大字符串操作,把解析拆为分批任务 |
| 长时间使用后内存暴涨 | 历史缓冲行数没有淘汰 | 确认行淘汰策略是否触发,是否有事件监听器残留 |
| 字体模糊发虚 | Canvas 没有适配 devicePixelRatio | 设置 canvas.width = 容器宽 * dpr |
| 终端的背景色铺满整行后文字消失 | 绘制顺序错误 | 必须先绘制背景 fillRect,再绘制文字 fillText |
| 粘滞感:滚动一顿一顿 | 数据解析和渲染没有分离 | 引入合并写缓冲和异步调度 |
| 多浏览器表现不一致 | Canvas 字体度量差异 | 统一指定 font-family,并在初始化时测量行高 |
7.2 一个真实的线上问题:滚动时“屏幕撕裂”
上线不久后有用户反馈:在快速滚动大日志时,画面中间会出现一条横向的分隔线,上半部分是旧内容,下半部分是新内容,肉眼看起来像是屏幕撕裂。排查了很久才发现,是浏览器把 Canvas 分成了多个合成层,滚动时合成层之间的同步出现了时间差。
解决方案是把 Canvas 的 willReadFrequently 属性和 transform 样式做了调整,强制浏览器走同一条合成路径。同时我们把每帧绘制结束后的 commit 操作改成在 requestAnimationFrame 回调用统一提交,避免中间态被浏览器展示出去。这个问题解决后,类似的撕裂现象再也没有出现过。
7.3 性能调优建议:给同样做终端渲染的同学一些经验
如果你正在做类终端产品,我的建议是先把数据管道和渲染调度这两层做好,再考虑更底层的高级优化。很多人一上来就盯 GPU 加速、WebGL 渲染,忽略了数据合并和画布重绘裁剪这些基础能力,反而没法获得明显的收益提升。
还有一个容易忽略的点:终端性能优化要和真实的“用户场景”绑定。比如你做的是运维工具,用户最常见的操作是查看大日志和同时打开多个终端标签页,那你的压测场景应该围绕多标签并发展开,而不是单纯在单个终端里堆数据。
工具方面,推荐用 Chrome DevTools 的 Performance 面板和 Memory 面板做火焰图分析和堆快照对比,再配合 Lighthouse 辅助看整体渲染性能基线。但这些工具只能帮我们发现性能瓶颈在哪个函数、哪条调用链上,最终怎么改,还是得回到业务场景里去做权衡。
8. 回顾与个人体会
OrcaTerm 这次渲染优化的全过程,让我对“性能优化”这件事有了更深的感知。性能问题通常不像功能 bug 那样有明确的错误信息,它是一点点累积起来的工程债。初版为了快速迭代选择全量 DOM 渲染,在当时看来是“最省事”的方案,但它残酷地决定了后期的天花板。只要架构底子不对,后面再怎么打补丁,也追不上一开始就选对路线的产品。
我在这次优化中最大的心得是三个词:分离、裁剪、合并。把数据接收和渲染调度分离,让它们各司其职;把渲染范围裁剪到用户真正看得见的区域,不做无畏的全局消耗;把高频的小数据合并成大块再处理,减掉无效的计算和内存分配。这三个思路不仅适用于终端渲染,做任何高吞吐实时界面都值得参考。
另外,性能优化一定要形成数据闭环。我们每完成一个子优化,就立刻跑压测记录数据,而不是等所有改动都做完了才测试。这样哪一步优化带来了收益、哪一步是无效改动,全局一目了然。现在 OrcaTerm 的每一个渲染相关的 PR,都会附带性能测试的对比记录,这已经成为了我们的开发习惯。
如果你也在优化自己的终端工具,或者正被类似的高吞吐渲染问题折磨,我希望这篇内容能给你带来一些具体的参考。最后再分享一个小技巧:做终端渲染优化时,不要只盯着“大日志”场景,多试试“高频小输出”场景——比如 top 命令或者连续 ping 的输出。这种场景下最容易暴露渲染调度的效率问题,也最能反映出终端工具的日常使用体验。