Service Worker で prefetch
update
2015/3/31
- @hirano_y_aa さんの指摘頂き訂正しました。
- response.body の clone
- TextDecoder の利用
intro
Service Worker の「オフライン以外」の利用第二弾です。 以前、 Service Worker で XHR のモック をしましたが、今回は Prefetch を実装してみようと思います。
prefetch とは
例えば /index.html
が以下のような HTML だったとします。
<!DOCTYPE html> <meta charset="utf-8"> <title>title</title> <ul> <li><a href="1.html">1</a></li> <li><a href="2.html">1</a></li> <li><a href="3.html">1</a></li> </ul>
この場合、ユーザは次に 1.html
, 2.html
, 3.html
のいずれかに遷移する可能性が高いため、
ユーザが index.html
を閲覧している途中に、バックグラウンドでこの三つの HTML を取得してキャッシュしておく事で、
実際にいずれかの <a>
タグがクリックされた際にキャッシュヒットし、一瞬で画面を表示できます。
この、「バックグラウンドで次のページを先(pre)に取得(fetch)しておく」のが prefetch です。
rel="prefetch"
実はこれはモダンなブラウザなら HTML だけでできます。 (あまり知られていない?)
<!DOCTYPE html> <header> <meta charset="utf-8"> <title>title</title> <link rel="prefetch" href="1.html"> <link rel="prefetch" href="2.html"> <link rel="prefetch" href="3.html"> </header> <body> <ul> <li><a href="1.html">1</a></li> <li><a href="2.html">1</a></li> <li><a href="3.html">1</a></li> </ul> </body>
他にも HTTP のレスポンスに Link
ヘッダを指定する方法や、それを <meta>
タグで指定する方法も有ります。 "prefetch" ではなく "next" という仕様もありますが、微妙に挙動が違うので今回は "prefetch" にしぼります。ページではなく DNS の名前解決だけを先にやる dns-prefetch
や prerender
という仕様もありますが、そちらは今回のスコープ外です。
詳細は以下。
課題
この "prefetch"
属性は <a>
とは別に <link>
タグを <header>
内に書く必要があります。
これは保守が微妙に面倒で、素直に考えればリソースへの参照を書いた部分に直接書いて、記述を一つにしたいものです。
そこで、今回は Service Worker を用いて、この rel="prefetch"
を <a>
に書けるようにしてみようと思います。
(本当は、 ここ に「クエリパラメータがついてるのは prefetch しない」って書いてあったから、じゃあそれでもキャッシュ可能なアプリ向けに、クエリパラメータを prefetch するのを書こうと思ってたんですが、やってる最中に実は Chrome も Firefox もクエリパラメータ付きをばっちり prefetch してたので慌てて題材を変えました。まあやってることはほぼ同じ。)
方針
- ブラウザが index.html へのアクセスする
- それを onfetch でフックし、変わりに fetch() で実際のリクエストを投げる
- response から body を取得する
- body を取得したら response をもとに戻して respondWith() する
- ブラウザには index.html が表示される
- その裏で非同期に body を解析する
"prefetch"
がついているリソース(1~3.html)を取得する- response をキャッシュしておく
- 1~3.html へのリクエストにはキャッシュを返す
6 番の解析は DOMParser
とか使いたいところですが、 Service Worker 内は DOM に触れないというか DOM API がごっそり有りません。なので、とりあえず片手間な正規表現でやっています。
ちょうど今週末 Service Worker ハッカソン があるらしいので、そこで fetch した HTML をメッセージングで一旦表に戻しつつ DOM を解析するような改善でもしようかなと思います。
しかし、この "prefetch" のクエリパラメータの扱いって、仕様というよりは実装判断で割と曖昧な気がしますね。 そういう点は、宣言的な仕様よりも、こういう「コードでの表現」の方が細かい制御は効くとみることもできるかもしれません。コード書かないといけないし、バグったとき解析しにくくはあるんですが。
実装
コードはここにおきました。横着して前回のブランチ。
response-injection/sample at cache · Jxck/response-injection · GitHub
肝になる部分はこんな感じです。
if ('ServiceWorkerGlobalScope' in self && self instanceof ServiceWorkerGlobalScope) { console.log('service worker'); function prefetch(html) { 'use strict'; console.log('start parse'); let files = html.split('\n').map(function (line) { return /a rel="prefetch" href="(.+?)"/.exec(line); }).filter(function(line) { return line !== null; }).map(function(line) { return line[1]; }).forEach(function(file) { // list for rel="prefetch" caches.open('prefetch').then(function (cache) { // check cache exists cache.match(file).then(function(matched) { if(matched) { // cached console.log('already cached', file); return; } // prefetch fetch(file).then(function(response) { console.log('prefetch and cache', file); cache.put(file, response) }); }); }); }); } self.onfetch = function(event) { 'use strict'; console.log('fetch for', event.request.url); event.respondWith(caches.open('prefetch').then(function(cache) { return cache.match(event.request).then(function(matched) { if (matched) { console.log('cache hit!!!!'); return matched; } return fetch(event.request.clone()).then(function (response) { if (response.headers.get('content-type') !== 'text/html') { // don't parse body return response; } return response.clone().body.getReader().read().then(function(body) { let decoder = new TextDecoder; let bodyString = decoder.decode(body.value); // prefetch in async setTimeout(function() { prefetch(bodyString); }, 0); // re-generate response let responseInit = { statue: response.status, statusText: response.statusText, headers: response.headers } let res = new Response(body.value, responseInit); return res; }); }); }); })); }; } else { Promise.race([ navigator.serviceWorker.register('prefetch.js', { scope: '.' }), navigator.serviceWorker.getRegistration() ]).then(function() { console.log('service worker ready'); }).catch(console.error.bind(console)); }
ノウハウ
response.body は Clone されない
fetch()
で扱う Request
と Response
には clone()
が生えていて、 DeepCopy を取得することができます。
[訂正] 本来仕様通りであれば body も clone()
されるようです。実際に clone()
されていないのは、現在の chrome の実装の問題だそうです。
よって、以下の解説は自分のミスでした。そして、今の実装は単なるワークアラウンドで、実装が直ればコードも直せるようです。
@Jxck_ こんにちは、すみません、Chrome 実装は、Response.body と clone() に問題があります。理想的には、res2 = res1.clone() したとき、res2.body と res1.body から独立にバイト列が取得できるはずです。
— Yutaka Hirano (@hirano_y_aa) March 31, 2015
@Jxck_ Streams の仕様はまだ publish されてないですが、https://t.co/zOKWVgJxI1 の議論が私の理解です。実装は今週中に直します。
— Yutaka Hirano (@hirano_y_aa) March 31, 2015
Stream の tee は俺が完全に勘違いしていただけなので、もう少し Stream の仕様が固まったら、また別でブログか何かで書こうと思います。
First draft at tee algorithms, for critique by domenic · Pull Request #302 · whatwg/streams · GitHub
しかし、 body
だけはもとの参照が引き継がれます。
clone()
の仕様に以下のように書かれている tee
が (linux
の tee コマンドのように分岐を意味してるんだと思いますが) 要するに実態は一個で、2つに clone()
してもどちらかが消費したら消えてしまうという事のようです。
body is a tee of request's body. body is a tee of response's body. https://fetch.spec.whatwg.org/
今回は、 Response
の body
を一旦取得しきって、その値(HTML)を解析する処理をバックグラウンドに回しながら、表ではそのレスポンスを返す必要があります。
この場合は、一度取り出した Buffer と、 Response の status
, statusText
, headers
をもとに、もう一度 Response
のインスタンスを作ってあげる必要があります。
これは恐らく、次に述べる 「body が Stream だ」ってことを考えるとしょうがないのかもしれませんが、注意が必要です。
body is ByteReadableStream
body
は Stream
です。この Stream
は以前ブログにも書いた、 WHATWG の定義する新しい Stream API です。
Stream API がブラウザにやってくる - Block Rockin’ Codes
この body
から HTML を文字列で取り出すには、 stream を読み切った結果を一旦 Uint8Array
として取り出して String.fromCharCode
もしくは実装されていれば String.fromCodePoint
を用いて変換します。
訂正
ここで String.fromCharCode
を Apply してもいいですが、テキストに変換するなら TextDecoder()
を使う事もできます。自分でも実装したくせにすっかり忘れていました(汗
ソースは後で直しておきます。
Service Worker Cache
cache の view は自分でリロードしないと保存されてても表示されません。 すげーハマった。
代替手段
単純に <link>
書きたくないだけなら、 <a>
などに書いた prefetch
を見て、 href
をかき集めて <link>
を動的に生成して <head>
に突っ込む方がブラウザの実装に任せられます。作り込めないならこちらの方が安全でしょう。こんな感じ。
let prefetch = document.querySelectorAll('[rel=prefetch]'); Array.prototype.slice.call(targets).map(function(target) { return target.href || target.src; }).forEach(function(url) { let link = document.createElement('link'); link.rel = 'prefetch'; link.href = url; document.head.appendChild(link); });
それと比べた Service Worker による実装のメリットは、一度 register させることができれば、あとは勝手に裏でやってくれるので、他のコンテンツに一切修正が必要ないということかと思います。
この考え方は確かに旨味があるんですが、今後 Service Worker が広がったとして、 「修正範囲の見積もりが面倒」とか「とにかく最小のコストでなんとか直したい」みたいな場合に、場当たり的な分岐ロジックでごまかした Service Worker を登録させる延命治療が流行って、逆に Service Worker 内が大変なことになっていくような世界もちょっと懸念されます。ここが体のいいゴミ捨て場になっていきそう。
メタな環境を得たからといって、ご利用は計画的にしないと、後でつらい目に合いそう。