1. 浏览器里做 AI 打字机,为什么你写的流式渲染总是一卡一卡
先说清楚我们要解决的是什么问题。AI 打字机效果,指的是大模型返回内容时,前端不是等整段文字生成完再一次性显示,而是像有人坐在屏幕前一个字一个字敲出来那样,边接收边渲染。它适合所有做 AI 对话、AI 写作、代码助手类产品的同学,尤其是前端工程师和全栈开发者。
听起来简单,真做起来坑不少。我见过太多项目,接口明明已经用了 SSE 或者 fetch 流式,页面上却还是「憋一大段突然蹦出来」,或者「字是一个个出,但滚动条抖得像地震」。核心原因通常有三个:第一,没搞清楚 ReadableStream 的分块边界,把半截 UTF-8 字节当成完整字符解码;第二,每收到一个 chunk 就 setState 一次,React 高频重渲染直接把主线程打满;第三,长文本不断追加 DOM,浏览器布局和重绘成本随字数线性上涨。
这三个问题叠在一起,表现就是首字延迟高、帧率掉到 20 以下、滚动卡顿。而它们跟模型本身关系不大,纯粹是浏览器端的接收与渲染策略问题。所以这篇不讲模型选型,只讲怎么把「流」接稳、把「字」打顺、把「滚」做滑。
在动手之前,你需要一个能稳定输出流式响应的 API 通道。多模型切换时,如果每个模型一套 Key、一套鉴权、一套 base_url,前端光维护配置就够呛。我这边统一走 TaoToken 的 Key 通道,一个 Key 打通多家模型的流式接口,前端只需要认一个 base_url 和一种响应格式,省掉大量适配代码。下面所有示例都基于这个前提来写。
2. TaoToken 统一 Key 通道:一个 base_url 接多家流式模型
2.1 为什么流式场景更需要统一通道
普通请求你还能容忍每个模型写一套调用逻辑,但流式渲染对响应格式的敏感度高得多。不同厂商的 SSE 事件名、data 结构、结束标记都不一样,前端解析层会被撕成好几份。TaoToken 的做法是把这些差异收敛到服务端,对外暴露 OpenAI 兼容的/v1/chat/completions流式接口,前端拿到的 chunk 结构一致,解析逻辑只写一遍。
对打字机效果来说,这意味着你的 ReadableStream 处理函数、逐字渲染队列、结束判断全都可以复用,换模型不用改渲染层。这是它最实际的价值。
2.2 拿到 Key 和接入地址
进入控制台创建 API Key,地址是 https://taotoken.net/api-keys ,登录后新建一个 Key 并复制保存。注意 Key 只在创建时完整显示一次,丢了只能重建。
接入用的 base_url 是https://taotoken.net/api,注意这个地址不带任何查询参数,直接作为 OpenAI SDK 或 fetch 的根路径。模型 ID 按你实际要用的填,比如gpt-4o、claude-3-5-sonnet这类,具体以控制台模型列表为准。
如果你更习惯用现成的编码工具,TaoToken 也提供了 Coding Plan 形态,地址 https://taotoken.net/coding-plan ,适合长期做 Agent 和代码生成的同学,这里不展开,重点还是浏览器端的流式渲染。
2.3 三件套配置对照
不管你是用原生 fetch 还是 SDK,接入信息永远是这三样,缺一不可:
| 配置项 | 值 | 说明 |
|---|---|---|
| Base URL | https://taotoken.net/api | 所有请求的根路径 |
| API Key | 控制台创建 | 放在 Authorization 头 |
| Model ID | 如gpt-4o | 按控制台列表填 |
把这三样记牢,后面所有代码都围绕它们展开。如果你用的是 Cline、CC Switch 这类工具,配置项名称可能叫 Base URL / API Key / Model,本质一样。
3. 可复制的 fetch 流式读取配置与逐帧渲染片段
3.1 用 fetch 接 ReadableStream
浏览器端推荐直接用 fetch,因为response.body就是一个 ReadableStream,比 EventSource 灵活,能带 POST body 和自定义头。下面这段是可直接复制的流式读取骨架:
async function streamChat({ messages, model = "gpt-4o", onDelta, onDone }) { const resp = await fetch("https://taotoken.net/api/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}`, }, body: JSON.stringify({ model, messages, stream: true, }), }); if (!resp.ok || !resp.body) { throw new Error(`stream failed: ${resp.status}`); } const reader = resp.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) { const trimmed = line.trim(); if (!trimmed.startsWith("data:")) continue; const payload = trimmed.slice(5).trim(); if (payload === "[DONE]") { onDone && onDone(); return; } try { const json = JSON.parse(payload); const delta = json.choices?.[0]?.delta?.content; if (delta) onDelta(delta); } catch (e) { // 半截 JSON 留给下一轮 buffer 处理 } } } }这里有两个关键点。第一,decoder.decode(value, { stream: true })的stream: true必须加,否则一个多字节汉字被拆到两个 chunk 时会解码成乱码。第二,buffer要保留最后一段不完整的行,因为网络分块不保证按行切,lines.pop()把可能残缺的尾部留到下一轮拼接。
3.2 用 requestAnimationFrame 控制逐字节奏
收到 delta 后不要立刻渲染。如果每个 chunk 都触发一次状态更新,模型吐字快的时候一秒能来几十次,React 直接过载。正确做法是把 delta 推进一个队列,用 requestAnimationFrame 按帧消费:
class Typewriter { constructor(render, charsPerFrame = 2) { this.queue = ""; this.render = render; this.charsPerFrame = charsPerFrame; this.rafId = null; this.running = false; } push(text) { this.queue += text; if (!this.running) this.start(); } start() { this.running = true; const tick = () => { if (this.queue.length === 0) { this.running = false; this.rafId = null; return; } const take = this.queue.slice(0, this.charsPerFrame); this.queue = this.queue.slice(this.charsPerFrame); this.render(take); this.rafId = requestAnimationFrame(tick); }; this.rafId = requestAnimationFrame(tick); } stop() { if (this.rafId) cancelAnimationFrame(this.rafId); this.running = false; } }charsPerFrame是节奏旋钮。设成 1 就是标准打字机,设成 3 到 5 适合长文快速铺开。因为消费发生在 rAF 回调里,渲染频率天然被锁在屏幕刷新率(通常 60fps),不会因为网络快慢而抖动。模型吐得慢时队列空,rAF 自动停;吐得快时队列积压,每帧稳定消费固定字数,视觉上就是匀速打字。
3.3 长文本滚动性能优化
字数上千后,每帧往 DOM 追加文本会触发整段重排。三个优化手段按优先级来:
第一,渲染容器用white-space: pre-wrap加固定宽度,避免每加一个字就重新计算换行导致整段回流。第二,把已输出内容拆成「稳定段 + 活动段」,稳定段用content-visibility: auto让浏览器跳过屏外渲染。第三,滚动跟随不要每帧scrollTop = scrollHeight,改成节流到每 100ms 一次,或者只在用户没有手动上滑时才自动跟随。
let lastScroll = 0; function followScroll(el) { const now = performance.now(); if (now - lastScroll < 100) return; lastScroll = now; el.scrollTop = el.scrollHeight; }这套组合下来,实测万字长文的渲染帧率能稳在 55 以上,滚动不再抖。
4. 验证请求:首字延迟与帧率怎么测
4.1 首字延迟测量
首字延迟(TTFB 到第一个字符渲染)是打字机体验的核心指标。在 fetch 发出前打一个时间戳,在第一次onDelta触发时再打一个,差值就是首字延迟:
const t0 = performance.now(); let firstCharAt = null; await streamChat({ messages: [{ role: "user", content: "写一段 200 字的介绍" }], onDelta: (d) => { if (firstCharAt === null) { firstCharAt = performance.now(); console.log("首字延迟:", (firstCharAt - t0).toFixed(0), "ms"); } typewriter.push(d); }, });正常网络下,走统一通道的首字延迟通常在 300 到 800ms 之间,取决于模型和负载。如果超过 2 秒,先排查是不是没开 stream,或者代理层把响应缓冲了。
4.2 帧率测量
用 requestAnimationFrame 的间隔反推帧率,在打字过程中采样:
let frames = 0; let start = performance.now(); function fpsProbe() { frames++; const now = performance.now(); if (now - start >= 1000) { console.log("FPS:", frames); frames = 0; start = now; } requestAnimationFrame(fpsProbe); } fpsProbe();打字过程中如果 FPS 掉到 30 以下,基本可以确定是渲染层的问题,回到第 3 节的 rAF 队列和滚动优化去查。
4.3 成功结果长什么样
一次正常的流式请求,你在 Network 面板里应该看到响应类型是text/event-stream,Transfer-Encoding 是 chunked,内容一行行data: {...}往外冒。页面上文字匀速出现,滚动平滑跟随,控制台打印的首字延迟和 FPS 都在合理区间。如果 Network 里响应是一次性返回的,说明 stream 参数没生效或者被中间层缓冲了。
5. 本篇常见错误排查:401、local proxy failed、reading choices、OAuth
5.1 401 Unauthorized
最常见。原因通常是 Key 没带、带错,或者复制时多了空格。检查Authorization: Bearer xxx里的 Key 是否和控制台一致。注意 base_url 是https://taotoken.net/api,请求路径拼成/v1/chat/completions,别把/api漏了或者重复拼。
5.2 local proxy failed
这个报错一般出现在你本地起了代理工具或者某些 IDE 插件自带代理层时。含义是本地代理转发失败,请求根本没到服务端。排查顺序:先确认系统代理是否指向了一个没启动的端口,再确认代码里有没有硬编码http://localhost:xxxx的代理地址。把代理关掉直连,或者把 base_url 换成https://taotoken.net/api直连,通常就好了。
5.3 reading 'choices' 或 Cannot read properties of undefined
这是解析层报错,说明你拿到的 JSON 里没有choices字段。两种可能:一是请求体里stream没设成 true,返回的是普通 JSON 结构不同;二是某个 chunk 是错误响应,比如{"error": {...}},你的代码直接去读choices[0]就炸了。修复方式是解析后先判断:
const json = JSON.parse(payload); if (json.error) { console.error("API error:", json.error); return; } const delta = json.choices?.[0]?.delta?.content;用可选链兜底,永远不要假设choices一定存在。
5.4 OAuth 相关报错
如果你用的是 Claude Code 这类工具,可能会遇到 OAuth token 过期或未授权的提示。这类工具走的是 OAuth 流程而非简单 API Key。解决方式是重新执行登录授权,或者在工具配置里改用 API Key 模式。以 Claude Code 为例,配置三件套时把 Base URL 指向https://taotoken.net/api,Key 用控制台创建的,Model 填对应模型 ID,就能绕开 OAuth 直接走 Key 鉴权。CC Switch 这类切换工具同理,核心还是 Base URL + Key + Model ID 三样对齐。
5.5 中文乱码
如果输出里出现「锟斤拷」或者方块,八成是 TextDecoder 没加{ stream: true }。多字节字符被 chunk 边界切开时,不加这个参数就会按单字节解码,直接损坏。回到 3.1 的代码确认这一行。
6. 把流式渲染接进你的项目:从验证到长期使用
到这里,接收、渲染、优化、排障四条线都通了。你可以先把第 3 节的 fetch 骨架和第 3.2 节的 Typewriter 类拷进项目,用第 4 节的测量方法跑一遍,确认首字延迟和帧率达标,再逐步替换掉项目里原来的整段渲染逻辑。
如果你只是偶尔验证模型输出效果,直接用模型对话页面试最省事,地址 https://taotoken.net/model-chat ,输入问题就能看到流式返回,不用写代码。如果你要长期做编码类 Agent、需要稳定的流式通道和额度管理,走 Coding Plan 更合适,地址 https://taotoken.net/coding-plan 。接入文档在 https://taotoken.net/doc ,里面有各语言 SDK 的流式示例,遇到格式问题可以对照。
最后留一个我踩过的坑:别在onDelta里直接setState(prev => prev + delta)。哪怕你加了 rAF,只要状态更新函数本身在闭包里捕获了旧值,快速连续调用时 React 的批处理也可能丢字。正确姿势是让 Typewriter 持有完整文本,render 回调只负责把「本帧新增的部分」交给一个 ref 或 reducer 去追加,保证顺序和完整性。这个细节不注意,打字机偶尔会「吞字」,而且极难复现。