1. 为什么服务端返回的Markdown不能直接塞进网页
1.1 大模型输出“不可控”是常态
做AI应用开发的人基本都绕不开这个场景:大模型回答内容不是普通JSON,而是一段带着各种语气的自然语言,在工程上我们再用Markdown作为答案的富文本载体。你在网页端拿到Markdown之后,如果图省事直接做 innerHTML = md,不出三个答疑对话就会翻车。我最早接大模型接口时就这么干过,结果被同事一句“你这个页面能执行任意JavaScript”直接怼了回来。
工程化要做的事情很简单:把“大模型说一段话”变成“网页上安全、稳定、长得好看的内容”。但简单背后全是细节。大模型返回的Markdown不像人写的博客那样规整,它可能带不闭合的代码块、残缺的表格、javascript: 链接,甚至还会冒出 <iframe> 标签。面向公众的产品如果没处理好,用户复制一个恶意Prompt,前端就多了一个XSS入口。
这篇内容就是我从实际项目里拆出来的渲染管线实践,面向正在做AI应用、聊天机器人、内容生成工具的开发者。核心思路:用 markdown-it 做Markdown解析,用 DOMPurify 做HTML消毒,再配合代码高亮、链接处理和流式输出优化,最后搭出一条可以上生产的渲染链路。
1.2 直接渲染会遇到的三类典型问题
第一个问题最致命:XSS。大模型本身是文本生成器,任何输入都可能诱导它输出 <script>、<img onerror>、<a href="javascript:..."> 这类内容。如果直接把字符串交给浏览器解析,等于把执行权交了出去。哪怕你的业务是“内部工具”,我也建议把安全过滤当成刚需,不要因为用户是同事就放松。
第二个问题是语法标准不统一。大模型通常训练了大量Github、论坛数据,输出里会混合标准Markdown、GitHub Flavored Markdown、HTML片段、LaTeX公式、表格、任务列表、脚注。你用正则拆几行容易,拆到嵌套列表就炸了。正则处理Markdown是典型的“看着能跑,换个输入就崩”。
第三个问题是没有样式。渲染成HTML之后,原生标签在默认浏览器样式下非常丑:没有圆角、没有代码高亮、表格贴在一起、引用块没背景色。用户一眼就能看出“这是开发者的半成品”。所以工程化不光是“转换格式”,还包括“把一整块PPT级别的显示效果做出来”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 选型实录:从正则替换到统一渲染管线的演进
2.1 早期方案:正则替换为什么撑不住
一开始,我的方案非常暴力:用正则把 #、**、` 这些符号替换成 <h1>、<strong>、<code>。写五六个正则之后,简单文案确实能出效果,但大模型一旦输出嵌套链接、列表、代码块里的Markdown符号,整个结果就乱套。
举个例子,大模型输出一段 [点击下载](javascript:alert(document.cookie))。用朴素正则转HTML,会得到 <a href="javascript:alert(document.cookie)">点击下载</a>。用户只要点击一次,页面就弹cookie。这说明正则的“无状态”特性根本撑不住Markdown的多层结构,更别提过滤危险链接。
另一个问题是转义。代码块里的内容本来应该原样显示,比如用户问“给我讲一下 **不是加粗**”,如果正则不分场合直接替换,代码块里的 ** 也被当成加粗处理,错误就出现了。真正的Markdown解析器需要理解“这是代码块,里面的内容不做语法解析”。这些判断对正则来说,越写越复杂,最后变成一坨没人敢动的补丁。
2.2 最终方案:前端统一渲染加消毒
后来我重新梳理需求,确定了技术路线。核心依赖三件套:
markdown-it:把Markdown源码解析成HTML字符串,支持扩展插件。DOMPurify:把HTML字符串过滤一遍,只留下安全的标签和属性。highlight.js:负责代码块的高亮渲染。
为什么不选服务端转换?当时评估过,让后端在返回之前就把HTML算好,前端直接显示,听起来更省事。但问题在于:大模型应用通常要支持多端(Web、小程序、App),如果服务端直接返回HTML,小程序里处理HTML标签又是一套逻辑。而且前端组件需要根据用户交互动态渲染代码块复制按钮,服务端返回的HTML会产生很多不必要的字符串协作成本。权衡后选了前端渲染:服务端只传输原始Markdown,各端按自己能力做解析显示。
前端渲染的逻辑也很简单:markdown-it 先转换,DOMPurify 再消毒,最后把干净的HTML塞进容器。这个顺序不能反。如果先消毒再转换,Markdown里的危险链接会绕过消毒,因为它还没变成 href;如果先转换再消毒,就能精准卡住HTML层面的危险属性。
3. 基于markdown-it的HTML渲染管线搭建
3.1 核心依赖与基础配置
先看依赖安装:
bash复制npm install markdown-it dompurify highlight.js github-markdown-css
markdown-it 是解析引擎,dompurify 负责安全过滤,highlight.js 做代码高亮,github-markdown-css 提供和GitHub风格一致的样式。这里我把配置直接写进一个独立模块,方便多个页面复用:
javascript复制import MarkdownIt from 'markdown-it';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js';
const md = new MarkdownIt({
html: false, // 不解析原始HTML,避免直接引入危险标签
linkify: true, // 自动识别URL,例如 www.example.com 变成链接
typographer: true, // 做一些标点修正,比如省略号
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return `<pre class="hljs"><code>${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>`;
} catch (__) {}
}
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`;
}
});
html: false 很关键。它让Markdown里的 <script>、<div> 这类原始HTML不做渲染,而是转义成普通文本。这样即使大模型被提示词诱导输出HTML标签,也不会直接进入页面DOM。但要注意,html: false 不代表百分百安全,Markdown链接、图片、代码块等场景依然可能绕过,所以最后的 DOMPurify 不能少。
3.2 扩展插件:表格、任务列表与高亮
基础版只能处理最常用的标题、加粗、列表。大模型产品通常需要GitHub风格表格和任务列表,尤其在做周报、技术方案、快捷回复这类场景时,表格几乎天天出现。光靠 markdown-it 自带的语法还不够,需要补两个插件:
bash复制npm install markdown-it-task-lists markdown-it-table
从实测来看,markdown-it新版本对表格的支持已经不错,但任务列表仍然是独立插件。初始化时挂上:
javascript复制import taskLists from 'markdown-it-task-lists';
import markdownItTable from 'markdown-it-table';
md.use(taskLists, { enabled: true, label: true });
任务列表插件会把 - [ ] 和 - [x] 渲染成带 checkbox 的列表项。用户在页面上勾选状态后,如果能把状态回传给前端逻辑,体验会好很多。
代码高亮方面,highlight.js 默认自带上百种语言。我实际项目里遇到过一个性能问题:高亮时每次解析都去找语言库,短文案还好,一篇文章超过几万字时会明显卡顿。优化手段是只加载常用语言子集,而不是全量包:
javascript复制import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
import typescript from 'highlight.js/lib/languages/typescript';
import python from 'highlight.js/lib/languages/python';
import bash from 'highlight.js/lib/languages/bash';
import json from 'highlight.js/lib/languages/json';
import xml from 'highlight.js/lib/languages/xml';
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('typescript', typescript);
hljs.registerLanguage('python', python);
hljs.registerLanguage('bash', bash);
hljs.registerLanguage('json', json);
hljs.registerLanguage('xml', xml);
只注册业务里真的会用到的语言,包体积能减掉一大半,解析速度也跟着提升。别上来就 import 'highlight.js/styles/github.css' 然后 import hljs from 'highlight.js',这种做法看起来很省事,但生产包会非常臃肿。
3.3 XSS过滤与白名单策略
markdown-it 转换出来的HTML字符串不能直接渲染,必须经过 DOMPurify:
javascript复制const sanitizedHtml = DOMPurify.sanitize(renderedHtml, {
ADD_ATTR: ['target', 'rel', 'referrerpolicy'],
ADD_TAG: ['input'],
});
这里为什么需要 ADD_ATTR ?因为DOMPurify的默认白名单里不一定包含 target、rel 这些属性。如果我不声明,链接的 target="_blank" 会被直接剥掉,体验上少了一个“新窗口打开”的能力。ADD_TAG: ['input'] 则是为了保住任务列表里的 checkbox,否则 markdown-it-task-lists 生成的 <input type="checkbox"> 会被消毒掉。
但是,DOMPurify 默认不会限制 javascript: 协议的链接,只过滤危险标签和事件属性。所以还必须在 markdown-it 的校验器里挡住非法协议:
javascript复制const defaultLinkOpen = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
md.renderer.rules.link_open = function(tokens, idx, options, env, self) {
const href = tokens[idx].attrGet('href') || '';
const normalized = href.replace(/[\u0000-\u001F\u0020]/g, '').toLowerCase();
if (!/^(https?:|mailto:|tel:)/.test(normalized)) {
tokens[idx].attrSet('href', '#');
}
tokens[idx].attrSet('target', '_blank');
tokens[idx].attrSet('rel', 'noopener noreferrer nofollow');
return defaultLinkOpen([token](https://taotoken.net?utm_source=general)s, idx, options, env, self);
};
这个校验器拦截了 javascript:、data:、vbscript: 等危险协议。重点是先去除空格和控制字符再判断,因为 java\nscript:、java script: 这类混淆很容易绕过刻板的正则。加了这层之后,最后被 DOMPurify 处理过的链接基本是干净的。
4. 让渲染结果真正能用的细节:样式、链接、代码块
4.1 链接处理:新窗口打开与安全关系
大模型经常提到外部链接,如果用户点击后直接在当前页跳走,会丢失对话上下文。所以对普通链接统一加 target="_blank"。真正需要注意的一点是 rel 属性:没有 noopener 时,新页面可以通过 window.opener 操作老页面,这是经典安全漏洞。我直接写死 rel="noopener noreferrer nofollow",防止恶意站点反向控制以及SEO权重传递。
nofollow 这个值看业务需求,如果是内部知识库,不加也行;如果是用户生成内容生成的链接,建议保留。
html复制<!-- 渲染后的链接示例 -->
<a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow">example.com</a>
4.2 代码块复制按钮与高亮主题
代码块是开发类AI应用的核心场景。刚渲染出来的高亮代码块没有复制按钮,用户想复制一段代码只能手动拖选,非常不舒服。我通过一个事件委托方案给所有代码块加复制按钮,不在 markdown-it 的解析阶段处理,避免污染HTML:
javascript复制document.addEventListener('click', async (event) => {
const button = event.target.closest('.code-copy-btn');
if (!button) return;
const code = button.parentNode.querySelector('code').innerText;
try {
await navigator.clipboard.writeText(code);
button.textContent = '已复制';
setTimeout(() => { button.textContent = '复制'; }, 2000);
} catch (error) {
button.textContent = '复制失败';
}
});
对应的HTML结构,我建议用 markdown-it 的 highlight 返回值统一包裹:
html复制<pre class="hljs">
<div class="code-block-header">
<span class="code-lang">javascript</span>
<button class="code-copy-btn">复制</button>
</div>
<code>...</code>
</pre>
注意代码里的 innerText 只取代码文本,不会把按钮自身复制进去,这是常见造轮子时最容易踩的坑。另一个细节:如果代码语言是 mermaid 这类特殊语言,建议不要在 highlight.js 里硬高亮,而是单独做图表渲染,避免风格冲突。
4.3 表格在移动端的适配
大模型输出表格时,列数可能很多,屏幕宽度不够就容易爆版。解决方案很粗暴但很有效:给整个渲染容器加透明滚动层。
css复制.markdown-body {
overflow-x: auto;
}
.markdown-body table {
display: block;
width: max-content;
max-width: 100%;
border-collapse: collapse;
}
display: block 配合 max-width: 100% 可以让宽表格在容器内横向滑动,不会把布局撑破。桌面端则看起来接近GitHub的表格效果。这里有一个经验:width: max-content 会让表格按内容自适应,但记得外层容器要有 overflow-x: auto,否则移动端依然溢出。
4.4 图片懒加载与外部图片追踪
大模型经常会返回图片链接,默认的 <img> 标签会直接请求外部服务器。这样做有两个隐患:一是由于图片外链服务可能不稳定,加载慢;二是外部网站可以通过图片请求记录用户IP和访问来源。我通常会给图片统一加 loading="lazy" 和 referrerpolicy="no-referrer"。
javascript复制const defaultImageOpen = md.renderer.rules.image_open || function(tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
md.renderer.rules.image_open = function(tokens, idx, options, env, self) {
tokens[idx].attrSet('loading', 'lazy');
tokens[idx].attrSet('referrerpolicy', 'no-referrer');
return defaultImageOpen(tokens, idx, options, env, self);
};
如果内部有图片代理服务,更好。可以把外部URL参数化之后走自己的代理,一方面控制访问速度,另一方面也能避免外链失效。至少 referrerpolicy="no-referrer" 这一条成本极低,优先级最高。
5. 流式输出场景下的Markdown渲染工程化
5.1 流式内容为什么会导致页面抖动
大模型接口现在几乎都支持流式输出,也就是逐字返回内容。如果每次拿到新的文本片段都立刻重新解析整段Markdown、更新DOM,页面会出现明显的闪烁和跳动。原因是Markdown语法在未完成状态下会被解析成错误结构。
最典型的是代码块:大模型还没输出完,内容里只有一个 ```javascript,没有闭合的 ```。此时解析器认为后面所有内容都在代码块里,整段文字都会被包成代码高亮,甚至格式混乱。用户看到一行行代码突然被渲染出来,然后又因为后续内容补全而重新排版,体验非常差。
5.2 增量渲染思路与防抖策略
我实际采用的方案是:在流式过程中不直接渲染 markdown-it 的结果,而是先显示原始Markdown文本,以纯文本的形式给出“正在生成”的反馈。等流式结束后,再一次性执行渲染管线,把HTML渲染到目标容器。
这种方法最简单,也最稳定。但有些产品希望用户在生成过程中就能看到排版效果,这时可以采用“短防抖+分段渲染”的折中方案:
javascript复制let renderTimer = null;
let lastRawMarkdown = '';
function onStreamUpdate(currentMarkdown) {
lastRawMarkdown = currentMarkdown;
if (renderTimer) clearTimeout(renderTimer);
renderTimer = setTimeout(() => {
renderMarkdown(lastRawMarkdown);
}, 300);
}
防抖时间设为 300ms 到 500ms 比较合适。太短会导致高频渲染,太长会让用户觉得预览滞后。还可以再优化一点:每次渲染时记录当前滚动位置,渲染完成后如果容器高度变化,把滚动位置恢复到最后一条消息底部,而不是强制回到顶部。
另外一个关键点是代码块。为了不让未闭合的代码块渲染得乱七八糟,我在流式过程中做了特殊标记:如果检测到 Markdown 文本中的代码围栏数量为奇数,就暂时不执行代码高亮,只把代码块里的内容按纯文本展示;等流式结束后再完整渲染。这个策略让我在聊天场景里省掉了大量“闪烁回跳”的投诉。
javascript复制function countCodeFences(text) {
const matches = text.match(/^```/gm);
return matches ? matches.length : 0;
}
function renderMarkdown(markdownText) {
if (countCodeFences(markdownText) % 2 === 1) {
// 代码块未闭合,先按纯文本渲染,避免错误的高亮结构
container.textContent = markdownText;
} else {
const rendered = md.render(markdownText);
container.innerHTML = DOMPurify.sanitize(rendered, { ADD_ATTR: ['target', 'rel'], ADD_TAG: ['input'] });
}
}
这套逻辑不复杂,但工程上非常管用。文本变长了之后,全量渲染的成本也不是无限增长。实测 5000 字左右的 Markdown 文档,markdown-it 渲染耗时通常在 5~10 毫秒,DOMPurify 额外 2~5 毫秒,浏览器显示频率完全扛得住。
6. 实测性能与容易踩的坑
6.1 性能测试:长文档与高并发场景
我做过一批Benchmark,数据很直观:
| 内容场景 | 文本长度 | markdown-it渲染耗时 | DOMPurify耗时 | 总计 |
|---|---|---|---|---|
| 简单聊天回答 | 300字 | 0.8ms | 0.5ms | 1.3ms |
| 技术文档 | 2000字 | 3.1ms | 1.8ms | 4.9ms |
| 带代码块和表格的长文章 | 5000字 | 9.6ms | 4.7ms | 14.3ms |
| 高频流式输出(100ms一次) | 200字 | 0.5ms | 0.3ms | 0.8ms |
浏览器渲染DOM的开销有时比解析本身高。所以真正需要优化的地方是:减少重复的DOM替换,减少浏览器回流。如果每次流式更新都把整个容器清空重绘,性能必然有问题。更好的做法是只在渲染内容真正变化时替换 innerHTML,并且外层容器不要设置复杂的CSS动画。
6.2 踩坑记录一:markdown-it的链接漏洞
我最早只依赖 DOMPurify,没有给 markdown-it 的链接加协议过滤。结果测试时发现,[点我](java%73cript:alert(1)) 这种URL编码形式可以被 markdown-it 渲染成 href="java%73cript:alert(1)",而 DOMPurify 默认不会认为这是危险协议,因为字符串面值不是 javascript:。浏览器解析时却会自动解码,最终执行脚本。
这里就体现了“双层校验”的价值:markdown-it 解析前先过滤,DOMPurify 再兜底。另外,不能只在权限层加黑名单,协议白名单优先。只允许 http:、https:、mailto:、tel:,其它全干掉的逻辑要简单很多。
6.3 踩坑记录二:代码高亮导致XSS
highlight.js 的返回值是HTML字符串,如果代码本身包含 <script>,高亮库不一定能自动转义。所以我的 highlight 函数里,在对代码片段做高亮之前,必须调用 md.utils.escapeHtml(str) 或者确保传入的代码文本不会被当作HTML标签解析。
之前有一个版本写成了:
javascript复制return `<pre>${hljs.highlight(str, { language: lang }).value}</pre>`;
当代码内容是 <img src=x onerror=alert(1)> 时,highlight.js 会把它当作代码标记并输出 <span class="hljs-tag"><img... 之类的内容吗?不一定,有些语言规则下它会原样输出 <img...>,最终插入DOM后就是真正的HTML。现在我的实现里,如果语言不在白名单,就使用 md.utils.escapeHtml(str) 渲染纯文本;即使高亮成功,我也假设当前 highlight.js 的返回值是安全的,但背后依赖高亮库版本和规则,不够稳定。所以更好的姿势是:高亮之前先转义一次,再交给 hljs.highlight,并确认 hljs.highlight 不会再解码。
实际上更稳妥的高亮实现,是先把代码文本用 escapeHtml 转成纯文本实体,再传给高亮库。但这样可能导致高亮库匹配不到代码。针对这个问题,我在生产环境里用了一个折中:默认语言只启用注册过的语言;未注册语言一律按纯文本转义输出,不强行高亮。
6.4 段落截断与未闭合结构的兜底
大模型流式输出时,经常在一个 | 表格行中间断掉,或者在 ** 加粗中间断掉。正式渲染前,可以给 markdown-it 设置 breaks: true 选项来支持换行。但更根本的思路是:输出结束后,用一段兜底代码检测未闭合结构。
我写了一个很小的工具函数,在渲染前修补明显未闭合的代码围栏:
javascript复制function normalizeMarkdown(markdownText) {
const fences = markdownText.match(/^```/gm) || [];
if (fences.length % 2 === 1) {
return markdownText + '\n```';
}
return markdownText;
}
这个函数只能修补代码围栏,表格和加粗的未闭合很难用简单规则修复。这也是我最终选择“流式过程中显示纯文本,流式结束再完整渲染”的原因之一。不要试图用AI去修正渲染中间的未闭合结构,那只会引入更多不确定性。
6.5 DOMPurify配置过严导致功能丢失
有段时间我发现代码块里的 <input type="checkbox"> 不显示了,排查半天发现是 DOMPurify 把 <input> 标签剥掉了。这类问题会让人很困惑:Markdown任务列表确实转换成 <input type="checkbox">,但消毒后标签被移除,只剩文本。解决方案就是在 DOMPurify.sanitize 的配置里加上 ADD_TAG: ['input']。
同理,如果你想保留 target="_blank",必须显式加 ADD_ATTR: ['target', 'rel'],否则你会看到所有来自大模型内容的链接都变成当前页打开。这个行为不是Bug,是DOMPurify默认白名单设计得比较严格,按需求扩展就好。
7. 我建议的生产级渲染管线配置
如果你也想在大模型应用里接入Markdown渲染,我建议直接复制下面这套配置,它能覆盖大多数聊天和内容生成场景。
javascript复制const md = new MarkdownIt({
html: false,
linkify: true,
typographer: true,
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return `<pre class="hljs"><code>${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>`;
} catch (__) {}
}
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`;
}
});
function renderMarkdownToHtml(markdownText) {
const normalized = normalizeMarkdown(markdownText);
const rawHtml = md.render(normalized);
return DOMPurify.sanitize(rawHtml, {
ADD_ATTR: ['target', 'rel', 'referrerpolicy'],
ADD_TAG: ['input'],
});
}
再配合样式引入:
javascript复制import 'highlight.js/styles/github.css';
import 'github-markdown-css/github-markdown.css';
页面容器只需要绑定:
javascript复制const container = document.querySelector('.markdown-body');
container.innerHTML = renderMarkdownToHtml(rawMarkdown);
这种模式已经在我负责的AI知识库、客服摘要、内容创作工具里跑了大半年。我见过不少人在这里自己造轮子,用正则从零写Markdown解析,到最后都在安全性上吃亏。真正省心的做法是站在 markdown-it、DOMPurify 这些成熟库的肩膀上,把精力留给业务差异,比如流式渲染优化、代码块交互、大模型特有内容的标注。
最后再分享一个小经验:不要只测输出正常时的渲染结果,要多用“脏数据”测试。让用户随意输入 [点击](javascript:alert(1))、、 ```<script>alert(1)</script> 这类内容,你能在测试阶段发现问题,而不是等用户帮你发现。渲染管线这种东西,投产前测得越狠,上线后睡得越香。
