296 lines
7.2 KiB
Markdown
296 lines
7.2 KiB
Markdown
# 博客目录跳转被导航栏遮盖问题
|
||
|
||
> **版本**: v1.0
|
||
> **日期**: 2026-05-06
|
||
> **状态**: 📝 待评审
|
||
|
||
---
|
||
|
||
## 一、问题描述
|
||
|
||
### 1.1 现象
|
||
|
||
博客正文页(`/posts/{slug}`)右侧有目录(TOC),点击目录项可以跳转到对应章节。但跳转后,目标标题被顶部导航栏遮盖,用户需要手动向上滚动才能看到标题。
|
||
|
||
### 1.2 复现步骤
|
||
|
||
1. 访问任意博客文章(如 `https://blog.ephron.ren/posts/hermes-chrome-opencode-ai-agent-bug`)
|
||
2. 滚动页面使右侧目录可见
|
||
3. 点击任意目录项(如「案例一:卡片显示不全」)
|
||
4. 观察页面跳转后的滚动位置
|
||
|
||
**预期行为**:标题完整可见,位于导航栏下方
|
||
**实际行为**:标题被导航栏遮盖,只能看到标题下半部分
|
||
|
||
### 1.3 影响范围
|
||
|
||
- 影响所有博客文章页(`/posts/{slug}`)
|
||
- 影响所有使用目录跳转的用户
|
||
- 不影响首页、归档页等无目录页面
|
||
|
||
---
|
||
|
||
## 二、根因分析
|
||
|
||
### 2.1 技术细节
|
||
|
||
**导航栏实现**(`blog/templates/base.html`):
|
||
```css
|
||
.site-header {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 100;
|
||
min-height: 64px;
|
||
}
|
||
|
||
body {
|
||
padding-top: 64px; /* 补偿导航栏高度 */
|
||
}
|
||
```
|
||
|
||
**目录实现**(`blog/templates/post.html`):
|
||
```javascript
|
||
// 生成目录链接
|
||
a.href = '#' + id; // 如 #heading-0
|
||
|
||
// 目录高亮判断
|
||
if (rect.top <= 120) {
|
||
current = heading.id;
|
||
}
|
||
```
|
||
|
||
### 2.2 问题根因
|
||
|
||
当点击 `href="#heading-0"` 的链接时,浏览器会执行以下操作:
|
||
|
||
1. 找到 `id="heading-0"` 的元素
|
||
2. 将该元素滚动到视口顶部(`scrollIntoView` 的默认行为)
|
||
3. 由于导航栏是 `position: fixed`,它始终在视口顶部
|
||
4. 标题被滚动到视口顶部后,立即被 fixed 导航栏覆盖
|
||
|
||
`body { padding-top: 64px }` 只对页面初始加载有效,对锚点跳转无效。
|
||
|
||
### 2.3 为什么目录高亮用了 120px?
|
||
|
||
```javascript
|
||
if (rect.top <= 120) {
|
||
current = heading.id;
|
||
}
|
||
```
|
||
|
||
这里的 120px 是经验值(64px 导航栏 + 一些留白),用于判断标题是否「接近顶部」。但这只是高亮逻辑,不影响实际滚动位置。
|
||
|
||
---
|
||
|
||
## 三、解决方案
|
||
|
||
### 3.1 方案对比
|
||
|
||
| 方案 | 实现复杂度 | 兼容性 | 用户体验 | 推荐度 |
|
||
|------|-----------|--------|----------|--------|
|
||
| A. CSS `scroll-margin-top` | ⭐ 低 | 现代浏览器 | ⭐⭐⭐ 最佳 | ✅ 推荐 |
|
||
| B. JS `scrollIntoView` | ⭐⭐ 中 | 全部 | ⭐⭐ 良好 | 备选 |
|
||
| C. JS `scrollTo` + offset | ⭐⭐ 中 | 全部 | ⭐⭐ 良好 | 备选 |
|
||
|
||
### 3.2 推荐方案:CSS `scroll-margin-top`
|
||
|
||
**原理**:CSS `scroll-margin-top` 属性可以为元素设置滚动外边距,当该元素被滚动到视口时,会自动添加额外的顶部间距。
|
||
|
||
**实现**:
|
||
```css
|
||
/* 为所有标题添加滚动外边距 */
|
||
.post-content h1,
|
||
.post-content h2,
|
||
.post-content h3,
|
||
.post-content h4,
|
||
.post-content h5,
|
||
.post-content h6 {
|
||
scroll-margin-top: 80px; /* 64px 导航栏 + 16px 留白 */
|
||
}
|
||
```
|
||
|
||
**优点**:
|
||
- 纯 CSS 实现,无需 JavaScript
|
||
- 浏览器原生支持,性能最佳
|
||
- 自动应用于所有锚点跳转(包括浏览器地址栏直接输入)
|
||
- 不影响现有 JS 逻辑
|
||
|
||
**兼容性**:
|
||
- Chrome 61+ ✅
|
||
- Firefox 68+ ✅
|
||
- Safari 14.1+ ✅
|
||
- Edge 79+ ✅
|
||
|
||
### 3.3 备选方案:JS `scrollIntoView`
|
||
|
||
如果需要支持旧版浏览器,可以用 JS 拦截点击事件:
|
||
|
||
```javascript
|
||
// 拦截 TOC 链接点击
|
||
tocList.addEventListener('click', function(e) {
|
||
const link = e.target.closest('a');
|
||
if (!link) return;
|
||
|
||
e.preventDefault();
|
||
const targetId = link.getAttribute('href').substring(1);
|
||
const target = document.getElementById(targetId);
|
||
|
||
if (target) {
|
||
target.scrollIntoView({
|
||
behavior: 'smooth',
|
||
block: 'start'
|
||
});
|
||
|
||
// 手动添加偏移
|
||
window.scrollBy(0, -80);
|
||
}
|
||
});
|
||
```
|
||
|
||
**缺点**:
|
||
- 需要处理 `scrollBy` 的时序问题(`scrollIntoView` 是异步的)
|
||
- 可能出现闪烁(先跳到目标位置,再偏移)
|
||
- 不影响地址栏直接输入锚点的情况
|
||
|
||
---
|
||
|
||
## 四、实现细节
|
||
|
||
### 4.1 修改文件
|
||
|
||
- `blog/templates/post.html`:在 `{% block extra_styles %}` 中添加 CSS
|
||
|
||
### 4.2 具体改动
|
||
|
||
在 `.post-content h4 { ... }` 之后添加:
|
||
|
||
```css
|
||
/* 修复锚点跳转被导航栏遮盖 */
|
||
.post-content h1,
|
||
.post-content h2,
|
||
.post-content h3,
|
||
.post-content h4,
|
||
.post-content h5,
|
||
.post-content h6 {
|
||
scroll-margin-top: 80px;
|
||
}
|
||
```
|
||
|
||
### 4.3 偏移量计算
|
||
|
||
```
|
||
导航栏高度: 64px
|
||
额外留白: 16px
|
||
总计: 80px
|
||
```
|
||
|
||
如果未来导航栏高度变化,需要同步修改这个值。建议将其提取为 CSS 变量:
|
||
|
||
```css
|
||
:root {
|
||
--nav-height: 64px;
|
||
--scroll-offset: 80px; /* nav-height + 16px */
|
||
}
|
||
|
||
.post-content h1,
|
||
.post-content h2,
|
||
.post-content h3,
|
||
.post-content h4,
|
||
.post-content h5,
|
||
.post-content h6 {
|
||
scroll-margin-top: var(--scroll-offset);
|
||
}
|
||
```
|
||
|
||
### 4.4 目录高亮逻辑调整
|
||
|
||
当前高亮逻辑使用 `rect.top <= 120`,这个值应该与 `scroll-margin-top` 保持一致:
|
||
|
||
```javascript
|
||
// 当前
|
||
if (rect.top <= 120) {
|
||
|
||
// 建议改为
|
||
if (rect.top <= 80) {
|
||
```
|
||
|
||
或者提取为变量:
|
||
|
||
```javascript
|
||
const SCROLL_OFFSET = 80; // 与 CSS --scroll-offset 保持一致
|
||
|
||
function updateTocHighlight() {
|
||
let current = '';
|
||
headings.forEach(heading => {
|
||
const rect = heading.getBoundingClientRect();
|
||
if (rect.top <= SCROLL_OFFSET) {
|
||
current = heading.id;
|
||
}
|
||
});
|
||
// ...
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 五、测试验证
|
||
|
||
### 5.1 测试用例
|
||
|
||
| 编号 | 测试步骤 | 预期结果 |
|
||
|------|---------|---------|
|
||
| T-001 | 点击目录中的 H2 标题 | 标题完整可见,位于导航栏下方 |
|
||
| T-002 | 点击目录中的 H3 标题 | 标题完整可见,位于导航栏下方 |
|
||
| T-003 | 浏览器地址栏输入 `#heading-0` | 标题完整可见 |
|
||
| T-004 | 快速连续点击多个目录项 | 无闪烁,每次跳转位置正确 |
|
||
| T-005 | 移动端视口下点击目录 | 行为一致(目录在移动端隐藏,此项可跳过) |
|
||
|
||
### 5.2 验证方法
|
||
|
||
1. 部署后访问博客文章
|
||
2. 打开浏览器开发者工具
|
||
3. 点击目录项,观察 `scroll-margin-top` 是否生效
|
||
4. 检查标题是否位于导航栏下方
|
||
|
||
---
|
||
|
||
## 六、风险与注意事项
|
||
|
||
### 6.1 风险
|
||
|
||
- **无**:`scroll-margin-top` 是纯 CSS 属性,不影响现有布局和交互
|
||
- **兼容性**:仅影响旧版浏览器(<2020 年),可忽略
|
||
|
||
### 6.2 注意事项
|
||
|
||
- 如果未来导航栏高度变化,需要同步修改 `--scroll-offset` 变量
|
||
- 目录高亮逻辑的阈值应与 `scroll-margin-top` 保持一致
|
||
|
||
---
|
||
|
||
## 七、优先级与排期
|
||
|
||
| 优先级 | 任务 | 预估时间 |
|
||
|--------|------|---------|
|
||
| P0 | 添加 `scroll-margin-top` CSS | 5 分钟 |
|
||
| P1 | 提取 CSS 变量 | 5 分钟 |
|
||
| P2 | 调整目录高亮逻辑阈值 | 5 分钟 |
|
||
|
||
**总计**:15 分钟
|
||
|
||
---
|
||
|
||
## 附录
|
||
|
||
### A. 相关文件
|
||
|
||
- `blog/templates/base.html`:导航栏定义
|
||
- `blog/templates/post.html`:目录生成和高亮逻辑
|
||
|
||
### B. 参考资料
|
||
|
||
- [MDN: scroll-margin-top](https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-margin-top)
|
||
- [CSS Scroll Snapping](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_scroll_snap)
|