AI 聊天应用中涉及到的前端技术
从 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 | 禁止用户或模型直接输出 HTML,降低 XSS 风险 |
linkify | 可以开启 | 自动识别链接 |
breaks | 聊天场景可以开启 | 单个换行也渲染为 <br>,更接近聊天文本习惯 |
typographer | 可选 | 替换部分排版符号,中文场景感知不强 |
开启 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 聊天应用要有“正在回答”的感觉,核心是流式输出。服务端不是等模型完整生成后一次性返回,而是生成一点就推一点给前端。
常见方案有三类:
| 方案 | 特点 | 适合场景 |
|---|
| SSE | 浏览器原生支持 EventSource,服务端单向推送 | 聊天回答、状态进度 |
| Streamable HTTP | 基于普通 HTTP 请求/响应做流式传输 | 新一些的协议或需要更灵活的请求体场景 |
| WebSocket | 双向长连接 | 多人协作、实时控制、复杂双向通信 |
普通 AI 聊天里,SSE 已经能覆盖大部分需求。
SSE 基础
SSE 的响应头一般是:
服务端推送的数据格式类似:
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 还是其他实现,都可以在适配层里处理。
中断、重试和状态
聊天流式输出还有几个容易遗漏的状态:
- 用户主动停止生成:使用
AbortController 中断请求。
- 网络异常:标记当前消息为失败,允许重试。
- 服务端错误:模型限流、鉴权失败、内容安全拦截等,要显示明确状态。
- 重复提交:发送中禁用按钮,或者允许并发但要按消息 ID 管理。
const controller = new AbortController();
async function sendMessage() {
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ messages }),
signal: controller.signal,
});
for await (const event of readChatStream(response)) {
if (event.type === 'message') {
appendContent(event.content);
}
if (event.type === 'done') {
break;
}
}
}
function stopGenerate() {
controller.abort();
}
从产品体验上看,“停止生成”非常重要,因为模型回答可能跑偏,用户需要随时拿回控制权。
输入框(contenteditable)
普通表单输入用 textarea 就够了,但 AI 聊天应用很容易遇到这些需求:
- 输入框高度自适应
Enter 发送,Shift + Enter 换行
- 粘贴图片或富文本
@ 引用文件、知识库或成员
- 插入附件卡片
- 输入区域内展示变量、标签或提示块
这时候很多团队会选择 contenteditable。
textarea 还是 contenteditable
两者没有绝对好坏,主要看需求复杂度:
| 方案 | 优点 | 缺点 |
|---|
textarea | 简单、稳定、可访问性好、表单行为天然 | 很难在文本中插入复杂节点 |
contenteditable | 可以混排文本、标签、附件、引用块 | 选区、粘贴、输入法、兼容性更复杂 |
如果只是一个普通聊天框,优先用 textarea。只有当输入区需要承载复杂结构时,再上 contenteditable,不然维护成本会明显增加。
基础结构
一个简单的 contenteditable 输入框可以这样写:
function ChatEditor({ onSubmit }) {
const editorRef = useRef(null);
function handleKeyDown(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
const text = editorRef.current?.innerText.trim() || '';
if (text) {
onSubmit(text);
editorRef.current.innerHTML = '';
}
}
}
return (
<div
ref={editorRef}
className="chat-editor"
contentEditable
role="textbox"
aria-multiline="true"
data-placeholder="请输入问题"
onKeyDown={handleKeyDown}
/>
);
}
对应的 placeholder 可以通过 CSS 实现:
.chat-editor:empty::before {
color: #999;
content: attr(data-placeholder);
pointer-events: none;
}
粘贴处理
contenteditable 最大的问题之一是粘贴。用户从网页、Word、飞书文档里复制内容时,剪贴板里可能带有大量 HTML 样式。如果直接放进编辑器,很容易污染 DOM,甚至带来安全风险。
普通聊天输入框建议默认只保留纯文本:
function handlePaste(event) {
event.preventDefault();
const text = event.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
}
document.execCommand 虽然属于比较老的 API,但在插入纯文本这类场景里仍然很常见。更现代的做法是直接操作 Selection 和 Range,不过代码会更长。
如果业务需要粘贴图片,可以单独读取 clipboardData.files 或 clipboardData.items,走上传流程,不建议把图片直接转成 base64 塞进编辑器内容里。
选区和结构化节点
一旦做 @、文件卡片、变量标签,就会涉及选区保存和恢复。思路一般是:
- 用户输入
@,记录当前 Range。
- 弹出候选面板。
- 用户选择文件或成员。
- 恢复 Range,插入一个不可编辑节点。
function insertMention(range, mention) {
const node = document.createElement('span');
node.className = 'mention';
node.contentEditable = 'false';
node.dataset.id = mention.id;
node.textContent = `@${mention.name}`;
range.deleteContents();
range.insertNode(node);
range.setStartAfter(node);
range.collapse(true);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
这类节点提交给后端时,不要只拿 innerText,否则会丢失结构信息。比较稳的方式是维护一份结构化数据:
type InputNode =
| { type: 'text'; text: string }
| { type: 'mention'; id: string; name: string }
| { type: 'file'; fileId: string; name: string };
也就是说,DOM 更像是编辑器的视图,真正提交的数据应该有自己的结构。
分享会话(Canvas/PDF/Word)
AI 聊天内容经常有分享需求,比如把某次问答整理成长图、导出 PDF、生成 Word 文档。不同格式适合的场景不一样。
| 导出格式 | 适合场景 | 常见方案 |
|---|
| 长图 | 社交分享、移动端传播 | html-to-image、html2canvas |
| PDF | 归档、打印、正式交付 | 浏览器打印、jsPDF、服务端生成 |
| Word | 二次编辑、报告交付 | docx、服务端模板 |
导出长图
长图导出的基本思路是:先把会话渲染成一个适合分享的 DOM,再把 DOM 转成图片。
import { toPng } from 'html-to-image';
async function exportImage(node) {
const dataUrl = await toPng(node, {
cacheBust: true,
pixelRatio: 2,
backgroundColor: '#ffffff',
});
const link = document.createElement('a');
link.download = 'chat-share.png';
link.href = dataUrl;
link.click();
}
这里最容易踩坑的是图片跨域和字体加载:
- 页面里的远程图片要允许跨域访问,否则 Canvas 会被污染。
- 导出前要等待图片加载完成。
- 自定义字体最好内联或提前加载完成。
- 长图太长时,移动端容易内存不足,可以分页导出。
实际项目里,分享长图不要直接截当前聊天窗口。当前窗口里有滚动条、按钮、输入框、hover 状态,导出来通常不好看。建议单独做一个“分享视图”,只保留头像、昵称、时间、问题、回答和必要的水印。
导出 PDF
PDF 有两条路线:
- 前端路线:DOM 转 Canvas,再用
jsPDF 生成 PDF。
- 浏览器/服务端路线:使用打印样式或服务端渲染生成 PDF。
前端路线实现快,但对长内容、分页、字体、表格支持一般。对于正式报告,服务端生成会更稳定。
如果只是简单导出,可以用浏览器打印:
@media print {
.chat-input,
.chat-toolbar {
display: none;
}
.chat-message {
break-inside: avoid;
}
}
然后调用:
这个方案看起来朴素,但在很多内部系统里反而最稳定,因为分页、字体和打印预览都交给浏览器处理了。
导出 Word
Word 的关键不是“截图”,而是把内容转换成文档结构,例如标题、段落、列表、表格、代码块。
import { Document, Packer, Paragraph, TextRun } from 'docx';
async function exportDocx(messages) {
const doc = new Document({
sections: [
{
children: messages.map((message) => {
return new Paragraph({
children: [
new TextRun({
text: `${message.role}: ${message.text}`,
}),
],
});
}),
},
],
});
const blob = await Packer.toBlob(doc);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'chat.docx';
link.click();
URL.revokeObjectURL(url);
}
如果回答里有复杂 Markdown,最好先把 Markdown 转成中间结构,再分别渲染到 HTML、PDF、Word。不要试图把一份 HTML 直接“万能导出”到所有格式,最后很容易在表格、代码块和图片上失控。
打字机效果(Typed.js)
AI 聊天里的打字机效果,本质上是“不要一下子把内容全部刷出来,而是让用户感觉内容正在自然出现”。常见实现有两类:
- 真实流式输出:服务端真的一个 chunk 一个 chunk 返回,前端收到就展示。
- 前端模拟打字:服务端已经返回完整内容,前端再按字符或词逐步显示。
Typed.js 更适合第二类,也就是静态文案的打字动画,比如首页 slogan、介绍页标题。如果是 AI 聊天正文,我更建议自己实现一个小的输出队列,因为它需要和流式请求、Markdown 渲染、停止生成、滚动位置等状态配合。
简单队列
一个比较简单的实现思路:
const queue = [];
let typing = false;
function pushChunk(chunk) {
queue.push(...chunk);
if (!typing) {
typing = true;
requestAnimationFrame(typeNext);
}
}
function typeNext() {
const chars = queue.splice(0, 3).join('');
if (chars) {
appendContent(chars);
}
if (queue.length > 0) {
requestAnimationFrame(typeNext);
} else {
typing = false;
}
}
这里每一帧输出 3 个字符,只是示例。真实项目里可以根据内容长度和设备性能动态调整:
- 内容少时慢一点,看起来更自然。
- 内容很多时快一点,避免用户等动画。
- 用户滚动到历史消息时,可以暂停自动滚动。
- 用户点击“停止生成”时,要同时停止请求和清空输出队列。
不要过度动画
打字机效果并不是越强越好。AI 聊天的核心体验是“快”和“稳定”,动画只是辅助。
几个经验:
- 第一屏响应要快,最好先出现 loading 或光标状态。
- 不要为了动画故意延迟真实内容。
- 长代码块可以直接展示,不一定逐字输出。
- Markdown 最终渲染完成后,尽量减少布局跳动。
- 移动端低端机上要控制重排频率。
如果回答里包含很长的代码,逐字符打字反而会让人着急。比较好的体验是普通段落保持轻微打字效果,代码块可以按行或按块出现。
其他细节
除了上面这些主线,AI 聊天应用里还有一些前端细节也值得单独关注。
滚动控制
流式输出时通常要自动滚到底部,但如果用户手动往上翻历史消息,就不应该强行把他拉回来。
function isNearBottom(container) {
return (
container.scrollHeight -
container.scrollTop -
container.clientHeight <
80
);
}
function appendContent(content) {
const shouldStick = isNearBottom(messageList);
updateMessage(content);
if (shouldStick) {
messageList.scrollTop = messageList.scrollHeight;
}
}
这个“是否接近底部”的判断能明显减少阅读被打断的感觉。
消息状态
每条消息最好有明确状态,而不是只存一个字符串:
type MessageStatus = 'pending' | 'streaming' | 'success' | 'error' | 'aborted';
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
rawContent: string;
html?: string;
status: MessageStatus;
errorMessage?: string;
createdAt: number;
}
这样 UI 才能准确展示“生成中、已中断、失败重试、已完成”等状态,也方便后续做会话持久化。
性能优化
AI 回答越长,前端越要注意性能:
- Markdown 渲染做节流,不要每个 token 都完整渲染。
- 长会话使用虚拟列表或分段渲染。
- 代码高亮放到最终完成后执行,或者放到 Web Worker。
- 分享导出使用独立视图,避免截取整页复杂 DOM。
- 会话列表和消息详情分开缓存,避免切换会话时重复解析所有内容。
在很多聊天应用里,真正卡住页面的不是接口,而是长内容反复 Markdown 解析、代码高亮和 DOM 更新。
总结
AI 聊天应用的前端技术看起来分散,但本质上围绕一条链路展开:
- 用户通过输入框表达意图。
- 前端把请求发送给模型服务。
- 服务端以流式方式返回内容。
- 前端增量展示,并在合适时机解析 Markdown。
- 用户对回答进行复制、导出、分享或继续追问。
如果只做一个 Demo,textarea + fetch + innerText 就能跑起来。但要做成一个稳定可用的产品,就要认真处理 Markdown 安全、流式协议、输入框复杂交互、导出边界和性能问题。
我自己的实践感受是:AI 聊天前端的难点不在“把文字显示出来”,而在“持续显示复杂内容时,仍然安全、稳定、可控”。
参考资料