文章目录
- 前言
-
- 背景
- [第一战:SSE 滚动劫持](#第一战:SSE 滚动劫持)
-
- 原始问题
- [第一次尝试:用 state 跟踪滚动位置](#第一次尝试:用 state 跟踪滚动位置)
- [根因:React 状态更新的时序窗口](#根因:React 状态更新的时序窗口)
- [最终方案:`useLayoutEffect` + 直接读 DOM](#最终方案:
useLayoutEffect+ 直接读 DOM)
- [第二战:Cursor 分页](#第二战:Cursor 分页)
- 第三战:踩坑合集
-
- [坑 1:`nextCursorCreatedAt` 用 state 导致的死循环 ------ 致命的闭包过期](#坑 1:
nextCursorCreatedAt用 state 导致的死循环 —— 致命的闭包过期) - [坑 2:Observer 依赖 `isLoadingHistory` 导致来回销毁/重建](#坑 2:Observer 依赖
isLoadingHistory导致来回销毁/重建) - [坑 3:Observer 回调里的状态检查形同虚设](#坑 3:Observer 回调里的状态检查形同虚设)
- [坑 4:首次加载后不滚到底部](#坑 4:首次加载后不滚到底部)
- [坑 5:nodejieba 原生模块在 Next.js 构建时丢失](#坑 5:nodejieba 原生模块在 Next.js 构建时丢失)
- [坑 1:`nextCursorCreatedAt` 用 state 导致的死循环 ------ 致命的闭包过期](#坑 1:
- 最终架构一览
- 核心收获
前言
习惯公众号阅读的玩家 🚀https://mp.weixin.qq.com/s/27Q1DFa2gTb56EULRSK4Rg
记录我在一个 Next.js + Prisma + PostgreSQL 的 AI 聊天项目中,对聊天记录加载逻辑进行深度优化的完整过程。涉及 SSE 滚动劫持、游标分页、无限加载、闭包陷阱等多个真实踩坑经历。
背景
项目是一个基于 Next.js 15 App Router 的 AI 聊天应用,类似 ChatGPT 的交互体验:用户发起问题后,后端通过 SSE(Server-Sent Events)流式返回 AI 的回复,前端逐字渲染。
上线运行一段时间后,暴露了三个体验问题:
- SSE 流式输出时,用户向上翻阅历史会被强制拉回底部
- 打开一个对话后,所有历史消息一次性加载,消息多了会很慢
- 频繁刷新页面会触发多次重复的
/api/get-chat请求
下面按解决顺序展开。
第一战:SSE 滚动劫持
原始问题
ChatPanel.tsx 中,每次 SSE 推送一个字符 chunk,都会触发 setMessages 更新状态:
ts
// 原代码 ------ 每次 messages 变化都强制 scrollIntoView
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
用户一旦向上翻阅历史,下一个 chunk 到达 → setMessages 更新 → useEffect 触发 → 瞬间被拉回底部。完全无法浏览历史。
第一次尝试:用 state 跟踪滚动位置
ts
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false)
const handleScroll = useCallback(() => {
const nearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 120
setIsUserScrolledUp(!nearBottom)
}, [])
useEffect(() => {
if (!isUserScrolledUp) {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
}, [messages, isUserScrolledUp])
结果:不生效,还是被拉回。
根因:React 状态更新的时序窗口
setIsUserScrolledUp(true) 是一个异步调度 。如果在 state 生效之前 SSE 又推送了 chunk,useEffect 看到 isUserScrolledUp 仍然是 false,继续滚动。在高速 SSE 流式场景下,这个窗口几乎必定命中。
最终方案:useLayoutEffect + 直接读 DOM
移除中间状态 isUserScrolledUp,在 useLayoutEffect 中同步读取 DOM 的真实滚动位置来做判断:
ts
useLayoutEffect(() => {
const container = scrollContainerRef.current
if (!container) return
// 标记位优先:初始加载或需调整位置时跳过常规检测
if (pendingScrollAdjRef.current) {
const { prevScrollHeight } = pendingScrollAdjRef.current
container.scrollTop += container.scrollHeight - prevScrollHeight
pendingScrollAdjRef.current = null
return
}
if (shouldScrollToBottomRef.current) {
shouldScrollToBottomRef.current = false
container.scrollTop = container.scrollHeight
return
}
// 直接读 DOM ------ 不受 React 状态异步影响
const threshold = 15
const nearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < threshold
if (nearBottom) {
container.scrollTop = container.scrollHeight
}
}, [messages])
关键点:
useLayoutEffect在 DOM 更新后、浏览器绘制前同步执行 ,读到的scrollTop是真实的- 用
container.scrollTop = container.scrollHeight代替scrollIntoView({ behavior: 'smooth' }),避免平滑动画与用户手动滚动的冲突 - 用 ref 做标记位来处理特殊场景(初始加载、prepend 历史消息后的位置修正),而不引入额外的 state 导致渲染抖动
这样在AI返回结果的同时,用户向上查看历史记录,互不影响。

第二战:Cursor 分页
效果:

原始问题
/api/get-chat?conversationId=xxx 返回该对话的全部消息,无分页:
ts
const messages = await prisma.openRouterChat.findMany({
where: { conversationId },
orderBy: { createdAt: 'asc' },
})
一个对话积累几百上千条消息后,全量加载 + 全量 DOM 渲染会直接拖垮性能。
方案选择:Cursor 还是 Offset?
Cursor 分页是纯 SQL 概念,Prisma 只是把它包装了一层语法糖。
底层 SQL 对比
无论用不用 Prisma,最终落到数据库的都是同样的 SQL。
Offset 分页(SQL 标准写法)
sql
-- SQL 标准
SELECT * FROM "OpenRouterChat"
WHERE "conversationId" = 'xxx'
ORDER BY "createdAt" ASC
LIMIT 30 OFFSET 90;
typescript
// Prisma
prisma.openRouterChat.findMany({
where: { conversationId: 'xxx' },
orderBy: { createdAt: 'asc' },
take: 30,
skip: 90,
})
Cursor 分页(SQL 标准写法)
sql
-- SQL 标准(不使用 OFFSET,而是 WHERE 条件过滤)
SELECT * FROM "OpenRouterChat"
WHERE "conversationId" = 'xxx'
AND ("createdAt", "id") < ('2026-05-20T12:00:00Z', 'cm7xxxx')
ORDER BY "createdAt" DESC, "id" DESC
LIMIT 30;
typescript
// Prisma(cursor API 方式)
prisma.openRouterChat.findMany({
where: { conversationId: 'xxx' },
orderBy: { createdAt: 'asc' },
take: 30,
skip: 1,
cursor: { id: 'cm7xxxx' },
})
typescript
// 或者不用 Prisma cursor API,直接用 where 也能实现(推荐)
await prisma.$queryRaw`
SELECT * FROM "OpenRouterChat"
WHERE "conversationId" = ${conversationId}
AND ("createdAt", "id") < (${lastCreatedAt}, ${lastId})
ORDER BY "createdAt" DESC, "id" DESC
LIMIT ${limit}
`
核心理解
| 概念 | 是谁的 |
|---|---|
SELECT ... LIMIT N OFFSET M |
SQL 标准(广泛支持) |
WHERE createdAt < $cursor 代替 OFFSET |
SQL 标准(任何数据库都支持) |
cursor: { id: 'xxx' }, skip: 1 |
Prisma 的语法糖,编译后生成上面的 SQL |
cursor 字段必须是 @id 或 @@unique |
Prisma 的限制(SQL 本身没有这个限制) |
ORDER BY createdAt ASC 和 WHERE createdAt < $cursor 配合 |
纯 SQL 设计模式,与 ORM 无关 |
换个数据库验证
同样的游标分页,不用 Prisma,用 pg 驱动直连写也是一样的:
typescript
// 只用 pg 驱动,不用任何 ORM
const result = await pool.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:
sql
-- Prisma 内部生成的 SQL(简化)
SELECT * FROM "OpenRouterChat"
WHERE "conversationId" = 'xxx'
AND "id" > 'cm7xxxx' -- ← cursor 展开成 WHERE 条件
ORDER BY "createdAt" ASC, "id" ASC -- ← 自动补 id 做 tiebreaker
LIMIT 30
OFFSET 1 -- ← 跳过游标本身
但 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 表加复合索引:
js
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 层
ts
// src/app/api/get-chat/route.ts
const cursorCreatedAt = searchParams.get('cursorCreatedAt')
const limit = Math.min(parseInt(searchParams.get('limit') || '10'), 100)
if (conversationId) {
const where: Record<string, unknown> = { conversationId }
if (cursorCreatedAt) {
where.createdAt = { lt: new Date(cursorCreatedAt) }
}
// 降序取 limit+1 条(多取 1 条判断 hasMore,免去额外 count 查询)
const messages = await prisma.openRouterChat.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit + 1,
})
const hasMore = messages.length > limit
if (hasMore) messages.pop()
messages.reverse() // 恢复为升序展示
return NextResponse.json({
messages,
nextCursorCreatedAt: hasMore ? messages[0]?.createdAt.toISOString() : null,
hasMore,
})
}
设计亮点:
take: limit + 1:多取 1 条判断是否还有更多数据,避免一次额外的count查询cursorCreatedAt做游标键:利用@@index([conversationId, createdAt]),WHERE + ORDER BY 走复合索引,不回表- 首次请求不传
cursorCreatedAt,取最新 N 条
前端层
ts
const PAGE_SIZE = 10
const sentinelRef = useRef<HTMLDivElement>(null)
const nextCursorRef = useRef<string | null>(null) // 注意:ref 而非 state!
const isLoadingHistoryRef = useRef(false) // 同步防重锁
// IntersectionObserver 监听 sentinel 进入视口
useEffect(() => {
if (!hasMore) return
const sentinel = sentinelRef.current
if (!sentinel) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && currentConversationId) {
loadOlderMessages()
}
},
{ rootMargin: '200px 0px 0px 0px' } // 提前 200px 触发,无感加载
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [hasMore, currentConversationId])
rootMargin: '200px 0px 0px 0px' 是体验的关键------触顶前 200px 就开始拉数据,等用户真正滚动到顶部时数据已经就绪。
第三战:踩坑合集
坑 1:nextCursorCreatedAt 用 state 导致的死循环 ------ 致命的闭包过期
现象:上滚加载历史时,第 2、3、4... 次请求的 cursor 始终是同一个值,反复请求同一页数据。
根因 :useEffect 的依赖数组里没有 nextCursorCreatedAt(它是 state),Observer 闭包捕获了初始值。而 hasMore 始终是 true(一直有更旧的数据),所以 useEffect 不会重建 Observer。
js
hasMore 不变 (true → true)
→ Observer 不重建
→ loadOlderMessages 闭包里的 nextCursorCreatedAt 永不变
→ 每次请求相同的 cursor
→ 返回相同数据
→ hasMore 还是 true
→ 死循环
修复 :用 useRef 代替 useState 存储 cursor:
ts
const nextCursorRef = useRef<string | null>(null)
// 写入(同步生效)
nextCursorRef.current = data.nextCursorCreatedAt
// 读取(始终是最新值,不受闭包影响)
`/api/get-chat?cursorCreatedAt=${nextCursorRef.current}&limit=${PAGE_SIZE}`
坑 2:Observer 依赖 isLoadingHistory 导致来回销毁/重建
原本依赖是 [hasMore, isLoadingHistory, currentConversationId],每次 isLoadingHistory toggle 都 disconnect → reconnect,sentinel 重新进入视口立即触发。
修复 :依赖精简为 [hasMore, currentConversationId],防重逻辑下沉到 loadOlderMessages 内部用 ref 做同步检查:
ts
if (isLoadingHistoryRef.current) return // ← ref 同步检查,无延迟
isLoadingHistoryRef.current = true // ← 立即加锁
坑 3:Observer 回调里的状态检查形同虚设
ts
// 修复前 ------ hasMore 和 isLoadingHistory 是闭包捕获的过期值
if (entry.isIntersecting && hasMore && !isLoadingHistory && currentConversationId)
// 修复后 ------ 只做最小检查,所有权限控制下沉到函数内部
if (entry.isIntersecting && currentConversationId)
坑 4:首次加载后不滚到底部
useLayoutEffect 中的 nearBottom 检测公式 scrollHeight - 0 - clientHeight < 15 在首次加载时 scrollTop = 0,条件永远不成立。
修复 :用 shouldScrollToBottomRef 标记初始加载完成,在 useLayoutEffect 中优先处理。
坑 5:nodejieba 原生模块在 Next.js 构建时丢失
nodejieba 是 C++ 原生模块,webpack 打包时路径错乱。修复只需要一行配置:
ts
// next.config.ts
serverExternalPackages: ['nodejieba'],
最终架构一览
js
用户打开对话
↓
GET /api/get-chat?conversationId=xxx&limit=10
↓ (走 @@index([conversationId, createdAt]))
SELECT ... WHERE conversationId = 'xxx'
ORDER BY createdAt DESC LIMIT 11
↓
返回 { messages(10条), nextCursorCreatedAt, hasMore }
↓
用户上滚 → sentinel(哨兵) 进入视口 (提前 200px)
↓
useLayoutEffect: shouldScrollToBottomRef → 强制滚底
↓
GET /api/get-chat?conversationId=xxx&cursorCreatedAt=...&limit=10
↓ (cursor 直接定位,O(1))
SELECT ... WHERE conversationId = 'xxx' AND createdAt < '...'
ORDER BY createdAt DESC LIMIT 11
↓
prepend 到消息列表 + useLayoutEffect 修正 scrollTop(不跳动)
SSE 流式输出中:
useLayoutEffect → 读 DOM scrollTop → 在底部?→ 滚到最新
→ 不在底部?→ 什么都不做 + 显示浮窗按钮
核心收获
-
跟 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配置可以免去大量原生模块的折腾。