AI 聊天应用中涉及到的前端技术

从 2022 年 ChatGPT 问世到现在,AI 聊天类应用已经变成了很常见的产品形态。自己公司也有相关应用,平时在需求和排查问题中陆续接触过一些细节,正好抽空做一次总结。

AI 聊天应用看起来只是“输入问题、等待回答”,但前端实际要处理的事情不少:markdown 渲染为 HTML、文本流式输出、自定义输入框、会话分享,以及流式输出时的打字机效果。本文重点会放在 Markdown 解析这一部分,因为它最容易从“能显示”演进到“要安全、要扩展、要性能”。

预览
AI 聊天应用前端链路示意图

整体链路

一个比较典型的 AI 聊天消息链路大概如下:

Markdown 渲染
接收增量文本
自定义输入框
请求模型服务
会话归档
分享长图/PDF/Word
打字机效果
用户输入
衍生能力
重新生成

这里面每一层都可以做得很深,但如果只是从业务开发角度出发,可以先抓住几个核心原则:

  1. 流式输出负责“快看到”,不要等服务端完整生成后再展示。
  2. Markdown 渲染负责“看得懂”,尤其是代码、表格、列表这类结构化内容。
  3. 安全过滤负责“别出事”,AI 生成内容本质上也是外部输入,不能直接信任。
  4. 输入框负责“能表达”,用户的输入不只有一行文本,后续往往会扩展到附件、快捷操作和上下文引用。
  5. 分享导出负责“可传播”,聊天内容如果有沉淀价值,最好能变成图片、PDF 或文档。

Markdown 解析(markdown-it)

markdown 对技术开发来说应该都比较熟悉了。AI 聊天应用里更是离不开它,因为模型很喜欢用 Markdown 来组织回答,例如:

  • 用标题分层说明问题
  • 用列表拆解步骤
  • 用表格对比方案
  • 用代码块输出示例
  • 用引用块补充注意事项

如果没有 Markdown 渲染,AI 的回答会变成一大段纯文本,可读性会差很多。前端比较常见的解析库有 markedmarkdown-itremark/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'],
  });
}

上面这段代码里有两个关键点:

  1. 代码高亮时,如果语言不存在,要走 escapeHtml,不能直接拼接原始代码。
  2. 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 都全量解析,容易带来三个问题:

  1. 性能压力:长回答每来一个字符就解析整段 Markdown,会造成重复计算。
  2. 渲染闪烁:不完整语法可能反复改变 DOM 结构。
  3. 滚动抖动:每次重新渲染都会影响消息高度。

比较稳的做法是“数据层实时拼接,视图层节流渲染”:

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 生成内容经常会被误以为“不是用户输入”,但它本质上仍然是不可信内容。只要最终进入 dangerouslySetInnerHTMLinnerHTML,都要考虑 XSS。

建议至少做这些限制:

  1. 默认关闭 Markdown 原始 HTML。
  2. 渲染后的 HTML 用 DOMPurify 等库清洗。
  3. 图片链接做协议白名单,只允许 httphttps 或业务允许的资源域名。
  4. 链接统一加 rel="noopener noreferrer"
  5. 代码块内容只当文本显示,不执行。
  6. Mermaid、公式、HTML 预览等增强能力单独做开关,不要默认对所有消息开放。

尤其是 Mermaid 这类“看起来只是图”的能力,也要关注版本和安全配置。越是能把文本解释成复杂结构的插件,越应该把它当成一个独立的安全边界处理。

流式输出(SSE/Streamable HTTP)

AI 聊天应用要有“正在回答”的感觉,核心是流式输出。服务端不是等模型完整生成后一次性返回,而是生成一点就推一点给前端。

常见方案有三类:

方案特点适合场景
SSE浏览器原生支持 EventSource,服务端单向推送聊天回答、状态进度
Streamable HTTP基于普通 HTTP 请求/响应做流式传输新一些的协议或需要更灵活的请求体场景
WebSocket双向长连接多人协作、实时控制、复杂双向通信

普通 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、上下文、文件信息,常见做法有两种:

  1. 先用 POST 创建任务,返回 conversationIdtaskId,再用 SSE 订阅结果。
  2. 不使用 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 还是其他实现,都可以在适配层里处理。

中断、重试和状态

聊天流式输出还有几个容易遗漏的状态:

  1. 用户主动停止生成:使用 AbortController 中断请求。
  2. 网络异常:标记当前消息为失败,允许重试。
  3. 服务端错误:模型限流、鉴权失败、内容安全拦截等,要显示明确状态。
  4. 重复提交:发送中禁用按钮,或者允许并发但要按消息 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.filesclipboardData.items,走上传流程,不建议把图片直接转成 base64 塞进编辑器内容里。

选区和结构化节点

一旦做 @、文件卡片、变量标签,就会涉及选区保存和恢复。思路一般是:

  1. 用户输入 @,记录当前 Range。
  2. 弹出候选面板。
  3. 用户选择文件或成员。
  4. 恢复 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-imagehtml2canvas
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();
}

这里最容易踩坑的是图片跨域和字体加载:

  1. 页面里的远程图片要允许跨域访问,否则 Canvas 会被污染。
  2. 导出前要等待图片加载完成。
  3. 自定义字体最好内联或提前加载完成。
  4. 长图太长时,移动端容易内存不足,可以分页导出。

实际项目里,分享长图不要直接截当前聊天窗口。当前窗口里有滚动条、按钮、输入框、hover 状态,导出来通常不好看。建议单独做一个“分享视图”,只保留头像、昵称、时间、问题、回答和必要的水印。

导出 PDF

PDF 有两条路线:

  1. 前端路线:DOM 转 Canvas,再用 jsPDF 生成 PDF。
  2. 浏览器/服务端路线:使用打印样式或服务端渲染生成 PDF。

前端路线实现快,但对长内容、分页、字体、表格支持一般。对于正式报告,服务端生成会更稳定。

如果只是简单导出,可以用浏览器打印:

@media print {
  .chat-input,
  .chat-toolbar {
    display: none;
  }

  .chat-message {
    break-inside: avoid;
  }
}

然后调用:

window.print();

这个方案看起来朴素,但在很多内部系统里反而最稳定,因为分页、字体和打印预览都交给浏览器处理了。

导出 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 聊天里的打字机效果,本质上是“不要一下子把内容全部刷出来,而是让用户感觉内容正在自然出现”。常见实现有两类:

  1. 真实流式输出:服务端真的一个 chunk 一个 chunk 返回,前端收到就展示。
  2. 前端模拟打字:服务端已经返回完整内容,前端再按字符或词逐步显示。

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 聊天的核心体验是“快”和“稳定”,动画只是辅助。

几个经验:

  1. 第一屏响应要快,最好先出现 loading 或光标状态。
  2. 不要为了动画故意延迟真实内容。
  3. 长代码块可以直接展示,不一定逐字输出。
  4. Markdown 最终渲染完成后,尽量减少布局跳动。
  5. 移动端低端机上要控制重排频率。

如果回答里包含很长的代码,逐字符打字反而会让人着急。比较好的体验是普通段落保持轻微打字效果,代码块可以按行或按块出现。

其他细节

除了上面这些主线,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 回答越长,前端越要注意性能:

  1. Markdown 渲染做节流,不要每个 token 都完整渲染。
  2. 长会话使用虚拟列表或分段渲染。
  3. 代码高亮放到最终完成后执行,或者放到 Web Worker。
  4. 分享导出使用独立视图,避免截取整页复杂 DOM。
  5. 会话列表和消息详情分开缓存,避免切换会话时重复解析所有内容。

在很多聊天应用里,真正卡住页面的不是接口,而是长内容反复 Markdown 解析、代码高亮和 DOM 更新。

总结

AI 聊天应用的前端技术看起来分散,但本质上围绕一条链路展开:

  1. 用户通过输入框表达意图。
  2. 前端把请求发送给模型服务。
  3. 服务端以流式方式返回内容。
  4. 前端增量展示,并在合适时机解析 Markdown。
  5. 用户对回答进行复制、导出、分享或继续追问。

如果只做一个 Demo,textarea + fetch + innerText 就能跑起来。但要做成一个稳定可用的产品,就要认真处理 Markdown 安全、流式协议、输入框复杂交互、导出边界和性能问题。

我自己的实践感受是:AI 聊天前端的难点不在“把文字显示出来”,而在“持续显示复杂内容时,仍然安全、稳定、可控”。

参考资料