AI客服聊天记录优化:从全量加载到游标分页 文章目录前言背景第一战SSE 滚动劫持原始问题第一次尝试用 state 跟踪滚动位置根因React 状态更新的时序窗口最终方案useLayoutEffect 直接读 DOM这样在AI返回结果的同时用户向上查看历史记录互不影响。第二战Cursor 分页效果原始问题方案选择Cursor 还是 Offset底层 SQL 对比Offset 分页SQL 标准写法Cursor 分页SQL 标准写法核心理解换个数据库验证所以 Prisma 在这里做了什么一句话总结数据库层API 层前端层第三战踩坑合集坑 1nextCursorCreatedAt 用 state 导致的死循环 —— 致命的闭包过期坑 2Observer 依赖 isLoadingHistory 导致来回销毁/重建坑 3Observer 回调里的状态检查形同虚设坑 4首次加载后不滚到底部坑 5nodejieba 原生模块在 Next.js 构建时丢失最终架构一览核心收获前言习惯公众号阅读的玩家 https://mp.weixin.qq.com/s/27Q1DFa2gTb56EULRSK4Rg记录我在一个 Next.js Prisma PostgreSQL 的 AI 聊天项目中对聊天记录加载逻辑进行深度优化的完整过程。涉及 SSE 滚动劫持、游标分页、无限加载、闭包陷阱等多个真实踩坑经历。背景项目是一个基于 Next.js 15 App Router 的 AI 聊天应用类似 ChatGPT 的交互体验用户发起问题后后端通过 SSEServer-Sent Events流式返回 AI 的回复前端逐字渲染。上线运行一段时间后暴露了三个体验问题SSE 流式输出时用户向上翻阅历史会被强制拉回底部打开一个对话后所有历史消息一次性加载消息多了会很慢频繁刷新页面会触发多次重复的/api/get-chat请求下面按解决顺序展开。第一战SSE 滚动劫持原始问题ChatPanel.tsx中每次 SSE 推送一个字符 chunk都会触发setMessages更新状态// 原代码 —— 每次 messages 变化都强制 scrollIntoViewuseEffect((){messagesEndRef.current?.scrollIntoView({behavior:smooth})},[messages])用户一旦向上翻阅历史下一个 chunk 到达 →setMessages更新 →useEffect触发 → 瞬间被拉回底部。完全无法浏览历史。第一次尝试用 state 跟踪滚动位置const[isUserScrolledUp,setIsUserScrolledUp]useState(false)consthandleScrolluseCallback((){constnearBottomcontainer.scrollHeight-container.scrollTop-container.clientHeight120setIsUserScrolledUp(!nearBottom)},[])useEffect((){if(!isUserScrolledUp){messagesEndRef.current?.scrollIntoView({behavior:smooth})}},[messages,isUserScrolledUp])结果不生效还是被拉回。根因React 状态更新的时序窗口setIsUserScrolledUp(true)是一个异步调度。如果在 state 生效之前 SSE 又推送了 chunkuseEffect看到isUserScrolledUp仍然是false继续滚动。在高速 SSE 流式场景下这个窗口几乎必定命中。最终方案useLayoutEffect 直接读 DOM移除中间状态isUserScrolledUp在useLayoutEffect中同步读取 DOM 的真实滚动位置来做判断useLayoutEffect((){constcontainerscrollContainerRef.currentif(!container)return// 标记位优先初始加载或需调整位置时跳过常规检测if(pendingScrollAdjRef.current){const{prevScrollHeight}pendingScrollAdjRef.current container.scrollTopcontainer.scrollHeight-prevScrollHeight pendingScrollAdjRef.currentnullreturn}if(shouldScrollToBottomRef.current){shouldScrollToBottomRef.currentfalsecontainer.scrollTopcontainer.scrollHeightreturn}// 直接读 DOM —— 不受 React 状态异步影响constthreshold15constnearBottomcontainer.scrollHeight-container.scrollTop-container.clientHeightthresholdif(nearBottom){container.scrollTopcontainer.scrollHeight}},[messages])关键点useLayoutEffect在 DOM 更新后、浏览器绘制前同步执行读到的scrollTop是真实的用container.scrollTop container.scrollHeight代替scrollIntoView({ behavior: smooth })避免平滑动画与用户手动滚动的冲突用 ref 做标记位来处理特殊场景初始加载、prepend 历史消息后的位置修正而不引入额外的 state 导致渲染抖动这样在AI返回结果的同时用户向上查看历史记录互不影响。第二战Cursor 分页效果原始问题/api/get-chat?conversationIdxxx返回该对话的全部消息无分页constmessagesawaitprisma.openRouterChat.findMany({where:{conversationId},orderBy:{createdAt:asc},})一个对话积累几百上千条消息后全量加载 全量 DOM 渲染会直接拖垮性能。方案选择Cursor 还是 OffsetCursor 分页是纯 SQL 概念Prisma 只是把它包装了一层语法糖。底层 SQL 对比无论用不用 Prisma最终落到数据库的都是同样的 SQL。Offset 分页SQL 标准写法-- SQL 标准SELECT*FROMOpenRouterChatWHEREconversationIdxxxORDERBYcreatedAtASCLIMIT30OFFSET90;// Prismaprisma.openRouterChat.findMany({where:{conversationId:xxx},orderBy:{createdAt:asc},take:30,skip:90,})Cursor 分页SQL 标准写法-- SQL 标准不使用 OFFSET而是 WHERE 条件过滤SELECT*FROMOpenRouterChatWHEREconversationIdxxxAND(createdAt,id)(2026-05-20T12:00:00Z,cm7xxxx)ORDERBYcreatedAtDESC,idDESCLIMIT30;// Prismacursor API 方式prisma.openRouterChat.findMany({where:{conversationId:xxx},orderBy:{createdAt:asc},take:30,skip:1,cursor:{id:cm7xxxx},})// 或者不用 Prisma cursor API直接用 where 也能实现推荐awaitprisma.$queryRawSELECT * FROM OpenRouterChat WHERE conversationId ${conversationId}AND (createdAt, id) (${lastCreatedAt},${lastId}) ORDER BY createdAt DESC, id DESC LIMIT${limit}核心理解概念是谁的SELECT ... LIMIT N OFFSET MSQL 标准广泛支持WHERE createdAt $cursor代替OFFSETSQL 标准任何数据库都支持cursor: { id: xxx }, skip: 1Prisma 的语法糖编译后生成上面的 SQLcursor字段必须是id或uniquePrisma 的限制SQL 本身没有这个限制ORDER BY createdAt ASC和WHERE createdAt $cursor配合纯 SQL 设计模式与 ORM 无关换个数据库验证同样的游标分页不用 Prisma用pg驱动直连写也是一样的// 只用 pg 驱动不用任何 ORMconstresultawaitpool.query(SELECT * FROM OpenRouterChat WHERE conversationId $1 AND createdAt $2 ORDER BY createdAt DESC LIMIT $3,[conversationId,lastCreatedAt,limit])换 MySQL、SQLite 也一样只是语法微调MySQL 用?占位SQLite 同 PostgreSQL。所以 Prisma 在这里做了什么Prisma 的cursor: { id: xxx }帮你生成了类似这样的 SQL-- Prisma 内部生成的 SQL简化SELECT*FROMOpenRouterChatWHEREconversationIdxxxANDidcm7xxxx-- ← cursor 展开成 WHERE 条件ORDERBYcreatedAtASC,idASC-- ← 自动补 id 做 tiebreakerLIMIT30OFFSET1-- ← 跳过游标本身但 Prisma 的限制是cursor 必须用id或unique字段而 SQL 本身没有这个限制——你可以用任何字段做 WHERE 过滤来实现游标。一句话总结LIMIT是 SQL 语法OFFSET是 SQL 语法WHERE col $cursor也是 SQL 语法。Prisma 只是把WHERE id $cursor包装成了cursor: { id: xxx }这样更声明式的写法。如果 Prisma 的限制让你束手束脚比如 cursor 字段必须唯一直接写WHERE createdAt $cursor就行了完全绕过 Prisma 的 cursor API。维度Offset (skip/take)Cursor (WHERE createdAt $cursor)深层分页性能线性衰减跳过的行仍需扫描O(1)恒定并发写入稳定性SSE 持续插入数据会偏移不受影响随机跳页✅ 支持❌ 不支持聊天不需要数据库索引要求(conversationId)(conversationId, createdAt)聊天场景天然是「无限滚动 持续追加」的模型Cursor 分页完美匹配。数据库层第一步给OpenRouterChat表加复合索引model OpenRouterChat{id String id default(cuid())userId String conversationId String role String content String createdAt DateTime default(now())updatedAt DateTime updatedAt index([conversationId,createdAt])// ← 游标分页的核心索引index([userId,createdAt])// ← 对话列表查询}API 层// src/app/api/get-chat/route.tsconstcursorCreatedAtsearchParams.get(cursorCreatedAt)constlimitMath.min(parseInt(searchParams.get(limit)||10),100)if(conversationId){constwhere:Recordstring,unknown{conversationId}if(cursorCreatedAt){where.createdAt{lt:newDate(cursorCreatedAt)}}// 降序取 limit1 条多取 1 条判断 hasMore免去额外 count 查询constmessagesawaitprisma.openRouterChat.findMany({where,orderBy:{createdAt:desc},take:limit1,})consthasMoremessages.lengthlimitif(hasMore)messages.pop()messages.reverse()// 恢复为升序展示returnNextResponse.json({messages,nextCursorCreatedAt:hasMore?messages[0]?.createdAt.toISOString():null,hasMore,})}设计亮点take: limit 1多取 1 条判断是否还有更多数据避免一次额外的count查询cursorCreatedAt做游标键利用index([conversationId, createdAt])WHERE ORDER BY 走复合索引不回表首次请求不传cursorCreatedAt取最新 N 条前端层constPAGE_SIZE10constsentinelRefuseRefHTMLDivElement(null)constnextCursorRefuseRefstring|null(null)// 注意ref 而非 stateconstisLoadingHistoryRefuseRef(false)// 同步防重锁// IntersectionObserver 监听 sentinel 进入视口useEffect((){if(!hasMore)returnconstsentinelsentinelRef.currentif(!sentinel)returnconstobservernewIntersectionObserver(([entry]){if(entry.isIntersectingcurrentConversationId){loadOlderMessages()}},{rootMargin:200px 0px 0px 0px}// 提前 200px 触发无感加载)observer.observe(sentinel)return()observer.disconnect()},[hasMore,currentConversationId])rootMargin: 200px 0px 0px 0px是体验的关键——触顶前 200px 就开始拉数据等用户真正滚动到顶部时数据已经就绪。第三战踩坑合集坑 1nextCursorCreatedAt用 state 导致的死循环 —— 致命的闭包过期现象上滚加载历史时第 2、3、4… 次请求的 cursor 始终是同一个值反复请求同一页数据。根因useEffect的依赖数组里没有nextCursorCreatedAt它是 stateObserver 闭包捕获了初始值。而hasMore始终是true一直有更旧的数据所以useEffect不会重建 Observer。hasMore不变(true→true)→ Observer 不重建 → loadOlderMessages 闭包里的 nextCursorCreatedAt 永不变 → 每次请求相同的 cursor → 返回相同数据 → hasMore 还是true→ 死循环修复用useRef代替useState存储 cursorconstnextCursorRefuseRefstring|null(null)// 写入同步生效nextCursorRef.currentdata.nextCursorCreatedAt// 读取始终是最新值不受闭包影响/api/get-chat?cursorCreatedAt${nextCursorRef.current}limit${PAGE_SIZE}坑 2Observer 依赖isLoadingHistory导致来回销毁/重建原本依赖是[hasMore, isLoadingHistory, currentConversationId]每次isLoadingHistorytoggle 都 disconnect → reconnectsentinel 重新进入视口立即触发。修复依赖精简为[hasMore, currentConversationId]防重逻辑下沉到loadOlderMessages内部用 ref 做同步检查if(isLoadingHistoryRef.current)return// ← ref 同步检查无延迟isLoadingHistoryRef.currenttrue// ← 立即加锁坑 3Observer 回调里的状态检查形同虚设// 修复前 —— hasMore 和 isLoadingHistory 是闭包捕获的过期值if(entry.isIntersectinghasMore!isLoadingHistorycurrentConversationId)// 修复后 —— 只做最小检查所有权限控制下沉到函数内部if(entry.isIntersectingcurrentConversationId)坑 4首次加载后不滚到底部useLayoutEffect中的nearBottom检测公式scrollHeight - 0 - clientHeight 15在首次加载时scrollTop 0条件永远不成立。修复用shouldScrollToBottomRef标记初始加载完成在useLayoutEffect中优先处理。坑 5nodejieba 原生模块在 Next.js 构建时丢失nodejieba是 C 原生模块webpack 打包时路径错乱。修复只需要一行配置// next.config.tsserverExternalPackages:[nodejieba],最终架构一览用户打开对话 ↓GET/api/get-chat?conversationIdxxxlimit10↓(走 index([conversationId,createdAt]))SELECT...WHEREconversationIdxxxORDERBYcreatedAtDESCLIMIT11↓ 返回{messages(10条),nextCursorCreatedAt,hasMore}↓ 用户上滚 → sentinel哨兵进入视口(提前 200px)↓useLayoutEffect:shouldScrollToBottomRef → 强制滚底 ↓GET/api/get-chat?conversationIdxxxcursorCreatedAt...limit10↓(cursor 直接定位O(1))SELECT...WHEREconversationIdxxxANDcreatedAt...ORDERBYcreatedAtDESCLIMIT11↓ prepend 到消息列表useLayoutEffect 修正 scrollTop不跳动SSE流式输出中 useLayoutEffect → 读DOMscrollTop → 在底部→ 滚到最新 → 不在底部→ 什么都不做显示浮窗按钮核心收获跟 DOM 有关的判断尽量直接读 DOM 而非用 state 中转。React 状态是异步的DOM 是同步的。Cursor 分页不是 ORM 特性是 SQL 层的设计模式。Prisma 的cursorAPI 有限制必须id/unique但你可以用where.createdAt { lt: cursor }绕过本质是一样的。IntersectionObserver的回调闭包会过期。回调里用到的 state 值需要放在useEffect依赖中但这又会导致 Observer 重建。解法是把真正「会变的值」用useRef存Observer 回调里直接读 ref。rootMargin是无限滚动的体验魔药。提前 200px 触发加载用户根本察觉不到分页的存在。一行serverExternalPackages配置可以免去大量原生模块的折腾。