fix(post): scrollToHash 增加 ResizeObserver 布局稳定期,修正 mermaid 异步渲染导致的锚点落点偏移
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled

直接访问 /post/slug#heading 或刷新该 URL 时,目标标题停在屏幕中间偏下,但点击目录
或标题跳转却正常。根因是时序竞争:

post_content 的 use_effect 同步依次调用 __initMermaid(注册 IntersectionObserver,
异步)→ __scrollToHash。scrollToHash 的"立即 + 两帧"校正只覆盖浏览器原生
fragment-scroll;而 mermaid 懒加载(加载 ~1MB bundle → render → pre.innerHTML=svg)
是数百毫秒的异步链,发生在两帧之后。mermaid 块从源码文本膨胀成 SVG 图时撑高内容,
把目标标题推离已校准的落点。mermaid.ts 渲染完成后无任何落点修正。点击目录正常是
因为那时 mermaid 已渲染、布局稳定。

解法:scrollToHash 末尾新增 stabilizeScrollOnResize——在 STABILIZE_WINDOW_MS(2s) 内
用 ResizeObserver 观察 .post-content 容器,检测到布局变化即用 rAF 合并重新校正一次
落点。与 mermaid 解耦,同时覆盖图片/字体等所有异步位移源。用户主动滚动(wheel/
touchmove/keydown)或超时后自动停止,不与交互打架。重入安全:主题切换重跑 use_effect
时先 dispose 上一次稳定器,避免泄漏/叠加。

测试(hash-scroll.test.ts)新增 3 例:resize 后再校正、用户 wheel 后停止、重入清理旧 RO。
This commit is contained in:
xfy 2026-07-16 16:05:11 +08:00
parent 1ea11b6434
commit f6b7ab4333
2 changed files with 190 additions and 0 deletions

View File

@ -4,15 +4,38 @@
*
* window.scrollTo sticky header scrollIntoView +
* scroll-margin-top fragment-scroll
*
* ResizeObservermermaid SVG
* /
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import './index';
// --- ResizeObserver mockhappy-dom 不实现,手动提供回调钩子 ---
let resizeCallback:
| ((entries: { target: Element; contentRect: { width: number; height: number } }[]) => void)
| null = null;
const roDisconnect = vi.fn();
vi.stubGlobal(
'ResizeObserver',
class {
constructor(cb: typeof resizeCallback) {
resizeCallback = cb;
}
observe() {}
disconnect() {
roDisconnect();
}
},
);
describe('scrollToHash', () => {
beforeEach(() => {
document.body.innerHTML = '';
history.replaceState(null, '', '#');
vi.restoreAllMocks();
resizeCallback = null;
roDisconnect.mockClear();
});
afterEach(() => {
document.body.innerHTML = '';
@ -108,4 +131,93 @@ describe('scrollToHash', () => {
expect(() => window.__scrollToHash()).not.toThrow();
expect(spy).not.toHaveBeenCalled();
});
// ---------- 布局稳定期ResizeObserver ----------
// 复现 bug 根因mermaid 懒加载等异步位移源发生在两帧校正之后,撑高内容把标题
// 推离落点。稳定期监听内容容器 resize位移后重新校正一次。
/** 构造一个带 .post-content 容器和带 id 标题的 DOM用于稳定期测试。 */
function setupPostWithHeading(headingId: string): { root: HTMLElement; heading: HTMLElement } {
const root = document.createElement('div');
root.className = 'post-content';
const heading = document.createElement('h2');
heading.id = headingId;
heading.getBoundingClientRect = () =>
({
top: 1000,
left: 0,
right: 0,
bottom: 1050,
width: 800,
height: 50,
x: 0,
y: 0,
toJSON() {},
}) as DOMRect;
root.appendChild(heading);
document.body.appendChild(root);
Object.defineProperty(window, 'scrollY', { value: 500, writable: true, configurable: true });
return { root, heading };
}
it('内容容器 resize 后再次校正落点(异步布局位移修正)', async () => {
vi.useFakeTimers();
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
setupPostWithHeading('mermaid-tu-biao');
history.replaceState(null, '', '#mermaid-tu-biao');
window.__scrollToHash();
// 推进两帧校正的 rAFfake rAF 周期 ~16ms不触发 2s 超时。
await vi.advanceTimersByTimeAsync(40);
spy.mockClear();
expect(resizeCallback).not.toBeNull();
// 模拟 mermaid 注入 SVG 撑高内容:触发一次 resize。
resizeCallback!([
{
target: document.querySelector('.post-content')!,
contentRect: { width: 800, height: 2000 },
},
]);
// rAF 合并的校正在下一帧执行。
await vi.advanceTimersByTimeAsync(20);
expect(spy).toHaveBeenCalled();
// 推进超时,让稳定器在本测试内自清理,避免泄漏到后续测试。
await vi.advanceTimersByTimeAsync(2000);
vi.useRealTimers();
});
it('用户主动滚动wheel后停止校正避免与用户交互打架', () => {
vi.useFakeTimers();
vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
setupPostWithHeading('h2');
history.replaceState(null, '', '#h2');
window.__scrollToHash();
expect(roDisconnect).not.toHaveBeenCalled();
// 用户主动滚动:派发 wheel应触发 disposeRO 断开、监听移除、超时清除)。
window.dispatchEvent(new WheelEvent('wheel'));
expect(roDisconnect).toHaveBeenCalled();
// dispose 后 resize 不再触发新的稳定器RO 已断开,回调不会再被调用。
expect(resizeCallback).not.toBeNull();
vi.useRealTimers();
});
it('重入安全:再次 scrollToHash 时旧稳定器被清理(不泄漏/不叠加)', () => {
vi.useFakeTimers();
vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
setupPostWithHeading('h3');
history.replaceState(null, '', '#h3');
window.__scrollToHash();
expect(roDisconnect).not.toHaveBeenCalled();
// 第二次调用(模拟主题切换触发 use_effect 重跑)。
window.__scrollToHash();
expect(roDisconnect).toHaveBeenCalled();
vi.useRealTimers();
});
});

View File

@ -10,6 +10,11 @@
* requestAnimationFrame
* scroll scroll
*
* mermaid
* SVG/
* 访 hash URL ResizeObserver
* stabilizeScrollOnResize
*
* CJK location.hash %E4%B8%89-... id
* id="三-五-零法则" decodeURIComponent getElementById
* fallback
@ -17,6 +22,75 @@
import { scrollToHeading } from './scroll-to-heading';
/** 布局稳定期时长:覆盖 mermaid bundle 加载+渲染、图片/字体加载等异步位移源。 */
const STABILIZE_WINDOW_MS = 2000;
/** 用户主动滚动的输入事件:收到任一即认为用户接管,停止自动校正。 */
const USER_INPUT_EVENTS: Array<keyof WindowEventMap> = ['wheel', 'touchmove', 'keydown'];
/** 当前活跃的稳定器;重入时先 dispose 上一次,避免泄漏/叠加。 */
let activeStabilizer: (() => void) | null = null;
/**
* STABILIZE_WINDOW_MS rAF
*
*
* ResizeObserver
* mermaid/ .post-content
* mermaid//
*/
function stabilizeScrollOnResize(el: Element): void {
// 重入安全dispose 上一次活跃的稳定器。
activeStabilizer?.();
activeStabilizer = null;
if (typeof ResizeObserver === 'undefined') return;
const target = el.closest('.post-content') ?? document.body;
let rafId = 0;
let disposed = false;
const dispose = () => {
if (disposed) return;
disposed = true;
ro.disconnect();
window.clearTimeout(timerId);
for (const evt of USER_INPUT_EVENTS) {
window.removeEventListener(evt, onUserInput, { capture: true } as EventListenerOptions);
}
if (rafId) window.cancelAnimationFrame(rafId);
if (activeStabilizer === dispose) activeStabilizer = null;
};
// rAF 合并:同帧多次 resize 只校正一次,避免抖动。
const scheduleCorrect = () => {
if (disposed) return;
if (rafId) return;
rafId = window.requestAnimationFrame(() => {
rafId = 0;
if (!disposed) scrollToHeading(el, false);
});
};
// 用户主动滚动即停止校正:不与用户交互打架。
// 用输入事件而非 scroll 事件——window.scrollTo 本身会触发 scroll无法区分。
const onUserInput = () => dispose();
for (const evt of USER_INPUT_EVENTS) {
window.addEventListener(evt, onUserInput, {
capture: true,
passive: true,
} as AddEventListenerOptions);
}
const ro = new ResizeObserver(scheduleCorrect);
ro.observe(target);
// 超时兜底:无论如何 STABILIZE_WINDOW_MS 后停止,防止长期占用。
const timerId = window.setTimeout(dispose, STABILIZE_WINDOW_MS);
activeStabilizer = dispose;
}
export function scrollToHash(): void {
const hash = window.location.hash.slice(1); // 去掉前导 #
if (!hash) return;
@ -34,4 +108,8 @@ export function scrollToHash(): void {
requestAnimationFrame(() => {
requestAnimationFrame(() => scrollToHeading(el, false));
});
// 布局稳定期覆盖两帧之后的异步位移mermaid 懒加载、图片/字体加载等),
// 位移后重新校正。用户主动滚动或超时后自动停止。
stabilizeScrollOnResize(el);
}