要是实在不知道要干什么,那就喝两杯思路就来了!

导航菜单

SSE流式传输与前端渐进式渲染的终极实践

在处理海量数据的前端展示场景中,我们经常会面临一个两难的困境:数据量太大,一次性加载会导致页面崩溃或长时间白屏;而传统的分页又割裂了用户体验,无法满足“无限滚动”的需求。
本文将分享一种基于 SSE(Server-Sent Events) 的后端流式推送与前端 渐进式渲染 相结合的解决方案。该方案不仅能实现首屏极速展示,还能通过精细的节奏控制,确保在低端设备上也能丝滑流畅地展示百万级数据。

核心痛点与解决方案概览

在传统的开发模式中,处理 100 万条数据通常有以下两种糟糕的体验:
  1. 一次性加载:后端查询所有数据并序列化为 JSON,前端接收后一次性渲染。结果往往是:浏览器内存溢出(OOM),或者用户面对长达数十秒的白屏。

  2. 传统分页:虽然减轻了单次压力,但“点击下一页”的交互打断了用户的浏览心流,且无法感知数据的连续性。

我们的目标是实现一个 “边读、边传、边渲染” 的管道化系统。

架构设计思路

本方案的核心在于将数据流视为一个“水龙头”,而非“水库泄洪”。
  1. 后端(生产者):利用数据库游标(Cursor)进行流式读取,不占用大量内存。通过 SSE 协议,将数据逐条或逐批推送到客户端。关键在于,后端需要引入人工限速机制,防止数据瞬间淹没前端。

  2. 网络层(管道):SSE 基于 HTTP 长连接,支持单向服务器推送。前端可随时通过 AbortController 中断连接,这意味着如果用户只看前几页就关闭了页面,后端剩余的 99% 数据根本不会被读取或发送,极大地节省了服务器资源。

  3. 前端(消费者)

    • 接收:监听 SSE 事件流。

    • 渲染:摒弃直接操作 DOM 的粗暴方式。首屏采用 requestAnimationFrame 保证渲染时机与屏幕刷新率同步;后续数据采用 DocumentFragment 进行批量插入,减少重排(Reflow)。

后端实现:流式读取与节奏控制

后端不仅要负责“发”,还要负责“控”。我们需要控制两个节奏:数据库的读取节奏和网络的发送节奏。
const express = require('express');
const app = express();

// 模拟数据库连接和游标
const db = require('./db'); 

// 辅助函数:延时
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

app.get('/api/stream-data', async (req, res) => {
  // 1. 设置 SSE 响应头
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  try {
    // 2. 获取数据库游标(流式读取,不一次性加载)
    // 假设我们要查询 100 万条数据
    const cursor = db.collection.find({}).cursor();

    let count = 0;
    for await (const doc of cursor) {
      // 3. 发送数据
      // 这里可以选择单条发送,也可以攒一小批(如10条)再发
      res.write(`data: ${JSON.stringify(doc)}\n\n`);
      
      count++;
      
      // 4. 关键:人工限速
      // 假设前端渲染一条需要 50ms,我们设置 100ms 的间隔
      // 这能防止后端发送过快导致前端内存堆积
      await sleep(100); 
      
      // 可选:每发送一定数量,记录日志或心跳
      if (count % 1000 === 0) {
        console.log(`已发送 ${count} 条数据`);
      }
    }
    
    res.write('data: [DONE]\n\n'); // 发送结束标记
    res.end();
    
  } catch (err) {
    console.error('流传输错误:', err);
    res.end();
  }
});
关键点解析:
  • 游标(Cursor):这是流式读取的基础。它保证内存中始终只有一条(或一小批)数据,而不是 100 万条。

  • await sleep(100):这是本方案的精髓。数据库读取速度极快(微秒级),如果不加限制,数据会瞬间填满 TCP 缓冲区。通过强制等待,我们让发送节奏与人类的阅读/滚动速度相匹配。

前端实现:高性能渐进式渲染

class StreamListRenderer {
  constructor(containerId) {
    this.container = document.getElementById(containerId);
    this.eventSource = null;
    this.dataBuffer = []; // 数据缓冲池
    this.isRendering = false;
    this.batchSize = 10; // 每次批量渲染的条数
  }

  // 启动流式请求
  start(url) {
    this.eventSource = new EventSource(url);

    this.eventSource.onmessage = (event) => {
      if (event.data === '[DONE]') {
        this.eventSource.close();
        console.log('传输完成');
        return;
      }

      // 1. 接收数据
      const data = JSON.parse(event.data);
      this.dataBuffer.push(data);

      // 2. 触发渲染循环
      this.scheduleRender();
    };

    this.eventSource.onerror = (err) => {
      console.error('SSE 连接错误', err);
      this.eventSource.close();
    };
  }

  // 调度渲染:使用 requestAnimationFrame 避免掉帧
  scheduleRender() {
    if (this.isRendering) return; // 防止重入

    this.isRendering = true;
    requestAnimationFrame(() => {
      this.renderBatch();
      this.isRendering = false;
      
      // 如果缓冲池还有数据,继续调度
      if (this.dataBuffer.length > 0) {
        this.scheduleRender();
      }
    });
  }

  // 批量渲染核心逻辑
  renderBatch() {
    if (this.dataBuffer.length === 0) return;

    // 1. 创建文档片段(DocumentFragment)
    // 这是一个存在于内存中的 DOM 节点,不触发重排
    const fragment = document.createDocumentFragment();

    // 2. 取出当前批次数据(例如 10 条)
    const batch = this.dataBuffer.splice(0, this.batchSize);

    batch.forEach(item => {
      const div = document.createElement('div');
      div.className = 'list-item';
      div.textContent = item.name; // 假设数据有 name 字段
      // 这里可以添加更多复杂的 DOM 结构
      fragment.appendChild(div);
    });

    // 3. 一次性将片段插入到容器中
    // 这一步才会触发一次重排和重绘
    this.container.appendChild(fragment);
  }

  // 停止请求(例如用户关闭页面或切换路由)
  stop() {
    if (this.eventSource) {
      this.eventSource.close();
      this.eventSource = null;
    }
  }
}

// 使用示例
const renderer = new StreamListRenderer('app');
renderer.start('/api/stream-data');

// 页面卸载时务必停止
window.addEventListener('beforeunload', () => {
  renderer.stop();
});
关键点解析:
  • dataBuffer:作为生产者(SSE)和消费者(渲染)之间的缓冲,平衡速度差异。

  • requestAnimationFrame:确保 DOM 操作发生在浏览器下一次重绘之前,避免阻塞主线程,保证页面交互(如滚动、点击)的响应速度。

  • DocumentFragment:这是高性能列表渲染的“银弹”。它将 10 次 DOM 插入操作合并为 1 次,极大地降低了浏览器的渲染开销。

方案优势总结

通过上述实现,我们获得了以下显著优势:
  1. 首屏极速:用户无需等待 100 万条数据全部加载,第一秒内即可看到首批数据。

  2. 资源节约

    • 后端:内存占用恒定(O(1)),且如果用户中途离开,后端通过 SSE 中断机制可立即停止数据库查询,节省 99% 的无效计算。

    • 前端:通过批量渲染和 rAF,CPU 占用率平稳,不会出现瞬间飙升导致的页面卡死。

  3. 体验流畅:数据像“打字机”一样逐行出现,或者随着滚动平滑加载,消除了白屏焦虑。

进阶思考:虚拟滚动的必要性

虽然上述方案在处理前几千条数据时表现完美,但如果用户真的快速滚动到了第 5 万条,DOM 节点数量过多依然会导致性能下降。
因此,在实际工程化落地时,建议采用混合策略
  • 阶段一(0 ~ 1000 条):使用上述的 DocumentFragment 真实 DOM 追加方案,保证首屏和初期浏览的极致体验。

  • 阶段二(> 1000 条):当列表长度超过阈值,无缝切换至虚拟滚动(Virtual Scroll)。此时停止向 DOM 插入新节点,仅维护一个可视区域的窗口,根据滚动位置动态渲染数据。

这种结合了 SSE 流式传输人工限速 以及 渐进式渲染 


发表评论