Notes / Web技術

Astro View Transitionsとイベントリスナーの累積。AbortControllerでクリーンアップする

AstroのView Transitionsを有効化すると、ページ遷移のたびに astro:page-load が発火します。セットアップ系の処理を書いていると、イベントリスナーが累積する。AbortControllerと astro:before-swap を使ったクリーンアップパターン。

Astro View Transitions AbortController memory leak

AstroのView Transitionsを有効化すると、リンククリックでページ遷移がSPA風に滑らかになります。便利。けれどページ遷移のたびに astro:page-load が発火することを忘れると、セットアップ系の処理が再実行され、イベントリスナーが累積していきます。

メモリリークだけでなく、Escapeキーで複数のモーダルが同時に閉じる、スクロールハンドラが2重に発火する、といった重複動作の原因になります。

症状: 遷移ごとにリスナーが累積する

典型的なAstroコンポーネントのインラインスクリプトです。

<header id="site-header">...</header>

<script>
  function setupHeader() {
    const header = document.getElementById('site-header');
    if (!header) return;

    const onScroll = () => {
      header.dataset.scrolled = window.scrollY > 8 ? 'true' : 'false';
    };
    window.addEventListener('scroll', onScroll, { passive: true });

    document.addEventListener('keydown', (e) => {
      if (e.key === 'Escape') { /* drawer close */ }
    });
  }

  setupHeader();
  document.addEventListener('astro:page-load', setupHeader);
</script>

(Astro の <script> 内では TypeScript 構文が使えるため、as キャストや型注釈を書ける前提でサンプルを書いています。素の HTML に転用する際は型注釈を外してください。)

これで /about/、/services/、/notes/ とSPA風遷移すると、scroll リスナーと keydown リスナーが毎回1個ずつ追加されます。3ページ遷移後にはスクロールハンドラが4重に発火する状態です。

原因: 再実行のたびに解除されない

astro:page-load はView Transitionsのswapが完了したタイミングで発火します。セットアップを書き直す機会としては正しい。問題は、前回のリスナーを解除する処理がないこと。登録だけが積もります。

普通のSPAフレームワーク(React、Vue等)ならコンポーネント unmount のライフサイクルでリスナーを外しますが、Astroのインラインスクリプトはコンポーネントライフサイクル概念がなく、自前でクリーンアップを組む必要があります。

解決: AbortController + signal

最近のJavaScriptの AbortController を使えば、複数のリスナーを1つのsignalでまとめて外せます。

<script>
  let headerAbort: AbortController | null = null;

  function setupHeader() {
    // 前回セットアップのリスナーを全部abort
    headerAbort?.abort();
    headerAbort = new AbortController();
    const { signal } = headerAbort;

    const header = document.getElementById('site-header');
    if (!header) return;

    const onScroll = () => {
      header.dataset.scrolled = window.scrollY > 8 ? 'true' : 'false';
    };
    window.addEventListener('scroll', onScroll, { passive: true, signal });

    document.addEventListener('keydown', (e) => {
      if (e.key === 'Escape') { /* ... */ }
    }, { signal });

    // 他にも click や resize 等あれば全部 { signal } を渡す
  }

  setupHeader();
  document.addEventListener('astro:page-load', setupHeader);
</script>

addEventListener の3引数目に signal を渡すと、そのsignalが abort() された瞬間にリスナーが全部外れます。セットアップを再実行する前に前回分をまとめて解除できます。

astro:before-swapで外す

astro:page-load はswap後に発火しますが、もう一つ astro:before-swap がswap直前に発火します。これも使うと、さらに整理されます。

document.addEventListener('astro:before-swap', () => {
  headerAbort?.abort();
  headerAbort = null;
});

ページ遷移開始時に前ページのリスナーを全部外し、新ページのセットアップで新規登録する流れになります。

二重初期化のガード

closureの外側で headerAbort を保持しておくと、同ページで setupHeader() が複数回呼ばれた場合(開発時のホットリロード、外部から手動再init)にも累積しません。headerAbort?.abort() を setupHeader() の冒頭に置けば、何度呼んでも前回分が必ず外れます。


Astro View Transitions は SPA 風遷移の体感を簡単に手に入れられる代わりに、ページライフサイクルのクリーンアップを意識する必要があります。signal を渡しておけば、abort 時に複数のリスナーをまとめて解除できます。

Web運用設計のご相談はお問い合わせから、業務領域の詳細はServicesへ。