From f6b7ab4333037e03a776912453a0b55eb52c3bcc Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 16 Jul 2026 16:05:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(post):=20scrollToHash=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=20ResizeObserver=20=E5=B8=83=E5=B1=80=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=E6=9C=9F=EF=BC=8C=E4=BF=AE=E6=AD=A3=20mermaid=20=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E6=B8=B2=E6=9F=93=E5=AF=BC=E8=87=B4=E7=9A=84=E9=94=9A?= =?UTF-8?q?=E7=82=B9=E8=90=BD=E7=82=B9=E5=81=8F=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 直接访问 /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。 --- libs/yggdrasil-core/src/hash-scroll.test.ts | 112 ++++++++++++++++++++ libs/yggdrasil-core/src/hash-scroll.ts | 78 ++++++++++++++ 2 files changed, 190 insertions(+) diff --git a/libs/yggdrasil-core/src/hash-scroll.test.ts b/libs/yggdrasil-core/src/hash-scroll.test.ts index 872aa37..a07ee3e 100644 --- a/libs/yggdrasil-core/src/hash-scroll.test.ts +++ b/libs/yggdrasil-core/src/hash-scroll.test.ts @@ -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(); + }); }); diff --git a/libs/yggdrasil-core/src/hash-scroll.ts b/libs/yggdrasil-core/src/hash-scroll.ts index 5a98856..99a1565 100644 --- a/libs/yggdrasil-core/src/hash-scroll.ts +++ b/libs/yggdrasil-core/src/hash-scroll.ts @@ -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 = ['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); }