App Shell モデルの素振り(前編)webpack と Workbox を利用した構築

App Shell モデルという設計パターン

App Shell モデルは、共通のガワ部分を構成する HTML、CSS、JavaScript をシェルと定義し、その中に入る動的なコンテンツ部分と構造的に分離して扱えるように設計されます。

アプリケーション シェル(App Shell)アーキテクチャは、ネイティブ アプリのように瞬時に、そして確実にユーザーの画面に読み込める Progressive Web App を構築する方法の 1 つです。

アプリの「シェル」とは、ユーザー インターフェースが機能するために必要な最小限の HTML、CSS、JavaScript です。これらをオフラインで使用できるようにキャッシュしておくことで、ユーザーが同じページに再アクセスした際に、瞬時に高いパフォーマンス が発揮されます。つまり App Shell は、ユーザーがアクセスするたびにネットワークからすべて読み込まれるわけではなく、必要なコンテンツだけが読み込まれます。 App Shell モデル | Web | Google Developers

Progressive Web Apps の文脈で登場する設計パターンですが、Progressive Web Apps を実現する唯一の手段ではないことに注意しましょう。App Shell のリファレンス実装は GoogleChrome/application-shell です。

どういうシチュエーションでネイティブアプリのように振る舞いたいかどうかは、あなたのプロダクトに SPA が必要なのかとか、React が必要なのかとか、際限の無い本質論になるので今回は取り扱いません!

今回の素振りの前提と目的

App Shell はその性質上、Single Page Application (SPA) であることがほぼ前提に入っています。先日、SPA + サーバーサイドレンダリング、そのダルさに関する私見で述べたとおり、SSR にダルさを感じているので、サーバーサイドなしの SPA を前提とします。

素振りでは次のツールにお世話になりました。

今回は前編で App Shell モデルの特徴を踏まえて素振りしつつ、後編で従来の SPA でよくある懸念の解決を模索します。SPA + SSR はそれぞれの短所をそれぞれの長所で打ち消し合うことでメリットを生み出しているので、SPA の欠点を代替手段で軽減すれば、相対的に SPA + SSR の必要性も下がるという考え方です。

PRPL パターンの取り込み

ついでに PRPL パターンについても Push を除いて Render、Pre-cache、Lazy-load あたりを踏襲していきます。PRPL というか Polymer 周辺のエコシステムはクセが強いので、今回は参考にするに留めています。

  • Push: 最初の URL ルートに不可欠なリソースを Push(プッシュ)する。
  • Render: 最初のルートを Render(レンダリング)する。
  • Pre-cache: 残りのルートを Pre-cache(事前キャッシュ)する。
  • Lazy-load: オンデマンドで残りのルートを Lazy-load(遅延読み込み)する。

従来の1ファイルにバンドルされがちなアプリケーションコードはシェルの一部に相当するはずですが、このシェル自体も分割してキャッシュと遅延ロードの効果を高めていきます。

素振りの概要

大きく分けてトピックは下記の3つです。AppShell と PRPL を踏まえた実装イメージと、SPA にありがちな懸念点の払拭についてです。

  1. シェルの分割とフラグメントの動的ロード(webpack のこと)
  2. Service Worker を踏まえたキャッシュ方針(Workbox のこと)
  3. その他 SPA にありがちな懸念点の検討 ( 後編・別記事 )

src と dist 内の構成

詳しくは ahomu/appshell-sandbox を覗いていただいたほうが良いかと。

  • src
    • config
      • fragments.js - フラグメント(≒ ページ)の定義
      • vendors.js - フラグメント間で共通利用するライブラリの定義
    • fragments - フラグメント置き場
      • [fragmentName]
        • index.js - フラグメントのエントリポイント
    • static - 静的ファイル置き場
      • images
      • manifest.json - WebApp マニフェスト
    • views
      • index.hbs - シェルの HTML テンプレート
    • bootstrap.js - シェルの JavaScript ブートストラップ
    • server.js - サーバースクリプト
    • sw.js - ServiceWorker スクリプト
  • dist
    • config
    • fragments
    • lib
    • public
      • bootstrap.bundle.js
      • vendors.bundle.js
      • [fragmentName].bundle.js
      • sw.js
      • polyfill.js
      • workbox-sw.js
    • views
    • server.js

1. シェルの分割とフラグメントのロード(webpack のこと)

本節、webpack ユーザーの各位には当たり前な機能だと思われますが、自分としては webpack との和解を試みる第一歩です。

大本のシェルに含まれる JavaScript バンドル(各種ライブラリやルーターなどの基本機能部分)の延長線上にある各ページが必要とするアプリケーションコード、仮にこれをシェルフラグメントとしますが、これを個別にバンドルして Dynamic Import(動的ロード)の対象とします。

1-1. Dynamic Import のために webpack を使用

ES proposal: import() – dynamically importing ES modules にあるような Dynamic Import の仕組みは元々 webpack に備わっていましたが、v2 になって require.ensure() のエイリアス的に ES proposal 風の import() をサポートしたようです。

// src/bootstrap.js
import UniversalRouter from 'universal-router';
import to from 'await-to-js';
import fragmentsConfig from './config/fragments.js';

const router = new UniversalRouter(fragmentsConfig.map((fragmentConfig) => {
  return {
    path: fragmentConfig.path,
    async action() {
      const [err, fragmentContent] = await to(
        import(/* webpackChunkName: "[request]" */ `./fragments/${fragmentConfig.name}/index`)
      );
      if (err) console.error(err);
      return {
        content: fragmentContent.default,
        config: fragmentConfig,
      };
    }
  };
}));

webpack キモい!上のような動的 import を記述すると ./fragments/**/index.js にあてはまるファイルをエントリポイントとしたチャンクが自働でバンドルされます。デフォルトでは 0.js, 1.js, 2.js のようなファイル名で生成されますが webpackChunkName という magic comment を利用するとファイル名を指定できます。

ネイティブな ES Modules に頼るとブラウザサポートがなくても動くようにするのは骨が折れそうなので今回はこのようにしますが、ES Modules が広く実装された暁には、バンドル前のコードでブラウザネイティブな動的ロードに移行することも現実的な…はず...です。

webpack-contrib/bundle-loader というのも見かけましたが、これはエラーハンドリングのコールバック登録がないとか、その他いろいろ過去の産物っぽいのでスルーしてます。

1-2. CommonsChunkPlugin と Dynamic Import 併用時の難点

webpack 固有の話題になりますが CommonsChunkPlugin を使っても Dynamic Import で生成されたチャンクからは共通パッケージを抽出してくれないようです。

あたりの話が解決すれば自動的な抽出が実現しそうですが、今は vendors.js に各フラグメントで使いそうなパッケージを明示的に宣言した上で唯一の entry 由来チャンクである bootstrapCommonsChunkPlugin を適用して済ませました。

// src/config/vendors.js
import 'skatejs-web-components';
import 'skatejs';

// src/bootstrap.js
import ‘config/vendors.js’;

// webpack.config.js
new webpack.optimize.CommonsChunkPlugin({
  name: 'vendors',
  chunks: 'bootstrap',
  minChunks: function (module) {
   return module.context && module.context.indexOf("node_modules") !== -1;
  }
}),

CommonsChunkPlugin 周りは Vendor and code splitting in webpack 2 – Adam Rackis – Medium とか眺めてると他にもいろいろできそうですが、今回はまあこんな感じで。vendors.js に node_modules 以下全部、みたいに詰め込みすぎると初回ロードを圧迫するので、クリティカルなリソースを明示して限定しておくのもアリでしょう。

この使い方だと DllPlugin でもよさそうですが、一旦事足りてるので試していない

1-3. 生成されたバンドルファイルの埋め込み

詳細は後述しますが、今回はファイル名に filename: '[name].[chunkhash].js' で chunkhash を含ませているので HTML テンプレートに対しても動的にファイルを埋め込みます。

// webpack.config.js
new HtmlWebpackPlugin({
  inject: false,
  path: path,
  template: path.join('src/views', 'index.hbs'),
  filename: path.join(path.resolve(__dirname, 'dist/views'), 'index.hbs'),
}),

jantimon/html-webpack-plugin は中で ejs が動いているので、HTML テンプレートの hbs に ejs を書くという若干気持ちの悪い工程を経てサーバーがランタイムに使うテンプレートを生成します。

<!-- before -->
<%= htmlWebpackPlugin.files.js.map(js =>
`<script src="/${htmlWebpackPlugin.options.path.basename(js)}" charset="utf-8"></script>
`
).join('') %>

<!-- after -->
<script src="/vendors.739037773ddcc6301a93.js" charset="utf-8"></script>
<script src="/bootstrap.429e03ad5e04c00d1d01.js" charset="utf-8"></script>

2. Service Worker を踏まえたキャッシュ方針(Workboxのこと)

キャッシュの方針としては、HTML や JavaScript、CSS、画像など dist/public 以下の静的リソースは積極的に Service Worker で precache していきます。SW 有効時と無効時で2つのハッシュ値を使い分け、キャッシュ更新を必要最小限に留めることでキャッシュの生存期間を最大限延ばします。

  • SW 有効: SW スクリプトの更新と同時に、ハッシュ値が更新されたリソースを cache bust と再 precache
  • SW 無効: ファイル名の chunkhash (webpackが付加するファイルハッシュ) に委ねて自然に cache bust

Service Worker スクリプトの生成には Workbox を利用しました。Workbox が何かというと

The next version of sw-precache & sw-toolbox

Workbox is a rethink of our previous service worker libraries with a focus on modularity. It aims to reduce friction with a unified interface, while keeping the overall library size small. Same great features, easier to use and cross-browser compatible. Welcome to Workbox

です。Workbox と周辺ソリューションのおかげで、ちょっと設定するだけでハッシュ値を踏まえたキャッシュコントロールを実現できました。

2-1. workbox-webpack-plugin に全てお任せ

Workbox の webpack プラグインであるModule: workbox-webpack-plugin を利用すると、Service Worker を利用した precache の設定は一通り済ませられます。

// webpack.config.js
new WorkboxBuildWebpackPlugin({
  globDirectory: 'dist/public',
  globPatterns: ['*.{html,js,css}', 'images/**/*.{jpg,jpeg,png,gif,webp,svg}'],
  globIgnores: ['sw.js'],
  swSrc:'src/sw.js',
  swDest: 'dist/public/sw.js',
  templatedUrls: {
    '/app-shell': ['../views/index.hbs'],
  },
})

このように設定すると dist/public の中から glob パターンに適合するファイルを拾い集めて、Service Worker スクリプトをベースに、各ファイルのハッシュ値と共にリストを埋め込んでくれます。

// src/sw.js
importScripts('workbox-sw.v1.0.1.js');
const workbox = new self.WorkboxSW({ skipWaiting: true });
self.workbox.logLevel = self.workbox.LOG_LEVEL.verbose;
workbox.precache([]); /* placeholder */
workbox.router.registerNavigationRoute('/app-shell');

// dist/public/sw.js
importScripts('workbox-sw.v1.0.1.js');
const workbox = new self.WorkboxSW({ skipWaiting: true });
self.workbox.logLevel = self.workbox.LOG_LEVEL.verbose;
workbox.precache([
{
  "url": "/bar-index.21706d784032baa7ad0c.js",
  "revision": "1e6062d62ddcc8a923f6c8085b50f020"},
{
  "url": "/bootstrap.429e03ad5e04c00d1d01.js",
  "revision": "ca93891df4428fc03cea75e0fe5365c9"},
/* ... 中略 ... */
{
  "url": "/images/icon.png",
  "revision": "6784e86067af2a4fa8ffe74e82befbba"},
{
  "url": "/app-shell",
  "revision": "7f1d9d2e2469303a9229ddf06c83fe56"}
]); /* placeholder */
workbox.router.registerNavigationRoute('/app-shell’);

ファイル名には webpack からの chunkhash が指定され、リビジョンには Workbox からのハッシュ値が指定されていることが分かります。(この例では画像には付いませんが)

ファイルリストの末尾にある /app-shell という URL は次で説明します。

2-2. App Shell HTML の生成と指定

App Shell モデルでシェルの一部として Service Worker がキャッシュしておく HTML はひとつだけです。Service Worker コントロール下のナビゲーションの全てに対してキャッシュしておいた該当 HTML でレスポンスします。この App Shell HTML 自体が動的生成されている場合は、実体のないパスのService Worker スクリプト内ハッシュ値を更新する必要があります。

これを実現するのが、先の設定例にあった templatedUrls です。この項目に素材ファイルを指定しておくと、動的生成の素材になっているファイルのハッシュ値を架空の /app-shell というパスに適用してくれるようになります。

// webpack.config.js
templatedUrls: {
  '/app-shell': ['../views/index.hbs', './will-inject-style.css'],
},

Service Worker コントロール下のすべてのナビゲーションを /app-shell にする指定と、Express の /app-shell ルーティングは次のような感じになります。

// src/sw.js
workbox.router.registerNavigationRoute('/app-shell', {
  // ホワイトリスト外のリクエストには Serivce Worker が 404 を返す
  whitelist: [/^\/$/, /^\/foo\/?$/, /^\/bar\/?$/]
});

// src/server.js
app.get('/app-shell', (req, res, next) => res.render('index'));

2-3. Service Worker 自体の更新

Service Worker 自体の更新は、Service Worker スクリプトのキャッシュ指定に従って行われます。sw.js がキャッシュに抱え込まれたままだとイザというときに更新できなくなってしまうので、Cache-Control ヘッダでキャッシュされないように指定すると安心です。

// server/src.js
function setHeaders(res, file) {
  if (file.endsWith('sw.js')) {
    // Disable caching so that ServiceWorker updates are detected immediately.
    // max-age affects registration.update()
    res.setHeader('Cache-Control', 'max-age=0, no-cache');
  } else {
    res.setHeader('Cache-Control', 'max-age=3600');
  }
}

app.use('/', express.static('dist/public', {
  index: false,
  setHeaders
}));

実際に precache 対象の更新などで Service Worker スクリプトのアップデートが見つかった時点では、即座に内容が更新されることはなく、次に開いたときから有効になります。API などの後方互換性を残しておかないと古いバージョンのアプリケーションコードで問題が発生する可能性があるので注意が必要そうです。

// src/bootstrap.js (雑な例ですが...)
registration.addEventListener('updatefound', (e) => {
  if (confirm('新しいバージョンがあるのでOKを押して更新してください')) {
    location.reload();
  }
});

基本的には updatefound のタイミングで「新しいバージョンがあるのでここを押して更新してください」的なオプションを提示することになるような気がします。n世代以上前のバージョン差異があれば動作させない、などのネイティブアプリ的挙動があっても良いかもしれません。

2-4. API や外部リソースにもキャッシュ方針を指定してオフライン対応 (?)

次のコードは Examples > workbox-sw にあるサンプルですが、このように特定のルーティングに対するキャッシュ方針を指定もできます。

workboxSW.router.registerRoute(
  'https://httpbin.org/delay/(.*)',
  workboxSW.strategies.networkFirst({networkTimeoutSeconds: 3})
);

workboxSW.router.registerRoute(
  'https://httpbin.org/image/(.*)',
  workboxSW.strategies.cacheFirst({
    cacheName: 'images',
    cacheExpiration: {
      maxEntries: 2,
      maxAgeSeconds: 7 * 24 * 60 * 60,
    },
    cacheableResponse: {statuses: [0, 200]},
  })
);

あまり試せてはいませんが、API や UGC が格納されている CDN などにもキャッシュストラテジーを指定することで全体的なオフライン対応が可能になるはずです。

それっぽい土台の完成

App Shell モデル自体が Service Worker を前提とした設計の初期型であり試行錯誤中という気もしますし、すべてのユースケースに適する万能な方法論というわけでもなさそうです。

が、とりあえずのコンセプトは予想に反してwebpack と Workbox を使ったら大体実現できてしまったのでひとまずヨシとします。この上に載せるアプリケーションコードの設計をどうするかは、実案件があったらマジメに考えようかと思います。(ウワモノの設計はどうとでもなる...)

以上、本記事なかなかに散漫な説明ですんません。

長くなったので SPA にありがちな懸念点の検討は次の後編に書きます。