既存のWebアプリをオフライン対応のPWAに改修するお仕事を承り、数ヶ月に及んだ開発が先日完了しました。バックエンドの開発は順調に進んだのですが、フロントエンドの開発でいくつもの難関があり、想定よりも大幅に時間が掛かってしまいました。同様の開発を行う方々のヒントとなるように、この記事を書くことにします。
今回の開発で利用した技術は、主に以下のとおりです。
- Service Worker
- Cache API
- localStorage
- IndexedDB (Dexie.js)
私がフロントエンドの開発で難しかった点を、以下に挙げていきます。
JavaScriptの非同期処理が難しい
今までのWebアプリ制作では、私はあまり非同期処理を書く機会がありませんでした。しかし最近のJavaScriptでは非同期処理の習得が必須のようです。たとえばService Workerの登録は非同期で行われますし、Fetch APIでデータを取得するときも、IndexedDBにデータを格納するときも非同期で行われます。
ちょうどよいタイミングで、技術評論社のSoftware Design 2023年9月号がJavaScript非同期処理を特集していました。async/awaitの書き方や、Promiseという概念が丁寧に説明されていて助かりました。最近はサーバーサイドでも、たとえばPHP8.1で非同期処理が導入されており、まだまだ勉強することは限りなくあるのですが、取っ掛かりとしてJSの非同期処理を学べたのは良い機会でした。
WebページとService Workerの通信が難しい
Service Workerは、Webページからは独立した環境で動作するJavaScriptです。DOMやlocalStorage等にはアクセスできないため、Webページ側のJavaScriptと連携するには一工夫が必要です。
一般的に利用されるのは ServiceWorker.postMessage() です。文字列やオブジェクトなど様々なデータを送受信できます。ただし利用するにはService Workerのステータスがactiveになっている必要があります。そして私の環境ではactiveにもかかわらず送信に失敗することがありました(書き方が何か誤っていたのだとは思います)。
代わりに利用したのは BroadcastChannel です。これはService Worker用の技術という訳ではないのですが、同じオリジン間であれば、異なるタブ・ウィンドウ・iframeなどで通信できる技術です。もちろんWebページとService Workerの通信にも利用できます。今回はBroadcastChannelの採用で対応できました。
古いjQueryライブラリがFetch APIに未対応
オフライン用のデータをCache APIに格納し、オフライン時にはHTTPリクエスト (fetchイベント) に割り込んでキャッシュを返す処理を書きました。通常のWebページや画像であれば、とても簡単にオフライン対応を実装することができます。
しかし既存のページの一部がAjaxで動いていた場合には少し複雑になります。XMLHttpRequest (XHR) とFetch APIは似ていますが別物であり、オフライン時にXHRのリクエストを投げてもfetchイベントでは捕捉されず、リクエストに失敗します。今回はXHRのかわりにfetchで取得する処理を書くことで解決できました。
jQuery.getJSON() を使っているライブラリが、キャッシュ対策でランダムな文字列をURLに追加してしまう問題もありました。Cache APIのキャッシュはURLがキーとなっており、1文字でもURLが異なっていればキャッシュにヒットしません。もう更新されていない古いjQueryライブラリなので、やむをえずライブラリのコードを直に書き換えて解決しました。
Web制作でjQueryはまだ広く使われており、今後もすぐには手放せないのですが、徐々に依存を減らしたほうがよいなと実感しました。
iOS Safariが音声データ取得時にHTTP Rangeリクエストを送る
audio要素により音声データを埋め込んでいるページで、オフライン時には再生できない不具合がありました。iOS Safariだけが失敗するため原因が分からず、デベロッパーツールを見ると、不思議なHTTPリクエストが発生していました。HTTPヘッダになぜか Range: bytes=0-1
と書かれています。
iOS Safariは音声や動画データを取得する際、HTTP Rangeリクエストを送り、まずは先頭の2バイトだけを取得する仕様になっているそうです。大きなファイルをRangeリクエストによって分割取得するためでしょうか。私が書いたService WorkerはRangeリクエストに未対応だったため、Rangeリクエストに対応するコードを書くことで解決しました。
Service Workerでは時間の掛かる処理ができない
最大の難関がこの問題でした。今回のケースでいうとキャッシュの取得処理です。数千件のオフラインデータを取得する際、何のエラーも出さずに途中でダウンロードが止まってしまう問題に悩まされました。端末によって多少の差異はあるものの、とくにデータサイズが大きいときには中断しやすい傾向にありました。
実機とMacをUSBケーブルで繋いでデバッグを試みましたが、何度やってみても不具合が再現せず、原因が分からないままに修正を繰り返しても不具合は解消せず、頭を抱えてしまいました。そして数日が経ったところで「USBデバッグ時には不具合が起きないこと自体がヒントでは?」と気付いたのでした。
調べてみると、まさにそれが原因でした。Service WorkerはWebページの裏側で待機しておりfetchイベントの捕捉等はできますが、Service Worker単体で長時間の処理は行うことができないそうです。しかしデベロッパーツールを開いているあいだはService Workerの処理が停止せず、長時間の処理でも行えるというChromeの仕様があったのでした。開発中やデバッグ時にはデベロッパーツールを開いていたため、ダウンロードは中断しなかったという訳です。
オフラインデータの取得とCache APIへの保存処理を、Service WorkerではなくWebページ側のスクリプトに書くことで、無事にダウンロードが完了するようになりました。Service Worker側の処理は必要最小限とし、可能なかぎりWebページ側で行うのが良さそうです。
まとめ
私が今回ぶつかった課題の多くは、たとえばWorkboxなどのライブラリを利用すれば容易に解決できたかもしれません。しかしライブラリの使い方を習得することは容易ではないように思われますし、時間はかかっても自分で実装することでPWA周りの様々な技術を学ぶことができました。今後の案件では必要に応じてWorkboxを使ってみたり、または再び自分で実装してみたいと考えています。