这篇文章是专门为验证文章页 TOC 滚动、高亮命中和锚点定位而写的样例。 它不会讨论一个独立的新功能,而是把当前项目文档里提到的内容系统、 配置层、页面结构和运行时行为串在一起,形成一个足够长、足够稳定、 标题层级足够密集的测试页面。
读这篇文章时,可以同时打开桌面侧边栏 TOC、移动端目录按钮和 Floating TOC。 从页面顶部缓慢向下滚动、点击目录项跳转、再从接近底部的位置继续滚动, 都应该能观察到当前高亮项是否和正文中实际靠近 viewport 顶部的标题一致。
项目内容系统的上下文
当前博客使用 Astro Content Collection 管理内容。普通文章放在
src/content/posts/**/*.md,特殊页面放在 src/content/spec/**/*.md,
网站通知放在 src/content/notifications/**/*.md。这些集合都由
src/content.config.ts 定义 schema,因此文章 frontmatter 的结构不是临时约定,
而是构建期会被 Astro 校验的内容契约。
对于 TOC 验证来说,最重要的是普通文章的 headings 数据。未加密文章会在构建期 由 Astro 提供 headings,桌面目录、移动端目录和 Floating TOC 都应共享同一份静态 TOC 数据。加密文章则不同:未解锁前不应该暴露静态目录,解锁后再从解密正文 root 生成 runtime TOC。
Content Collection 与 headings
Astro 在编译 Markdown 时会解析标题,并把标题结构暴露给页面层。这个过程让 TOC 不需要在普通文章里做全页面 DOM 扫描,也让目录数据在服务端渲染阶段就能稳定生成。 如果标题文本、slug 或层级发生变化,TOC 的节点结构也会随之变化。
这篇样例刻意使用大量 H2 和 H3 标题。H2 用来模拟一级目录项,H3 用来模拟二级目录项。 当页面滚动到某个 H2 的正文范围内时,该 H2 应该展开;当滚动进入它下面的某个 H3 范围时, 对应 H3 应该成为 active item。
文章路由与 canonical slug
内容指南里说明,未设置 alias 时,文章文件名就是 canonical slug,并生成
/posts/{文件名}/。这篇文章的文件名是 toc-scroll-anchor-validation.md,
因此预期访问路径是 /posts/toc-scroll-anchor-validation/。
这里不使用 alias,是为了减少验证变量。我们希望所有行为都集中在 TOC scroll、
hash anchor、Swup 拦截和 active tracker 上,而不是额外引入路由别名。
分类和标签
项目文档建议每篇文章只设置一个分类,标签可以多个,但应避免同义词和大小写差异。
这篇文章使用规范分类 技术,并保留 TOC、Markdown、Astro、Svelte、
Testing 作为英文关键词。这样既符合现有分类系统,也便于之后在分类页快速找到它。
为什么需要专门的 TOC 验证文章
TOC 问题通常不是单纯的视觉问题。它混合了文档结构、滚动容器、固定导航栏高度、 hash 更新、浏览器原生锚点跳转、客户端路由、标题可见性和文章底部边界。 如果样例文章太短,很多问题只会在真实长文里出现;如果标题层级太少, 二级标题折叠和根标题收起的问题也很难稳定复现。
这篇文章的目的,是提供一个可以重复操作的验证页面。它不追求内容主题上的完整性, 而是追求滚动行为上的覆盖率。每个章节都故意写得比普通说明更长一些, 让 active tracker 有足够的距离判断当前标题范围。
滚动命中不是点击定位
点击 TOC 标题时,页面会主动滚动到目标 heading。这个动作可以使用明确的 offset,
例如根据 navbar 或 --main-content-offset 计算出来的值。用户手动滚动时则不同,
active tracker 需要在每一次 scroll update 中判断当前 viewport 应该归属于哪个标题。
如果点击定位和滚动命中使用不同 offset,就会出现一种不舒服的体验: 点击目录项后标题位置看起来是准确的,但稍微滚动一点,高亮项却跳到上一个或下一个标题。 这种不一致通常说明 scroll-to-heading 和 active-heading detection 没有共享同一套边界。
二级标题折叠的复现场景
用户在 /posts/markdown-extended/ 中观察到的问题,出现在命中二级标题
Custom Titles 后继续向下滚动很小距离时,TOC 高亮收起了所有二级标题,
表现得像已经滚动到页面底部。这类问题特别适合用长文复现,因为它常常和
bottom boundary、当前标题范围和最后一个标题范围之间的关系有关。
在这篇文章中,后面会安排多个连续 H3,并在接近底部的位置继续放置 H2/H3。 如果目录在未真正到达文章底部时提前变成 roots-only 状态,就可以很快看出来。
验证路线一:从页面顶部自然滚动
第一条验证路线是最接近日常阅读的方式:打开文章后不点击 TOC,只使用滚轮或触控板 从顶部缓慢往下滚动。这个过程中需要观察三个点:第一个 H2 何时高亮,H2 下的 H3 何时高亮,以及离开某个 H3 后是否仍然保持在合理的父级章节里。
这条路线可以暴露 active tracker 的起始命中问题。如果 tracker 的判断线离页面顶部 过远,标题可能还没有接近可读区域就提前高亮;如果判断线过低,标题已经被固定导航栏 遮住或滚过很久,高亮才会更新。
顶部进入正文的第一个观察点
从文章标题区域滚动到正文区域时,第一个可观察目标是 H2 项目内容系统的上下文。
当它进入主要阅读位置时,TOC 应该把该 H2 标记为 active。由于页面顶部可能存在
navbar、公告条或其他固定区域,实际判断点不应简单等同于 window.scrollY。
如果当前项目使用 --main-content-offset 表达主内容偏移,那么 active tracker
和点击跳转都应该尽量消费同一个值。这样用户看到的标题位置和 TOC 高亮的判断位置
才会一致。
中段稳定性的第二个观察点
继续滚动到当前章节时,TOC 应该保持展开当前 H2,并在进入本 H3 的正文范围后 高亮这个 H3。这里特别关注“刚刚越过标题一小段距离”的状态。正常情况下, 高亮不应该因为离开标题元素本身而立刻丢失,因为用户阅读的是这个标题下的内容范围, 不是只阅读标题那一行。
如果 active tracker 只依赖 getBoundingClientRect().top 的瞬时值,
而没有把标题之间的范围建模清楚,就容易在两个标题之间出现空窗期。
范围建模的价值在这里非常明显。
低速滚动与惯性滚动
触控板的惯性滚动会产生很多小步进,鼠标滚轮则可能产生更大的跳跃。 TOC 逻辑应该能同时处理这两种输入。低速滚动用于检查边界是否抖动, 快速滚动用于检查 tracker 是否能跳过中间不可见标题并仍然命中正确范围。
这也是为什么测试文章需要比较长。短文章很难制造“快速滚动跳过多个 heading” 之后仍然需要准确判断 active item 的场景。
验证路线二:点击 TOC 标题跳转
第二条验证路线是从 TOC 主动点击标题。点击一级标题时,页面应该滚动到该 H2 附近; 点击二级标题时,页面应该滚动到该 H3 附近。滚动结束后,active item 应该和刚刚点击的 目录项一致,不能依赖浏览器原生 hash jump 再做一次不可控的补偿。
如果项目使用 Swup 或类似客户端导航库,TOC 链接还要避免被当作普通页面导航处理。 对于同页 hash 跳转,理想行为是由 TOC runtime 接管滚动、更新 hash、刷新 active state。
点击一级标题后的预期
点击 TOC 中的 H2 时,标题应停在可读区域的顶部附近。这里的“顶部附近”不是绝对的
0px,而是应该扣除固定导航或配置的 scroll offset。若 offset 为 0,
标题可以贴近 viewport 顶部;若 offset 等于 navbar 高度,标题不应被 navbar 遮挡。
随后手动向下滚动一小段,active item 不应该马上跳走。只有当 tracker 的判断线进入 下一个标题范围时,TOC 才应该推进到下一个节点。
点击二级标题后的预期
点击 H3 时,父级 H2 应该展开,目标 H3 应该高亮。桌面 TOC、移动端 TOC 和 Floating TOC 如果同时存在,应表现一致。这里尤其要注意移动端目录关闭后的滚动状态: 关闭浮层本身不应改变页面滚动高度,也不应触发额外的 native hash jump。
如果点击后页面滚到一个位置,但下一帧 active tracker 判断为另一个标题, 通常说明点击 scroll offset 和 active offset 仍然不一致。
Hash 更新与后退行为
TOC 点击通常会更新地址栏 hash,例如 #点击二级标题后的预期。这个 hash 的作用是
提供可分享链接和浏览器历史记录,但它不应该让浏览器执行第二套默认锚点滚动。
因此点击 handler 需要明确阻止默认行为,由项目自己的 scrollToHeading 或同类逻辑
完成定位。这样才能保证 offset、history、active state 的来源一致。
验证路线三:连续二级标题
连续二级标题是 TOC active tracker 最容易出错的场景之一。如果几个 H3 之间的正文都很短, 滚动判断线可能在很短时间内穿过多个范围。此时 tracker 既不能停留在过期标题, 也不能在没有明确命中底部之前收起所有二级标题。
下面几个小节故意放得比较近,但每个小节仍然有足够正文。测试时可以在这一段慢慢滚动, 观察高亮是否按照 H3 顺序稳定推进。
连续标题 A:范围开始
这一小节代表连续标题区域的起点。理想情况下,它的 active range 应该从本标题的
scroll target 附近开始,到下一个 H3 的 scroll target 附近结束。用户阅读这段文字时,
TOC 高亮应该停留在 连续标题 A:范围开始。
如果高亮提前跳到下一节,说明 range end 可能过早;如果始终停留在上一节, 说明 range start 可能过晚,或者当前节点推进逻辑没有正确比较 next heading。
连续标题 B:范围推进
这一小节用于观察从 A 到 B 的推进。滚动判断线越过 B 的起点后,active item 应该变为 B。 这个判断最好来自标题图结构,而不是每次都从头扫描所有 heading 后凭经验猜测。
图结构可以把每个节点的父级、前一个兄弟、后一个兄弟和范围边界准备好。 这样 active tracker 只需要根据当前 scroll position 判断是否推进、回退或保持。
连续标题 C:范围结束
这一小节用于观察连续标题组的结尾。离开 C 后,如果还没有进入下一个 H2, TOC 应该仍然保持父级展开,并根据当前范围保持 C 或父级,而不是直接切到 roots-only。
roots-only 更适合表达“页面已经超过文章有效 TOC 范围”的状态。 它不应该被一个普通的 H3 range end 错误触发。
验证路线四:底部边界
底部边界是这次问题分析中最关键的区域。旧逻辑如果使用“文章正文容器 bottom 减去某个固定值” 来判断 bottom state,就可能在视觉上还没有到达真正底部时提前进入 roots-only。 这会让用户看到二级标题突然全部收起,像是 TOC 认为正文已经结束。
更稳妥的做法,是让 bottom state 和最后一个标题的有效范围建立关系。 也就是说,只有当滚动判断线确实超过最后一个 TOC heading 的合理范围, 或者文章内容已经没有可归属标题时,才进入 bottom roots-only。
接近底部但仍在章节内
这一小节模拟“已经很靠近底部,但仍然在某个标题正文内”的状态。 滚动到这里时,TOC 不应该因为文章容器 bottom 比某个阈值小而提前收起。 如果本节还有可读内容,active item 就应该仍然指向本节或它的父级章节。
这个场景可以通过慢速滚动验证:当页面底部逐渐接近 viewport 下边缘时, 目录是否仍然保持当前 H3 展开。如果它突然变成只显示根标题, 就说明 bottom state 的触发条件仍然过早。
最后一个标题的有效范围
最后一个标题比较特殊,因为它没有 next heading。没有 next 并不意味着它的范围立刻结束。 它的 range end 应该来自文章内容结束位置、正文容器结束位置或一个明确的文档边界, 而不是来自不存在的下一个节点。
如果最后一个 H3 下还有很长一段内容,用户阅读这段内容时仍然应该看到这个 H3 高亮。 只有继续滚动到文章有效内容之后,TOC 才有理由切换到 bottom roots-only。
文章结尾的 roots-only 状态
真正的 roots-only 状态可以保留,但触发条件要明确。它表示用户已经离开 TOC 可表达的正文范围, 不是表示“某个内部计算值碰到了阈值”。在 UI 上,它可以帮助目录在页面底部保持简洁; 在逻辑上,它应该是 state machine 的一个明确终态或临界状态。
这个状态最好由 tracker 根据 current node、previous node、next node 和 range boundary 共同判断。这样即使页面高度、字体加载、图片懒加载或移动端 viewport 变化, 也不会因为一个魔法数字而改变语义。
rangeStart 与 rangeEnd 的使用方式
rangeStart 和 rangeEnd 可以理解为某个 TOC 节点在滚动轴上的归属范围。
当 active line 落在这个范围内时,当前节点就是最合理的 active heading。
如果 active line 小于 rangeStart,tracker 需要考虑回退到 previous;
如果 active line 大于 rangeEnd,tracker 需要考虑推进到 next。
这两个字段不是单纯的视觉位置缓存。它们更像是把标题图结构映射到滚动坐标后的结果。 只要 range 计算和 scroll offset 使用同一套输入,点击定位和手动滚动命中就更容易一致。
用 rangeStart 表示标题开始
一个 heading 的 rangeStart 可以来自该 heading 的页面绝对位置减去共享 offset。
共享 offset 可以由 siteConfig.toc.scrollOffset 显式配置,也可以回落到
--main-content-offset。如果两者都不可用,默认值为 0 是最容易解释的选择。
这样一来,点击目录项时滚到的位置,和 active tracker 判断“进入该标题”的位置, 会指向同一条滚动轴上的线。
用 rangeEnd 表示标题结束
一个 heading 的 rangeEnd 通常可以来自下一个同级或后继 heading 的 rangeStart。
对于最后一个 heading,rangeEnd 可以来自文章正文的有效结束位置。
这比使用固定的 container bottom - 160px 更清晰,因为它描述的是内容范围,
不是某个临时视觉缓冲。
如果存在图片加载、字体加载或内容解密导致的高度变化,range 需要在布局稳定后刷新。 这也是 TOC runtime 应该监听 resize、content refresh 或 unlock event 的原因。
把 range 接入 active tracker
active tracker 可以维护一个 current node。每次滚动时先计算 active line, 再检查 current 的 range。如果仍在范围内,就保持不变;如果越过 range end, 就沿 next 推进;如果回到 range start 之前,就沿 previous 回退。
这种图或链表式推进能减少全量扫描,也能让 bottom roots-only 变成一个明确的状态流转。 当 current 是最后一个节点且 active line 超过最后 range end 时,才进入 bottom state。
配置层与默认 offset
项目文档强调配置形状应由 src/types/config.ts 定义,默认配置放在 src/config/site.ts,
浏览器运行时需要通过 ConfigCarrier 或同类桥接组件拿到必要的 runtime config。
TOC scroll offset 属于这类配置:它影响浏览器端滚动行为,但也应该有类型定义和默认值。
对于这个项目,默认 offset 设为 0 是合理的,因为它不会假设 navbar 高度。
如果站点未来有固定导航栏高度变化,可以显式配置 siteConfig.toc.scrollOffset。
如果 CSS 已经暴露 --main-content-offset,runtime 可以把它作为更贴近布局的回退来源。
为什么避免新的魔法数字
魔法数字的问题不在于数字本身,而在于缺少语义来源。160px 可能在某个屏幕上看起来合适,
但它无法说明为什么是 160,也无法保证在不同字体、不同 viewport、不同内容密度下仍然正确。
当 offset 由配置或 CSS 变量提供时,数字仍然存在,但它有了来源。 维护者可以追踪它属于 navbar、content inset 或其他布局约束,而不是只能凭经验调整。
默认值与用户配置
默认值应尽量保守。0 的语义很清楚:不额外补偿固定区域。配置值则用于项目明确知道
顶部有固定遮挡时。这样既不会把布局假设写死在 TOC 逻辑里,也保留了站点按需调整的能力。
这篇文章本身不要求某个具体 offset。它的作用是帮助观察当前配置下, 点击定位和滚动命中是否共享同一套 offset。
运行时验证清单
下面是一组可以手动执行的验证动作。它们不是自动化测试脚本,但可以帮助快速定位问题。 如果以后要写 Playwright 测试,也可以把这些动作拆成断言。
从顶部慢慢滚到本文段
打开 /posts/toc-scroll-anchor-validation/,从页面顶部缓慢滚动到这里。
观察 TOC 是否依次高亮前面的 H2/H3。中途不要点击目录项,只依赖自然滚动。
预期结果是 active item 按正文顺序推进,当前 H2 始终展开,二级标题不会在普通段落中 突然全部收起。
点击目录跳转到本小节
点击 TOC 中的 点击目录跳转到本小节。页面应该定位到本标题附近,
标题不应被固定导航栏遮挡。滚动结束后,TOC 中本小节应保持高亮。
随后向下滚动很小一段距离,active item 应继续保持在本小节, 直到判断线进入下一个标题范围。
从接近底部继续向下滚动
滚动到 验证路线四:底部边界 附近,然后继续向下慢慢滚动。
如果 TOC 在还没有进入文章末尾时就收起所有二级标题,
说明 bottom roots-only 的触发条件仍然过早。
真正接近文章结尾时,目录可以进入简洁状态,但这个状态应该发生在最后一个标题范围之后。
结论:这篇文章应该如何被使用
这篇样例文章可以长期保留在内容集合中,作为 TOC 行为的人工验证页面。
它覆盖了构建期 headings、同页 hash、点击跳转、自然滚动、连续二级标题和底部边界。
当后续调整 TocActiveTracker、scrollToHeading、Floating TOC 或配置桥接逻辑时,
都可以用它做第一轮肉眼验证。
如果某次改动让点击定位和滚动命中再次不一致,这篇文章的中段连续 H3 和底部章节 应该能很快暴露问题。到那时,优先检查 shared scroll offset、rangeStart/rangeEnd 刷新时机,以及 bottom roots-only 是否只在最后一个有效 heading range 之后触发。