Chrome では、リリース 40 からごく一部で「マシな AppCache」とも言われている ServiceWorker がデフォルトで使えるようになります。ServiceWorker はオフライン API の1つとして紹介されていることが多いですが、実は 「Webの世界観を変える (かもしれない) **大注目API」**の1つです!
ここでは、Chrome 40 で出来たての ServiceWorker をひと通り試す方法を書いてみたいと思います。
ServiceWorker とは?
詳しいことは最新スペック (Editor's Draft)やHTML5Rocks の記事を見てもらうとして、ものすごくざっくり書くと ServiceWorker とはバックグラウンドで実行される Javascript 環境のことで、 ブラウザ内で動くJavascriptで書いたネットワークプロキシ のように動作させることができます。
ブラウザの Javascript 環境というとページの UI 部分を書くものというイメージが強いですが、ServiceWorker からページの DOM をいじるようなことはできません。その代わり、ServiceWorker は次のような強力な機能を備えています:
- ServiceWorker に紐付けられたページのネットワーク要求を横取りして代わりにデータを返すことができる
- Offline cache のためのプログラマブルな Cache API を持つ
- 紐付けられたWebページと PostMessage で通信することで、協調して動作することができる
- 将来的に Push メッセージング、バックグラウンド同期、ジオフェンシング (位置認識)などの機能が取り込まれることが予定されている(!)
AppCache の代わりにプログラマブルに offline cache を扱うことができる、という点がよくフィーチャーされていますが、それに限らずバックグラウンドで様々なことを行うための土台として発展していくことが期待されています。
ServiceWorker で使えるようになる (予定の) 機能はどれも低レイヤかつ実に強力なものばかりで、 ServiceWorker の登場により「Web」が指す世界はこれから大きく変わっていくのではないかと考えられています。
今すぐ ServiceWorker を試す
Chrome 40 は 2014 年 12 月現在 Beta まで来ていますので、Beta, Dev あるいは Canary がインストールされていれば、何もしなくても誰でも今すぐに ServiceWorker を試すことが可能です! Beta や Dev 版の Chrome を使っていない場合、Chrome Canaryを使うのがおそらくもっとも簡単でしょう。
自分の Chrome が ServiceWorker をサポートしているか知るには、chrome://version からバージョンが 40 以上であることを確認してみてください。
HTTPS を準備する (localhost 以外で試す場合)
ServiceWorker は大変強力な API なので、セキュリティのために HTTPS 必須となっており、localhost で試す場合以外は HTTPS のセットアップをする必要があります。例えば Github Pages は HTTPS でサーブされるので、デモなどのページであれば Github Pages で作ってしまうのが簡単かもしれません。
以降では localhost でとにかく試す方法を書いています。
最も単純なサンプルを試す
ServiceWorker のサンプルコードは、例えば https://github.com/GoogleChrome/samples から取ってくることができます。samples/service-workerにいろいろな基本パターンのサンプルが置かれています。
試しに一番単純なサンプルを Chrome 40 で動かしてみましょう。
$ git clone https://github.com/GoogleChrome/samples.git GoogleChromeSamples
$ cd GoogleChromeSamples/service-worker
$ python -m SimpleHTTPServer
このようにしたあと、Chrome で http://localhost:8000/basic
を開くと、次のようなページが開きます:
リンクが2つ貼られているので、試しに "world" というリンクを辿ってみましょう。"world" は hello/world
というページにリンクされていますが、basic
ディレクトリ以下にそんなページは存在しないので 404 Not Found のエラーページが返されます…… 変ですね?
ここでおもむろに上の方にある Register
ボタンを押してからもう一度 "world" リンクを辿ってみます。すると次のような表示に変わります:
存在しないはずのページが表示されました! 実はこのページのデータは Web サーバが返したものではなく、Register
によってブラウザに登録された ServiceWorker が返しています。この ServiceWorker は動的にページを生成するので、実は http://localhost:8000/basic/hello/hoge など、'hello/' の後ろに任意のパスを指定しても動きます。
"basic"サンプルがやっていること
ここで、この basic サンプルが何をやっているのか少しだけ見てみましょう。basic/index.htmlの中を見ると、Register
ボタンが押されると次のようなコードが走るようになっています:
...
var scope = 'hello/';
...
function register() {
navigator.serviceWorker.register('service-worker.js', {scope: scope})
.then(function(r) {
console.log('registered: ');
registration = r;
console.log(registration);
})
.catch(function(whut) {
console.error('uh oh... ');
console.error(whut);
});
}
...
このコードでは、navigator.serviceWorker.register()
メソッドを使って service-worker.jsを ServiceWorker として登録(インストール)しています。(register
メソッドはPromisesを返します)
この ServiceWorker は、scope として指定されている hello/
以下の URL に対して動作するように登録されます。
登録されている ServiceWorker を調べる
ここで、Chrome 上で実際に登録されている ServiceWorker を見てみましょう。現在登録されている ServiceWorker は chrome://serviceworker-internals から調べることができます:
ここで、http://localhost:8000/basic/hello/ 以下の URL スコープに対し、http://localhost:8000/basic/service-worker.js という ServiceWorker が実際に登録されていることが確認できます。このページで ServiceWorker が console.log()
で出力したログも見ることが出来ます。
ServiceWorker を DevTools で開く
次に、実際に登録されている ServiceWorker の中身を見てみましょう。chrome://serviceworker-internals ページの Inspect
ボタンをクリックすると、登録されている ServiceWorker のスクリプトをデバッグするための DevTools ウィンドウが開かれます。
Sources
タブでスクリプトの中身を見てみると、中身は小さな onfetch
というイベントハンドラだけなことがわかります。その中で、リクエストの URL が hello/hoge のような文字列だったら "Hello, hoge!" という文字列を作り、それを Blob として動的にレスポンスを生成して返しています。
self.onfetch = function(event) {
console.log('got a request');
...
var body = new Blob([salutation, whom, energy_level, version]);
event.respondWith(new Response(body));
};
ここで、onfetch
はスコープ内の URL に対してネットワークリクエストが出されたときに呼び出されるイベントハンドラです。ここで渡される FetchEvent
の .respondWith()
というメソッドを使って任意のレスポンスを返すことができます。
オフラインで ServiceWorker を動作させる
一度登録された ServiceWorker は、そのあとオフラインになっても登録されたスコープに対してアクセスがあると呼び出されるようになります。試しにローカルの HTTP サーバを止めてみましょう:
$ python -m SimpleHTTPServer
^C
ここで http://localhost:8000/basic をアクセスし直すと、「このページにアクセスできない」というエラーが表示されるはずです。
一方、ServiceWorker が登録されている hello/
以下の URL、例えば http://localhost:8000/basic/hello/offline にアクセスすると、ServiceWorker によって "Hello, offline!" というページが表示されるはずです。
ServiceWorker スクリプトを更新する
ServiceWorker は一度登録されればオフラインでも動作することはわかりましたが、スクリプトを更新したい場合はどうすればいいのでしょう?
答えはシンプルで、ServiceWorker が登録されているスコープ内の URL に対するアクセスがあると、ブラウザ (Chrome) は自動的に更新をチェックしてくれます。ただし、複数バージョンの ServiceWorker が同時に動いておかしな状態にならないよう、前のバージョンのスクリプトを使って表示されているページがいずれかのタブで開かれている限り、古いスクリプトが動作し続けます。
ここで、 タブを開いたままリロードするだけでは ServiceWorker スクリプトは置き換わらないことに注意して下さい。ブラウザは現在のページをアンロードする前に次のネットワークリクエストを出すため、古いスクリプトが動作し続けてしまうのです。新しいスクリプトを使うには、 一旦タブを閉じて開き直す 必要があります。
(なお、リロード無しで新しい ServiceWorker を使えるようにするための skipWaiting() という新しいメソッドが提案されており、Chrome でもじきに使えるようになる予定です)。
試しに service-worker.js
を書き換えてみましょう。例えば次のように "Hello" を "こんにちは"、"Version 1" を "Version 2" に書き換えて保存し、http://localhost:8000/basic/hello/world を開いたタブをリロードしてみます。
...
var salutation = 'こんにちは、';
var whom = decodeURIComponent(event.request.url.match(/\/([^/]*)$/)[1]);
var energy_level = (whom == 'Cleveland')
? '!!!' // take it up to 11
: '!';
var version = '\n\n(Version 2)';
...
リロードしても変わらず "Hello, world! (Version 1)" と表示され続けるはずです。(ここでchrome://serviceworker-internalsをチェックすると、"Status: INSTALLED" と書かれた ServiceWorker がもう一つ登録されていることが確認できます)
新しいスクリプトを使うために、タブを一度閉じてからまた http://localhost:8000/basic/hello/world にアクセスしてみましょう。すると今度はちゃんと "こんにちは、world! (Version 2)" と表示されるはずです。
Fetch と Cache API を使う
Chrome 40 では、XHR (XMLHttpRequest) をより包括的かつ低レイヤから置き換えるFetch APIも ServiceWorker から使えるようになっています。ここでは、Fetch と Cache API を使ってオフラインでも動作する Web サイトを作ってみます。
Cache API のポリフィルを準備する
Chrome 40 では Cache.add(), Cache.addAll(), Cache.match() など、Cache API のいくつかの機能がまだ実装されていません。これらの機能を使うには、Cache API のポリフィル (https://github.com/coonsta/cache-polyfill) を使う必要があります。
ポリフィルを使うには、dist/serviceworker-cache-polyfill.jsを取ってきて、ServiceWorker スクリプトの中で import します:
importScripts('serviceworker-cache-polyfill.js');
なお、Cache.match はもうじき(おそらく Chrome 41 で)ポリフィルなしでも使えるようになる予定です。
Cache API を使ったオフライン対応サイト
ここからは実際にシンプルなオフライン対応サイトを作ってみます。次のような index.html, page.js, main.css, offline_icon.png の 4 つのファイルから成る静的な Web サイトを作ります。
<!DOCTYPE html>
<head>
<link rel='stylesheet' type='text/css' href='main.css'>
<link rel='stylesheet' type='text/css' href='http://fonts.googleapis.com/css?family=Special+Elite'>
</head>
<body>
<h1>Service Worker sample</h1>
<p>ServiceWorker を使ってページをキャッシュするサンプルです。</p>
<p id="log"></p>
<script type='text/javascript' src='page.js'></script>
</body>
function log(msg) {
document.querySelector('#log').textContent = msg;
}
navigator.serviceWorker.register('./service-worker.js', {scope:'./'})
.then(function(sw) {
if (navigator.serviceWorker.controller) {
log('このページは ServiceWorker にコントロールされています');
} else {
log('ServiceWorker が登録されました');
}
})
.catch(function(err) {
log('ServiceWorker の登録に失敗しました: ' + err);
});
h1:after { content: url('offline_icon.png'); }
h1 { font-size: 36px; }
* { font-family: 'Special Elite'; }
offline_icon.png
は HTML5 Offline Storage のアイコンを使うことにします:
このページをコントロールする ServiceWorker のためのスクリプトを service-worker.js に準備します。
importScripts('serviceworker-cache-polyfill.js');
// インストール時 (register時) に静的ファイルをキャッシュしておく
self.oninstall = function(event) {
// .waitUntil() に渡された Promises が resolve されたら
// インストール完了
event.waitUntil(
caches.open('statics-v1').then(function(cache) {
return cache.addAll([
'/',
'/page.js',
'/offline_icon.png',
'/main.css']);
})
);
};
// ページヘのネットワークリクエストが来たらキャッシュにある
// データを返す
self.onfetch = function(event) {
event.respondWith(caches.match(event.request));
};
この ServiceWorker スクリプトでは、importScripts()
で Cache API のポリフィルを import し、ServiceWorker のインストール時に呼び出される oninstall
イベントハンドラでこのサイトを構成するすべての静的ファイルをキャッシュに入れています。
また、ページへのネットワークリクエストが来たら呼び出される onfetch
イベントハンドラでは、サーバに取りに行く代わりにキャッシュにあるデータを返します。
全ファイルと Cache API のポリフィルを sample
というディレクトリにおいて、このディレクトリをルートとして http サーバを走らせてみましょう。
$ ls sample/
index.html service-worker.js
main.css serviceworker-cache-polyfill.js
offline_icon.png
$ python -m SimpleHTTPServer
このあと http://localhost:8000/ を Chrome 40 で開くと、次のように表示されるはずです:
"ServiceWorker が登録されました" というメッセージは、navigator.serviceWorker.register()
による ServiceWorker のインストールが完了したあとに表示されます。service-worker.js
の oninstall
ハンドル内のキャッシュの初期化が終わらないとインストールは完了しないので、メッセージが出るまで数秒かかることがあります。
ここでもう一度リロードすると、今度は表示が次のように変わります。
"このページは ServiceWorker にコントロールされています" というメッセージは、ServiceWorker が無事にインストールされ、index.html へのネットワークリクエストが ServiceWorker によって処理されていることを示しています。
この ServiceWorker の onfetch
イベントハンドラはキャッシュにあるデータを返すだけの単純なものなので、このサイトはこのままオフラインでも動作します。ただし、oninstall
でキャッシュに入れなかった Web フォントのデータは返すことができないため、見ての通りリロード後は Web フォントが使われていません。
Cache API と Fetch を組み合わせる
キャッシュに入っていないデータがリクエストされたときにはネットワークにデータを取りに行くように service-worker.js
を書き換えてみましょう。書き換え後の onfetch
ハンドラは次のようになります:
self.onfetch = function(event) {
event.respondWith(
caches.match(event.request).then(function(response) {
return response || fetch(event.request);
})
);
};
Promises に慣れてないと読みにくいですが、キャッシュからリクエストにマッチするデータを探し、もしなかったら fetch()
を呼び出してネットワークからデータを取得しています。
新しい ServiceWorker を使うために、ページをリロードしてから一度閉じて開くと、今度は(オンラインであれば) Web フォントを使ったページが表示されるはずです。
ネットワークから取ってきたレスポンスをさらにキャッシュしたければ、さらに次のような感じに書けます。
self.onfetch = function(event) {
event.respondWith(
caches.open('statics-v1').then(function(cache) {
return cache.match(event.request).then(function(response) {
console.log(event.request.url, response);
if (response)
return response;
fetch(event.request.clone()).then(function(response) {
if (response.status < 400) {
// HTTP response code がエラーじゃないときだけ fetch が返した
// レスポンス(のコピー)をキャッシュする。
// (ただし、non-CORS リクエストについてはレスポンスは filtered
// opaque response となり、.status は常に 0 にセットされるため、
// エラーレスポンスをキャッシュしてしまう可能性はある
// https://fetch.spec.whatwg.org/#concept-filtered-response-opaque)
cache.put(event.request, response.clone());
}
return response;
});
})
})
);
};
これで、一度 Web フォントが取ってこられたら次からはそれもキャッシュに入ることになります。
ただし、コード中のコメントにもあるように、この方法では non-CORS で異なるオリジンからのレスポンスをすべてキャッシュしてしまうので、エラーレスポンスをキャッシュしてしまう可能性があります。(逆に言うと、non-CORS リクエストのレスポンスはエラーとほとんど見分けがつきません)
コード一式
ここで使ったオフライン対応サイトのコード一式はこちらから取得できます:
オフライン挙動のさまざまなパターン
オフライン時の挙動をどのように ServiceWorker で書くかについては、Jake さんの Offline cookbook という blog エントリで次のようなさまざまなパターンが紹介されています:
- Cache only: 毎回キャッシュから返す
- Network only: 毎回ネットワークに取りに行く
- Cache, falling back to network: キャッシュを見て、なかったらネットワークへ
- Cache & network race: キャッシュとネットワークに同時にリクエストして速い方を使う
- Network falling back to cache: まずネットワークに取りに行って、失敗したらキャッシュを使う
- Cache then network: キャッシュとネットワークに同時にリクエストし、先に返されたデータを使う。ネットワークからの返答があればさらにキャッシュも更新する
- Generic fallback: キャッシュもネットワークもデータを返さなかったらデフォルトの fallback データを返す
- ServiceWorker-side templating: ServiceWorker 内でテンプレートエンジンを動かす
各パターンにはそれぞれ長短がありますが、こうしたさまざまな挙動を目的にあわせて Javascript で細かくプログラムできるのが、ServiceWorker のもっとも大きな特徴であり魅力の1つといえます。
みなさんも今日から ServiceWorker で #offlinefirst な Web サイトを作ってみませんか?
参考にさせてもらったページ
概ね HTML5Rocks と Google の developer advocate である Jake Archibald さんの blog を参考にしてます。
- HTML5Rocks: Intro to ServiceWorkers
- Jake Archibald blog: Using ServiceWorker in Chrome today
- Jake Archibald blog: The offline cookbook
- Is ServiceWorker Ready?
また、@kenjibaheuxさん、@nhiroki_さんに内容をレビューしてもらいました、どうもありがとうございます!
Disclaimer: ここで述べられていることは私の個人的な意見に基づくものであり、私の雇用者には関係ありません。