DeepChat Chat Scroll Ownership 重构实战:从 3000 行 ChatPage 到单滚动所有者状态机
【免费下载链接】deepchat🐬DeepChat - A smart assistant that connects powerful AI to your personal world项目地址: https://gitcode.com/GitHub_Trending/dee/deepchat
本篇技术指南以 DeepChat 仓库中 chat-scroll-ownership/tasks.md 的任务清单为主轴,完整梳理「Chat 滚动所有权」重构的八个阶段:从基线取证、纯状态机、会话预加载,到控制器接入、隔离页面几何、特性兼容与性能清理。读完本文,你将掌握如何为一个超大型 Vue 渲染组件建立单一滚动所有者架构,用 typed request、session epoch 与独占仲裁器根治「闪跳、回滚、滚动条被抢」等浏览器布局难题,并了解仓库中对应的源码实现与验收标准。
背景:为什么滚动需要「所有权」而不是「时间窗口」
重构的起点是 spec.md 中描述的核心问题:ChatPage.vue在超过 3,000 行的单组件里同时承担页面编排、消息转换、会话恢复、历史分页、有界渲染、行测量、自动跟随、搜索、Spotlight 导航、计划布局、编辑器行为与直接 DOM 滚动。
更致命的是:同一个外层元素既是页面外壳又是消息滚动容器。其滚动几何因此包含了顶栏、搜索框、消息、计划内边距、pending 车道、状态内容和吸底编辑器。这些区域的高度变化与消息无关,却会让 Chromium 在 JavaScript 没有显式请求滚动时自行钳制scrollTop。同时,多个代码路径各自通过scrollTop或scrollIntoView写滚动位置,所有权只能靠重叠的毫秒时间窗口推断,jsdom 单测无法复现真实 Chromium 的布局、sticky 定位、原生滚动锚定、合成器行为与滚动钳制。
因此本项目在 plan.md 中确立了九大目标,核心三条为:
- 让消息视口成为唯一拥有聊天滚动的元素;
- 所有程序化滚动经由一个带显式 reason 与 priority 的 typed controller 路由;
- 活跃用户手势获得持久所有权,直到用户显式返回底部或发起导航。
阶段总览:八阶段渐进迁移,而非一次性重写
tasks.md 将整个迁移切分为八个阶段,每个阶段保持仓库可构建、既有测试保持绿色,避免长时间并存两套生产滚动实现:
| 阶段 | 主题 | 状态(截至 2026-07-15 左右) |
|---|---|---|
| Phase 0 | 基线与浏览器取证 | 进行中 |
| Phase 1 | 纯所有权模型 | 已完成 |
| Phase 2 | 会话准备与性能基线 | 已完成 |
| Phase 3 | 当前 DOM 上的控制器集成 | 已完成 |
| Phase 4 | 隔离页面几何 | 进行中 |
| Phase 5 | 特性兼容 | 待办 |
| Phase 6 | 性能与清理 | 进行中 |
| Phase 7 | 验证与交接 | 进行中 |
| Phase 8 | 评审加固 | 已完成 |
Phase 0:先取证,再动手改架构
Phase 0 强调「先复现、再重构」:
- 添加仅开发环境生效的滚动插桩,记录 reason、request ID、session epoch、期望 top、实际 top、scroll height、client height 与 anchor ID;
- 添加可实际驱动 ChatPage 布局与原生滚动的真实 Chromium 测试 harness;
- 在改动架构前先复现并记录「四条消息回滚」问题;
- 为恢复、流式、编辑器 resize、计划、搜索、Spotlight、历史、编辑器聚焦与长窗口测量建立基线场景。
这一步对应 plan.md 中的迁移顺序第一步:先建立浏览器回归 harness 与滚动写插桩,度量当前行为,后续每步迁移都以此对照。仓库中对应的 opt-in Electron 场景位于test/e2e/specs/31-chat-scroll-ownership.smoke.spec.ts,Playwright 已能成功发现它,但真实 Electron/Provider 场景与 macOS 触控板手动矩阵仍待完成。
Phase 1:纯所有权模型 —— 状态机、优先级与独占仲裁
Phase 1 是全部工作的理论地基,全部标为完成。它交付了四样东西:typed 滚动原因/目标/请求/状态、纯滚动状态机、一帧一写的请求队列、跨帧只有一个活跃所有者的独占操作仲裁器。
Typed 原因与请求
在源码 chatScrollState.ts 中,滚动原因被限定为以下联合类型:
export type ChatScrollReason = | 'session-restore' | 'auto-follow' | 'submit' | 'history-prepend' | 'measurement-anchor' | 'history-navigation' | 'search-navigation' | 'spotlight-navigation' | 'user-return-to-bottom'(比 spec 中的早期清单多了history-navigation一项,可见评审阶段对历史导航语义的收敛。)每个请求携带id与sessionEpoch,目标分为四类:
export type ChatScrollTarget = | { kind: 'bottom' } | { kind: 'absolute'; top: number } | { kind: 'message'; messageId: string; align: 'start' | 'center' | 'one-third' }显式且稳定的优先级
chatScrollState.ts 用getChatScrollRequestPriority把原因映射为数值,与 spec 的优先级列表一一对应:
| 优先级 | 原因 | 说明 |
|---|---|---|
| 100 | user-return-to-bottom / history-navigation / search-navigation / spotlight-navigation | 显式用户导航 |
| 90 | submit | 提交新消息 |
| 80 | history-prepend | 历史前插锚点保持 |
| 70 | session-restore | 会话恢复(被用户打断前) |
| 60 | auto-follow | 生成自动跟随 |
| 50 | measurement-anchor | 被动测量精化 |
纯状态机与可接受性
状态机输入是ChatScrollEvent(begin-session、user-gesture-start/end、bottom-proximity-changed、return-to-bottom、submit-started、history-navigation-start、explicit-navigation-start、restore-requested/complete、stream-updated、viewport-resized、measurements-committed、request-committed 等),输出是新的ChatScrollState(mode、userOwned、nearBottom、activeGesture、lastCommittedRequestId 等),完全无 DOM 依赖,可用 jsdom 之外的最小环境逐事件单测。
关键规则体现在canAcceptChatScrollRequest:restore 只有在未被用户拥有、且无显式导航(hasExplicitNavigation为假)时才可接受,防止「用户在搜索/Spotlight 跳转后 restore 又把视口拽回底部」;history-prepend 与 measurement-anchor 都要求userOwned且mode === 'reading',即被动操作永远不会抢占用户。
独占操作仲裁器与单槽请求队列
chatScrollOperationArbiter.ts 实现「任意时刻最多一个活跃操作」:同原因请求合并(replace)、更高优先级原子替换(返回被替换的 requestId)、更低优先级直接丢弃而非延迟重放。会话 epoch 不匹配的请求一律拒绝。
chatScrollRequestQueue.ts 则是一个单槽 pending 队列,take(sessionEpoch)只在 epoch 匹配时弹出;评审加固后(Phase 8)队列采用单调递增的 epoch 排序,stale take 时会保留更新的 pending 工作。
Phase 2:会话准备与性能基线
Phase 2 聚焦「首帧体验」与「会话切换体验」,全部完成:
- 为 selection、preparation、commit、first message paint、input readiness、secondary state completion 添加 performance marks —— 源码见 chatSessionPerformance.ts,标记格式为
deepchat:chat-session:${phase},并携带sessionId与sessionEpoch,且明确「插桩绝不影响聊天就绪」; - 引入不可变
PreparedChatSessionView(sessionId、sessionEpoch、messageRevision、messageIds、messageCache、cursor、hasMoreHistory、可选 layoutSnapshot 与 viewportAnchor),带 stale-epoch 拒绝; - 把「先清空再加载」替换为一次原子目标会话提交,杜绝「新会话头 + 旧会话消息」的混合帧;
- 允许消息就绪先于 pending inputs、plans 与 metadata 解除绘制阻塞;
- 添加渲染端有界 LRU(最多五个会话视图 + 内存/数量预算),含 cache revision 失效与驱逐测试;
- 添加冷启动、缓存切换、未缓存切换、快速 A/B/C 竞态测试。
spec 中给出了清晰的 critical / secondary 路径划分:
critical path session selection -> prepare latest message window -> atomically commit session view -> render latest bounded rows -> position once -> input and message viewport interactive secondary path pending inputs + plans + metadata + adjacent pre-measurement + optional pre-hydration性能验收标准(spec 第 13 条)要求参考机器上:热缓存会话切换首次有效消息绘制 p95 ≤ 100ms,未缓存本地切换 ≤ 250ms,初始聊天外壳可交互 ≤ 150ms;CI 记录这些指标并对绝对预算放宽以容忍机器方差。
Phase 3:控制器接入当前 DOM
Phase 3 在不动 DOM 结构的前提下把滚动权逐步收归useChatScrollController,全部完成:
- 围绕当前视口接入 controller;
- 会话恢复、提交与流式自动跟随、历史补偿与测量锚定、搜索与 Spotlight 导航全部改走 controller;
- 程序化滚动事件改用request ID / 期望目标匹配而非时间窗口;
- 新增聚焦所有权测试,禁止在 controller 之外对消息视口产生新的直接写。
源码 useChatScrollController.ts 是这套逻辑的落点,也是唯一允许给视口赋scrollTop的低层模块。其公开 API 包括:
export function useChatScrollController(options: { viewport: Readonly<ShallowRef<HTMLElement | null>> resolveMessageTop: (messageId: string) => number | null bottomThreshold?: number // 默认 80px canAutoFollow?: () => boolean onCommitted?: (top: number, request: ChatScrollRequest) => void })返回的 controller 暴露state、activeOperation、beginSession、request、requestImmediate、notifyUserGestureStart/End、notifyViewportScroll/Resize、dispose。
在 ChatPage.vue 中可以看到实际接入:导入useChatScrollController,会话切换时调用chatScrollController.beginSession(id)取得新 epoch,滚轮/触摸等手势经onGestureStart回调转发为notifyUserGestureStart(kind)。
一帧一写与验证帧
controller 内部的关键机制:
scheduleCommit通过requestAnimationFrame排入提交帧,writeCommittedThisFrame保证一帧至多一次物理写;即使没有第二个请求等待,也在下一帧边界过期立即写保护;- 提交时
resolveTargetTop计算目标 top(bottom 取scrollHeight - clientHeight,absolute 做 0..maxTop 钳制,message 目标按 align 计算 center/one-third 偏移),若与当前scrollTop差 < 1px 则跳过物理写; - 提交后记录
{ request, expectedTop },下一次notifyViewportScroll用1px 容差比对:匹配则视为程序化回执并完成操作;不匹配则归类为用户/原生几何事件——彻底替代旧的programmaticScrollUntil/userScrollInputUntil毫秒窗口判断。
手势的原子取消
notifyUserGestureStart一次性完成:取消已排帧、清空队列、arbiter.cancelAll()、清空 committedScroll、触发user-gesture-start状态迁移(mode → reading、userOwned → true)。用户手势对活跃操作与所有未提交工作是「一步取消」,而不是依赖空闲定时器——spec 明确「空闲定时器只能降低测量/渲染工作量,绝不能决定滚动条归谁所有」。
Phase 4:隔离页面几何 —— 三行网格外壳
Phase 4 的核心是把「外壳与滚动容器」拆开。已完成部分包括:搜索与历史状态移到消息流之外的 overlay;把顶栏、计划内边距、pending 车道、状态、编辑器从消息滚动范围中移除;按 controller 模式在 header/composer 的 ResizeObserver 变化中保持视口锚点。
进行中的部分则是引入三个新组件:
ChatPageShell.vue:header、viewport、composer 三区域;ChatMessageViewport.vue:唯一拥有聊天 overflow 的元素;ChatComposerRegion.vue:不改子组件挂载生命周期与公开行为的前提下搬移 composer。
spec 规定的目标布局如下:
ChatPageShell (overflow: hidden) ├── ChatHeaderRegion fixed layout row; never scrolls with messages ├── ChatMessageViewport minmax(0, 1fr); the only overflow-y:auto owner │ ├── history status overlay out of normal message flow │ ├── search overlay out of normal message flow │ └── MessageList messages and virtual spacers only └── ChatComposerRegion fixed layout row; outside message scroll geometry ├── pending input lane ├── plan / interaction layer ├── ChatInputBox └── ChatStatusBar外壳使用display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden,只有ChatMessageViewport可以overflow-y: auto。头部、composer、计划、状态、pending-input、搜索的高度变化不再改变消息滚动范围内的内容;视口尺寸变化以几何事件上报 controller(notifyViewportResize),而不是被误判为用户滚动。notifyViewportResize内部按 mode 分派:restoring 发 session-restore、following 发 auto-follow,且都遵守canAutoFollow;用户拥有时直接返回 null 不写。
Phase 5:特性兼容矩阵 —— 全量行为保持不变
Phase 5 全部为待办,但它定义了重构的「不破坏清单」,也是验收的对照表。spec 的兼容矩阵要求以下能力逐一保持:
- 会话打开/切换:最新消息出现在底部(除非用户打断恢复);
- 首次应用聊天加载:外壳与输入先于次要会话状态可交互;
- 缓存会话切换:近期会话视图与锚点即时恢复、无空态闪烁;
- 未缓存会话切换:固定视口加载态,随后一次原子目标会话提交;
- 自动滚动开启/关闭:开启时跟随至用户离开底部,关闭时永不抢视口;
- 新消息提交:新回合一次性移动到底部,之后尊重用户所有权;
- 短会话:无分页、无测量修正、无闪烁回滚;
- 长会话:有界挂载行、稳定 spacer、无空白缝隙;
- 旧历史加载:同一条消息与消息内偏移保持可见;
- Cmd/Ctrl+F:完整已加载消息计数,next/prev 跨虚拟窗口可用;
- Spotlight/trace 跳转:目标挂载、居中一次、高亮、清除 pending 导航;
- 流式 Markdown:阅读模式下不重建列表、不移动视口;
- 图片/artifact/工具:延迟尺寸变化保持长列表锚点,短列表零写;
- 计划/交互/pending 车道:视觉行为不变,不参与消息滚动范围;
- composer resize/focus/IME:草稿与焦点保持,消息视口不被滚动;
- 只读/subagent 会话、捕获/导出:显示与导航行为、完整已加载消息捕获能力保持不变。
Tasks 中进一步细化为十余条勾选项,特别强调「TipTap 滚动被限制在编辑器内部」「在渐进挂载有界重型行的同时保持已加载历史/搜索计数」「会话切换全程保持外壳、composer、TipTap 与输入就绪」。
Phase 6:性能与清理 —— 短列表零写,长列表有界
Phase 6 已完成的部分体现了性能要点:
- 短列表(1–20 条消息)完全渲染且零测量滚动写;
- 长列表挂载行保持有界,并优化消息条目查找(避免热路径上反复全数组搜索,对应 plan 中的 message ID → layout entry 缓存);
- 布局读取、测量与每帧一次视口写批量处理;
- 删除遗留滚动定时器、重复布尔量、直接写 helper 与过时注释。
待办项包括:把 ChatPage 收敛为编排壳并抽取所有权清晰的特性 composables;验证无滚动状态 CSS 变更引发大 repaint 或合成器抖动;在参考机器上达标初始外壳、缓存切换、未缓存切换延迟预算;验证近期会话缓存内存保持在配置预算内。
性能验收标准的其余条目同样值得引用(spec 第 1–12 条):活跃用户手势期间零未授权写;阅读模式下逻辑锚点漂移 ≤ 1 CSS 像素;一次用户导航至多一次视口写 + 一次纯高亮 DOM 遍历;每帧至多一次提交写;跨帧至多一个活跃事务且无第二所有者交替写;连续滚轮/触控板滚动无 50ms 以上应用长任务、无逐帧全量列表转换;流式滚动工作按帧合并,token 频率 ≠ 写频率;搜索/历史/Spotlight 导航无中间窗口交换。
Phase 7:验证与交接
Phase 7 的已完成项包括:全部纯状态/controller 测试通过、ChatPage 与消息窗口组件测试通过、全量渲染套件通过(spec 记录:168 个文件、1267 个测试,4 worker、单测 30 秒预算)、format/i18n/lint/Web typecheck/build 全绿。
待办项提示了「真实浏览器」与「真实设备」的边界:真实 Chromium 滚动矩阵(锚点漂移 ≤ 1px)、macOS 短/长对话触控板与滚轮手动验证、前后性能与滚动写指标记录、冷首载/热缓存/未缓存/快速切换竞态指标记录,以及把最终实现结果回写本规范与 retained scroll-coordinate issue(docs/issues/chat-history-search-scroll-coordinates/spec.md)。spec 明确:目前尚未对最终物理设备验证作出任何声明——这是判断本项目进度的准确依据。
Phase 8:评审加固 —— 竞态与所有权的不变量
Phase 8 全部完成,是代码质量最密集的一轮,tasks 列出的关键项与源码一一对应:
- 请求队列 epoch 排序单调,stale take 保留更新的 pending 工作(见 chatScrollRequestQueue.ts);
- 立即写帧保护在下一帧边界过期(见 controller 中
scheduleCommit后的注释); - 用户所有权跨原生布局滚动与合并显式导航保持;
- resize 驱动的自动跟随受
autoScrollEnabled门控,但不阻塞初始恢复; - 失败或已被取代的会话准备停留在安全的 committed-view 边界之后,绝不暴露目标视图或启用其 composer;
- 触摸所有权跨原生惯性滚动保持活跃,并在不依赖后续
scroll事件的情况下武装顶部翻页(对应 plan 中「touch ownership 持续到scrollend+ idle 兜底」); - 近期测量快照跨 keyed
ChatPageremount 生命周期持久; - Electron 搜索断言从纯时间窗改为可观察完成态;
- 每个评审加固不变量都补充回归测试并重跑全部质量门禁;
- committed message readiness 改为 store-owned,并以 live mutation revision 围栏同会话刷新;
- 对流式终止身份、pending-input 写入、历史重叠、提交延续与 A-B-A 会话水合全部加 fence。
spec 总结的评审加固成果还包括:review-hardening 的 scroll/page/keyed-parent/cache/architecture 套件120/120 通过;useChatScrollController成为唯一低层消息视口写入者;被动所有者按模式门控(restore/follow 不能在 reading 模式运行,measurement anchor 不能在 following 模式运行)。
数据与兼容性保证:无迁移的源码级回滚
重构刻意把改动圈定在渲染端布局与瞬时状态内。spec 的数据保证明确:不触碰src/main、src/preload、数据库 schema、加密、导入导出与持久化配置;不改 message ID、renderKey、排序、分页 cursor、会话身份与消息缓存语义;autoScrollEnabled保留既有持久化 key 与含义;controller 状态仅存在于渲染端、随会话 epoch 重置、永不持久化。因此回滚是纯源码回滚,不需要数据回滚——这也是 Phase 6/7 能放心逐阶段推进的前提。
质量门禁与测试策略速查
无论推进到哪个阶段,仓库的 plan.md 都要求以下门禁全绿:聚焦纯/controller 测试、既有 ChatPage 与消息窗口套件、真实 Chromium 滚动套件、全量渲染套件,以及pnpm run format、pnpm run i18n、pnpm run lint、pnpm run typecheck:web、pnpm run build。相关测试可在 test/renderer/composables/chat/(chatScrollArchitecture.test.ts、useChatScrollController.test.ts)与 ChatPage.test.ts 中找到。
关键的风险对冲同样值得记取:composer 抽取破坏 TipTap/IME → 只搬 DOM 不改挂载生命周期并补焦点/草稿测试;搜索/Spotlight 丢失窗口外目标 → 保留逻辑消息寻址与 mount-before-highlight 流程;迁移期出现两个冲突所有者 → 聚焦所有权测试 + 分阶段删除直接写;近期会话缓存内存膨胀 → 小 LRU + 内存预算 + 可观测驱逐。
结语:一份可复用的滚动所有权重构清单
从 tasks.md 的 8 个阶段可以提炼出一条通用方法论:先取证(Phase 0)、再建纯模型(Phase 1)、后铺性能地基(Phase 2)、逐路径收权(Phase 3)、最后动几何(Phase 4),并以兼容矩阵(Phase 5)、性能清理(Phase 6)、真实浏览器验证(Phase 7)与竞态加固(Phase 8)收尾。核心纪律只有三条:typed request + session epoch 替代时间窗口、独占仲裁器 + 一帧一写替代多路径直接写、用户手势原子取消替代空闲定时器。这套模式对任何「单页壳 + 长列表 + 流式内容」的应用都有直接的借鉴价值,而 DeepChat 的这份文档与源码就是最完整的落地样本。
【免费下载链接】deepchat🐬DeepChat - A smart assistant that connects powerful AI to your personal world项目地址: https://gitcode.com/GitHub_Trending/dee/deepchat
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考