核心痛点与解决方案概览
一次性加载:后端查询所有数据并序列化为 JSON,前端接收后一次性渲染。结果往往是:浏览器内存溢出(OOM),或者用户面对长达数十秒的白屏。
传统分页:虽然减轻了单次压力,但“点击下一页”的交互打断了用户的浏览心流,且无法感知数据的连续性。
架构设计思路
后端(生产者):利用数据库游标(Cursor)进行流式读取,不占用大量内存。通过 SSE 协议,将数据逐条或逐批推送到客户端。关键在于,后端需要引入人工限速机制,防止数据瞬间淹没前端。
网络层(管道):SSE 基于 HTTP 长连接,支持单向服务器推送。前端可随时通过
AbortController中断连接,这意味着如果用户只看前几页就关闭了页面,后端剩余的 99% 数据根本不会被读取或发送,极大地节省了服务器资源。前端(消费者):
接收:监听 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 次,极大地降低了浏览器的渲染开销。
方案优势总结
首屏极速:用户无需等待 100 万条数据全部加载,第一秒内即可看到首批数据。
资源节约:
后端:内存占用恒定(O(1)),且如果用户中途离开,后端通过 SSE 中断机制可立即停止数据库查询,节省 99% 的无效计算。
前端:通过批量渲染和 rAF,CPU 占用率平稳,不会出现瞬间飙升导致的页面卡死。
体验流畅:数据像“打字机”一样逐行出现,或者随着滚动平滑加载,消除了白屏焦虑。
进阶思考:虚拟滚动的必要性
阶段一(0 ~ 1000 条):使用上述的
DocumentFragment真实 DOM 追加方案,保证首屏和初期浏览的极致体验。阶段二(> 1000 条):当列表长度超过阈值,无缝切换至虚拟滚动(Virtual Scroll)。此时停止向 DOM 插入新节点,仅维护一个可视区域的窗口,根据滚动位置动态渲染数据。

发表评论