fix(post): scrollToHash 增加 ResizeObserver 布局稳定期,修正 mermaid 异步渲染导致的锚点落点偏移
直接访问 /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:
parent
1ea11b6434
commit
f6b7ab4333
@ -4,15 +4,38 @@
|
||||
*
|
||||
* 现在用 window.scrollTo 手动扣除 sticky header 高度(不再依赖 scrollIntoView +
|
||||
* scroll-margin-top,因为浏览器原生 fragment-scroll 对后者的应用时序不稳定)。
|
||||
*
|
||||
* 布局稳定期(ResizeObserver)测试:钉住异步布局位移(mermaid 懒加载注入 SVG、
|
||||
* 图片/字体加载撑高内容)后落点会重新校正,且用户主动滚动后停止校正。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import './index';
|
||||
|
||||
// --- ResizeObserver mock:happy-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();
|
||||
// 推进两帧校正的 rAF(fake 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,应触发 dispose(RO 断开、监听移除、超时清除)。
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user