ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零自研Canvas分页富文本编辑器:架构、分页引擎与性能优化复盘

从零自研Canvas分页富文本编辑器:架构、分页引擎与性能优化复盘 LumenPage 这名字是我在项目立项当晚起的。lumen 是光通量的单位page 是页面合在一起的意思是“让每一页都亮清楚”。不过等我真把这个 Canvas 分页富文本编辑器从零写到能跑的地步我越发觉得这名字更像是对踩坑过程的总结——每一处暗坑在排查清楚之后都亮得刺眼。起因是去年接了一个电子合同编辑器的活。需求说起来特别朴素能打字、能调字号颜色、能插表格插图片关键一条——预览的时候必须按真实纸张分页显示。合同嘛A4 纸就是唯一世界观每一页落在纸上什么位置用户都盯得很死。我一开始天真地觉得用 contenteditable 改改就行两周之后人就麻了。这篇文章就把 LumenPage 从动机到架构、从分页引擎到光标系统、从性能优化到后续规划完整复盘一遍想评估自研编辑器、纠结技术选型、或者对 Canvas 排版有兴趣的朋友应该都能从里面捞到点东西。1. 为什么我要自己造一个“分页”编辑器轮子1.1 一个真实的“电子合同预览”需求需求方拿来的原型其实很简单左侧是工具栏中间是一个模拟 A4 纸的白色页面右侧是属性面板。用户在里面编辑合同条款完成后一键导出 PDF同时前端预览必须和最终 PDF 完全一致——哪一行在哪个位置断开、表格在第几页被截断一个字都不能差。这个“和 PDF 完全一致”就是问题的核心。合同的排版不是 Word 那种“无限画布 打印时才分页”而是编辑状态就要看到物理分页效果。用户要在第 1 页写完亲眼看到文字顶到页面底部然后整体挪到第 2 页还要能跨页选中文字、拖拽图片。这时候我意识到普通的富文本编辑器压根不是为“物理页面”设计的。1.2 Contenteditable 给我的三座大山我一开始真的试过用 contenteditable 硬扛而且不是没扛过。当时做了一个 Demo一个 div 模拟 A4 纸内部 contenteditable然后给段落加break-inside: avoid试图用 CSS 控制断页。真正开发起来撞上了三座绕不开的大山。第一座大山是排版引擎不可控。浏览器的换行和断页逻辑是个黑盒。CSS 里有page-break-after、break-inside这些属性但它们在编辑态下的表现和打印态完全不是一回事而且不同浏览器对“段落最后一行的位置”处理得千奇百怪。合同里每一页要精确到毫米级靠 CSS 微调根本做不到。第二座大山是DOM 节点爆炸。一份 30 页的合同加上样式标签、嵌套的 span轻轻松松两万多个 DOM 节点。用户每敲一个字浏览器都要重新做样式计算和布局输入延迟肉眼可见光标还时不时飘一下。这还不算严重的情况等我把表格、图片、批注都塞进去那个页面复杂度直接失控。第三座大山是选区模型撕裂。富文本编辑的核心操作——光标定位、选区高亮、拖选——在 contenteditable 里依赖浏览器原生的 Selection API。这个 API 是围绕 DOM 节点设计的对“跨页选中”这种需求你得自己把选区拆成多个矩形区域去渲染。可一旦页面发生重排之前记录的 DOM 节点引用全部失效选区状体维护变成一场噩梦。1.3 调研一圈现成框架也救不了“分页”我也认真评估过市面上的开源编辑器。Slate 是个优秀的数据模型框架但它的视图层渲染最后还是落回 DOM分页控制依然是空白ProseMirror 的数据流和 schema 设计我很喜欢但它压根没有“物理页”这个概念做分页等于自己重写一套布局系统CKEditor 5 重度封装了模型与视图改造成本甚至比从零写还高。这不是说这些框架不行。它们的定位是“在线文档编辑器”默认是无限长文档大部分场景博客后台、留言板、知识库根本不需要分页。但我的需求是“合同按 A4 纸分页”这个约束把编辑器变成了排版软件。排版软件的核心是布局引擎而布局引擎必须和渲染层、交互层深度耦合。结论只有一个得自己造轮子而且要从数据模型开始造。一个后来验证很重要的决策LumenPage 从第一天起就把“数据模型”和“视图层”彻底分离。所有编辑操作先改数据再由布局引擎决定怎么画。这个决策让后面所有功能分页、撤销、协同都有了地基。2. LumenPage 的架构基础数据模型与渲染模型分离2.1 不再以 DOM 为中心的文档结构LumenPage 的文档不是一棵 DOM 树而是一棵纯 JSON 的节点树。顶层是EditorDocument里面有一个body数组每个元素是一个块级节点。我最早定了这么几类节点paragraph普通段落内部是一个行内元素数组heading标题和 paragraph 结构相同但附带头级与大纲信息table表格内部行列结构独立管理image图片记录 src、宽度、对齐方式pageBreak显式分页符强制后续内容进入新页行内元素用类似 Slate 的 mark 机制一串文本带一个 marks 数组marks 里有bold、italic、fontSize、color这些属性。这种结构的好处是序列化很干净交给后端就是一份可读的 JSON 文档比 HTML 字符串好处理太多了。{ type: paragraph, children: [ { text: 甲方应在合同签订后 , marks: [] }, { text: 30 个自然日内, marks: [bold] }, { text: 完成首期付款。, marks: [] } ] }文档的每一次变更都会生成一个 Operation 对象比如插入文字{ type: insertText, path: [2, 1], offset: 5, text: 30 }。所有 Operation 进一个队列既做撤销重做也做自动保存实时同步。这个设计给了 LumenPage 后面接协同编辑的空间——只要两个端共享同一个 Operation 日志数据早晚会一致。2.2 两层渲染架构文档层和渲染层之间必须有一道缓存有了数据模型只是第一步怎么把它画到 Canvas 上是另一个问题。这里我采用了“文档 → 布局 → 绘制”三层管线。布局层Layout Tree是整个系统的核心。每次文档变更布局层会重新计算每个块的几何信息生成一棵和文档树对应的视图节点树。视图节点不存文本内容只存x、y、width、height、行数组、字体信息、颜色这些绘制所需的参数。简单说文档树是“台词本”布局树是“舞台调度”Canvas 拿到的是已经排好位置的“演员表”。为什么中间要隔一层布局因为 Canvas 绘图 API 是“一次性”的没有“节点”的概念。直接拿文档数据去画每次重绘都得从头测量文本宽度、计算行高、断行。而布局层把这些计算结果缓存下来文档不变时滚动、缩放、选区重绘都不需要重新布局。document.json → layoutTree → renderList → canvas.draw() ↓ ↓ ↓ 变更事件 增量布局 脏矩形裁剪上面的箭头表达的就是数据流动方向我在设计文档里画了几十遍这个图算是 LumenPage 最简单也最重要的那张架构图。2.3 Canvas 和 React 的分工边界技术上我选了 React 做外壳工具栏、侧边栏、弹窗、右键菜单这些 UI 用 React 写很顺手。但编辑器核心——Canvas 画布、布局引擎、光标系统——全部独立于 React 之外通过一个 ref 暴露命令式 API 给 UI 调用。这个边界一开始就划清楚带来一个特别实际的收益React 组件随便怎么重渲染Canvas 画布都不会被动一下。我见过很多项目把 Canvas 封装成 React 组件然后把 document 存在 useState 里结果每次状态更新画布全量重绘性能直接崩掉。LumenPage 的做法是React 只负责“转发事件”和“显示工具状态”真正的数据变更发生在 React 外部变更完成后主动调用canvas.invalidate()触发重绘。还有高分屏适配。Canvas 默认的分辨率和 CSS 像素是 1:1 的直接在 retina 屏上画文字边缘会糊。必须在初始化时读取window.devicePixelRatio把 Canvas 的实际尺寸乘以 dpr再ctx.scale(dpr, dpr)。这个代码早期我漏写了一行结果在 MacBook 上预览文字像蒙了一层纱用户截图给我看我才反应过来。3. 分页引擎LumenPage 最核心的模块3.1 从“无限画布”到“物理纸张”的转变逻辑分页引擎要做的事本质上是把无限长的排版结果切成一段段固定高度的页面。切分的过程不是简单“画到第 800 像素就换页”而是要处理一大堆排版规则。我的实现里布局层维护一个PageBuilder它逐块读取布局树上的块节点尝试把块放进当前页。放不下的情况分三种处理不可分割块表格行、图片、代码块整个块被推到下一页绝不能从中间劈开段落内部断页允许段落被打断但打断后当前页必须保留至少两行下一页也要保留至少两行。这就是排版里的 widow/orphan 控制显式分页符遇到pageBreak节点不管当前页还剩多少空间直接切页function layoutBlock(block, currentPage) { if (block.type image || block.type tableRow) { if (currentPage.remainingHeight block.height) { return { pageBreak: true, block }; // 整块移入下一页 } } if (block.type paragraph) { const lines breakParagraphIntoLines(block, currentPage.availableWidth); const fitCount fitLinesToPage(lines, currentPage.remainingHeight, { minLines: 2 }); if (fitCount lines.length) { return { split: true, headLines: lines.slice(0, fitCount), tail: /* 剩余 */ }; } } return { fit: true, block }; }这段代码看起来简单但真正调试起来非常折磨人。合同的条款很多是“标题 正文 表格”的组合表格行高会随着内容变化图片尺寸又不固定还要处理表格跨页时重复表头的问题。我花了整整一周才把 widow/orphan 规则调到“用户看不出来有问题”的粒度。3.2 页面模型与两种展示模式LumenPage 的页面不是画出来的长方形而是一个有完整属性的ViewPage对象pageIndex、width、height、marginTop、marginBottom、块的引用列表。页面的物理尺寸可以从一个PageSetup配置里读A4 纸对应210 × 297mm乘以一个mmToPixel换算系数96dpi 下是 3.78就得到画布像素值。这里必须提一个和很多编辑器不一样的设计连续滚动模式。实际编辑时用户不一定想一页一页翻更习惯像 Word 那样连续滚动。LumenPage 的布局树是连续的分页只是布局结果上的“切分标记”所以切换模式非常简单——预览模式逐页绘制带纸张阴影的页面卡片编辑模式直接绘制连续的长画布。这个特性是分页模型和布局模型分离带来的红利前期设计赚回来的时间在后面太多了。页边距是另一个坑。合同预览时页边距要留白编辑时留白又浪费空间。我干脆把“编辑态边距”和“预览态边距”做成两个配置切换模式时重新跑一次布局。执行下来发现这是最稳妥的方案因为边距变了段落换行位置一定变不重排就全是错位。3.3 级联重排优化改一个字不让整篇文档重排分页引擎最大的性能挑战是级联重排。想象你在第 3 页中间插入了一个字理论上只是第 3 页多了一个字符的宽度但这一行可能因为多了一个字符多出一行这一页因此多出一行的高度于是原本在第 4 页的内容被挤到第 5 页第 5 页的被挤到第 6 页……整个文档尾部全部偏移。很多“分页替换”方案遇到这种情况就直接全量重排100 页的文档每次敲键都卡一下。但 LumenPage 的布局层有缓存体系每个块节点有一个version标记只有当标记声明的依赖宽度、字号、边距发生变化时这个块才重新参与布局。插入字符时先定位到所属块块的重排结果会告知 PageBuilder “这一块变高了”或“没变”。如果没变后续所有页面连同它们的缓存全都不用动。实测下来500 页的文档在尾部插入一两个字整个重排耗时在 20ms 以内视觉上完全无感。这个优化还有个彩蛋Undo 恢复也走同一套增量重排。撤销一次插入后影响区域自动缩小到操作点附近大部分页面布局缓存原封不动所以撤销重做非常跟手。4. 光标本体与输入链路所有编辑器最细的活4.1 屏幕坐标反查文本HitTest 的层层剥茧Canvas 上没有 DOM 光标所有光标都是自己画上去的那么问题来了用户在“第 3 页第二段第三行”点了一下我怎么知道这个坐标对应文档里的哪个字符我把这个问题拆成四层查先查页面。遍历当前视口内的ViewPage看坐标落在哪一页的矩形内再查块。遍历该页的块引用看坐标落在哪个块的矩形内再查行。块内部的行对象有各自的 y 区间按 y 值锁定行最后查字符。拿到行内每个字符的 x 起始位置数组二分查找最近的 text offset字符宽度从哪来ctx.measureText()。但这个方法每调用一次都有一定开销如果每个字符都现量一次 HitTest 要调用几十次。我做了个字符宽度缓存以“字体 字号 字符本身”为 key把测量结果存在一个 Map 里。这样同一个字符再次出现时直接从缓存拿宽度实测命中率 90% 以上。光标定位还有个视觉细节要考虑字符间隙和行高。默认textBaseline是alphabetic不同字体、不同行高的 baseline 会导致视觉上下抖动。我在绘制文本时统一将ctx.textBaseline设置为top然后固定从行顶开始画行高统一用一个lineHeight常量这样光标矩形和文字之间的关系就干净了。4.2 键盘事件与 IME 组合输入的折磨键盘输入链路是编辑器里最容易出 bug 的地方。LumenPage 的输入不走 contenteditable而是隐藏了一个原生textarea让它在后台接收键盘事件。但这个方案碰到 IME输入法就会出现经典问题在中文输入法里用户敲拼音时句子还没上屏此时键盘事件处于 composition 状态keydown 的keyCode是 229如果这时照常处理输入会往文档里插入半截拼音。我在keydown里加了个判断if (event.isComposing) return;所有输入等到compositionend事件触发后再统一提交。组合输入状态下还有界面显示问题拼音底下那条跟随光标的横线怎么画如果用隐藏 textarea它在 Canvas 外部光标位置完全对不上。我的处理是composition 期间监听从 textarea 读出的中间值compositionstart到compositionend之间在光标位置绘制一段半透明的灰色背景文字compositionend 后拿最终文本替换。这个方案不算完美但已经能覆盖搜狗、微软拼音、以及 macOS 原生输入法的大多数情况。4.3 选区与拖选跨页选中的渲染选区是 Canvas 编辑器里最容易让新手崩溃的部分因为你需要自己维护一组“高亮矩形”。LumenPage 的选区模型参照浏览器标准anchor锚点和focus焦点两者都映射到文档 offset。高亮渲染时先把选区拆成多个矩形区块再逐块填充淡蓝色背景。跨页选中是最恶心的分支。用户按住鼠标从第 2 页末尾拖到第 3 页开头选区矩形至少包含三段第 2 页末尾的半行、第 2 页和第 3 页之间的空白边距区这段不需要高亮、第 3 页开头的半行。如果整页整页地选中每一页都有独立的高亮矩形。拖动的过程中鼠标每移动一像素都要重新计算选区范围还要考虑反向选择用户从左往右拖和从右往左拖的交互差异。这个模块我前后重构了三次。第一次是纯遍历行矩形性能差第二次引入二分查找性能好了但反向选区高亮错位第三次把选区拆分成“主选区 边界选区”两个对象正向反向统一处理才算稳定下来。后来看到网上有人统计编辑器开发时间分配“光标和选区”能占到 40%我深有同感。5. 性能实测500 页文档下的挣扎与优化5.1 首屏渲染卡 3 秒虚拟化势在必行功能跑通之后我做了一次严肃的性能测试。用脚本生成了 500 页的文档分别是300 段正文、50 个表格、20 张图片、若干标题。第一次打开时布局引擎把所有页面全部算出然后一次性绘制到 Canvas 上。首屏耗时 2.7 秒用户交互时明显卡顿。问题出在两个地方布局全量计算和绘制全量执行。500 页的文档布局树有几万个节点每个节点要测量文本宽度、计算断行、处理断页全部跑一遍不可能快而 Canvas 一次性把所有页面画上去GPU 和 CPU 都在超载。优化方案是视口虚拟化。我只布局并绘制视口内的页面上下各预留一页作为缓冲。滚动时新进入视口的页面触发增量布局离开视口的页面从绘制列表里移除。这个优化一上线首屏耗时从 2.7 秒降到 680ms滚动时保持在 60fps 上下。function updateViewport() { const visibleRange getPageRangeInViewport(scrollTop, viewportHeight, pageLayouts); const start visibleRange.start - BUFFER_PAGE_COUNT; const end visibleRange.end BUFFER_PAGE_COUNT; pageLayouts.retainRange(start, end); // 回收不可见页面的缓存 renderer.drawPages(pageLayouts.pagesInRange(start, end)); }5.2 脏矩形与离屏 Canvas重绘成本的精细化管理虚拟化解决的是“画多少”的问题接下来面对“每次重画哪里”的问题。最初实现里只要光标闪一下、或者用户输入一个字符我直接清空整个画布重绘。在 100 页以内的文档里还算流畅500 页文档一滚动整个 Canvas 区域全部重绘CPU 直接拉满。后来引入了脏矩形机制。每次编辑操作结束后我会计算“这次操作影响的区域”把影响范围上报给渲染器。渲染器只重绘脏矩形内的内容其余部分直接从离屏 Canvas 的缓存里拷贝。离屏 Canvas 是我给页面建的一个双层缓冲下层画背景和文字上层画光标和选区。光标闪烁只需重绘很薄一条矩形连文字层都不用碰。分层还能解决一个很实际的问题选区拖动时的性能。用户拖选时选区高亮每秒要更新几十次如果每次都重排文字市面上没有哪台机器扛得住。我的方案是文字层保持不动只在上层的选区层里更新并重绘高亮矩形。视觉上给用户的感觉是选区在实时跟随鼠标实际文字层一帧都没重画性能开销几乎可以忽略。5.3 React 18 严格模式的双重 Effect 陷阱集成 React 的时候踩过一个很隐蔽的坑React 18 的严格模式在开发环境下会让 Effect 执行两次我的 Canvas 初始化代码在第二次执行时直接创建了第二个 Canvas 上下文导致画布闪烁和事件重复注册。排查了很久后来在初始化逻辑里加了幂等判断if (canvas._lumenPageInitialized) return;并且把初始化的副作用从类组件生命周期中彻底剥离出来放到独立的initLumenPage()函数里管理。另一个 React 的教训是不要把运行时数据放 useState。光标位置、选区范围、当前页号这些数据每时每刻都在变化如果用 React 状态去存组件会疯狂重渲染编辑器卡成 PPT。LumenPage 把这些数据全部放在核心引擎内部React 组件只通过订阅机制获取“需要的”那部分状态比如“当前是否加粗”这种工具栏状态才走 React。这个边界守住了整个编辑器的手感才正常。5.4 字体加载与 measureText 的测量误差Canvas 画文字的宽度依赖字体文件加载完成。如果用户在字体还没加载完的时候就开始编辑measureText会拿系统默认字体去测量等 Web Font 加载完所有文字的宽度都变了布局全部错位。我在初始化时用document.fonts.ready注册了一个回调字体就绪后强制全局重排一次。另外用document.fonts.load提前主动加载正文常用的几种字重进一步缩小字体加载的空白窗口。还有一个针对中文的建议。Canvas 对中文的换行处理比英文保守因为中文字符没有空格可以断断行逻辑是纯字符级。中文标点句号、引号在行首行尾的处理规则标点挤压在 Canvas 里完全没有现成功能得自己写。LumenPage 早期版本直接无视了这个规则导致中文段落行首出现逗号、句号视觉效果很业余。后来我写了一个简单的标点避头避尾过滤器在换行算法里处理虽然还不能完全对标出版级的挤压规则但比默认状态好太多了。6. 复盘、教训与接下来还想做的事6.1 这个项目现在能用了吗目前 LumenPage 已经能跑通完整的编辑链路新建文档、输入文本、设置段落样式、插入图片表格、切换预览模式、导出 JSON 数据。简历编辑器、合同模板预览、简单的出版物排版工具这三类场景我都测试过可以拿来做内部原型了。如果你希望“完全复刻 Word 的体验”还差得远。图片文字环绕、表格合并单元格、脚注尾注、多栏排版、分页时表头重复渲染的精细化控制这些都还没做完整。早期为了赶进度我曾经把复杂表格整块转成图片塞进文档用户放大看全是马赛克这显然不是长久之计。6.2 给想自研编辑器的后来者的建议如果你只是需要“在线文档 80% 的体验”直接用成熟方案最划算。Slate ProseMirror 各种辅助库能覆盖绝大多数需求而且社区踩坑经验丰富你半夜遇到问题至少有地方查。只有当你的核心需求是“物理分页”“精确排版”“跨平台一致渲染”并且评估下来现有方案无法满足时才值得走自研这条路。而且自研一定要先想清楚数据模型。我最后悔的是早期为了快速看到效果先写了渲染层和交互层数据结构随便用数组糊了一个结果后面每一次加功能都在改数据格式改格式就要重写序列化和历史记录成本是成倍增长的。如果重来一次我会先用一周把文档 JSON schema 的各项边界定义清楚再动手写第一行绘图代码。6.3 一些还在想、还想做的事协同编辑是我最想做的下一步。Operation 日志的框架已经打好接 OT 或者 CRDT 都有基础但协同引入的并发控制、冲突解决、光标广播每一项都是大工程得单独立项来推。离线存储也在计划里。目前文档数据是纯 JSON写入 IndexedDB 非常顺手这样用户断网也能继续编辑联网后再同步。这个做好了LumenPage 能脱离“必须在线”的束缚使用体验会提升一大截。做这个项目的过程中我最大的体会是编辑器是数据、渲染、交互三者深度耦合的产品它不像普通业务页面那样可以“先画界面再补逻辑”而是每一步都要有体系化的设计。如果你正站在自研编辑器的门槛前希望这篇复盘能让你看清前面的坑有多深、路有多长。但如果你把数据模型和布局引擎这两根柱子立稳了后面盖楼的速度真的可以很快。
返回列表