
ここ最近はプログレッシブWebアプリ(Progressive Web Apps、PWA)の話題をよく耳にするとともに、これが未来のWebなのかどうかが議論の的になっています。ネイティブアプリ vs PWA論争に加わるつもりはありませんが、PWAがモバイルデバイス対応を強化し、ユーザーエクスペリエンスを向上することだけは確かです。2018年までにはモバイルからのアクセス数がほかのすべてのデバイスからのアクセス数を上回ると予測されるなかで、どうしてこの流れを無視できるのでしょうか。
良いニュースは、PWAの作成は難しくないということです。実際のところ既存のサイトをPWA化することも可能です。これこそ本記事で紹介することです。この記事を読み終えるころには、ネイティブアプリのように扱えるWebサイトが手に入り、オフラインでも動作し、ホーム画面にはアプリアイコンを置けるのです。
PWAとは?
PWAは、Web技術における画期的なイノベーションです。Webアプリをモバイルデバイスでネイティブアプリのように動作させるための技術から成り立っているのがPWAです。開発者とユーザーにもたらす数々の恩恵のなかには、Webだけ、あるいはネイティブアプリだけといった、それぞれ特有の制約を解消してくれます。
- W3C標準のWeb技術を使って1つのアプリだけ開発すればよく、特別にネイティブアプリ用のコードを書く必要がない
- ユーザーはインストール前にアプリを試せる
- AppStoreを通す必要はなく、不可解なルールにしたがったり、手数料を払う必要がない。ユーザーの操作とは無関係に自動でアプリのアップデートができる
- ユーザーに「インストール」を促してホーム画面にアイコンを置ける
- 起動時に魅力的なスプラッシュスクリーン表示ができる
- 全画面モードにしたい場合も、Chromeのオプション変更で実現できる
- 必要なファイルをローカル保存することで、PWAは通常のWebアプリよりも、むしろネイティブアプリ以上に高速に応答できる
- 軽量なインストール容量ですむ。たいていは数百キロバイトのキャッシュデータのみ
- すべてのデータ通信が、安全なHTTPS接続で実行できる
- PWAはオフラインでも動作し、接続が回復したときにデータを同期できる
現在はまだ初期段階ですが、これまでの事例では好感触です。インドで最大のEコマースサイトFlipkartではネイティブアプリを廃止してPWAにしたところコンバージョン率が70%上昇し、サイト滞在時間が3倍になりました。世界最大の商業プラットホームAlibabaでも同じようにコンバージョン率が76%上昇しました。
Firefox、Chrome、ほかのBlinkベースのブラウザーはPWA技術にしっかり対応しています。現在、マイクロソフトもEdgeでの対応に取り組んでいます。アップルはWebKitの5カ年計画で前向きなコメントがあったものの、まだ沈黙を保ったままです。幸運なことに、ブラウザー側の対応はさほど重要ではないことです。
PWA化はプログレッシブな進化
製作したPWAアプリは、PWA技術に非対応のブラウザーでも動作します。オフライン対応の恩恵は受けられないものの、全機能がPWA対応ブラウザー同様に使えます。コストパフォーマンスを考えれば、自分のサイトをPWA化しない理由はほとんどありません。
PWAはただのアプリではない
グーグルはPWAへの移行を推し進める立場なので、多くの記事はChromeベースでネイティブアプリ風のPWAをゼロから製作する方法を説明しています。しかし、単一ページアプリをわざわざ作ったり、マテリアルデザインのガイドラインに合わせる必要はありません。たいていのWebサイトは数時間あればPWA化できるのです。サイトがWordPressでも静的サイトでも同じです。ちょうど本記事の執筆中にも、Smashing MagazineがPWAへの移行を発表しました!
デモ用アプリのコード
デモ用アプリのコードはここから入手してください。
デモは単純な4ページの画像付きWebサイトで、スタイルシートのファイルが1つとメインのJavaScriptファイルが1つあります。サイトはすべてのモダンブラウザー(IE10以上)で動作します。PWAに対応したブラウザーならばオフライン状態でも以前アクセスしたページを閲覧できます。
コードを実行するにはNode.jsがインストールされた状態で、コマンドターミナルからWebサーバーを起動します。
node ./server.js [port]
このときの[port]は任意で、初期値は8888です。Chrome、Opera、VivaldiといったBlinkエンジンベースのブラウザーを開き「http://localhost:8888/(もしくは自分が指定したポート)」を開きます。また、F12キーもしくはCmd/Ctrl + Shift + Iキーでデベロッパーツールを開けばコンソールメッセージが見られます。
ホームページを開いて別のページにもアクセスしたあとで、以下のどちらかの方法でオフラインに移行します。
- Cmd/Ctrl + CキーでWebサーバーを停止
- NetworkもしくはApplicationにあるOfflineにチェックを入れる(デベロッパーツールのService Workersタブ)
この状態で以前アクセスしたページに再度アクセスすると、きちんと読み込めるはずです。アクセスしたことのないページを開くと「you’re offline」と表示され、表示可能なページのリストが示されます。
デバイスを接続する
USB経由でAndroidスマートフォンをPC/Macに接続すれば、Androidでデモページを見られます。3点メニューにあるMore toolsから、Remote deviceパネルを開きます。
Settingsを選択したら、Port forwardingのところのAdd Ruleをクリックして、ポート8888をlocalhost:8888へマッピングします。これでスマートフォンのChromeから「http://localhost:8888/」を開けます。
ブラウザーメニューから「ホーム画面に追加(Add to Home screen)」ができます。あるいは数回ページを訪れたらブラウザーがインストールを促してきます。どちらの方法でもホーム画面にアイコンが作られます。数ページを見たあとでChromeを閉じ、インターネット接続を切ってください。アイコンからPWAのWebアプリを立ち上げればスプラッシュスクリーンが表示され、サーバーに接続していなくても以前訪れたページが閲覧できるはずです。
自分のサイトをPWA化するためには、3つの大事なステップがあります。
Step 1:HTTPSを有効にする
あとで説明しますが、PWAにはHTTPS接続が必要です。価格と手順はホストによって異なりますが、Google検索ランキングが安全なサイトを上位表示することまでも考慮すれば、出費と手間に見合うだけの価値はあります。
Chromeではテスト用にローカルホストあるいは127.x.x.xのアドレスの使用を許しているため、デモではHTTPSが不要です。また以下のコマンドラインフラッグを付けてChromeを起動すれば、HTTPサイトのままでPWAをテストできます。
- --user-data-dir
- --unsafety-treat-insecure-origin-as-secure
Step 2:Webアプリのマニフェストを作る
Webアプリのマニフェストは、アプリ名称、説明、OSがホーム画面アイコンに使う画像、スプラッシュ画面、viewportなどのアプリの情報を提供するファイルです。つまりマニフェストファイルは、既存Webページで用意したような数々のベンダー別アイコンやテーマのメタタグに代わるものと考えれば良いでしょう。
マニフェストはアプリのルートに置くJSON形式のテキストファイルです。HTTPヘッダーはContent-Type:application/manifest+jsonもしくはContent-Type: application/jsonとする必要があります。ファイル名は自由ですがデモ用コードでは/manifest.jsonと名付けています。
{
"name" : "PWA Website",
"short_name" : "PWA",
"description" : "An example PWA website",
"start_url" : "/",
"display" : "standalone",
"orientation" : "any",
"background_color" : "#ACE",
"theme_color" : "#ACE",
"icons": [
{
"src" : "/images/logo/logo072.png",
"sizes" : "72x72",
"type" : "image/png"
},
{
"src" : "/images/logo/logo152.png",
"sizes" : "152x152",
"type" : "image/png"
},
{
"src" : "/images/logo/logo192.png",
"sizes" : "192x192",
"type" : "image/png"
},
{
"src" : "/images/logo/logo256.png",
"sizes" : "256x256",
"type" : "image/png"
},
{
"src" : "/images/logo/logo512.png",
"sizes" : "512x512",
"type" : "image/png"
}
]
}
すべてのページの<head>内に、このファイルへのリンクが必要です。
<link rel="manifest" href="/manifest.json">
マニフェストの主なプロパティは、以下の通りです。
- name:ユーザーに対して表示されるアプリのフルネーム
- short_name:フルネームが表示できない大きさの場合に表示する短い名前
- description:アプリの説明
- start_url:アプリ開始場所の相対URL(通常は/)
- scope:ナビゲーションのスコープ(領域)。たとえばスコープが/app/ならアプリの遷移をこのフォルダー内に制限する
- background-color:スプラッシュスクリーンおよびChromeブラウザー(要求時)に使われる背景色を指定
- theme_color:アプリの表示に影響するアプリの色指定。通常は背景色に合わせる
- orientation:優先する表示方向(画面の向き)を次から選択:any、natural、landscape、landscape-primary、landscape-secondary、portrait、portrait-primary、portrait-secondary
- display:優先するビューを指定:fullscreen(ChromeのUIを表示しない全画面)、standalone(ネイティブアプリ風になる)、minimal-ui(最小限のUI部品のみ)、browser(通常のブラウザータブ)
- icons:src URL、sizes、typeを指定したアイコン画像のオブジェクトを入れた配列。一連のアイコン画像を定義する
MDNではWebアプリマニフェストプロパティの全リストを公開しています。
ChromeデベロッパーツールのApplicationタブにあるManifest欄ではマニフェストのJSONファイルが有効か無効かを確認でき、デスクトップ機で使える「ホーム画面に追加(Add to homescreen)」のリンクも提供しています。
Step 3:Service Workersを作る
Service Workersは、ネットワークリクエストをインターセプト(横取り)して応答ができる、プログラミング可能なプロキシです。その実態はアプリのルートディレクトリに置かれたJavaScriptファイルです。
サイトのJavaScript(デモ用コード中では/js/main.js)はService Workersの有無をチェックし、ファイルを登録します。
if ('serviceWorker' in navigator) {
// register service worker
navigator.serviceWorker.register('/service-worker.js');
}
もしオフライン対応しないアプリで良いなら、空の/service-worker.jsファイルを作るだけです。ユーザーにアプリをインストールしてもらいましょう!
Service Workersと聞いて戸惑う開発者もいるかもしれませんが、デモのコードを自分の用途に合わせて変えてください。デモは標準的なService Workersのスクリプトであり、可能ならブラウザーはこれをダウンロードして別々のスレッドで走らせます。Service WorkersはDOMやAPIへのアクセスはできませんが、ページの遷移、リソースのダウンロード、Ajaxのコールによって発生するネットワークリクエストをインターセプトできます。
実はこれこそが、サイトがHTTPSでなければならない一番の理由です。想像してください。別ドメインから第三者のスクリプトが自身のService Workersを注入できたらどうなるでしょう。クライアントとサーバー間でやりとりするすべてのデータが覗けるうえに、変更できてしまいます!
Service Workersは3つの主要イベント、install、activate、fetchに応答します。
Installイベント
アプリケーションがインストールされた際に発生します。通常、installイベントはCache APIで必要なファイルをキャッシュするのに使われます。
最初に設定変数を定義します。
- キャッシュ名(CACHE)とバージョン(version)。アプリはキャッシュを複数持てるがここでは1つしか使わない。バージョン番号が使えるので、アプリに大きな変更を加えたら新しくデータをキャッシュして古いキャッシュファイルを無視できる
- オフラインページURL(offlineURL)。ユーザーがオフライン状態のとき、アクセスしていないページを読み込もうとした際に表示されるページ
- サイトがオフラインで動作するために必要なファイルを入れた配列(installFilesEssential)。ここにはCSSやJavaScriptのリソースが含まれるが、加えてホームページ(/)とロゴも含めた。もしURLが/と/index.htmlのように複数の方法で表されるなら、そのバリエーションも考慮する必要がある。この配列にはofflineURLも含まれる
- オプションで希望するファイルを入れた配列(installFilesDesirable)。これらのファイルは可能であればダウンロードされる。エラーで失敗するとインストールされない
// configuration
const
version = '1.0.0',
CACHE = version + '::PWAsite',
offlineURL = '/offline/',
installFilesEssential = [
'/',
'/manifest.json',
'/css/styles.css',
'/js/main.js',
'/js/offlinepage.js',
'/images/logo/logo152.png'
].concat(offlineURL),
installFilesDesirable = [
'/favicon.ico',
'/images/logo/logo016.png',
'/images/hero/power-pv.jpg',
'/images/hero/power-lo.jpg',
'/images/hero/power-hi.jpg'
];
installStaticFiles()関数は、プロミスベースのCache APIを使ってキャッシュにファイルを保存します。必要なファイルがキャッシュされたときにのみ戻り値が返ります。
// install static assets
function installStaticFiles() {
return caches.open(CACHE)
.then(cache => {
// cache desirable files
cache.addAll(installFilesDesirable);
// cache essential files
return cache.addAll(installFilesEssential);
});
}
最後にinstallイベントリスナーを加えます。waitUntilメソッドは、含まれるコードがすべて実行されるまでService Workersがインストールを始めないようにする役割です。そしてinstallStaticFiles()を走らせ、次にself.skipWaiting()でService Workersを有効化します。
// application installation
self.addEventListener('install', event => {
console.log('service worker: install');
// cache core files
event.waitUntil(
installStaticFiles()
.then(() => self.skipWaiting())
);
});
Activateイベント
Activateイベントはインストール直後あるいは復帰時の、Service Workersが有効化されたタイミングで発生します。イベントハンドラは使わなくてよい場合もありますが、デモでは古いキャッシュがある場合にそれを削除するため、このイベントを使用しています。
// clear old caches
function clearOldCaches() {
return caches.keys()
.then(keylist => {
return Promise.all(
keylist
.filter(key => key !== CACHE)
.map(key => caches.delete(key))
);
});
}
// application activated
self.addEventListener('activate', event => {
console.log('service worker: activate');
// delete old caches
event.waitUntil(
clearOldCaches()
.then(() => self.clients.claim())
);
});
最後のself.clients.claim()を呼ぶことで、Service Workersがこのサイトの有効なワーカーとしてセットされます。
Fetchイベント
Fetchイベントはネットワークリクエストの際に発生します。respondWith()メソッドでGETリクエストを横取りして、以下を返します。
- キャッシュされたリソースを返す
- もし上の1を失敗したら、Fetch API(Service Workersのfetchイベントとは無関係)でネットワークからリソースを読み込みキャッシュに保存する
- もし上の1と2の両方が失敗したら、それを示すレスポンスを返す
// application fetch network data
self.addEventListener('fetch', event => {
// abandon non-GET requests
if (event.request.method !== 'GET') return;
let url = event.request.url;
event.respondWith(
caches.open(CACHE)
.then(cache => {
return cache.match(event.request)
.then(response => {
if (response) {
// return cached file
console.log('cache fetch: ' + url);
return response;
}
// make network request
return fetch(event.request)
.then(newreq => {
console.log('network fetch: ' + url);
if (newreq.ok) cache.put(event.request, newreq.clone());
return newreq;
})
// app is offline
.catch(() => offlineAsset(url));
});
})
);
});
最後のofflineAsset(url)の呼び出しは、ヘルパー関数を使って適切な応答を返します。
// is image URL?
let iExt = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'].map(f => '.' + f);
function isImage(url) {
return iExt.reduce((ret, ext) => ret || url.endsWith(ext), false);
}
// return offline asset
function offlineAsset(url) {
if (isImage(url)) {
// return image
return new Response(
'<svg role="img" viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg"><title>offline</title><path d="M0 0h400v300H0z" fill="#eee" /><text x="200" y="150" text-anchor="middle" dominant-baseline="middle" font-family="sans-serif" font-size="50" fill="#ccc">offline</text></svg>',
{ headers: {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'no-store'
}}
);
}
else {
// return page
return caches.match(offlineURL);
}
}
offlineAsset()関数はリクエストが画像に対するものかどうかをチェックし、「offline」と書かれたSVG画像を返します。そのほかのリクエストはすべてofflineURLページを返します。
ChromeデベロッパーツールのApplicationタブのService Workerセクションにはワーカーの情報があり、エラー情報や、強制リロード、ブラウザーをオフラインにする機能が提供されています。
Cach Storageセクションでは、現在のスコープにあるすべてのキャッシュのリストとそこに含まれるリソースを見られます。キャッシュが更新された際には下方にあるrefresh(更新)ボタンをクリックします。
その名の通り、Clear stroageセクションではService Workersとキャッシュの中身を消去できます。
Step 4:便利なオフラインページを作る(おまけ)
オフラインページは、要求されたページがオフラインでは閲覧できないことをユーザーに知らせる静的HTMLページです。閲覧できるページURLのリストも載せられます。
アプリのmain.jsからCache APIへのアクセスもできます。しかし、このAPIはプロミスを使うので、非対応ブラウザーではうまくいかず、すべてのJavaScriptの実行が停止してしまいます。それを避けるには、別の/js/offlinepage.jsJavaScriptファイル(前述したinstallFilesEssential配列に入っている)をロードする前に、オフラインページリストとChache APIが利用可能かチェックするコードを追加します。
// load script to populate offline page list
if (document.getElementById('cachedpagelist') && 'caches' in window) {
var scr = document.createElement('script');
scr.src = '/js/offlinepage.js';
scr.async = 1;
document.head.appendChild(scr);
}
/js/offlinepage.jsのスクリプトでは、バージョン番号をもとに一番新しいキャッシュを見つけて、すべてのURLキーを取得し、ページ以外のURLを取り除き、リストをソートしてそれをID名cachedpagelistでDOMに追加します。
// cache name
const
CACHE = '::PWAsite',
offlineURL = '/offline/',
list = document.getElementById('cachedpagelist');
// fetch all caches
window.caches.keys()
.then(cacheList => {
// find caches by and order by most recent
cacheList = cacheList
.filter(cName => cName.includes(CACHE))
.sort((a, b) => a - b);
// open first cache
caches.open(cacheList[0])
.then(cache => {
// fetch cached pages
cache.keys()
.then(reqList => {
let frag = document.createDocumentFragment();
reqList
.map(req => req.url)
.filter(req => (req.endsWith('/') || req.endsWith('.html')) && !req.endsWith(offlineURL))
.sort()
.forEach(req => {
let
li = document.createElement('li'),
a = li.appendChild(document.createElement('a'));
a.setAttribute('href', req);
a.textContent = a.pathname;
frag.appendChild(li);
});
if (list) list.appendChild(frag);
});
})
});
開発ツール
もしJavaScriptのデバッグ作業がしんどいと感じるならば、Service Workersは楽しいものではありません。デベロッパーツールのApplicationタブには、使えそうな機能が揃っており、ロギングステートメントはコンソールにも表示されます。
開発中はアプリをシークレット(Incognito)ウィンドウで実行することも検討してください。タブを閉じたあと、キャッシュされたファイルが残りません。
Firefoxでは、ツールメニューのService WorkersオプションにJavaScriptデバッガがあります。ツールは今後改善されていくでしょう。
最後に、Lighthouse extension for ChromeもPWA実装のための有益な情報を提供してくれます。
PWAの注意事項
PWAには新しいテクノロジーが求められるので慎重さも必要です。とはいえ、数時間ほどでできる既存Webサイトの強化であり、PWA非対応のブラウザーでも問題は起きません。
開発者によって意見は異なりますが、検討すべきことがあります。
URLの隠ぺい
デモ用サイトではURLバーを隠していますが、ゲームのような単一URLのアプリでないかぎりおすすめしません。多くのサイトでは、マニフェストのオプションでdisplay: minimal-uiあるいはdisplay: browserにするのが一番良いでしょう。
キャッシュのオーバーロード
サイトのすべてのページのファイルをキャッシュすることも可能です。ごく小さなサイトなら良いのですが、何千ページもあるサイトではどうでしょうか。サイトのすべてのコンテンツに興味がある人など普通はいませんし、デバイスの記憶領域も足りません。デモのようにアクセス済みのページのみを保存した場合でさえ、キャッシュはだんだん膨れ上がっていくでしょう。
おそらく考えるべきは、以下のようなことです。
- ホーム、連絡先、一番新しい記事など重要なページだけをキャッシュする
- 画像や動画そのほかの重いファイルはキャッシュしない
- 定期的に古いキャッシュファイルを掃除する
- 「オフラインで読むためにこのページを保存」ボタンを作ってユーザーがキャッシュする記事を選べるようにする
キャッシュの更新
デモは、ネットワークからリソースを取ってくる前に、キャッシュにあるか、ないかを検索します。ユーザーがオフラインのときに便利な反面、オンラインのときにも、キャッシュされたページが表示されてしまいます。
画像や動画といったリソースのURLは通常変わらないので、長期間キャッシュしてもあまり問題はないでしょう。少なくとも1年間(31,536,000秒)は保存されるようにCache-Control HTTPヘッダーで指定できます。
Cache-Control: max-age=31536000
ページ、CSS、スクリプトファイルはもっと頻繁に変更されるためより短い24時間を保存期限とし、オンラインならばサーバーにあるバージョンと照合できるようにします。
Cache-Control: must-revalidate, max-age=86400
また古いキャッシュデータが使われないように、キャッシュ破壊のテクニックも検討の余地があります。たとえばCSSファイルにstyles-abc123.cssと名付けて、リリースのたびにハッシュを変更するなどです。
キャッシュは複雑にもなるので、Jake Archiboldによる『Caching best practices & max-age gotchas(キャッシュ活用のベストプラクティスとmax-ageの勘所)』の一読をおすすめします。
役に立つリンク
プログレッシブWebアプリについてもっと知りたいときには、以下のリソースが役に立ちます。
- PWA.rocks example applications
- Progressive Web Apps
- Your First PWA
- Mozilla Service Worker Cookbook
- MDN Using Service Workers
今回のデモ用コードを書くときにも参考になったオンライン記事は、ほかにもたくさんあります。コードは自由に自分用に改変して使えます。幸運を祈ります!
※本記事はAJ Latour、Panayiotis «pvgr» Velisarakos、Dave Maxwellが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。
(原文:Retrofit Your Website as a Progressive Web App)
[翻訳:西尾健史/編集:Livit]
