👤 关于作者
JavaAgent架构师 — 十年Java分布式架构老兵,专注AI Agent企业级落地。
主导过数字员工、SOP智能引擎等项目,开发过RPC框架、消息中间件、ORM框架。
正在输出《前端AI工程化》《Java体系也能玩转AI》《从0构建Agent系统》专栏,让Java开发者不转Python也能构建企业级AI应用。
📮 关注专栏|🔔 点赞收藏|💬 评论区见
核心定位:解决AI长文本流的渲染性能瓶颈,实现生产级打字机效果
关键产出:高性能Markdown流式渲染引擎
核心定位:解决AI长文本流的渲染性能瓶颈,实现生产级打字机效果
关键产出:高性能Markdown流式渲染引擎
2.1 AI长文本流式渲染的性能攻坚战
开篇:你忽略的那个性能炸弹
AI聊天的流式渲染,看起来只是"追加文本"这么简单的事——直到你遇到这些场景:
- 一段3000字的回答,包含5个代码块、3个表格、2个嵌套列表
- 代码块正在流式输出,高亮引擎还没检测到语言类型
- 用户同时打开了3个AI对话窗口,每个都在流式输出
每个token到来时触发一次全量Markdown解析 + DOM重建 = 页面卡死的元凶。
这一期,我们系统拆解流式渲染的性能瓶颈,并建立优化策略的知识框架。
一、流式渲染的核心矛盾
流式渲染存在一个根本性的矛盾:
更新频率 vs 渲染性能
| 维度 | 需求 | 约束 |
|---|---|---|
| 更新频率 | 每秒20-60个token,用户期望实时看到 | 每次更新都可能触发重排重绘 |
| 内容复杂度 | Markdown包含代码块、表格、公式等复杂结构 | 复杂结构的解析和渲染成本高 |
| 交互响应 | 用户可能随时滚动、选中、复制 | 渲染不能阻塞主线程 |
这个矛盾的本质是:你不能每来一个token就做一次完整的渲染。
二、三大性能瓶颈深度剖析
瓶颈1:高频DOM更新
每个token到来时,最直觉的做法是追加文本到DOM:
// ❌ 最直觉但最慢的方式
tokenStream.on('token', (token) => {
contentDiv.innerHTML += token; // 每次追加都触发完整HTML解析
});
问题分析:
// innerHTML += 的实际执行过程:
// 1. 读取 contentDiv.innerHTML(序列化现有DOM树)
// 2. 拼接新token
// 3. 重新解析整个HTML字符串
// 4. 重建整个DOM子树
// 5. Diff & Patch
// 6. 重排 + 重绘
一个3000字、50个token/秒的流式输出,意味着每秒50次完整的DOM重建。
瓶颈2:Markdown解析开销
Markdown到HTML的转换是CPU密集型操作:
import { marked } from 'marked';
// ❌ 每个token都全量解析
tokenStream.on('token', () => {
const html = marked.parse(accumulatedText); // 随着文本增长,耗时线性增加
contentDiv.innerHTML = html;
});
marked.parse的耗时与文本长度正相关。1000字的解析可能只需要2ms,但5000字可能需要15ms——而这15ms在60fps的预算中占了整整一帧(16.67ms)。
瓶颈3:代码高亮计算
代码高亮(如highlight.js、Prism)是另一个性能黑洞:
// ❌ 流式输出代码块时的高亮
tokenStream.on('token', (token) => {
accumulatedText += token;
const html = marked.parse(accumulatedText);
contentDiv.innerHTML = html;
// 对每个代码块执行高亮
contentDiv.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block); // 每次都重新高亮所有代码块
});
});
代码高亮的特殊性:代码块在流式输出时,语言类型可能还没出现(三个反引号后的语言标识可能还没到达),高亮引擎无法确定语法规则;即使确定了,对不完整的代码做高亮会产生大量中间状态的高亮DOM,每次追加token都需要重新计算。
三、三种渲染策略的性能对比
策略A:innerHTML全量替换
class InnerHTMLRenderer {
private text = '';
append(token: string): void {
this.text += token;
this.element.innerHTML = marked.parse(this.text);
}
}
- 优点:实现简单,Markdown渲染结果始终正确
- 缺点:O(n)解析复杂度,DOM全量重建,光标跳动,滚动位置丢失
- 适用:文本量小(<500字),简单Markdown
策略B:虚拟DOM增量更新
class VDOMRenderer {
private text = '';
private vdom: VNode;
append(token: string): void {
this.text += token;
const newVdom = h('div', { innerHTML: marked.parse(this.text) });
patch(this.vdom, newVdom); // 只更新差异部分
this.vdom = newVdom;
}
}
- 优点:DOM更新粒度更细,减少不必要的重排
- 缺点:Markdown全量解析的开销依然存在,VDOM diff本身也有成本
- 适用:中等文本量(500-2000字),复杂Markdown结构
策略C:手动DOM操作 + 增量解析
class IncrementalRenderer {
private currentBlock: HTMLDivElement;
private blockType: 'text' | 'code' | 'table' = 'text';
private blockContent = '';
append(token: string): void {
this.blockContent += token;
// 只更新当前正在追加的block
if (this.blockType === 'text') {
this.currentBlock.innerHTML = marked.parseInline(this.blockContent);
} else if (this.blockType === 'code') {
this.updateCodeBlock();
}
}
}
- 优点:只更新当前活跃块,O(1)渲染复杂度
- 缺点:实现复杂,需要自行管理Markdown块边界
- 适用:大文本量(>2000字),高性能要求场景
性能对比数据
| 指标 | innerHTML全量 | VDOM增量 | 手动DOM增量 |
|---|---|---|---|
| 1000字渲染耗时 | ~8ms | ~5ms | ~1ms |
| 5000字渲染耗时 | ~35ms | ~18ms | ~2ms |
| 主线程阻塞 | 严重 | 中等 | 极轻 |
| 实现复杂度 | 低 | 中 | 高 |
| 光标/滚动稳定性 | 差 | 中 | 好 |
四、requestAnimationFrame与渲染调度
无论选择哪种策略,都需要将渲染与浏览器刷新周期对齐:
class ScheduledRenderer {
private pendingTokens: string[] = [];
private rafId: number | null = null;
append(token: string): void {
this.pendingTokens.push(token);
// 如果没有待执行的渲染帧,调度一帧
if (this.rafId === null) {
this.rafId = requestAnimationFrame(this.render);
}
}
private render = (): void => {
// 合并本帧内的所有token
const tokens = this.pendingTokens;
this.pendingTokens = [];
this.rafId = null;
// 执行渲染
const combinedToken = tokens.join('');
this.doRender(combinedToken);
};
}
核心思想:token的到达频率(50次/秒)高于浏览器刷新频率(60fps),将同一帧内的token合并后只渲染一次,既保证了流畅度,又减少了不必要的渲染。
requestIdleCallback的场景
当渲染任务较重(如代码高亮)且不是用户当前关注的焦点时,可以用requestIdleCallback延迟到浏览器空闲时执行:
// 代码高亮延迟到空闲时执行
requestIdleCallback((deadline) => {
if (deadline.timeRemaining() > 5) {
// 有足够空闲时间,执行高亮
hljs.highlightElement(codeBlock);
}
// 否则等下一次空闲
});
五、增量Markdown解析:避免全量重解析
这是性能优化的关键突破点。
传统方式是每次追加token后,对整个文本重新执行marked.parse。增量解析的思路是:
只解析新增部分,复用已有的解析结果。
class IncrementalMarkdownParser {
private parsedBlocks: ParsedBlock[] = [];
private currentBlockText = '';
append(token: string): ParsedBlock[] {
this.currentBlockText += token;
// 检测块边界
if (this.isBlockComplete(this.currentBlockText)) {
const block = marked.parse(this.currentBlockText);
this.parsedBlocks.push({
html: block,
text: this.currentBlockText,
stable: true, // 已完成的块不会再变
});
this.currentBlockText = '';
return this.parsedBlocks.slice(-1); // 只返回新增的块
}
// 当前块尚未完成,返回临时预览
return [
...this.parsedBlocks,
{
html: marked.parseInline(this.currentBlockText),
text: this.currentBlockText,
stable: false, // 未完成的块,后续还会追加
},
];
}
private isBlockComplete(text: string): boolean {
// 检测是否以双换行符结尾(Markdown块分隔符)
return text.endsWith('\n\n') || text.endsWith('\n\n\n');
}
}
关键设计:
- 已完成的块标记为
stable,后续渲染可以跳过 - 只有当前活跃块需要重新解析
- 块边界检测是增量解析的核心难点
六、代码块的特殊处理:先渲染后高亮
代码块在流式场景下的处理策略:
阶段1:检测到三个反引号 → 创建<pre><code>容器
阶段2:语言标识到达 → 记录语言类型,暂不高亮
阶段3:代码内容流式追加 → 只做纯文本追加,不执行高亮
阶段4:代码块结束(遇到结束反引号)→ 执行完整高亮
class StreamingCodeBlockHandler {
private codeContent = '';
private language = '';
private element: HTMLElement;
private isComplete = false;
start(language: string, container: HTMLElement): void {
this.language = language;
this.element = document.createElement('code');
this.element.className = `language-${language}`;
container.appendChild(this.element);
}
append(token: string): void {
this.codeContent += token;
if (this.isComplete) {
// 已完成,可以增量高亮
this.element.innerHTML = hljs.highlight(this.codeContent, {
language: this.language,
}).value;
} else {
// 未完成,只做纯文本渲染(极快)
this.element.textContent = this.codeContent;
}
}
complete(): void {
this.isComplete = true;
// 最终完整高亮
this.element.innerHTML = hljs.highlight(this.codeContent, {
language: this.language,
}).value;
}
}
实践任务
任务:使用Chrome DevTools Performance面板对比三种流式渲染策略的帧率表现,输出性能分析报告。
步骤:
- 准备一个SSE模拟服务端,以50 tokens/秒输出一段包含代码块、表格、列表的Markdown文本
- 分别用innerHTML全量、VDOM增量、手动DOM增量三种方式渲染
- 使用Performance面板录制10秒,关注以下指标:
- FPS(帧率)
- Main Thread占用率
- Layout Shifts(布局偏移)
- Long Tasks(>50ms的任务)
- 输出对比报告,含截图和数据表格
面试题解析
Q:AI流式输出时,前端如何保证渲染性能避免页面卡顿?
答题要点:
- 渲染调度:requestAnimationFrame合并同帧token,避免高频DOM更新
- 增量解析:只重新解析当前活跃块,跳过已稳定的块
- 延迟高亮:代码块流式阶段只做纯文本,完成后一次性高亮
- 虚拟滚动:长对话场景下只渲染可视区域的消息
- 分块策略:Markdown按块管理,已完成块不再参与重渲染
Q:innerHTML、虚拟DOM、手动DOM操作在流式场景下哪个更优?
答题要点:没有绝对优劣,取决于场景规模。小文本innerHTML够用;中等文本VDOM平衡了性能与开发效率;大文本高性能场景需要手动DOM增量。生产环境推荐混合策略——当前活跃块用手动DOM操作保证性能,已完成块用VDOM保证可维护性。
2.2 实现生产级打字机效果
开篇:ChatGPT的打字机效果,比你想的复杂得多
看到ChatGPT的逐字输出效果,大多数人会想:这不就是逐个追加字符嘛?
但当你真正开始实现,问题接踵而来:
- 光标要闪烁,但闪烁频率不能被token到达节奏干扰
- 代码块中途换行时,行号要正确对齐
- 表格正在流式输出,列宽不能每来一个token就重新计算
- 网络突然抖动,3秒没来新token,再恢复时不能"突然冒出一大段"
- 用户点了"停止生成",当前token渲染到一半,怎么优雅收尾
这一期,我们从零实现一个处理所有这些边界情况的生产级打字机效果。
一、打字机效果的核心机制
1.1 逐字符渲染 vs 逐Token渲染
LLM的token不等于字符——一个token可能是1个字符,也可能是一个完整单词,甚至是"```"这样的标记。
逐Token渲染:每个token立即显示
→ 速度快,但可能出现"突然跳出一大块"的不连贯感
逐字符渲染:token拆成字符,按固定节奏逐个显示
→ 节奏稳定,但引入了人为延迟,与实际生成速度脱节
生产级方案:逐Token渲染 + 缓冲平滑。
class TypewriterBuffer {
private buffer: string[] = []; // 待渲染的token队列
private isFlushing = false; // 是否正在消费缓冲区
push(token: string): void {
this.buffer.push(token);
if (!this.isFlushing) {
this.flush();
}
}
private flush(): void {
this.isFlushing = true;
const renderFrame = () => {
if (this.buffer.length === 0) {
this.isFlushing = false;
return;
}
// 每帧消费所有缓冲的token(合并渲染)
const combined = this.buffer.join('');
this.buffer = [];
this.render(combined);
requestAnimationFrame(renderFrame);
};
requestAnimationFrame(renderFrame);
}
private render(text: string): void {
// 子类实现具体渲染逻辑
}
}
为什么不用逐字符? 因为LLM的生成速度本身就是"逐token"的节奏,逐字符会引入不必要的人为延迟,让用户感觉"更慢了"。缓冲平滑解决的是网络抖动导致的"时快时慢",而不是人为减速。
二、光标管理
2.1 光标的CSS实现
.typing-cursor {
display: inline-block;
width: 2px;
height: 1.1em;
background-color: currentColor;
margin-left: 1px;
animation: blink 1s step-end infinite;
vertical-align: text-bottom;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* 流式输出时光标常亮,停止后才闪烁 */
.typing-cursor.active {
animation: none;
opacity: 1;
}
关键细节:
- 流式输出时光标应该是常亮的(
active状态),不是闪烁的——闪烁的光标+不断追加的文字=视觉干扰 - 只有在生成完成(或暂停)后,光标才开始闪烁
- 光标宽度2px、高度1.1em,与等宽字体的字符宽度接近,视觉上像真正的文本光标
2.2 光标位置管理
光标必须始终跟随在最后一个渲染字符之后:
class CursorManager {
private cursorEl: HTMLElement;
constructor(container: HTMLElement) {
this.cursorEl = document.createElement('span');
this.cursorEl.className = 'typing-cursor active';
container.appendChild(this.cursorEl);
}
/** 在目标元素后重新插入光标 */
moveAfter(element: Node): void {
element.parentNode?.insertBefore(this.cursorEl, element.nextSibling);
}
/** 追加文本时,光标自动跟在末尾 */
appendTo(container: HTMLElement): void {
container.appendChild(this.cursorEl);
}
/** 生成完成,切换为闪烁模式 */
finish(): void {
this.cursorEl.classList.remove('active');
}
/** 移除光标 */
remove(): void {
this.cursorEl.remove();
}
}
三、弱网场景:Backpressure与缓冲区管理
3.1 问题场景
正常情况:token均匀到达 → 渲染节奏稳定 ✅
弱网场景:3秒无token → 突然涌入20个token → 渲染节奏被打乱 ❌
用户感知到的是:“文字先是停了3秒,然后突然跳出一大段”——这严重破坏了打字机效果的流畅感。
3.2 缓冲平滑策略
class SmoothTypewriter {
private tokenBuffer: string[] = [];
private tokensPerFrame = 1; // 动态调节:每帧渲染的token数
private lastRenderTime = 0;
private lastTokenTime = 0;
push(token: string): void {
this.tokenBuffer.push(token);
this.lastTokenTime = performance.now();
}
private renderLoop = (): void => {
const now = performance.now();
if (this.tokenBuffer.length === 0) {
// 无待渲染token,暂停渲染循环
return;
}
// 动态调节渲染速度
// 如果缓冲区积压了较多token,加速消费
// 如果缓冲区快空了,减速以保持节奏
if (this.tokenBuffer.length > 10) {
this.tokensPerFrame = Math.min(5, this.tokenBuffer.length / 3);
} else if (this.tokenBuffer.length <= 2) {
this.tokensPerFrame = 1;
}
// 消费token
const tokensToRender = this.tokenBuffer.splice(0, Math.ceil(this.tokensPerFrame));
const combined = tokensToRender.join('');
this.doRender(combined);
this.lastRenderTime = now;
requestAnimationFrame(this.renderLoop);
};
}
3.3 Backpressure:通知服务端降速
在极端情况下,如果客户端渲染速度跟不上token到达速度,可以通过SSE的上行通道通知服务端降速:
// 客户端检测到缓冲区过满
if (this.tokenBuffer.length > 50) {
// 通过HTTP POST通知服务端降低生成速度
// 或者在SSE的上行通道发送backpressure信号
fetch('/api/chat/backpressure', {
method: 'POST',
body: JSON.stringify({ slowDown: true }),
});
}
注意:大多数LLM API不支持服务端降速,Backpressure更多是一个客户端的缓冲管理策略。实际效果是:当token堆积时,加速消费;当token稀少时,减速以保持节奏感。
四、Markdown流式渲染的边界处理
4.1 代码块流式渲染
收到: "这是代码:\n```"
→ 检测到代码块开始,切换到代码块模式
收到: "java"
→ 记录语言类型,暂不高亮
收到: "\npublic clas"
→ 纯文本追加到代码容器
收到: "s Hello {\n"
→ 继续纯文本追加
收到: "```"
→ 代码块结束,执行完整高亮
class MarkdownStreamingRenderer {
private mode: 'normal' | 'code' | 'table' = 'normal';
private codeBlockHandler: StreamingCodeBlockHandler | null = null;
private tableHandler: StreamingTableHandler | null = null;
append(token: string): void {
this.accumulatedText += token;
switch (this.mode) {
case 'normal':
this.renderNormalMode(token);
break;
case 'code':
this.renderCodeMode(token);
break;
case 'table':
this.renderTableMode(token);
break;
}
}
private renderNormalMode(token: string): void {
// 检测代码块开始
if (this.accumulatedText.match(/```\w*$/)) {
this.mode = 'code';
const langMatch = this.accumulatedText.match(/```(\w+)$/);
const lang = langMatch?.[1] ?? '';
this.codeBlockHandler = new StreamingCodeBlockHandler();
this.codeBlockHandler.start(lang, this.container);
return;
}
// 检测表格开始
if (this.accumulatedText.match(/\|.+\|$/m) && this.accumulatedText.includes('---')) {
this.mode = 'table';
this.tableHandler = new StreamingTableHandler();
this.tableHandler.start(this.container);
return;
}
// 普通文本:增量渲染
this.renderInlineMarkdown();
}
private renderCodeMode(token: string): void {
// 检测代码块结束
if (this.accumulatedText.endsWith('```')) {
this.codeBlockHandler!.complete();
this.mode = 'normal';
this.codeBlockHandler = null;
return;
}
// 追加代码内容
this.codeBlockHandler!.append(token);
}
}
4.2 表格流式渲染
表格的流式渲染更加棘手——列宽需要等到所有行已知才能确定,但流式输出时行是逐行到来的。
class StreamingTableHandler {
private rows: string[][] = [];
private currentRow: string[] = [];
private tableEl: HTMLTableElement;
private headerRow: HTMLTableRowElement | null = null;
start(container: HTMLElement): void {
this.tableEl = document.createElement('table');
this.tableEl.className = 'streaming-table';
container.appendChild(this.tableEl);
}
append(token: string): void {
// 解析表格行的逻辑
// 每检测到一行完整数据,渲染为新行
const lines = token.split('\n');
for (const line of lines) {
const cells = line.split('|').filter(c => c.trim());
if (cells.length > 0) {
if (cells[0].trim().match(/^-+$/)) {
// 分隔行,标记上方为表头
this.headerRow = this.tableEl.rows[0] ?? null;
if (this.headerRow) {
this.headerRow.parentElement?.replaceChild(
this.createHeaderRow(this.headerRow),
this.headerRow
);
}
} else {
this.addRow(cells);
}
}
}
}
private addRow(cells: string[]): void {
const tr = document.createElement('tr');
for (const cell of cells) {
const td = document.createElement('td');
td.textContent = cell.trim();
tr.appendChild(td);
}
this.tableEl.appendChild(tr);
}
private createHeaderRow(oldRow: HTMLTableRowElement): HTMLTableRowElement {
const tr = document.createElement('tr');
for (const cell of Array.from(oldRow.cells)) {
const th = document.createElement('th');
th.textContent = cell.textContent;
tr.appendChild(th);
}
return tr;
}
}
五、中断与回放:用户停止生成时的状态处理
当用户点击"停止生成"按钮:
class TypewriterEngine {
private isGenerating = false;
stop(): void {
this.isGenerating = false;
// 1. 清空缓冲区中未渲染的token(丢弃,不渲染)
this.tokenBuffer = [];
// 2. 完成当前正在流式输出的块
if (this.codeBlockHandler) {
this.codeBlockHandler.complete();
this.codeBlockHandler = null;
}
this.mode = 'normal';
// 3. 光标切换为闪烁模式
this.cursorManager.finish();
// 4. 触发完成回调
this.onComplete?.();
}
}
关键决策:停止生成时,缓冲区中未渲染的token应该丢弃还是立即显示?
- 丢弃:用户看到的是"截止到当前已渲染的内容",体验更自然
- 立即显示:用户看到的是"模型实际生成的全部内容",信息更完整
生产级方案:提供一个"继续显示"按钮,默认丢弃,但用户可以选择继续查看。
实践任务
任务:实现一个完整的流式Markdown渲染器,支持代码块流式高亮、表格流式渲染、光标闪烁动画。
验收标准:
- 普通文本逐token流式渲染,光标常亮跟随
- 代码块检测→纯文本追加→完成后高亮,整个过程不闪烁
- 表格逐行渲染,表头/数据行正确区分
- 嵌套列表流式渲染,缩进层级正确
- 生成完成后光标切换为闪烁模式
- 点击"停止"后优雅收尾
面试题解析
Q:如何实现ChatGPT的打字机效果?弱网环境下如何保证平滑?
答题要点:
- 核心机制:token级追加渲染 + requestAnimationFrame调度
- 光标管理:流式输出时常亮,完成后闪烁
- 缓冲平滑:token先入缓冲区,渲染循环按帧消费,避免网络抖动导致的"时快时慢"
- Backpressure:缓冲区积压时加速消费,极端情况通知服务端降速
- 块级渲染:代码块/表格等复杂结构需要状态机管理,不同阶段用不同渲染策略
2.3 流式渲染引擎封装与性能度量
开篇:从"能用"到"工程化"
前两期我们解决了流式渲染的核心技术问题。现在要把这些零散的解决方案封装为一个可复用、可扩展、可度量的流式渲染引擎。
这一期的关键词是:架构、度量、监控。
一、渲染引擎架构:Parser → Renderer → Highlighter三层解耦
┌───────────────────────────────────────────────────┐
│ StreamRenderer │
│ (门面/协调层) │
├───────────┬─────────────────┬─────────────────────┤
│ Parser │ Renderer │ Highlighter │
│ 解析层 │ 渲染层 │ 高亮层 │
├───────────┼─────────────────┼─────────────────────┤
│ - 增量MD │ - DOM操作 │ - 代码高亮 │
│ 解析 │ - 光标管理 │ - 延迟高亮策略 │
│ - 块边界 │ - 缓冲平滑 │ - LaTeX渲染 │
│ 检测 │ - 虚拟滚动 │ - 自定义高亮器 │
│ - 状态机 │ - 事件分发 │ │
└───────────┴─────────────────┴─────────────────────┘
↕ ↕ ↕
┌───────────────────────────────────────────────────┐
│ Plugin System │
│ (插件扩展层) │
│ - 自定义Parser - 自定义Renderer │
│ - 自定义Highlighter - 性能监控插件 │
└───────────────────────────────────────────────────┘
设计原则:
- 单一职责:Parser只负责解析,Renderer只负责渲染,Highlighter只负责高亮
- 可替换:每一层都可以独立替换实现(如用markdown-it替换marked)
- 插件化:通过插件系统扩展功能,不修改核心代码
二、核心接口定义
// === 解析层 ===
interface IStreamParser {
/** 追加token,返回需要更新的块 */
append(token: string): ParseResult;
/** 标记当前内容结束 */
finalize(): ParseResult;
/** 重置解析器状态 */
reset(): void;
}
interface ParseResult {
/** 新增的稳定块(已完成,不需要重新解析) */
stableBlocks: ParsedBlock[];
/** 当前活跃块(未完成,后续会继续追加) */
activeBlock: ParsedBlock | null;
}
interface ParsedBlock {
id: string;
type: 'paragraph' | 'code' | 'table' | 'list' | 'heading' | 'blockquote';
html: string;
rawText: string;
stable: boolean;
metadata?: Record<string, any>; // 如代码块的语言类型
}
// === 渲染层 ===
interface IStreamRenderer {
/** 渲染解析结果 */
render(result: ParseResult): void;
/** 获取容器DOM */
getContainer(): HTMLElement;
/** 滚动到底部 */
scrollToBottom(smooth?: boolean): void;
/** 销毁渲染器 */
destroy(): void;
}
// === 高亮层 ===
interface IHighlighter {
/** 对代码块执行高亮 */
highlight(code: string, language: string): string;
/** 检测是否支持指定语言 */
supportsLanguage(language: string): boolean;
/** 获取所有支持的语言列表 */
getSupportedLanguages(): string[];
}
// === 插件系统 ===
interface IStreamRendererPlugin {
name: string;
install(engine: StreamRendererEngine): void;
uninstall?(): void;
}
三、引擎核心实现
class StreamRendererEngine {
private parser: IStreamParser;
private renderer: IStreamRenderer;
private highlighter: IHighlighter;
private plugins: IStreamRendererPlugin[] = [];
// 性能度量
private metrics: RendererMetrics;
// 渲染调度
private pendingTokens: string[] = [];
private rafId: number | null = null;
private isStreaming = false;
constructor(options: StreamRendererOptions) {
this.parser = options.parser ?? new IncrementalMarkdownParser();
this.renderer = options.renderer ?? new DOMRenderer(options.container);
this.highlighter = options.highlighter ?? new HighlightJsAdapter();
this.metrics = new RendererMetrics();
}
// === 公共接口 ===
/** 追加一个token */
append(token: string): void {
this.pendingTokens.push(token);
this.isStreaming = true;
this.scheduleRender();
}
/** 标记流结束 */
finalize(): void {
this.isStreaming = false;
// 执行最终渲染
const result = this.parser.finalize();
this.renderer.render(result);
// 执行延迟高亮
this.highlightPendingBlocks();
// 记录度量
this.metrics.recordFinalize();
}
/** 重置引擎 */
reset(): void {
this.pendingTokens = [];
this.isStreaming = false;
this.parser.reset();
// 清空渲染容器
this.renderer.getContainer().innerHTML = '';
this.metrics.reset();
}
/** 注册插件 */
use(plugin: IStreamRendererPlugin): this {
this.plugins.push(plugin);
plugin.install(this);
return this;
}
/** 获取性能度量数据 */
getMetrics(): Readonly<RendererMetricsData> {
return this.metrics.getData();
}
// === 渲染调度 ===
private scheduleRender(): void {
if (this.rafId !== null) return;
this.rafId = requestAnimationFrame(() => {
this.rafId = null;
this.renderPending();
});
}
private renderPending(): void {
const startTime = performance.now();
// 合并本帧所有token
const combined = this.pendingTokens.join('');
this.pendingTokens = [];
// 解析
const parseResult = this.parser.append(combined);
// 渲染
this.renderer.render(parseResult);
// 延迟高亮(只对稳定块执行)
for (const block of parseResult.stableBlocks) {
if (block.type === 'code') {
this.scheduleHighlight(block);
}
}
// 记录度量
const renderTime = performance.now() - startTime;
this.metrics.recordFrame(renderTime);
// 如果还有pending token,继续调度
if (this.pendingTokens.length > 0) {
this.scheduleRender();
}
}
private pendingHighlights: ParsedBlock[] = [];
private scheduleHighlight(block: ParsedBlock): void {
this.pendingHighlights.push(block);
// 使用requestIdleCallback延迟高亮
requestIdleCallback((deadline) => {
while (this.pendingHighlights.length > 0 && deadline.timeRemaining() > 2) {
const block = this.pendingHighlights.shift()!;
this.doHighlight(block);
}
// 如果还有未高亮的块,继续调度
if (this.pendingHighlights.length > 0) {
this.scheduleHighlight(this.pendingHighlights.shift()!);
}
});
}
private doHighlight(block: ParsedBlock): void {
const codeElement = this.renderer
.getContainer()
.querySelector(`[data-block-id="${block.id}"] code`);
if (codeElement) {
const lang = block.metadata?.language ?? '';
codeElement.innerHTML = this.highlighter.highlight(
codeElement.textContent ?? '',
lang
);
}
}
}
四、性能度量指标体系
interface RendererMetricsData {
/** 首Token渲染时间 (Time to First Token Render) */
ttft: number;
/** 每秒Token渲染数 (Tokens Per Second) */
tps: number;
/** 平均帧渲染耗时 */
avgFrameTime: number;
/** 最大帧渲染耗时 */
maxFrameTime: number;
/** 帧率(基于渲染频率计算) */
fps: number;
/** 总渲染帧数 */
totalFrames: number;
/** 总渲染token数 */
totalTokens: number;
/** 长任务次数(>50ms的帧) */
longTaskCount: number;
/** DOM节点数 */
domNodeCount: number;
}
class RendererMetrics {
private startTime = 0;
private firstTokenTime = 0;
private frameTimes: number[] = [];
private tokenCount = 0;
private longTaskThreshold = 50; // ms
recordStart(): void {
this.startTime = performance.now();
}
recordFirstToken(): void {
if (this.firstTokenTime === 0) {
this.firstTokenTime = performance.now();
}
}
recordFrame(renderTime: number): void {
this.frameTimes.push(renderTime);
if (renderTime > this.longTaskThreshold) {
this.longTaskCount++;
}
}
recordToken(count: number = 1): void {
this.tokenCount += count;
}
recordFinalize(): void {
// 最终度量快照
}
getData(): RendererMetricsData {
const elapsed = (performance.now() - this.startTime) / 1000;
const avgFrameTime = this.frameTimes.length > 0
? this.frameTimes.reduce((a, b) => a + b, 0) / this.frameTimes.length
: 0;
return {
ttft: this.firstTokenTime - this.startTime,
tps: elapsed > 0 ? this.tokenCount / elapsed : 0,
avgFrameTime,
maxFrameTime: Math.max(...this.frameTimes, 0),
fps: this.frameTimes.length > 0 && elapsed > 0
? this.frameTimes.length / elapsed
: 0,
totalFrames: this.frameTimes.length,
totalTokens: this.tokenCount,
longTaskCount: this.longTaskCount,
domNodeCount: document.querySelectorAll('*').length,
};
}
reset(): void {
this.startTime = 0;
this.firstTokenTime = 0;
this.frameTimes = [];
this.tokenCount = 0;
this.longTaskCount = 0;
}
private longTaskCount = 0;
}
五、性能监控面板插件
class PerformanceMonitorPlugin implements IStreamRendererPlugin {
name = 'performance-monitor';
private panel: HTMLElement | null = null;
private engine: StreamRendererEngine | null = null;
private updateInterval: ReturnType<typeof setInterval> | null = null;
install(engine: StreamRendererEngine): void {
this.engine = engine;
this.createPanel();
this.updateInterval = setInterval(() => this.update(), 500);
}
uninstall(): void {
this.panel?.remove();
if (this.updateInterval) clearInterval(this.updateInterval);
}
private createPanel(): void {
this.panel = document.createElement('div');
this.panel.className = 'perf-monitor-panel';
this.panel.innerHTML = `
<div class="perf-title">StreamRenderer Metrics</div>
<div class="perf-row"><span>TTFT</span><span id="perf-ttft">-</span></div>
<div class="perf-row"><span>TPS</span><span id="perf-tps">-</span></div>
<div class="perf-row"><span>FPS</span><span id="perf-fps">-</span></div>
<div class="perf-row"><span>Avg Frame</span><span id="perf-avg">-</span></div>
<div class="perf-row"><span>Max Frame</span><span id="perf-max">-</span></div>
<div class="perf-row"><span>Long Tasks</span><span id="perf-lt">-</span></div>
<div class="perf-row"><span>DOM Nodes</span><span id="perf-dom">-</span></div>
`;
document.body.appendChild(this.panel);
}
private update(): void {
if (!this.engine || !this.panel) return;
const data = this.engine.getMetrics();
const $ = (id: string) => this.panel!.querySelector(`#perf-${id}`);
($('ttft')!.textContent = `${data.ttft.toFixed(0)}ms`);
($('tps')!.textContent = `${data.tps.toFixed(1)}`);
($('fps')!.textContent = `${data.fps.toFixed(0)}`);
($('avg')!.textContent = `${data.avgFrameTime.toFixed(1)}ms`);
($('max')!.textContent = `${data.maxFrameTime.toFixed(1)}ms`);
($('lt')!.textContent = `${data.longTaskCount}`);
($('dom')!.textContent = `${data.domNodeCount}`);
}
}
六、引擎插件化扩展点
// 自定义渲染器示例:Canvas渲染器(适合超长文本)
class CanvasRenderer implements IStreamRenderer {
private ctx: CanvasRenderingContext2D;
private lineHeight = 24;
private scrollOffset = 0;
constructor(container: HTMLElement) {
const canvas = document.createElement('canvas');
container.appendChild(canvas);
this.ctx = canvas.getContext('2d')!;
}
render(result: ParseResult): void {
// Canvas渲染逻辑——不受DOM性能约束
}
getContainer(): HTMLElement {
throw new Error('Canvas renderer does not use DOM container');
}
scrollToBottom(smooth?: boolean): void {
// Canvas滚动逻辑
}
destroy(): void {
this.ctx.canvas.remove();
}
}
// 自定义高亮器示例:Shiki渲染器
class ShikiHighlighter implements IHighlighter {
private shiki: any;
async init(): Promise<void> {
this.shiki = await import('shiki');
}
highlight(code: string, language: string): string {
return this.shiki.codeToHtml(code, { lang: language });
}
supportsLanguage(language: string): boolean {
return this.shiki.getLoadedLanguages().includes(language);
}
getSupportedLanguages(): string[] {
return this.shiki.getLoadedLanguages();
}
}
实践任务
任务:封装StreamRendererEngine类,支持插件扩展,附带性能监控Dashboard。
验收标准:
- Parser/Renderer/Highlighter三层可独立替换
- 插件系统可注册/卸载插件
- PerformanceMonitorPlugin实时显示TTFT/TPS/FPS等指标
- 增量解析:已稳定块不参与重渲染
- 延迟高亮:代码块完成后在空闲时执行高亮
- 完整的TypeScript类型导出
面试题解析
Q:如何衡量AI前端页面的性能指标?
答题要点:
- AI场景特有指标:
- TTFT(首Token渲染时间):用户发送消息到看到第一个token的时间
- TPS(每秒Token渲染数):反映流式渲染的吞吐量
- 帧率稳定性:流式渲染期间FPS是否保持>55
- Long Task占比:>50ms任务占渲染帧的比例
- 通用Web指标:
- LCP、FID、CLS等Core Web Vitals
- DOM节点数、内存占用
- 度量方法:
- Performance API记录关键时间点
- requestAnimationFrame回调计算实时FPS
- MutationObserver监控DOM变化频率
- 开发自定义性能面板实时展示
下期预告:前端AI工程化(三) - 异步编程与并发控制,我们将从渲染层进入调度层,拆解Promise高级模式在多AI模型调用场景的实战应用。
转载自CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/sijingqian/article/details/161035481



