技術探し

技術探し

JavaScriptを中心に記事を書いていきます :;(∩´﹏`∩);:

PWAの実装をしてみた

そういえばPWAの実装したことがなかったなと思ったので少し触ってみた。

PWAとは?

PWA(Progressive Web Apps)

インストールが不要で、不安定なネットワークでも素早く起動し、プッシュ通知を可能にします。
また、ホーム画面にアイコンも表示でき、アプリと同様の扱いをすることが可能となります。

つまり、アプリに近づけたwebですね。

以下の記事が詳しいのでそちらを見てください;)

developers.google.com

目的

  • https, localhostでしかService Workerは動かないので常に安全
  • Service Workerの更新プロセスにより常に最新
  • App Shellモデルによる構成でUIをネイティブにさらに近づける
  • プッシュ通知による再エンゲージメント
  • キャッシュすることにより、ネットワークの依存度を下げる

技術スタック

Service Worker

対応状況は以下の通り
f:id:about_hiroppy:20170727174456p:plain

Can I use... Support tables for HTML5, CSS3, etc

代表的な提供機能

  • オフライン機能
  • push通知
  • バックグラウンドコンテンツの更新
  • コンテンツキャッシュ

レシピ: ServiceWorker Cookbook

PWAのview

PWAにはApp ShellとContentというものがあります。
以下の図を見るとわかりやすいと思います。

f:id:about_hiroppy:20170727175055p:plain

developers.google.com

つまり、アプリケーションシェルというのはダイナミックコンテンツじゃない部分を指します。

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">

developers.google.com

今回は、webpack-pwa-manifestというプラグインがあったのでそれを使いました。

github.com

デザイン

PRPL patternというのがあります。
より高速にモバイルでwebのエクスペリエンスを提供します。
Push、Render、Pre-cache、Lazy-loadで構成されます。

以下のAddy Osmaniの記事がとてもわかりやすいです!
The PRPL Pattern  |  Web  |  Google Developers

また今度、ブログにでも書こうかと思います。

FirefoxChromeしかService Workerないけどどうするの?

普通のHTML、CSS、JSなので問題はありません。
Service Workerはあくまでもネイティブ機能に近づける実現方法なので今までどおりにフォールバックします。

ネットワーク

Twitter Liteでhttp/2, GraphQLが使われています。

実装

github.com

上記のリポジトリで開発してみました。
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のリファレンス読みづらいんだよね。。)

ということで、これをラップしてたライブラリがあったので今回はそれを使った。

github.com

ただ、残念なことに<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以降はデフォルトで入っています)

アセットのインストー

f:id:about_hiroppy:20170727190449p:plain

このような感じで保存される。 キャッシュの種類は main, additional, optional の三種類ある。

mainはinstallイベント時にservice workerにキャッシュされ、もし失敗したら全部のキャッシュはされません。
additionalmainが正常にロードされた後、ロードされます。
optionalはサーバからfetchされたときのみキャッシュされるので、事前にダウンロードしません。

github.com

今回はindex.htmlと各種JS,画像を保存しているので、これでたとえserveしていなかったりネットを切っている状態でも恐竜が現れるのではなく通常のページが表示されます。

ネットワークタイムラインはこのようになります。
f:id:about_hiroppy:20170727191352p:plain

sizeのところを見るとわかりますが、Service Workerからコードを取得しています。
ネットワークが切れているので、sw.jsの取得は失敗しますがコアコードはすでに取得済みなのであたかも生きているようにレンダリングされる。
また高速です。(vendor.jsとか重いのに。。)

API周り

今回は、通信周りの実装を行っていないのでコードはありませんが、
Twitter LiteではAPIを叩いた後Normalizrを通してReduxに効率よくデータを渡しています。 また、IndexedDBにもその結果を保存します。

SSR

今回のサンプルではやっていませんが、基本的にはやったほうがいいと思っています。
Twitter Liteでは、認証をし、初期状態の構成をし、App Shellのレンダリングをする設計になっています。
Twitter Liteをインスペクタで色々見ると楽しいかも。

資料

developers.google.com

The PRPL Pattern  |  Web  |  Google Developers

blog.twitter.com

medium.com

さいごに

手探りでやっていてこれがすべてベストプラクティスではなかったり、誤読があるかもしれないので、何かあればPRやIssue、コメントなど出してもらえると助かります:p