十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

自研编辑器内核:从contenteditable到AST的实战之路

自研编辑器内核:从contenteditable到AST的实战之路 这个标题只有孤零零一个词“editor”但我一看到就笑了——因为过去两个月我几乎每天都在跟这两个词较劲。我在做一款记笔记的小应用用户反馈最多的不是功能缺失而是“粘贴进来格式就乱了”和“代码块高亮总不对”。我试着接开源编辑器要么重得像装了个浏览器要么定制一个功能要翻三天源码。最后我决定自己写一个editor内核。这篇文章就把我从选型、解析、光标管理到撤销重做、上线翻车的完整过程掰开揉碎讲一遍适合想深入理解编辑器底层机制、或者有想法自研编辑器内核的前端开发者。文章里没有那种“看完依然不会动手”的宏观介绍全部是我实际跑过的代码和踩过的坑。1. 为什么我放着现成的编辑器不用非要自己写一个1.1 市面方案的“两头难”先说结论开源编辑器不是不够好而是它们和你想要的“恰好不一样”。市面上大体分两派。一派是以Quill、Slate为代表的富文本编辑器它们的数据模型围绕格式化的文本块设计加粗、斜体、标题很顺手但如果想把“粘贴进来的HTML”干净地转成Markdown语法你得在clipboard事件里自己写一套完整的HTML解析器工作量不小。另一派是纯Markdown源码编辑器比如CodeMirror、Monaco编辑体验极佳可普通用户并不会写Markdown他们只想要“像Word一样打字但保存下来是干净的结构化内容”。我做的是笔记工具目标用户既包括会写Markdown的开发者也包括完全不懂语法的普通用户。这意味着编辑器必须同时支持两类交互一种是“所见即所得”的直接输入一种是源码模式的精准控制。而两种模式需要共享同一份底层数据模型否则切一次模式丢一次格式用户会直接卸载。这就导致一个尴尬局面开源方案里CodeMirror的定位偏代码编辑Quill的定位偏富文本排版ProseMirror和Lexical足够强大但学习曲线极其陡峭团队只有两个人耗不起这个时间。1.2 需求边界其实很窄但很深我给自己列了一个“最低可用”需求清单支持标题、段落、列表、引用、代码块、图片链接等常见Markdown块。粘贴HTML时能自动转成对应的Markdown结构而不是一堆垃圾标签。代码块能识别语言并高亮。实时字数统计且统计的是纯文本字数不是HTML标签长度。撤销重做要符合直觉一次粘贴算一步一次输入法组词算一步而不是拆成十几个原子操作。单看每一条都不难真正难的是它们组合在一起。尤其是不经意间会碰撞出大量边界问题比如“用户在一个列表项中间按回车”“用户在代码块内部粘贴了带格式的内容”“中文输入法还在组词时我该不该触发解析”。这些边界问题正是现有编辑器“定制成本高”的根源。我不需要它们提供的100种功能但它们复杂得让人不敢动源码。所以自己写一个“够用就好”的编辑器内核反而成了最省时的方案。2. 技术选型为什么锚定contenteditable而不是重建编辑引擎2.1 三条技术路线的取舍实现一个网页编辑器底层路线其实只有三条。第一条是textarea加预览面板。左边写Markdown右边看渲染结果。实现简单但交互割裂用户没法直接在预览区域修改内容。对于只想要“所见即所得”的普通用户这条路根本走不通。第二条是基于contenteditable让浏览器接管用户的键盘输入和光标管理我们自己在背后处理数据结构和渲染。这条路可以做到真正的富文本体验破坏性最小用户感觉就是在Word里打字。缺点是浏览器原生行为并不完全可控特别是在光标和选区方面有一堆历史遗留问题。但这些问题都有成熟的应对策略只是比较隐蔽容易踩坑。第三条是用canvas或WebGL自己重绘所有内容甚至自己实现文本布局。这条路的灵活度最高但工作量大到足以让一个团队耗尽预算。没有大厂资源不建议碰。我选了第二条contenteditable负责“打字”这个动作我负责“内容怎么结构化”“如何渲染”“如何撤销”。2.2 别被document.execCommand带偏很多教程会教你用document.execCommand(bold)来做加粗用execCommand(insertText)来插入内容。这个API确实方便但它已经被标记为废弃浏览器只维持基本兼容行为在各平台不一致。最致命的是它操作的是浏览器的“原生编辑命令”你很难在这些命令之间插入自己的数据逻辑。我的做法是绕开execCommand手动管理选区和文本插入。具体来说核心是用beforeinput事件拦截用户的每次输入请求editor.addEventListener(beforeinput, (event) { // 拦截输入类型决定走自定义处理逻辑 if (event.inputType insertParagraph) { event.preventDefault(); handleCustomParagraphInsert(); } else if (event.inputType historyUndo) { event.preventDefault(); customUndo(); } else if (event.inputType insertText) { event.preventDefault(); insertText(event.data || ); } });这段代码的意义在于把本应由浏览器自动完成的插入行为变成我们可控的函数调用。所谓“编辑器内核”本质上就是把这些函数一个个实现并且保证它们和光标、数据模型保持一致。2.3 分层的架构设计整个editor被拆成四层View层一个contenteditable的容器负责接收用户输入维护可见DOM。Parser层把纯文本解析成Markdown的AST抽象语法树例如标题、段落、代码块。Model层AST的内存表示这是唯一的数据源撤销、统计字数都基于它。Renderer层把AST渲染回DOM更新View层。关键原则是用户输入→View层拦截→Parser解析→更新Model→Render回View形成闭环。每层各司其职后续增加新块类型时只需要在Parser和Renderer中各加一小段。3. 解析层Markdown语法树与行级增量解析3.1 为什么需要AST而不是到处正则替换很多人的第一反应是解析Markdown不就是正则替换吗把#开头换成h1把**bold**换成strong。小范围demo没问题但一旦面对“代码块内的##不应当被当作标题”“列表嵌套”“行内代码里的星号不该触发加粗”这类组合场景正则很快就会失控。我选择先把整篇文档解析成AST。AST是一个树状结构每个节点代表一个Markdown块比如// 解析## 标题\n\n正文内容 { type: document, children: [ { type: heading, level: 2, children: [{ type: text, value: 标题 }] }, { type: paragraph, children: [{ type: text, value: 正文内容 }] } ] }这样做的好处有两个。第一渲染层可以直接遍历AST生成DOM不必再考虑文本怎么切分。第二撤销、统计字数、导出、复制等所有功能都可以基于这份结构数据操作不会出现“展示层和真实数据对不上”的问题。3.2 行级增量解析不全文重扫最初我的Parser是整篇文本一次性解析的文档一长输入一个字符都要全部重扫明显能感觉到卡顿。后来改成“行级增量解析”。思路很简单文档按\n拆成行解析的时候一行一行处理而不是整篇处理。维护一个lineCache保存每一行的原始文本和解析结果。用户编辑时我们只需要重解析被改动的那一行以及可能受影响的相邻行比如一个代码块跨多行某一行发生变化后整个代码块的起止可能都要变。一个简化的行解析器结构大概是class LineParser { parseLine(lineText, prevState) { const trimmed lineText.trimStart(); if (trimmed.startsWith(#)) { const level trimmed.match(/^#{1,6}/)[0].length; return { type: heading, level, content: trimmed.slice(level 1) }; } if (trimmed.startsWith()) { return { type: codeBlockStart, lang: trimmed.slice(3).trim(), prevState // 记录上一行的状态用于判断是否在代码块内 }; } return { type: paragraph, content: lineText }; } }这里的prevState非常关键。它让解析器知道“当前这一行是否处于代码块内部”“是否处于列表延续状态”。有了这个状态机意识增量解析才能保持正确性。3.3 代码块的语言识别千万别写死代码块的语言识别一开始我用的是一个固定映射表比如js对应JavaScriptts对应TypeScript。但实际使用中发现用户经常不写语言标识或者随手写个node、tsx这样的别名。更实用的做法是做一个“轻量评分器”针对常见语言准备一组关键特征词解析时对代码内容打分得分最高的语言就是猜测结果。const languageScoring { javascript: [function, const, , console.log], python: [def , import , print(, self.], java: [public class, System.out.println, Override] }; function guessLanguage(codeText) { let bestLang plaintext; let bestScore 0; for (const [lang, keywords] of Object.entries(languageScoring)) { const score keywords.reduce((acc, kw) acc (codeText.includes(kw) ? 1 : 0), 0); if (score bestScore) { bestScore score; bestLang lang; } } return bestLang; }这个算法虽然简单但应对真实笔记场景足够了。它最大的价值不是“猜得准”而是不会出现“语言识别代码比解析代码本身还复杂”的情况。4. 光标与选区一切崩溃的根源4.1 为什么浏览器光标总是乱跑contenteditable的底层模型是浏览器把DOM树当作文本编辑对象。问题是我们在渲染时往往会用许多嵌套的span、strong、code标签来组织内容。浏览器处理光标时是按DOM节点的文本偏移量来计算的一旦DOM结构被我们重新渲染原本的偏移位置就失效了光标就会跳到开头或末尾。你输入一个字符Model层更新Render层把某一行的DOM重建了一遍此时浏览器发现原来的文本节点被移除了于是只能把光标放到最近的可用位置——通常是容器开头。这就是“打字时光标乱跳”的根源。4.2 渲染后恢复光标位置的锚点策略解决思路很朴素在进行任何“会导致DOM重建”的操作前先记录光标上下文DOM更新完成后再根据上下文找回位置。最简单的可靠做法是“文本锚点法”。记录光标前后各20个字符作为锚function saveCaretPosition(container) { const selection window.getSelection(); if (!selection.rangeCount) return null; const range selection.getRangeAt(0); const preCaretRange range.cloneRange(); preCaretRange.selectNodeContents(container); preCaretRange.setEnd(range.startContainer, range.startOffset); const prefix preCaretRange.toString().slice(-20); const postCaretRange range.cloneRange(); postCaretRange.selectNodeContents(container); postCaretRange.setStart(range.endContainer, range.endOffset); const suffix postCaretRange.toString().slice(0, 20); return { prefix, suffix }; }恢复的时候在重建后的DOM中搜索包含prefix结尾和suffix开头的位置然后把光标插到中间function restoreCaretPosition(container, saved) { if (!saved) return; const walker document.createTreeWalker(container, NodeFilter.SHOW_TEXT); let node, textBefore ; while ((node walker.nextNode())) { const newTextBefore textBefore node.textContent; if (newTextBefore.length saved.prefix.length) { const offset saved.prefix.length - textBefore.length; const range document.createRange(); range.setStart(node, Math.max(0, offset)); range.collapse(true); const selection window.getSelection(); selection.removeAllRanges(); selection.addRange(range); return; } textBefore newTextBefore; } }这个方案对中文、英文、混合内容都有效而且实现成本低。它能应对90%的光标问题。剩下10%是输入法。4.3 IME输入法组合态中文编辑器的生死线中文输入法的坑随便一踩就是一片。用户在拼音输入法里打“nihao”还没选字时编辑器里已经出现了拼音字母beforeinput事件里能拿到这些字母。如果这时触发Parser解析可能会把“nihao”当成一个英文单词渲染后拼在一起的拼音被拆成span候选词弹窗就会闪烁甚至消失。我的处理是在compositionstart到compositionend之间完全跳过Parser和Renderer等组词结束再一次性解析let isComposing false; editor.addEventListener(compositionstart, () { isComposing true; }); editor.addEventListener(compositionend, () { isComposing false; requestAnimationFrame(() { parseAndRender(); // 组词结束后再做真正的解析 }); });在beforeinput里判断一下if (isComposing event.inputType.startsWith(insertComposition)) { // 让浏览器自己处理不做任何拦截 return; }这一步不做你的编辑器在中文用户手里基本没法用。做了之后中文输入的稳定性立刻上一个台阶。5. 撤销重做命令模式与快照压缩5.1 快照方案为什么不行最无脑的撤销方案是每次输入后把整个文档的AST存一份到一个数组里撤销时弹出最后一份。文档短的时候可以但笔记类应用动辄几万字一次输入存一份几千个节点的结构内存会一路飙升。更糟的是如果用户在3秒内打了20个字会产生20份几乎一样的快照撤销要按20下才回到输入前这不符合直觉。所以必须走“命令模式”记录每次操作的类型和数据撤销时反向执行。5.2 事务合并与diff生成我定义了一个Transaction来表示一次用户意图class Transaction { constructor() { this.before null; // 操作前的纯文本 this.after null; // 操作后的纯文本 this.merged false; } }每次有输入或删除操作时我取整个文档的textContent做一次文本级diff生成操作前后的对比。这听起来贵但现代浏览器对纯字符串的对比性能非常高而且我们只在“需要记录撤销”的时候才做。同时做时间窗口合并500毫秒内的连续输入视为同一个事务。let lastInputTime 0; let currentTransaction null; function recordChange(beforeText, afterText) { const now Date.now(); if (now - lastInputTime 500 currentTransaction) { currentTransaction.after afterText; currentTransaction.merged true; } else { if (currentTransaction) undoStack.push(currentTransaction); currentTransaction new Transaction(); currentTransaction.before beforeText; currentTransaction.after afterText; } lastInputTime now; redoStack.length 0; }有意思的是用textContent做全量前后对比天然就解决了“怎么知道这次改了哪一行”的问题。我们不需要自己逐字diff只要在撤销时把整个文本替换成before的文本然后重新解析渲染即可。你可能会问整篇重新渲染不卡吗这就轮到下一节的“渲染层优化”上场了。5.3 撤销的结果为什么可能“多撤了一步”这里有个非常隐蔽的坑。用户选中一段文字并粘贴新内容浏览器会把原内容删除再插入新内容。如果我们监听的是beforeinput一次粘贴可能会触发出一个deleteContent和一个insertText事件。如果每个事件都记录一次事务撤销一步就只会撤销“删除”或“插入”结果完全不符合预期。解决方式是做“事件合并”一旦检测到某个输入操作属于“组合动作”的一部分就用一个标志位把它们合并进同一个事务。比如粘贴时在剪贴板事件里主动拦截并生成一个Transaction而不是依赖后续的beforeinput。这个坑不踩一遍真的很难发现。它会让用户觉得撤销“抽风”一会儿能撤销多步一会儿只能撤销半步。6. 渲染层从AST到DOM的diff更新6.1 全文innerHTML重绘是性能杀手第一版渲染器非常偷懒每次解析完AST直接生成一整段HTML字符串赋给容器。文档只有几百字时没问题到几千字每次输入都全量重绘光标的恢复开始变得不稳定而且滚动位置会跳。后来我改成“只更新受影响的块”。核心是给每个AST节点分配一个稳定的块ID渲染时对比新旧AST找出变化的块只替换对应的DOM节点。6.2 块级diff与文本hash这里的做法非常务实不追求教科书级的diff算法而是基于“块ID加内容哈希”做判断function blockHash(block) { const raw block.type | (block.level || ) | block.textContent; let hash 0; for (let i 0; i raw.length; i) { hash (hash 5) - hash raw.charCodeAt(i); hash | 0; } return hash.toString(36); }渲染时遍历AST的block列表如果某个block的hash和上一次一样就直接复用旧DOM不一样才重新创建。这个策略比全量diff高效得多而且代码简单。大多数编辑器其实不需要精确到每一个文本节点的diff粒度到“块”已经足够。6.3 事件代理别在每个块上绑事件第一版我为每个block都绑定了点击、输入等多个监听器节点一多内存和初始化时间都Hold不住。后来改成事件代理只在容器上绑定一次事件用event.target.closest([data-block-id])找到对应的块。editor.addEventListener(click, (event) { const blockEl event.target.closest([data-block-id]); if (!blockEl) return; const blockId blockEl.dataset.blockId; handleBlockClick(blockId, event); });这样不管文档多大事件监听器数量恒定。后续如果做提醒、图片选中框之类的扩展也只需要在代理里增加分支逻辑。7. 上线后的三次事故与排查复盘7.1 事故一粘贴富文本内容后页面卡死现象从某个网页复制一段带样式的内容进编辑器Chrome标签页直接崩溃。排查过程我先在本地复现发现卡死发生在paste事件处理函数中。我写的HTML转Markdown逻辑里有一堆正则某个富文本片段触发了灾难性回溯。根因嵌套的div和span标签太多正则用了类似/div[^]*.*\/div/gi这样贪婪匹配加回溯的模式一段几KB的内容导致的匹配次数能达到指数级。修复方案有两个一是所有解析逻辑里禁止使用包含.*的贪婪匹配二是给粘贴内容设置最大长度限制超过50KB直接提示用户粘贴纯文本。两者合并之后卡死问题再也没出现过。7.2 事故二iOS Safari光标频繁跳到文末现象在iPhone上编辑长文档输入两三行光标就会跳到末尾。排查过程Safari的selectionchange事件触发极其频繁且它发生在浏览器渲染之前。我们的代码监听了selectionchange去做状态同步结果同步过程修改了DOM结构导致原本的光标位置失效Safari就把它重置到末尾。修复方案把渲染和光标恢复操作放到requestAnimationFrame里批量执行不允许在selectionchange回调里直接操作DOM。这样既保证状态同步的及时性也避免和浏览器的光标计算打架。let pendingRender false; function scheduleRender() { if (pendingRender) return; pendingRender true; requestAnimationFrame(() { pendingRender false; doRender(); }); }这个修复还有额外收益输入卡顿感明显下降因为不再每次击键都同步渲染。7.3 事故三撤销时整段内容被清空现象部分用户反馈撤销多次之后整个文档变成空白。排查过程我复现后发现这个bug和代码块有关。当光标处于代码块内部时textContent会保留代码内容但代码块的渲染结构中包含precode标签。我在记录事务时取的是渲染后DOM的textContent而textContent会把所有标签剥掉只留下文本。问题在于解析器在解析纯文本时会把连续的pre内部空间当作Markdown前缀过滤。当我们撤销时整篇文本被替换但解析器面对的是“失去了代码块上下文”的纯文本于是把代码块内容当成了标题或普通段落渲染全乱。根因是我在事务里存储的是“渲染后的文本”而不是“解析后的AST文本”。后来统一改成所有撤销重做事务只操作Model层的AST序列化结果不碰渲染层文本。这次事故让我意识到编辑器的数据源必须唯一。一旦你同时让“渲染文本”和“解析AST”同时承担数据源职责早晚会出问题。写在最后editor真正难的地方回头再看这个editor项目解析Markdown、渲染DOM这些环节其实只占了工作量的一小部分。真正耗费我大量时间的是状态同步输入与解析的时机、光标与渲染的先后、撤销与数据源的统一这些看不见的“时序问题”才是编辑器开发的深水区。如果你想自研一个编辑器我的建议是别一上来就想着支持多少种语法先把一个最简单的段落输入跑通然后立刻着手实现“渲染后光标恢复”。把这一步打通了再往里面加标题、列表、代码块都不会太难。反过来如果一上来就写一大堆块的解析逻辑光标问题早晚会把信心磨没。现在我的笔记应用已经稳定跑完三个版本迭代这个editor内核虽然不到两千行但它解决了我所有的核心痛点。以后如果再有机会写编辑器我应该会第一时间把“状态同步”的设计文档先写清楚而不是直接上手写Parser。
返回列表