跳到正文

d-v-list

DsVirtualList 是 DeepSeek 网页版聊天里的虚拟列表,从他们网站下发的 JavaScript 还原出来。它只作为做 VirtualList 时的参考:不属于 raft-ui,不导出,也不能发布。下面的演示运行的就是还原出来的代码本身,一处没改:还原文件只加了绑定它在打包产物里的相邻模块的 import 声明(和还原出来的 AST 逐节点比对过),加上它依赖的模块和 DeepSeek 自己的 CSS(按规则逐条抽取)。它们跑在这段代码构建时用的 React 18.3.1 上,各自有独立的 React root。只有演示文件和未包含部分(消息组件、应用的服务容器)的替身是新写的代码,见该目录的 README.md。本页运行 JavaScript 版本 js/;d-v-list(TypeScript) 用 TypeScript 版本 ts/ 运行同样的演示:同样的文件只加了类型,同样逐节点比对过。

条目在挂载时测量高度,偏移来自 Fenwick 树,渲染窗口按最小条目高度计算(没有 overscan),滚动处理里用 flushSync 同步提交新的范围。

演示

基础用法

一万行,每行一到四行文字,挂载之前不知道高度(itemResizable,itemEstimatedSize 84,itemMinSize 57)。Scroll to index 和 Scroll to key 调用 scrollTo({ index | key, debounce: false }):目标偏移取自 Fenwick 树,途中的行没测量之前只是估计值;跳转在目标和附近的行测量之后会重放(在一帧加一个任务之内)。

聊天

DeepSeek 的 ChatMessageList(itemMinSize 60,itemEstimatedSize 140)和它的滚动控制器,按应用驱动它们的方式驱动:对话放在应用的消息 store 里,路由指明打开的会话,应用的各个 provider 都在。Stream an answer 按流式服务的方式更新 store:先加入一个问题和一个 WIP 回答,会话的 forceScrollToBottomTrigger 加一;然后回答每 40 ms 增长一次,先是思考块,再是正文;消息组件每次更新都调用 requireScrollToBottom(),让 scrollToBottomTrigger 加一。停在底部时列表跟随到底,直到回答正文(.ds-assistant-message-main-content)的顶部到达视口顶部,跟随在那里停下(每个回答一次),让人从头读。向上滚动会停止跟随;↓ Latest(控制器要求显示回到底部按钮时出现)在应用的事件总线上发 clickButtonToScrollToBottom。Jump to message 像滚动导航那样发 scrollNavActivateMessage;Jump to answer body 设置一个待定的搜索目标,列表把回答正文对齐到距顶部 48 px。部分回答在第一段下面带一张不知道尺寸的图片,在消息挂载后最多两秒才加载出来:向上滚动时从上方进入的行不会让正在读的内容移动(估计高度和第一次测量的差会被滚动抵消),但消息在视口上方的部分里稍后加载出来的图片,会把视口里的文字往下推一个图片的高度,因为只有第一次测量会被补偿。

保留选中的行

有选中文字时,滚出范围的行不卸载,绝对定位在原来的位置(这里高亮显示),所以滚动后选区和复制仍然有效。先点 Select text in row 2,再点 Scroll to row 1000。按还原出来的代码,这个判断会保留所有已挂载、不含 input 的行,而不只是包含选区的那一行,所以有选区时保留的行数会一直增长。

开关虚拟化

DeepSeek 在打印前、以及 iOS 上的短对话里关闭虚拟化。关闭时所有行以普通流挂载,并按切换前记下的快照恢复视口顶部的那一行;打开时先记录每一行测量到的高度。再次打开时位置保不住:这个切换不会重新设置占位高度,只剩渲染出来的几行时浏览器会把滚动偏移夹回去。DeepSeek 只在换会话时重新打开虚拟化,那时位置本来就会重置。