# 20200128 PWA化によるオフライン対応 ###### tags: `firebase` `web` # 休講のお知らせ 2020年1月29日(木曜日)は講師出張のため休講です。 # 復習とお品書き 前回の最後にはAndroidアプリではRealtime Databaseを変化をリアルタイムに捉えて表示しました。今回は再びWebアプリケーションに戻りFirebaseをJavaScriptから使います。 1. setでコールバックを指定することによりクライアントからサーバへの書き込みの失敗を捉える 2. PWA(Prograssive Web Application)の要件を満たすことによりオフライン対応する # 参考 * [Android2019 as Github repository](https://github.com/TakashiSasaki/Android2019/blob/master/README.md) * [hello-kbc-sasaki as Glitch project](https://glitch.com/~hello-kbc-sasaki) * [ウェブでのデータの読み取りと書き込み](https://firebase.google.com/docs/database/web/read-and-write) # 完了コールバックの追加 >データがいつ commit(確定)されたのかを把握したい場合は、完了コールバックを追加できます。set() と update() はどちらも、完了コールバックをオプションとして取ります。このコールバックは、書き込みがデータベースに commit されたときに呼び出されます。呼び出しが失敗した場合は、失敗した理由を示すエラー オブジェクトがコールバックに渡されます。 >[ウェブでのデータの読み取りと書き込み](https://firebase.google.com/docs/database/web/read-and-write) ```javascript= firebase.database().ref('users/' + userId).set({ username: name, email: email, profile_picture : imageUrl }, function(error) { if (error) { // The write failed... } else { // Data saved successfully! } }); } ``` `scrpt.js`では今まで`ref`に対する`set`でコールバックを設定していなかったが、ここに`set`の完了に対するコールバックを設定する。こうすることでFirebase Realtime Databaseのルールに違反したなどの理由で書き込みに失敗した際のエラーを捕捉することができる。 ```javascript= ref.set({ email: firebase.auth().currentUser ? firebase.auth().currentUser.email : "anonymous", text: textarea.value }, function(error){ if(error){ var errorInput = document.getElementById(id + "error"); if(errorInput == null) return; errorInput.value = JSON.stringify(error); } else { errorInput.value = "Set successfully in " + (new Date()); }//if }); ``` # PWAの基本 PWA (Progressive Web Application)とは古典的なウェブアプリに対する制約を取り除き、いわゆる従来型のインストール可能なアプリでしか得られなかったユーザー体験を実現するものである。制約とは例えば次のようなものである。 * オフラインでは使うことができない * ホーム画面やスタートメニューに現れない * プッシュ型の通知を受け取ることができない このような制約を取り除くための様々な機能がHTML5以降新しくブラウザに導入されている。古典的なウェブアプリはそれ自体きちんと機能するものであり決して劣ったものではない。そこからさらに制約を取り除きユーザー体験を改善しようとする取り組みがなされたウェブアプリを総称してPWAと呼ぶので、何がPWAであって何がPWAでないかという厳格な区別は無く、制約の除去は部分的に、徐々に、漸次的に可能なものである。そういう意味を込めて*progressive*と呼んでいるのである。 * [はじめてのプログレッシブウェブアプリ](https://developers.google.com/web/fundamentals/codelabs/your-first-pwapp?hl=ja) 今日の授業ではPWAの例としてオフライン対応を実現してみる。 ## すべてはマニフェストから ブラウザがあるウェブサイトを訪れた時、それがPWA的であることをどのように認識するのだろうか。ブラウザはただ当該URLからHTTPリクエストによりHTMLドキュメントをダウンロードするだけである。そのHTML文書のヘッダでマニフェストが書かれているかがすべての始まりとなる。 ```htmlembedded= <!DOCTYPE html> <html lang="en"> <head> <title>hello-kbc-sasaki</title> <meta charset="utf-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <link rel="stylesheet" href="/style.css" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/mini.css/3.0.1/mini-default.min.css" /> <link rel="manifest" href="/manifest.json" /> </head> ``` マニフェストは日本語では「選挙公約」などという軽々しく白々しい訳語がつけられてしまっているが、もともとは貿易においてある船がどこの船籍でどんな船員、乗客、貨物を搭載しているかを全てもれなく書き記した文書のことである。安全、通関、課税、保険などすべての場面でその船を審査し、受け入れるか否かを決定するために使われるものである。したがってマニフェストは積載されているすべてのものを記さねばならないし、そこに記されてないものに対していかなる権利も主張できない。もちろん外交上の意図をもってそこにあるべきものを書いたり書かなかったりすることがある。 ![](https://i.imgur.com/RDAzUzQ.png) ではウェブアプリのマニフェストファイルの中身を見てみよう。マニフェストファイルはみんな大好きJSONで記述される。 ```json= { "lang": "ja", "short_name": "hks", "name": "hello-kbc-sasaki", "icons": [ { "src": "https://cdn.glitch.com/project-avatar/8cd4fd9a-3eae-4063-abfb-5b60a878c846.png", "type": "image/png", "sizes": "200x200" } ], "start_url": "https://hello-kbc-sasaki.glitch.me/", "display": "standalone", "theme_color": "#2196F3", "background_color": "#2196F3", "orientation": "portrait" } ``` ![](https://i.imgur.com/RqMLYin.png) マニフェストが認識されていることは、F12で開くChrome Dev Toolsで確認できる。 ![](https://i.imgur.com/xKzq97t.png) # PWAと関係なくマニフェストは書く マニフェストとはそれだけを見れば総体について把握できるべきものである。 PWA的であることを意図しているか否かにかかわらず、マニフェストは書いてよいものであるし、書いた方が良い。それによりHTML文書がウェブアプリケーションの構成要素として自己記述的になるのである。 HTMLを単独で見れば、それがどのURLで指し示されていたものか、それがどのようなウェブサイトの一部を構成しているものか、などといった記述は含まれていない。すなわちHTML文書をウェブアプリケーションの構成要素として捉えると、それが外部からどのように取り扱われることを意図しているかについて、自己記述的ではない。ここで述べているのは`meta`要素で示される当該文書自身のメタデータや`link`要素などにより示される文書間の関係のことではなく、あくまでウェブアプリケーションとしての扱われ方について自己記述的でないということである。 # サービスワーカー サービスワーカーは[JavaScriptワーカー](https://www.html5rocks.com/en/tutorials/workers/basics/)の一種で、ページ内で動くコードとは独立の実行コンテキストを持つ。 >Service Worker はブラウザが Web ページとは別にバックグラウンドで実行するスクリプトで、Web ページやユーザーのインタラクションを必要としない機能を Web にもたらします。 既に現在、プッシュ通知やバックグラウンド同期が提供されています。 さらに将来は定期的な同期、ジオフェンシングなども導入されるでしょう。 このチュートリアルで説明する機能は、ネットワーク リクエストへの介入や処理機能と、レスポンスのキャッシュをプログラムから操作できる機能です。 > >この API にとてもわくわくするのは、それがオフライン体験をサポートし、そして開発者がその体験を完全に制御できるからです。 > >Service Worker 以前にも、オフライン体験を Web にもたらすものとして AppCacheというものがありました。 AppCache API にはいくつもの問題があり、Service Worker はこれらの弱点を避けるように設計されています。 > [Service Worker の紹介](https://developers.google.com/web/fundamentals/primers/service-workers?hl=ja) * [サービスワーカー API](https://developer.mozilla.org/ja/docs/Web/API/Service_Worker_API) * [Service worker の使用](https://developer.mozilla.org/ja/docs/Web/API/Service_Worker_API/Using_Service_Workers) * [ウェブアプリへの Service Worker とオフラインの追加](https://developers.google.com/web/fundamentals/codelabs/offline) # サービスワーカーへのアクセス 閲覧のウェブページで動くJavaScript環境におけるグローバルオブジェクトは`window`である。Nagivatorオブジェクトは`window.nagivator`でアクセスできる。さらにサービスワーカーは`window.navigator.serviceWorker`でアクセスできる。すべてのブラウザにおいてサービスワーカーを使えるわけではないので、`serviceWorker`の存在を確認する必要があるのだが、ここでは省略する。 ```javascript= window.addEventListener("load", function() { window.navigator.serviceWorker.ready.then(registration => { console.log("Service worker is ready."); }); window.navigator.serviceWorker.register("/sw.js"); }); ``` サービスワーカーに対して`/sw.js`という相対URLで示されるJavaScriptコードを実行せよ、と指示している。あとはこの`/sw.js`に何を書くかということである。 サービスワーカーが動いていることはDev Toolsで確認できる。 ![](https://i.imgur.com/I0eVRad.png) # サービスワーカーで動かすコード 初めてサービスワーカーが登録された時に発火する`install` イベントと、ウェブページがデータを要求した時に発火する`fetch`イベントに対する処理を実装する。古いキャッシュを削除したければ`activate`イベントに対する処理で行う。詳細は[hello-kbc-sasaki](https://glitch.com/edit/#!/hello-kbc-sasaki)の`sw.js`を参照。 ## `install` イベントに対する処理 ```javascript= self.addEventListener("install", event => { console.log("'install' event is fired."); event.waitUntil( caches .open("hello-kbc-sasaki-cache-20200128.0") .then(function(cache) { //cache は指定したキャッシュ名に対応するCacheオブジェクト return cache.addAll([ "/manifest.json", "/script.js", "/styloe.css", "/index.html", "/sw.js" ]); }) .then(() => self.skipWaiting()) .catch(function(e) {}) ); }); ``` 実のところ、このコードではオフライン対応として不完全である。なぜならこのウェブアプリは当該ドメイン hello-kbc-sasaki.glitch.me 以外にも4つのURLからのデータを必要とするからである。 * https://cdnjs.cloudflare.com/ajax/libs/mini.css/3.0.1/mini-default.min.css" * https://www.gstatic.com/firebasejs/7.6.2/firebase-app.js * https://www.gstatic.com/firebasejs/7.6.2/firebase-auth.js * https://www.gstatic.com/firebasejs/7.6.2/firebase-database.js ウェブワーカーは自分の[オリジン](https://developer.mozilla.org/ja/docs/Glossary/Origin)以外のデータに介入することはできないため、上記4つのファイルもコピーしてキャッシュに含めるべきである。(ライブコーディング) ## `fetch` イベントに対する処理 ```javascript= self.addEventListener("fetch", event => { // abandon non-GET requests if (event.request.method !== "GET") return; let url = event.request.url; event.respondWith( caches.open("hello-kbc-sasaki-cache-20200128.0").then( //cache は指定したキャッシュ名に対応するChcheオブジェクト cache => { return cache.match(event.request).then(response => { if (response) { //キャッシュからの取り出しに成功した console.log("cache hit for " + url); return response; } // キャッシュからの取り出しに失敗した console.log("cache miss for " + url); return ( // キャッシュに無いのでfetchするしかない fetch(event.request) .then(newreq => { console.log("network fetch: " + url); if (newreq.ok) cache.put(event.request, newreq.clone()); return newreq; }) // キャッシュにもないしfetchもできなかった .catch(() => { //オフライン用のレスポンスを生成して返す offlineResponse(url); }) ); }); } ) ); }); function offlineResponse(url) { //URLを見てオフライン用の画像やテキストを生成して返す。 //ここではどんなURLに対してもただのプレーンテキストを返している。 console.log(url); return new Response("offline", { headers: { "Content-Type": "text/plain", "Cache-Control": "no-store" } }); } //offlineResponse ``` サービスワーカーからコンテンツが供給されていることはDev Toolsで確認できる。 ![](https://i.imgur.com/T9WuYK2.png) # mini-default.css は? firebase-app.js は? ![](https://i.imgur.com/brEM2s6.png) # fetch の利点 Fetch APIはCORS未対応のWebサーバからコンテンツを取得できる。ただしReponseオブジェクトでラップすることによりウェブページ側のコードから従来の手段ではコンテンツにアクセスできないという制限をかけている。 # 古いキャッシュの削除 ## `activate` イベントに対する処理 ```javascript= //古いキャッシュを破棄する処理 self.addEventListener("activate", event => { console.log("'activate' event is fired."); event.waitUntil( caches .keys() .then(function(cacheNames) { return Promise.all( cacheNames.map(function(cacheName) { if (["hello-kbc-sasaki-cache-20200128.0"].indexOf(cacheName) >= 0) return caches.delete(cacheName); }) ); }) .catch(function(e) { console.log(e); }) ); }); ```