ServiceWorkerの流儀1……Safariのキャッシュと遅い読み込みの問題を解決する


この記事は Menhera.org 日本語 WebCompat シリーズの第2回です.

今回は,Safari に限らず,一般のブラウザで一貫性のある動作をする ServiceWorker の書き方を紹介します.

要点

  • Safari は ServiceWorker 〔PWA には必須〕の存在下で,(或いは存在しなくとも) 古い CSS/JS を読み込んでしまい,更新しないことがある.これはサーヴァ側でヘッダを設定することで軽減可能な場合もあるが,そのような修正はむづかしいことも多い.
  • Safari は WebComponents で使われる Shadow Root の内部に CSS を読みこんだときに描画が遅く,スタイルが適用されていない状態で一瞬表示されるため点滅が生ずるので醜い.
  • これらは実は fetch に使用されるデフォルトの挙動が Safari と Firefox/Chrome で異なることに由来する.本稿では,すべてのブラウザで一貫性のある動作をする ServiceWorker の書き方について述べる.

Why Safari?

  • iOS でデフォルトであるばかりか,全てのブラウザが内部では Safari であるため,Safari できちんと動作しないことは膨大な数の iPhone/iPad/iPod ユーザを全て捨てることになってしまうため.
  • Safari は Firefox/Chrome とは異なる動作が多く,対応していない技術も多いため,対応には多くの開発者が難儀することが知られている.

一般向けのアプリでは「Chrome に対応すれば良い」はやめましょう

一般のブラウザで動くコードは佳きプログラミングに繋がるものです.Chrome の独占に一般の開発者が加担するのは良くありません.

方略

1. ServiceWorker の更新を急かす

ServiceWorker の install イヴェントで,ev.waitUntil(promise) を用い非同期にキャッシュを予め満たしておくことでオフライン対応をよく行うかと存じますが,これが済み次第 self.skipWaiting() を実行することで直ぐに新しい ServiceWorker が使われるようにします.

2. 使われていない Cache を取り除く

ServiceWorker の activate イヴェントでは,以前の ServiceWorker で使われていたが今は使われていないキャッシュを取り除くことが推奨されます.これは,アプリの更新とかで古いキャッシュが使われずに残ることが屢〻あるからであります.

また,このように多数の非同期処理を行う場合一般について言えることなのですが,わたくしが思うに,Promise.all() で並列処理を行うことが推奨されましょう.さすれば読み込みも素早くなりましょうから.

例:

const MY_CACHES = new Set(['assets-v1', 'cache-v1']);
self.addEventListener('install', (ev) => void ev.waitUntil((async () => {
    const keys = await caches.keys();
    await Promise.all(
        keys
        .filter(key => !MY_CACHES.has(key))
        .map(key => caches.delete(key))
    );
    return self.skipWaiting();
})()));

3. ServiceWorker で HTTP Cache-Control を制御する

これは,fetch イヴェントに於いて Request を書き換えることによって実現されます.

注意:ServiceWorker が受け取る Request オブジェクトには,modenavigate のものが含まれます.〔スクリプトでは使われないけれども.〕このような Request オブジェクトは,オプションを弄ることができません.されば,navigate 以外の値に定めれば良いでしょう.これを怠ればページの再読み込み時にエラーが出ます.

気をつけて書いた例が以下になります:

self.addEventListener('fetch', ev => void ev.respondWith((async (req) => {
    const freshReq = new Request(req, {
        mode: req.mode == 'navigate' ? 'cors' : req.mode,
        credentials: req.credentials,
        cache: 'no-cache', // 再検証を必須にする〔キャッシュ自体は用いられる.〕.
    });
    const cache = await caches.open('assets-v1');
    const cachedRes = await cache.match(freshReq);
    if (!cachedRes) return fetch(freshReq);
    try {
        const freshRes = await fetch(freshReq);
        if (!freshRes.ok) throw 'Non-2xx response';
        await cache.put(freshReq, freshRes.clone()); // clone() せねば Response が使用済みになってしまい読み込みできなくなる.
        return freshRes;
    } catch (e) {
        return cachedRes;
    }
})(ev.request)));

何と魔法のように,これで Safari も Firefox/Chrome 同様に最新の内容を返すようになり,開発中アプリが更新されないときに特に重宝します.また,WebComponent で Shadow DOM に CSS を読み込むときに特に顕著である描画の遅れもほぼ生じなくなります.

なお,この例では,予め定められたファイルだけをキャッシュし,キャッシュに無いものはキャッシュに追加しない実装を採用しています.

関連記事