发布于: 2024-12-13最后更新: 2026-5-3字数 1019阅读时长 3 分钟

type
Post
status
Published
date
Dec 13, 2024
slug
内容自动跟随
summary
做 AI 聊天产品最容易翻车的细节之一,是「自动滚到底部」那个小逻辑。看着两行代码搞定,实际上踩坑能踩半年。这篇聊聊我做这一类功能时的几次反复。
tags
工具
开发
思考
category
技术分享
icon
password
synced
paired_with
3551d487-a2a1-81d8-ad0f-f1ba4443cdcb
source_hash
5702b96809e03a4e24bfe5b036f3fdf1e567dbe2ba8d9355b3b6a63fe6adee76
translation_locked
translation_locked
💭
做 AI 聊天产品有一个细节最容易翻车,那就是"自动滚到底部"。AI 在流式输出,新字一个一个往外蹦,理想状态下窗口跟着滚到最新的位置。但如果用户中途想往上看一眼之前的内容,它又得识相地停下来。这个判断"用户现在到底想不想跟"的小逻辑,看着两行代码就能写完,实际上踩坑能踩半年。这篇聊聊我做这一类功能时的几次反复。

最简单的版本

第一版我和大多数人一样,用阈值。距离底部多少像素之内算"用户想跟",超出就停。
跑起来很丝滑。但只要用户想往上回看一段比较长的回答,就会发现一个尴尬的现象。AI 还在生成,每来一段新内容,scrollHeight 变大,distance 跟着变大,于是 shouldAutoScroll 翻成 false,看起来像"它停了"。但只要 distance 再次掉到阈值以内(比如用户继续往下滚),它又会自动跳到底部,把用户当前看的内容一下顶飞。

第二版,加状态机

意识到这是状态问题之后,我把它改成显式的"跟随模式"。
关键是把"用户主动滚动"和"内容增长导致的位置变化"分开来。前者影响模式,后者只在 following 模式下才触发自动滚。
但还有个细节。怎么区分"用户主动滚"和"我自己滚的"?因为每次 scrollTop = scrollHeight 也会触发一次 scroll 事件。解决办法是写完之后立刻设个 flag

长对话的性能问题

问题不止在状态切换。当一段对话长到几千行,DOM 节点全部留着,光是滚动本身就开始卡。这时候得上虚拟滚动,只渲染视窗附近的几屏内容。
但虚拟滚动给"自动跟到底部"这件事增加了难度。scrollHeight 是按"假装所有 item 都渲染了"算的虚拟高度,所以 scrollTop = scrollHeight 不再等于"滚到最后一条"。要专门写一个 scrollToIndex(lastIndex) 才行。

那些更隐蔽的边界情况

写这种自动滚的时候,能掉进的坑远不止上面几个。
  • 窗口缩放。用户拉大浏览器窗口,clientHeight 变了,distance 算法重新跑一遍判断
  • 字号变化。用户按 Ctrl 加号放大字体,每个 item 的高度变了,虚拟滚动里假定的 itemHeight 失效
  • 图片加载。AI 输出里嵌了图,图加载完之前 DOM 里它的高度是 0,加载完后突然撑高,跟随逻辑乱跳
  • 键盘事件。用户按 Page Up 翻页,应该被识别为"想自由浏览",进入 free 模式
  • 触摸滑动。手机端用户用手指滑屏,wheel 事件不会触发,得听 touchmove
每一个都不难,但加起来够你写一个礼拜的。

写在最后

最后我学到一件事,跟用户意图打交道的功能,不要去预测用户想要什么,而是老老实实记录用户做了什么。所谓"智能跟随",本质上是把用户的滚动行为分类(主动离开 / 主动回归 / 被动跳变)然后按规则响应。机器学习不是这件事的好答案,状态机才是。

📎 参考文章


Loading...
Next.js还是Remix

Next.js还是Remix

2024 年 OpenAI 把 ChatGPT 的前端从 Next.js 换到了 Remix,技术圈炸了。这篇聊聊我看完 OpenAI 那篇博客后对两个框架的理解,以及自己什么时候会选哪个。


关于算法学习的思考

关于算法学习的思考

看了一篇 ACM Queue 的文章,又刷到一个抖音讲链表插入的视频,两件事撞到一起,让我回头反思自己学算法走过的弯路——原来差别不是天赋,是有没有人帮你把那层窗户纸捅破。


公告
网站持续更新中…