fix(post): 直接访问 #hash 时标题被 sticky header 遮住

点击锚点正常,但直接访问带 hashtag 的链接时滚动后标题被 80px sticky
header 遮住。根因:浏览器原生 fragment-scroll(直接访问触发)不读 CSS
scroll-margin-top,在我们 scrollToHash 的平滑滚动之后触发,把标题顶到
视口最顶端(header 下方)。

抽出共享 scrollToHeading:用 getBoundingClientRect 手动计算绝对滚动位置,
减去动态测量的 header 高度(+16px 呼吸空间)后 window.scrollTo,不再依赖
scroll-margin-top。hash-scroll(直接访问)与 anchor-click(点击)统一调用它。

scrollToHash 额外用双 requestAnimationFrame 延后做一次即时校正,覆盖原生
fragment-scroll 的触发窗口,确保落点在原生 scroll 之后生效。

scroll-margin-top: 6rem 保留在 input.css 作其它原生 scroll 场景的兜底。
This commit is contained in:
xfy 2026-07-13 18:31:31 +08:00
parent 5ad6ce60cd
commit 067075df8a
5 changed files with 129 additions and 18 deletions

View File

@ -40,11 +40,10 @@ describe('initAnchorClick', () => {
anchor.setAttribute('href', '#ru-men-zhi-nan'); anchor.setAttribute('href', '#ru-men-zhi-nan');
document.body.appendChild(anchor); document.body.appendChild(anchor);
const spy = vi.spyOn(heading, 'scrollIntoView'); const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
anchor.click(); anchor.click();
expect(spy).toHaveBeenCalledOnce(); expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' });
// 关键:Dioxus 委托监听器未被触发 → handleClickNavigate/browser_open 不会执行。 // 关键:Dioxus 委托监听器未被触发 → handleClickNavigate/browser_open 不会执行。
expect(dioxusInterceptorCalled).toBe(false); expect(dioxusInterceptorCalled).toBe(false);
}); });
@ -93,7 +92,7 @@ describe('initAnchorClick', () => {
anchor.setAttribute('href', '#target'); anchor.setAttribute('href', '#target');
document.body.appendChild(anchor); document.body.appendChild(anchor);
const spy = vi.spyOn(heading, 'scrollIntoView'); const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
// 模拟 ctrl+click新标签页:dispatchEvent 携带 ctrlKey。 // 模拟 ctrl+click新标签页:dispatchEvent 携带 ctrlKey。
const event = new MouseEvent('click', { bubbles: true, ctrlKey: true }); const event = new MouseEvent('click', { bubbles: true, ctrlKey: true });
@ -116,10 +115,10 @@ describe('initAnchorClick', () => {
window.__initAnchorClick(); window.__initAnchorClick();
window.__initAnchorClick(); window.__initAnchorClick();
const spy = vi.spyOn(heading, 'scrollIntoView'); const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
anchor.click(); anchor.click();
// 即使重复调用scrollIntoView 仍只触发一次(监听器只注册一份)。 // 即使重复调用scrollTo 仍只触发一次(监听器只注册一份)。
expect(spy).toHaveBeenCalledOnce(); expect(spy).toHaveBeenCalledOnce();
}); });
}); });

View File

@ -14,10 +14,13 @@
* fragment-scroll hydration * fragment-scroll hydration
* *
* capture Dioxus bubble hash * capture Dioxus bubble hash
* <a> stopPropagation Dioxus scrollIntoView * <a> stopPropagation Dioxus
* history.replaceState URL hash popstate/hashchange * scrollToHeading header history.replaceState URL hash
* popstate/hashchange
*/ */
import { scrollToHeading } from './scroll-to-heading';
let installed = false; let installed = false;
/** /**
@ -59,8 +62,8 @@ export function initAnchorClick(): void {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
// 平滑滚动到目标标题scroll-margin-top 在 input.css 里设为 6rem 避开 sticky header)。 // 平滑滚动到目标标题,手动扣除 sticky header 高度(见 scroll-to-heading.ts)。
el.scrollIntoView({ behavior: 'smooth', block: 'start' }); scrollToHeading(el, true);
// 更新 URL hash 但不产生历史记录抖动replaceState 不触发 popstate/hashchange。 // 更新 URL hash 但不产生历史记录抖动replaceState 不触发 popstate/hashchange。
// 这样地址栏显示当前章节,刷新页面也能 scrollToHash 回到原位。 // 这样地址栏显示当前章节,刷新页面也能 scrollToHash 回到原位。

View File

@ -1,6 +1,9 @@
/** /**
* hash-scroll 测试:钉住锚点跳转的解码与 scrollIntoView * hash-scroll 测试:钉住锚点跳转的解码与手动偏移滚动行
* 黑盒驱动:只通过 window.__scrollToHash * 黑盒驱动:只通过 window.__scrollToHash
*
* window.scrollTo sticky header scrollIntoView +
* scroll-margin-top fragment-scroll
*/ */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import './index'; import './index';
@ -17,18 +20,16 @@ describe('scrollToHash', () => {
}); });
it('hash 为 CJK 百分号编码时解码后命中并滚动', () => { it('hash 为 CJK 百分号编码时解码后命中并滚动', () => {
// 标题 id 是原始 CJK 字符URL hash 是百分号编码形式。
const heading = document.createElement('h2'); const heading = document.createElement('h2');
heading.id = '三-五-零法则'; heading.id = '三-五-零法则';
document.body.appendChild(heading); document.body.appendChild(heading);
const spy = vi.spyOn(heading, 'scrollIntoView'); const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
history.replaceState(null, '', `#${encodeURIComponent(heading.id)}`); history.replaceState(null, '', `#${encodeURIComponent(heading.id)}`);
window.__scrollToHash(); window.__scrollToHash();
expect(spy).toHaveBeenCalledOnce(); expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' });
}); });
it('hash 为纯 ASCII 时直接命中', () => { it('hash 为纯 ASCII 时直接命中', () => {
@ -36,21 +37,75 @@ describe('scrollToHash', () => {
heading.id = 'getting-started'; heading.id = 'getting-started';
document.body.appendChild(heading); document.body.appendChild(heading);
const spy = vi.spyOn(heading, 'scrollIntoView'); const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
history.replaceState(null, '', '#getting-started'); history.replaceState(null, '', '#getting-started');
window.__scrollToHash(); window.__scrollToHash();
expect(spy).toHaveBeenCalledOnce(); expect(spy).toHaveBeenCalled();
});
it('滚动落点扣除 sticky header 高度', () => {
// 模拟 sticky headergetBoundingClientRect 返回 height 80。
const header = document.createElement('header');
header.className = 'sticky';
header.getBoundingClientRect = () =>
({
top: 0,
left: 0,
right: 0,
bottom: 80,
width: 1000,
height: 80,
x: 0,
y: 0,
toJSON() {},
}) as DOMRect;
document.body.appendChild(header);
// 目标标题:放在 scrollY=1000 处getBoundingClientRect.top 反映相对视口位置)。
const heading = document.createElement('h2');
heading.id = 'mid';
heading.getBoundingClientRect = () =>
({
top: 1000,
left: 0,
right: 0,
bottom: 1050,
width: 800,
height: 50,
x: 0,
y: 1000,
toJSON() {},
}) as DOMRect;
document.body.appendChild(heading);
// scrollY 参与 scrollTo 目标计算top = rect.top + scrollY - headerH - offset。
Object.defineProperty(window, 'scrollY', { value: 500, writable: true, configurable: true });
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
history.replaceState(null, '', '#mid');
window.__scrollToHash();
// 第一帧调用smoothtop = 1000 + 500 - 80 - 16 = 1404
expect(spy).toHaveBeenCalled();
const firstCall = spy.mock.calls[0][0] as ScrollToOptions;
expect(firstCall.top).toBe(1000 + 500 - 80 - 16);
expect(firstCall.behavior).toBe('smooth');
}); });
it('hash 为空时不滚动也不报错', () => { it('hash 为空时不滚动也不报错', () => {
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
history.replaceState(null, '', '#'); history.replaceState(null, '', '#');
expect(() => window.__scrollToHash()).not.toThrow(); expect(() => window.__scrollToHash()).not.toThrow();
expect(spy).not.toHaveBeenCalled();
}); });
it('目标元素不存在时不报错', () => { it('目标元素不存在时不报错', () => {
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
history.replaceState(null, '', '#no-such-heading'); history.replaceState(null, '', '#no-such-heading');
expect(() => window.__scrollToHash()).not.toThrow(); expect(() => window.__scrollToHash()).not.toThrow();
expect(spy).not.toHaveBeenCalled();
}); });
}); });

View File

@ -5,11 +5,18 @@
* DOM fragment-scroll * DOM fragment-scroll
* scroll * scroll
* *
* 访 #hash fragment-scroll
* scrollToHash scroll-margin-top sticky header
* requestAnimationFrame
* scroll scroll
*
* CJK location.hash %E4%B8%89-... id * CJK location.hash %E4%B8%89-... id
* id="三-五-零法则" decodeURIComponent getElementById * id="三-五-零法则" decodeURIComponent getElementById
* fallback * fallback
*/ */
import { scrollToHeading } from './scroll-to-heading';
export function scrollToHash(): void { export function scrollToHash(): void {
const hash = window.location.hash.slice(1); // 去掉前导 # const hash = window.location.hash.slice(1); // 去掉前导 #
if (!hash) return; if (!hash) return;
@ -18,5 +25,13 @@ export function scrollToHash(): void {
const el = document.getElementById(id) ?? document.getElementById(hash); const el = document.getElementById(id) ?? document.getElementById(hash);
if (!el) return; if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'start' }); // 立即先滚一次(覆盖骨架屏阶段原生 scroll 失败的窗口)。
scrollToHeading(el, true);
// 延后两帧再校正:直接访问 #hash 时浏览器原生 fragment-scroll 会在此期间触发,
// 把标题顶到 header 下面(它不读 scroll-margin-top。两帧后原生 scroll 已完成,
// 用即时滚动(非 smooth避免与刚才的 smooth 动画叠加抖动)把落点拉回正确位置。
requestAnimationFrame(() => {
requestAnimationFrame(() => scrollToHeading(el, false));
});
} }

View File

@ -0,0 +1,39 @@
/**
* sticky header
*
* scrollIntoView + CSS scroll-margin-top
* fragment-scroll访 #hash scroll-margin-top
* DOM scrollToHash
* sticky header getBoundingClientRect
* window.scrollTo访 /
*/
/** 呼吸空间:标题顶部与 header 下沿之间留出的空白。 */
const HEADING_OFFSET_PX = 16;
/**
* sticky header .md-content sticky header
* 80px/ header
*/
function measureStickyHeaderHeight(): number {
const header = document.querySelector('header.sticky');
return header ? header.getBoundingClientRect().height : 0;
}
/**
* header +
*
*/
export function scrollToHeading(el: Element, smooth = true): void {
// scrollIntoView 配合 scroll-margin-top 在点击场景能工作,但直接访问 #hash 时
// 浏览器原生 fragment-scroll 会争抢落点。统一用手动计算绕开时序竞态。
const top =
el.getBoundingClientRect().top +
window.scrollY -
measureStickyHeaderHeight() -
HEADING_OFFSET_PX;
window.scrollTo({
top: Math.max(top, 0),
behavior: smooth ? 'smooth' : 'auto',
});
}