浅谈Markdown 渲染

38 阅读16分钟

前置基础:理解渲染前必须知道的 3 个底层认知

在聊渲染机制前,先搭建三个底层认知,避免后续概念跳步:

  1. Markdown 的语义本质:Markdown 是一种轻量级标记语言,它只通过纯文本符号定义内容的语义结构(例如 # 代表一级标题、``` 代表代码块),本身不包含任何视觉样式。我们看到的排版效果,全部由渲染层赋予。
  2. 桌面端渲染的宿主本质:Mimo/Codex 这类 Electron 架构的桌面端产品,Markdown 渲染最终依赖 Chromium 内核完成,本质是 Web 渲染的一种落地形态,核心逻辑与浏览器端一致,但额外受桌面端性能、交互体验的约束。
  3. 流式输出的核心矛盾:常规 Markdown 渲染是「全文输入→一次性渲染」的完整流程;而流式场景是「逐片段输入→持续更新渲染」,核心挑战是在语法不完整的输入下,同时保证解析正确性、渲染性能和视觉稳定性,避免样式跳变与交互卡顿。

一、Markdown 渲染的核心定义与标准流水线

定义

Markdown 渲染是将纯文本格式的 Markdown 源码,转换为可直接可视化展示的富文本内容的完整处理过程,核心是完成「语义标记」到「可视化结构 + 样式」的映射。

底层原理

标准的 Markdown 渲染遵循四阶段流水线,每个阶段职责单一且顺序执行:

  1. 输入接收:获取原始 Markdown 文本字符串,是整个流程的输入源
  2. 语法解析:识别文本中的标记符号,将无结构的纯文本转换为结构化的语法节点
  3. 格式转换:将解析后的语法节点,映射为渲染宿主可识别的格式(Web 场景下为 HTML 标签)
  4. 布局绘制:渲染内核结合 CSS 样式规则,完成元素布局、像素绘制,最终呈现出可视化效果

其中,语法解析是整个渲染流程的核心,直接决定渲染的准确性和能力上限。

具象示例

以输入 # 欢迎使用 Mimo 为例:

  1. 输入阶段:接收字符串 # 欢迎使用 Mimo
  2. 解析阶段:识别开头的 # 标记,判定该行为「一级标题」节点,内容为「欢迎使用 Mimo」
  3. 转换阶段:将一级标题节点映射为 HTML 标签 <h1>欢迎使用 Mimo</h1>
  4. 绘制阶段:Chromium 内核根据 h1 标签的 CSS 样式(字号、字重、边距等),在窗口中绘制出大号加粗的标题效果

应用场景

覆盖所有需要展示结构化文本的场景,包括但不限于:

  • 技术文档、博客文章的内容展示
  • 聊天对话、AI 助手的回复渲染
  • 本地 Markdown 编辑器的预览功能
  • 代码生成工具的结果输出

二、两种主流解析渲染技术路线

解析是渲染的核心环节,目前工业界有两种主流实现思路,对应不同的需求场景。

定义

两种路线分别是字符串替换法AST 解析法,代表了「模式匹配」和「结构理解」两种不同的技术思路,是所有 Markdown 渲染库的底层分类。

术语补充:AST(抽象语法树,Abstract Syntax Tree) ,是一种用树形结构表达文本语法结构的数据格式,每个节点对应一个语法单元,包含类型、内容、子节点等信息。

底层原理

  1. 字符串替换法本质是基于正则表达式的文本模式匹配:预先编写对应每种 Markdown 标记的正则规则,直接将匹配到的标记替换为目标 HTML 标签。整个过程不构建完整的语法结构,只做表层的字符串替换。

  2. AST 解析法本质是先理解结构再生成内容:

    • 第一步:词法分析,将文本拆解为最小的标记单元(Token)
    • 第二步:语法分析,根据 Markdown 语法规则,将 Token 组装成树形的 AST 结构
    • 第三步:遍历 AST 树,每个节点对应生成对应的 HTML 标签

核心差异对比

对比维度字符串替换法AST 解析法
实现复杂度低,仅需编写正则规则高,需要完整的语法解析器
解析准确性低,嵌套语法、边界场景容易出错高,能正确处理复杂嵌套和边缘情况
可扩展性差,新增语法容易出现正则冲突好,可通过插件扩展自定义语法
短文本性能极快略低于正则方案
长文本性能随复杂度提升急剧下降性能稳定,适合长文本

具象示例

以解析 **加粗文本** 为例:

  • 字符串替换法:用正则 /**(.*?)**/g 匹配内容,直接替换为 <strong>$1</strong>
  • AST 解析法:先生成类型为 strong 的语法节点,子节点为文本「加粗文本」,再遍历节点生成 <strong>加粗文本</strong>

当遇到嵌套场景 **加粗且*斜体*的文本** 时,字符串替换法容易出现匹配错乱,而 AST 解析法可通过树形结构准确处理嵌套关系。

应用场景

  • 字符串替换法:仅适合极简场景,例如仅支持标题、加粗、列表的轻量评论区
  • AST 解析法:工业界主流方案,适用于所有复杂场景,尤其是流式输出、扩展语法、长文档等需求

三、流式 Markdown 渲染(桌面端 AI 场景核心)

这是 Mimo/Codex 这类桌面端大模型产品的核心场景,也是和常规 Markdown 渲染差异最大的部分。

定义

流式 Markdown 渲染,是指在 Markdown 文本未完整接收时,基于持续增量输入的文本片段,动态更新渲染结果的渲染模式。核心目标是在内容逐段输出的过程中,给用户实时、流畅且无闪烁的视觉体验,同时保证桌面端交互不卡顿。

底层原理

流式渲染的核心难点在于:Markdown 大量语法是成对、多行、可嵌套的(例如代码块、引用块、有序列表),片段输入时往往处于语法未闭合状态,直接渲染容易出现样式错乱、闪烁、解析错误等问题,同时频繁重渲染也会带来性能压力。

目前工业界共有三种主流实现思路,对应不同的体验与成本权衡:

1. 全量重渲染方案

实现逻辑:每收到新的文本片段,就将其追加到完整文本中,重新执行完整的「解析→转换→绘制」全流程,整体替换页面 DOM。

  • 优点:实现最简单,逻辑不易出错,语法正确性由解析器兜底
  • 缺点:长文本下性能损耗大,频繁重排容易导致页面跳动、主线程阻塞,文本越长性能越差

2. 保守增量方案(块缓冲版)

实现逻辑:维护当前的解析状态,严格等待一个完整语法块完全闭合后,再将其追加到 DOM 中;未闭合的语法片段暂存缓冲区,不参与渲染。

  • 优点:完全避免中间态样式跳变,视觉稳定性高,性能优于全量重渲染
  • 缺点:内容呈现存在延迟,尤其是代码块、表格等多行块,用户会感知到内容 “突然出现”,实时感差

3. 工业级主流方案:全量词法分块 + 块级增量渲染

这是当前 AI 对话产品的通用落地方案,也是本项目采用的方案,核心思路是「稳定块永久缓存,尾块动态更新,解析正确性靠规范兜底」。

核心原理分为三层:

(1)解析层:基于 CommonMark 规范的词法分块

CommonMark 规范明确规定:未闭合的围栏代码块会自动在文档末尾、引用块末尾或列表项末尾闭合,起始围栏出现后,后续所有行默认归属于该代码块CommonMark。这意味着闭合标记不是代码块成立的必要条件,只是结束代码块的标记。基于此规范,marked.lexer 等词法解析器只要遇到起始标记 ```,就会立刻生成一个合法的 code token,后续所有内容都归属于该代码块,无需等待闭合。

(2)渲染层:Settled/Live 双状态块级增量

每次新内容到来时,对当前完整文本执行一次全量词法分块,将所有块分为两类:

  • Settled 已稳定块:位于文本前部,后续输入不会再改变其语法属性的块,通过组件缓存机制永久复用,不再重渲染
  • Live 活跃尾块:位于文本末尾、还在持续增长的块,是唯一会随输入更新的部分

该方案的本质是「用轻量的全量词法分析,换取块级增量渲染的稳定性」—— 词法分析的成本远低于 DOM 重绘,且彻底避免了手动维护状态机容易出现的语法错乱。

(3)高亮层:节流全量高亮 + 素色尾巴过渡

针对代码块高亮,采用分层策略平衡体验与性能:

  • 词法高亮天然支持不完整代码:Shiki 等高亮引擎基于 TextMate 规则做逐行词法匹配,不要求代码语法完整,半截函数、未闭合字符串均可正常着色
  • 节流控制高亮频率:Live 状态下设置 100–120ms 的节流阈值,避免每字符重高亮导致的 O (n²) 性能退化
  • 素色尾巴平滑过渡:两次高亮之间新增的字符,以纯文本默认色直接拼接在高亮结果末尾,下一轮高亮时自动收编上色,用户肉眼几乎感知不到延迟

具象示例

以流式输出一段代码块为例,输入顺序逐帧到达:第1帧:```js第2帧:换行第3帧:console.log('hello')第4帧:换行第5帧:```

三种方案的逐帧表现对比如下:

  1. 全量重渲染方案

    • 第 1–4 帧:全文重解析,因无闭合标记,部分解析器会判定为普通文本,内容以普通段落样式显示
    • 第 5 帧:识别到完整代码块,整体瞬间切换为代码块样式,出现明显视觉跳变
    • 核心问题:样式突变 + 全量 DOM 替换导致滚动跳动
  2. 保守增量(块缓冲)方案

    • 第 1–4 帧:内容暂存缓冲区,页面对应位置无内容,保持空白
    • 第 5 帧:代码块完全闭合,一次性渲染完整代码块
    • 核心问题:内容延迟出现,实时感差
  3. 全量词法分块 + 块级增量(工业主流)

    • 第 1 帧:识别到代码块起始标记,立刻渲染代码块容器(背景、圆角、等宽字体),内容为 js
    • 第 2–4 帧:持续向当前代码块节点追加内容,容器样式全程保持,无跳变;文字先以素色显示,每 120ms 批量完成高亮
    • 第 5 帧:代码块闭合,标记为 Settled 状态,永久缓存不再更新;执行最终全量高亮修正跨行语法
    • 核心优势:容器实时生效,高亮平滑过渡,性能稳定,体验最优

应用场景

  • 大模型对话助手的流式回复展示
  • 实时协作的 Markdown 文档编辑
  • Mimo/Codex 这类桌面端 AI 代码生成、内容生成工具
  • 实时日志、流式数据的结构化展示

四、Electron 桌面端渲染的特殊约束

Mimo/Codex 这类桌面端产品的渲染,在 Web 渲染基础上,有其专属的约束和优化方向。

定义

Electron 架构下的 Markdown 渲染,是 Web 渲染在桌面混合应用中的落地形态,依托 Chromium 渲染进程实现,同时受桌面端的交互体验、资源调度、进程架构的约束。

底层原理

  1. 进程架构约束Electron 分为主进程和渲染进程,Markdown 渲染运行在渲染进程中。若渲染主线程阻塞,会直接导致窗口拖动、菜单点击、输入响应等桌面交互卡顿,影响体验的严重程度远高于浏览器端 —— 浏览器标签页卡顿仅影响单个页面,而桌面端窗口卡顿会直接影响用户对软件整体流畅度的感知。
  2. 性能优先级不同浏览器端更关注页面加载速度,桌面端更关注交互流畅度和内存占用。长文本、大代码块的频繁重渲染,更容易引发桌面端的性能问题,尤其是低配置设备上的表现差异会被放大。
  3. 扩展能力差异依托 Node.js 能力,桌面端渲染可直接处理本地文件、本地图片路径、系统字体等,是浏览器端不具备的优势;同时也可将语法高亮、数学公式渲染等耗时操作放到独立的 Worker 线程甚至后台进程中执行。

具象示例

在 Mimo 桌面端流式输出 1000 行的代码文件:

  • 若采用全量重渲染方案,每次新增字符都重新解析 + 高亮,会导致渲染主线程频繁阻塞,表现为窗口拖动卡顿、复制粘贴响应延迟。
  • 若采用块级增量渲染 + Web Worker 高亮方案,主线程仅处理 DOM 增量更新,代码高亮放到 Worker 线程执行,窗口交互始终保持流畅。

应用场景

  • 桌面端 Markdown 编辑器、笔记软件
  • 本地 AI 助手、代码生成工具
  • 离线文档阅读器、技术手册客户端

常见认知误区

  1. 误区:正则替换比 AST 解析性能更好,流式场景用正则更轻量纠正:短文本下正则有微弱性能优势,但流式场景存在大量未闭合、嵌套语法,正则方案极易出现解析错误、样式错乱,维护成本极高。工业界流式渲染方案均基于 AST / 词法解析实现。
  2. 误区:代码块必须等到闭合标记 ``` 出现,才能识别为代码块并执行高亮纠正:CommonMark 规范明确支持未闭合的围栏代码块,起始标记出现后解析器即可合法识别代码块;同时 Shiki 等词法高亮引擎不要求代码语法完整,可逐段着色。工业界主流方案均为起始标记出现即渲染代码块容器,同步做增量高亮。
  3. 误区:流式渲染就是逐字渲染,越实时越好纠正:逐字渲染会引发巨量的 DOM 操作和重排,不仅性能差,还会出现严重的样式闪烁。主流方案都是按块粒度更新,配合 100ms 级别的高亮节流,用户感知不到差异,但体验和性能会大幅提升。
  4. 误区:所有 Markdown 渲染结果都是一致的纠正:Markdown 没有唯一官方标准,CommonMark 是目前最通用的规范。不同渲染库对表格、任务列表、数学公式、脚注等扩展语法的支持差异极大,选型前必须对齐语法规范。
  5. 误区:桌面端渲染和浏览器端完全一致,无需额外优化纠正:Electron 的进程调度、GPU 加速策略与浏览器存在差异,长文本、大代码块的渲染更容易触发性能瓶颈,且桌面端对交互流畅度的要求更高,必须做针对性的性能优化。

落地实操建议(面向桌面端流式场景)

1. 技术库选型

  • 渲染核心:优先选择基于 AST / 词法解析、符合 CommonMark 规范的成熟库

    • 追求轻量、高性能:marked(符合 CommonMark 规范,词法分析速度快,适合分块策略)
    • 追求高可扩展、自定义语法:markdown-it(插件生态丰富)
    • 追求极致工程化、复杂生态:remark(统一语法树,适合复杂链路处理)
  • 代码高亮:优先选用 shiki(高亮精度高,支持增量词法解析),采用节流式增量高亮,无需等待代码块闭合;备选 highlight.js

  • 数学公式:按需接入 KaTeXMathJax,复杂公式建议块闭合后渲染,避免中间态排版错乱

2. 流式渲染核心落地步骤

  1. 建立全局文本缓冲区,接收流式片段并做突发批处理(约 450ms 窗口),减少解析触发次数
  2. 每次更新时调用词法解析器做全量分块,将块划分为 Settled 稳定块与 Live 尾块
  3. Settled 块通过组件缓存永久复用,仅对 Live 尾块执行更新渲染
  4. 针对不同类型的 Live 块做差异化处理:代码块实时渲染容器,表格做缓冲等待,普通段落做自愈修正
  5. 流式输出结束后,执行一次全量校验修正,处理跨行语法、异常嵌套等边界问题

3. 桌面端性能优化

  • 减少全量 DOM 替换,优先使用块级增量更新,降低重排重绘次数
  • 长文本场景接入虚拟滚动,仅渲染可视区域内的内容,大幅降低 DOM 节点数量
  • 将代码高亮、数学公式渲染等耗时操作,放到 Web Worker 中执行,避免阻塞渲染主线程
  • 超大代码块(>20KB)Live 期降级为纯文本显示,结束后一次性执行全量高亮
  • 复用已渲染的节点,相同类型的语法块优先更新内容,而非销毁重建

4. 体验优化细节

  • 代码块起始标记出现后立刻渲染容器样式,保证视觉一致性;高亮采用素色尾巴平滑过渡
  • 固定已渲染内容的高度,新增内容时自动维护滚动位置,防止页面跳动
  • 表格、复杂嵌套块等布局敏感元素,可适当做行级缓冲,避免布局频繁跳动
  • 未闭合的行内标记(如加粗、斜体)做降级显示,避免样式外溢错乱
  • 预留代码块、标题的基础行高占位,避免渲染前后的高度跳变