30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Service Worker の状態遷移を理解する

ServiceWorkerオブジェクトはstateというプロパティを持っており、その名の通り Service Worker の状態を示している。
この値は状況に応じて移り変わっていくのだが、どのようなタイミングでどう変化するのかについてメンタルモデルを構築できると、Service Worker の挙動を理解しやすくなる。
この記事では、stateが変化するタイミングや、stateに関連する他のプロパティやメソッドについて見ていく。

Service Worker の基本的な概念や使い方については、以下の記事にまとめてある。

numb86-tech.hatenablog.com

動作確認には Next.js と Google Chrome を使っている。それぞれのバージョンは以下の通り。

  • Next.js 10.2.0
  • Google Chrome 90.0.4430.212

特に言及がない限り、public/に置いたsw1.jsというファイルを、pages/index.jsに書いたコンポーネントで読み込んでいる。
これにより、http://localhost:3000/にアクセスしたときに、http://localhost:3000/sw1.jsを読み込むようになる。

ユーザーが初めてページにアクセスした状況を再現するために、シークレットウィンドウでページを開くようにしている。

state は単方向に変化する

stateには必ず文字列が入るが、その種類は 5 つある。
そして、以下に挙げる順番に変化していく。

  1. installing
  2. installed
  3. activating
  4. activated
  5. redundant

必ずinstallingから始まるし、以前の値に戻ることもない。

ServiceWorker オブジェクトの取得

これからstateがどのように移り変わっていくのかを検証していくが、そのためにはまずServiceWorkerオブジェクトを取得する必要がある。

方法はいくつかあるが、この記事では基本的に以下の方法でServiceWorkerオブジェクトを取得することにする。

import {useEffect} from 'react';

export default function Home() {
  useEffect(() => {
    navigator.serviceWorker.register('/sw1.js').then((reg) => {
      console.log(reg.installing); // ServiceWorker
    });
  }, []);

  return (
    <>
      <p>Hello Service Worker!</p>
    </>
  );
}

registerが返す promise は、Service Worker の登録に成功したときにServiceWorkerRegistrationで解決される。
そしてそのServiceWorkerRegistrationにはinstallingプロパティがあるのだが、このプロパティに、登録された Service Worker のインターフェイスであるServiceWorkerオブジェクトが入っている。

installingの名が示す通り、インストール中の Service Worker を取得するためのプロパティだが、registerからつないだthenメソッドのなかなら確実に、installingプロパティにServiceWorkerが入っている。インストールが早く終わってしまってinstallingnullになってしまった、という状況は発生しない。
以下のコードでそれを確認できる。sleep関数を使って3秒間処理をブロックしているが、その間にinstallingnullになってしまうようなことはなく、ServiceWorkerオブジェクトを取得できる。

import {useEffect} from 'react';

function sleep(ms) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
}

export default function Home() {
  useEffect(() => {
    navigator.serviceWorker.register('/sw1.js').then((reg) => {
      sleep(3000);
      console.log(reg.installing); // ServiceWorker
    });
  }, []);

  return (
    <>
      <p>Hello Service Worker!</p>
    </>
  );
}

早速stateを確認してみると、installingになっていることが分かる。

import {useEffect} from 'react';

export default function Home() {
  useEffect(() => {
    navigator.serviceWorker.register('/sw1.js').then((reg) => {
      const sw = reg.installing;
      console.log(sw.state); // installing
    });
  }, []);

  return (
    <>
      <p>Hello Service Worker!</p>
    </>
  );
}

ServiceWorkerオブジェクトには、stateが変化したときに発生するstatechangeというイベントがあるので、それを使ってstateの遷移を確認する。

以下のようにイベントハンドラを設定すれば、stateが変化する度に新しいstateがログに流れるようになる。

sw.addEventListener('statechange', () => {
  console.log(sw.state);
});

statechangeを使った以下のコードで、ページ表示時に登録された Service Worker がどのような状態遷移を辿るのかを知れる。

import {useEffect} from 'react';

export default function Home() {
  useEffect(() => {
    navigator.serviceWorker.register('/sw1.js').then((reg) => {
      const sw = reg.installing;
      console.log(sw.state);
      sw.addEventListener('statechange', () => {
        console.log(sw.state);
      });
    });
  }, []);

  return (
    <>
      <p>Hello Service Worker!</p>
    </>
  );
}

以下のログが流れる。

installing
installed
activating
activated

installingから始まり、そのままactivatedまで変化することが分かった。
次は、それぞれの変化がどのようなタイミングで発生しているのかを見ていく。

installing -> installed or redundant

installイベントが終了したタイミングで、installingからinstalledに変化する。

そのため、Service Worker のコードを以下のようにすると、installイベントが始まった約5秒後に、installedに変化する。

function sleep(ms) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
}

self.addEventListener('install', (e) => {
  e.waitUntil(
    new Promise((resolve) => {
      sleep(5000);
      resolve();
    })
  );
});

e.waitUntilに渡した promise がリジェクトされるとインストールが失敗したと見做され、stateredundantに変化する。
そのため以下のコードだと、installイベントが始まった約5秒後に、installingからredundantに変化する。

function sleep(ms) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
}

self.addEventListener('install', (e) => {
  e.waitUntil(
    new Promise((resolve, reject) => {
      sleep(5000);
      reject();
    })
  );
});

installed -> activating -> activated

インストールに成功しinstallingになったServiceWorkerオブジェクトはactivatingに変化する。
そしてactivateイベント終了のタイミングで、activatedに変化する。

従って以下のコードの場合は、すぐにactivatingにまで遷移し、その約5秒後にactivatedになる。

function sleep(ms) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
}

self.addEventListener('activate', (e) => {
  e.waitUntil(
    new Promise((resolve) => {
      sleep(5000);
      resolve();
    })
  );
});

installイベントとは異なり、e.waitUntilに渡した promise がリジェクトされてもredundantにはならない。
そのため以下のコードでも、activateイベント発生の約5秒後にactivatedになる。

function sleep(ms) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
}

self.addEventListener('activate', (e) => {
  e.waitUntil(
    new Promise((resolve, reject) => {
      sleep(5000);
      reject();
    })
  );
});

navigator.serviceWorker.readyというプロパティがあり、activateイベントが発生すると解決される promise を持っている。
そのため、このプロパティを使えばactivateイベントの発生を感知できる。

以下の Service Worker は、installに約2秒かかり、activateに約3秒かかる。

function sleep(ms) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
}

self.addEventListener('install', (e) => {
  e.waitUntil(
    new Promise((resolve) => {
      sleep(2000);
      resolve();
    })
  );
});

self.addEventListener('activate', (e) => {
  e.waitUntil(
    new Promise((resolve) => {
      sleep(3000);
      resolve();
    })
  );
});

このとき、Service Worker を登録する側のコードが以下のようになっていた場合、登録から約2秒後にready!が表示され、それから約3秒後にactivatedが表示される。

import {useEffect} from 'react';

export default function Home() {
  useEffect(() => {
    navigator.serviceWorker.ready.then(() => {
      console.log('ready!');
    });
    navigator.serviceWorker.register('/sw1.js').then((reg) => {
      const sw = reg.installing;
      console.log(sw.state);
      sw.addEventListener('statechange', () => {
        console.log(sw.state);
      });
    });
  }, []);

  return (
    <>
      <p>Hello Service Worker!</p>
    </>
  );
}

既にコントローラーが存在する場合

ServiceWorkerRegistrationinstallingServiceWorkerオブジェクトがセットされるのは、Service Worker の登録時だけではない。更新確認を行い更新があった際にも、セットされる。
installingServiceWorkerオブジェクトがセットされるとupdatefoundイベントが発生するので、これを使うと新しくセットされたServiceWorkerも取得できるようになる。

以下のコードのページを開き、Service Worker ファイルを更新してからupdateボタンを押下すると、ServiceWorkerオブジェクトがログに流れる。

import {useEffect} from 'react';

export default function Home() {
  useEffect(() => {
    navigator.serviceWorker.register('/sw1.js').then((reg) => {
      reg.addEventListener('updatefound', () => {
        console.log(reg.installing); // ServiceWorker
      });
    });
  }, []);

  const update = async () => {
    const registration = await navigator.serviceWorker.getRegistration();
    registration.update();
  };

  return (
    <>
      <p>Hello Service Worker!</p>
      <p>
        <button type="button" onClick={update}>
          update
        </button>
      </p>
    </>
  );
}

既にコントローラーが存在する状態で Service Worker を更新した場合、その Service Worker の状態遷移は、これまでの説明とは異なったものになる。

それを確認するために、ページのコンポーネントを以下のように書き換える。

import {useEffect} from 'react';

let counter = 0;

export default function Home() {
  useEffect(() => {
    navigator.serviceWorker.register('/sw1.js').then((reg) => {
      reg.addEventListener('updatefound', () => {
        const sw = reg.installing;
        if (!sw) return;

        counter += 1;
        const version = `v${counter}`;

        console.log(version, sw.state);

        sw.addEventListener('statechange', () => {
          console.log(version, sw.state);
        });
      });
    });
  }, []);

  const update = async () => {
    const registration = await navigator.serviceWorker.getRegistration();
    registration.update();
  };

  return (
    <>
      <p>Hello Service Worker!</p>
      <p>
        <button type="button" onClick={update}>
          update
        </button>
      </p>
    </>
  );
}

そして Service Worker のコードを以下のようにする。

self.addEventListener('activate', (e) => {
  e.waitUntil(self.clients.claim());
});

この状態でページを開くと、以下のログが流れる。

v1 installing
v1 installed
v1 activating
v1 activated

これまでと同様、installingからactivatedにまで遷移している。
そしてself.clients.claimを実行しているので、この Service Worker がそのままこのページのコントローラーになる。

次に Service Worker ファイルのコードを更新する。適当にコメントを付けるなどで構わない。
そしてupdateボタンを押下すると、以下のログが流れる。

v2 installing
v2 installed

これまでと異なり、更新後の Service Worker の状態遷移がinstalledで止まってしまっている。

このように、コントローラーが既に存在する状態でインストールされた Service Worker は、activateイベントが発生しないのである。
コントローラーが存在しなければactivateイベントは発生する。そのため、self.clients.claimを実行していないコードで同じ検証を行うと、v2の Service Worker もすぐにactivatedにまで遷移する。

v1がコントロールしているページを全て閉じてから改めてページを開くと、v2activatedされコントローラーになっている。
もしすぐにv2activatedさせたい場合は、skipWaitingを実行する。

self.addEventListener('install', (e) => {
  e.waitUntil(self.skipWaiting());
});

self.addEventListener('activate', (e) => {
  e.waitUntil(self.clients.claim());
});

この場合、Service Worker 更新後にupdateボタンを押下すると、v2がすぐにactivatedにまで遷移する。
そして、v1redundantに変化している。

ちなみに、既にコントローラーが存在する状態でskipWaitingが行われたときは、更新後の Service Worker がアクティベートされるだけでなく、その Service Worker がそのままコントローラーになる。

ServiceWorkerRegistration の各種プロパティ

これまで何度か見てきたように、Service Worker のインストールが開始されるとServiceWorkerRegistrationinstallingプロパティにServiceWorkerオブジェクトがセットされる。
ServiceWorkerRegistrationには他にもwaitingactiveというプロパティを持ち、それぞれ以下のstateServiceWorkerオブジェクトがセットされる。

  • installingプロパティ
    • stateinstalling
  • waitingプロパティ
    • stateinstalled
  • activeプロパティ
    • stateactivatingもしくはactivated
import {useEffect} from 'react';

export default function Home() {
  useEffect(() => {
    navigator.serviceWorker.register('/sw1.js').then((reg) => {
      const sw = reg.installing;
      console.assert(sw.state === 'installing');
      sw.addEventListener('statechange', () => {
        if (reg.waiting) {
          console.assert(reg.waiting.state === 'installed');
        } else if (reg.active) {
          console.assert(
            reg.active.state === 'activating' ||
              reg.active.state === 'activated'
          );
        }
      });
    });
  }, []);

  return (
    <>
      <p>Hello Service Worker!</p>
    </>
  );
}

参考資料