PWAの実装をしてみた
一年以上前の記事なので、コードが古いです。気をつけて読んでください。
そういえばPWAの実装したことがなかったなと思ったので少し触ってみた。
PWAとは?
PWA(Progressive Web Apps)
インストールが不要で、不安定なネットワークでも素早く起動し、プッシュ通知を可能にします。
また、ホーム画面にアイコンも表示でき、アプリと同様の扱いをすることが可能となります。
つまり、アプリに近づけたwebですね。
以下の記事が詳しいのでそちらを見てください;)
目的
- https, localhostでしかService Workerは動かないので常に安全
- Service Workerの更新プロセスにより常に最新
- App Shellモデルによる構成でUIをネイティブにさらに近づける
- プッシュ通知による再エンゲージメント
- キャッシュすることにより、ネットワークの依存度を下げる
技術スタック
Service Worker
対応状況は以下の通り
Can I use... Support tables for HTML5, CSS3, etc
代表的な提供機能
- オフライン機能
- push通知
- バックグラウンドコンテンツの更新
- コンテンツキャッシュ
PWAのview
PWAにはApp ShellとContentというものがあります。
以下の図を見るとわかりやすいと思います。
つまり、アプリケーションシェルというのはダイナミックコンテンツじゃない部分を指します。
App Shell
一番、PWAの実現で難しい部分であり、一番パフォーマンスの向上を図ることが期待される部分(らしい)
シンプルなデザインコンセプトで設計されます。
Service Workerのキャッシング機能により、パフォーマンスの向上が可能です。
最上位のアプリのロジック、ルーターなどがあります。
Content
動的なビューです。例えばTwitterのタイムラインとか。
ここもそれぞれのコンテンツで必要に応じてJSのチャンクは細かく切られます。
キャッシュ戦略
基本的に、App ShellとContentのJSは別チャンクにするべきです。
Service Workerにそれぞれのチャンクを保持させることにより、ユーザが前のページに戻った時に早く読み込むことが可能です。
理論上、App Shellの読み込みとContentの読み込みを別にすることにより、パフォーマンスとユーザビリティの向上が図れるらしいです。
App Shellはどのページでも常に読み込まれ、Contentは必要に応じて読み込むという感じです。
読み込みフロー
PWAは先にその時に必要なものだけを取り、それをキャッシュする仕組みです。
なのでwebpackでチャンクを細かく切ることにより、必要なリソースだけを読み込めるように設計します。
その後、Service Worker側で追加のリソースを事前に取得して、将来的な読み込みを行います。
また、ローディングの順序はApp Shellで基本的なUIを構築し、その後にコンテンツです。
Web App Manifest
manifest.json
に名前、カラー、ホーム画面に置くアイコンの設定等を書きます。
PWAには必須です。
以下のようなスキーマになります。
{ "name": "My PWA Sample", "orientation": "portrait", "display": "standalone", "start_url": ".", "short_name": "MyPWA", "description": "This is a sample App!", "background_color": "#f5f5f5" }
また、HTMLでは以下のように指定します。
<link rel="manifest" href="/manifest.json">
今回は、webpack-pwa-manifestというプラグインがあったのでそれを使いました。
デザイン
PRPL patternというのがあります。
より高速にモバイルでwebのエクスペリエンスを提供します。
Push、Render、Pre-cache、Lazy-loadで構成されます。
以下のAddy Osmaniの記事がとてもわかりやすいです!
The PRPL Pattern | Web Fundamentals | Google Developers
また今度、ブログにでも書こうかと思います。
FirefoxとChromeしかService Workerないけどどうするの?
普通のHTML、CSS、JSなので問題はありません。
Service Workerはあくまでもネイティブ機能に近づける実現方法なので今までどおりにフォールバックします。
ネットワーク
Twitter Liteでhttp/2, GraphQLが使われています。
実装
上記のリポジトリで開発してみました。
SPA + Service Workerで実現しています。
今回はCSRのみです。
ライブラリ
react, react-routerで構築しました。
詳しくは、リポジトリのwebpack.config.js
とかを見てください。
webpack-offline
Service Workerのファイル吐き出しと結合を行ってくれるプラグイン。
注意点として、キャッシュファイルの保存容量が超えた時のエラーがわかりづらい。
Uncaught (in promise) DOMException: Quota exceeded. service worker
は多分そのエラー。
なので、基本的にはdevではofflineを使わないでプロダクションのときにだけ使うようにしたほうがいい。(HMRもおそらくできないので)
ただ、もちろんswに本当に接続できているか確認したいときはあるのでそのときはライブラリ群のチャンクをキャッシュから外してデバッグしている。
様々なオプションがあるのでチューニングによるパフォーマンスとかの変化はありそう。
構成
ファイル
Asset Size Chunks Chunk Names vendor.2a193704.js 806 kB 5 [emitted] [big] vendor node-8f3bc311d3d7fbab90a659d57e126fbf.png 3.73 kB [emitted] Boron.content.bundle.2a193704.js 2.3 kB 1 [emitted] Boron.content Argon.content.bundle.2a193704.js 2.3 kB 2 [emitted] Argon.content List.content.bundle.2a193704.js 1.89 kB 3 [emitted] List.content bundle.2a193704.js 9.28 kB 4 [emitted] bundle Carbon.content.bundle.2a193704.js 2.3 kB 0 [emitted] Carbon.content manifest.json 367 bytes [emitted] index.html 462 bytes [emitted] sw.js 23.2 kB [emitted] appcache/manifest.appcache 265 bytes [emitted] appcache/manifest.html 3.3 kB [emitted]
ファイルは上記のように分けました。
vendor.js
ではライブラリのコードのみが入っています。
なので一番サイズが大きいです。
ライブラリのバージョンが頻繁に変わらないためコアコードから隔離します。
bundle.js
がApp Shellです。
ルーティングとToolbarを持っています。
ベストプラクティスがわからないですが、これは一緒にしないほうがいいかもです。
*.content.bundle.js
がcontentです。
今回は、Argon, Boron, Carbonの三種類とルートページのリスト、合計4チャンクあります。
ちなみにNodeのLTSの名前がこの三種類です(v4, v6, v8)
ルーティング
慣れているreact-routerを使う。
必要な時にcontentのチャンクを取得するためにlazy loadを使います。
react-routerには慣れているつもりだったが、v4からgetComponent
がなくなっていることに気付いてなかった。。
昔は、
<Route path="/" getComponent={(location, callback) => { require.ensure([], require => { callback(null, require('./Root')) }, 'Root') }} />
みたいに書けたはずなのに今は書けなくなっている。
調べた限り多分、FBの方が書いてたこれが一番キレイな書き方。
Quick and dirty code splitting with React Router v4 · GitHub
うーん。。。 ラッパーを書かないといけないの。。(あとreact-routerのリファレンス読みづらいんだよね。。)
ということで、これをラップしてたライブラリがあったので今回はそれを使った。
ただ、残念なことに<Switch>
が対応してなくて、つらい。。
今PRが出ているのでそれ待ちという状態です。
Can not use with Switch · Issue #4 · mhaagens/lazy-route · GitHub
しょうがないので、/
に対してexact
を付けることにした。
// 長いので省略部あり const Routes = () => ( <App> <Route exact path="/" render={() => <LazyRoute component={ import(/* webpackChunkName: 'List.content' */ './components/contents/List') } /> } /> <Route path="/argon" render={() => <LazyRoute component={ import(/* webpackChunkName: 'Argon.content' */ './components/contents/Argon') } /> } /> </App> );
AppはView全体を構築します。
この中にToolbarが入っており、this.props.children
が上記に当てはまったルートになりそれをレンダリングします。
よくreact-routerでやる部分的更新の手法ですね。
しかし、今回はレンダリング時にファイルを取得し読み込み流したいので動的に取得する実装が必要です。
なのでstage-3のdynamic importを使う必要があります。(webpack2以降はデフォルトで入っています)
アセットのインストール
このような感じで保存される。
キャッシュの種類は main
, additional
, optional
の三種類ある。
main
はinstallイベント時にservice workerにキャッシュされ、もし失敗したら全部のキャッシュはされません。
additional
はmain
が正常にロードされた後、ロードされます。
optional
はサーバからfetchされたときのみキャッシュされるので、事前にダウンロードしません。
今回はindex.html
と各種JS,画像を保存しているので、これでたとえserveしていなかったりネットを切っている状態でも恐竜が現れるのではなく通常のページが表示されます。
ネットワークタイムラインはこのようになります。
sizeのところを見るとわかりますが、Service Workerからコードを取得しています。
ネットワークが切れているので、sw.jsの取得は失敗しますがコアコードはすでに取得済みなのであたかも生きているようにレンダリングされる。
また高速です。(vendor.jsとか重いのに。。)
API周り
今回は、通信周りの実装を行っていないのでコードはありませんが、
Twitter LiteではAPIを叩いた後Normalizrを通してReduxに効率よくデータを渡しています。
また、IndexedDBにもその結果を保存します。
SSR
今回のサンプルではやっていませんが、基本的にはやったほうがいいと思っています。
Twitter Liteでは、認証をし、初期状態の構成をし、App Shellのレンダリングをする設計になっています。
Twitter Liteをインスペクタで色々見ると楽しいかも。
資料
The PRPL Pattern | Web Fundamentals | Google Developers
さいごに
手探りでやっていてこれがすべてベストプラクティスではなかったり、誤読があるかもしれないので、何かあればPRやIssue、コメントなど出してもらえると助かります:p