Webbundle によるサブリソース取得の最適化
Intro
WebBundle を用いてサブリソースのみを Bundle する、Subresource Bundle の策定と実装が進んでいる。
これを用いると、複数サブリソースの取得を一回の fetch で行うことができ、RTT を減らしつつも個別に取得したかのようにキャッシュを制御できる。
現時点での仕様と実装を解説する。
Subresource Bundling
WebBundle の初期の仕様は、HTML を頂点としたページ全体をまとめる方向で始まった。
これをサブリソース(JS, CSS, Img etc)に対して利用できるようにする仕様だ。
HTML 自体は普通に配信し、複数サブリソースの取得を 1 fetch にまとめることができる。
実際に gen-bundle を用いてサブリソースのみを Bundle する。
サブリソースを以下のように subresource ディレクトリにまとめたとする。
なお、 gen-bundle は CLI の仕様上 dump-bundle すると以下のようになる。
この Bundle を読み込む HTML は以下のようになる。
普通にサブリソースを読む HTML に加え、 この resources 属性に、bundle 側で解決する URL を absolute URL で明示する必要がある。
Chrome Canary を flag 付きで起動する。
HTML にアクセスすると、以下の様にサブリソースが bundle から解決されていることがわかる。
実際に実行されている fetch が HTML と Bundle だけになっていることがわかる。
もし、ブラウザが Subresource Bundling に対応していなければ、 ここでは wbn は no-store としているが、展開されたサブリソースは wbn 内で設定された Cache-Control が効いているため、個別にキャッシュが行われている。
また、ここでは一切署名を用いてないため、リソースの URL を別のものにすることも可能だ。
html はこうなる。
つまり、3rd Party のサブリソースを読み込む際に、その配信元が webbundle に対応していなくとも、1st Party 側が Bundle を生成して配信することが可能ということになる。それで良いのかは仕様がきちんと出てから議論されるのではないだろうか。
JS だけではなくあらゆる Subresource を対象に bundle できるということは、Web の開発を Component 単位で扱うようになり始めた今日のワークフローにもマッチするようになるだろう。
HTML, CSS, JS, Img などをまとめて、いわゆる WebComponent の単位で bundle を生成するといったことも可能だ。
以下の DEMO は、 今は、JS 以外は これら関連仕様が進むと、Synthetic Module ベースで構成された WebComponent の使い勝手がよくなり、Bundle で fetch の数も気にせずに積極的に使えるようになるかもしれない。
前述の WebComponents を複数組み合わせたり、大きめなフレームワークを組み合わせて使うケースは既に多くある。
これらを一つの WebBundle にするよりも、おそらく WebBundle に Webbundle を含むことになっていくだろう。
イメージとしては以下のような構成だ。
実際このような Nested Bundle も生成可能で、Chrome もそれを最後まで展開するところが確認できた。
gen-bundle
$ ls subresource
a.js
b.js
c.css
d.css
e.png
favicon.ico
a.js
は b.js
を import し、c.css
は d.css
を import している。
-dir
を指定するとディレクトリ以下をまるっと bundle してくれる。
-primaryURL
(HTML を頂点とする Bundle での HTML の URL)が必須だが、Subresource の場合は特定の Primary が存在しない。Chrome の実装もこれを無視しているようなので、適当に指定している。
-headerOverride
でヘッダを追加することもできる。フォーマットの仕様上は個々のリソースごとに別々にヘッダを追加可能だが、この CLI は全てのリソースに追加される。
$ gen-bundle \
-version b1 \
-baseURL https://labs.jxck.io/webpackaging/subresource-webbundle/ \
-primaryURL https://labs.jxck.io/webpackaging/subresource-webbundle/a.js \
-dir subresource/ \
-headerOverride "Cache-Control: max-age=60" \
-o bundle.wbn
$ dump-bundle -i bundle.wbn
Version: b1
Primary URL: https://labs.jxck.io/webpackaging/subresource-webbundle/a.js
> :url: https://labs.jxck.io/webpackaging/subresource-webbundle/a.js
< :status: 200
< Content-Type: [application/javascript]
< Accept-Ranges: [bytes]
< Cache-Control: [max-age=60]
< Last-Modified: [Sat, 13 Jun 2020 10:56:18 GMT]
< Content-Length: [54]
< [len(Body)]: 54
import {data} from './b.js'
console.log('a.js', data)
> :url: https://labs.jxck.io/webpackaging/subresource-webbundle/b.js
< :status: 200
< Content-Length: [43]
< Content-Type: [application/javascript]
< Accept-Ranges: [bytes]
< Cache-Control: [max-age=60]
< Last-Modified: [Sat, 13 Jun 2020 10:56:18 GMT]
< [len(Body)]: 43
export const data = 10
console.log('b.js')
> :url: https://labs.jxck.io/webpackaging/subresource-webbundle/c.css
< :status: 200
< Content-Type: [text/css; charset=utf-8]
< Accept-Ranges: [bytes]
< Cache-Control: [max-age=60]
< Last-Modified: [Fri, 24 Jul 2020 08:35:13 GMT]
< Content-Length: [17]
< [len(Body)]: 17
@import 'd.css';
> :url: https://labs.jxck.io/webpackaging/subresource-webbundle/d.css
< :status: 200
< Last-Modified: [Fri, 24 Jul 2020 08:34:57 GMT]
< Content-Length: [57]
< Content-Type: [text/css; charset=utf-8]
< Accept-Ranges: [bytes]
< Cache-Control: [max-age=60]
< [len(Body)]: 57
h1 {
color: red;
}
img {
border: solid 1px black;
}
> :url: https://labs.jxck.io/webpackaging/subresource-webbundle/e.png
< :status: 200
< Content-Type: [image/png]
< Accept-Ranges: [bytes]
< Cache-Control: [max-age=60]
< Last-Modified: [Sat, 06 Jun 2020 10:56:17 GMT]
< Content-Length: [1993]
< [len(Body)]: 1993
[non-text body]
> :url: https://labs.jxck.io/webpackaging/subresource-webbundle/favicon.ico
< :status: 200
< Content-Type: [image/vnd.microsoft.icon]
< Accept-Ranges: [bytes]
< Cache-Control: [max-age=60]
< Last-Modified: [Sat, 06 Jun 2020 10:56:17 GMT]
< Content-Length: [5686]
< [len(Body)]: 5686
[non-text body]
link rel bundle
<link>
で web bundle の読み込みを指定する。
<!DOCTYPE html>
<meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Subresource Web Bundle DEMO</title>
<link href=favicon.ico rel="shortcut icon" type=image/x-icon>
<link rel=webbundle
href=bundle.wbn
resources="
https://labs.jxck.io/webpackaging/subresource-webbundle/a.js
https://labs.jxck.io/webpackaging/subresource-webbundle/b.js
https://labs.jxck.io/webpackaging/subresource-webbundle/c.css
https://labs.jxck.io/webpackaging/subresource-webbundle/d.css
https://labs.jxck.io/webpackaging/subresource-webbundle/e.png
https://labs.jxck.io/webpackaging/subresource-webbundle/favicon.ico
"/>
<h1>Subresource Web Bundle DEMO</h1>
<script src=a.js type=module></script>
<link href=c.css rel=stylesheet>
<img src=e.png width=100>
挙動
$ open -a /Applications/Google\ Chrome\ Canary.app --args --enable-features=SubresourceWebBundles
<link rel=webbundle>
が無視され個別にリクエストが走ることになる。
$ gen-bundle \
-version b1 \
-baseURL https://example.com/webpackaging/subresource-webbundle/ \
-primaryURL https://example.com/webpackaging/subresource-webbundle/a.js \
-dir subresource/ \
-headerOverride "Cache-Control: max-age=60" \
-o bundle-3p.wbn
<link rel=webbundle
href=bundle-3p.wbn
resources="
https://example.com/webpackaging/subresource-webbundle/a.js
https://example.com/webpackaging/subresource-webbundle/b.js
https://example.com/webpackaging/subresource-webbundle/c.css
https://example.com/webpackaging/subresource-webbundle/d.css
https://example.com/webpackaging/subresource-webbundle/e.png
https://example.com/webpackaging/subresource-webbundle/favicon.ico
"/>
<script src=https://example.com/webpackaging/subresource-webbundle/a.js type=module></script>
<link href=https://example.com/webpackaging/subresource-webbundle/c.css rel=stylesheet>
<img src=https://example.com/webpackaging/subresource-webbundle/e.png width=100>
Web Component Bundle
<profile-card>
を Custom Element として実装し、そこに必要なリソースを bundle している。
$ ls profile-card
jxck.png
profile-card.js
style.css
template.html
import
できないため、CSS は @import
で、HTML は fetch()
で解決しているが、別途策定が進んでいる Synthetic Modules (JSON, CSS, HTML などを ES Module のように扱う)が実装されれば、全て import
で扱える可能性がある。
Nested Bundling
- bundle.wbn
- react.wbn
- react-dom.wbn
- redux.wbn
考察
bundle のネイティブ化への期待の背景
もともと何を期待してたのかという背景を振り返る。
特に JS では、長い議論と実装の努力により ES Modules がおおよそ使える所まで来た。
しかし npm にあるような依存の深いモジュールを単純に import で取得しては、発生する fetch が多すぎてオーバーヘッドが無視できない。
これは H2 や Prefetch を使うといった構成でもカバーしきれず、それを詳細に調査した Chrome のチームからは「100 を超えるなら webpack でバンドルした方が良い」というレポートが出てしまった。
この課題は すでに広く普及した webpack などのビルドツールにより解決されていたので、開発の現場ではあまり体感することはなかったかもしれない。
結果、ES Module の import はブラウザがランタイムで依存解決するためではなく、ビルドツールが変換に使うための placeholder として使われて、多くの依存を bundle.js にまとめ、その中の 1byte でも変わったらバンドルし直して再取得させる運用が通常となっている。
筆者としては「せっかく ES Module が手に入ったのにブラウザから見れば恩恵はほとんど得られていない」と言う点に煮えきらなさを感じていた。
Bundle Subresource は、「ブラウザ上でも Module を分割して扱いながらも fetch は減らしたい」という 2 つの要求を繋ぐ仕様として期待することができる。
WebPackaging の構想が出たときから期待していた仕様であり、SXG よりもより多くの現場に影響を与える仕様になりえるのではないだろうか。
動的な bundling
そうした理想を完全に叶えるためには、まだ足りてない部分もある。
一番の問題は、モジュールごとにキャッシュを設計しても、ブラウザが何をキャッシュしているのかをサーバが知る術が無いことだ。
先の例で、別で取得したキャッシュがおおよそあり、e.png
だけが無い場合は、e.png
だけを送りたいが、それがわからないと全てを含んだ bundle を送ることになってしまう。それはやはり無駄だ。
この問題は Server Push と同じで、そのときは Cache-Digest によってキャッシュ内容のヒントを送るという提案がなされた。
ところが、実装上の難易度などの問題から作業が止まり、結局実現しなかった。
WebBundle の Explainer には、この問題に対する考慮が一応書いてあるため、ここの議論が進めばより理想に近づくと個人的には期待したい。
現状の実装ではそこまではスコープに無いと思われるので、今後この議論が進むかどうかに注目しつつ、フィードバックしていきたい。
resources
bundle で fetch の数が解決して、ES Module を積極利用するとなると、自動生成するとしても link タグの resources が少し肥大しすぎるあたりも気になる。
ぱっと思い浮かぶのは import map だが、そちらも最近どうなってるかよくわからない。
今はナイーブに全部書く実装になっているとは言え、あくまで Explainer ベースであり、仕様もかっちり固まっているわけではなく、さらに Explainer でも別の案について書かれているため、Experiment を進める中でもう少し良い代替案がでることを期待したい。
DEMO
動作するデモを以下に用意した。
Outro
まだまだ実装が始まったばかりで、Explainer と Design Doc から仮実装されたような状態であるため、仕様が固まっているとはいえないだろう。
その中でも、コアの部分についてはある程度動いており、いくつかのユースケースは実現できることが確認できた。
なにより Cache-Digest の問題が気になるところなので、今後仕様策定が進んで改善されていくことを期待しつつ、検証を続けていきたい。
Resources
- Spec
- Explainer
- Requirements Doc
- Mozilla Standard Position
- Webkit Position
- TAG Design Review
-
Intents
- Intent to Prototype: Subresource loading with Web Bundles
- Chrome Platform Status
- DEMO
- Blog
- Presentation
- Issues
- Other