先月は、とある受託開発でPWA (Progressive Web App) の地図アプリケーションを開発していました。2月末で終わる予定であったこのプロジェクト、開発終盤に機能の微調整やUIのブラッシュアップ等を行うことになり開発期間が延長。この記事を書いている時点 (3月9日) になってようやく終わりが見えてきました。長かった…
そんなこんなで終盤は、湧いて出てくる課題をひたすら潰す作業をしていたわけですが、最後の最後まで残った、面倒くさい課題が「PWAのアップデート問題」でした。
発生した問題
アプリケーションの機能を作りながら、随時サーバにデプロイし、動作確認する、というサイクルを回していた時にそれは起こりました。古いバージョンのアプリを直前に開いていた端末で、修正を確認しようとリロードしてみたのですが、何度リロードしても新しいバージョンを確認できない状態に陥りました。
つまりどういうこと? 時間軸を整理してみますと、以下の手順になります。
- アプリケーションをサーバにデプロイ
- スマートフォンで1. の動作確認 (アプリケーションへの新規アクセス、ブラウザにキャッシュされる)
- アプリケーションを修正して、再度デプロイ
- スマートフォンで3.の動作確認 → 失敗
1. のキャッシュが残っているため、3.を確認できない。 (リロードしても解消されず)
原因は上記の通り、古いキャッシュが残っている事でした。Reactアプリをビルドした時にルートに配置されるindex.htmlがキャッシュを参照している様子。なので最初に、このファイルをキャッシュしないように、キャッシュマニフェストを設定してみました。
キャッシュマニフェストの設定
HTML5ではキャッシュマニフェストを定義することで、キャッシュさせたくないファイルを指定できる、ということで早速指定してみました。
CACHE MANIFEST
# version1.0.0 - 2022-03-04
NETWORK:
index.html
こんな感じのテキストファイルを作成し、拡張子”.appcache”で保存し、index.htmlから参照します。
<!DOCTYPE html>
<html lang="ja" manifest="manifest.appcache">
中略
</html>
これでindex.htmlファイルはキャッシュされず、毎回オンライン上から取得されるようになるはず。…と思って動作確認するも、何故か古いhtmlが取得されます。よく確認してみると、index.htmlは”Service Worker”から取得されていました。(※補足:Service Workerについて)
今回はオフライン利用を想定したアプリではないので、一旦ServiceWorkerへの登録を無効化してデプロイしてみたところ、index.htmlがキャッシュされなくなったので、とりあえず目的は達成できました。しかし…
ServiceWorkerを正しく理解する
リロードにより更新が反映されるWebアプリにはなりましたが、ServiceWorkerを無効化したことで、PWAとして機能しなくなってしまいました。(※スマホにアプリとしてインストールできず、ショートカット扱いになる)
今回開発しているアプリはやはりPWAとして動くようにしたい、ということで一旦消したServiceWorkerを復活させ、正しく理解した上でアプリが更新されるように修正することにしました。
まず、ServiceWorkerのライフサイクルを正しく理解する必要があります。詳しくはgoogleのドキュメントを見て頂くとして、アプリの更新を検知した時のServiceWorkerのライフサイクルを以下に図解で示します。
正確さにあまり自信がありませんが、だいたいこんな感じです。ServiceWorkerのスレッドが、アプリの更新を検知した時、バックグラウンドでは上記の通り新しいServiceWorkerを生成します。新しいServiceWorkerは、インストール(state: installed)までは、自動的に行われますが、古いServiceWorkerと差し替わるには、何らかのトリガーが必要になります。このトリガーが存在しない事には、アプリが更新されません。
で、このトリガーとなる操作は2つ。
- 現行のServiceWorkerを使用しているブラウザの全てのタブを閉じる→新しく開く
- ServiceWorkerGlobalScope.skipWaiting()メソッドを呼び出す
1.も嵌りやすいポイントなので注意は必要ですが、ここで注目したいのは2. の方。skipWaiting()メソッドは、待機中のServiceWorkerを即座にactivatedに移行させる命令のようです。つまり、アプリを更新させたいタイミングでこのメソッドを呼べばよい、ということですね。
アプリに更新通知機能を実装する
今回のアプリでは、こんな感じの更新通知ダイアログを表示した上で、skipWaiting()を呼び出して更新することにしました。
Create React Appで生成したプロジェクトでしたので、ServiceWorkerを利用するための以下のコードが自動生成されていました。
serviceWorkerRegistration.ts
// 省略
export function register(config?: Config) {
// 略
registerValidSW(swUrl, config);
}
function registerValidSW(swUrl: string, config?:Config) {
navigator.serviceWorker.register(swUrl)
.then(registration => {
// 略
registration.onupdatefound = () => {
const installingWorker = registration.installing;
// 略
installingWorker.onstatechange = () => {
if (installingWorker.state === "installed") {
// 略
if (config && config.onUpdate) {
config.onUpdate(registration);
}
}
}
}
})
// 略
}
解説したい部分だけピックアップしていますが、上記の実装は、新しいServiceWorkerのstateが”installed”になったときに、register関数で設定したonUpdateイベントを実行するように作られています。なのでこのタイミングでダイアログが表示されるように実装してみます。
index.tsx
// 略
serviceWorkerRegistration.register({
onUpdate: registration => {
if (registration.waiting) {
ReactDOM.render(<ServiceWorkerUpdateDialog registration={registration} />,
document.querySelector('.SW-update-dialog'));
}
}
});
この実装により、更新の準備ができたら、id=”SW-update-dialog”のdiv要素にダイアログのコンポーネントを差し込むようになりました。このdiv要素はアプリ上のどこか適当な場所に配置しておけばよいでしょう。
ServiceWorkerUpdateDialog.tsx
import { VFC, useState } from 'react';
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
// 略
const ServiceWorkerUpdateDialog: VFC<ServiceWorkerUpdateDialogProps> = function SWUpdateDialog(props: ServiceWorkerUpdateDialogProps) {
const { registration } = props;
const [show, setShow] = useState(!!registration.waiting);
const handleUpdate = () => {
registration.waiting?.postMessage({ type: 'SKIP_WAITING' });
setShow(false);
};
return (
<Dialog open={show}>
<DialogTitle style={{textAlign: "center"}}>
アップデート通知
</DialogTitle>
<DialogContent>
<DialogContentText>
新しいバージョンがリリースされました。
</DialogContentText>
</DialogContent>
<DialogActions sx={{justifyContent: "center"}}>
<Button onClick={handleUpdate}>アップデート</Button>
</DialogActions>
</Dialog>
);
};
ダイアログコンポーネントはこんな感じで作りました。MUIを使って汎用的なコンポーネントとして作っています。アップデートボタンを選択することで、postMessage({ type: ‘SKIP_WAITING’ })を経由してskipWaiting()が実行され、待機中のServiceWorkerがactiveになります。
最後に、新しいServiceWorkerがactiveになったことが確認出来たら画面がリロードされるように実装します。
serviceWorkerRegistration.ts
function registerValidSW(swUrl: string, config?:Config) {
navigator.serviceWorker.register(swUrl)
.then(registration => {
// 略
registration.onupdatefound = () => {
const installingWorker = registration.installing;
// 略
installingWorker.onstatechange = () => {
if (installingWorker.state === "installed") {
// 略
} else if (installingWorker.state === "activated") {
window.location.reload();
}
}
}
})
// 略
}
これで、更新通知機能は完成しました。
所感
PWAを開発する際に、更新時の挙動をどうするかをしっかり設計しておかないと、このように苦労することになります。今回得たノウハウは、開発部内で共有して、今後のPWA開発に生かしていきたいと思います。