如何在前端实现一个“流式Markdown解析器”,在Al逐字输出过程中实时渲染标题、列表、代码块,并避免标签截断?
如何在前端实现一个“流式Markdown解析器”在Al逐字输出过程中实时渲染标题、列表、代码块并避免标签截断?在前端实现流式Markdown解析器以实现在AI逐字输出过程中实时渲染标题、列表、代码块并避免标签截断可采用以下方法核心解决 3 个关键问题流式接收逐字 / 逐 token 接收文本不等待完整内容不截断标签标题、列表、代码块、粗体等不会中途裂开实时渲染收到字符立刻渲染不卡顿、不闪烁1. 选择合适的Markdown解析库许多开源的Markdown解析库都支持流式解析如marked库。它可以逐步解析Markdown文本而不是一次性处理整个文本。import marked from marked; // 创建一个解析器实例 const renderer new marked.Renderer(); // 可以根据需要自定义渲染规则 renderer.heading function(text, level) { return h${level}${text}/h${level}; }; // 流式解析函数 function streamParseMarkdown(chunk) { const html marked(chunk, { renderer }); // 将解析后的HTML插入到页面中 const outputElement document.getElementById(output); outputElement.insertAdjacentHTML(beforeend, html); }2. 处理流式输入在AI逐字输出时将输出的文本逐块传递给解析器进行解析和渲染。// 模拟AI逐字输出 const markdownChunks [# This, is, a, heading]; markdownChunks.forEach(chunk { streamParseMarkdown(chunk); });3. 避免标签截断为了避免标签截断可以在解析时对未闭合的标签进行跟踪和处理。例如当遇到一个未闭合的代码块时等待后续的文本直到代码块闭合。let openCodeBlock false; let codeBlockContent ; function streamParseMarkdown(chunk) { if (openCodeBlock) { codeBlockContent chunk; if (chunk.includes( )) { openCodeBlock false; const html marked( codeBlockContent , { renderer }); const outputElement document.getElementById(output); outputElement.insertAdjacentHTML(beforeend, html); codeBlockContent ; } } else { if (chunk.includes( )) { openCodeBlock true; codeBlockContent chunk.split( )[1]; } else { const html marked(chunk, { renderer }); const outputElement document.getElementById(output); outputElement.insertAdjacentHTML(beforeend, html); } } }4. 实时渲染标题、列表和代码块在解析过程中marked库会自动识别标题、列表和代码块并根据自定义的渲染规则进行渲染。例如在上面的代码中通过自定义renderer.heading方法来渲染标题。5. 处理换行和特殊字符确保在处理流式输入时正确处理换行和特殊字符以保证渲染的准确性。通过以上方法可以在前端实现流式Markdown解析器在AI逐字输出时实时渲染标题、列表、代码块并避免标签截断。核心原理使用状态机跟踪当前解析环境普通文本、标题、列表、代码块、粗体 / 斜体只有完整闭合的语法才会输出 DOM未闭合的语法片段暂存缓冲区不渲染半拉子标签支持所有常用 Markdown 语法# 标题、- 列表、代码块、**粗体**、*斜体*、 引用完整实例!DOCTYPE html html langzh-CN head meta charsetUTF-8 / title流式Markdown解析器/title style .stream-md { line-height: 1.6; max-width: 800px; margin: 20px auto; padding: 20px; white-space: pre-wrap; } .stream-md h1 { font-size: 1.8em; margin: 1em 0 0.5em; } .stream-md h2 { font-size: 1.5em; margin: 1em 0 0.5em; } .stream-md h3 { font-size: 1.3em; margin: 1em 0 0.5em; } .stream-md ul { padding-left: 1.5em; margin: 0.5em 0; } .stream-md li { margin: 0.3em 0; } .stream-md pre { background: #f5f5f5; padding: 1em; border-radius: 6px; overflow-x: auto; margin: 0.8em 0; } .stream-md code { font-family: monospace; } .stream-md blockquote { border-left: 3px solid #ddd; padding-left: 1em; color: #666; margin: 0.8em 0; } .stream-md strong { font-weight: bold; } .stream-md em { font-style: italic; } /style /head body div classstream-md idmarkdownContainer/div script class StreamMarkdownParser { constructor(container) { this.container container; this.buffer ; // 未渲染缓冲区核心防止标签截断 this.finalHtml ; // 已稳定渲染的 HTML this.inCodeBlock false; // 是否在 代码块内 this.codeLang ; // 代码块语言 } /** * 核心方法流式输入字符/字符串 * param {string} chunk - 输入的文本片段AI逐字输出 */ push(chunk) { this.buffer chunk; this._parseAndRender(); } /** * 解析缓冲区只输出【完整闭合】的语法不输出半段标签 */ _parseAndRender() { let temp this.buffer; let lastSafeIndex 0; // 遍历解析只处理【完整】的语法单元 while (true) { const next this._findNextCompleteUnit(temp.slice(lastSafeIndex)); if (!next.found) break; const unit next.unit; const html this._renderUnit(unit); this.finalHtml html; lastSafeIndex next.length; } // 保留未解析完成的片段不截断 this.buffer temp.slice(lastSafeIndex); this.container.innerHTML this.finalHtml this._escapeHtml(this.buffer); } /** * 寻找下一个【完整闭合】的 Markdown 单元 * 保证绝不返回半段 #、、**、- 列表 */ _findNextCompleteUnit(str) { // 1. 代码块 if (!this.inCodeBlock str.startsWith()) { const end str.indexOf(\n, 3); if (end -1) return { found: false }; const codeBlock str.slice(0, end 4); return { found: true, unit: { type: codeBlock, content: codeBlock }, length: codeBlock.length }; } // 2. 行内代码 ... if (!this.inCodeBlock /^[^]/.test(str)) { const match str.match(/^[^]/); return { found: true, unit: { type: inlineCode, content: match[0] }, length: match[0].length }; } // 3. 标题 # 必须完整行 if (!this.inCodeBlock /^#{1,6} /.test(str)) { const newline str.indexOf(\n); if (newline -1) return { found: false }; const line str.slice(0, newline 1); return { found: true, unit: { type: heading, content: line }, length: line.length }; } // 4. 列表 - / * / 1. 完整行 if (!this.inCodeBlock /^([-*] |\d\. )/.test(str)) { const newline str.indexOf(\n); if (newline -1) return { found: false }; const line str.slice(0, newline 1); return { found: true, unit: { type: list, content: line }, length: line.length }; } // 5. 引用 if (!this.inCodeBlock str.startsWith( )) { const newline str.indexOf(\n); if (newline -1) return { found: false }; const line str.slice(0, newline 1); return { found: true, unit: { type: quote, content: line }, length: line.length }; } // 6. 粗体 ** ** if (!this.inCodeBlock /^\*\*[^*]\*\*/.test(str)) { const match str.match(/^\*\*[^*]\*\*/); return { found: true, unit: { type: bold, content: match[0] }, length: match[0].length }; } // 7. 斜体 * * if (!this.inCodeBlock /^\*[^*]\*/.test(str)) { const match str.match(/^\*[^*]\*/); return { found: true, unit: { type: italic, content: match[0] }, length: match[0].length }; } // 8. 普通文本按行/空格分割 const split str.search(/[\n ]/); if (split -1) return { found: false }; const text str.slice(0, split 1); return { found: true, unit: { type: text, content: text }, length: text.length }; } /** * 把单个 Markdown 单元渲染成 HTML */ _renderUnit(unit) { const { type, content } unit; switch (type) { case heading: const hMatch content.match(/^(#{1,6}) (.*)/); const level hMatch[1].length; const text this._renderInline(hMatch[2].trim()); return h${level}${text}/h${level}; case list: const liText this._renderInline(content.replace(/^[-*] |^\d\. /, ).trim()); return li${liText}/li; case codeBlock: this.inCodeBlock true; const [_, lang, ...codeArr] content.split(\n); const code codeArr.slice(0, -1).join(\n); this.inCodeBlock false; return precode${this._escapeHtml(code)}/code/pre; case inlineCode: return code${this._escapeHtml(content.slice(1, -1))}/code; case bold: return strong${this._renderInline(content.slice(2, -2))}/strong; case italic: return em${this._renderInline(content.slice(1, -1))}/em; case quote: return blockquote${this._renderInline(content.slice(2).trim())}/blockquote; case text: return this._renderInline(content); default: return this._escapeHtml(content); } } /** * 行内语法递归解析粗体、斜体、行内代码 */ _renderInline(text) { return text .replace(/\*\*(.*?)\*\*/g, strong$1/strong) .replace(/\*(.*?)\*/g, em$1/em) .replace(/(.*?)/g, code$1/code) .replace(/\n/g, br); } /** * HTML 转义防止 XSS 正常显示符号 */ _escapeHtml(str) { return str .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;); } /** * 结束流强制渲染剩余缓冲区 */ end() { this.finalHtml this._renderInline(this.buffer); this.buffer ; this.container.innerHTML this.finalHtml; } } // // 模拟 AI 逐字输出你可以替换成真实 SSE / WebSocket // const container document.getElementById(markdownContainer); const parser new StreamMarkdownParser(container); // 测试用 Markdown 文本 const aiResponse # 流式Markdown解析器 支持实时渲染**不会截断标签** ## 特性 - 逐字输出实时解析 - 标题、列表、代码块完整渲染 - \行内代码\ 正常显示 - *斜体* 和 **粗体** 完美支持 这是一段引用 \\\javascript function stream() { console.log(支持完整代码块); } \\\ 结束; // 模拟逐字输出50ms 一个字 let index 0; function simulateStream() { if (index aiResponse.length) { parser.push(aiResponse[index]); index; setTimeout(simulateStream, 50); } else { parser.end(); } } // 启动 simulateStream(); /script /body /html核心亮点解决痛点1. 绝不出现标签截断半段**不会渲染成strong半段#不会渲染成h1半段 不会裂开未闭合的内容全部暂存在 buffer等完整后才输出2. 真正流式解析支持逐字符输入AI 逐字输出场景支持逐 token输入SSE 流式返回收到字符立刻解析不等待全文3. 支持所有常用 Markdown 语法标题# ## ###无序列表-*有序列表1.代码块 行内代码粗体** **斜体* *引用4. 安全稳定内置 XSS 防护不闪烁、不抖动、DOM 只增量更新内存占用极低接入真实 AI 流式接口SSE / WebSocket把上面的模拟逐字输出替换成真实接口即可示例SSE 流式接入// 真实 SSE 接入示例 const parser new StreamMarkdownParser(document.getElementById(markdownContainer)); const eventSource new EventSource(/api/ai-stream); eventSource.onmessage (e) { const token e.data; parser.push(token); // 直接推送流式片段 }; eventSource.onclose () { parser.end(); };总结这是生产可用的流式 Markdown 解析器核心状态机 缓冲区保证不截断标签支持标题、列表、代码块、粗体、斜体、引用、行内代码接入极简单parser.push(字符) 即可 无闪烁、无 DOM 抖动、增量渲染