资讯动态

Markdown渲染引擎的流式解析与安全输出实践

发布时间:2026/9/15 1:54:29 来源:尧图企业网站定制
这个系列走到第8篇前7篇把词法分析、AST设计和行内解析的基本盘都讲完了按常理说一个能用能跑的Markdown渲染引擎早该收工但真正把它推进到“生产可用”的时候问题才开始暴露。一个渲染引擎从demo能用到面对几十万行文本、未知用户输入、导出链路和各种扩展语法都能稳定工作中间的差距就是标题里写的“流式解析”和“安全输出”这两件事。先说卡死。我的编辑器支持直接粘贴大段内容预览某次测试时用户粘了一段13万行的Markdown文档中间嵌套了三层代码块。第一版实现是“整篇读入、整篇解析、整篇渲染”拿到全部文本parse整棵AST再一次性生成DOM diff进页面。结果Chrome标签页直接无响应等了几分钟才恢复。这个问题不是解析慢了而是我的架构压根没考虑过“文档比屏幕大几个数量级”时的生存方式。再说告警。公司内部知识库工具会渲染用户提交的Markdown早期版本默认允许内嵌HTML。有同事从网页复制内容进来里面带着一段img onerror...的代码。虽然只是内网工具没造成实害但安全扫描第二天就报了中危漏洞。这让我意识到Markdown渲染引擎在生产环境里默认安全是必须的不是可选项。不能假设输入来自可信来源不能依赖“用户不会乱填”也不能把消毒当成事后补丁。于是重构目标就定为两件事同时成立第一大文档必须边读边解析、按需渲染第二输出端默认关闭原始HTML所有输入经过白名单消毒。这篇就围绕这两条主线展开记录整个设计、实现和踩坑过程。1. 为什么“流式”和“安全”要一起解决一次卡死与一次告警1.1 13万行文档把页面卡死的根因其实刚开始做渲染引擎的时候我用的方案和大部分人一样fs.readFile把整个文档读进来然后丢给解析器解析器返回整棵AST再递归生成HTML字符串最后一次性插入DOM。这个流程写起来非常顺前几百行、几千行的文档跑起来毫无知觉直到碰到一份13万行的真实日志。这份日志长什么样前面是几千行说明文字中间夹着三层嵌套的代码块后面还有一个占了几百行的表格。我当时的渲染引擎在解析阶段其实只花了不到40毫秒问题出在后续的整树遍历和DOM构造上——要递归创建几万个节点每个节点都要做字符串拼接、属性处理、文本转义浏览器主线程被占用了好几秒。这里有个认知要纠正Markdown渲染慢通常不是解析器慢而是DOM构建和布局慢。解析器处理十几万行文本一般都能在几十毫秒内完成但几万个DOM节点的创建、插入和重排才是长任务的真正来源。所以“流式”的意义不只是解析端的分块而是整个渲染管线都要跟着分块解析器按块产出渲染器按块消费DOM按块挂载。这样才能让主线程每帧只用几毫秒处理新增的那部分内容浏览器才不卡。1.2 默认允许HTML导致的告警那次安全告警的触发链很简单用户提交的Markdown里有一段从网页复制的富文本里面有个img标签带了onerror属性。我的渲染引擎把内嵌HTML原样输出到页面浏览器执行了这段脚本。虽然没有造成实际破坏但它彻底改变了我对“Markdown渲染”的安全假设。Markdown本身是安全的危险的从来都是它允许承载的HTML片段。CommonMark规范里原始HTML是合法语法很多编辑器为了兼容性默认支持。但一旦支持你就必须回答一系列问题哪些标签允许哪些属性允许href能不能是javascript:src能不能是data:text/html事件属性要不要清掉如果每个问题都靠“发现漏洞再打补丁”来解决安全就永远是被动的。所以我的决定是默认不信任任何原始HTML一律先转义成纯文本只有通过白名单消毒器的标签和属性才恢复为HTML。这个顺序是“默认拒绝、显式放行”而不是“默认放行、尝试识别危险”。1.3 两个约束同时成立的设计原则把这两次事故放在一起看其实指向同一个设计原则渲染引擎的每个环节都要有“边界意识”。解析端要知道自己一次处理多少内容不越界输出端要知道哪些内容可以进DOM不越界。流式解决的是“量”的问题安全解决的是“质”的问题一个管性能上限一个管安全底线。所以我没有把流式解析和安全输出拆成两个独立模块来做而是在架构上统一考虑分块器每产出一个块先经过“块卫生检查”确认语法状态正确渲染器拿到块的AST后先经过“输出消毒器”确认生成的是安全HTML。块在流动的过程中天然就被拦截了两道。后文会按这个链路逐步展开。2. 流式解析的核心块边界、块上下文状态机与增量AST补丁2.1 为什么不能简单按行切分一说“流式”最容易想到的做法就是按行读、按行解析。这个思路方向对但直接落地会踩坑因为Markdown的很多块结构是跨行的而且跨的方式还不是“行与行相邻”这么简单。举两个典型例子。代码块以三个反引号开头中间可以包含任意行包括空行直到另一个三个反引号出现才结束。如果你按行切分、逐行独立解析中间内容会被当成普通段落三个反引号也会被误判成文本或标题符号。再比如引用嵌套符号可以连续跨几十行里面再套列表、套代码块块级语义是叠在行之上的。表格更特殊表头行和分隔行是强绑定的分隔行不出现前面那行就只是普通段落分割行一旦出现整个表格块才算成立。所以分块的真正单位不是“行”而是“块候选”。解析器维护一个buffer不断读入新行遇到空行或者明确的块结束标记比如代码块闭合、表格结束、列表缩进中断时才把buffer里累积的内容提交为“一个块”。这个提交动作是由状态机控制的不是由行数控制的。2.2 块上下文状态机代码块不会提前闭合我实现了一个简单的块上下文状态机每种状态对应一种块级语境。核心状态包括普通段落、引用块、无序列表、有序列表、围栏代码块、缩进代码块、HTML块、表格、数学公式块、图表块。每次读入一行状态机先根据当前状态判断这行要不要吃掉比如当前是围栏代码块那么唯一能改变状态的只有“遇到和开始标记同等长度及以上的反引号行”其他一切内容包括空行都归代码块所有。这个状态机还有一个附带好处流式解析时如果文档还没读完遇到一个未闭合的代码块分块器可以继续累积而不提交等文档结束或遇到闭合标记才把整个代码块作为一个整体提交。这个特性在做编辑器实时预览时特别有用因为你不可能等用户敲完整个代码块才出渲染结果。下面是分块器核心逻辑的简化伪代码我用JavaScript风格来表达class BlockSplitter { constructor() { this.state paragraph; this.buffer []; this.fenceChar ; this.fenceLen 0; } feed(line) { if (this.state fence) { this.buffer.push(line); if (this.isFenceClose(line)) { this.commitBlock(code); this.state paragraph; } return; } // 其他状态按正常规则判断是否提交 if (this.state paragraph this.isEmptyLine(line)) { this.commitBlock(paragraph); return; } if (this.isFenceStart(line)) { if (this.buffer.length 0) this.commitBlock(paragraph); this.state fence; this.buffer [line]; return; } this.buffer.push(line); // 表格分隔行的特殊处理 if (this.isTableDivider(line)) { this.commitBlock(table); return; } } commitBlock(type) { // 将当前 buffer 交给下一级块解析器并开启新的 buffer } }这个状态机本身不难难的是“什么时候该提交”的判断顺序。我的调试经验是判断顺序必须是“先处理当前状态的退出条件再处理新状态的进入条件”反过来就很容易把闭合标记误判成新块的开头。这个坑在第5章还会细说。顺带说一句换行处理。Markdown的软换行在渲染时默认是空格硬换行行尾两个空格或反斜杠才输出br。流式解析对换行语义要特别小心分块器在提交块时不能把块内最后的软换行吞掉否则段内换行和段间换行的语义就混了。我的做法是在块提交前一并记录每行的换行类型留给行内解析器判断是软换行还是硬换行。2.3 增量更新让AST只重新计算脏分支分块器解决了“边读边解析”但实时预览场景还有一个问题文档编辑后不能整棵AST重建。我的方案是引入“脏块标注”机制编辑器每次输入变化时记录变化涉及的文本范围映射到对应的分块索引解析器只重新解析这些脏块替换AST里对应子树渲染器再根据AST子树的变化只更新页面上对应的DOM节点。这个机制的核心数据结构是“分块索引表”每个条目保存块的起始偏移、结束偏移、块的AST指针、对应的DOM容器节点。输入变化时二分查找受影响的分块范围然后对范围内的块重新解析。如果变化跨越了解析块边界比如新增了一行导致后面所有引用块合并了那么受影响范围会向后扩散直到遇到一个没有变化的稳定块为止。扩散的收敛速度通常很快因为大部分编辑都集中在一两个块内。增量更新的收益是量级的。全量解析一份1万行的文档包含解析和DOM重建耗时在数百毫秒到一秒以上而一次单块更新耗时通常在几毫秒内。用编辑器场景来说用户每敲一个字符预览区只需要重绘正在编辑的那个块而不是整个页面输入跟手度和预览流畅度是两个层次的感觉。3. 安全输出的三层防线默认转义、白名单消毒器与URL协议校验3.1 第一层一切先转义原始HTML默认关闭安全输出的第一层最简单也最容易被忽视在解析阶段就决定“原始HTML要不要保留”。我的选择是默认关闭把原始HTML当普通文本处理进行HTML转义。这意味着b加粗/b这种写在Markdown里的内容不会被渲染成加粗而是显示为一段包含尖括号的文本。为什么这么严格因为原始HTML一旦开启解析器就无法可靠地区分“危险代码”和“安全代码”。就算有消毒器在后面兜底也是建立在“消毒器没有漏网”这个假设上。而消毒器是一个黑名单思维的逆向工程你需要知道所有攻击方式才能拦截所有攻击这在实践中不可能做到。关闭原始HTML的代价是损失了一部分灵活性比如用户无法在文档里嵌入details折叠面板。我的折衷方案是提供一个“扩展HTML白名单开关”当明确需要这类功能时配置里开启但开启后所有HTML仍然必须通过第二层的白名单消毒器。生产环境推荐保持关闭。3.2 第二层白名单消毒器属性级清理第二层是消毒器它是安全输出的主力层。我的实现逻辑分三步第一步把HTML字符串解析成DOM树用DOMParser或者htmlparser2这类容错解析器第二步遍历节点凡是不在白名单内的标签直接移除或转义为文本第三步对白名单标签逐一检查属性不在属性白名单内的删除事件属性on*全部删除。我维护的标签白名单大致是分类允许的标签基础排版p,br,strong,em,del,ul,ol,li,blockquote,pre,code标题与语义h1~h6,hr,details,summary,span,div链接与媒体a,img表格table,thead,tbody,tr,th,td,caption排版辅助section,figure,figcaption属性白名单更短a只保留href和titleimg只保留src、alt、titlecode保留class用于高亮语言标记且class值要匹配language-前缀th、td保留align。除此之外的属性和各种事件钩子一律删除。有一点要注意白名单里的class不能无脑放行因为class本身不执行但某些前端框架会用它做事件委托或样式挂钩攻击者可以把恶意样式类名注入进去。所以我对class也做了前缀白名单校验只允许language-、hljs这种和渲染引擎自身约定的类名。3.3 第三层URL协议白名单与图片路径校验标签和属性都白名单化之后还有一个容易被忽略的入口a的href和img的src。href可以写成javascript:alert(1)src可以写成data:text/html;base64,...。这些值虽然是字符串但被浏览器当成协议执行时就变成了脚本执行入口。所以第三层是协议白名单对于a[href]只允许http:、https:、mailto:、tel:其他协议一律拒绝空值和锚点#开头可以放行对于img[src]只允许http:、https:以及data:image/前缀的图片数据。注意data:不能整体放行只能放行data:image/否则data:text/html会成为一个绕过点。协议校验要在解码之后做不要直接对原始字符串做前缀判断。因为javascript#58;alert(1)经过实体解码后会变成javascript:alert(1)如果只检查原始字符串就会被绕过。我的做法是先把属性值做HTML实体解码再用new URL(value, base)解析最后检查protocol字段而不是用字符串startsWith。3.4 逐类拆解典型攻击载荷把三层防线做完后我对着几类常见攻击载荷做了回归测试这里列几个典型的img srcx onerroralert(1)消毒器直接移除onerror属性标签保留但失去执行能力。[点我](javascript:alert(1))URL协议校验直接把这个链接判为非法我在渲染时会用一个javascript:占位URL替代并给链接加上relnoopener noreferrer和防误点样式。scriptalert(1)/script默认转义层已经把尖括号转义成lt;和gt;渲染为文本不会进DOM。[点我](java#115;cript:alert(1))实体解码后再校验协议层拦截。a hrefhttps://evilsite.com onclicksteal()链接/a白名单属性清理时onclick被删除链接正常保留。这套回归测试我固化在了测试用例里每次改解析器或消毒器都会跑一遍。安全输出不是做完就完了它需要像回归测试一样长期维护因为解析器每新增一种语法比如数学公式、图表都可能打开一个新的注入面。4. 性能调优记录13万行文档如何做到滚动渲染不掉帧4.1 基准测试的方法和数据基线调优前我建立了一个基准文档真实的13万行日志包含代码块、长表格、嵌套引用和大量行内代码。测试环境是Node服务端解析加Chromium渲染用Performance API记录各阶段耗时。基线数据大概是阶段全量方案耗时流式方案耗时文本读取18ms逐块读取忽略块级解析36ms按块分摊每块0.1ms量级行内解析52ms按块分摊DOM构建与插入约1300ms首屏约30ms滚动增量渲染不适用每帧约8ms从这张表可以看到解析阶段的耗时并不恐怖可怕的是DOM构建。流式方案的价值不在于“让解析更快”而在于“让渲染不再和文档总长度挂钩”。4.2 三个关键优化正则预编译、可变buffer与避免回溯灾难流式方案跑通后我又做了三轮性能优化每一轮的收益都很明显。第一轮是正则预编译。行内解析里有大量正则比如识别行内代码、强调、链接、图片。最初图省事直接在函数里new RegExp跑在13万行文档上带来了可观的GC压力。优化方式是把这些正则提升到模块级别创建一次反复使用并把正则的source和flags固化成常量。第二轮是可变buffer。buffer.push(line)在行数极多时会有数组扩容开销我改用了一个预分配容量的字符缓冲结构按4KB一块连续追加。分块器提交时直接引用缓冲区的子串视图而不是拷贝一份新的字符串。这一步把解析器的内存分配次数降低了一个数量级。第三轮是规避回溯灾难。Markdown的行内解析最考验正则的地方是嵌套结构和可选分支。比如链接文本里可以包含强调、行内代码而强调里又可以包含链接如果正则写得贪婪很容易在特定输入上触发灾难性回溯。我的处理原则是能分步就不合写能限定就不贪婪。行内语法尽量拆成多个独立的小解析器按优先级顺序试而不是堆一个巨无霸正则。4.3 渲染端的增量节点复用流式方案在渲染端的核心是“节点复用”。全量方案每次重新创建DOM节点流式方案则维护了一个“AST节点到DOM节点”的映射表。当某个块重新解析后先比较新旧AST的结构差异能复用的节点直接用replaceChild挂回原位只有变化的部分才新建节点。这个映射表还有助于滚动虚拟化。我的渲染器只挂载可视区域附近的块距离视口超过一定阈值的块会被卸载滚动回来时从AST重新生成DOM。因为AST是纯数据不占布局资源卸载DOM节点不会丢内容只会丢浏览器布局缓存内存占用和滚动流畅度都能得到平衡。实测下来13万行文档的滚动渲染基本稳定在每帧8毫秒左右配合content-visibility: auto这种CSS优化即使长表格也能做到接近60帧的滚动体验。5. 踩坑记录分块边界、事件丢失与消毒器误杀的修复链路5.1 代码块的开始标记被分块器吞掉第一个坑出现在分块器里。最初的实现遇到行时会把这行当作“围栏代码块开始”处理直接推进状态并把这行吞进buffer。问题在于如果这行本来只是段落里的普通文本比如“代码块写法是三个反引号”它也会被判定为围栏开始导致后续所有内容都被当成代码块直到文档末尾。修复思路是给围栏开始加更严格的判定条件反引号必须出现在行首且前面没有其他非空白字符反引号数量必须在3个及以上后面跟的内容只允许是语言标记和空白。这些条件都满足才进入代码块状态。附带的教训是分块器判定新状态的进入条件必须比退出条件更严格宁可不识别也不要误识别。5.2 增量更新时虚拟节点复用带来的事件绑定失效第二个坑在增量更新的DOM复用上。我的第一版实现里渲染器把AST映射表里的DOM节点直接复用但忘了处理一个细节用户通过编辑器给某个块绑定了交互行为比如点击代码块右上角复制按钮。当这个块被增量更新时旧的DOM节点被替换新节点没有重新绑定事件按钮点了没反应。排查链路是这样走的先确认事件绑定逻辑在初始化时执行正常再确认增量更新时确实走了replaceChild分支最后定位到是新节点创建后没有调用绑定函数。修复方案是给每个块的渲染函数统一收口无论是首次创建还是增量替换都走同一个“创建节点并绑定行为”的入口不允许跳过绑定步骤。这个经验后来也推广到了所有带交互的块类型。5.3 消毒器误杀合法链接与正常文本第三个坑相反是消毒器过于严格误杀了合法内容。具体场景是用户写的Markdown里有一个https://example.com/path?a1b2这样的链接我在第一层解析时对做了实体转义输出HTML时又转义了一次导致变成了amp;amp;在页面上显示成了amp;浏览器地址栏跳转后参数也丢了。根因是转义函数被重复调用。修复方式是明确整个管线的转义时机解析阶段只做语法解析不转义渲染阶段统一做一次HTML转义消毒器的协议校验使用解析后的AST数据而不是用已经转义的字符串。这样在整个链路中只会经过一次转义不会出现双重转义。5.4 表格跨块解析与数学公式的边界最后说两个边界问题的处理。表格因为依赖分隔行天然适合“多行凑成一个块”的解析模式但表格行内如果出现了管道符|会被误判为单元格分隔。我的处理是先看整行是否满足表格行的最小结构要求有管道符、有分隔符不满足就按普通段落解析避免用户写一个“a|b|c”的普通句子被切成表格。数学公式的边界则在于$的歧义。用户写“价格是$5和$10”时如果解析器见到美元符号就当作行内公式开始内容就会错乱。我的规则是行内公式的起始$后面不能紧跟数字、空格和另一个$公式内容里如果出现两个连续换行则视为错误回退为普通文本。这些规则不完美但覆盖了绝大多数日常输入。6. 延伸方向编辑器联动、表格导出与公式渲染隔离6.1 编辑器和渲染器共享同一套分块索引流式解析天然适合编辑器的实时预览架构。因为分块索引保存了每块在原始文本中的精确偏移编辑器在输入时可以复用同一套索引光标位置的块级定位、自动补全的上下文判断都变得非常容易。我在实现中让编辑器直接调用渲染引擎的getBlockAtOffset(offset)接口省掉了两套数据结构同步的问题。6.2 表格导出PDF和Excel复用AST结构很多人问“Markdown表格怎么转Excel”“Markdown怎么导出PDF”其实只要渲染引擎的AST结构设计得好这些导出都是顺带的事。表格在AST中是table节点内部按headerRow、bodyRows、cells组织天然可以和Excel的单元格坐标系一一对应导出PDF则可以直接复用渲染好的HTML套一版打印样式通过浏览器的打印机制输出。比如VS Code这类编辑器导出PDF插件通常就是把Markdown源码交给渲染引擎生成HTML再走打印样式输出PDF核心还是AST。整个项目里最有复用价值的就是AST所有下游功能都从AST取数不要让下游直接解析原始文本。6.3 公式与图表的渲染隔离扩展语法数学公式、图表的安全风险在于它们往往需要引入额外的渲染器而额外的渲染器可能引入新的执行上下文。我的处理是给扩展渲染器划一个独立渲染区域把公式内容用文本节点传给KaTeX这类库禁止它们接收HTML字符串图表则把数据作为JSON传入渲染结果只输出SVG同样不允许HTML字符串回流。这个原则一句话扩展渲染器的输入和输出都必须是纯数据不能是HTML。守住这条线扩展语法就不会变成新的注入面。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价