Chrome 40 から ServiceWorker が使えるようになりました。Opera もバージョン 27 から対応し、Firefox でも現在実装が進められています (参考「Is ServiceWorker ready?」)。ServiceWorker はページのライフタイムとは独立した JavaScript の実行コンテキストを提供する機能で、ページからのリクエストをフックしてキャッシュからレスポンスを返したり、サーバからのプッシュイベントを受けてそれをページに通知するといったことが可能になります。
ServiceWorker のコンセプトや基本的な使い方、ユースケースなどは下記のページが参考になります。
- Service worker が拓く mobile web の新しいかたち
- Service Worker の紹介: Service Worker の使い方 - HTML5 Rocks
- Chrome 40 で今すぐ ServiceWorker を試す - Qiita
- Service Workerに関する仕様とか機能とか - 1000ch.net
- ServiceWorker を使った XHR のモックテスト - Block Rockin’ Codes
ServiceWorker を試用した人のフィードバックを見ていると、どうやら ServiceWorker のスコープの指定を間違えていたり、ServiceWorker がページをコントロールし始めるタイミングを勘違いしていることによってうまく使えていないケースがあるようです。そこで本記事では、上記のページを一通り読んでサンプルコードを眺めたり動かしたりしたことがある人を対象に、スコープやページコントロールについて深く説明していきたいと思います。
DISCLAIMER: ここで述べられている内容はすべて私の個人的な意見に基づくものであり、所属する組織、団体とは一切関係ありません。
ServiceWorker のスコープについて
スコープ (scope) とは ServiceWorker がサービスを提供するページ URL の範囲です。このスコープに含まれるページは ServiceWorker からオフラインアクセスやプッシュ通知といったサービスを受けることができます。一方、スコープに含まれないページはサービスを受けることができません。
例えば、ServiceWorker の提供するサービスの一つにネットワークリクエストのフックがあります。これはスコープ内にあるページを開くメインリソースリクエストと、そのページからのサブリソースリクエスト (eg. XHR) をフックし onfetch
イベントハンドラを発火します。
時々「スコープ内の URL に対するサブリソースリクエストが onfetch
でフックできない」という質問を受けますが、これはリクエスト元のページがこのスコープに含まれていないことが原因であることが多いです。スコープが意味するのは、フック可能なリクエスト先の URL の範囲ではなく、フック可能なリクエスト元の URL の範囲であることがとても重要です。
例えば、/in-scope/
をスコープとして登録した場合、/out-of-scope/page.html
から /in-scope/foo.png
へのリクエストはフックされませんが、/in-scope/page.html
のメインリソースリクエストや、そこから /out-of-scope/foo.png
や別オリジンである http://example.com/bar.png
へのサブリソースリクエストはフックされます。
スコープの指定方法
スコープは navigator.serviceWorker.register()
の省略可能な第二引数として指定することができます。
navigator.serviceWorker.register("/sw.js", {scope: "/scope/"})
.then(function(registration) {
// 登録成功!
});
self.addEventListener("fetch", function(event) {
// “/scope/” 以下のページからのリクエストがフックされる。
});
省略した場合は第一引数で指定したスクリプト URL のディレクトリパスが使われます。例えば、/foo/bar/sw.js
をスコープ指定せずに登録した場合は /foo/bar/
がスコープとして扱われ、/foo/bar/page.html
などからのリクエストがフックされるようになります。
navigator.serviceWorker.register("/foo/bar/sw.js")
.then(function(registration) {
// 登録成功!
});
self.addEventListener("fetch", function(event) {
// “/foo/bar/” 以下のページからのリクエストがフックされる。
});
スコープとページ URL は最長一致によって判定されます。もし、/in-scope/
というスコープを持つ ServiceWorker-A と /in-scope/foo/
というスコープを持つ ServiceWorker-B がアクティブな場合、ページ /in-scope/foo/bar.html
はより長くマッチする ServiceWorker-B にコントロールされます。
スコープのパス制限
スコープの指定にはセキュリティ上いくつかの制約があります (詳しくは「Service worker が拓く mobile web の新しいかたち」の 40 枚目以降を参照)
- スコープは同一オリジンしか指定できません。
- スコープはスクリプト URL 以下のパスを指定しなければなりません。例えば、
/foo/bar/sw.js
を登録する場合は、スコープとして/foo/bar/
や/foo/bar/in-scope/
を指定できますが、/foo/in-scope/
のように包含関係にないパスを指定するとリジェクトされます (参考「Launching ServiceWorker without breaking the web」)。 - スクリプト URL によるパス制限は
Service-Worker-Allowed:
ヘッダをサーバ側で指定することで解除することができます (Chrome 42 以降で対応)
ServiceWorker によるページコントロールについて
ある ServiceWorker があるページのネットワークリクエストをフックできる状態を「ServiceWorker はページをコントロールしている」と表現します。またこのとき「ServiceWorker はページのコントローラーである」といいます。
コントロール対象
ServiceWorker はページ (Document) に限らず、iframe や SharedWorker もコントロールします。iframe の場合は生成時に指定した src
によってスコープとのマッチングが行われます。つまり親フレームとは個別の ServiceWorker によってコントロールされる可能性があります。iframe 生成時に src
を指定しなかった場合の挙動は現在議論中です。
var frame = document.createElement('iframe');
frame.src = "/foo/bar/iframe.html";
document.body.appendChild(frame); // frame.src とスコープがマッチングされる
SharedWorker はスクリプト URL によってスコープのマッチングが行われます。SharedWorker のコントロールは Chrome ではバージョン 42 以降で利用可能になります (Issue 450515 - Chromium)。
// スクリプト URL とスコープがマッチングされる
var worker = new SharedWorker('/foo/bar/shared-worker.js');
コントロールするタイミング
ServiceWorker がコントローラーになるには、それがアクティブな状態 (onactivate
ハンドラが発火した後の状態) である必要があります。ServiceWorker のアクティベートプロセスについては HTML5 Rocks の記事 が参考になります。
さて、ServiceWorker がページをコントロールするかどうかは、そのページを開いた時に判断されます。もし開こうとしているページをスコープに含むアクティブな ServiceWorker がいれば、それがコントローラーになります。これは別の見方をすると、ページを開いた後に登録された ServiceWorker が、そのページを直ち (リロード無し) にコントロールすることは基本的にはないことを意味します。これをコードで表現すると次のようになります。
navigator.serviceWorker.register("/sw.js", {scope: "/scope/"})
.then(function(registration) {
// 登録成功!
return navigator.serviceWorker.ready;
})
.then(function(registration) {
// アクティベートされたが、この時点では
// このページ “/scope/will-be-controlled.html” はコントロールされていない。
assert_true(navigator.serviceWorker.controller == null);
});
アクティベート完了時点で will-be-controlled.html は既に開かれているため、リロードするまでそのページからのリクエストがフックされることはありません。
ページ側で自身のコントローラーとなる ServiceWorker がアクティブかどうかは、navigator.serviceWorker.ready
をチェックすると分かります。ready
は ServiceWorker がアクティブな時に resolve される Promise を返します。注意すべきは ready
が resolve されたからといって、そのページがコントロールされているとは限らないことです。
ページがコントロールされているかどうかは navigator.serviceWorker.controller
をチェックすると分かります。コントロールされている場合は ServiceWorker
オブジェクトが、されていない場合は null
が返ってきます。
先ほど “ページを開いた後に登録された ServiceWorker が、そのページを直ち (リロード無し) にコントロールすることは基本的にはない” といいました。しかし、ページのリロードを待たずに ServiceWorker にコントロールさせたい場合も多くありそうです。例えば、テスト環境で ServiceWorker によって HTTP サーバをモックしたい場合、初回ロード時からリクエストをフックできると良さそうです。これを可能にする claim()
という API を ServiceWorker は提供します (Chrome ではバージョン 42 以降で使用可能です)。これについては別の記事で改めて説明したいと思っています。
(2015/04/18 補足) claim() について書きました
まとめ
ServiceWorker のスコープとページコントロールについて説明しました。
- スコープは ServiceWorker がサービスを提供するページの URL の範囲を意味します。
- スコープ内のコントロールされているページのメインリソースリクエストとそのページが発行したサブリソースリクエストが
onfetch
ハンドラによってフックされます。 - ServiceWorker がページをコントロールするかどうかは、そのページを開いた時に判断されます。もし開こうとしているページをスコープに含むアクティブな ServiceWorker がいれば、それがコントローラーになります。
- ページを開いた後に登録された ServiceWorker が、そのページをリロード無しにコントロールすることは基本的にはありません。これをするには
claim()
を呼ぶ必要があります。