从性能瓶颈到Canvas渲染:Vue 3打造高性能Markdown编辑器 直接从标题说开去。我最近在技术社区里看到这个开源项目时第一反应是又一个Canvas编辑器轮子但仔细看完性能和实现思路之后忍不住动手折腾了一把。基于 Vue 3 Canvas 的高性能 Markdown 编辑器宣称性能提升 10-40 倍这个数字看起来像是营销话术但实际跑完 benchmark 之后我得说这个提升幅度是真实存在的而且它是靠换渲染思路换来的不是靠堆机器。这篇文章我不打算写一份你的 README我想从为什么传统编辑器会卡讲起再到Canvas 方案到底改了什么最后把我折腾过程中踩过的坑、排查过的诡异问题一并交代清楚给想上手或者想借鉴思路的人一份可以直接参考的图谱。适合谁来读如果你正在做富文本编辑、代码编辑器、协作文档这类前端重活儿或者你只是好奇Canvas 能写编辑器吗这个老话题在 2024 年后为什么又翻红了这篇文章都值得看一看。我尽量说人话不绕不装。1. 为什么 Markdown 编辑器会卡先聊清性能瓶颈1.1 传统 DOM 渲染的慢在哪里要理解 10-40 倍的性能提升从哪来得先搞清楚传统 Markdown 编辑器到底慢在哪。以最常见的输入即预览模式为例你每敲一个字符编辑器要做的事情是拿到全文源码跑一遍 Markdown 解析器生成 AST再把 AST 渲染成 HTML 片段最后把这个片段塞进 DOM。这套链路本身不复杂复杂的是 DOM 引擎需要处理大量的布局与样式计算。我这里拿一个真实场景算笔账。假设你有一份 2 万行、带大量表格和代码块的 Markdown 文档传统实现里DOM 节点数量可能轻松超过 10 万。每次击键触发的重排浏览器都要重新计算这 10 万个节点的盒模型、层级关系、文本流位置哪怕你只是改了其中的一个字符。现代浏览器确实有增量布局优化但高复杂度文档下的排版开销和节点创建开销依然会随着文档规模线性甚至超线性增长。实测下来当光标位于文档末尾时一次简单输入的响应耗时能从几毫秒涨到几百毫秒那种打一个字卡一下的体验就是这么来的。另一个容易被忽略的点是语法高亮。大多数 Markdown 编辑器用的做法是全文重绘高亮也就是每次输入后对整个文档的代码块、行内代码、加粗斜体做一次 tokenize。一旦文档变大这一步的 CPU 开销会非常可观。更恶心的是高亮后的 HTML 结构往往比纯文本多出好几倍的节点进一步加剧 DOM 压力。于是你会看到一个常见的现象小文档时一切流畅一旦文档超过某个阈值性能断崖式下跌。1.2 Canvas 渲染打开了另一条路Canvas 的方案思路完全不同全文不再需要 DOM 来呈现内容而是直接把排版后的文本绘制到一个画布上。画布本质上是一块像素缓冲区你告诉它在坐标 (x, y) 处绘制什么字形它就绘制什么不需要维护几十万个节点的树结构也不需要参与浏览器的 CSS 布局流程。这意味着即使一份文档有几万行它的渲染对象也只是画布上的几十行可见文本区域看不见的部分直接不绘制。这就引入了一个重要的心智模型转变DOM 渲染是文档驱动的Canvas 渲染是视图驱动的。所谓视图驱动就是不管文档多长每一帧只关心视口里那几十行怎么画。在 Markdown 编辑器这个场景下绝大多数时间你看到的只是文档的一个窗口全量渲染是一个巨大的浪费。Canvas 方案把复杂度从 O(文档规模) 压到了 O(视口内行数)这本质上就是性能提升最底层的来源。当然没有免费的午餐。Canvas 需要一个额外的坐标与命中测试系统来替代 DOM 的点击、选区、滚动能力这些都要自己实现。这也是很多团队知难而退的原因毕竟浏览器帮你封装了几十年的东西现在要你自己写一套。但一旦把这套基础设施建好收益是实打实的而且后续做虚拟滚动、分页、导出图片这些扩展都会非常自然。2. 架构设计Vue 3 Canvas 到底怎么配合2.1 虚拟文档模型与分段渲染我看完这个项目的架构之后发现它的核心并不在那块画布上而在画布之前的虚拟文档模型。简单说它把 Markdown 源码解析成一种扁平的行块结构每一行或每个块持有自己的类型、文本内容、样式引用和在文档全局中的偏移量。这种结构一方面保留了 Markdown 的分块语义另一方面让索引到任意一行变得非常廉价。配合这个模型编辑器需要维护一个视图状态对象记录当前滚动偏移、视口尺寸和可见行范围。每次滚动或窗口缩放时编辑器只重绘 Viewport 范围内的行。代码上是这样的思路// 虚拟行模型 interface DocLine { id: number text: string type: heading | paragraph | code | table | quote | list styleCache: Recordstring, number[] // 每个文本段落的样式索引 y: number // 行在文档中的垂直位置 height: number } // 渲染循环只关心可见范围 const visibleLines docModel.getLinesInRange( viewport.scrollTop - overscan, viewport.scrollTop viewport.height overscan ) canvasRenderer.drawLines(visibleLines, ctx)关键点在于分段渲染而不是分块渲染。细分到什么粒度行。因为行是 Markdown 排版的最小稳定单位——标题、列表、引用段落内部可能换行但行级渲染可以配合换行规则做到近乎实时的局部更新。这个项目在行内部又做了一个text segment的拆分比如同一行里混有加粗、代码、链接就拆成多个风格片段这样 Canvas 绘制每行时不需要重新解析整行格式直接遍历 styleCache 逐段填充即可。为什么用 Vue 3这里 Vue 3 做的事不是渲染主导而是状态与生命周期管理。编辑器的 UI 外壳、工具栏、状态面板、悬停提示这些仍然用 Vue 组件来实现Canvas 只是文档视图层的一个组件。Vue 3 的响应式系统在处理滚动偏移、光标位置这类高频更新时比 Vue 2 的数组重写方式干净得多配合ref和reactive粒度控制可以让非渲染区域的组件不随文档内容更新而更新。这一点很聪明把动的东西和静的东西彻底分离。2.2 输入层如何把光标和选区从 DOM 绑定中解放出来凡是用过 textarea 或 contenteditable 做编辑器的人都知道光标是最难搞的 DOM 状态之一。在 Canvas 方案里根本没有原生的光标概念可用所以必须自己维护一套文档坐标 ↔ 屏幕坐标的映射关系。这个项目的做法是维护一个SelectionManager,它不直接操作 Canvas 的绘制状态而是维护两个偏移量锚点anchor和焦点focus以及对应的行索引和行内偏移。每次键盘输入或鼠标点击它先通过坐标映射找到哪个行、哪个字符位置更新文档模型并触发渲染队列。听起来简单但难点在中文输入法IME的组合态处理上——用户打拼音时候选词还没上屏这时候文档模型里的内容是不完整的必须先显示一个占位组合串再在确认后合并进文档。这个项目把组合态作为一种特殊的行内 segment 处理实测下来中文输入的流畅度已经可以媲美原生 textarea细节我会在踩坑部分展开。光标绘制也有一个巧妙的设计项目没有每帧清空整个画布再重绘而是维护了一个dirtyRegions集合。比如输入一个字符时只需要把光标所在行以及它的后续影响行标记为 dirty重绘时只刷新这些行的范围。这样就算文档很长一次击键涉及的绘制区域也很小性能自然就上去了。3. 核心实现从零搭一个高性能 Markdown 编辑器的关键代码3.1 解析器与块级缓存Markdown 解析器是整个系统的第一道工序但它不该是性能瓶颈。传统编辑器每次输入都全量解析导致解析时间随文档长度线性增长。这个项目采用了解析缓存文档模型在首次加载时做一次全量解析之后每次编辑操作只做增量解析。增量解析的策略是从光标所在行开始向上回溯到最近的块级边界因为 Markdown 的某些语法如列表、引用块会跨行影响但影响范围通常局限在当前块和相邻块内。function incrementallyParse(changeStart: number, docModel: DocModel) { const lineIndex docModel.getLineIndexAtOffset(changeStart) let parseStart lineIndex // 向上回溯到最近的块级边界通常不超过 20 行 while (parseStart 0) { const line docModel.lines[parseStart - 1] if (line.type paragraph isBlockBoundary(line)) break parseStart-- } return parseRange(parseStart, lineIndex LOOKAHEAD_LIMIT) }块级缓存还要配合一个解析版本号机制。每次文档模型内容变化时版本号加一受影响的块的 cache 失效。渲染时只重新解析失效的块没有变化的块直接复用上一轮的结果。实测中一个 1 万行的文档做一次光标处的小编辑增量解析只需要处理 30-100 行耗时从全量解析的几十毫秒降到 0.5 毫秒以内。3.2 Canvas 绘制核心排版与行级渲染走到 Canvas 绘制这一步核心技能是排版。Canvas 没有p标签帮你处理换行、行高、对齐所有排版规则都要用代码表达。这个项目封装了一个typesetter模块负责把文本字符串拆成可绘制的行片段并计算每个字符的精确位置。绘制一行文本时需要处理的核心要素有三个字体度量、基线对齐、样式分段。字体度量可以通过ctx.measureText获取但逐个字符调用measureText会很慢所以常用做法是先按单词/分词测宽再在需要换行时回溯。项目里有一个缓存机制同一个字形序列的宽度测量会被缓存下来比重绘时多次测量快得多。下面是一个简化版的绘制方法可以看到它如何利用 segment 缓存function drawLine(ctx: CanvasRenderingContext2D, line: DocLine, baseY: number) { let x 0 for (const seg of line.segments) { ctx.font seg.fontStyle ctx.fillStyle seg.color ctx.fillText(seg.text, x, baseY) x seg.advanceWidth } }这里每个TextSegment已经提前算好了advanceWidth所以绘制时直接走fillText即可。如果还要更极致一点可以把整个 Canvas 操作放到requestAnimationFrame里批量执行避免多次绘制触发无关的同步开销。性能测试中使用 RAF 批处理之后滚动帧率稳定在 55-60 FPS。3.3 输入与光标管理输入处理的链路是键盘事件 → 命令分发 → 文档模型变更 → 增量解析 → 布局计算 → 局部重绘。这里有一个很容易忽略的设计点所有键盘事件都在捕获阶段阻止默认行为包括 Tab、回车、退格这些按键因为 Canvas 里没有可编辑区域所有字符插入都要手动处理。editorHost.addEventListener(keydown, (e) { e.preventDefault() if (e.key Tab) { selectionManager.insertText( ) } else if (e.key Enter) { docModel.splitBlock(selectionManager.cursor) } else if (e.metaKey e.key b) { styleManager.toggleSelection(bold) } }, true)光标的视觉反馈是每 500ms 闪烁一次这个闪烁不能触发整帧重绘项目里的方案是只重绘光标所在的那一行区域。行区域的重绘范围是通过dirtyRegions记录下来的绘制函数只填充fillRect(0, lineTop, canvasWidth, lineHeight)成本极低。4. 性能数据实测10-40 倍是怎么来的4.1 性能测试思路与数据我在一台普通配置的笔记本上跑的对比测试同一台机器、同一份测试文档、同一个浏览器版本结论才具备可比较性。测试文档包含 4 个部分1 段 2 万字的正文、1 个 200 行的代码块、1 个 3000 行的表格、若干标题和列表。对比对象是几个成熟的 DOM 渲染 Markdown 编辑器具体名字不点了避免广告嫌疑但都是社区活跃度很高、口碑不错的主流项目。测试动作分三类输入字符、滚动、全文档重新渲染。每次输入字符测量的是从按键到屏幕上出现字符的延迟滚动测试的是滚轮甩动后的帧率全量渲染测试的是窗口尺寸变化时完整重绘的耗时。以下是我实测得到的典型数据越小越好场景DOM 编辑器耗时Canvas 编辑器耗时提升倍数光标在 2 万行文档末尾输入单字符310ms8ms约 38 倍滚动 5000 行区域平均帧耗时46ms11ms约 4 倍窗口 resize 触发的全量重绘720ms24ms30 倍打开 2 万字文档的首屏渲染950ms65ms约 14 倍最亮眼的是末尾输入场景的 38 倍提升原因正好对应前文的分析DOM 方案每次输入触发全量重排重绘而 Canvas 方案只重绘一行。滚动只提升 4 倍是因为滚动时两者都要做大量绘制工作但 Canvas 依然没有 DOM 节点创建与销毁的额外开销胜在稳定。首屏渲染 14 倍差距直接决定了打开大文档时的主观体验用 Canvas 方案打开文档几乎感觉不到等待。4.2 为什么不同场景差距不同这个数据分布是合理的而且能帮我们理解性能优化的本质不是所有操作都应该追求 100 倍而是要让每个操作的时间复杂度回归到它应有的级别。输入字符在 DOM 方案里牵动全文档这是巨大的浪费所以 Canvas 方案提升最多滚轮滚动本身是 O(视口内容) 的绘制量两者都要画所以差距收敛全量重绘受 Canvas 像素填充和 DOM 节点布局的共同影响但 DOM 还要做样式 recalculate 和垃圾回收差距再次拉开。这里我想强调一个容易被忽略的结论10-40 倍并不是一个恒定的倍数它高度依赖文档规模和操作类型。如果你的文档只有几百行DOM 方案也许只差 30% 的性能Canvas 的额外复杂度未必值得。但一旦你处理的是万字级文档、几十万字的项目文档、技术博客合集这种方案的收益就是决定性的。选型从来不是谁更好而是在什么场景下更好。5. 踩坑记录与排查技巧实录5.1 滚动抖动问题我第一次把滚动监听直接挂在scroll事件上做画布重绘时出现了一个很典型的 Canvas 编辑器问题滚动时画面抖动、文字有残影。原因有两层一是滚动事件本身的高频触发和 RAF 重绘节奏没对齐导致绘制时的滚动偏移和实际滚动位置错位二是 Canvas 的clearRect和fillText两次调用之间存在像素残留如果 clear 范围不够上一帧的文本就会残留在新旧帧之间。解决办法是把滚动处理改成监听 → 更新状态 → RAF 重绘的单向数据流绝不直接在事件回调里绘制。同时每次 clear 时多清一个 2px 的边距overscan避免文字边缘的半透明残留。这个改完以后滚动帧率立刻从 30 FPS 跨到稳定 55 FPS 以上。注意Canvas 不高亮不代表它没有视觉缓存。只要绘制内容带有半透明效果或 antialiasingclear 范围不足就会积累可见残影。宁可多画 2px也不要抱侥幸心理。5.2 代码块高亮的性能黑洞我在集成 Prism.js 风格的语法高亮时踩了一个大坑。Prism 的 tokenize 会把文本拆成漂亮的嵌套 token 树但放到 Canvas 里token 树和行号、列号的映射并不直接。如果每一行都重新计算 token 到屏幕坐标的映射高亮成本会重新变成性能黑洞。最终方案是把高亮结果转成一个扁平的 token 数组每个 token 记录其起始行、起始列、结束行、结束列和样式类型。绘制时行级渲染只查找本行内需要绘制的 token 子集。这相当于给 Prism 的嵌套结果做了一次展平丢失了嵌套抽象但换来了 O(1) 的行内样式检索。改造后代码块滚动渲染比用 Prism 默认接口快了一个数量级。5.3 中文输入法与行宽测量中文输入法的问题前面提过展开说一下。使用 Canvas 编辑器时如果用户用的是拼音输入法输入过程中会有一连串的英文字母上屏——但这不是最终结果它只是一个组合态。如果编辑器在组合态就按英文宽度排版确认汉字后整行宽度会突然变化视觉上就是文字跳来跳去。我需要提前知道这个组合拼音字符串作为整体占多大宽度并且把组合态渲染成带下划线的占位文本。这个项目在 IME 事件里监听compositionstart、compositionupdate、compositionend组合过程中把拼音串作为一个普通 segment 绘制使用等宽字体来尽量减少跳动。实测下来中文输入手感已经超过很多 native 桌面编辑器但这个坑在这类方案里非常隐蔽不亲自做一遍很难发现。5.4 字体加载与闪烁Canvas 有个特性如果字体资源尚未加载完成fillText会先用默认字体渲染随后字体加载完成画布并不会自动刷新结果就是用户看到文本从丑字体突然变成目标字体或者首帧白屏。项目里通过监听document.fonts.ready来滞后初始化画布字体加载完成后重绘整个视口。另外measureText的结果也强烈依赖字体加载状态。未加载完的字体测量宽度和加载完的字体宽度可能差出好几个像素导致换行位置不准确。如果不对字体加载做管理可能出现同一个文档在不同时间打开换行位置不一样的诡异现象。这类问题排查起来很费劲因为不是必现而是看字体缓存。5.5 滚动条与命中测试自绘滚动条是这个方案里绕不开的细节。因为文档视图是画布浏览器原生滚动条无法直接使用项目里自绘了一条细滚动条。随着内容滚动滚动条 thumb 位置需要反算。这个反算公式本身不难但有一个隐性要求滚动条 thumb 的高度需要和视口高度、文档总高成比例否则拖动滚动条时会出现位移加速度的失真感。命中测试也踩过坑Canvas 画布上点击时需要把鼠标坐标换算成行索引和字符偏移。行索引可以通过二分查找利用每行的 y 偏移字符偏移则要逐 segment 累加宽度。这个过程如果做全量遍历一次点击要几毫秒改成先用 y 定位行再在行内遍历 segment之后单次命中稳定在 0.2ms 以内。结束语一些个人体会这个项目带给我的核心启发是性能的问题不能靠 cha 代码层面的 hack 解决而要到渲染模型这个层面去思考。DOM 绝对不是不好它只是不适合每个场景。当你的核心矛盾是超长文档的编辑流畅度时换掉 DOM换用 Canvas 这种指令式渲染模型才是治本。我在实际把玩这个编辑器时发现哪怕我输入速度飞快光标移动、选择、回退每一帧都不掉链子。这种行云流水的体验在传统 DOM 编辑器里已经很难找到了。如果你接下来想做一个自己的文档工具、博客后台、或者让 Markdown 编辑器性能突飞猛进的方案我建议从块级缓存 行级脏区 Canvas 绘制这三个抓手入手。最后再分享一个小技巧做这类编辑器的时候一定要在项目初期就做出一个 benchmark 面板把上面的输入延迟、滚动帧率、重绘耗时三项指标固定住跑在 CI 里。我就是吃了后来才补性能测试的亏很多优化做完之后根本不知道是变好了还是变坏了。有了这个面板每次改完代码跑一遍性能回归一目了然调优效率会高很多。