やってみる

アウトプットすべく己を導くためのブログ。その試行錯誤すらたれ流す。

JSのCache APIを使う

 リソースファイルを保存する。 

成果物

情報源

Cache

 Cacheはローカルにサイトのファイルを保存する仕組みである。ふつうServiceWorkerと連携させることで、オフライン時にもサイトを閲覧できるようにする。

API

CacheStorage: window.caches

 重要なのは2つ。open()delete()だ。これらはキャッシュの生成・削除である。それ以外はキャッシュの検索だ。

API 概要
caches.match() 所与の Request が、CacheStorage オブジェクトが追跡する Cache オブジェクトのキーであるかどうかを確認し、その一致で解決する Promise を返します。
caches.has() cacheName に一致する Cache オブジェクトが存在する場合、true に解決される Promise を返します。
caches.keys() CacheStorage によって追跡されるすべての名前付き Cache オブジェクトに対応する文字列を含む配列で解決される Promise を返します。 このメソッドを使用して、すべての Cache オブジェクトのリストを反復処理します。
caches.open() cacheName に一致する Cache オブジェクトに解決される Promise を返します(まだ存在しない場合は新しいキャッシュが作成されます)。
caches.delete() cacheName に一致する Cache オブジェクトを見つけ、見つかった場合は Cache オブジェクトを削除し、true に解決される Promise を返します。 Cache オブジェクトが見つからない場合、false に解決されます。
const CACHE_NAME = '0.0.1';

caches.open(CACHE_NAME).then(function(cache) {
    // return cache.addAll(['./index.html']);
});

caches.delete(CACHE_NAME);

Cache: window.Cache

 重要なのは2つ。put(), delete()だ。これらはキャッシュファイルの生成・削除である。

 他。addAll()はキャッシュする全ファイルパスを登録する。add()put()で実装できる。それ以外はキャッシュファイルの検索だ。

API 概要
Cache.match(req, opt) Cache オブジェクトで最初に一致したリクエストに関連するレスポンスで解決する Promise を返します。
Cache.matchAll(req, opt) Cache オブジェクトで一致するすべてのリクエストの配列で解決する Promise を返します。
Cache.keys(req, opt) Cache キーの配列で解決する Promise を返します。
Cache.add(req) URL を受け取り、それを取得して、指定されたキャッシュに結果のレスポンスオブジェクトを追加します。機能的には fetch() を呼び出してから、 put() を使用してキャッシュに結果を追加するのと同等です。
Cache.addAll(reqs) URL の配列を受け取り、それらを取得して指定されたキャッシュに結果のレスポンスオブジェクトを追加します。
Cache.put(req, res) リクエストとそのレスポンスの両方を受け取り、指定されたキャッシュへ追加します。
Cache.delete(req, opt) キーがリクエストである Cache エントリを探し、見つかった場合は Cache エントリを削除して、 true で解決する Promise を返します。 Cache エントリが見つからない場合、Promise は false で解決します。
API 用途
addAll() 新規追加(複数ファイルをまとめて)
add() 新規追加(1ファイルだけ)
put() 変更

 fetch()でネットワークからリソースを取得する。このとき、リソースをキャッシュする実装をする。これでファイルを更新したときの細かい制御ができる。

fetch(url).then(function(response) {
  if (!response.ok) {
    throw new TypeError('Bad response status');
  }
  return cache.put(url, response);
})

 簡単にやるなら以下。addAll()を使う。

const CACHE_NAME = '0.0.1';
const CACHE_FILES = [
    './index.html'
];

caches.open(CACHE_NAME).then(function(cache) {
    return cache.addAll(CACHE_FILES);
});

caches.delete(CACHE_NAME);

アップデート方法についての考察

 アップデートするとき、2つの方法がある。

仮名 方法
フル・キャッシュ バージョンごとに全ファイルを保持する
ミニ・キャッシュ 最新バージョンのキャッシュのみを保持する

 ふつうのアプリはミニ・キャッシュだと思う。でも「古いほうがよかった。前のに戻したい」というときもよくある。だが大抵は売る側の経営戦略によって前の版に戻せない。

 自分でつくるときは自由だ。なのでメリットを比較してどちらの方法を用いるか吟味したい。

メリット比較

方法 旧版 ファイルサイズ 更新速度 削除頻度 実装負荷
フル・キャッシュ
ミニ・キャッシュ
方法 旧版 ファイルサイズ 更新速度 削除頻度 実装負荷
フル・キャッシュ
ミニ・キャッシュ

フル・キャッシュ・カスタム

 フル・キャッシュにして、ユーザがどのバージョンを保存し、削除するのかを管理できるのがよい。要件は以下。

  • 自動で最新版にする
  • 前のバージョンのほうがよかった。戻したい
  • 別のバージョンも使ってみたい
  • このバージョンは要らない

保存すべき版

 メジャー番号が更新されるごとに保存する。

 インタフェースが変更されるメジャー番号が変わったときだけキャッシュを新規作成すれば、前のバージョンに戻せる。できるだけ少ないファイル容量で。

  • バグはできるだけ消す(マイナーバージョン以下の更新ならキャッシュを上書きする)

 アプリはミニマムにすべき。最近の傾向としては、メジャー番号もガンガンあげていく。アプリが大きくなるほど、対して使っていない機能をアップデートしただけでメジャー番号があがってしまう。したがってアプリは機能を厳選してミニマムにすべき。

開発版

major-version 概要 詳細
0 開発版 とにかく最新版のコード。テスト実施されずとも。
1 安定版 テスト済みのコード。

 ふつうのユーザは開発版なんて使わない。なので設定で自動入手するチェックをさせたい。チェックがなければ開発版は自動更新されない。

自動削除

 使っていない版は自動削除したい。これによりファイル圧迫を最小限にする。ファイル削除頻度をさげるため、使っていないメジャー版キャッシュが3つ以上あるときに実行する。この頻度もユーザが調整できると嬉しい。

  • 残す
    • 最新版
    • 今使っている版
    • ブックマークした版
  • 削除する
    • 上記以外すべての版

実装例

ServiceWorker

 ServiceWorkerを登録する。ここではsw.jsファイルをサービスワーカーの実装ファイルとして登録している。

export default class ServiceWorkerRegister {
    constructor() {
        this.#setup();
    }
    #setup() {
        navigator.serviceWorker.register('sw.js', {
            scope: './'
        }).then(function(registration) {
            var serviceWorker;
            if (registration.installing) {
                serviceWorker = registration.installing;
            } else if (registration.waiting) {
                serviceWorker = registration.waiting;
            } else if (registration.active) {
                serviceWorker = registration.active;
            }
            if (serviceWorker) {
                console.log(`state: ${serviceWorker.state}`);
                serviceWorker.addEventListener('statechange', function (e) {
                    console.log(`state: ${e.target.state}`);
                });
            }
        }).catch (function (error) {
            console.log(error);
        });
    }

sw.js

 処理を実装する。それぞれのイベント時に、キャッシュ処理を実装する。

self.addEventListener('install', function(e) {
    console.log(`install event !!`, e);
});
self.addEventListener('fetch', function(e) {
    console.log('fetch event !!', e);
});
self.addEventListener('activate', function(e) {
    console.log(`activate event !!`, e);
});

 ポイントはselfwindowじゃない。navigator.serviceWorker.register('sw.js', ...)の第一引数で渡されたjsファイルはselfオブジェクトが与えられるようだ。このselfを使ってサービスワーカーのイベントを登録する。

イベント タイミング
install 初回時
fetch 二回目以降
activate 完了時(installfetchが完了後)
イベント 実装すべきCache処理
install すべてのファイルをキャッシュする
fetch 既存のキャッシュにないファイルが最新版で必要なら追加する。
activate 既存のキャッシュで最新版に不要なファイルがあれば削除する。

sw.js(フル・キャッシュ)

 最も楽に書けるフル・キャッシュを実装してみる。

実装段階 要点 コード
フル・キャッシュ1 最小限 3/FullCache.js
フル・キャッシュ2 クラス化 4/FullCache.js
フル・キャッシュ3 ファイルパス自動取得 5/FullCache.js, 5/ServiceWorkerRegister.js

フル・キャッシュ1

 シンプル。

const VERSION = '0.0.1';
const CACHE_FILES = [
    'index.html', 
    'main.js', 
    'FullCache.js', 
];
self.addEventListener('install', function(e) {
    console.log(`install event !!`, e);
    addAll(e);
});
self.addEventListener('fetch', function(e) {
    console.log('fetch event !!', e);
    cache(e)
});
self.addEventListener('activate', function(e) {
    console.log(`activate event !!`, e);
});
function addAll(event) {
    caches.open(VERSION).then(function(cache) {
        console.log('---- open()', CACHE_FILES);
        return cache.addAll(CACHE_FILES);
    });
}
function cache(event) {
    // リクエストに一致するデータがキャッシュにあるかどうか
    caches.match(event.request).then(function(cacheResponse) {
        // キャッシュがあればそれを返す、なければリクエストを投げる
        return cacheResponse || fetch(event.request).then(function(response) {
            return caches.open(VERSION).then(function(cache) {
                // レスポンスをクローンしてキャッシュに入れる
                cache.put(event.request, response.clone());
                // オリジナルのレスポンスはそのまま返す
                return response;
            });  
        });
    });
}

フル・キャッシュ2

 classにしてみた。

const WORKER = self; // ServiceWorker
class FullCache {
    #VERSION = '0.0.1';
    #CACHE_FILES = [
        'index.html', 
        'main.js', 
        'FullCache.js', 
    ];
    constructor() {
        this.#setup();
    }
    #setup() {
        const self = this;
        WORKER.addEventListener('install', function(e) {
            console.log(`install event !!`, e);
            self.addAll(e);
        });
        WORKER.addEventListener('fetch', function(e) {
            console.log('fetch event !!', e);
            self.cache(e)
        });
        WORKER.addEventListener('activate', function(e) {
            console.log(`activate event !!`, e);
        });
    }
    addAll(event) {
        const self = this;
        caches.open(this.#VERSION).then(function(cache) {
            console.log('---- open()', self.#CACHE_FILES);
            return cache.addAll(self.#CACHE_FILES);
        });
    }
    cache(event) {
        const self = this;
        // リクエストに一致するデータがキャッシュにあるかどうか
        caches.match(event.request).then(function(cacheResponse) {
            // キャッシュがあればそれを返す、なければリクエストを投げる
            return cacheResponse || fetch(event.request).then(function(response) {
                return caches.open(self.#VERSION).then(function(cache) {
                    // レスポンスをクローンしてキャッシュに入れる
                    cache.put(event.request, response.clone());
                    // オリジナルのレスポンスはそのまま返す
                    return response;
                });  
            });
        });
    }
}
const c = new FullCache(); 

代名詞について

self

 selfはサービスワーカーである。navigator.serviceWorker.register('sw.js', {...で登録されたファイルsw.jsは、グローバル変数selfが作られる。このselfによってサービスワーカーのイベントリスナを登録する。すなわちself.addEventListener('install', function(e) {などである。

 サービスワーカーを変数WORKERに代入した。今回はコード1行目でconst WORKER = self;とし、WORKERという変数に変えた。理由は後述するが、selfを別の参照に使いたかったから。

this

 thisはなにを示すか文脈次第。ここでは2パターンある。

 イベントリスナ内でクラスインスタンスを参照したい。なのにthisで参照されるのはEventオブジェクトである。どうすればイベントリスナ内でクラスインスタンスを参照できるか?

 クラスメソッドの1行目でconst self = this;する。以降、selfからクラスのメソッドなどメンバが参照できる。イベントリスナ内であっても参照できる。

 慣例としてクラス参照にselfという名前を使っていた。なのでサービスワーカー文脈内で使われるselfのほうをWORKERに代入した次第である。はたしてこれがいいやり方なのか疑問だ。クラスインスタンス変数をclsとし、サービスワーカーはselfにしたほうがよかったかもしれない。と思ったが、サービスワーカーのイベント登録なんて全体の一部でしかない。なのでWORKERという固有名詞にしたほうがわかりやすい。現状のコードでよし。

フル・キャッシュ3

 パス取得を自動化した。キャッシュするファイルのパスをすべて書くのが面倒くさかったので。情報源

 2ファイル変更する必要がある。

ServiceWorkerRegister.js

export default class ServiceWorkerRegister {
    #path;
    constructor(path='sw.js') {
        this.#path = path;
        this.#setup();
    }
    show() {
        console.log(this.msg);
        alert(this.msg);
    }
    #setup() {
        console.log('has navigator:',navigator);
        console.log(`has serviceWorker: ${'serviceWorker' in navigator}`);
        navigator.serviceWorker.register(this.#path, {
            scope: '/'
        }).then(function(registration) {
            const data = {
                type: 'CACHE_URLS',
                payload: [
                    location.href,
                    ...performance.getEntriesByType('resource').map((r) => r.name)
                ]
            };
            registration.installing.postMessage(data);
        }).catch (function (error) {
            console.log(error);
        });
    }
}

sw.js

const WORKER = self; // ServiceWorker
class FullCache {
    #VERSION = '0.0.1';
    constructor() {
        this.#setup();
    }
    #setup() {
        const self = this;
        WORKER.addEventListener('message', (e) => {
            console.log(`message event !!`, e);
            self.addAll(e);
        });
        WORKER.addEventListener('install', function(e) {
            console.log(`install event !!`, e);
            e.waitUntil(WORKER.skipWaiting());
        });
        WORKER.addEventListener('fetch', function(e) {
            console.log('fetch event !!', e);
            self.cache(e)
        });
        WORKER.addEventListener('activate', function(e) {
            console.log(`activate event !!`, e);
        });
    }
    addAll(event) {
        const self = this;
        if (event.data.type === 'CACHE_URLS') {
            event.waitUntil(
                caches.open(self.#VERSION).then((cache) => {
                    console.log(`addAll(): ${event.data.payload}`);
                    return cache.addAll(event.data.payload);
                })
            );
        }
    }
    cache(event) {
        const self = this;
        // リクエストに一致するデータがキャッシュにあるかどうか
        caches.match(event.request).then(function(cacheResponse) {
            // キャッシュがあればそれを返す、なければリクエストを投げる
            return cacheResponse || fetch(event.request).then(function(response) {
                return caches.open(self.#VERSION).then(function(cache) {
                    // レスポンスをクローンしてキャッシュに入れる
                    cache.put(event.request, response.clone());
                    // オリジナルのレスポンスはそのまま返す
                    return response;
                });  
            });
        });
    }
}
const c = new FullCache(); 

課題

キャッシュ削除していない

 キャッシュの削除が未実装である。

  • メジャーバージョン単位でキャッシュをつくる
  • ユーザが任意にバージョンを選んで削除できる

所感

キャッシュってすごい大変ね

 以下4ステップを踏む必要がある。

  1. サービスワーカーとキャッシュの概念を理解する
  2. サービスワーカーとキャッシュのAPIを理解する
  3. キャッシュ方法を考える
  4. 実装する

 キャッシュ方法を考えついたとして、そのとおりにAPIをつかって実装するのが大変。ライブラリとかないのかな?

対象環境

$ uname -a
Linux raspberrypi 5.4.83-v7l+ #1379 SMP Mon Dec 14 13:11:54 GMT 2020 armv7l GNU/Linux