刷新不丢、断线能续------持久化与恢复机制
Token管理好了,但用户不小心刷新页面,一切归零。在地铁里信号差,AI说了半句话就断了。本文用IndexedDB + SSE断线续传构建坚韧的体验。
01. 场景一:刷新页面,对话消失
用户聊了半小时,误触F5。由于内存数据重置,界面一片空白。用户愤怒地关闭了网页。
解决 :每次AI回复完成,将完整对话序列化存入IndexedDB(不要用localStorage,它只能存5MB且同步阻塞)。
IndexedDB封装(使用Dexie.js):
typescript
import Dexie from 'dexie';
const db = new Dexie('AIChatDB');
db.version(1).stores({
sessions: 'sessionId, lastUpdated',
messages: 'id, sessionId, timestamp',
});
// 存储消息
async function saveMessages(sessionId: string, messages: Message[]) {
await db.messages.bulkPut(
messages.map(m => ({ ...m, sessionId, timestamp: Date.now() }))
);
await db.sessions.put({ sessionId, lastUpdated: Date.now() });
}
// 页面加载时恢复
async function loadSession(sessionId: string): Promise<Message[]> {
const cached = await db.messages.where('sessionId').equals(sessionId).toArray();
return cached.sort((a, b) => a.timestamp - b.timestamp);
}
// 启动时:快速先用IndexedDB展示,再去服务端拉取最新差异(乐观更新)
useEffect(() => {
loadSession(sessionId).then(localMsgs => {
setMessages(localMsgs);
// 后台静默同步服务端
fetchFromServer(sessionId).then(serverMsgs => {
if (serverMsgs.length > localMsgs.length) {
setMessages(mergeMessages(localMsgs, serverMsgs));
saveMessages(sessionId, serverMsgs);
}
});
});
}, []);
02. 场景二:SSE断线续传(Last-Event-ID)
大模型流式输出使用SSE(Server-Sent Events)。网络不稳定时连接断开,用户不想重头生成。
SSE原生支持断线续传 :浏览器在重连时会自动发送Last-Event-ID头。
服务端实现:
- 每个数据块携带
id(自增或时间戳+随机数)。 - 接收到
Last-Event-ID后,从该ID之后继续推送剩余内容。
前端实现(EventSource封装):
typescript
class ResilientEventSource {
private url: string;
private lastId: string = '';
private retryCount = 0;
private maxRetries = 5;
private eventSource: EventSource | null = null;
connect(onMessage: (chunk: string) => void) {
// 关键:如果存在lastId,拼接到URL参数中
const urlWithId = this.lastId ? `${this.url}?lastEventId=${this.lastId}` : this.url;
this.eventSource = new EventSource(urlWithId);
this.eventSource.onmessage = (event) => {
// 解析并保存最新的ID
const data = JSON.parse(event.data);
if (data.id) this.lastId = data.id;
// 处理内容块(可能是Delta)
if (data.content) {
onMessage(data.content);
}
// 重置重试计数
this.retryCount = 0;
};
this.eventSource.onerror = (e) => {
this.eventSource?.close();
// 指数退避重连
const delay = Math.min(1000 * Math.pow(2, this.retryCount), 30000);
setTimeout(() => {
this.retryCount++;
if (this.retryCount <= this.maxRetries) {
console.log(`重连中... 第${this.retryCount}次`);
this.connect(onMessage);
}
}, delay);
};
}
disconnect() {
this.eventSource?.close();
this.eventSource = null;
}
}
03. 流式渲染的状态管理进阶(归一化)
SSE高频推送(每几十毫秒一次)。如果直接setMessages([...messages, newChunk]),整个虚拟滚动列表都会重渲染,即使其他消息没变。
使用归一化结构(Normalized State):
typescript
// 状态结构
interface ChatState {
entities: Record<string, Message>; // id -> Message
order: string[]; // 仅存ID顺序
}
// 流式更新时
const updateStreamingMessage = (id: string, deltaContent: string) => {
setState(prev => ({
...prev,
entities: {
...prev.entities,
[id]: {
...prev.entities[id],
content: prev.entities[id].content + deltaContent,
}
}
// order 完全不动!不触发列表顺序重新计算!
}));
};
结合Valtio(Proxy-based状态管理) : Valtio能自动追踪依赖,只有消费了entities[id].content的组件才会重渲染。
tsx
import { proxy, useSnapshot } from 'valtio';
const store = proxy<ChatState>({ entities: {}, order: [] });
// 在MessageItem中
function MessageItem({ id }) {
const snap = useSnapshot(store);
const msg = snap.entities[id]; // 只有这个msg变化时才重渲染
return <div>{msg.content}</div>;
}
04. 跨设备同步
如果用户PC和手机同时登录,本地IndexedDB不互通。必须依赖服务端全量存储,前端通过sessionId拉取。实现WebSocket或轮询增量同步。
📖 下一篇预告(第05篇)
P7的终极答案------服务端会话 + 分层存储
场景预告:前面四层全做了,但遇到上万轮对话的重度用户还是扛不住------问题出在架构上。
核心看点:上下文管理从客户端剥离下沉至Redis / 服务端会话管理完整交互流程 / 前端热/温/冷三层存储架构 / Sentry内存监控阈值告警与自动清理
👋 最后一篇硬核架构篇,关注我别错过!
- 点击「关注」获取更新推送
- 已关注的朋友,欢迎转发给需要的同事或朋友
「点赞 👍 + 收藏 ⭐ + 评论 💬」三连走起,让好内容流动起来!