从 2022 年 ChatGPT 问世到现在,AI 聊天类应用已经变成了很常见的产品形态。自己公司也有相关应用,平时在需求和排查问题中陆续接触过一些细节,正好抽空做一次总结。
AI 聊天应用看起来只是“输入问题、等待回答”,但前端实际要处理的事情不少:markdown 渲染为 HTML、文本流式输出、自定义输入框、会话分享,以及流式输出时的打字机效果。本文重点会放在 Markdown 解析这一部分,因为它最容易从“能显示”演进到“要安全、要扩展、要性能”。
整体链路
一个比较典型的 AI 聊天消息链路大概如下:
这里面每一层都可以做得很深,但如果只是从业务开发角度出发,可以先抓住几个核心原则:
- 流式输出负责“快看到”,不要等服务端完整生成后再展示。
- Markdown 渲染负责“看得懂”,尤其是代码、表格、列表这类结构化内容。
- 安全过滤负责“别出事”,AI 生成内容本质上也是外部输入,不能直接信任。
- 输入框负责“能表达”,用户的输入不只有一行文本,后续往往会扩展到附件、快捷操作和上下文引用。
- 分享导出负责“可传播”,聊天内容如果有沉淀价值,最好能变成图片、PDF 或文档。
Markdown 解析(markdown-it)
markdown 对技术开发来说应该都比较熟悉了。AI 聊天应用里更是离不开它,因为模型很喜欢用 Markdown 来组织回答,例如:
- 用标题分层说明问题
- 用列表拆解步骤
- 用表格对比方案
- 用代码块输出示例
- 用引用块补充注意事项
如果没有 Markdown 渲染,AI 的回答会变成一大段纯文本,可读性会差很多。前端比较常见的解析库有 marked、markdown-it、remark/rehype 等。这里主要聊 markdown-it,因为它插件生态成熟,语法扩展也比较方便。
基础使用
最基础的使用方式很简单:
import MarkdownIt from 'markdown-it';
const md = new MarkdownIt({
html: false,
linkify: true,
typographer: true,
breaks: true,
});
const html = md.render('## 标题\n\n这是一段 **Markdown** 内容');
几个配置需要注意:
开启 html: false 并不等于绝对安全,它只是不解析 Markdown 里的原始 HTML。最终渲染到页面之前,仍然建议再做一次 HTML 清洗。
常用插件
AI 聊天里比较常见的 Markdown 扩展包括表格、任务列表、代码高亮、公式、Mermaid 图等。其中 markdown-it 默认已经支持表格和围栏代码块,其他能力可以通过插件补齐。
import MarkdownIt from 'markdown-it';
import hljs from 'highlight.js';
import DOMPurify from 'dompurify';
const md = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
highlight(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return `<pre class="hljs"><code>${hljs.highlight(code, { language: lang }).value}</code></pre>`;
}
return `<pre class="hljs"><code>${md.utils.escapeHtml(code)}</code></pre>`;
},
});
export function renderMarkdown(content) {
const html = md.render(content);
return DOMPurify.sanitize(html, {
ADD_ATTR: ['target', 'rel'],
});
}
上面这段代码里有两个关键点:
- 代码高亮时,如果语言不存在,要走
escapeHtml,不能直接拼接原始代码。
md.render 后再用 DOMPurify.sanitize 过滤一次,避免链接、图片、HTML 边界上出现安全问题。
实际项目里还可以对链接做统一处理:
const defaultRender =
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 token = tokens[idx];
const href = token.attrGet('href') || '';
if (/^https?:\/\//.test(href)) {
token.attrSet('target', '_blank');
token.attrSet('rel', 'noopener noreferrer');
}
return defaultRender(tokens, idx, options, env, self);
};
这样外链会在新窗口打开,同时避免 window.opener 带来的安全问题。
AI 场景下的特殊问题
普通 Markdown 渲染往往是“一次性拿到完整文本,然后渲染”。AI 聊天不一样,内容是一个 chunk 一个 chunk 回来的,半路上经常出现不完整语法:
```js
function hello() {
console.log('还没输出完')
```
在流式输出过程中,代码块可能还没闭合,表格可能只输出了一半,列表缩进也可能暂时不完整。如果每 次收到一个 token 都全量解析,容易带来三个问题:
- 性能压力:长回答每来一个字符就解析整段 Markdown,会造成重复计算。
- 渲染闪烁:不完整语法可能反复改变 DOM 结构。
- 滚动抖动:每次重新渲染都会影响消息高度。
比较稳的做法是“数据层实时拼接,视图层节流渲染”:
let rawContent = '';
let renderTimer = null;
function onMessageChunk(chunk) {
rawContent += chunk;
if (renderTimer) return;
renderTimer = requestAnimationFrame(() => {
renderTimer = null;
message.html = renderMarkdown(rawContent);
});
}
如果回答特别长,还可以在流式输出中先做轻量渲染,等服务端返回 done 后再做一次完整渲染,例如补齐代码复制按钮、目录锚点、Mermaid 图、公式等增强能力。
代码块增强
AI 回答里代码块占比很高,代码块至少可以加三个能力:
如果使用 React,可以在 Markdown 渲染后做一次 DOM 处理,也可以用 markdown-it 的 renderer 直接改写围栏代码块。
const fence = md.renderer.rules.fence;
md.renderer.rules.fence = function(tokens, idx, options, env, self) {
const token = tokens[idx];
const lang = token.info.trim() || 'text';
const rendered = fence(tokens, idx, options, env, self);
return `
<div class="code-block" data-lang="${md.utils.escapeHtml(lang)}">
<div class="code-block__header">
<span>${md.utils.escapeHtml(lang)}</span>
<button type="button" class="code-block__copy">复制</button>
</div>
${rendered}
</div>
`;
};
这里有个小坑:token.info 来自模型输出,不要直接拼进 HTML 属性里,一样要转义。
安全边界
AI 生成内容经常会被误以为“不是用户输入”,但它本质上仍然是不可信内容。只要最终进入 dangerouslySetInnerHTML 或 innerHTML,都要考虑 XSS。
建议至少做这些限制:
- 默认关闭 Markdown 原始 HTML。
- 渲染后的 HTML 用
DOMPurify 等库清洗。
- 图片链接做协议白名单,只允许
http、https 或业务允许的资源域名。
- 链接统一加
rel="noopener noreferrer"。
- 代码块内容只当文本显示,不执行。
- Mermaid、公式、HTML 预览等增强能力单独做开关,不要默认对所有消息开放。
尤其是 Mermaid 这类“看起来只是图”的能力,也要关注版本和安全配置。越是能把文本解释成复杂结构的插件,越应该把它当成 一个独立的安全边界处理。
流式输出(SSE/Streamable HTTP)
AI 聊天应用要有“正在回答”的感觉,核心是流式输出。服务端不是等模型完整生成后一次性返回,而是生成一点就推一点给前端。
常见方案有三类:
普通 AI 聊天里,SSE 已经能覆盖大部分需求。
SSE 基础
SSE 的响应头一般是:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
服务端推送的数据格式类似:
event: message
data: {"content":"你好"}
event: message
data: {"content":",我是 AI 助手"}
event: done
data: {}
前端使用 EventSource:
const source = new EventSource('/api/chat/stream?conversationId=1');
source.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
appendContent(data.content);
});
source.addEventListener('done', () => {
source.close();
});
source.onerror = () => {
source.close();
};
不过 EventSource 有一个明显限制:它只能发 GET 请求,不能直接传复杂请求体。如果要传很长的 prompt、上下文、文件信息,常见做法有两种:
- 先用 POST 创建任务,返回
conversationId 或 taskId,再用 SSE 订阅结果。
- 不使用
EventSource,改用 fetch + ReadableStream 自己读取流。
fetch 读取流
fetch 更灵活,可以发送 POST 请求,也可以带复杂 body:
const response = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
messages,
}),
});
if (!response.body) {
throw new Error('当前浏览器不支持 ReadableStream');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const text = line.replace(/^data:\s?/, '');
if (text === '[DONE]') {
break;
}
const data = JSON.parse(text);
appendContent(data.content || '');
}
}
这里要注意“粘包”和“半包”。网络层返回的 chunk 不一定刚好等于服务端发送的一条 data,所以需要 buffer 暂存未完成的一行。
Streamable HTTP
Streamable HTTP 可以理解为一种更现代的“HTTP 上的流式消息”思路。它仍然利用 HTTP,但不局限于传统 SSE 的 GET 订阅模型,通常可以结合 POST 请求、会话标识和可恢复的流式响应来做更复杂的通信。
在前端实现上,它通常还是落到两类 API:
- 如果服务端返回
text/event-stream,前端按 SSE 事件格式解析。
- 如果服务端返回普通二进制/文本流,前端用
ReadableStream 逐块读取。
因此前端真正要抽象的是“流式解析器”,而不是把业务代码绑定死在某一个协议上。
type StreamEvent =
| { type: 'message'; content: string }
| { type: 'error'; message: string }
| { type: 'done' };
async function* readChatStream(response: Response): AsyncGenerator<StreamEvent> {
if (!response.body) {
yield { type: 'error', message: 'ReadableStream 不可用' };
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split('\n\n');
buffer = events.pop() || '';
for (const event of events) {
const dataLine = event
.split('\n')
.find((line) => line.startsWith('data:'));
if (!dataLine) continue;
const data = dataLine.replace(/^data:\s?/, '');
if (data === '[DONE]') {
yield { type: 'done' };
return;
}
const json = JSON.parse(data);
yield { type: 'message', content: json.content || '' };
}
}
yield { type: 'done' };
}
这样 UI 层只关心 message/error/done,底层是 SSE、Streamable HTTP 还是其他实现,都可以在适配层里处理。