TypeScript实战:流式AI对话接口的前端渲染与性能优化方案

流式AI对话的前端挑战

大模型聊天接口采用Server-Sent Events(SSE)逐Token推送响应,前端需要在Token到达时立即渲染,同时保证文本排版、Markdown解析、代码高亮的效果。核心难点在于:高频DOM更新导致重排重绘、Markdown增量解析效率、长对话的虚拟滚动、中断与重连机制。TypeScript的类型系统能有效管理SSE连接状态和Token缓冲区。

SSE连接管理器:类型安全的实现

使用TypeScript封装SSE连接管理器,处理连接建立、Token接收、错误重连、手动中断等生命周期:

interface SSEConfig {
  url: string;
  headers?: Record<string, string>;
  body: unknown;
  onToken: (token: string) => void;
  onComplete: (fullText: string) => void;
  onError: (error: Error) => void;
}

class StreamChatConnector {
  private controller: AbortController | null = null;
  private fullText = '';

  async connect(config: SSEConfig): Promise<void> {
    this.controller = new AbortController();
    this.fullText = '';

    try {
      const response = await fetch(config.url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          ...config.headers,
        },
        body: JSON.stringify(config.body),
        signal: this.controller.signal,
      });

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      const reader = response.body
        ?.pipeThrough(new TextDecoderStream())
        .getReader();

      if (!reader) throw new Error('No readable stream');

      let buffer = '';
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        buffer += value;
        const lines = buffer.split('\n');
        buffer = lines.pop() || '';

        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const data = line.slice(6);
            if (data === '[DONE]') {
              config.onComplete(this.fullText);
              return;
            }
            try {
              const parsed = JSON.parse(data);
              const token = parsed.choices?.[0]?.delta?.content ?? '';
              if (token) {
                this.fullText += token;
                config.onToken(token);
              }
            } catch { /* 跳过非JSON行 */ }
          }
        }
      }
    } catch (err) {
      if ((err as Error).name !== 'AbortError') {
        config.onError(err as Error);
      }
    }
  }

  abort(): void {
    this.controller?.abort();
  }
}

Token批量渲染:减少DOM操作频率

SSE每个Token都触发一次DOM更新是性能杀手。实测中,70B模型流式输出速度约30-50 Token/s,每次更新触发Markdown重解析和重排,FPS会掉到30以下。解决方案是Token批量聚合渲染:

class BatchRenderer {
  private pendingTokens: string[] = [];
  private rafId: number | null = null;
  private readonly BATCH_INTERVAL = 32; // ~30fps

  constructor(
    private container: HTMLElement,
    private renderFn: (text: string) => void
  ) {}

  append(token: string): void {
    this.pendingTokens.push(token);
    if (!this.rafId) {
      this.rafId = requestAnimationFrame(() => {
        const batch = this.pendingTokens.join('');
        this.pendingTokens = [];
        this.rafId = null;
        this.renderFn(batch);
      });
    }
  }

  flush(): void {
    if (this.rafId) {
      cancelAnimationFrame(this.rafId);
      this.rafId = null;
    }
    if (this.pendingTokens.length > 0) {
      this.renderFn(this.pendingTokens.join(''));
      this.pendingTokens = [];
    }
  }
}

批量渲染将DOM更新频率从30-50次/秒降低到约30次/秒(每帧一次),同时用户感知的流式打字效果不受影响。

增量Markdown解析与代码高亮

完整的Markdown解析在每次Token到达时全量重算,时间复杂度O(n^2)。增量解析只处理新增部分:

import { marked } from 'marked';
import hljs from 'highlight.js';

class IncrementalMarkdownRenderer {
  private lastRenderedLength = 0;
  private cachedHTML = '';

  render(fullText: string): string {
    // 仅当文本长度增长超过阈值时重解析
    if (fullText.length - this.lastRenderedLength < 16) {
      return this.cachedHTML;
    }

    // 配置marked
    marked.setOptions({
      highlight: (code: string, lang: string) => {
        if (lang && hljs.getLanguage(lang)) {
          return hljs.highlight(code, { language: lang }).value;
        }
        return hljs.highlightAuto(code).value;
      },
      breaks: true,
    });

    this.cachedHTML = marked.parse(fullText) as string;
    this.lastRenderedLength = fullText.length;
    return this.cachedHTML;
  }
}

关键优化:每累积16个字符才触发一次Markdown解析。在代码块内,检测到未闭合的反引号时跳过解析,避免频繁生成不完整的代码高亮HTML。

长对话虚拟滚动方案

AI对话页面在数百轮交互后,DOM节点数可达数万,页面滚动严重卡顿。虚拟滚动只渲染可视区域内的消息节点:

interface VirtualItem {
  index: number;
  offsetTop: number;
  height: number;
}

class ChatVirtualScroller {
  private items: VirtualItem[] = [];
  private containerHeight = 0;
  private scrollTop = 0;
  private readonly OVERSCAN = 200; // 上下各多渲染200px

  onScroll(scrollTop: number): number[] {
    this.scrollTop = scrollTop;
    const viewportTop = scrollTop - this.OVERSCAN;
    const viewportBottom = scrollTop + this.containerHeight + this.OVERSCAN;

    const visibleIndices: number[] = [];
    for (const item of this.items) {
      const itemBottom = item.offsetTop + item.height;
      if (item.offsetTop < viewportBottom && itemBottom > viewportTop) {
        visibleIndices.push(item.index);
      }
    }
    return visibleIndices;
  }

  setItems(messages: { height: number }[]): void {
    let offset = 0;
    this.items = messages.map((msg, i) => {
      const item = { index: i, offsetTop: offset, height: msg.height };
      offset += msg.height;
      return item;
    });
  }
}

虚拟滚动将DOM节点数从O(n)降到O(可视区域消息数),即使千轮对话也保持60fps滚动。需要注意消息高度变化(Markdown渲染完成、图片加载)时需要重新计算items布局。

网络中断重连与消息恢复

移动端网络不稳定,SSE连接断开后需要自动重连并恢复上下文:

class ResilientSSEConnector extends StreamChatConnector {
  private retryCount = 0;
  private readonly MAX_RETRIES = 3;
  private readonly RETRY_DELAY = 1000;
  private lastEventId = '';

  async connectWithRetry(config: SSEConfig): Promise<void> {
    const enhancedConfig: SSEConfig = {
      ...config,
      headers: {
        ...config.headers,
        ...(this.lastEventId ? { 'Last-Event-ID': this.lastEventId } : {}),
      },
      onError: async (err) => {
        if (this.retryCount < this.MAX_RETRIES) {
          this.retryCount++;
          const delay = this.RETRY_DELAY * Math.pow(2, this.retryCount - 1);
          await new Promise(r => setTimeout(r, delay));
          return this.connectWithRetry(config);
        }
        config.onError(err);
      },
    };

    return super.connect(enhancedConfig);
  }
}

重连时携带Last-Event-ID,服务端可以从断点续传Token流,避免从头重新生成。这要求后端在SSE事件中包含id字段,并实现断点续传逻辑。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/typescript-shi-zhan-liu-shi-ai-dui-hua-jie-kou-de-qian-duan/

(0)
小编小编
上一篇 2小时前
下一篇 2小时前

相关推荐