笔记滚动条拖动卡顿(TOC observer 每个 scroll 事件重建)¶
状态:已修复(@platejs/toc@53.0.0 仓库内 patch,observer 长生命周期 + rAF 合并的 diff 重挂)
影响面:长笔记(NOTES)面板的滚动条拖动、TOC 平滑滚动;三面板(PDF + 笔记 + 对话)同开时最明显
相关代码:
patches/@platejs__toc@53.0.0.patch— 改动@platejs/toc的useContentObserver/useContentControllerpnpm-workspace.yaml—patchedDependencies登记vite.config.ts— 显式不把@platejs/toc加进optimizeDeps.exclude(原因见 §4.3)src/components/editor/overlays/toc-sidebar.tsx—queryTocHeadings三层缓存(本次未改)test/editor-toc-observer.test.ts— observer 复用 / diff 重挂 / activeId 回归测试- 文档:
docs/frontend/markdown.md§限制与后续、本文件
1. 问题现象¶
长笔记里拖动竖直滚动条 thumb 时「一拖就卡、拖不动」,改用滚轮反而顺一些;TOC 平滑滚动同样掉帧。只开笔记面板时基本无感,三面板同开时最重。
「拖动卡、滚轮不卡」是 macOS WebKit 的固有差异:滚轮滚动交给合成线程推进,主线程再忙画面照走;而 ::-webkit-scrollbar 样式让 overlay 滚动条退化成 classic 可拖 thumb,拖拽在主线程逐 pointer 事件同步推进。因此 scroll 事件上的任何主线程成本都会直接体现在 thumb 跟手上。
2. 根因¶
@platejs/toc@53.0.0 把 scroll 事件接成了「整表重建」的信号,两个 hook 合起来构成回环:
scroll 事件(拖动 thumb 时逐 pointer 事件到达)
└─ useContentController 的 scroll 监听
└─ setStatus(Date.now()) ← React state 更新
├─ useContentController 消费者重渲染
│ └─ TocSidebar 整个按钮列表 reconcile
└─ useContentObserver 的 effect deps 含 status
└─ disconnect() + new IntersectionObserver(...)
└─ headingList.forEach:
NodeApi.get(editor, path)
editor.api.toDOMNode(node)
observer.observe(element) ← 每个标题各一次
即每个 scroll 事件:一次 React 状态更新 + 整棵 TOC 重渲染 + 一次 IO 构造与销毁 + 每个标题一次 NodeApi.get + toDOMNode + observe。长笔记下约 2–5ms/事件,且与滚动距离无关——所以滚动条拖得越慢,掉帧的单次成本一分不少。
需要 status 这个「踢一脚」的原因本身是合理的:Slate 在结构编辑后可能重挂块的 DOM 节点,observer 需要重新挂到新节点上。问题在于它用的是整表重建,而不是按需重挂。
3. 修复方案¶
改 @platejs/toc 的两处,语义不变、成本结构改变:
3.1 observer 改为长生命周期¶
useContentObserver 里 observer 只在 isObserve / isScroll / rootMargin / editor / ref 变化时重建(status 不再参与),并把已挂载目标记在 targetsRef(Map<headingId, Element>)里。
3.2 滚动只做 diff 重挂,且按 rAF 合并¶
新增一次「同步目标」:对每个标题解析 NodeApi.get + toDOMNode,与 targetsRef 逐 id 比较——只有 DOM 节点真的换了才 unobserve 旧节点 + observe 新节点。这一步由 hook 自己持有的 scroll 监听触发,并合并到一个 rAF 里,因此:
- scroll 事件不再触发任何 React 状态更新(TOC 不重渲染);
- 不再构造 / 销毁 IntersectionObserver;
- DOM 节点未变的标题不再被重复
observe。
useContentController 里 setStatus(Date.now()) 的 scroll 监听随之删除;status 参数保留为可选(index.d.ts 标 status?: number),外部调用者传了也不报错。
3.3 前后对比¶
| 维度 | 修改前 | 修改后 |
|---|---|---|
| 每 scroll 事件 React 状态更新 | 1 次 | 0 次 |
| 每 scroll 事件 IO 构造 / 销毁 | 1 次 | 0 次 |
每 scroll 事件 observe 调用 |
每个标题 1 次 | 仅 DOM 节点换过的标题 |
NodeApi.get + toDOMNode |
每事件 × 每个标题 | 每帧 1 次 × 每个标题(rAF 合并) |
| DOM 重挂后的重新挂载 | 整表重建 | 按 id diff 精确重挂 |
activeId 追踪 / TOC 自动滚动 |
不变 | 不变 |
4. 验收¶
4.1 自动化证据¶
pnpm exec vitest run test/editor-toc-observer.test.ts
pnpm test
pnpm typecheck
pnpm run lint:ts
pnpm build
git diff --check
test/editor-toc-observer.test.ts 用 happy-dom 挂一棵真的 Plate 编辑器(4 个标题)+ TocSidebar,mock IntersectionObserver 记录每次构造与 observe:
| 用例 | 断言 |
|---|---|
| 挂载 | 每个标题恰好被 observe 一次 |
| 20 次 scroll + rAF | observer 数量不变、observe 总次数不变、未 disconnect |
| DOM 重挂 | 旧节点被 unobserve、新节点被 observe |
| observer 回调 | activeId 仍正确驱动 #toc_item_active |
| scroll 后 | #toc_wrap 的 HTML 不变(未触发 React 状态更新) |
关键:这几个断言在未打补丁的 53.0.0 上会失败(滚动会逐个事件新增 observer 并重复 observe 全部标题),打补丁后通过——即该测试是有效回归护栏,而非同义反复。
4.2 手动验收¶
- 打开一篇有较多标题的长笔记(或三面板:PDF + 笔记 + 对话同开)。
- 拖动笔记的竖直滚动条 thumb 上下来回——thumb 应跟手,不出现「拖不动 / 一顿一顿」。
- 点 TOC 条目平滑跳转,TOC 高亮应正常跟随。
- 编辑标题、在标题上方插入块,TOC 高亮应继续正确。
4.3 patch 生效范围(不要把 @platejs/toc 加进 optimizeDeps.exclude)¶
一个踩过的坑,留档以免重犯:
@platejs/toc 依赖 只有 CommonJS 构建的 react-compiler-runtime
(main: dist/index.js,无 module / exports / type: module),并且用具名导入取它的
c:
import { c } from "react-compiler-runtime";
CJS→ESM 的具名导出 interop 只有 Vite 的预打包(esbuild)这一步会做。一旦把
@platejs/toc 放进 optimizeDeps.exclude,Vite 就会原样把它当源码发给浏览器,
裸的具名导入无法解析,webview 直接报:
Importing binding name 'c' is not found.
整个 Markdown 编辑器因此起不来(表现为错误页 / 重试)。
那 patch 怎么进 vite dev? 靠 pnpm 自己解决:patch 后包被解析到
node_modules/.pnpm/@platejs+toc@53.0.0_patch_hash=<hash>/…,预打包读的就是这份已打补丁的副本。
而预打包缓存 key 覆盖 lockfile,patch 内容一变,pnpm-lock.yaml 里的
patch_hash 就变 → 缓存自动失效。所以预打包 + patch 是可以共存的,
不需要 exclude。
验证方式(已实测):
rm -rf node_modules/.vite
pnpm exec vite --port 5199
# 预打包产物里应含补丁标记,且不应残留 react-compiler-runtime 的裸导入
grep -c unobserve node_modules/.vite/deps/@platejs_toc_react.js # 期望 1
grep -c 'from "react-compiler-runtime"' node_modules/.vite/deps/@platejs_toc_react.js # 期望 0
生产构建侧同样验证:pnpm build 后 dist/assets/ 中承载 TOC 的 chunk 应含 unobserve
(原版从不调用 unobserve)。
5. 边界¶
- 只修 scroll 这一条信号:
useTocObserver(TOC 自动滚动的高亮定位)仍会在activeId变化时重建它自己那个「单元素」observer——那是切标题才发生的事,不在滚动热路径上,本次不动。 - rAF 合并带来一帧延迟:DOM 重挂后的重新挂载最多晚一帧生效。这与原实现「同一事件内同步重建」相比是行为差异,对高亮正确性无影响(observer 回调本身是异步的)。
- 依赖 patch 而非 app 侧重写:
useTocSideBarState/useTocSideBar仍由库提供,toc-sidebar.tsx只保留了queryTocHeadings三层缓存与展示层。避免复制库的状态机(mouseInToc / isObserve / 滚动定位)。 - 升级
@platejs/toc时必须重做 patch:pnpm install会因补丁上下文不匹配而失败,属于预期内的显式失败,不会静默退回未修复版本。 - 不解决 macOS 上的块级重排:长笔记滚动时整篇绘制仍走 WebKit(
content-visibility在 WebKit 上被刻意关闭,见 markdown-read-jank-offscreen-blocks.md 与src/lib/core/paint-perf.ts)。本次移除的是 scroll 热路径上的固定 JS 开销,不是绘制成本。
6. 决议记录¶
| 议题 | 决议 |
|---|---|
| app 侧重写 hook 还是 patch 上游 | patch 上游;库的状态机(mouseInToc / isObserve / 滚动定位)不值得复制,且缺陷在上游 |
| observer 是否彻底不管 DOM 重挂 | 否;保留重挂能力,但从「整表重建」改为「按 id diff」 |
| scroll 踢一脚仍走 React state 吗 | 否;改由 hook 自己持有 scroll 监听 + rAF 直接调用同步,避免整棵 TOC 重渲染 |
是否给 status 破坏性改名 |
否;保留为可选参数,类型标 status?: number,外部调用不受影响 |
是否把 @platejs/toc 加进 optimizeDeps.exclude |
否;它会让 CJS-only 的 react-compiler-runtime 具名导入 c 解析失败(见 §4.3)。预打包读的就是 pnpm 的 patch_hash 副本,补丁照样生效 |