1. What?
A new way to deliver amazing user experiences on the web.
Webのユーザー体験を向上させるための手法です。Web本来の(利便性の高い)体験に加え、3つの特徴があります:Reliable(信頼性)、Fast(高速)、Engaging(魅力的)。
- 信頼性:不安定なネットワーク環境下でも即座に読み込まれ、(オフラインになっても)突然「遠い昔のインターネット」に戻ってしまうようなことがありません。
信頼性とはオフラインキャッシュを指します。オフライン時にはキャッシュを利用し、オフラインシーンでも利用可能であることを保証します。service worker と cache API を組み合わせてキャッシュ・プロキシメカニズムを構築します。
- 高速:滑らかなアニメーションで即座にインタラクションに反応し、スクロールのカクつき(ドロップフレーム)がありません。
高速とは、あくまでインタラクションのフィードバックが「速く感じる」ことを強調しており、推奨されている Material Design と関連しています。純粋な速度面での優位性があるわけではありません(少なくとも初回の読み込みに関しては)。
一方で、キャッシュ・プロキシメカニズムのおかげで、再訪問時にはローカルキャッシュを利用するため非常に高速になります。
- Nativeライク:デバイスのネイティブアプリのように、没入感のあるユーザー体験(すなわちフルスクリーン)を提供します。
フルスクリーンの他に、ホーム画面のアイコン(Web Appをホーム画面に常駐させる)やシステム通知(リエンゲージメントの能力)があり、これらは Web App Manifest の設定を通じて実現され、ユーザーの環境サポートに依存します。
P.S. Engagingという抽象的な形容詞は翻訳が難しいですが、ここでは実際の意味である「Nativeライク」としておきます。
したがって、表面上、PWAのハイライトは2つの部分に分かれます:
-
(オフライン)キャッシュ・プロキシメカニズム
-
フルスクリーン、ホーム画面アイコン、システム通知などのNativeライクな機能
キャッシュメカニズム自体はWeb AppやSPAにおいて目新しくありません。データ層を切り離した後は、キャッシュ処理はついでに行われます。しかし、PWAのキャッシュメカニズムは静的リソースのキャッシュに重点を置いているのに対し、Web App/SPAのキャッシュ層は動的コンテンツのキャッシュ(前回の内容が期限切れでなければ、動的な部分を再取得せずに直接クライアント側でレンダリングする)によく使われます。
フルスクリーン、ホーム画面アイコン、システム通知などのNativeライクな機能については、漸進的エンハンスメント(Progressive Enhancement)における*エンハンス(強化)*にあたります。これらはサポートされているユーザー環境で利用可能です(一部のブラウザはサポートしていますが、より広範なWebView環境では近い将来でもまだ難しいかもしれません)。しかし、これはWebが漸進的エンハンスメントの形でPC時代から抜け出し、モバイル化へと向かっていることを示しています。
2. 試してみる
依存環境
- HTTPS
サービス元が安全である必要があるため、HTTPS環境が求められます。Webの情報セキュリティへの配慮に加え、HTTPSの普及を推進したいという思惑もあります。Web技術発展のための不可欠なインフラとして、カメラ、録音、プッシュAPIなどの新機能にはユーザーの許可が必要であり、HTTPSはその権限ワークフローの鍵となる部分で、欠かすことができません。
P.S. permission.site では、HTTPS環境とHTTP環境におけるユーザーの権限取得の違いを体験できます。
Nativeライクな強化
Web App Manifest 設定ファイルを導入することで、Nativeライクな強化を実現します。PWAをサポートするブラウザで有効になります(サポートされていない環境では、単に追加でJSONファイルを1つリクエストするだけという結果になります):
<link rel="manifest" href="./manifest.json">
注意点として、よく似たものに Application Cache (HTML5の機能、現在は非推奨)がありますが、導入方法が異なります:
<html manifest="example.appcache">
...
</html>
導入方法が異なるため、Web App Manifest と Application Cache は無関係であり、過去のしがらみを気にする必要はありません。
P.S. Application Cache はSPAとの相性は良いですが、マルチページアプリケーションには適さず、 多くの問題が存在します 。ここでは詳しく紹介しません。
ホーム画面アイコン
Web App Manifest の内容例は以下の通りです:
{
"short_name": "ホーム画面に表示されるアプリ名",
"name": "インストールバナーに表示されるアプリ名",
"icons": [
{
"src": "launcher-icon-1x.png",
"type": "image/png",
"sizes": "48x48",
"density": "1.0"
},
{
"src": "launcher-icon-2x.png",
"type": "image/png",
"sizes": "96x96",
"density": "1.0"
},
{
"src": "launcher-icon-4x.png",
"type": "image/png",
"sizes": "192x192",
"density": "1.0"
}
],
"start_url": "index.html?launcher=true"
}
P.S. インストールバナーとは、権限取得のようなポップアップパネルのことで、ユーザーはホーム画面に追加するかキャンセルするかを選択できます。一定の条件を満たすと、Chromeは自動的にインストールバナーを表示します。詳細は Web App Install Banners を参照してください。
これにより、理想的な状況下ではホーム画面アイコンを持つことができます。Web App Manifest をサポートする環境は、最も適切な(48dpに最も近い)アイコンを選択します。
注意: index.html の内容は、初回レンダリングに必要な最小限の内容であるべきです。初回の即時読み込���効果を得るために、ローディング表示やデフォルトのプレースホルダー画像を含むページフレームワークを App Shell として表示することができます。また、瞬時に利用可能な初回読み込みパフォーマンスを実現するために、Web App で用いられる他の一般的なパフォーマンス最適化手法も PWA で推奨されています。例えば、 データの直接出力(サーバーサイドでの流し込み) などです。冒頭で述べた通り、PWAには生まれつきの(初回読み込み時の)パフォーマンス上の優位性はないため、Web App に適した一般的な最適化手法は依然として必要です。
スプラッシュ画面(Splash)
ホーム画面のアイコンから起動した際、カスタマイズ可能な起動画面に表示される内容は、タイトル、背景色、画像などです。新しい設定項目は以下の通りです:
// 背景色
"background_color": "#2196F3",
// ツールバーを含むテーマカラー
"theme_color": "#2196F3",
画像は icons の中から128dpに最も近いものがスプラッシュ画面として選ばれます。 アニメーションGIFはサポートされていません 。
また、表示モードや画面の向きも指定できます:
// フルスクリーン(ブラウザのUIを隠す)
"display": "standalone",
// ブラウザの外枠を表示し、ブックマークを開くように表示する
"display": "browser",
// 横向き
"orientation": "landscape"
P.S. スプラッシュ画面の例や詳細については、 Adding a Splash Screen for Installed Web Apps in Chrome 47 を参照してください。
特に注意: manifest.json ファイルが更新されても、ユーザーがホーム画面にアプリを再度追加しない限り、それらの変更は自動的には反映されません。
システム通知
Web App Manifest とは無関係で、 Push API に依存します。簡単な例は以下の通りです:
// service-worker.js
self.addEventListener("push", function (event) {
event.waitUntil(
self.self.registration.showNotification("新しい記事が公開されました", {
body: "新しい記事が公開されました。クリックして確認してください。"
})
);
});
ここでは詳しく紹介しません(現在(2017/12/15)のところ、この機能は存在しない と考えても良いくらいです)。APIは仕様で定義されていますが、統一されたプッシュプロトコルが規定されていないため、ブラウザごとにプッシュメカニズムが異なります。例えば、Chromeの GCM は、特定の地域では利用できません。
Push APIの詳細については、 【Service Worker】消息推送功能“全军覆没” を参照してください。
キャッシュ・プロキシ
キャッシュはいくつかの部分に分かれます:
-
初回読み込み用の静的リソースキャッシュ(プリキャッシュ)
-
訪問済みリソースのキャッシュ(実行時キャッシュ)
-
動的コンテンツのキャッシュ(実行時キャッシュ)
キャッシュは純粋なデータ操作(永続化を含む)ですが、 service worker はバックグラウンドで動作できるため、このようなページやインタラクションに関係のない処理を行うのに非常に適しています。そのため、service worker は Cache API、Push API と良きパートナーになります。ただし、service worker 自体も「強化」項目として捉えるべきであり、service worker をサポートしていない環境ではキャッシュメカニズムをスキップして、基本的なページ体験を保証する必要があります。簡単な特徴検知のコードは以下の通りです:
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('./service-worker.js')
.then(function() { console.log('Service Worker Registered'); });
}
service worker は install イベントハンドラにおいて、App Shell を含む初回読み込み用の静的リソースのキャッシュを完了させます:
// service-worker.js
var cacheName = 'weatherPWA-step-6-1';
var filesToCache = [
// エントリURL
'/',
'/index.html',
'/scripts/app.js',
'/styles/inline.css',
// App Shellに必要なリソース
'/images/ic_add_white_24px.svg',
'/images/ic_refresh_white_24px.svg',
// コンテンツ表示に使われる可能性のあるリソース
'/images/clear.png',
'/images/cloudy-scattered-showers.png',
'/images/cloudy.png',
'/images/fog.png',
'/images/partly-cloudy.png',
'/images/rain.png',
'/images/scattered-showers.png',
'/images/sleet.png',
'/images/snow.png',
'/images/thunderstorm.png',
'/images/wind.png'
];
self.addEventListener('install', function(e) {
console.log('[ServiceWorker] Install');
e.waitUntil(
caches.open(cacheName).then(function(cache) {
console.log('[ServiceWorker] Caching app shell');
//! 一つでも失敗すると次へは進みません
return cache.addAll(filesToCache);
})
);
});
もちろん、キャッシュには基本的なバージョニング管理が必要です:
// service-worker.js
self.addEventListener('activate', function(e) {
console.log('[ServiceWorker] Activate');
e.waitUntil(
caches.keys().then(function(keyList) {
return Promise.all(keyList.map(function(key) {
// cacheNameをキャッシュキーとし、古いキャッシュが存在すれば削除する
if (key !== cacheName) {
console.log('[ServiceWorker] Removing old cache', key);
return caches.delete(key);
}
}));
})
);
// service workerを即座にアクティブにし、エッジケースを回避する
return self.clients.claim();
});
P.S. エッジケースとは、特定の状況下で service worker がすぐにアクティブ状態に復帰できず、キャッシュが効かなくなることを指します。これらのエッジケースを排除するために、 GoogleChromeLabs/sw-precache を使用してキャッシュ制御(期限切れ、更新戦略など)を管理することをお勧めします。
キャッシュができたら、次はプロキシ部分を実装します。リクエストをインターセプトし、キャッシュの内容をレスポンスとして返します:
// service-worker.js
// リクエストをイ���ターセプト
self.addEventListener('fetch', function(event) {
console.log('[ServiceWorker] Fetch', e.request.url);
// レスポンス内容をカスタマイズ
e.respondWith(
// キャッシュを探し、なければリクエストする
caches.match(e.request).then(function(response) {
return response || fetch(e.request);
})
);
});
これで基本的なキャッシュ・プロキシメカニズムの準備が整いました。以下のことを行いました:
-
リソースリストに従って静的リソースを事前にキャッシュする
-
リクエストをインターセプトする
-
キャッシュの内容をレスポンスとして返す
3つの注意事項があります:
-
ブラウザのキャッシュがキャッシュの更新に影響を与える可能性があるため、
installイベントハンドラ内でのリクエストはキャッシュを経由せず、直接ネットワークにアクセスします。 -
service worker を登録解除してもキャッシュはクリアされません。キャッシュキーが変わらなければ、その後も古いキャッシュ内容が取得されます。
-
デフォルトでは、新しく登録された service worker はページが再読み込みされた後にのみ有効になります( 特殊な処理 を行わない限り)。
また、このシンプルな実装にはいくつか課題があります。例えば:
-
キャッシュのバージョン管理が静的なキャッシュキーに依存しており、
service-worker.jsを更新するたびにこのキーを修正する必要があります。 -
一度キャッシュキーが変わると、すべてのキャッシュが消去され、リクエストがやり直されるため、静的リソースにとっては無駄が多いです。
-
実行時キャッシュがないため、リソースリストが硬直的です。アクセスしながらキャッシュするより強力な仕組みが望まれます。
1番目の問題については良い解決策がありませんが、2番目の問題はリソースタイプを細分化することで緩和できます。例:
// 特定のバージョンのキャッシュにマッピングされた短縮識別子
var CURRENT_CACHES = {
font: 'font-cache-v' + FONT_CACHE_VERSION,
css: 'css-cache-v' + CSS_CACHE_VERSION,
img: 'img-cache-v' + IMG_CACHE_VERSION
};
より細粒度のバージョニング管理により、キャッシュの強制更新コストをある程度下げることができます。もちろん、キャッシュ層の下には HTTP Cache が控えているため、キャッシュ更新のコストはそれほどクリティカルではありません。
実行時キャッシュについては、実際には最後にもう一歩踏み出すだけです:
- キャッシュにヒットしなければ、リソースをリクエスト*し、かつキャッシュする*
具体的には以下の通りです:
// キャッシュを探し、なければリクエストする
caches.match(e.request).then(function(response) {
return response || fetch(e.request).then(function(res) {
return caches.open(dataCacheName).then(function(cache) {
// かつ、キャッシュに保存する
cache.put(e.request.url, res.clone());
return res;
)
})
})
また、リソースタイプやシナリオの要求に応じて、適切なキャッシュ戦略を使い分けることもできます。例:
// service-worker.js
self.addEventListener('fetch', function(e) {
console.log('[Service Worker] Fetch', e.request.url);
var dataUrl = 'https://cache.domain.com/fresh/';
// 戦略1:リアルタイム性が求められるリソースはリクエストを優先する (fetch then cache)
if (e.request.url.indexOf(dataUrl) > -1) {
e.respondWith(
caches.open(dataCacheName).then(function(cache) {
return fetch(e.request).then(function(response){
cache.put(e.request.url, response.clone());
return response;
});
})
);
} else {
// 戦略2:一般的なリソースはキャッシュを優先する (cache falling back to fetch)
}
});
P.S. その他のキャッシュ戦略については、参考資料セクションを参照してください。
3. Demo
公式デモ: Weather PWA (正常にアクセスできない場合があります)。
ミラーデモ(公式デモを github pages に移動): https://ayqy.github.io/pwa/demo/weather-pwa/index.html
P.S. github pages は、実験場として非常に適しています。安定したHTTPSが提供され、コンテンツ公開に制限がなく自由にいじることができます。今後のブログのデモは徐々にこちらへ移行していく予定です(これまでは自分のFTPに置いていましたが、あれは本当に愚かでしたね…)。
[caption id="attachment_1610" align="alignnone" width="169"]
weather-pwa[/caption]
あまり楽観的ではないニュース:実際、入念に環境(公式の正規版Chrome + 公式デモ)を整えて Xiaomi 4 で試しましたが、インストールバナーは自動的に表示されませんでした(操作方法などの条件が満たされていなかったのかもしれません。上記参照)。手動で「ホーム画面に追加」をクリックし、トースト通知では追加成功と出ましたが、ホーム画面には何も現れませんでした……これが、自作デモを動かしてみる気になれない理由です(まあ、主な理由は面倒だからですが ;))。
4. 事例
-
餓了麼(Ele.me):不思議ですね、なぜキャッシュの効果を感じられないのでしょうか。
注意:シークレットモードでは、阿里巴巴国際サイトの service worker が以下のエラーを投げる場合があります:
Uncaught (in promise) DOMException: Quota exceeded.
通常の環境であれば正常に体験できます。
P.S. その他の事例については、 Case Studies | Web | Google | Developers を参照してください。
5. 応用シーン
端的に言えば、PWAは Web App のアップグレード版であり、主なハイライトは Nativeライクなサポートです。漸進的エンハンスメントの形をとることで、多大なコストをかけずに Web App から PWA への「アップグレード」を完了でき、一部のユーザー(PWAをサポートする環境)に対して、より速く(キャッシュ)、より便利に(ホーム画面アイコン)、Nativeライクな体験(フルスクリーン)を提供できます。
具体的な応用シーンは以下の通りです:
-
キャッシュが明確な利益をもたらす Web App
-
オフライン能力、Nativeライクな体験、あるいは単純にホーム画面アイコンが欲しい Web アプリ
-
最新技術のトレンドに乗りたい、あるいはその発展を後押ししたい Web アプリや ブラウザベンダー
応用シーンはさておき、zxx氏のキャッシュ(あるいはworker?)に関する記事にもありましたが、これほどわずかなコストでページにオフライン能力を持たせ、キャッシュの恩恵をはっきりと受けられるのであれば、やらない手はないでしょう。
また、Angular、React、Vue などの主要なフレームワークも PWA のスキャフォールディングを提供しています。詳細は The Ultimate Guide to Progressive Web Applications を参照してください。
参考資料
-
The offline cookbook :キャッシュ戦略の図解。素晴らしい資料です。 ServiceWorker Cookbook と併せて読むことをお勧めします。
-
Your First Progressive Web App :公式チュートリアル。
-
A Beginner's Guide To Progressive Web Apps :包括的な入門ガイド。
-
改造你的网站,变身PWA :原文 Retrofit Your Website as a Progressive Web App
コメントはまだありません