BASEプロダクトチームブログ

ネットショップ作成サービス「BASE ( https://thebase.in )」、ショッピングアプリ「BASE ( https://thebase.in/sp )」のプロダクトチームによるブログです。

ブラウザのNavigation Timing APIとdocument.referrerを使って特定ページからの初めての遷移を判定する

この記事はBASE Advent Calendar 2021の19日目の記事です。

f:id:sam8:20211219184619p:plain

BASE BANK株式会社でエンジニアをしている若野(@sam8helloworld)です。 私が普段見ているサービスではBASEの他のアプリケーションや外部サービスのページから遷移してくることがよくあります。 さらにその時々でユーザに提供する情報や振る舞いを変えたくなることもあります。

今回はそういったケースの中でも

外部サービスの特定のページから遷移した時のみAPIを叩き、それ以外のページ更新、戻る、進む、他ページからの遷移の場合はAPIを叩かない

という仕様を実現するために私が行った調査検証が記事の内容となります。

仕様を簡単に図で表すと以下のようになります。 f:id:sam8:20211219184652p:plain

アプリケーションの構成の整理

今回の実装を行うに当たって前提となるアプリケーションは以下の3つの登場人物で成り立っています。

SPAの起点になるindex.htmlを配信しているバックエンドサーバ 特定のディレクトリ配下(e.g. /shop_admin/xxx)のURLにアクセスしてきた時に、SPAの起点になるindex.htmlをレスポンスとして返すことが責務です。

BFFサーバ SPAから呼ばれて表示要件に応じたjsonをレスポンスとして返すことが責務です。サービスのビジネスロジックはここに集約されている場合が多いです。

SPA BFFサーバにサービスのビジネスロジックを委譲しており、画面の表示やユーザのインタラクションを扱うことが主な責務です。

どこにロジックを持つか問題

さて登場人物がわかったところで、今度はその中でも上記の

外部サービスの特定のページから遷移した時のみAPIを叩き、それ以外のページ更新、戻る、進む、他ページからの遷移の場合はAPIを叩かない

というロジックを持つべきなのは誰なのか?ということが問題になります。 今回のケースでは以下の要件を満たすことができるかが鍵になります。

  • ページ遷移してきた時に処理を実行できる
  • 直前のページのURLを取得できる
  • 戻るや進むボタン(ブラウザのHistoryAPI)、ページ更新の時は処理を実行しない

では登場人物を1つずつ検証していきます。

SPAの起点になるindex.htmlを配信しているバックエンドサーバ

結論から言うと全ての要件を満たすことはできませんでした。(厳密に言えばやろうとすればできるけど遠回り感すごい。)

  • [x] ページ遷移してきた時に処理を実行できる
  • [x] 直前のページのURLを取得できる
  • [ ] 戻るや進むボタン(ブラウザのHistoryAPI)、ページ更新の時は処理を実行しない

まず1つ目の要件の「ページ遷移してきた時に処理を実行できる」に関しては、外部サービスからページ遷移してくる都合上必ずバックエンドサーバのControllerでSPAの起点になるindex.htmlを返す処理を行うので以下のようにページ遷移時にAPIを叩くことも可能です。

public class XXController {
    // 省略
    public function index()
    {
        // ページ遷移してきた時の処理を行う
        // $api->call();
        $this->render('SPAのindex.html');
    }
}

次に2つ目の要件の「直前のページのURLを取得できる」に関しては、以下のようにリファラを参照することでブラウザセッション内の直前のページのURLを取得できます。

public class XXController {
    // 省略
    public function index()
    {
        $referer = $_SERVER['HTTP_REFERER'];
        if ($referer === '特定のURL') {
          // ページ遷移してきた時の処理を行う
        }
        $this->render('SPAのindex.html');
    }
}

※ 注意点: <meta name="referrer" content="no-referrer">タグがあるなど、リファラを送信しない設定になっているページのURLは取得できません。

最後に3つ目の「戻るや進むボタン(ブラウザのHistoryAPI)、ページ更新の時は処理を実行しない」に関しては、以下の3パターンを場合のサーバサイドの状態を考える必要があります。

  • 外部ページから戻ってきた時
  • 外部ページから進んできた時
  • ページを更新した時

外部ページから戻ってきた時 例えば以下のように a.example.com -> b.example.com -> c.example.comとリンクを押して遷移して、戻るボタンでb.example.comに戻ってきた場合を考えます。

f:id:sam8:20211219184722p:plain

ブラウザの進むボタン(history forward)や戻るボタン(history back)はリファラの状態も元に戻してしまいます。なので、c.example.comから遷移してきたにも関わらずバックエンドでリファラを参照するとあたかもa.example.comから遷移してきたように見えてしまうのです。

developer.mozilla.org

html.spec.whatwg.org

外部ページから進んできた時 「外部ページから戻ってきた時」とは違って確かに直前のページの意味合いはあっていますが、今度はバックエンドでリンクを踏んだページ遷移と区別がつかないので要件を満たせません。

ページを更新した時 ページを更新した場合も「外部ページから進んで来た時」と同様にリファラはそのままですが、バックエンドではリンクを経由したページ遷移と区別がつかないので要件を満たせません。

※ セッションやCookieを使えばバックエンドで状態を持てるのでページの更新や2回目以降の遷移かどうかが判断できますが、後述の方法の方がシンプルなので採用していません。

BFFサーバ

そもそものSPAから呼ばれてjsonを返すことが責務なので、BFFサーバに画面遷移の状態を判別させるのはお門違いです。

  • [ ] ページ遷移してきた時に処理を実行できる
  • [ ] 直前のページのURLを取得できる
  • [ ] 戻るや進むボタン(ブラウザのHistoryAPI)、ページ更新の時は処理を実行しない

SPA

SPAではなんとか3つの要件全てを満たすことができました。

  • [x] ページ遷移してきた時に処理を実行できる
  • [x] 直前のページのURLを取得できる
  • [x] 戻るや進むボタン(ブラウザのHistoryAPI)、ページ更新の時は処理を実行しない

1つ目の要件の「ページ遷移してきた時に処理を実行できる」と3つ目の要件の「戻るや進むボタン(ブラウザのHistoryAPI)、ページ更新の時は処理を実行しない」に関しては、ブラウザのNavigation Timing APIを使うことで要件を満たせます。 Navigation Timing APIではバックエンドで判別できなかった戻る・進む・更新・ページ遷移をそれぞれ状態として取得できるので、ページ遷移の時だけ処理を行うということが可能になります。

developer.mozilla.org

ただ注意が必要なことは、バックエンドと違ってSPAではページ遷移という概念が普通のリンクを踏んだページ遷移とvue-routerなどのjsで制御されたルーティングによるページ遷移の2つあるということです。

2つ目の要件の「 直前のページのURLを取得できる」に関しては、document.referrerがバックエンドのリファラと同じ役割を果たすので要件を満たせます。

mounted() {
  const referrer = document.referrer
  if (referrer === '特定のURL') {
    // ページ遷移してきた時の処理を行う
  }
}

Navigation Timing API

さて、Navigation Timing APIを使えば要件を満たせると言いましたが、Navigation Timing APIとはどういうものなのでしょうか?

Navigation Timing APIのMDN Web Docs を参照すると主に以下の使い方をするAPIであると書かれています。

  • Collecting timing information(タイミング情報の収集)
  • Determining navigation type(ナビゲーションタイプの決定)

さらにDetermining navigation typeでは以下のことが判別できると書いてあります。

Was this a load or a reload? (ロードかリロードか) Was this a navigation or a move forward or backward through history? (ページ遷移かhistory backかhistory forwardか) How many (if any) redirects were required in order to complete the navigation? (遷移が完了するまでに何回りダイレクトしたか)

これは上述したクリアすべき要件にピッタリハマりそうです。

Navigation Timing APIを使ってナビゲーションタイプを判別する方法

ナビゲーションタイプを判別する方法は2つあります。 1つは window.performance.navigation.typeを参照する方法です。

if (window.performance.navigation.type === window.performance.navigation.TYPE_NAVIGATE) {
  console.log('ページ遷移')
}
if (window.performance.navigation.type === window.performance.navigation.TYPE_RELOAD) {
  console.log('ページ更新')
}
if (window.performance.navigation.type === window.performance.navigation.TYPE_BACK_FORWARD) {
  console.log('戻る・進む')
}
if (window.performance.navigation.type === window.performance.navigation.TYPE_RESERVED) {
  console.log('その他')
}

developer.mozilla.org

2つ目の方法はPerformanceNavigationTimingインタフェースを使用する方法です。

const entries = window.performance.getEntriesByType('navigation')
for (const entry of entries) {
    if (entry.type === 'navigate') {
        console.log('ページ遷移')
    }
    if (entry.type === 'reload') {
        console.log('ページ更新')
    }
    if (entry.type === 'back_forward') {
        console.log('戻る・進む')
    }
    if (entry.type === 'prerender') {
        console.log('その他')
    }
}

developer.mozilla.org

2つの判別方法の使い分け

2021年12月現在、主要ブラウザでwindow.performance.navigation.typeを利用することはできますが、参照することは非推奨となっています。 代替の方法として2つ目のPerformanceNavigationTimingインタフェースを利用することが推奨されている状況です。

ただし、PerformanceNavigationTimingはiosのsafariが今のところサポートしていない状況です。 f:id:sam8:20211219184745p:plain

const types = window.PerformanceObserver.supportedEntryTypes
if (types.includes('navigation')) {
  // PerformanceNavigationTimingインタフェースに対応
}

上記のsupportedEntryTypesでPerformanceNavigationTimingのサポート状況は確認できます。 navigationという文字列を含む配列を返す場合は対応済みなのでPerformanceNavigationTimingインタフェースを使った実装を。navigationという文字列を含まない配列を返す場合はwindow.performance.navigation.typeを使った実装を行うのが良さそうです。

developer.mozilla.org

Navigation Timing APIを使った判別はvue-routerを利用した遷移にも使える?

Navigation Timing APIにおける「ページ遷移」「戻る・進む」はあくまでもdocumentオブジェクトが初期化・再構築されるような処理に対して遷移に対して判定されるようです。

www.w3.org

html.spec.whatwg.org

VueをはじめとしたSPAのルーティングではdocumentオブジェクトを初期化するのではなく、一部のDOMを更新する仕組みになっているので「ページ遷移」「戻る・進む」は判定できませんでした😢

ただ、今回処理を行いたいのはVueのルーティングではなく外部ページからリダイレクトされてきた時なので要件は満たせるというわけです。

document.referrerと組み合わせて「特定ページからの初めての遷移を判定する」をコード

isFromCertainPage() {
    // safari on ios か確認
    if (!window.PerformanceObserver.supportedEntryTypes.includes('navigation')) {
        // ページ遷移か確認
        if (window.performance.navigation.type !== window.performance.navigation.TYPE_NAVIGATE) {
            return false
        }
    }

    // ページ遷移か確認
    const entries = window.performance.getEntriesByType('navigation')
    for (const entry of entries) {
        if (entry.type !== 'navigate') {
            return false
        }
    }
    const expectedReferer = '特定のURL'
    let referrer = document.referrer.replace(/\?.*$/,'')
    // safariのバージョンによってreferrerのトレイリングスラッシュの有無が分かれるので、一律トレイリングスラッシュをつける
    // https://trac.webkit.org/changeset/280342/webkit/
    referrer = referrer.slice(-1) !== '/' ? referrer + '/' : referrer
    if (!referrer.startsWith(expectedReferer)) {
        return false
    }
    // SPA内での回遊判定
    if (this.$store.state.alreadyVisited) {
       return false
    }
    return true
}

※ SPAのルーティングの遷移に関しては現状判定するAPIがないので、状態を持つようにしています。

終わりに

Navigation Timing API自体は複雑ではないので、すぐに実装に入れました。ただ、SPAではなぜNavigation Timing APIでページ遷移が判定できないのか?ということを深掘りしていくとブラウザAPIの仕組みが垣間見えて結構面白かったです。

あとは今回みたいにブラウザのAPIを利用したコードのテスト書こうとしたときに、jsdomだとモックだらけになっちゃうし実ブラウザを使ったIntegrationテストにするにもコストかかるしで塩梅が難しいと感じました。ここら辺のテスト詳しい方がいたらぜひ@sam8helloworldまで🙇‍♂️

また、もしBASEBANKに興味のある方は@sam8helloworldや下記のリンクまで

herp.careers

明日はOwners Experience Backend Groupの杉浦さんです。