1. 问题本质:流式解析为什么会“标签截断”
先还原一下这个面试题的现场。你正在做一个类 Notion 的 Markdown 编辑器,用户在输入框里敲字,右侧实时预览。输入是流式的,也就是说每一帧都可能处于“Markdown 写了一半”的状态。比如用户刚敲了一个#,还没敲标题文字;或者敲了```js三个反引号,还没来得及输入代码内容;又或者输入了一个**加粗,星号还没闭合。这个时候,如果你把这段不完整的 Markdown 直接丢给 marked 去渲染,得到的 HTML 大概率是残缺的,甚至会把后面的内容一并“吞掉”。这就是面试官问的“标签截断”。
再具体一点,截断分两种。一种是块级标签截断,比如代码围栏(code fence)只写了开头没写结尾,marked 会把后面所有内容都当作代码渲染,整个预览区域就变成一个巨大的代码块,其他样式全部失效。另一种是行内标签截断,比如**加粗只写了两个星号,marked 会在行尾找不到闭合标记,最终那段文本可能被整体吞进<strong>,或者直接原样输出,样式错乱。
面试官追问的“直接重新让 marked 全部渲染”指的是一种朴素解法:既然手里的 Markdown 不完整,那就每次输入都把全文重新传给 marked,让它全量渲染一遍。听起来好像能解决——因为这一次解析,marked 看到的是包含当前所有输入的最新完整文本,它会把代码块、标题、列表重新归一。但面试官这么问,潜台词就是“这个方案有坑,你来说说坑在哪”。
其实这个方案不是完全不能用,关键看应用场景。但如果是实时编辑器,问题会非常大。我把它拆成三个方面来说:光标跳动、性能衰减、上下文丢失。
先看光标跳动。预览区用 innerHTML 全量替换,意味着 DOM 里的所有节点都会被销毁重建。浏览器对输入框的选区(selection)和光标位置(caret)有很强的“锚定”能力,但这是建立在 DOM 节点未被替换的前提下。一旦整个预览区的节点被替换,编辑器主体的光标位置就可能被浏览器重新计算到一个错误的位置,甚至直接跳到文本末尾。如果你用过一些老旧的 Markdown 编辑器,输入几个字光标就满屏乱跑,八成就是这个原因。用户体验直接崩盘。
再看性能衰减。marked 的解析过程分为 lexing(词法分析)和 parsing(语法分析)两步,复杂度大致是 O(n),其中 n 是 Markdown 源文本长度。当文档从 100 字增长到 10000 字,单次解析耗时线性增长。配合上全量 innerHTML 替换的 DOM 操作开销,输入稍微快一点,主线程就会被占满,输入延迟和卡顿非常明显。你可以打开浏览器的 Performance 面板实测一下,超过 1000 行的 Markdown 文档,每次键盘敲击都触发的全量重渲染,帧率能掉到个位数。
最隐蔽的是上下文丢失。Markdown 解析在部分场景下是依赖上下文的。举一个最典型的例子:GFM 规范里,列表项内的缩进代码块要求四个空格缩进,而“缩进”这个概念在流式输入中是不稳定的——用户可能正在敲缩进,还没有敲出代码内容。又比如有序列表1. item,如果上一帧用户输入了1.后面跟了空格,这一帧变成了1. it,marked 需要判断这是列表项还是普通文本,它依赖“这个符号是否在一行的开头”这个上下文。全量重渲染看起来是把“所有上下文”都喂给了 marked,但 marked 本身并不保留上一次解析的中间状态,每次都是从零开始推断。一旦文本处于“语法已完成一半”的状态,推断结果就是错的。
所以结论很明确:全量重渲染在实时流式场景下,不是“行不行”的问题,而是“能用但不该用”的问题。它是兜底方案,不是首选方案。接下来进入正题,说一下我在实际项目中采用的增量解析方案,以及为什么要这么设计。
2. 从全量渲染到增量解析:思路转变是关键
2.1 先搞清楚 marked 内部是怎么工作的
要想避免标签截断,仅仅停留在“用 marked 渲染”这个层面是不够的。你得知道 marked 内部把解析分成了哪几个阶段,才能在流式场景下做文章。
marked 的核心 API 是marked.parse(src),但这个函数内部实际做了三件事:
- Lexer(词法分析):把 Markdown 源字符串拆成一串 token。每个 token 带有
type字段,常见的有heading、code、list、blockquote、paragraph、space、html、table等。这一阶段相当于把文本“结构化”了。 - Parser(语法分析):遍历 token 数组,逐个调用对应的 renderer(渲染器)生成 HTML 字符串。
- 编译与拼接:把各段 HTML 拼起来,返回最终字符串。
关键点在于:Lexer 阶段的 token 流是有状态的,而且这个状态可以保存。比如一个codetoken,它的text字段里保存着代码内容,lang字段保存着语言标识。如果输入的 Markdown 是不完整的,Lexer 会产生一个“未闭合”的codetoken,把后面所有文本都吸收进去。同样,listtoken 会维护一个items数组,blockquotetoken 会维护嵌套层级。
由此引出增量解析的核心思路:不把整段文本重新解析,而是维护一个“上次解析到哪”的指针和一组“未闭合 token 的状态栈”。新输入的内容,只对状态栈顶部的未闭合 token 做增量追加,而不是全局重新解析。
2.2 状态栈:流式解析的地基
Markdown 的块级结构本质上是一个可嵌套的栈。想象一下这段输入:
> 引用第一行 > > - 列表第一项 > - 嵌套列表 > ```js > const a = 1当前解析状态是什么?最外层是一个 blockquote,里面套了一个 list,list 里又套了一个 code fence。如果此时用户继续输入,新内容应当被归入 code fence 内部,而不是被当成一个新的顶层段落。
如果我用全量重渲染,marked 会重新扫描全文,它当然能正确处理——前提是 code fence 已经闭合。但流式场景下 code fence 没闭合,marked 会把“后续所有内容”都当作代码文本,直到用户输入了闭合的三反引号。这期间,外面的 blockquote 和 list 样式全部丢失。
增量解析的做法是维护一个未闭合 token 栈:
const openTokenStack = [ { type: 'blockquote', depth: 1 }, { type: 'list', ordered: false, depth: 2 }, { type: 'code', fence: '`', lang: 'js', depth: 3 } ];当新输入进来时,我先判断它是不是会闭合栈顶 token 的语法。如果是,就弹出栈顶;如果不是,就继续追加到栈顶 token 对应的内容区域。只有那些真正闭合了某个 token 的文本,才会被送入 marked 的 Lexer/Parser 做局部渲染。
这个状态栈的设计是整个方案的灵魂。它本质上是一种手写状态机,和 marked 内部的 tokenizer 配合工作。听起来复杂,但工程实现上没有那么可怕,核心就两个操作:追加内容、判断闭合。
2.3 段落缓冲:最简单的第一层防线
状态栈解决的是块级大结构的截断。但实际开发中还有一个更常见、成本更低的技巧——段落缓冲(paragraph buffering)。
Markdown 的块级结构,大多数是以“空行”作为分隔符的。标题、列表项、引用、段落,它们之间必然存在一个空行(这是 CommonMark 规范的核心定义之一)。流式输入时,如果当前光标所在的那一段还没有遇到空行,就说明这一段是不完整的,不能立即渲染。
具体做法是:
- 维护一个输入缓冲区。
- 每收到一次输入,按空行(
\n\n)切分缓冲区内容。 - 最后一个“段落片段”如果末尾不是空行,就暂时扣在缓冲区里不渲染。
- 只有已经出现空行的“完整段落”,才送入 marked 渲染。
举个例子,用户输入了:
# 标题 这里是第一段内容,还末尾的“这里是第一段内容,还”没有空行结尾,所以这段不渲染,先扣住。等用户继续输入完“没有写完”这三个字,再遇到一个空行,这一整段才会被送入 marked 解析。
这个方案的好处是简单、可预测、bug 少。它避开了绝大多数的行内标签截断问题——因为段落不完整时压根不解析。但它的局限也很明显:如果用户在代码块内部输入,空行不能作为“段落完整”的判断依据,因为代码块内部本身就可以包含空行。所以段落缓冲需要和状态栈配合使用:只有在“当前不在任何未闭合的块级 token 内部”时,空行才作为段落边界。
2.4 分层策略:增量为主,全量为兜底
有了上面的思路,我实际项目中的策略其实是分层的:
- 输入增量为小段文本(敲键盘):增量解析 + 状态栈更新,只重渲染受影响的局部区域。
- 输入增量为大段文本(粘贴):先把粘贴内容按块级规则切分,然后对该段做局部全量解析,再更新状态栈。
- 文档结构发生重大变化(拖拽、批量操作):此时增量解析的复杂度已经接近全量解析,直接退回到 marked 全量渲染,但需要配合“冻结预览区”和“恢复光标位置”两个操作来弥补光标问题。
这套分层策略的核心思想是:不追求所有场景都用增量解析,而是让 90% 的常规输入场景走增量路径,剩下的 10% 用全量兜底。这样既保住了性能,也控制了代码复杂度。
3. 代码级实现:基于 marked 的增量渲染方案
3.1 利用 marked 的 Lexer 拿 token 流
既然要用 marked,就得先把它的内部 API 摸透。不同的 marked 版本 API 有差异,我这里以目前常用的markedv12 为例(v4 和 v5 的 Lexer API 基本一致,只是类型定义更严了)。
核心是绕过marked.parse,直接调用marked.Lexer:
import { Lexer, Parser } from 'marked'; const lexer = new Lexer({ gfm: true, breaks: false, }); const parser = new Parser(); // 词法分析:把 Markdown 转成 token 流 function lexMarkdown(src) { return lexer.lex(src); } // 语法分析:把 token 流转成 HTML function tokensToHtml(tokens) { return parser.parse(tokens); }有了这个能力之后,我就可以把“增量追加的内容”单独走一遍 lexer,得到新 token,再决定怎么合并到既有 token 树上。
但这里有个坑:lexer.lex()返回的 token 数组里,某些 token 内部还带有嵌套的 tokens 数组。比如listtoken 的items字段里,每个 item 又有自己的tokens数组;blockquotetoken 里有tokens数组。如果我把新 token 直接 push 到顶层数组,遇到这种嵌套结构就错了。
所以正确的做法是:先更新状态栈,再决定新 token 应该追加到哪一层。举例来说,如果状态栈栈顶是code,那么新输入不应该走 lexer,而是直接追加到codetoken 的text字段里。只有栈顶是paragraph、list这类“开放”结构时,才走 lexer 生成新 token。
我这里的习惯是写一个appendToTokens(tokens, newText)的递归函数,它按状态栈层层下钻,找到合适的插入点:
function appendToTokens(state, newText) { const { tokens, openStack } = state; // 如果当前在代码块内部,直接追加文本,不走 lexer const top = openStack[openStack.length - 1]; if (top && top.type === 'code') { top.text += newText; return; } // 其他情况:把新文本按块级规则切分,依次 lex const newTokens = lexMarkdown(newText); // 这里还要处理 newTokens 中可能出现的“未闭合 token” // 把它们压入 openStack,已经闭合的 token 就直接 push 进 tokens mergeTokens(state, newTokens); }这段代码是示意性的,真正的 merge 逻辑要看你对“未闭合 token”的定义。我的经验是,先实现一个能过的版本,再逐步增加边界情况,千万不要一开始就追求完美。
3.2 闭合状态机的设计
状态机是增量解析的核心难点。我的做法是把 Markdown 的块级语法分成两类:自闭合型和上下文闭合型。
- 自闭合型:标题
#、分割线---、空行。它们只要出现,就立刻完成闭合,不会跨行。 - 上下文闭合型:代码围栏、列表、引用、HTML 块。它们需要遇到特定的结束条件才闭合。
针对上下文闭合型,我维护一个openStack,每个栈元素大致长这样:
{ type: 'code' | 'list' | 'blockquote', tokenIndex: 12, // 这个未闭合 token 在 tokens 数组中的下标 contentStart: 0, // 内容在原始文本中的起始偏移量 contentBuffer: '', // 未闭合部分已经累积的内容 meta: {} // type 相关的额外信息,如代码块的 lang }当有增量输入时,先扫描新文本,判断是否有闭合当前栈顶 token 的语法。下面是部分判断逻辑:
function tryCloseTopToken(state, deltaText) { const top = state.openStack[state.openStack.length - 1]; if (!top) return null; switch (top.type) { case 'code': // 代码块:检测新的三反引号或三波浪号是否结束 fence if (top.meta.fenceChar) { // 这里要检测 deltaText 最后一行是否以相同 fenceChar 开头 // 注意 fence 长度必须 >= 3,且后续只能跟空格或文本 const fenceRegex = new RegExp(`^${top.meta.fenceChar}{3,}\\s*$`, 'm'); if (fenceRegex.test(deltaText)) { state.openStack.pop(); return { closed: true }; } } top.contentBuffer += deltaText; return { closed: false }; case 'list': // 列表:遇到空行可能出现列表结束,但也可能只是列表内的空行 // 更可靠的判断是:下一行如果是非缩进非列表标记的文本,则列表结束 // 还有一种情况:列表项的缩进降级到 0,且新行不是列表标记 if (isBlankLine(deltaText)) { // 不能立刻弹栈,要缓存“空行后第一行”再做决定 top.pendingBlankLines = (top.pendingBlankLines || 0) + 1; return { closed: false }; } if (!isListMarker(deltaText) && !startsWithIndent(deltaText)) { state.openStack.pop(); return { closed: true }; } top.contentBuffer += deltaText; return { closed: false }; case 'blockquote': // 引用:以 > 开头的行继续,遇到非 > 且非空行的行结束 if (!deltaText.startsWith('>') && !isBlankLine(deltaText)) { state.openStack.pop(); return { closed: true }; } top.contentBuffer += deltaText; return { closed: false }; } }注意上面的列表闭合判断,我用了一个pendingBlankLines的缓存机制。这是因为 GFM 规范里,列表项内部可以有空行,连续两个空行才真的结束列表。这个细节非常容易被踩坑。
3.3 增量渲染的 UI 落地:防抖 + 局部更新
状态机和 token 树归并搞定了,剩下的是渲染层。
我的推荐做法是:用防抖把高频率的输入合并成低频的渲染批次。因为流式输入时,每次键盘敲击间隔在几十毫秒,而 HTML 渲染的代价远高于 Markdown 解析。一个 300ms 的防抖能显著降低渲染频率,同时不损失实时性——因为人眼的感知极限也就 100ms 左右,300ms 的延迟几乎无感。
防抖函数很简单:
function debounce(fn, wait = 300) { let timer = null; return function (...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), wait); }; } const scheduleRender = debounce(() => { const html = tokensToHtml(currentTokens); previewContainer.innerHTML = html; }, 300);但这里有个进阶优化:不要整块 innerHTML 替换,只替换变化的部分。如果一个增量输入只影响了一个<p>标签里的内容,那么最优做法是只在预览区里找到那个<p>,替换它的 innerHTML。这需要给 token 生成 HTML 时打上>function renderTokens(tokens) { return tokens.map((token, index) => wrapWithTag(token, parser.parse([token]), index) ).join(''); } function wrapWithTag(token, html, index) { return `<div>function updateTokenDom(index, html) { const nodes = previewContainer.querySelectorAll(`[data-token-index="${index}"]`); if (nodes.length === 1) { nodes[0].innerHTML = html; } else { // fallback:节点不存在或匹配异常,做局部重渲染 } }
这个方案比全量 innerHTML 替换的 DOM 操作成本低一个量级,光标丢失的问题也几乎不会出现,因为预览区的其他节点都在原地没有动。
3.4 为什么 marked 默认的渲染器不够用
用 marked 的人都知道,它本身提供了Renderer定制能力。你可以在渲染阶段拦截heading、list、code等 token,生成自定义 HTML。但这解决不了流式解析的问题,因为问题出在 lexer 阶段,不是 renderer 阶段。
我见过一些项目试图重写 renderer 来“修复”截断,比如在codetoken 的 renderer 里判断text是否以反引号结尾,如果没有就强制补充闭合标签。这属于事后补漏,治标不治本。因为一旦截断发生,token 树已经是错误的状态,后面的内容全被吞进了一个大 code 块里,renderer 层根本无从分辨哪些内容才是真正的代码。
所以我的建议是:不要想着在 renderer 层修补,要在 lexer 和 token 管理层做文章。这也是为什么上文重点讲状态机和 token 归并,而不是讲自定义 renderer 的原因。
3.5 长文档下的性能验证
我在实际项目里验证过,当 Markdown 文档达到 2000 行左右时,全量重渲染的单次耗时大约在 80~150ms(取决于机器和是否开启 GFM 表格)。这个数字意味着:如果用户以每 100ms 一次的频率连续输入,主线程会完全被解析和渲染占满,输入框的光标会开始“飘”。
改为增量解析 + 局部更新后,单次输入耗时被压缩到 2~10ms 之间,主要开销是状态机的字符串扫描和 token 追加。输入 2000 行文档时的体验和输入 10 行文档时几乎没有区别。
有一个值得注意的坑:marked 的 Lexer 对象在跨批次使用时,内部会维护一些缓存状态,比如lexer.state。如果复用了同一个 Lexer 实例,需要确保它的state与我们的openStack保持一致。我的做法是每个输入会话维护独立的 Lexer 实例,避免状态串扰。当然,如果文档结构被大幅度修改导致整体重渲染,我会直接 new 一个新的 Lexer 和 Parser。
4. 常见问题与排查技巧实录
4.1 高频踩坑点速查表
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 光标在输入后跳动到文末 | 预览区全量 innerHTML 替换,导致编辑区 DOM 结构变化 | 改为局部 DOM 更新,或用>
|