オフラインWebアプリがどんどん使われるようになっています。オフラインのサポートが重要になった結果、最初にオフラインでの利用を考える「オフライン・ファースト」を話題にするのが普通になりました。また、プログレッシブWebアプリ(PWA)という考え方が現れたのもオフライン・サポートが普及し始めた一因になっています。
この記事では、アセット・キャッシング、クライアント側のデータストレージ、リモート・データストアとの同期などの機能を利用して、基本的な電話帳Webアプリにオフライン・サポートを追加する方法を説明します。
アプリのソースコードはGitHubで入手できます。
なぜオフライン・サポート?
なぜ、オフライン・サポートが必要なのでしょうか?
私は毎日1時間以上、電車の中で過ごします。時間を無駄にしたくないので、途中、少し仕事をするのにノートパソコンを持っていきます。ネットにつなぐのに携帯電話ネットワークを使いますが、接続が安定せず良く切れます。私のこれまでの経験では、優れたオフライン・サポートがあり、思ったとおりに動いてくれて、ネット接続を意識しなくていいアプリは知りません。動きが変になって、ページを更新すると、データが失われてしまうこともあります。オフライン・サポートがまったくないものがほとんどで、きちんと使えるようになるのにネット接続が回復するのを待たなければなりません。
接続が悪いときに限った話ではありません。何時間もオフラインである場合、たとえば、飛行機に乗るときのことも考える必要があります。
オフライン・サポートのもう1つの良いところは、処理速度が飛躍的に向上することです。ブラウザーはサーバーからアセットをロードするのを待つ必要がありません。データについても同様で、クライアント側でいったんセーブしておけば、それで終わりです。
以上、まとめると、オフラインが必要な理由は、次のようになります。
- ネット接続が不安定なときでも(電車の中の携帯接続など)アプリが使える
- ネット接続がなくても(飛行機の中など)仕事ができる
- 動作速度が向上する
Progressive Web Apps
Googleの「Progressive Web Apps(PWA)」コンセプトは、Webアプリでネイティブのモバイルアプリと同等のユーザー・エクスペリエンスの提供を目的とした方法論です。PWAにはオフライン・サポートも含まれますが、それ以外も対象にしています。
- 応答性:モバイル、タブレット、デスクトップなどさまざまな形態をサポートする
- Webアプリマニフェスト:ホーム画面にアプリをインストールする
- アプリシェル:基本的なUIアプリシェルがあとからロードされるコンテンツと分かれているデザインパターンを使用する
- プッシュ通知:サーバーからただちにアップデートを受け取る
PWAについては、Addy Osmaniが参考になる入門記事を書いています。
この記事で注目するのはそのうちの1つ、オフライン・サポートです。
オフライン・サポートの定義
オフライン・サポートで明らかに必要なのは、次の2点です。
- アプリアセット:HTMLキャッシュ、JSスクリプト、CSSスタイルシート、画像
- アプリデータ:クライアント側でのデータ保存
アプリアセット
HTML5でオフラインアセットをキャッシュする最初の方法は、AppCacheでした。ブラウザーキャッシュに保存すべきリソースが書かれたマニフェストを用意するという考え方です。次回アプリがロードされるときに、指定されたアセットがブラウザーのキャッシュから読み込まれるということです。
重要:AppCacheを使うのは簡単なのですが、たくさんの落とし穴があるため、現在は余り使われなくなりました。しかし、ブラウザーではいまでも広くサポートされています。
AppCacheに代わってService Workersが使われるようになりました。オフライン・サポートのためにいろいろな使い方を提供します。Service Workersは、発生するリクエストを管理して、スクリプトでインターセプトして必要な応答を返します。キャッシングのロジックはすべて開発者の肩にかかっています。アプリのコードでアセットがキャッシュにセーブされているかをチェックでき、必要な場合だけサーバーにアセットをリクエストします。
Service WorkersはHTTPS経由(HTTPではlocalhostのみ許可)でしかサポートされないことに注意してください。Service Workersの使い方についてはあとで説明します。
アプリデータ
アプリデータはブラウザーが用意しているオフライン・ストレージに保存できます。
HTML5ではいくつかの選択肢があります。
- WebStorage:key-valueストレージ
- IndexedDB:NoSQLデータベース
- WebSQL:ビルトインの SQLite データベース
WebStorageはkey-valueストレージです。もっとも簡単なクロスブラウザー保存ですが、落とし穴があります。セーブする値は文字列でなければならないので、保存するデータを直列化、復元化する必要があります。大きなデータセットにはサイズ制限があります。また、競合の問題もあります。すなわち、ブラウザーでタブを2つ同時に開いた場合、なにが起こるか分からないのです。
IndexedDBはWebStorageに比べてずっと強力で、オフライン・ストレージに使うには最適です。スペースは十分です。トランザクションをサポートし、ブラウザーで同時に複数のタブを使っても安全です。最近のブラウザーならサポートしています。
WebSQLは文字通りブラウザーのSQLiteです。クライアント側でACID特性を備え、完全な機能を持ったリレーショナルDBです。残念なことに標準委員会からは認証されず、non-Blink/Webkitブラウザーでサポートされませんでした。
オフライン・ストレージからデータを抽出する機能を提供するライブラリーは以下のものです。
- localForage:簡単なlocalStorageライクなAPI
- IDBWrapper:クロスブラウザーのkdexedDBラッパー
- PouchDB:CouchDBが提供するクライアント側でのストレージ。CouchDBを使っている場合、バックエンドで自動的に同期をサポートする
電話帳アプリ
Webアプリにオフライン・サポートを加える方法を説明します。例として、基本的な電話帳を取り上げます。
左側に連絡先のリストを、右側に連絡先を編集するのに使う詳細フォームを配置します。連絡先には3つのフィールド「名」「姓」「電話番号」があります。
アプリのソースコードはGitHubにあります。アプリを実行するには、Node.jsをインストールしておく必要があります。よく分からなかったら、npmのビギナーズガイドを参考にしてください。
最初に、ソースをダウンロードしてプロジェクトフォルダーから次のコマンドを実行します。
$ npm install
$ npm run serve
バックエンドはどうなのでしょうか。CouchDBストーレジにREST APIを提供するpouchdb-serverとフロントエンド・アセットのためにhttp-serverを使っています。
package.jsonのscriptsは次のようになります。
"scripts": {
"serve": "npm-run-all -p serve-front serve-backend",
"serve-front": "http-server -o",
"serve-backend": "pouchdb-server -d db"
},
npm-run-allパッケージはコマンドを並列で使えます。http-serverとpouchdb-serverの両方を使います。
アプリアセットのオフライン・サポートをインプリメントします。
オフライン・アセット
/publicディレクトリにアプリケーションに必要なアセットがすべてあります。
- /css/style.css:アプリケーションスタイルシート
- /js/ext:外部ライブラリー(ES2015で書かれたPouchDBとBabel)を含んだディレクトリ
- /js/app.js:メインのアプリケーション・スクリプト
- /js/register-service-worker.js:Service Workersを登録するスクリプト
- /js/store.js:PouchDBストレージを扱うアダプタークラス
- /contactbook.appcache:AppCacheのマニフェスト
- /index.html:アプリケーション・マークアップ
- /service-worker.js:Service Workersのソース
Service Workersの登録から始めます。以下がregister-service-worker.jsの登録コードです。
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js', {
scope: '/'
}).then(function() {
// success
}).catch(function(e) {
// failed
});
}
最初にserviceWorkerがブラウザーでサポートされているかチェックします。サポートされているならregisterメソッドを呼び出し、Service WorkersスクリプトにURLを渡し(この例では/service-worker.js)、Service Workersのスコープを指定するパラメータを渡します。パラメータはオプションで、デフォルトのscopeの値はルート/です。
重要:スコープとして、アプリのルートディレクトリが使えるようするには、Service Workersスクリプトはアプリのルートに置いておかなければなりません。
registerメソッドはPromiseを返します。
Service Workersのライフサイクルはインストールから始まります。installイベントを操作し、必要なリソースをキャッシュに置けます。
var CACHE_NAME = 'contact-book-v1';
var resourcesToCache = [
'/',
'/css/style.css',
'/js/ext/babel.min.js',
'/js/ext/pouchdb.min.js',
'/js/register-service-worker.js',
'/js/store.js',
'/js/app.js'
];
self.addEventListener('install', function(event) {
event.waitUntil(
// open the app browser cache
caches.open(CACHE_NAME)
.then(function(cache) {
// add all app assets to the cache
return cache.addAll(resourcesToCache);
})
);
});
最後に必要なことは、Service Workersのスコープからリソースがフェッチされるたびにトリガーされるfetchイベントを制御することです。
self.addEventListener('fetch', function(event) {
event.respondWith(
// try to find corresponding response in the cache
caches.match(event.request)
.then(function(response) {
if (response) {
// cache hit: return cached result
return response;
}
// not found: fetch resource from the server
return fetch(event.request);
})
);
});
これで終わりです。ちゃんと動くかテストしてみます。
- npm run serveでアプリを起動する
- ChromeでURL http://127.0.0.1:8080/を開く
- コンソールからCtrl + Cでサーバーを停止する(あるいはChromeのデベロッパーツールsimulate going offlineを使う)
- Webページをリフレッシュする
アプリはまだ使えています。すごい!
AppCache
上の方法で問題なのは、Service Workersのブラウザー・サポートが限られていることです。ブラウザー・サポートが優れているAppCacheを使ってフォールバックソリューションを実装します。AppCacheの使い方の詳細はここを読んでください。
使い方は簡単で、2つのステップがあります。
- AppCacheのマニフェストcontactbook.appcacheを定義する
CACHE MANIFEST # v1 2017-30-01 CACHE: index.html css/style.css js/ext/babel.min.js js/ext/pouchdb.min.js js/store.js js/app.js
例の簡単なアプリではCACHEセクションを1つ定義し、アセットを全部置いておく
- Htmlからマニフェストファイルを参照する
<html manifest="contactbook.appcache" lang="en">
これで終わりです。Service Workersをサポートしていないブラウザのページを開いて、前回と同じようにテストします。
オフラインデータ
アセットをキャッシュできることはすごいことです。しかし、十分ではありません。アプリがオフラインで動くためにはユニークなデータが扱えるかどうかです。PouchDBをクライアント側のデータストレージとして使います。PouchDBは強力で使いやすく、すぐにデータの同期ができます。
PouchDBのことが良く分からないときは『たった2行でサーバーとも同期できるJSデータベース「PouchDB」がアツい!』を参照してください。
PouchDBを操作するにはヘルパークラスStoreを使います。
class Store {
constructor(name) {
this.db = new PouchDB(name);
}
getAll() {
// get all items from storage including details
return this.db.allDocs({
include_docs: true
})
.then(db => {
// re-map rows to collection of items
return db.rows.map(row => {
return row.doc;
});
});
}
get(id) {
// find item by id
return this.db.get(id);
}
save(item) {
// add or update an item depending on _id
return item._id ?
this.update(item) :
this.add(item);
}
add(item) {
// add new item
return this.db.post(item);
}
update(item) {
// find item by id
return this.db.get(item._id)
.then(updatingItem => {
// update item
Object.assign(updatingItem, item);
return this.db.put(updatingItem);
});
}
remove(id) {
// find item by id
return this.db.get(id)
.then(item => {
// remove item
return this.db.remove(item);
});
}
}
Storeクラスのコードは典型的なCRUDを用いるもので、PromiseベースのAPIを提供します。
以下でメインのアプリのコンポーネントがStoreを使えるようになります。
class ContactBook {
constructor(storeClass) {
// create store instance
this.store = new storeClass('contacts');
// init component internals
this.init();
// refresh the component
this.refresh();
}
refresh() {
// get all contacts from the store
this.store.getAll().then(contacts => {
// render retrieved contacts
this.renderContactList(contacts);
});
}
...
}
Storeクラスは、具体的なデータをアプリクラスから分離するコンストラクタに渡されます。 いったんストアーが生成されると、すべての連絡先にアクセスするためrefreshメソッドで使われます。
アプリの初期化は次のようになります。
new ContactBook(Store);
このストアーに影響するほかのアプリメソッドは、次のようになります。
class ContactBook {
...
showContact(event) {
// get contact id from the clicked element attributes
var contactId = event.currentTarget.getAttribute(CONTACT_ID_ATTR_NAME);
// get contact by id
this.store.get(contactId).then(contact => {
// show contact details
this.setContactDetails(contact);
// turn off editing
this.toggleContactFormEditing(false);
})
}
editContact() {
// get id of selected contact
var contactId = this.getContactId();
// get contact by id
this.store.get(this.getContactId()).then(contact => {
// show contact details
this.setContactDetails(contact);
// turn on editing
this.toggleContactFormEditing(true);
});
}
saveContact() {
// get contact details from edit form
var contact = this.getContactDetails();
// save contact
this.store.save(contact).then(() => {
// clear contact details form
this.setContactDetails({});
// turn off editing
this.toggleContactFormEditing(false);
// refresh contact list
this.refresh();
});
}
removeContact() {
// ask user to confirm deletion
if (!window.confirm(CONTACT_REMOVE_CONFIRM))
return;
// get id of selected contact
var contactId = this.getContactId();
// remove contact by id
this.store.remove(contactId).then(() => {
// clear contact details form
this.setContactDetails({});
// turn off editing
this.toggleContactFormEditing(false);
// refresh contact list
this.refresh();
});
}
ストアーCRUDメソッドを使った基本的なオペレーションを以下に示します。
- showContact:リストから連絡先が選択されたとき、詳細を表示する
- editContact:連絡先の詳細を編集できるようにする
- saveContact:新規または既存の連絡先の詳細を保存する
- removeContact:選択された連絡先を削除する
これで、オフラインで連絡先を加えてページを更新しても、データは失われません。
ただし、気をつけなければならないことがあります。
データ同期
ここまではすべてうまくいきました。しかし、データはすべてブラウザーにローカル保存されているので、違うブラウザーでアプリを開いても変更は反映されていません。
サーバーにデータ同期機能を実装する必要があります。 2方向のデータ同期を実装するのは簡単ではありません。幸い、バックエンドでCouchDBが動いていればPouchDBの同期機能を使えます。
Storeクラスを変更すれば、リモートのデータソースと同期します。
class Store {
constructor(name, remote, onChange) {
this.db = new PouchDB(name);
// start sync in pull mode
PouchDB.sync(name, `${remote}/${name}`, {
live: true,
retry: true
}).on('change', info => {
onChange(info);
});
}
コンストラクタに2つのパラメータを追加しました。
- remote:リモートサーバーのURL
- onChange:バックエンドから変更がきたときに発生するコールバック
PouchDB.syncメソッドがうまくバックエンドとの同期をします。liveパラメータは定期的に変更があるかどうかをチェックしており、retryはエラーが発生したときにリトライします(したがって、ユーザーがオフラインにしても、同期は止まりません) 。
アプリクラスを変更し、必要なパラメータをStoreコンストラクタに渡す必要があります。
class ContactBook {
constructor(storeClass, remote) {
this.store = new storeClass('contacts', remote, () => {
// refresh contact list when data changed
this.refresh();
});
...
}
メインのアプリクラス・コンストラクタはストアーに渡されるリモートのURLを受けとります。onChangeコールバックはrefreshメソッドを呼び出し、連絡先のリストを更新します。
アプリの初期化も変更しなければなりません。
new ContactBook(Store, 'http://localhost:5984');
できました! これでオフラインで電話帳を編集できます。アプリがネットワーク接続したら、データはバックエンド・ストレージと同期されます。
試してみます。
- $ npm run serveでWebサーバーを起動する
- 2種類のブラウザーでURL http://127.0.0.1:8080/を開く
- Ctrl + CをクリックしてWebサーバーを停止する
- 両方のブラウザーで電話帳を編集する
- $ npm run serveでWebサーバーを再度起動する
- 両方のブラウザーで電話帳をチェックする(両方のブラウザーで変更が反映されているはず)
すばらしい! やりました!
GitHubにあるアプリのソースコードをチェックしてください。
最後に
オフラインでのアプリの使用は、日に日に重要性を増しています。移動中にネット接続がよく切れたり、飛行機でネット接続ができないときにアプリが使えることは、手放せないアプリを使うのに非常に重要です。アプリの実行速度を上げることにもなります。
オフラインで使えるようにするには次のことに注意する必要があります。
- アプリアセットをキャッシングする:Service Workersがすべてのブラウザーでサポートされるまでは、AppCacheのフォールバックを利用する
- クライアント側でデータをストアーする:indexedDBのようなブラウザー・オフライン・ストレージをライブラリーとともに利用する
以上をどのように実装するのか説明してきました。楽しんでいただけたでしょうか。
※本記事はJames KolceとCraig Bucklerが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。
(原文:Create Offline Web Apps Using Service Workers & PouchDB)
[翻訳:関 宏也/編集:Livit]