高見知英の技術ログ

技術関係のログをQiitaから移行してきました。プログラミングのほか、使っているアプリの細かい仕様についてなど書いていきます。

既存WebアプリにServiceWorkerを追加してPWAっぽくする(たぶん続く)

とりあえず途中まで進んだので記録。

表題の通り既存のWebアプリにServiceWorkerを追加して、PWAっぽく使えるようにします。操作対象は最近いろいろいじっている、CastBackgroundです。今回はキャッシュ処理とmanifest.jsonの設定だけします。

github.com

実際どうやってるのかを見たい場合は、上記ソースコードを直接確認してください。manifest.jsonsw.jsを見れば大抵わかると思います*1

実際の手順

既存サイトにServiceWorkerを追加する場合、やるべきことは以下の通り

  1. (manifest.jsonを追加する)
  2. ServiceWorker用のJavaScriptファイルを「ルートフォルダに」作成する
  3. キャッシュ対象となるファイルのリストを作成する
  4. ServiceWorkerのライフサイクルに対応するイベントハンドラを記述する(最低限必要なのは、install, fetch。たぶんactivate)

とりあえず順を追って確認

(manifest.jsonを追加する)

ServiceWorkerとは直接関係ないですが、まずはmanifest.jsonをつくっておくと、アプリがPWAとして認識されるようになるので、作っておくと便利です。

この辺は参考文献のMDNを参考に。とりあえず記述の通りの内容を書いておけば問題はなさそうです。Edgeで開くと「Web app manifest should have the filename extension 'webmanifest'.」と言われていますがその辺はよくわからないのでとりあえずスキップ

アイコンファイルなどは事前に作成しておくと良いでしょう。

ServiceWorker用のJavaScriptファイルを「ルートフォルダに」作成する

まずは、ServiceWorkerを作成します。インストール処理については大まかにいろんなサイトに書いてある限りで問題なし。

  <script type="module">
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.register('/sw.js', {scope: "/"})
        .then((reg) => {
          console.log('Service worker registered.', reg);
        });
    }
  </script>

type="module"はとりあえず流れで書いてしまっていますが要らないかも(外したときの挙動を確認していないので記載しています)。

第一引数にはServiceWorkerとして動作するためのJavaScriptファイルを指定します。

ServiceWorkerは、サイトの状態などを確認するためにブラウザにインストールされるUIを持たないJavaScriptファイルです。このファイルでオフライン時のキャッシュの制御などの操作を行います。

第二引数のscopeについては、第一引数のJavaScript配下のディレクトリパスを指定します。未指定時はJavaScriptファイルのあるディレクトとなります。

インストールが完了すると、以降scope配下のファイルリクエストがあったときに、このスクリプトが呼び出されるようになります。このため、よほどのことがなければアプリケーションのルートディレクトリ、すなわち/を指定することになります。

なお、ここでsw.jsがあるディレクトリより上位のディレクトリを指定すると例外が発生します*2

これは「Service Worker、はじめの一歩 | 第1回 Service Workerとは | CodeGrid」によると、「Service-Worker-Allowedヘッダ」というヘッダがないと変更できない動作のため、sw.jsは素直にルートディレクトリに置くのが無難です。

キャッシュ対象となるファイルのリストを作成する

次に、キャッシュしたいファイルの一覧を作成します。キャッシュ対象は自分の作成したファイルだけでなく、CDNなどを参照しているJQueryやBootstrapなどのファイルも対象です。

この辺については面倒なので、わたしは次のようなファイルを列挙してJavaScriptファイルを生成するPythonスクリプトを書きました。ファイルの変更があったときはこれを使用する ということで。

from pathlib import Path
import json

def files(rootdir: Path, path: Path) -> list[str]:
  l = []
  for f in path.glob("*"):
    if str(f.name).startswith("."): continue
    if f.is_file() and f.suffix == "": continue
    if f.is_file() and f.suffix in [".pptx", ".py", ".json", ".md"]: continue
    if f.is_dir() and f.name in ["tools"]: continue
    if f.is_file() or not f.name in ["res", "src"]:
      fs = "/{}{}".format(str(f.relative_to(rootdir)).replace('\\', '/'), '/' if f.is_dir() else '')
      print(fs)
      l.append(fs)
    if f.is_dir():
      l.extend(files(rootdir, f))
  return l

root = Path(__file__).parent.parent
data = json.dumps(files(root, root), indent=2)

with (root / "src" / "files.js").open("w") as f:
  f.write(f"""
const CB_ALL_FILES = {data}
""")

print("Done.")

そして、sw.jsの冒頭に以下のように書きます。

importScripts("./src/files.js")
const CB_ALL_FILES_URLS = CB_ALL_FILES.concat(
  "https://cdn.honokak.osaka/honoka/4.3.1/css/bootstrap.min.css",
  "https://code.jquery.com/jquery-3.5.1.slim.min.js",
  "https://cdn.honokak.osaka/honoka/4.3.1/js/bootstrap.bundle.min.js"
);

これでファイルリストの作成は完了です。*3

なお、詳細は不明ですが、このような記述と、JavaScript読み込み時のintegritycrossoriginの指定は競合を起こす場合があります*4

ServiceWorkerのライフサイクルに対応するイベントハンドラを記述する

sw.jsに各種イベントハンドラを書いていきます。ServiceWorkerのライフサイクルはGoogleの記事などを見ればおおよそわかります

とりあえず最低限キャッシュを動作させるには、installfetchだけ書けば良いっぽい(ただ適宜キャッシュを捨てるなどの処理におそらくactivateも必要)。

キャッシュが必要なファイルの一覧作成

installでキャッシュ必要なURLのリスト(https://からはじまるアドレスかサーバルートからの絶対パス)を指定します。

self.addEventListener('fetch', (event) => {
  console.log('service worker fetch ... ' + event.request);
  event.respondWith(
    caches.match(event.request).then((cacheResponse) => {
      return cacheResponse || fetch(event.request).then((response) => {
        return caches.open('v1').then((cache) => {
          cache.put(event.request, response.clone());
          return response;
        });
      });
    })
  );
});

上記のopen()メソッドの引数に使っている「v1」はキャッシュの呼称なので、本来であれば定数として持っておいた方が良さそうです。定数が変わればキャッシュを参照しなくなります。

ただしこの時点ではキャッシュが必要なURLのリストは指定できてもキャッシュは行なわれていない状態です。実際のキャッシュはfetchで行ないます。

fetchする

次に実際にファイルをダウンロードし、必要ならキャッシュに追加します。

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('v1').then((cache) => {
      return cache.addAll(
        CB_ALL_FILES
      );
    })
  );
});

これでキャッシュは完了です。

動作確認する

動作確認には、Edgeの開発者モードがとても便利です。Applicationタブでほとんどの情報を参照することができます。

f:id:TakamiChie:20210922174914p:plain
開発者モード

ただしくServiceWorkerがインストールされていれば、「ServiceWorkers」項目にsw.jsの名前が、「キャッシュストレージ」の項目にキャッシュされているファイルの一覧が表示されています(キャッシュストレージの項目に入っているはずのファイルがなかった場合は、files.jsの中身を確認します)。

オフライン時の挙動を確認する

オフライン時の挙動を確認するときは、画面上部のオフラインチェックボックスをチェックします。するとその間はインターネットに接続していない扱いになります。

f:id:TakamiChie:20210922175623p:plain
オフライン チェックボックス

ServiceWorkerを削除してやりなおす

キャッシュが古くて更新したいときなど、ServiceWorkerを削除するときは、「登録解除」ラベルをクリックします(実運用の時は、ちゃんとアンインストール処理を設けましょう)。

f:id:TakamiChie:20210922180020p:plain
登録解除ラベル

ただしそのまま再読込するとページがうまく読めないときがあるので、一度ブラウザタブを閉じてから開き直すと良いです。

(この時点での)まとめ

とりあえず落とし穴がところどころにあるのが注意が必要ですが、PWAのキャッシュストレージ機能を実装しました。実際使うにはキャッシュを適宜更新したり、期限切れキャッシュを削除したりする必要がありますが、ひとまず一つ目の峠は越えたようです。

参考文献

*1:執筆時点ではfeature-pwa_support_n32というブランチで作業中です

*2:Failed to register a ServiceWorker for scope ('指定しようとしたURL') with script ('sw.jsのファイルURL'): The path of the provided scope ('指定したディレクトリ') is not under the max scope allowed ('sw.jsのあるディレクトリ'). Adjust the scope, move the Service Worker script, or use the Service-Worker-Allowed HTTP header to allow the scope.

*3:なお、ディレクトリも必要であれば指定が必要です。たとえば多くのサーバーでは「/src/」がリクエストされた場合「/src/index.html」が返されますが、ServiceWorker側では相手のサーバが挙動をするサーバかどうかが判断できないからです。なお、今回は「/src/」に直接アクセスする可能性がないので記述していません

*4:実際に指定したファイルを読み込もうとすると「The FetchEvent for "https://code.jquery.com/jquery-3.5.1.slim.min.js" resulted in a network error response: an "opaque" response was used for a request whose type is not no-cors」というエラーが出ました。Githubで検索をしてみるとそれでも読めているソースコードが散見されるのでなにか手順が抜けている気もします