🦃

よくわかる SPA のリフレッシュ対応

2023/01/15に公開

ロンラン株式会社 CEO 兼 CTO の武部です。

Vue, React などの SPA アプリケーションにおいて、「新しいバージョンをデプロイ後、どうやってユーザーのアプリケーションをリフレッシュするか」という、全 SPA 開発エンジニアを悩ませる課題があります。

この前向きな解決方法を整理しました。

ちなみに、後ろ向きな解決として次のようなものは思いつきます。

  • 挙動がおかしかったら、ユーザー自身がブラウザリロードしてくれるだろう... 🙏
  • サポートチームに伝達だ...「問い合わせあったらひとまずリロードしてもらってください 🤪」

商用サービスでこれを許容してしまうとユーザー体験を損なう可能性が大きいため、エンジニアの 小さな工夫 で前向きに解決したいところです。

ふたつのステップに分けて検討する

この課題はふたつのステップ、「検知方法」と「ユーザーへの伝え方」それぞれについて検討します。

検知方法については、ユーザーに使わせるバージョンを厳格にコントロールしたいか、あるいはその必要がないかによって設計アプローチが変わります。
厳格にコントロールしたいなら、バージョン管理・判定と、判定結果に応じた振る舞いの実装が必要となります。そうではないなら、新しいバージョンのアプリケーションが配信元インフラに配置されたことを検知するだけで済みます。

ユーザーへの伝え方については、明示的か、サイレント(しれっとリロード)かです。

図解すると次のような整理になります。

まず、検知方法の設計アプローチから見てゆきましょう。

厳格なバージョンコントロールが不要な場合

ユースケースが次の場合。

  • サービス運営サイドからの厳格なバージョンコントロールが不要
  • 新しいバージョンがリリースされたら、その途端でなくてもよいが、あまり間を空けずに更新してもらいたい
  • アップデートによって互換性が失われており、アプリケーションが正常動作しない場合、速やかに更新して操作に復帰してもらいたい

大半の SPA 製品/プロジェクトはこちらに該当するのではないかと思うので、先にこちらから取り上げます。

【検知方法例】TTL 管理+ ES2020 の Dynamic Imports のエラーを利用する(推奨)

ユーザーがダウンロードした SPA アプリケーションの TTL を管理するのは、実はかなり容易です。

VueRouter の beforeEach フックを利用して、前回操作から一定時間経過したら、強制リロードするようにします。
TTL のスタート時間は、beforeEach の外側の適当な場所に定義しておきます。

const router = createRouter({
  ...
})

/**
 * 前回更新から 12h 経過したらリフレッシュ
 */
const REFRESH_TIMER_SEC = 60 * 60 * 12;
const initialTime = Date.now();
router.beforeEach((to, from, next) => {
  const intervalSec = Math.ceil((Date.now() - initialTime) / 1000);
  if (intervalSec > REFRESH_TIMER_SEC && to.meta.forceReload) {
    console.warn("Cache timeout. force reload");
    window.location.assign(to.fullPath);
    return;
  }
  next();
});

上記に加えて、「変更によって互換性が失われており、アプリケーションが正常動作しない場合」の強制リロードも仕込みます。

こちらは VueRouter の onError フックを活用します。ルーティング時に chunk 済み js の Dynamic Import が失敗することを逆手にとって利用します。

/**
 * route エラー時は強制的にリフレッシュ
 */
router.onError(
  (error, to: RouteLocationNormalized, from: RouteLocationNormalizedLoaded) => {
    if (error.message.includes("Failed to fetch dynamically imported module")) {
      console.warn(`${error.message}, force reload.`);
      window.location.assign(to.fullPath);
    }
  }
);

これだけです。エラー文字列を直接判定している部分に脆さがあることは否めないものの、簡潔なコードが魅力です。

  • この例での「ユーザーへの伝え方」は、コールバック関数内でサイレントにリロードし、明示的に伝えていません。もしユーザーにメッセージを伝えたいなら、window.alert や、あるいはもっとリッチな UI を実装して伝えることができるでしょう
    • 特定のルーティング時のみ、謝罪や警告メッセージを出したい場合もあるかもしれないです。その場合は、to.name などで判定できます
  • vue-router を使っていない場合は、app.config.errorHandler や、利用しているルーティング機能のエラーハンドラーを利用してもよいでしょう

このコードにおける前提は ES2020 以降を使っていることです。ES2020 よりも前の場合は、Dynamic Import が使えないため、この方法は使えません。

【検知方法例】ハンズラボさんの例

ハンズラボさんでは「トランスパイル後の index.html の hash を確認する」方式を考案されています。

https://www.hands-lab.com/tech/t5172/

やや仕組みが複雑かなと感じますが、Dynamic Import に依存しないため、古い ES バージョンを使う必要がある場面では選択肢になるでしょう。

厳格なバージョンコントロールをしたい場合

「古いバージョンの利用も許容しつつ、決定的な理由があるとき(例:「セキュリティホールが見つかった」「バックエンドの構成が変化して下位互換がない」といったケース)だけ強制バージョンアップしたい」

こういったユースケースが求められる場合、準備はやや複雑です。
配布後のリソースのキャッシュ戦略に加え、バージョンコントロールによるリフレッシュが必要になるでしょう。決めた期間中はビルド後のリソースも消さずに配置しておく必要があります。

厳格なバージョンコントロールにおけるキャッシュ戦略

キャッシュ戦略については、配信側のインフラで設定することになりますが、深いのでここで詳しくは触れません。mdn などを参照して設定をしてください。

https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Cache-Control

厳格なバージョンコントロール

バージョンコントロールについては、次のような概念の仕組みを用意します。

  • アプリケーションの外部にバージョン番号等を管理する仕組みを用意する
  • 動作中のアプリケーションはルーティング時などのタイミングで、上記で用意した外部システム等を参照し、動作中の自身のバージョンと比較。差を知る

バージョン番号を手動で運用管理するか、自動運用するかも検討ポイントです。

もしあなたのチーム体制において手動運用が可能なのであれば、フィーチャートグル製品を利用したり、オリジナルの仕組みを構築して、リリース後に誰かにバージョン番号の設定値を更新してもらいましょう。

一方、それが負担であったり現実的でない場合は、自動検知の仕組みを模索します。

下記はエイチームさんのソリューションの例です。アクセス可能な公開領域にバージョン(リビジョン)を管理するファイルを配置し、運用する方式を考案されています。

https://qiita.com/okkuyama/items/484fdd868b17d8c960a9

上記では、リビジョンが変更されていることを検知したら更新しているだけですが、たとえば「セマンティック・バージョンを判定したうえで、決めたルールどおりに振る舞う」といった実装も可能でしょう。

私もやるならこれかなと考えた時期があり、ほぼ同様の仕組みを汎用化したうえで、次のとおりサンプル実装を公開しています。

https://github.com/longrun/vue3-spa-version-update-confirmation

検知の仕組みとしては「デプロイ時に package.json の version 値をアクセス可能な公開領域(public/)に出力しておき、それと比較する」というフローを考えました。ユーザーへの伝え方としては、 Toast 型の通知を表示しています。

厳格なバージョンコントロールを実現しようとすると、手動であれ自動であれ、仕組みがなかなか複雑になってしまうのが難点です。

上記の当社 OSS レポジトリも、実は自社製品に組み込んでおらず、先に挙げた「TTL + Dynamic Import エラー検知方式」に着地しました。将来もし高度な運用が必要になった折りには、この方式へのシフトを検討したいと考えています。

ユーザーへの伝え方はソフトかハードか

ユーザーへの伝え方について。

更新を通知し、更新タイミングをユーザーに委ねるソフトなアプローチが必要でしょうか。
それとも、選択肢は委ねずしれっとリロードする、ハードなアプローチを取るべきでしょうか。

ここは悩むところですが、最近私が遭遇した、示唆を含むできごとを共有したいと思います。

とあるオンライン MTG 中のことでした。

モニターの向こう側の方が、Notion の画面を共有しながらプレゼン中でした。その際、Notion のページ上部に水色背景の更新通知バナーが出ており、「更新して!」と猛烈にアピールしているのにもかかわらず、プレゼンが続きます。私はそれが気になって、話が頭に入ってきません。

プレゼンがひと区切り付いたときに『なぜ更新通知が出ているのに、リンクを押して更新しないのですか?』と聞いてみたところ、「まったく気づいていなかった!」という返事が返ってきました。

この経験から、ユーザーに更新通知を表示したとて、意図どおりに更新操作をしてくれると期待してはいけないとあらためて痛感しました。

アラートが水色からオレンジ、赤に変わったとしても、やはりユーザーは開発者の意図どおりに操作してくれるとは思えないです。いつまで経っても Chrome 右上の更新ボタンを押さない人、見たことありませんか?

ハードなアプローチ(しれっとサイレントにリロード時)の留意点

しれっとサイレントにリロードする場合の留意点として、いわずもがなですが「ユーザーが画面へ情報入力をしながら、その情報を引き継いで画面遷移している」ような UI と操作フロー時に自動リロードしてしまうと、情報がロストしかねません。

旧来から Web の世界によくある「大型のフォームと更新ボタン」という UI 自体をやめて、各フィールドが更新されるたびに値をバックエンドへ永続化することで、このようなリスクを減らせると思います。この点は、UI および操作フローの初期設計において最初から考慮しておく必要があるでしょう。

SPA のローリングアップデート

ローリングアップデートを行いたい時もあるかもしれません。たとえば、

「メジャー版リリースから 30 日間はユーザー意思でアップデートしてもらう。ただし 30 日後には強制的にアップデートさせる」といったケースです。

この場合は、ソフトな通知とハードなリロードをミックスするアプローチが考えられます。

ただ、本当にこのような複雑な運用がユーザーにとって/サービスにおいて必要かは、よくよく吟味検討したいものです。

まとめ

  • 検知を仕組み化してゆくうえで、厳格なバージョン管理が必要か、そうでもないかを検討しましょう
    • 厳密なバージョン管理が必要となった場合、手動で人が運用管理してゆくか、自動で運用するかを検討しましょう
  • ユーザーへの伝え方について、ソフトかハードかを検討しましょう

最後に

ネイティブアプリがうらやましい。寝ている間に OS が勝手にアップデートしてくれるわけですし。

とはいえ Web アプリケーションでは、SPA であってもユースケースに応じた柔軟な解決方法が取り得る点、利もあるなと感じます。

ロンラン Tech Zenn

Discussion