Skip to content
AI Chat 消息渲染:Markdown 解析、安全过滤与流式代码块处理
模型返回的文本通常是 Markdown 格式。前端需要把 Markdown 解析为 HTML,过滤危险内容,并在流式输出时保持页面稳定。以下内容分别介绍消息数据模型、Markdown 解析、安全过滤、渲染管线、流式更新和代码块处理。
消息数据模型
角色、状态与增量内容
每条消息可以包含 role、content、status 等字段。role 决定展示样式:user 消息在右侧,assistant 消息在左侧,system 消息通常作为特殊区域。content 保存完整的 Markdown 原文。status 表示消息是否仍在接收数据,常见值可以是 pending、streaming、completed、error。
可以用一个 ES6 类表示这条消息:
js
class ChatMessage {
constructor({ id, role, content = '', status = 'pending' }) {
this.id = id;
this.role = role;
this.content = content;
this.status = status;
}
appendDelta(delta) {
this.content += delta;
return this.content;
}
}示例中的 appendDelta 方法用于在流式过程中追加增量内容。调用方不需要手动拼接字符串:
js
const message = new ChatMessage({ id: 'm1', role: 'assistant' });
message.appendDelta('```js\n');
message.appendDelta('const value = 1;\n');
message.appendDelta('```\n');
console.log(message.content);
// ```
// const value = 1;
// ```这段代码只维护数据。渲染层读取 status 决定是否显示流式光标,读取 content 决定显示什么。
Markdown 与 GFM 解析
Markdown 的语法定义有很多版本。CommonMark 是社区广泛采用的规范,GitHub Flavored Markdown(GFM)在 CommonMark 之上增加了表格、任务列表、删除线、自动链接等扩展 [1]。
解析器的工作可以拆成两个阶段:
- 词法分析:把 Markdown 文本切成 token,生成中间表示。
- 渲染:把 token 序列化为 HTML 字符串。
很多解析器把 AST 或 token 结构暴露给插件,方便定制。以 marked 为例:
js
import { marked } from 'marked';
const source = '- 项目一\n- 项目二';
const html = marked.parse(source);
console.log(html);
// 输出以实际 marked 版本为准,通常为:
// <ul>
// <li>项目一</li>
// <li>项目二</li>
// </ul>marked 默认启用 GFM,因此表格等扩展语法可以直接解析:
js
const table = '| 名称 | 数量 |\n| --- | --- |\n| 苹果 | 3 |';
console.log(marked.parse(table));
// 会输出 <table> 相关标签不同解析器对 CommonMark/GFM 的支持程度存在差异。选择解析器时,可以关注它通过 CommonMark 官方 spec 测试的比例 [1]。比例越高,越不容易出现“同一段 Markdown 在两个解析器里结构不同”的问题。
安全过滤与 XSS 防护
Markdown 的一个特点是允许内嵌 HTML。这意味着用户或模型可以直接输出 <img src="x" onerror="alert(1)">,如果前端直接把它插入 DOM,图片加载失败时会执行 onerror。
需要先澄清一个常见误解:把 <script> 标签写入 innerHTML 时,浏览器不会自动执行其中的脚本。XSS 风险更多来自事件属性、javascript: 链接、<iframe> 嵌套等。例如:
js
const dirty = '<img src="x" onerror="alert(1)"><a href="javascript:alert(1)">点我</a>';
container.innerHTML = dirty; // 不经过滤时有风险<img> 的 src 指向不存在的图片,浏览器触发 onerror;<a> 的 href 是 javascript: 伪协议,点击后会执行脚本。这两种情况都需要过滤。
DOMPurify 是一个基于浏览器 DOM 的 XSS 过滤器 [2]。它把输入的 HTML 解析成 DOM 树,移除危险节点和属性,再返回干净的 HTML 字符串。核心用法是 DOMPurify.sanitize(dirtyString) [3]。
js
import DOMPurify from 'dompurify';
const dirty = '<img src="x" onerror="alert(1)"><a href="javascript:alert(1)">点我</a>';
const clean = DOMPurify.sanitize(dirty, {
USE_PROFILES: { html: true },
FORBID_TAGS: ['style'],
});
console.log(clean);
// <img src="x"><a>点我</a>USE_PROFILES: { html: true } 告诉 DOMPurify 只需要保留 HTML,不需要 SVG 和 MathML [4]。FORBID_TAGS: ['style'] 可以阻止 <style> 被保留,因为 CSS 也可能导致数据泄漏 [4]。开发者应根据实际需求决定允许保留的标签和属性。
渲染管线与组件划分
渲染管线的完整顺序是:
text
Markdown 文本
-> 解析器:生成 HTML
-> 净化器:移除危险内容
-> DOM:插入页面
-> 代码高亮与交互增强每一步都应该独立,这样流式更新时可以随时在解析前插入“是否不完整”的判断,在插入 DOM 后执行高亮。
组件层面可以这样划分:
MessageList:负责消息数组和滚动,可以按需渲染可视区域。MessageItem:绑定一条消息的数据,处理角色样式和状态。MarkdownContent:接受content,执行 parse 和 sanitize,输出安全 HTML。CodeBlock:接受code元素,负责语法高亮、复制按钮和行号。
关系大致如下:
js
class MessageItem {
constructor(message, renderer) {
this.element = document.createElement('div');
this.element.className = `message message-${message.role}`;
this.contentNode = document.createElement('div');
this.contentNode.className = 'message-content';
this.element.appendChild(this.contentNode);
this.renderer = renderer;
this.message = message;
}
setMessage(message) {
this.message = message;
this.renderer(this.contentNode, message);
}
}这里没有引入具体框架,目的是展示职责边界:MessageItem 只关心消息条目,renderer 专门负责把消息内容转换成 DOM。
流式增量渲染
流式消息的 content 是逐步变长的。最常见的做法是每次收到增量后拼接内容,然后重新执行“parse -> sanitize -> innerHTML”。这个做法简单,但会带来两个问题:
- 重新设置
innerHTML会让消息内容闪烁,滚动位置可能跳动。 - 不完整的 Markdown 会被解析成另一种结构,等完整输入到达后又突然跳回正确结构。
例如,未闭合的 **bold** 在闭合符到达之前会显示为普通文本;表格随着新行和新列到达不断改变宽度 [6]。
一个可行的缓解办法是:如果检测到当前文本有“未完成”的语法,就暂时不调用完整渲染;等状态变为 completed 或内容完整后再做最终渲染。
不完整 Markdown 的处理
代码围栏是流式渲染里最需要特殊处理的结构。模型在生成代码时,会先输出开头的三个反引号,再逐行输出代码,最后输出闭合反引号。在闭合反引号到达前,解析器通常会把这个未闭合围栏回退成普通段落,导致页面中途显示为正文,最后突然变成代码块 [5]。
先写一个检测函数:
js
const FENCE_RE = /^\s*(`{3,}|~{3,})/;
function hasIncompleteCodeFence(source) {
let fences = 0;
for (const line of source.split('\n')) {
if (FENCE_RE.test(line)) fences += 1;
}
return fences % 2 === 1;
}FENCE_RE 匹配以三个及以上反引号或波浪线开头的行。统计围栏数量,如果是奇数,说明还没有闭合。
流式渲染时,可以在渲染函数里使用这个判断:
js
function renderMessage(contentNode, message) {
if (hasIncompleteCodeFence(message.content)) {
contentNode.textContent = message.content;
return;
}
const unsafe = marked.parse(message.content);
const safe = DOMPurify.sanitize(unsafe, sanitizeOptions);
contentNode.innerHTML = safe;
highlightCodeBlocks(contentNode);
}使用 textContent 可以避免不完整代码块被错误解析成普通段落。代价是流式过程中代码部分只能看到原文。等围栏闭合后,最后一次完整渲染会替换成高亮的代码块。这种策略属于“延迟渲染”,适合对代码块稳定性要求高的场景 [5]。
对于行内语法,延迟策略也可以局部使用:检测到成对分隔符未闭合时,暂缓渲染该块。如果每一行都做增量渲染,还可以使用节流(throttle)来控制 DOM 刷新频率。
代码块处理
当 Markdown 解析器识别到围栏代码块时,会生成类似下面的 HTML:
html
<pre><code class="language-js">const value = 1;</code></pre>语言名称由解析器从围栏信息字符串中提取,例如 ```js 中的 js。前端拿到这个 code 元素后,就可以交给语法高亮库处理。
语法高亮与交互增强
highlight.js 的 highlightElement 方法可以直接处理页面中的 code 元素:
js
import hljs from 'highlight.js';
function highlightCodeBlocks(container) {
container.querySelectorAll('pre code').forEach((codeElement) => {
const className = codeElement.className || '';
const language = className.replace(/^language-/, '').trim();
if (language && hljs.getLanguage(language)) {
codeElement.dataset.language = language;
hljs.highlightElement(codeElement);
} else {
codeElement.classList.add('plaintext');
}
});
}如果语言名存在,highlightElement 会检测语法并给元素添加 hljs class。如果语言名不存在,就保留为纯文本,避免高亮库误判。
如果使用 highlight.js 的纯字符串 API 来生成 HTML,例如 hljs.highlight(code, { language }).value,需要注意一点:返回的只是 token 后的代码片段,外层 <code> 元素上的 hljs class 不会自动出现。highlight.js 的 CSS 主题通常依赖 .hljs-keyword、.hljs-string 这样的 class,但这些 class 位于内部 span 上;外层 <code> 如果没有 hljs class,会影响代码块的背景色和默认样式。因此在拼 HTML 时必须补上。
复制按钮是代码块最常见的交互。可以在渲染完代码块后,向 pre 元素添加一个按钮:
js
function addCopyButtons(container) {
container.querySelectorAll('pre').forEach((preElement) => {
if (preElement.querySelector('.code-copy-button')) return;
const button = document.createElement('button');
button.className = 'code-copy-button';
button.type = 'button';
button.textContent = '复制';
button.addEventListener('click', async () => {
const code = preElement.querySelector('code');
if (!code) return;
try {
await navigator.clipboard.writeText(code.innerText);
button.textContent = '已复制';
} catch (err) {
// Clipboard API 不可用时需要降级,例如临时 textarea + document.execCommand('copy')
}
});
preElement.appendChild(button);
});
}navigator.clipboard 要求页面运行在安全上下文(HTTPS 或 localhost)中,且需要用户手势。如果浏览器不支持或权限被拒绝,需要降级到传统复制方式。
行号通常需要在高亮之前处理:把代码按行拆分,对每一行单独高亮并输入行号元素。如果在 highlightElement 之后再拆行,会破坏已经生成的 span 结构。也可以使用 highlight.js 或 Prism 的配套行号插件。
自动换行可以用 CSS 解决:
css
pre code {
white-space: pre-wrap;
overflow-wrap: anywhere;
}white-space: pre-wrap 保留空格和换行,同时允许折行;overflow-wrap: anywhere 让长 token 在必要时断行。
工具选型与对比
下面列出 AI Chat 渲染中常见的工具。实际选择时要结合项目体积、扩展需求和维护成本。
| 工具 | 类型 | 主要用途 | 特点 |
|---|---|---|---|
| CommonMark | 规范 | Markdown 语法标准 | GFM 是 CommonMark 的扩展 [1] |
| marked | Markdown 解析器 | 文本转 HTML | 默认启用 GFM,体积小 |
| markdown-it | Markdown 解析器 | 文本转 HTML | 插件机制丰富,配置灵活 |
| remark/unified | Markdown 解析器 | 生成 AST 再处理 | 适合深度定制和过滤器 |
| DOMPurify | 安全过滤器 | 清除危险 HTML | 基于浏览器 DOM [2] |
| highlight.js | 代码高亮器 | 代码块染色 | 语言支持多,可以按语言分包 |
| Prism | 代码高亮器 | 代码块染色 | 主题多,按语言加载 |
| Shiki | 代码高亮器 | 代码块染色 | 使用 TextMate 语法,输出接近编辑器 |
从维护角度看,解析器和高亮器经常需要更新。选型时应优先参考各项目官方 README,并留意 CommonMark 规范符合度 [1]。
示例:用 ES6 实现消息渲染器
把前面的内容组合成一个完整的 ES6 模块。这个渲染器在浏览器环境运行,依赖 marked、dompurify 和 highlight.js:
js
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js';
const FENCE_RE = /^\s*(`{3,}|~{3,})/;
const SANITIZE_OPTIONS = {
USE_PROFILES: { html: true },
FORBID_TAGS: ['style'],
};
function hasIncompleteCodeFence(source) {
let fences = 0;
for (const line of source.split('\n')) {
if (FENCE_RE.test(line)) fences += 1;
}
return fences % 2 === 1;
}
function highlightCodeBlocks(container) {
container.querySelectorAll('pre code').forEach((codeElement) => {
const className = codeElement.className || '';
const language = className.replace(/^language-/, '').trim();
if (language && hljs.getLanguage(language)) {
codeElement.dataset.language = language;
hljs.highlightElement(codeElement);
} else {
codeElement.classList.add('plaintext');
}
});
}
export function createMessageRenderer(contentNode) {
function render(message) {
if (hasIncompleteCodeFence(message.content)) {
contentNode.textContent = message.content;
return;
}
const unsafe = marked.parse(message.content);
const safe = DOMPurify.sanitize(unsafe, SANITIZE_OPTIONS);
contentNode.innerHTML = safe;
highlightCodeBlocks(contentNode);
}
return { render };
}
export class ChatMessage {
constructor({ id, role, content = '', status = 'pending' }) {
this.id = id;
this.role = role;
this.content = content;
this.status = status;
}
appendDelta(delta) {
this.content += delta;
return this.content;
}
}调用方式:
js
const contentNode = document.querySelector('#message-content');
const renderer = createMessageRenderer(contentNode);
const message = new ChatMessage({ id: 'm1', role: 'assistant' });
message.appendDelta('```js\n');
message.appendDelta('const answer = 42;\n');
renderer.render(message);
// 未闭合围栏,显示 Markdown 原文
message.appendDelta('```\n');
message.status = 'completed';
renderer.render(message);
// 完整解析,代码块被高亮render 每次都会重新解析 message.content。在消息较短、流式频率不高时足够。如果消息列表很长,可以只在状态变化或围栏闭合时执行完整渲染,其余时间用 textContent 预览。
边界情况与注意点
- 流式结束必须补一次完整渲染。
hasIncompleteCodeFence只是让未闭合围栏保持原样,不会替代最终渲染。 - 行内语法不完整时,延迟渲染会影响响应速度。可以在流式期间使用节流,把多个增量合并成一次更新。
- Markdown 解析器输出的 HTML 不能直接信。即使 parser 做了转义,自定义插件或未知扩展仍可能引入危险内容,所以净化步骤不能省略。
- 代码语言名来自模型输出。如果要把语言名写进 HTML class,需要先校验或转义。使用
classList和dataset可以避免字符串拼接。 - 复制按钮会被
innerHTML清空。如果渲染流程是“设置 innerHTML -> addCopyButtons”,每次渲染都会重新创建按钮,需要注意去重。addCopyButtons里检查.code-copy-button就是这个原因。 - 长列表渲染时,代码高亮是主要性能消耗。可以考虑只对可视区域内的代码块执行高亮,或者对已渲染的代码块缓存高亮结果。
- 无障碍方面,代码块应允许键盘操作,复制按钮需要提供
aria-label。高亮结果不要只依赖颜色,必要时提供明暗两种主题。
