1. 业务背景与技术难点
在诸如 ChatGPT、Claude 以及其他大语言模型 (LLM) 分析系统的 UI 设计中,通常采用“流式输出 (Streaming)”的交互方式。在回答生成期间,内容会不断向下追加,为了良好的阅读体验,界面需要自动跟随滚动到底部 (Auto-Stick to Bottom)。
⚠️ 技术冲突:如果用户在生成期间觉得前面某段内容很重要,主动向上滚动查看历史记录,此时系统必须立即停止自动滚动,否则用户会被强制拽回底部,造成极其恶劣的体验。
这就引出了一个前端界面的核心技术痛点:原生的 DOMscroll事件本身仅仅是一个结果通知,它不附带任何上下文信息,无法区分到底是“用户操作触发”的滚动,还是“代码逻辑 (Programmatic) 触发”的滚动。
2. 核心架构设计思想
为了精准识别滚动来源,系统采用了一套基于启发式的“事件监听 + 时间窗口 (Time Window) + 优先级裁决”模型。该模型不直接依赖scroll事件去判断来源,而是通过建立一个事件包裹层,监听所有可能引发滚动的输入设备事件。
每当捕获到物理层面的交互事件(如鼠标拖动、滚轮、按键、触摸)时,系统会为该类型的事件开启一个短暂的“有效时间窗口 (Until Token)”。当真正的scroll事件发生时,通过比对当前时间戳与各个激活的时间窗口,按优先级进行认领。
3. 优先级裁决流 (Decision Pipeline)
当scroll事件触发时,核心的classify()方法获取当前时间戳t,并严格按以下次序进行断言,命中即返回:
| 优先级 | 判定条件 | 判定结果 (Method) | 类型归属 |
|---|---|---|---|
| 1 | 当前正在触摸屏幕 (isTouching) | touch (触摸滑动) | 用户主动行为 |
| 2 | 触摸离开,但仍在惯性滑动窗口期内 (isTouchInertia) | touch (惯性滑动) | 用户主动行为 |
| 3 | 鼠标正悬停或拖拽原生滚动条 (isMouseOnScrollbar) | mouse-drag (滚动条拖拽) | 用户主动行为 |
| 4 | 鼠标正在页面上任意拖拽中 (dragging) | mouse-drag (鼠标拖拽) | 用户主动行为 |
| 5 | 处于程序化滚动的强制保护期内 | programmatic (代码强制) | 系统自动行为 |
| 6 | 处于鼠标滚轮窗口期内 (wheelUntil) | wheel (滚轮) | 用户主动行为 |
| 7 | 处于键盘控制窗口期内 (keyboardUntil) | keyboard (键盘) | 用户主动行为 |
| 8 | 以上皆未命中(兜底) | programmatic | 系统自动行为 |
4. 各设备交互场景的深度拆解与防误判
A. 移动端触摸滚动 (Touch)
触摸是最容易产生误判的场景。用户可能是想滑动页面,也可能是仅仅点击了一个触发程序化滚动的按钮(比如“置底”按钮)。
滑动防抖:在
touchstart记录初始坐标,在touchmove中计算位移距离。只有水平或垂直位移超过阈值(如tapMaxMovePx = 6px)时,才确认为实质上的滑动。惯性兼容:当
touchend发生时,由于移动端存在惯性滚动特性,系统会立刻开启一段 100ms 的惯性窗口期 (touchInertiaWindowMs),并将随后在此窗口期内发生的滚动依然算作 Touch 行为。点击剥离:如果用户仅仅是短触且未产生滑动位移,系统会将其视为点击操作,主动赋予一段时间的
tapProgrammaticUntil保护期,防止点击代码控制按钮导致的滚动被误算为手指滑动。
B. 桌面端原生滚动条拖拽 (Mouse-Drag)
用户直接用鼠标拖动侧边栏的滚动条时,会触发mousedown等事件。系统使用了巧妙的 DOM 几何运算来检测:
💡 原理:系统会实时计算容器元素的带滚动条总宽 (
offsetWidth) 和纯内容宽 (clientWidth)。两者的差值即为垂直滚动条的宽度 (verticalGutter)。 结合mousedown时的鼠标坐标偏移量x, y,精准判断鼠标是否正好落在了右侧(或底部)的滚动条区域内。如果是,则锁定isMouseOnScrollbar状态。
C. 键盘控制滚动 (Keyboard)
使用 PageUp/PageDown、空格、上下方向键也是常见的浏览手段。但用户也可能在输入框内使用这些按键。
按键白名单:系统通过
isScrollKey()方法仅拦截有滚动语义的键(ArrowUp, PageDown, Home, End, Spacebar 等)。上下文免疫:利用
document.activeElement检查当前焦点。如果焦点处于input,textarea,select或设定了contenteditable的元素内,用户的敲击会被直接放行,不作为滚动事件记录。
D. 程序化滚动 API (Programmatic)
该机制并非纯被动响应。业务逻辑在调用scrollTo()或者其他导致视图突变的代码前,可以主动调用工具类提供的markProgrammatic(durationMs)方法。
该方法会强制延后tapProgrammaticUntil标记。根据第三节的优先级裁决,在此窗口期内,任何滚动都会被强行判给programmatic,这能有效地应对混合交互(如用户按下一个按键,触发了复杂的业务逻辑并执行了滚动)时的误判问题。
5. 总结
通过这套ScrollSource的精巧设计,上层业务逻辑只需监听最终抛出的带上下文的事件回调。一旦发现当前滚动是由用户主观意志产生的(info.isUser === true),且向上发生了滚动,上层便可立刻断开“跟随置底”功能。这套机制避免了去 Hack 浏览器的底层事件,利用高维的时序状态机优雅地解决了大语言模型流式输出界面的核心体验难题。