🧑‍💻

React 絶対ズレないアンカースクロール

に公開

動機

URI フラグメント

https://example.com#hogeid="hoge"要素にスクロールさせたい時ありますよね。

微妙な解決策

ブラウザ標準

その機能は初回のレイアウトの後に1度だけスクロール先の場所を演算するようで、画像ロードでレイアウトが変わるとズレた位置に止まります。

全画像ロード待ち

「全画像のロードを全て待ってからスクロール」するスクリプトは1つの解決です。
が、

  • スクロール位置より下の遅延ロード画像
  • レイアウトシフトを起こさない画像(widthやhightが指定されてるとか、css背景)

に待つことになります。多い場合はスクロールのタイミングが不要に遅くなります。

今回の解決策

レイアウトが変わったらもう一度スクロール位置を計算すればよいです。

  1. 即スクロールを開始する
  2. ResizeObserverをbodyに使って、レイアウトシフトを観測する
  3. レイアウトシフトを検知したら再度正しい位置へのスクロールを登録する
  4. ユーザーの操作があれば、それ以降スクロール登録はしない(これをしないと、ユーザーが読み進める中で再シフトが起こるとまたhogeに戻ってしまいます)
  5. ユーザー操作検知漏れに備え、一定秒数後にはスクロール登録を停止
import { useEffect } from 'react';

const userEvents = ['touchstart', 'wheel', 'mousedown', 'keydown'] as const;
const endObservationAfter = 2000; // useEffectの実行から2秒後に追加スクロールをやめる

const useScrollByUriFragment = () => {
  useEffect(() => {
    const uriFragment = location.hash.slice(1);
    if (!uriFragment) return;
    const targetEl = document.getElementById(uriFragment);
    if (!targetEl) return;

    const scroll = () => {
      targetEl.scrollIntoView({
        behavior: 'smooth',
        block: 'start',
      });
    };
    scroll();

    //画像ロードなどリサイズ時に追加スクロール
    const resizeObserver = new ResizeObserver(() => {
      scroll();
    });
    resizeObserver.observe(document.body);

    const stopAddingScroll = () => {
      resizeObserver.disconnect();
    };
    //ユーザーの操作があったら、それ以降はスクロール追加しない
    userEvents.forEach((event) => {
      document.addEventListener(event, stopAddingScroll, {
        once: true,
        passive: true,
      });
    });
    //↑の操作検知に漏れがあった時用に操作不能を回避
    const timeoutId = setTimeout(stopAddingScroll, endObservationAfter);

    return () => {
      clearTimeout(timeoutId);
      resizeObserver.disconnect();
      userEvents.forEach((event) => {
        document.removeEventListener(event, stopAddingScroll);
      });
    };
  }, []);
};

export default useScrollByUriFragment;

これで、画像云々の理由を問わず、レイアウトシフトそのものに対抗できます。
大きなシフトがあると、ビューン ぐぐっ ビューンみたいな微妙に多段階動いてるような挙動になりますが、実用には耐える折衷案ですね。

固定ヘッダー分ずらしたい

固定ヘッダー分ずらすような場合はscrollIntoViewではできないので、古き良きscrollToを使うことになります。

const targetRect = targetEl.getBoundingClientRect();
const offsetTop = window.pageYOffset + targetRect.top - 80;//ヘッダー分
window.scrollTo({
 top: offsetTop,
 behavior: 'smooth',
});

Discussion