一直显示?点击任意区域即可关闭
个性化配置

📌 VitePress 跨页面 Hash 锚点平滑滚动定位失效 BUG 修复文档

本页面记录了在「逆熵角落」博客系统中,**从一个页面点击带 # 锚点链接跳转至另一个页面的指定位置(如 0613 页面中跳转至 0609 页面特定脚本讲解处)**时,锚点定位失效的问题分析与解决方案。


🔍 BUG 现象与根本原因分析

1. 现象

在页面 A(如 0613)中点击类似 /posts/2026/0609#_3-核心-python-推理脚本-generate-py 的路由链接:

  • 浏览器成功跳转至 0609 路由。
  • 页面能正常展示,但页面停留在最顶部,没有自动向下滚动滚动到对应标题位置

2. 根本原因:自定义过渡加载动画的渲染竞态

在系统根组件 App.vue 中,全站的内容布局容器 .mian-layout 深度绑定了 Pinia 中的全局加载状态 loadingStatus

html
<!-- App.vue -->
<main :class="['mian-layout', { loading: loadingStatus, 'is-post': isPostPage }]">

在对应的样式表中:

scss
.mian-layout {
  &.loading {
    display: none; /* 👈 当 loadingStatus 为 true 时,内容区被物理隐藏 */
  }
}

执行序列冲突:

  1. 当用户点击跨页链接时,路由守卫触发 loadingStatus = true,触发加载动画,同时通过 display: none 隐藏了整个 .mian-layout
  2. 此时,VitePress 核心路由器在底层完成了新页面的 DOM 载入,并立即触发浏览器的原生/自定义 Hash 锚点滚动定位(Scroll to Hash)
  3. 定位失效点:由于此时内容容器为 display: none,整个页面没有任何高度(所有子元素的 offset 均为 0 或不可视),浏览器无法量度目标标题的绝对高度,导致滚动操作直接被系统丢弃。
  4. 随后随机延时结束,loadingStatus 变回 false,内容区域重新显现(display: block)。但由于浏览器的滚动定位流程在第 3 步已经完结且不会二次触发,页面最终只能尴尬地停留在最顶端。

🛠️ 解决方案与代码实现

我们在根组件 App.vue 中挂载一个针对 loadingStatusVue 响应式监听器(Watcher)。 当全站加载状态结束(即 DOM 重新变为 display: block 可见、元素尺寸可被准确计算的第一瞬间),重新手动接管并触发平滑滚动。

修改的代码部分:

  • 修改文件:[.vitepress/theme/App.vue](file:///opt/1panel/www/sites/blog.chgr.cc/index/.vitepress/theme/App.vue)
javascript
// 监听加载状态结束,恢复滚动到 hash 锚点
watch(
  () => loadingStatus.value,
  (loading) => {
    if (!loading) { // 👈 当 loadingStatus 变为 false(加载结束,DOM 可见)时
      nextTick(() => {
        const hash = window.location.hash;
        if (hash) {
          try {
            // 对 URL 编码的中文 hash 进行解码(例如 #_3-核心...)
            const decodedHash = decodeURIComponent(hash);
            const targetId = decodedHash.substring(1); // 剥离 '#' 字符
            const targetEl = document.getElementById(targetId);
            
            if (targetEl) {
              // 延迟 150ms 确保页面二次重绘排版完全定型后测距
              setTimeout(() => {
                const headerTop = targetEl.getBoundingClientRect().top;
                // 计算目标元素距离文档顶部的绝对高度,并减去顶部固定 Nav 导航栏占用的 80px 像素高度
                const scrollHeight = headerTop + window.scrollY - 80;
                
                // 执行平滑滚动
                window.scrollTo({ top: scrollHeight, behavior: "smooth" });
              }, 150);
            }
          } catch (e) {
            console.error("锚点滚动定位失败:", e);
          }
        }
      });
    }
  }
);

💡 特别注意与后续维护

  1. 中文锚点编码:VitePress 自动生成的中文 ID(如 _3-核心-python-推理脚本-generate-py)在 URL 中会被百分号编码(%E6%A0%B8...)。必须使用 decodeURIComponent() 解码后再通过 document.getElementById 匹配。
  2. 顶栏遮挡问题:由于博客有 Sticky 固定顶栏,如果不减去 80window.scrollY - 80),滚动完毕后标题会被顶栏死死挡住,该计算公式完美避开了顶栏遮挡。
  3. 相同页面内部跳转:如果是在同一个页面内点击锚点(不触发 loading 过渡状态),VitePress 原生的 Hash 滚动机制依然生效且运行良好,本监听器不会与其冲突。