news 2026/9/17 22:38:47

DeepChat Chat Scroll Ownership 重构实战:从 3000 行 ChatPage 到单滚动所有者状态机

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
DeepChat Chat Scroll Ownership 重构实战:从 3000 行 ChatPage 到单滚动所有者状态机

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。同时,多个代码路径各自通过scrollTopscrollIntoView写滚动位置,所有权只能靠重叠的毫秒时间窗口推断,jsdom 单测无法复现真实 Chromium 的布局、sticky 定位、原生滚动锚定、合成器行为与滚动钳制。

因此本项目在 plan.md 中确立了九大目标,核心三条为:

  1. 让消息视口成为唯一拥有聊天滚动的元素;
  2. 所有程序化滚动经由一个带显式 reason 与 priority 的 typed controller 路由;
  3. 活跃用户手势获得持久所有权,直到用户显式返回底部或发起导航。

阶段总览:八阶段渐进迁移,而非一次性重写

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一项,可见评审阶段对历史导航语义的收敛。)每个请求携带idsessionEpoch,目标分为四类:

export type ChatScrollTarget = | { kind: 'bottom' } | { kind: 'absolute'; top: number } | { kind: 'message'; messageId: string; align: 'start' | 'center' | 'one-third' }

显式且稳定的优先级

chatScrollState.ts 用getChatScrollRequestPriority把原因映射为数值,与 spec 的优先级列表一一对应:

优先级原因说明
100user-return-to-bottom / history-navigation / search-navigation / spotlight-navigation显式用户导航
90submit提交新消息
80history-prepend历史前插锚点保持
70session-restore会话恢复(被用户打断前)
60auto-follow生成自动跟随
50measurement-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 都要求userOwnedmode === '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},并携带sessionIdsessionEpoch,且明确「插桩绝不影响聊天就绪」;
  • 引入不可变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 暴露stateactiveOperationbeginSessionrequestrequestImmediatenotifyUserGestureStart/EndnotifyViewportScroll/Resizedispose

在 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 },下一次notifyViewportScroll1px 容差比对:匹配则视为程序化回执并完成操作;不匹配则归类为用户/原生几何事件——彻底替代旧的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 兜底」);
  • 近期测量快照跨 keyedChatPageremount 生命周期持久;
  • 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/mainsrc/preload、数据库 schema、加密、导入导出与持久化配置;不改 message ID、renderKey、排序、分页 cursor、会话身份与消息缓存语义;autoScrollEnabled保留既有持久化 key 与含义;controller 状态仅存在于渲染端、随会话 epoch 重置、永不持久化。因此回滚是纯源码回滚,不需要数据回滚——这也是 Phase 6/7 能放心逐阶段推进的前提。

质量门禁与测试策略速查

无论推进到哪个阶段,仓库的 plan.md 都要求以下门禁全绿:聚焦纯/controller 测试、既有 ChatPage 与消息窗口套件、真实 Chromium 滚动套件、全量渲染套件,以及pnpm run formatpnpm run i18npnpm run lintpnpm run typecheck:webpnpm run build。相关测试可在 test/renderer/composables/chat/(chatScrollArchitecture.test.tsuseChatScrollController.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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 22:35:42

SketchUp模型如何高效导入UE4:FBX与Datasmith管线全流程解析

最近好几位做方案的朋友在问我同一个问题&#xff1a;手头这堆skp文件&#xff0c;到底怎么才能干净利落地弄进UE4&#xff1f;说实话&#xff0c;SketchUp和UE4之间不存在单击拖拽就完成的神奇操作——skp是SketchUp的私有格式&#xff0c;UE4原生不认识它。但把它转进去这件事…

作者头像 李华
网站建设 2026/9/17 22:35:19

3 步装好 Zola 主题 kirifuji:从克隆仓库到自定义导航菜单

3 步装好 Zola 主题 kirifuji&#xff1a;从克隆仓库到自定义导航菜单 【免费下载链接】zola A fast static site generator in a single binary with everything built-in. https://www.getzola.org 项目地址: https://gitcode.com/GitHub_Trending/zo/zola kirifuji 是…

作者头像 李华