如何在前端实现大模型流式输出的打字机效果?

来源:Vuejs社区作者:美园和花头衔:网络博主
导读:本期聚焦于美园和花创作的《如何在前端实现大模型流式输出的打字机效果?》,敬请观看详情。调用大模型API时,如果等完整响应返回再一次性渲染,首字延迟往往超过数秒,体验很像卡死。要让回答像ChatGPT一样逐字出现,前端需要处理流式响应、增量文本和光标动画三个核心环节。本文从零梳理浏览器端读取SSE流、使用fetch的ReadableStream解析分块数据、按字符或小片段追加DOM的实现思路,同时给出防抖更新、自动滚动和中文截断处理等细节。还会说明为什么直接按字节切分会把UTF-8多字节字符拆坏,以及打字机效果与Markdown渲染如何分阶段共存。读完可以落地一个响应快速、观感自然的对话界面。

大模型的HTTP接口通常默认返回完整JSON,但当输出长度超过几百token时,首字等待时间会长到难以接受。流式输出(Streaming)让服务端边生成边返回,前端收到分片后立即展示,从而把可感知的首字延迟降到几十毫秒。实现打字机效果本质上是把网络层的分批数据转换成DOM中逐步增长的文本,并控制渲染节奏。

如何在前端实现大模型流式输出的打字机效果?

一、流式输出的基础:选择合适的传输方式

大模型流式接口通常有两种:Server-Sent Events(SSE)和HTTP分块传输。SSE是浏览器原生EventSource支持,但只支持GET且无法自定义请求头;fetch的ReadableStream可以处理POST请求并读取分块数据,更适合带Authorization的API。这里以fetch读取流为例。

调用时需要在请求体中开启stream参数。以OpenAI兼容接口为例,请求体中加入 stream: true,响应头的Content-Type通常是text/event-stream。拿到response后,用 response.body.getReader() 获取读取器,循环读取Uint8Array,再用TextDecoder按UTF-8解码。

async function* streamChat(messages) {
  const res = await fetch('/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ messages, stream: true })
  });
  if (!res.ok || !res.body) throw new Error('请求失败');
  const reader = res.body.getReader();
  const decoder = new TextDecoder('utf-8');
  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 data = line.slice(5).trim();
      if (data === '[DONE]') return;
      try {
        const json = JSON.parse(data);
        const delta = json.choices?.[0]?.delta?.content ?? '';
        if (delta) yield delta;
      } catch (e) {
        // 忽略半包JSON,等待下一个分片
      }
    }
  }
}

二、打字机效果的核心实现

获得增量文本后,直接追加到DOM并不是严格意义的打字机效果,因为网络分块粒度不可控,可能一次吐出几十个字符。要实现逐字显示,需要在前端维护一个显示队列和一个定时器,以固定速度消费队列。

设计上可以把服务端增量写入一个待渲染字符串,渲染循环每16毫秒或每30毫秒取出一到两个字符,追加到DOM节点。这样无论底层数据到达多快,视觉上都会保持平滑稳定。使用 requestAnimationFramesetInterval 都可以,后者更容易控制速度。

class Typewriter {
  constructor(el, speed = 20) {
    this.el = el;
    this.speed = speed;
    this.queue = '';
    this.timer = null;
  }

  push(text) {
    this.queue += text;
    if (!this.timer) this.start();
  }

  start() {
    this.timer = setInterval(() => {
      if (!this.queue) {
        clearInterval(this.timer);
        this.timer = null;
        return;
      }
      const step = Math.min(this.queue.length, 2);
      const chunk = this.queue.slice(0, step);
      this.queue = this.queue.slice(step);
      this.el.textContent += chunk;
    }, this.speed);
  }
}

这个实现简单但有效:push 方法把新内容放入队列,定时器每20毫秒从队列头部取两个字符写入DOM。速度参数可根据产品需要调整,通常20到40毫秒比较自然。若要更强的真实感,可以加入随机抖动,让每次取字符数在1到3之间变化,避免机械节奏。

注意这里直接修改 textContent,适合纯文本阶段。如果一边打字机一边渲染Markdown,会因为Markdown不完整导致闪烁或解析错误,这部分在后面单独说明。还需要考虑停止条件:当服务端结束且队列清空时,要触发完成回调,以便恢复光标、显示操作按钮等。

三、处理UTF-8多字节字符和增量边界

流式传输中一个非常容易忽视的问题是UTF-8编码边界。汉字、emoji等字符在UTF-8中占3到4个字节,如果网络分片恰好把某个字符的字节序列切开,用 TextDecoder 一次性解码可能会得到乱码替换符。好在 TextDecoder 支持 { stream: true } 选项,会把不完整字节保留在内部,待下一块数据到达后再拼接解码。

但即便解码正确,逐字符切片时也要注意Unicode代理对。例如emoji在JavaScript字符串中由两个UTF-16码元组成,如果按 slice(0,1) 切到代理对的一半,DOM中会显示为乱码。更稳妥的做法是用 Array.from(queue) 把它按码点拆分,或者使用 for...of 迭代字符,再按字符数量取出。

function takeChars(str, count) {
  const chars = Array.from(str);
  return {
    chunk: chars.slice(0, count).join(''),
    rest: chars.slice(count).join('')
  };
}

// 在渲染循环中使用
const { chunk, rest } = takeChars(this.queue, step);
this.queue = rest;
this.el.textContent += chunk;

这样即使遇到emoji或组合字符,也不会从中间截断。另一个可选方案是使用 Intl.Segmenter 按字形簇分割,但兼容性和性能需要评估。对于中文聊天场景,按码点处理已经足够。

增量边界还体现在JSON解析上。SSE数据行按换行分隔,但负载JSON可能被截断,所以解析失败时不能中断循环,要把半包数据留在缓冲区,和下一个分片拼接后再试。前面代码中的 buffer 变量就是为这个目的保留的。

四、与Markdown渲染、自动滚动和性能结合

聊天产品中,模型回答往往包含代码块、列表、标题等Markdown语法。如果在打字机过程中实时解析Markdown,会频繁出现未闭合的代码块导致页面跳动。推荐采用两阶段策略:输出过程中先以纯文本方式展示打字机效果,待模型输出结束后,再将完整文本一次性交给Markdown渲染器,最后替换DOM内容。

如果一定要在输出过程中保留Markdown格式,可以降低渲染频率,例如每200毫秒才把当前文本交给渲染器,同时给代码块设置最小高度,减少跳动。但体验上不如完成后渲染稳定。自动滚动方面,在每次追加字符或渲染更新后,将容器滚动到底部:container.scrollTop = container.scrollHeight。如果用户手动向上翻阅,可以暂停自动滚动,避免打断阅读。

function appendAndScroll(text) {
  outputEl.textContent += text;
  if (shouldAutoScroll) {
    chatContainer.scrollTop = chatContainer.scrollHeight;
  }
}

chatContainer.addEventListener('scroll', () => {
  const nearBottom =
    chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight < 40;
  shouldAutoScroll = nearBottom;
});

性能方面,频繁修改 textContent 会触发浏览器重排,但每20毫秒修改一次文本节点在现代浏览器中开销很小。为了更优,可以使用 DocumentFragment 或把文本拆成多个span,但收益有限。更需要注意的是不要在每次追加时都重新解析整个Markdown或调用高亮库,这会成倍增加CPU消耗。可以在完成后统一高亮,或者使用Web Worker隔离。

最后,光标闪烁动画可以放在输出容器末尾,用一个span元素模拟,通过CSS动画控制透明度。在流输出结束后隐藏光标,或替换为静态提示。这样整体效果就接近ChatGPT等产品的逐字输出体验。

大模型流式输出打字机效果前端实现修改时间:2026-08-29 01:39:49

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。