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

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

Service Worker のライフサイクルとスコープを理解する

Service Worker は独自のライフサイクルを持っている。ブラウザにインストールされ、有効化され、そして新しい Service Worker に置き換えられる。
Service Worker を正しく使うためには、このライフサイクルに対する理解が不可欠である。これを理解していないと、意図した通りに動かせず、古い Service worker が動作し続けてしまうなどの不具合を起こしてしまう恐れがある。
そのためこの記事では、Service Worker はどのようなライフサイクルを辿るのかを見ていく。
また、Service Worker の挙動には「スコープ」という概念も影響してくるため、スコープについても説明する。

プッシュ通知やオフライン対応などの、Service Worker を使うとどんなことが出来るようになるのか、といったことについては扱わない。それらの機能の基盤である Service Worker そのものをどうやって使えばよいのかについて、書いていく。

動作確認には 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を読み込むようになる。

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

登録

Service Worker を利用するためにはまず、Service Worker が書かれたファイルを読み込む必要がある。そこからライフサイクルが始まる。

まず、以下の Service Worker ファイルを用意する。

// public/sw1.js
console.log('This is sw1.js');

何もせず、ただログを流すだけのスクリプト。

そしてこれを、以下のHomeコンポーネントで読み込むようにする。
読み込みにはnavigator.serviceWorker.registerメソッドを使う。
このメソッドは promise を返し、登録に成功するとその promise を解決する。

// pages/index.js
import {useEffect} from 'react';

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

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

next devを実行して開発サーバを起動する。

この状態でhttp://localhost:3000/にアクセスすると、This is sw.jsregisteredがログに流れる。

これで、Service Worker の登録は完了した。

ページを何度リロードしても、registeredはその度に表示されるのに対し、This is sw.jsは最初の一度しか表示されない。
ブラウザは/sw1.jsの内容をチェックしており、その内容に変更がない場合は「更新がない」と見做して、/sw1.jsの内容を再度実行することはない。
試しに/sw1.jsの内容を更新してからページをリロードすると、/sw1.jsの内容が実行されるはずである。
この更新確認に関する挙動については、後述する。

登録しようとしたファイルが存在しない場合、登録に失敗する。
その場合、registerが返した promise はリジェクトされる。

import {useEffect} from 'react';

export default function Home() {
  useEffect(() => {
    navigator.serviceWorker
      .register('/foo.js') // 存在しないファイルを登録しようとしている
      .then(() => {
        console.log('registered'); // これは実行されない
      })
      .catch(() => {
        console.log('register failed'); // これが実行される
      });
  }, []);

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

Service Worker が例外を投げたときも同様に、登録に失敗する。

// public/sw1.js
throw new Error();

登録に成功すると、registerが返した promise はServiceWorkerRegistrationで解決される。

import {useEffect} from 'react';

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

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

このServiceWorkerRegistrationは、Service Worker 側のスクリプトでもself.registrationで取得できる。

// public/sw1.js
console.log(self.registration.constructor.name); // ServiceWorkerRegistration

ServiceWorkerRegistrationを使うことで登録された Service Worker に関する情報を取得できる。

import {useEffect} from 'react';

export default function Home() {
  useEffect(() => {
    navigator.serviceWorker.register('/sw1.js').then((reg) => {
      console.log(reg.scope); // http://localhost:3000/
      console.log(reg.updateViaCache); // imports
    });
  }, []);

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

ServiceWorkerRegistrationについては後述する。
まずは、ライフサイクルの続きを見ていく。

インストール

登録に成功するとinstallイベントが発生する。

Service Worker 側でinstallイベントにハンドラを設定しておくと、インストール時にそのハンドラが実行される。

self.addEventListener('install', () => {
  console.log('install');
});

ハンドラに渡されるイベントオブジェクトにはwaitUntilメソッドがある。
このメソッドを使うことで、任意の処理が終わるまでinstallイベントの完了を待つことができる。

例えばinstallイベントハンドラでは、ファイルのキャッシュなどを行うことが多い。
ファイルのキャッシュは非同期で行われるが、それが終わる前にinstallイベントが終わってしまうと困る。
waitUntilを使えば、ファイルのキャッシュが終わるまでinstallイベントは終了しない。

具体的には、waitUntilに promise を渡して使う。
そうすると、渡された promise が解決された時点でinstallイベントが終了するようになる。
また、渡された promise がリジェクトされるとインストール処理が失敗したと見做されるため、インストール処理の成否を伝える手段としても使える。

以下のコードだと、someTask()が例外を投げると promise がリジェクトされるため、インストールが失敗したと見做される。

self.addEventListener('install', (e) => {
  e.waitUntil(
    new Promise((resolve, reject) => {
      try {
        someTask();
        resolve();
      } catch (err) {
        reject(err);
      }
    })
  );
});

アクティベート

installイベントが完了すると、activateイベントが発生する。

// install
// activate
// の順でログに流れる

self.addEventListener('install', () => {
  console.log('install');
});

self.addEventListener('activate', () => {
  console.log('activate');
});

先程インストールを失敗させる方法を書いたが、その場合、activateイベントは発生しない。

// install
// のみがログに流れる

self.addEventListener('install', (e) => {
  console.log('install');
  e.waitUntil(Promise.reject());
});

self.addEventListener('activate', () => {
  console.log('activate');
});

installイベントが完了しない限りactivateイベントは発生しない。
そのため以下のコードだと、Service Worker を登録してから約3秒後にactivateがログに流れる。

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

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

self.addEventListener('activate', () => {
  console.log('activate');
});

activateイベントでも、installイベント同様にwaitUntilを使って処理を行う。
使い方は同じで、waitUntilに渡された promise が解決されるまでactivateイベントは終了しない。

だがactivateイベントの場合、promise がリジェクトされても「アクティベートの失敗」とはならず、アクティベートは完了となってしまう。
そしてそのままライフサイクルは続き、コントローラー(後述)になれてしまう。

// 問題なくアクティベートが完了する
self.addEventListener('activate', (e) => {
  e.waitUntil(Promise.reject());
});

navigator.serviceWorker.readyを使うことで、activateイベントが発生したかを知ることができる。
このプロパティの値は promise で、activateイベントが発生しない限りpendingであり続ける。
そのため以下のコードだと、何度checkボタンを押下しても promise はpendingのままである。

export default function Home() {
  const onClick = () => {
    console.log(navigator.serviceWorker.ready); // Promise {<pending>}
  };

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

この promise は、activateイベントが発生する直前にServiceWorkerRegistrationで解決される。
activateイベントが完了したら、ではないことに注意する。

そのため以下のコードでは、activateがログに流れる前にcheckボタンを押下してもnavigator.serviceWorker.readyは解決されており、ログにServiceWorkerRegistrationが流れる。

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

self.addEventListener('activate', (e) => {
  e.waitUntil(
    new Promise((resolve) => {
      sleep(5000);
      console.log('activate');
      resolve();
    })
  );
});
import {useEffect} from 'react';

export default function Home() {
  useEffect(() => {
    navigator.serviceWorker.register('/sw1.js');
  }, []);

  const onClick = () => {
    navigator.serviceWorker.ready.then((reg) => {
      console.log(reg.constructor.name); // ServiceWorkerRegistration
    });
  };

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

開いているページの URL が登録された Service Worker のスコープ外であった場合、その Service Worker でactivateイベントが発生しても、navigator.serviceWorker.readyは解決されない。

import {useEffect} from 'react';

export default function Home() {
  useEffect(() => {
    // http://localhost:3000/ を開いているが Service Worker のスコープは http://localhost:3000/foo
    navigator.serviceWorker.register('/sw1.js', {scope: '/foo'});
  }, []);

  const onClick = () => {
    console.log(navigator.serviceWorker.ready); // 常に Promise {<pending>}
  };

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

スコープについては後述する。

コントローラーになる

Service Worker には「コントロール」や「コントローラー」という概念がある。
Service Worker がページを制御している状態を、そのページをコントロールしている、と表現する。
そして制御している Service Worker のことを、コントローラーと呼ぶ。
コントローラーとなった Service Worker は例えば、コントロールしているページに対するリクエストを制御し、予めキャッシュしておいたリソースをレスポンスとして返す、といったことが可能になる。

ここまで、Service Worker の登録、インストール、アクティベートを見てきたが、これだけではまだ、Service Worker はコントローラーにはなっていない。

今開いているページをコントロールしている Service Worker が何であるかは、navigator.serviceWorker.controllerで確認できる。

以下のコードでは、checkボタンを押下すると、コントローラーが存在するかを調べる。
そうすると、何度押下してもnavigator.serviceWorker.controllernullのままである。
ログが流れるということはnavigator.serviceWorker.readyが解決しているということであり、既にactivateイベントは発生している。
にも関わらず、コントローラーにはなっていないのである。

import {useEffect} from 'react';

export default function Home() {
  useEffect(() => {
    navigator.serviceWorker.register('/sw1.js');
  }, []);

  const onClick = () => {
    navigator.serviceWorker.ready.then(() => {
      console.log(navigator.serviceWorker.controller); // null
      console.log(navigator.serviceWorker.controller?.scriptURL); // undefined
    });
  };

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

コントローラーになるためには、ページをリロードする必要がある。

ページをリロードしてから押下すると、ServiceWorkerオブジェクトがログに表示され、scriptURLhttp://localhost:3000/sw1.jsであることが分かる。
これは、http://localhost:3000/sw1.jsがこのページのコントローラーであることを意味している。

ページをコントロールするかどうかは、そのページを開いたときにアクティベートされているかで、判断される。
そのため、このような挙動になるのである。

整理すると以下のようになる。

  1. http://localhost:3000/にアクセスする
  2. この時点では、Service Worker は何も登録されていないため、このページをコントロールする Service Worker は存在しない
  3. http://localhost:3000/sw1.jsが登録され、アクティベートが行われる
  4. ページをコントロールするかどうかの判定はページを開いたときに行われているので、http://localhost:3000/sw1.jsが登録されアクティベートされたところで、このページをコントロールする Service Worker が存在しないことには変わりはない(navigator.serviceWorker.controllernullのまま)
  5. (リロードするなどして)http://localhost:3000/に再訪する
  6. http://localhost:3000/sw1.jsが登録されており、かつアクティベートされているので、この Service Worker がhttp://localhost:3000/をコントロールするようになる(navigator.serviceWorker.controllerServiceWorkerオブジェクトになる)

これがデフォルトの挙動だが、ページを開き直すのを待たず、即座にコントロールを開始したいケースもある。
そのようなときはself.clients.claimを使う。
このメソッドを使うと、スコープ内のページのコントローラーとして自分自身(この記事の例だと/sw1.js)を設定できる。

Service Worker ファイルを以下の内容にすると、アクティベートされた時点で、コントローラーとなる。先程のようにリロードする必要はない。
claimは promise を返すので、そのままwaitUntilに渡すことができる。

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

installイベントのなかでclaimを呼び出そうとするとエラーになる。

self.addEventListener('install', (e) => {
  e.waitUntil(self.clients.claim()); // Only the active worker can claim clients.
});

navigator.serviceWorker.controllerが変化すると、controllerchangeイベントが発生し、navigator.serviceWorker.oncontrollerchangeが呼び出される。
このメソッドの初期値はnullなので、デフォルトだと何も発生しない。

以下のコードでは、controllerchangeイベントが発生するとログにコントローラーが流れるようにしている。
そしてregisterボタンを押下すると Service Worker の登録が行われるため、押下すると登録とclaimの実行が発生しコントローラーが設定され、ServiceWorkerオブジェクトが流れる。

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

export default function Home() {
  useEffect(() => {
    navigator.serviceWorker.addEventListener('controllerchange', () => {
      console.log(navigator.serviceWorker.controller);
    });
  }, []);

  const onClick = () => {
    navigator.serviceWorker.register('/sw1.js');
  };

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

スコープ

Service Worker には必ずスコープが設定されており、そのスコープ内のページのみをコントロールする。
スコープから外れているページはコントロールしない。

スコープは、registerで Service Worker を登録するときに設定される。
registerの第 2 引数で設定可能で、省略した場合は Service Worker ファイルが置かれているディレクトリがスコープになる。

import {useEffect} from 'react';

export default function Home() {
  useEffect(() => {
    navigator.serviceWorker
      .register('/sw1.js', {scope: '/foo'})
      .then((reg) => {
        console.log(reg.scope); // http://localhost:3000/foo
      });
  }, []);

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

スコープを/fooに設定するとhttp://localhost:3000/はスコープ外になり、登録した Service Worker によってコントロールされることはない。

以下のコードではcheckボタンを押下するとnullがログに流れる。
もちろんページを何度リロードしても、挙動は変わらない。

// public/sw1.js
self.addEventListener('activate', (e) => {
  e.waitUntil(self.clients.claim());
});
// pages/index.js
import {useEffect} from 'react';

export default function Home() {
  useEffect(() => {
    navigator.serviceWorker.register('/sw1.js', {scope: '/foo'});
  }, []);

  const onClick = () => {
    console.log(navigator.serviceWorker.controller); // null
  };

  return (
    <>
      <p>This page is top.</p>
      <p>
        <button type="button" onClick={onClick}>
          check
        </button>
      </p>
    </>
  );
}

この状態で以下の内容のhttp://localhost:3000/fooにアクセスしボタンを押下すると、コントローラーが存在していることを確認できる。
Service Worker のスコープは/fooであり、このページはそのスコープ内だからである。

// pages/foo/index.js
export default function Foo() {
  const onClick = () => {
    console.log(navigator.serviceWorker.controller); // ServiceWorker
  };

  return (
    <>
      <p>This page is foo.</p>
      <p>
        <button type="button" onClick={onClick}>
          check
        </button>
      </p>
    </>
  );
}

スコープは、「コントロールするページ」の範囲を決めるものに過ぎない。
そのため、コントロールしているページからのリクエストについては、スコープの制御を受けない。

例えば、/fooからfetch/api/xにリクエストを飛ばした場合、そのリクエストも Service Worker でコントロールできる。
/api/xはスコープから外れているが、関係ない。

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

// 本筋から外れるので詳細な説明は省くが、/api/ で始まるパスへのリクエストを制御し、abc を返すようにしている
self.addEventListener('fetch', (e) => {
  const url = new URL(e.request.url);
  if (/^\/api\//.test(url.pathname)) {
    e.respondWith(new Response('abc'));
  }
});
// pages/foo/index.js
import {useEffect} from 'react';

export default function Foo() {
  useEffect(() => {
    navigator.serviceWorker.register('/sw1.js', {scope: '/foo'});
  }, []);

  const onClick = async () => {
    const res = await fetch('/api/x');
    const text = await res.text();
    console.log(text); // abc
  };

  return (
    <>
      <p>This page is foo.</p>
      <p>
        <button type="button" onClick={onClick}>
          fetch
        </button>
      </p>
    </>
  );
}

スコープは、登録する Service Worker ファイルが置かれているディレクトリか、それより下の階層しか、設計できない。
例えば以下の例だと、Service Worker ファイルが置かれているのは/foo/ディレクトリなのに、それより上位の/をスコープとして設定しようとしている。
そのためエラーになり、登録に失敗している。

useEffect(() => {
  navigator.serviceWorker.register('/foo/sw2.js', {scope: '/'}).catch((e) => {
    // Failed to register a ServiceWorker for scope ('http://localhost:3000/') with script ('http://localhost:3000/foo/sw2.js')
    console.log(e.message);
  });
}, []);

下位の階層なら、問題ない。

useEffect(() => {
  navigator.serviceWorker
    .register('/foo/sw2.js', {scope: '/foo/bar/buz'})
    .then((reg) => {
      console.log(reg.scope); // http://localhost:3000/foo/bar/buz
    });
}, []);

この制限を解除したい場合、HTTP ヘッダのService-Worker-Allowedフィールドを使う。
Service Worker ファイルを返す HTTP レスポンスのヘッダにService-Worker-Allowed: /をつけた場合、/以下のスコープを自由に設定できるようになる。

Next.js ではnext.config.jsを編集すると独自フィールドを付与できるので、以下の内容にして確認してみる。

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/foo/sw2.js',
        headers: [
          {
            key: 'Service-Worker-Allowed',
            value: '/',
          },
        ],
      },
    ];
  },
};

Service Worker ファイルは/foo/に置かれているが、それよりも上位のパスである/をスコープに設定できるようになった。

useEffect(() => {
  navigator.serviceWorker
    .register('/foo/sw2.js', {scope: '/'})
    .then((reg) => {
      console.log(reg.scope); // http://localhost:3000/
    });
}, []);

更新確認

ここまで、Service Worker が登録されてからコントローラーになるところまでを見てきた。
コントローラーになったということはページを制御しているということであり、Service Worker を基盤とした様々な機能を利用できるようになったということである。

だが Service Worker のライフサイクルはこれで終わりではない。
インストールされた Service Worker はページが閉じられた後もブラウザに残り続けるが、これをそのままにしておくと、古い Service Worker がいつまでも残り続けてしまう。
そのためブラウザは定期的に、Service Worker ファイルに更新がないかをサーバに対してチェックしている。そしてもし更新があった場合、更新後の Service Worker ファイルをインストールし、以前インストールした Service Worker は所定のタイミングで破棄される。

これから更新確認について説明していくが、いつ更新確認を行うのかはブラウザによって異なる可能性がある。
冒頭にも書いたように、この記事では Google Chrome 90.0.4430.212 で動作確認を行っている。

復習になるが、以下のページを表示するとinstallがログに流れるが、それ以降は何回リロードしてもinstallは流れない。

self.addEventListener('install', () => {
  console.log('install');
});
import {useEffect} from 'react';

export default function Home() {
  useEffect(() => {
    navigator.serviceWorker.register('/sw1.js');
  }, []);

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

リロードされる度にブラウザは更新確認を行っており、/sw1.jsの内容に変化がないため、更新がないと判断された。
更新があった場合は、改めて/sw1.jsが読み込まれ、installイベントが発生する。

http://localhost:3000/を開きっぱなしにした状態で/sw1.jsを以下の内容に更新し、ページをリロードしてみる。

console.log('v2');

self.addEventListener('install', () => {
  console.log('installed v2');
});

そうすると、v2installed v2がログに流れる。
更新確認の結果、/sw1.jsが更新されていると判定されたためである。

Service Worker ファイルそのものだけではなく、importScriptsで読み込んだファイルも、更新確認の対象になる。
importScriptsについてはこの記事では詳述しないが、このメソッドを使って他のファイルを読み込める。

// sw1.js
importScripts('/someFile.js');

self.addEventListener('install', () => {
  console.log('install');
});

このとき、/someFile.jsが更新されれば、/sw1.jsは更新がなかったとしても、/sw1.js自体が改めて読み込まれて実行される。

更新確認の際、ブラウザは常にサーバに確認しに行くわけではない。
最初からサーバに確認しに行くこともあれば、ブラウザにキャッシュされているファイルを使って確認することもある。
後者の場合、キャッシュが存在しなかったときにのみ、サーバに対して確認する。

この挙動は、登録時に設定したupdateViaCacheで決まる。

navigator.serviceWorker.register('/sw1.js', {updateViaCache: 'all'});

値に応じて、以下の挙動になる。

register されたファイル インポートされたファイル
all キャッシュをまず確認する キャッシュをまず確認する
imports 最初からサーバに確認しに行く キャッシュをまず確認する
none 最初からサーバに確認しに行く 最初からサーバに確認しに行く

設定を省略した場合はimportsになる。
そのため、インポートされたファイルについてのみ、まずはキャッシュされたファイルを確認する。

再びnext.config.jsを編集して、確認してみる。

module.exports = {
  async headers() {
    return [
      {
        source: '/(.*).js',
        headers: [
          {
            key: 'Cache-Control',
            value: 'max-age=10',
          },
        ],
      },
    ];
  },
};

これで、/sw1.js/someFile.js10秒間キャッシュされるようになった。
そのため、ページ表示から10秒以内にimportScriptsで読み込んだファイル(今回の例でいえば/someFile.js)を更新してページをリロードしても、Service Worker の更新処理は行われない。

但し 24 時間以上経過した場合は、updateViaCacheとは関係なく必ずサーバに確認しに行く。
そうすることで、設定ミスなどによって古い Service Worker が残り続けてしまうのを防いでいる。

アクティベートのタイミングについて

ファイルが更新されると更新後のファイルが読み込まれinstallイベントが発生することを確認したが、activateはすぐには発生しない。

確認用に作った以下の Service Worker ファイルではclaimを使っており、ページ表示後すぐにアクティブになる。
そのため、fetchボタンを押下すると、v1がログに流れる。

const VERSION = 'v1';

self.addEventListener('install', () => {
  console.log(`install ${VERSION}`);
});

self.addEventListener('activate', (e) => {
  console.log(`activate ${VERSION}`);
  e.waitUntil(self.clients.claim());
});

self.addEventListener('fetch', (e) => {
  const url = new URL(e.request.url);
  if (/^\/api\//.test(url.pathname)) {
    e.respondWith(new Response(VERSION));
  }
});
import {useEffect} from 'react';

export default function Home() {
  useEffect(() => {
    navigator.serviceWorker.register('/sw1.js');
  }, []);

  const onClick = async () => {
    const res = await fetch('/api/x');
    const text = await res.text();
    console.log(text);
  };

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

ページを開いたままにして、VERSIONの値をv1からv2を変える。
そしてページをリロードすると、install v2が表示される。
だが、activate v2は表示されない。
つまり、アクティベートされていないのである。事実、fetchボタンを押下するとv1が流れる。相変わらずv1の Service Worker がこのページをコントロールしている。

v2をアクティベートするためには、v1がコントロールしているページを全て閉じる必要がある。
そうすると、新しくhttp://localhost:3000/を開いたときに改めてv2のインストール、そしてアクティベートが行われる。
今回のコードではclaimを使っているので、そのままv2がコントローラーになる。

そしてv1は無効となり、この Service Worker のライフサイクルは終わりを迎える。

更新後の Service Worker をすぐにアクティベートしたい場合は、self.skipWaitingを使う。

self.addEventListener('install', (e) => {
  console.log(`install ${VERSION}`);
  e.waitUntil(self.skipWaiting());
});

このメソッドを使うと、即座にその Service Worker ファイルがアクティベートされるようになる。

skipWaitingによって古い Service Worker から置き換えられた Service Workerは、そのままページのコントローラーになる。

ServiceWorkerRegistration について

ライフサイクルの説明は一通り終わったので、最後にServiceWorkerRegistrationについて説明する。

Service Worker の登録を行うregisterメソッドは、登録に成功するとServiceWorkerRegistrationで解決する promise オブジェクトを返す。
ここまで説明してきたスコープやupdateViaCacheも、ServiceWorkerRegistrationで確認できる。

useEffect(() => {
  navigator.serviceWorker.register('/sw1.js').then((registration) => {
    console.log(registration); // ServiceWorkerRegistration
    console.log(registration.scope); // http://localhost:3000/
    console.log(registration.updateViaCache); // imports
  });
}, []);

ServiceWorkerRegistrationnavigator.serviceWorker.getRegistrationでも取得できる。

import {useEffect} from 'react';

export default function Home() {
  useEffect(() => {
    navigator.serviceWorker.register('/sw1.js');
  }, []);

  const onClick = async () => {
    const registration = await navigator.serviceWorker.getRegistration();
    console.log(registration); // ServiceWorkerRegistration
  };

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

ServiceWorkerRegistrationが持っているupdateメソッドを使うと、明示的に更新確認を行える。

以下のコードでは、updateボタンを押下したときにupdateメソッドを実行している。
そのため、VERSIONの値をv2に変えてからupdateボタンを押下すると、v2の Service Worker がアクティベートされ、そのままページのコントローラーになる。
ページをリロードする必要はない。

const VERSION = 'v1';

self.addEventListener('install', (e) => {
  console.log(`install ${VERSION}`);
  e.waitUntil(self.skipWaiting());
});

self.addEventListener('activate', (e) => {
  console.log(`activate ${VERSION}`);
  e.waitUntil(self.clients.claim());
});

self.addEventListener('fetch', (e) => {
  const url = new URL(e.request.url);
  if (/^\/api\//.test(url.pathname)) {
    e.respondWith(new Response(VERSION));
  }
});
import {useEffect} from 'react';

export default function Home() {
  useEffect(() => {
    navigator.serviceWorker.register('/sw1.js');
  }, []);

  const fetchApi = async () => {
    const res = await fetch('/api/x');
    const text = await res.text();
    console.log(text);
  };

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

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

ServiceWorkerRegistrationにはunregisterというメソッドもある。
このメソッドを使うと、Service Worker の登録を抹消できる。

登録解除後にnavigator.serviceWorker.getRegistration()を実行するとundefinedで解決された promise が返ってくるので、登録が抹消されていることが分かる。
だが登録が抹消されても、Service Worker は引き続きページをコントロールしている。

以下のコードでそれを確認できる。
抹消後もnavigator.serviceWorker.controllerにはServiceWorkerオブジェクトが入っている。
つまり Service Worker によってコントロールされているということであり、事実、fetchApiボタンを押下すると、Service Worker によって制御された結果が返ってくる。

import {useEffect} from 'react';

export default function Home() {
  useEffect(() => {
    navigator.serviceWorker.register('/sw1.js');
  }, []);

  const fetchApi = async () => {
    const res = await fetch('/api/x');
    const text = await res.text();
    console.log(text);
  };

  const unregister = async () => {
    const registration = await navigator.serviceWorker.getRegistration();
    registration?.unregister();
    console.log(await navigator.serviceWorker.getRegistration()); // undefined
    console.log(navigator.serviceWorker.controller); // ServiceWorker
  };

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

この Service Worker がコントロールしているページを全て閉じたときに、controllernullになる。

参考資料