我叫它“大模型返回值的最后一公里”,也是很多大模型应用从 demo 走向产品时绕不开的一道坎:前后端都在等大模型吐出漂亮的 Markdown,但用户的浏览器只认 HTML。如果你正在开发 AI 聊天助手、智能报告生成器、知识库问答系统,或者任何需要把大模型输出直接展示在 Web 页面里的应用,这篇文章就是给你准备的。
我不会讲太多抽象理论,重点放在工程实现路径上:为什么大模型偏爱 Markdown、业界最常用的渲染方案有哪些、真实项目里怎么把 marked + DOMPurify + highlight.js 串起来,以及流式输出(SSE)场景下那些“一改就崩”的细节。文章里所有代码和配置都来自我实际跑过的项目,你拿过去改改就能用。
1. 内容整体设计与思路拆解
1.1 为什么大模型“只说”Markdown,产品却“只认”HTML
现在主流的大模型 API,无论是 OpenAI、Anthropic 还是国内各家开源模型,默认输出格式基本都是 Markdown。这个选择非常聪明:Markdown 是一种轻量标记语言,纯文本就能表达标题、列表、表格、代码块、加粗、斜体等富文本结构,token 消耗小,解析容易,对训练数据也没那么敏感。
但问题来了,用户的浏览器不认 Markdown。如果你把一段 ## 标题 直接塞进 <div>,页面只会老老实实显示两个井号。用户不关心底层格式,他们只看到“排版乱得像接口文档”。
所以工程上必须有一层转换:把大模型吐出来的 Markdown 字符串,解析成浏览器能渲染的 HTML DOM。这个转换器的选型、配置、安全处理,就是整个大模型应用工程化里最容易被低估但实际最影响体验的部分。
我见过不少团队,前期 demo 阶段直接用前端 Markdown 库渲染,效果惊艳;一上生产环境,不是 XSS 漏洞被打穿,就是代码高亮乱成一团,表格直接溢出页面。问题不在模型,而在“渲染管线”没搭好。
1.2 技术选型:不折腾,选三条主流路线
Markdown 转 HTML 的方案很多,工程里真正经得起折腾的就三条路线:
第一条是前端渲染,代表库是 marked、markdown-it、showdown。优势是零后端依赖、部署简单、支持流式增量渲染,适合聊天类应用。劣势是有潜在 XSS 风险,必须配合白名单过滤。我的经验是个人项目或内部系统用 marked,它轻、快、生态好。
第二条是后端渲染,代表方案是 Python 的 markdown 库配 bleach,或者 Node 端用 markdown-it 再输出 HTML 字符串返回前端。优势是安全策略集中管理,适合对内容合规要求高的场景,比如政务、金融、医疗领域的报告生成系统。劣势是每次渲染要走网络或进程间通信,流式场景下频繁请求会拖慢体验。
第三条是双端混合:Markdown 源文本存库,前端实时渲染,后端在必要时做一次安全清洗。这是我现在最推荐的方式,因为它既保留完整源数据,又能在前端秒级反馈。
选型逻辑不要跟风。我踩过最大的坑是团队里有人为了“统一生态”把所有渲染放在后端,结果聊天类页面每收到一个 token 就要向后端发一次渲染请求,服务器 CPU 直接飙满。所以先问自己一个问题:这块内容是实时生成的,还是预先生成的?实时选前端,预生成选后端,混合场景选双端。
1.3 渲染前必须想的三个问题
很多人拿到 Markdown 就匆忙找库,忽略了三个前置问题。
第一,安全边界在哪里?大模型输出不是你写的文案,它可能是训练语料里原封不动带出来的内容,攻击者也可以通过 prompt injection 诱导模型输出恶意 HTML。所以无论选哪条路线,XSS 过滤不是可选项,是必需品。
第二,样式锚点在哪里?HTML 渲染出来只是光秃秃的标签,用户看到的“好看”全部来自 CSS。你没有定义 pre、table、blockquote 的样式,页面就会退回浏览器默认样式,丑得没法看。这一步很多人漏掉,导致“内容渲染出来了,但跟产品设计稿差距巨大”。
第三,流式还是非流式?ChatGPT 风格的打字机效果现在已经是标配,这影响解析器的调用方式。如果一次性渲染整个 Markdown 字符串,流式收到的半截内容(比如代码块只有开头三个反引号)会让解析器行为不可预期,必须做特殊处理。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 接入 marked:真正能跑起来的配置
我用 marked 比较多,简单说下核心用法。新版 marked 从 v5 开始 API 有变化,marked.parse() 替代了曾经的 marked()。一个最小可用配置是这样的:
javascript复制import { marked } from 'marked';
// 扩展:让所有链接在新窗口打开
const renderer = {
link(token) {
return `<a href="${token.href}" target="_blank" rel="noopener noreferrer">${token.text}</a>`;
}
};
marked.use({ renderer });
const html = marked.parse(markdownText);
document.getElementById('content').innerHTML = html;
代码里第一件做的事就是 renderer.link 重写,这是所有接了大模型的页面都必须做的动作:默认情况下 marked 生成的链接会在当前页面跳转,用户点一下聊天记录里的 URL,整个应用就跑了,体验非常差。加上 noopener noreferrer 是安全习惯,防止新窗口页面通过 window.opener 操控你的页面。
需要注意,marked 对 Markdown 语法的支持是 CommonMark 子集,不是所有语法都能识别。比如表格,marked 能渲染,但对“单元格内换行”这类 edge case 支持有限。如果产品里有大量复杂表格,我会推荐 markdown-it + multimarkdown 插件,但换来的是体积和复杂度上升。项目初期用 marked 绝对够,先跑通再优化。
2.2 安全防线:DOMPurify 必装,别省
接大模型输出的第一课,就是永远不要相信模型返回的字符串。XSS 攻击在 AI 应用里不是“可能发生”,而是“已经在发生”。攻击者可以在提问里塞一段 <img src=x onerror=alert(1)>,诱导模型在回答里原样输出或者变形输出,前端一旦直接注入,脚本就执行了。
业界标准做法是配合 DOMPurify 使用,把 marked 生成的 HTML 字符串清洗一遍再插入 DOM:
javascript复制import DOMPurify from 'dompurify';
const cleanHTML = DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
});
document.getElementById('content').innerHTML = cleanHTML;
sanitize 默认会去掉 onerror、javascript: 这类危险内容,但比如你想允许 class、data-* 属性用于代码高亮,就得显式加白名单:
javascript复制const cleanHTML = DOMPurify.sanitize(html, {
ADD_ATTR: ['class'],
});
这里最容易被忽略的一个细节是 USE_PROFILES: { html: true } 这个选项。如果你不设,DOMPurify 默认允许 SVG 和 MathML,但这两个东西在纯 HTML 场景里不仅无用,还是已知 XSS 重灾区。显式声明只处理 HTML,等于关掉了两个攻击面。
我在实际项目里还遇到过一种情况:DOMPurify 默认会移除 target="_blank" 属性,因为它觉得 target 不必要。但我前面渲染器扩展里又特意加了 target="_blank"。这里要小心,顺序不对结果完全不同。正确做法:先 marked.parse(),再 DOMPurify.sanitize(),并在 sanitize 的 ADD_ATTR 里带上 target,否则用户点击链接还是会当前页跳走。
安全这块还有一个容易被忽略的点:清洗逻辑必须放在后端或至少封装成一个纯函数,不能散落各个组件。我见过一个项目,安全过滤只写在某个弹窗组件里,结果另一条渲染路径完全裸奔。
2.3 代码高亮:让大模型写出的代码“能看”
大模型输出里代码占比极高,这也是 Markdown 渲染 HTML 场景里用户感知最强的部分。没有高亮的代码块,就像没有调味的菜,能吃但没体验。所以我都会给渲染管线加 highlight.js:
javascript复制import hljs from 'highlight.js';
import { marked } from 'marked';
marked.setOptions({
highlight(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value;
}
return hljs.highlightAuto(code).value;
}
});
这里有个版本差异需要提醒:marked.setOptions 的 highlight 字段在 v12 以后已经标记为 deprecated,官方推荐用 marked-highlight 插件。但很多网上教程还在教旧写法,照着做会发现 setOptions 直接不生效。新写法如下:
bash复制npm install marked-highlight highlight.js
javascript复制import { Marked } from 'marked';
import { markedHighlight } from 'marked-highlight';
import hljs from 'highlight.js';
const marked = new Marked(
markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, { language }).value;
}
})
);
同时记得在 CSS 里引入高亮主题。
css复制/* 你的应用入口 CSS 文件 */
@import 'highlight.js/styles/github-dark.css';
很多人的代码高亮“不生效”,90% 的原因是忘了两件事:第一,渲染后的代码 HTML 里 <code> 标签必须带 language-xxx 类名,highlight.js 默认按这个类名识别语言;第二,就算高亮 JS 正确执行,你没引入主题 CSS,输出也是白底黑字,看不出来效果。上面 langPrefix: 'hljs language-' 就是为了同时兼容这两种机制。
2.4 表格与样式:渲染出来只是第一步,好看才是目的
marked 默认会把 Markdown 表格渲染成 <table> 标签,但浏览器里它极其丑陋,没有边框、没有斑马纹、超出容器不换行。如果你只是把 HTML 塞进页面就不管了,用户看到的基本是一堆文字挤在一起。
我是这样处理的:在 CSS 里专门为渲染区定义一套“文章容器”样式。
css复制.markdown-body {
line-height: 1.75;
word-wrap: break-word;
font-size: 15px;
}
.markdown-body table {
display: block;
width: 100%;
overflow-x: auto;
border-collapse: collapse;
margin: 16px 0;
}
.markdown-body th,
.markdown-body td {
border: 1px solid #ddd;
padding: 8px 12px;
text-align: left;
}
.markdown-body th {
background: #f6f8fa;
font-weight: 600;
}
.markdown-body pre {
background: #0d1117;
color: #e6edf3;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
}
.markdown-body code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 13px;
}
.markdown-body blockquote {
border-left: 4px solid #dfe2e5;
margin: 8px 0;
padding-left: 16px;
color: #6a737d;
}
这里想重点说表格的 display: block; overflow-x: auto 这个组合。大模型很喜欢生成宽表格,列一多就会把页面容器撑破。设成 block 之后表格可以在小屏幕上横向滚动,而不是挤压布局。这是 GitHub 官方 CSS 的套路,实测下来是最稳的方案。
代码块我用深色背景,和正文形成明显区分。很多组件库(比如 Element Plus、Ant Design)本身有 Markdown 渲染组件,或者至少提供了样式变量,能复用就复用,不要自己重新造轮子。
3. 实操过程与核心环节实现
3.1 定义输入输出结构:别让字符串裸奔
真实项目里我不会直接把 markdownString 塞进渲染函数,而是定义好输入输出结构,方便后续做埋点和扩展。
typescript复制interface RenderRequest {
content: string; // 大模型返回的原始 Markdown
streaming?: boolean; // 是否为流式输出
codeTheme?: 'light' | 'dark';
}
interface RenderResult {
html: string; // 清洗后的 HTML 字符串
raw: string; // 原始 Markdown(可保留用于复制)
codeLanguages: string[]; // 本次渲染涉及的语言列表,便于统计
hasTable: boolean;
hasCode: boolean;
error?: string;
}
codeLanguages 和 hasTable 这些字段一开始可能用不上,但一旦产品经理跟你提“能不能做个代码语言分布统计”或者“带表格的回答要固定宽度展示”,你就知道当初这个结构设计多重要了。
渲染函数我封装成纯函数,不在组件内部直接调用 marked,方便单测和复用:
typescript复制import { marked } from 'marked';
import DOMPurify from 'dompurify';
export function renderMarkdown(input: RenderRequest): RenderResult {
try {
const rawHtml = marked.parse(input.content);
const cleanHtml = DOMPurify.sanitize(rawHtml, {
ADD_ATTR: ['target'],
});
const codeLanguages = extractCodeLanguages(input.content);
return {
html: cleanHtml,
raw: input.content,
codeLanguages,
hasTable: input.content.includes('|'),
hasCode: input.content.includes('```'),
};
} catch (err) {
return {
html: DOMPurify.sanitize(escapeHtml(input.content)),
raw: input.content,
codeLanguages: [],
hasTable: false,
hasCode: false,
error: String(err),
};
}
}
escapeHtml 就是一个把 <, >, & 转义的简单函数,用于渲染失败时兜底,保证用户至少能看到内容而不是白屏。生产环境里这个兜底路径极其重要,我曾经因为一个大模型返回格式异常导致整页崩溃,加了这个兜底之后,体验至少不会从“差”变成“无”。
3.2 接入 API 层:大模型返回直接进渲染管线
后端接口返回的数据建议保留两层结构:raw_content(原始 Markdown)和 rendered_content(可选,后端清洗后的 HTML)。我个人喜欢前端渲染,所以接口只返回原始 Markdown,渲染全部由前端完成。
API 层封装这样写:
typescript复制async function fetchAIResponse(prompt: string): Promise<string> {
const res = await fetch('/api/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt })
});
if (!res.ok) {
throw new Error(`API Error: ${res.status}`);
}
const data = await res.json();
return data.choices[0].message.content;
}
拿到内容后调用渲染函数:
typescript复制const markdownText = await fetchAIResponse(userPrompt);
const result = renderMarkdown({ content: markdownText });
document.getElementById('answer')!.innerHTML = result.html;
这个流程看起来简单,但里面有个大坑,我在团队 code review 时见过不止一次:组件内多次渲染导致 XSS 过滤被跳过。比如你先把 rawHtml 赋值给 dangerouslySetInnerHTML(React)或 innerHTML(原生),之后又因为某些依赖变化重新渲染,逻辑分支走得不对,安全函数也许就没被调用。所以我的习惯是:组件里只存 RenderResult,任何依赖变化都从 result.html 取,绝不重新 parse。把渲染过程收敛成一个不可变快照,是工程化的基本素养。
3.3 流式输出(SSE)场景的专属优化
现在大多数 AI 应用都做打字机效果,也就是 SSE 流式返回。这块对 Markdown 渲染是个麻烦:大模型还没来得及输出完,你手里拿到的就是一个“半截 Markdown”。此时如果直接渲染,可能会遇到三类问题。
第一类,代码块没闭合。客户端刚刚收到 ~~~js,后面的代码还没回来,直接解析会得出异常结果,页面出现一个没有样式的 <code> 标签。第二类,表格还在拼接中,| 列名 只到了第一个管道符,渲染器可能把它当成普通段落。第三类,加粗符号 ** 出现一半,渲染器可能把它当普通文本,半秒后又变成加粗,频繁闪烁。
我的解决方案是三层策略:
第一层,节流渲染。用 requestAnimationFrame 或定时器对更新做合并,不是每收到一个 token 就渲染一次,而是每 100ms 或每 16ms 做一次合并渲染。这样既能保证流畅度,也大幅降低 DOM 操作频率。
第二层,半成品标记。在流式过程中插入一个占位样式,等全部接收完成后再做一次最终渲染。比如可以维护一个 isStreaming 状态,流式过程中模板里加一个行内元素提示:
javascript复制const streamingMark =
'<span class="streaming-cursor">▍</span>';
这个光标在 CSS 里做一个闪烁关键帧动画,让用户明确感知“正在生成”。
第三层,强制校验。流式过程中如果检测到未闭合的代码黑块(代码块起始标记数量大于结束标记),就临时不启用代码高亮,只做纯文本展示。避免半截 <pre> 块把布局撑坏。
具体实现片段:
javascript复制let buffer = '';
let timer: number | null = null;
function handleSSEChunk(chunk: string) {
buffer += chunk;
if (timer) window.clearTimeout(timer);
timer = window.setTimeout(() => {
const result = renderMarkdown({ content: buffer, streaming: true });
renderBox.innerHTML = result.html;
}, 100);
}
这段代码还有一个隐藏好处:如果大模型输出特别快,100ms 的缓冲能把多次 chunk 合并成一次渲染,减轻主线程压力。如果用户感觉打字机效果卡,可以减小到 50ms;如果感觉闪烁频繁,可以增大到 200ms。这个值是需要根据目标设备性能调的。
3.4 几个必须处理的边界场景
渲染用户不可见区域的内容时,比如折叠面板、Tabs 切换页,建议延迟渲染,等组件真正进入视口再执行 parse。这在大模型回答特别长时能省一大截主线程时间。
深色模式下代码高亮主题需要切换。highlight.js 引入的是固定主题,如果要支持主题切换,要同时引入两套 CSS 并通过 prefers-color-scheme 或者 data-theme 选择器控制:
css复制.markdown-body {
--code-bg: #0d1117;
--code-color: #e6edf3;
}
@media (prefers-color-scheme: light) {
.markdown-body {
--code-bg: #f6f8fa;
--code-color: #24292e;
}
}
还有一类内容是 Markdown 与 HTML 混排。大模型偶尔会在 Markdown 里嵌入原始 <div>、<span>,或者训练语料里带出 <iframe> 这类危险标签。DOMPurify 会把这些元素剥离掉,但剥离后内容是丢失的。如果产品有强需求保留视频 iframe(比如模型回答里配上讲解视频),最好用 DOMPurify 的 addHook 做白名单化的 iframe 过滤,而不是一刀切关闭。但这个场景实属少数,绝大多数项目把 iframe 直接去掉更安全。
4. 常见问题与排查技巧实录
4.1 表格没边框、排版乱,怎么办
这是我在各个项目群里被问得最多的一个问题,几乎每三周就会遇到一次。排查思路很简单:第一,先确认 HTML 里 <table> 标签是否存在,浏览器 DevTools 里看一下就知道;第二,如果表格标签存在但没有边框,那 100% 是 CSS 没写对。
常见错误是 CSS 写成了 .table,但 marked 渲染出来的标签根本没有 class。所以正确选择器必须覆盖原生的 table 或 .markdown-body table。另外一个坑是很多组件库会有全局样式打架,比如 Element Plus 的 table 样式会覆盖你的自定义样式。解决办法是提高选择器优先级:
css复制.markdown-body table,
.markdown-body table th,
.markdown-body table td {
border: 1px solid var(--border-color, #ddd);
}
4.2 代码高亮不生效或乱码
遇到这个问题,按以下顺序检查:
第一步看 <code> 标签有没有 language-js 之类的类名。没有类名,highlight.js 不知道高亮什么语言。第二步看 highlight 函数有没有拿到值。如果你用的 marked-highlight 插件在服务端执行,但 hljs 引入失败,结果就是抛异常也不报错,高亮直接失效。第三步看主题 CSS 有没有引入,引了哪个主题。很多主题只有特定语言的上色规则,比如 github 主题对新的语言支持不完整,可以换 atom-one-dark 这种覆盖面广的。
还有一个小经验:大模型经常输出带 language-jsx、language-tsx 的代码块,如果 hljs.getLanguage('jsx') 查询失败,我的代码里 fallback 成了 plaintext,最终代码不高亮。实际上应该先注册语言别名:
javascript复制import js from 'highlight.js/lib/languages/javascript';
hljs.registerLanguage('js', js);
对于 React 技术栈的 AI 应用,强烈建议注册 jsx、tsx 这两个语言。
4.3 换行效果不符合预期
Markdown 语法里,单换行是空格,双换行才产生新段落。但大模型生成的回答里经常出现“看起来是换行、渲染出来却挤在一起”的情况,比如列表项之间、地址信息里。
处理办法有两个方向。一个是全局启用 breaks: true,让 marked 把单换行也渲染成 <br>。这个选项在 marked 里这样配置:
javascript复制marked.setOptions({
breaks: true,
});
但 marked v12 之后这个字段位置也有变化,正确做法:
javascript复制import { Marked } from 'marked';
const marked = new Marked({ breaks: true });
这会让所有 Markdown 内容的换行都变得“像聊天消息”而不是“像文档”。如果你的应用是报告生成、知识库类,就不建议开启,会破坏 Markdown 排版习惯。聊天类应用则强烈建议开,因为用户的自然输入里没有那么多空行。
4.4 XSS 漏洞:自测脚本与修复
我建议所有接了大模型渲染的团队,在 CI 里加一个自测用例:用下面这段 Markdown 跑渲染函数,确认 onerror 脚本被移除:
javascript复制const attackMd = [
'# XSS Test',
'<img src=x onerror=alert(1)>',
'[click](javascript:alert(2))',
'<svg onload=alert(3)>',
'<script>alert(4)</script>'
].join('\n');
const result = renderMarkdown({ content: attackMd });
if (result.html.includes('onerror') || result.html.includes('<script>')) {
throw new Error('XSS sanitization failed');
}
实际测试下来,DOMPurify 默认配置能挡住大部分攻击,但 javascript: 链接这种需要特殊处理。我建议在 afterSanitizeAttributes 钩子里强制拦截:
javascript复制DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A') {
const href = node.getAttribute('href') || '';
if (href.startsWith('javascript:')) {
node.removeAttribute('href');
}
}
});
这也是我唯一推荐的暴力修复方式。不要试图用正则过滤危险内容,HTML 的解析复杂度远超正则的表达能力,用成熟库的白名单机制才是正解。
4.5 渲染性能:长文卡顿的排查套路
一次渲染有几百 KB 内容的 Markdown,marked.parse 本身也要计算几十毫秒,再加上高亮、DOM 插入,页面会明显卡顿。排查性能从三个地方看。
第一个是 parse 时间。在 console.time 包住解析函数,如果在普通 PC 上超过 200ms 就值得优化,可以换更轻量的解析器或缓存部分结果。第二个是 DOM 更新。如果一次性插入几百个节点,浏览器会触发大量 reflow。我的习惯是先用 DocumentFragment 拼接再一次性插入。第三个是高亮耗时。大模型输出里可能有一百多个代码块,全量高亮很慢,优先只做“可见区域”的高亮或者按需渲染。
我在一个知识库问答项目里遇到过长文卡顿,排查后发现是 highlightAuto 对每个无语言标记的代码块都做自动检测,那玩意儿极其耗时。后来改成语言不明的代码块全部不处理,性能提升非常明显。
5. 工程化实践总结与进阶建议
5.1 渲染统一收敛:别让每处都自己渲染
我见过最乱的代码就是每个组件里都有一份 marked.parse + innerHTML 的逻辑。产品迭代几轮后,有的页面有 XSS 过滤,有的没有,有的是旧版 API,有的混用了两种高亮方案。这种状态非常危险。
正确的做法是把渲染逻辑收敛为一个独立的工具模块,甚至一个微服务 / 函数,提供唯一入口,所有展示 Markdown 的组件都走这个入口。入口内部做五件事:解析、安全过滤、代码高亮、链接处理、兜底错误处理。不在入口之外重复任何渲染动作。
更激进一点的做法是写一个 Vue/React 的展示组件,比如 <MarkdownContent :content="text" :streaming="isStreaming" />,组件内部封装渲染逻辑,对外只暴露 content 属性。这样业务代码里不再出现任何 innerHTML,审查代码时只查这一个文件就够了。
5.2 我踩过的坑与积累的独家习惯
第一,永远不要直接渲染未过滤的模型输出。哪怕是“内部使用”的系统。prompt injection 不是玩笑,一次外部人员诱导模型输出恶意脚本,你的内部系统就裸奔了。
第二,解析和过滤分开写。先把 Markdown 变成 JSON 语法树,再渲染成 HTML 字符串,最后再做白名单过滤。三层分离方便你在出问题时快速定位是解析的 bug 还是过滤的 bug。
第三,Markdown 源文保留。即便页面只展示渲染后的 HTML,也把源 Markdown 留在 store 或数据库里。这样做的好处是:用户可以一键复制原始内容到 Typora 或 VS Code 继续编辑,也能方便你做语音播报、纯文本导出等其他场景。
第四,不要完全依赖自动语言检测。大模型经常标错代码语言(比如把 SQL 标成 Python),自动检测会进一步加剧错误。提供一个“手动选择语言”的下拉框或者“复制代码”按钮,比猜测语言更实际。
5.3 扩展方向:从“能渲染”到“能沉淀”
渲染管线一旦稳定下来,就可以往上叠加更多能力。我目前正在实践的一个方向是“可交互代码块”:在渲染出的代码块右上角加一个“运行”按钮,调用沙箱环境执行代码,结果直接在页面里内联显示。这需要你把 Markdown 解析成 AST,而不是只拿渲染后的 HTML 字符串,所以在设计渲染函数时就应该考虑返回一个结构化的中间对象。
另一个方向是引用溯源:大模型回答里经常会引用多个来源,如果能识别出 Markdown 里的链接结构并映射到原文段落,就能做“答案锚点”。这个功能本质上是解析层的事,跟渲染无关,但渲染管线的结构决定了你能不能低成本地拿到这些信息。
最后说说 VSCode 插件方向:如果你每天写大量带代码块的技术文档或博文,可以基于 marked 做一个本地预览插件,实时把 Markdown 渲染成带样式的 HTML。我试着写过一个类似的扩展,用 WebView 加载渲染后的 HTML,代码高亮直接用 highlight.js,效果相当不错。这也算是把这条渲染管线复用到编辑器生态的一个小实践。
无论你的应用规模多大,从第一行 marked.parse 开始就把上面的细节考虑到,后面省下的时间会让你庆幸当初没嫌麻烦。
