📌 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 时,内容区被物理隐藏 */
}
}执行序列冲突:
- 当用户点击跨页链接时,路由守卫触发
loadingStatus = true,触发加载动画,同时通过display: none隐藏了整个.mian-layout。 - 此时,VitePress 核心路由器在底层完成了新页面的 DOM 载入,并立即触发浏览器的原生/自定义 Hash 锚点滚动定位(Scroll to Hash)。
- 定位失效点:由于此时内容容器为
display: none,整个页面没有任何高度(所有子元素的 offset 均为 0 或不可视),浏览器无法量度目标标题的绝对高度,导致滚动操作直接被系统丢弃。 - 随后随机延时结束,
loadingStatus变回false,内容区域重新显现(display: block)。但由于浏览器的滚动定位流程在第 3 步已经完结且不会二次触发,页面最终只能尴尬地停留在最顶端。
🛠️ 解决方案与代码实现
我们在根组件 App.vue 中挂载一个针对 loadingStatus 的 Vue 响应式监听器(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);
}
}
});
}
}
);💡 特别注意与后续维护
- 中文锚点编码:VitePress 自动生成的中文 ID(如
_3-核心-python-推理脚本-generate-py)在 URL 中会被百分号编码(%E6%A0%B8...)。必须使用decodeURIComponent()解码后再通过document.getElementById匹配。 - 顶栏遮挡问题:由于博客有 Sticky 固定顶栏,如果不减去
80(window.scrollY - 80),滚动完毕后标题会被顶栏死死挡住,该计算公式完美避开了顶栏遮挡。 - 相同页面内部跳转:如果是在同一个页面内点击锚点(不触发
loading过渡状态),VitePress 原生的 Hash 滚动机制依然生效且运行良好,本监听器不会与其冲突。