🧑💻
React 絶対ズレないアンカースクロール
動機
URI フラグメント
https://example.com#hoge
でid="hoge"
要素にスクロールさせたい時ありますよね。
微妙な解決策
ブラウザ標準
その機能は初回のレイアウトの後に1度だけスクロール先の場所を演算するようで、画像ロードでレイアウトが変わるとズレた位置に止まります。
全画像ロード待ち
「全画像のロードを全て待ってからスクロール」するスクリプトは1つの解決です。
が、
- スクロール位置より下の遅延ロード画像
- レイアウトシフトを起こさない画像(widthやhightが指定されてるとか、css背景)
に待つことになります。多い場合はスクロールのタイミングが不要に遅くなります。
今回の解決策
レイアウトが変わったらもう一度スクロール位置を計算すればよいです。
- 即スクロールを開始する
- ResizeObserverをbodyに使って、レイアウトシフトを観測する
- レイアウトシフトを検知したら再度正しい位置へのスクロールを登録する
- ユーザーの操作があれば、それ以降スクロール登録はしない(これをしないと、ユーザーが読み進める中で再シフトが起こるとまたhogeに戻ってしまいます)
- ユーザー操作検知漏れに備え、一定秒数後にはスクロール登録を停止
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