[go: up one dir, main page]
More Web Proxy on the site http://driver.im/

カミナシ エンジニアブログ

株式会社カミナシのエンジニアが色々書くブログです

Service Worker がページをコントロールし始めるタイミングを skipWaiting と clients.claim から理解する

こんにちは。カミナシでソフトウェアエンジニアをしております佐藤です。

先日、『カミナシ レポート 記録アプリ(Web)』の提供を開始いたしました。

現場帳票システム『カミナシ レポート』、マルチデバイスで記録ができるWeb版をリリース

PWA として利用が可能であり、また、ネットワークが不安定な現場での使用も想定したオフライン機能も備えています。これらを実現する上では、Service Worker が重要な役割を果たしています。

『カミナシ レポート 記録アプリ(Web)』の実装には、VitePWA を利用しています。VitePWA では Service Worker の制御に Workbox が使用されています。

Workbox は Service Worker の扱いをよしなにやってくれる非常に便利なライブラリなのですが、正しく活用する上では、やはり、Service Worker そのものについてしっかりと理解することが欠かせません。

(かくいう私も初めは Workbox に頼りっきりで、後追いで Service Worker についての理解を深めていったため、偉そうなことは言えませんが…)。

とりわけ、お客様がアップデートを正しく適用できるようにしたり、安定してオフライン利用できるようにするためには、「Service Worker が有効になり、Web ページをコントロールし始めるタイミング」を理解することが重要でした。

この記事では、上記の点につき、skipWaiting と clients.claim の振る舞いを紐解くことで、理解を深めていこうと思います!

(*) Q. なぜ、skipWaiting と clients.claim なのか? A.これら2つの振る舞いを理解しようとしたところ、結果的に Service Worker のライフサイクルやその他についての理解が深まった、という個人的な経験が背景にあります。

Service Worker のデフォルトの振る舞い

本記事の主題との関連では、Service Worker が「有効になるタイミング」「Service Worker がページをコントロールし始めるタイミング」について把握することが重要です。

Service Worker はブラウザにインストールされ、プロキシのように振る舞います。ここでいう「プロキシ」は、HTTP リクエストをインターセプトし何かをするもの、というイメージです。もちろん、あらゆるサイトからの HTTP リクエストをインターセプトするのではなく、同一オリジンのスコープ内のページからの HTTP リクエストをインターセプトします。

このように「同一オリジンのスコープ内のページからの HTTP リクエストをインターセプト」できるようになっていることを、そのページを「コントロールしている」などといいます。

一度インストールされ有効になった Service Worker はブラウザに常駐し、破棄されるまではスコープ内のページをコントロールし続けます。

Service Worker ページコントロールのイメージ

Service Worker はインストールされたら、即対象ページをコントロールし始めるわけではなく、デフォルトでは以下のようなフェーズを経て、ページのコントロールができるようになります。

  1. Service Worker(=新しいバージョンの Service Worker)がインストールされる。
  2. 同一スコープにすでに Service Worker (=古いバージョンの Service Worker)がインストールされている場合、待機。
  3. 古いバージョンの Service Worker が入れ替え可能になったら、新しいバージョンの Service Worker が有効になる。(古いバージョンの Service Worker は破棄される)
  4. 新しい Service Worker はページをコントロールし始める。

Service Worker の状態遷移とページコントロールのタイミング

「古いバージョンの Service Worker が入れ替え可能になる」条件は、その Service Worker がコントロールしているページが全て閉じられること、です。

また、Service Worker に一切コントロールされていなかったページは、Service Worker が有効になるだけでは、コントロール対象になりません。

(一切コントロールされていなかったページ=そのスコープに Service Worker が初回インストールされる前から開かれていたページ)。

そして上記のような振る舞いゆえに、以下のような状況が発生し得ます。

  • 新しいバージョンの Service Worker がインストールされたが有効になっておらず、古いバージョンの Service Worker が有効なままとなっている。
  • Service Worker が有効になっているが、スコープ内の一部のページがコントロールされていない。

これらの振る舞いを変更するのが、冒頭で述べた skipWaiting と clients.claim です。

具体的には、

  • 「2」の「待機」の振る舞いを変えるのが、skipWaiting
  • 「4」のページをコントロールし始めるタイミングを変えるのが、clients.claim

です。以降では、skipWaiting と clients.claim の理解を深めていきます。

なお、Service Worker の「スコープ」や「コントロール」の考え方については以下の記事が非常に参考になります:

ServiceWorker のスコープとページコントロールについて - Qiita

skipWaiting

skipWaiting は、Service Worker のライフサイクルにおける、待機の振る舞いを変更します。skipWaiting を用いると、古いバージョンの Service Worker がある場合でも、待機状態を「skip」し、新しいバージョンの Service Worker を有効にできます。

以下のサンプルを使って、動作確認していきます。ソースコード全体は記事最後の Appendix に乗せています。

画面:ボタンが1つのみあるシンプルなものです

(sw.js - Service Worker)

const currentVersionCacheKey = "v1";
const allStaticAssets = new Set(["/logo.png"]);

self.addEventListener("install", (event) => {
  // CacheStorage を使ったキャッシュの構築処理など
});

self.addEventListener("activate", (event) => {
  // 古いバージョンの CacheStorage 上のキャッシュをクリーンアップする処理など
});

self.addEventListener("fetch", (event) => {
  console.log(
    `from cache version ${currentVersionCacheKey}`,
    event.request.url
  );
  // URI が一致する場合、サーバーに問い合わせず、キャッシュから取得して返却する処理
});

(Servie Worker にコントロールされるページの JavaScript コード)

    <script>
      window.addEventListener("load", () => {
        if ("serviceWorker" in navigator) {
          navigator.serviceWorker.register("/sw.js").then((registration) => {
            if (!navigator.serviceWorker.controller) {
              console.warn("!!reload!!");
              window.location.reload();
              return
            }
            setInterval(() => {
              // 3 秒間隔で sw.js ファイルの更新をチェック
              registration.update();
            }, 3000);
          });
        }
        document.querySelector("#insertImage").addEventListener("click", () => {
          // Service Worker が active な場合、logo.png は、CacheStorage から取得される。
          document
            .querySelector("#div")
            .insertAdjacentHTML(
              "beforeend",
              `<img src="logo.png" width="30"/>`
            );
        });
      });
    </script>

一旦、ページを開き、 Service Worker がインストールされ、有効になるのを待ちます。これを Chrome のデベロッパー ツールで見ると以下のようになります。

Service Worker が有効な状態

有効な状態であることが見て取れると思います。

この状態で、「Insert image」ボタンを押下すると、コンソールには、以下のようなメッセージが表示されます。

from cache version v1 http://localhost:61616/logo.png

ここで、ページは開いたままで sw.js を以下のように変更してデプロイし直して見ましょう。

- const currentVersionCacheKey = 'v1';
+ const currentVersionCacheKey = 'v2';

デプロイ後、間も無くすると、Service Worker は以下のような状態になります。

Service Worker が待機状態

このように、新しいバージョンの Service Worker はインストールされてもすぐには有効な状態になりません。「Insert image」ボタンを押下しても、コンソールに出力されるバージョンは「v1」のままです。

from cache version v1 http://localhost:61616/logo.png

該当ページを一度閉じ、再度開き直した後で、改めてデベロッパー ツールを見てみます。

すると、以下のように待機状態の Service Worker がなくなり、新しい Service Worker が有効な状態となります。

Service Worker が有効な状態

「Insert image」ボタンを押下すると、「v2」がコンソールに出力されます。

from cache version v2 http://localhost:61616/logo.png

ちなみに、仮にタブを1つのみ開いた状態でリロードしても、新しいバージョンの Service Worker に更新されません。これはブラウザのナビゲーションの振る舞いに起因しているとのことです。

ここまで、すでにインストール済みの Service Worker がある場合、新しいバージョンの Service Worker は待機状態になることを見てきました。以下では skipWaiting によってこの振る舞いが変わることを見ていきます。

変更をリバートし、再度、動作確認していきます。

(*実際に動かしてみる場合は、デベロッパーツール で、Service Worker を unregister するなどクリーンな状態であることを確認した上でお試しください)

install イベントのハンドラ内で self.skipWaiting() を呼び出すように書き換えた上で、同様のことをやってみます。

self.addEventListener('install', (event) => {
    self.skipWaiting()
        // CacheStorage を使ったキャッシュの構築処理
});

一度 Service Worker をインストールした後、以下の書き換えを行った上で、再度デプロイします。

- const currentVersionCacheKey = 'v1';
+ const currentVersionCacheKey = 'v2';

すると今度は、待機状態を経ずに、新しい Service Worker が有効になります。

Service Worker の待機状態がスキップされる

また、古いバージョンの Service Worker にコントロールされていたページはリロードなどをしなくても、新しいバージョンの Service Worker にコントロールされるようになります。

skipWaiting の利用シーン

デフォルトの動作は、「古いバージョンのページが、そのままの振る舞いを維持できる」ことを優先した設計である、ということができると思います。(感覚的にその方が安全な設計になりやすい印象はあります)

とはいえ、正しい動作を担保するためには、ページ・Service Worker のビルドバージョンは同一であることが求められるケースはあると思います。

例えば、

  • ページA(build#1) は、Service Worker(build#1) でコントロールされてほしい。
  • ページA(build#2) は、Service Worker(build#2) でコントロールされてほしい。

というケースです。

ここで、

  • ページA(build#1) が開いている状態で、別タブでページA(build#2) をロードする。

という操作について考えます。デフォルトの動作の場合、

  1. Service Worker(build#1)が有効な状態で、Service Worker(build#2)がインストールされる。
  2. Service Worker(build#2)は待機状態となり、有効にならない。
  3. ページA(build#1) は引き続き Service Worker(build#1) にコントロールされ続け、別タブで開いたページA(build#2)も、Service Worker(build#1)にコントロールされる。

という結果になります。「ページA(build#2)が、Service Worker(build#1)にコントロールされる」という不整合な状態となってしまいます。

これを回避するには、以下のような方法が考えられます。

  • skipWaiting で Service Worker(build#2) への更新を強制。
  • ページA(build#1)のコード上に、コントロールしている Service Worker の変更を検知する機構を仕込んでおく。変更を検知したら、画面をリロードして、ページA(build#2) に更新する。

この方法では次のようにして、最終的に不整合が解消されています。

  1. Service Worker(build#1)が有効な状態で、Service Worker(build#2)がインストールされる。
  2. Service Worker(build#2)は待機状態を経ずに、有効になる。
  3. 「2」の結果、ページA(build#1) は Service Worker(build#2) にコントロールされるようになる。別タブで開いたページA(build#2)も、Service Worker(build#2)にコントロールされる。
  4. ここで、リロードが走ることにより、ページA(build#1) だったタブもページA(build#2) に更新されるため、不整合が解消される。

clients.claim

clients.claim は、Service Worker の「初回インストール時」に効果を発揮する仕組みです。

まずは以下のデフォルトの振る舞いを把握することが重要です。

  • Service Worker の初回インストール前から開かれていたページは、Service Worker が有効になってもコントロール対象とならない
  • 「初回インストール前から開かれていたページ」には、Service Worker の register を実行したページも含む

以下のサンプルを使って、動作確認していきます。skipWaiting の確認時に用いたものとほとんど同一ですが、一部書き換えています。ソースコード全体は記事最後の Appendix に乗せています。

画面:ボタンが1つのみあるシンプルなものです

(sw.js - Service Worker)

const currentVersionCacheKey = "v1";
const allStaticAssets = new Set(["/logo.png"]);

self.addEventListener("install", (event) => {
  // CacheStorage を使ったキャッシュの構築処理など
});

self.addEventListener("activate", (event) => {
  // 古いバージョンの CacheStorage 上のキャッシュをクリーンアップする処理など
});

self.addEventListener("fetch", (event) => {
  console.log(
    `from cache version ${currentVersionCacheKey}`,
    event.request.url
  );
  // URI が一致する場合、サーバーに問い合わせず、キャッシュから取得して返却する処理
});

(Servie Worker にコントロールされるページの JavaScript コード)

    <script>
      window.addEventListener("load", () => {
        if ("serviceWorker" in navigator) {
          navigator.serviceWorker.register("/sw.js").then((registration) => {
            setInterval(() => {
              // 3 秒間隔で sw.js ファイルの更新をチェック
              registration.update();
            }, 3000);
          });
        }
        document.querySelector("#insertImage").addEventListener("click", () => {
          // Service Worker が active な場合、logo.png は、CacheStorage から取得される。
          document
            .querySelector("#div")
            .insertAdjacentHTML(
              "beforeend",
              `<img src="logo.png" width="30"/>`
            );
        });
      });
    </script>

ページを開くと、初回インストールのため、Service Worker は待機状態を経ずに有効になります。

Service Worker が有効な状態

この状態で、リソースの fetch を行うとどうなるでしょうか?

もし、同ページが Service Worker にコントロールされているのであれば、「Insert image」ボタンを押下したとき、コンソールに以下のようなログが表示されるはずです。

from cache version v1 http://localhost:61616/logo.png

しかし、実際には何も表示されません。

この動作こそが、「Service Worker の初回インストール前から開かれていたページは、Service Worker が有効になってもコントロール対象とならない」の正体です。(なお、この状態でタブ・ウィンドウをリロードすると、ページが Service Worker にコントロールされるようになり、リソースの fetch 時にログが確認できるようになります)

このようなケースにおいて、(リロードしなくとも)ページをコントロール対象とするために使用するのが、clients.claim です。以降で確認していきましょう。

activate イベントハンドラ内で、clients.claim を呼び出すように書き換えた上で、同様のことをやってみます。

self.addEventListener("activate", (event) => {
  event.waitUntil(clients.claim());
  // 古いバージョンの CacheStorage 上のキャッシュをクリーンアップする処理
});

今度は、Service Worker が有効になった後に、「Insert image」ボタンを押下すると、コンソール上で以下のログが確認できます。

from cache version v1 http://localhost:61616/logo.png

デベロッパー ツールを見ると、先ほどとは異なり、有効になると同時に「Clients」にエントリーが表示されています。これは、Service Worker のコントロール対象のページを指しています。このことからも、clients.claim の動作が実感できるのではないでしょうか。

コントロール対象のページが「Clients」に表示されている

clients.claim の利用シーン

skipWaiting 同様、デフォルトの動作は、「古いバージョンのページが、そのままの振る舞いを維持できる」ことを優先した設計である、ということができると思います。

とはいえ、以下のようなケースは想定されます。

  1. 最初にページを開いた時点で、Service Worker にそのページをコントロールさせたい。
  2. Service Worker にコントロールされていない古いページが開かれている。別のタブやウィンドウで、そのページをスコープに含む Service Worker がインストールされた時、そのページもコントロールさせたい。

例えば、アプリケーションのページを開いて、即座にオフライン環境に移動して操作をしたいときなどです(「1.」の例)。

ここまでのまとめ

ここまで見てきた、skipWaiting・clients.claim について簡単にまとめます。

🗒️ skipWaiting

  • デフォルトでは、新しいバージョンの Service Worker はインストール後、すぐには有効にならず、待機状態となる。
  • skipWaiting は、この待機状態をスキップするために呼び出す。skipWaiting を呼び出した場合、インストール後すぐに新しいバージョンの Service Worker は有効にできる。
  • すでに古いバージョンの Service Worker にコントロールされていたページは、skipWaiting 後、新しいバージョンの Service Worker にコントロールされるようになる。
  • ページのソースのビルドバージョンと Service Worker のソースのビルドバージョンの不整合を防ぎたい場合、skipWaiting 後にリロードするなど、一定の工夫が必要。

🗒️ clients.claim

  • デフォルトでは、Service Worker の初回インストール前から開かれていたページは、Service Worker が有効になってもコントロール対象とならない。
  • clients.claim により、リロードなどを強制しなくても、このようなページを Service Worker のコントロール対象とできる。

カミナシレポートでの例

『カミナシ レポート 記録アプリ(Web)』の実装には、VitePWA を利用しています。VitePWA では Service Worker の制御に Workbox が使用されています。

当記事の主題に関わるところでは、『カミナシ レポート 記録アプリ(Web)』は以下のような性質を持っています。

  • Service Worker の CacheStorage には、js、css、svg などのアプリの動作に必要となる静的アセットが保持される。インストール時に全ての静的アセットをダウンロードしている。
  • Service Worker が有効になった後は、端末のオフライン・オンラインの状態を問わず、全ての静的アセットが Service Worker の CacheStrorage から取得される。
  • 全ての静的アセットが同時にビルド & デプロイされる。よって、ページ側のコードとService Worker 側のコードのビルドバージョンは同一であることが動作の前提となる。

オフライン環境での利用を想定していることもあり、Service Worker のインストールが、ネイティブアプリのインストールのような意味合いを持っている、とイメージしていただけるとわかりやすいかと思います。

更新については一定間隔で ServiceWorkerRegistration.update() を呼び出して、Service Worker の更新をチェックしています。新しいバージョンの Service Worker のインストールが完了すると、自動的に(Service Worker の更新を伴う)画面のリロードが走るように実装しています。

ただし、ユーザーの入力・編集内容が失われる恐れがあるページが開かれている場合、Service Worker の更新とリロードが走らないようにしています。この場合、新しい Service Worker は待機状態が維持されます。

上記の機構は、VitePWA で用意されている useRegisterSW を利用して実装しています(以下の例は実際のコードより簡略化しています)。

export function useUpdate() {

  ..... 
  
  const {
    needRefresh: [needRefresh, setNeedRefresh],
    updateServiceWorker,
  } = useRegisterSW({
    onRegisteredSW(_, r) {
      setRegistration(r)
    },
    onRegisterError(error) {
      // ....
    },
  })

  useEffect(() => {
    let interval: NodeJS.Timeout
    if (registration) {
      interval = setInterval(
        async () => {
          if (registration.installing || !navigator) return
          if ('connection' in navigator && !navigator.onLine) return
          await registration.update()
        },
        intervalMS,
      )
    }

    return () => {
      clearInterval(interval)
    }
  }, [registration])

  useEffect(() => {
    if (needRefresh && canUpdate) {
      void updateServiceWorker(true)
    }
  }, [
    needRefresh,
    canUpdate,
    updateServiceWorker
  ])
  
  .....
 }

🗒️ skipWaiting について

VitePWA では Service Worker のファイルも自動生成されます。その自動生成された処理の中で、self.skipWaiting() が呼び出されているようです。

具体的には、workbox-window のドキュメントに記載されている {type: 'SKIP_WAITING'} メッセージが、処理の過程で Service Worker に対して送信されています。

🗒️ clients.claim について

clients.claim は呼び出していません。VitePWA のプラグインの設定において、以下のようにすることで、clients.claim を呼び出すように設定できますが、現状はデフォルト(false)にしています。

VitePWA({
    workbox: {
        clientsClaim: true,
    },
})

「アプリにアクセスしてすぐにオフライン利用する」というユースケースも想定されるため、一見 clients.claim を利用しないとまずいように思えます。しかし現状では以下の理由により cliants.claim はなくとも問題がないと考えています(現に、現時点で clients.claim がないことが原因と考えられる問題は検知されていません)。

  • アプリを利用するためには、ログインする必要がある。
  • ログイン前の初期ページで Service Worker はインストールされる。
  • ログインを行うと、ページのリダイレクトなどが発生し、一旦別オリジンにアクセスした後、再度ページがロードされる。このタイミングで、インストールされた Service Worker にページがコントロールされるようになる。

🗒️ アプリケーションの静的アセット以外のデータ

上記の通り、アプリケーションの静的アセットは、Service Worker の CacheStorage に保存しています。一方、ユーザーデータ(カミナシレポート上でユーザーが作成するデータ)は、Indexed DB に保存しています。本記事の主題とは外れるので、詳細は割愛します。興味のある方は以下のスライドなどもご確認ください。

怖くないオフライン機能開発 〜基本的な技術で実現する現場向けオフライン機能

おわりに

改めて記事を書いてみて、Service Worker 難しい…と感じました。ですが Service Worker を正しく扱うことが、プロダクトの価値に直結するため、非常にやりがいも感じます。

最後に宣伝です📣

カミナシでは絶賛採用中です!一緒に最高のサービスを作っていく仲間を募集しています!

参考サイト

Appendix

以下は、本記事で利用したサンプルコードです。Docker が使用可能な環境で動作させることができます。「logo.png」は適当なものをご用意ください。

(コマンド)

# Docker イメージをビルド
docker build -t nginx-static-server .

# nginx を起動
docker run --rm -p 61616:80 \
  -v $(pwd)/public:/usr/share/nginx/html:ro \
  nginx-static-server

(ディレクトリ構造)

├── Dockerfile
├── nginx.conf
└── public
    ├── index.html
    ├── logo.png
    └── sw.js

(sw.js)

const currentVersionCacheKey = "v1";
const allStaticAssets = new Set(["/logo.png"]);

self.addEventListener("install", (event) => {
  // self.skipWaiting();
  event.waitUntil(
    caches.open(currentVersionCacheKey).then((cache) => {
      return cache.addAll(allStaticAssets);
    })
  );
});

self.addEventListener("activate", (event) => {
  // event.waitUntil(clients.claim());
  event.waitUntil(
    caches.keys().then((keys) => {
      return Promise.all(
        keys.map((key) => {
          if (key !== currentVersionCacheKey) {
            return caches.delete(key);
          }
        })
      );
    })
  );
});

self.addEventListener("fetch", (event) => {
  console.log(
    `from cache version ${currentVersionCacheKey}`,
    event.request.url
  );
  const url = new URL(event.request.url);
  if (url.origin === location.origin && allStaticAssets.has(url.pathname)) {
    event.respondWith(caches.match(url.pathname));
  }
});

(index.html)

<html>
  <body>
    <div id="div">
      <button id="insertImage">Insert image</button>
    </div>
    <script>
      window.addEventListener("load", () => {
        if ("serviceWorker" in navigator) {
          navigator.serviceWorker.register("/sw.js").then((registration) => {
            /* 
             * skipWaiting パートを確認する時は、コメントアウトを外すと確認がしやすいです。
             * clients.claim パートを確認するときは、コメントアウトを外さないでください。
             console.warn("!!reload!!");
             if (!navigator.serviceWorker.controller) {
              window.location.reload();
              return
            }
            */
            setInterval(() => {
              // 3 秒間隔で sw.js ファイルの更新をチェック
              registration.update();
            }, 3000);
          });
        }
        document.querySelector("#insertImage").addEventListener("click", () => {
          // Service Worker が active な場合、logo.png は、CacheStorage から取得される。
          document
            .querySelector("#div")
            .insertAdjacentHTML(
              "beforeend",
              `<img src="logo.png" width="30"/>`
            );
        });
      });
    </script>
  </body>
</html>

(Dockerfile)

FROM nginx:alpine

COPY nginx.conf /etc/nginx/conf.d/default.conf

(nginx.conf)

server {
    listen 80;
    server_name localhost;

    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }

    location ~* \.(html|js|png)$ {
        add_header Cache-Control "no-store";
    }
}