読者です 読者をやめる 読者になる 読者になる

Block Rockin’ Codes

back with another one of those block rockin' codes

Service Worker で prefetch

serviceworker 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-prefetchprerender という仕様もありますが、そちらは今回のスコープ外です。 詳細は以下。

課題

この "prefetch" 属性は <a> とは別に <link> タグを <header> 内に書く必要があります。 これは保守が微妙に面倒で、素直に考えればリソースへの参照を書いた部分に直接書いて、記述を一つにしたいものです。

そこで、今回は Service Worker を用いて、この rel="prefetch"<a> に書けるようにしてみようと思います。

(本当は、 ここ に「クエリパラメータがついてるのは prefetch しない」って書いてあったから、じゃあそれでもキャッシュ可能なアプリ向けに、クエリパラメータを prefetch するのを書こうと思ってたんですが、やってる最中に実は ChromeFirefox もクエリパラメータ付きをばっちり prefetch してたので慌てて題材を変えました。まあやってることはほぼ同じ。)

方針

  1. ブラウザが index.html へのアクセスする
  2. それを onfetch でフックし、変わりに fetch() で実際のリクエストを投げる
  3. response から body を取得する
  4. body を取得したら response をもとに戻して respondWith() する
  5. ブラウザには index.html が表示される
  6. その裏で非同期に body を解析する
  7. "prefetch" がついているリソース(1~3.html)を取得する
  8. response をキャッシュしておく
  9. 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() で扱う RequestResponse には clone() が生えていて、 DeepCopy を取得することができます。

[訂正] 本来仕様通りであれば body も clone() されるようです。実際に clone() されていないのは、現在の chrome の実装の問題だそうです。 よって、以下の解説は自分のミスでした。そして、今の実装は単なるワークアラウンドで、実装が直ればコードも直せるようです。

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/

今回は、 Responsebody を一旦取得しきって、その値(HTML)を解析する処理をバックグラウンドに回しながら、表ではそのレスポンスを返す必要があります。 この場合は、一度取り出した Buffer と、 Response の status, statusText, headers をもとに、もう一度 Responseインスタンスを作ってあげる必要があります。 これは恐らく、次に述べる 「body が Stream だ」ってことを考えるとしょうがないのかもしれませんが、注意が必要です。

body is ByteReadableStream

bodyStream です。この Stream は以前ブログにも書いた、 WHATWG の定義する新しい Stream API です。

Stream API がブラウザにやってくる - Block Rockin’ Codes

この body から HTML を文字列で取り出すには、 stream を読み切った結果を一旦 Uint8Array として取り出して String.fromCharCode もしくは実装されていれば String.fromCodePoint を用いて変換します。

訂正

ここで String.fromCharCode を Apply してもいいですが、テキストに変換するなら TextDecoder() を使う事もできます。自分でも実装したくせにすっかり忘れていました(汗 ソースは後で直しておきます。

Encoding Standard

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 内が大変なことになっていくような世界もちょっと懸念されます。ここが体のいいゴミ捨て場になっていきそう。

メタな環境を得たからといって、ご利用は計画的にしないと、後でつらい目に合いそう。