【Vue.js】Vue CLI 3のプロジェクトをPWA化する

久しぶりに、所得税・住民税・事業税・国保の計算をバージョンアップしたいなぁと考え、必要性はそこまでないですがPWA化してみます。

PWAをこのシステムに導入する利点とすれば、キャッシュを使ってオフライン対応ですね。今後は、Webプッシュも使ってようと考えてますが。

環境

  • Windows 10 Pro
  • vue cli 3
  • Node 10.13

PWAとno PWAの違い

まずは、PWAの指定有無で2つのプロジェクトを作成し、その差分からどのようにバージョンアップしていくか計画を立てます。

PWA対応プロジェクトの作成

vueプロジェクトを作成します。

vue create pwa

最初にManualを選択し、あとはPWAを追加しただけです。

Vue CLI v3.4.1
┌───────────────────────────┐
│  Update available: 3.8.4  │
└───────────────────────────┘
? Please pick a preset: Manually select features
? Check the features needed for your project: Babel, PWA, Linter
? Pick a linter / formatter config: Basic
? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i> to invert selection)Lint on save
? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? In dedicated config files
? Save this as a preset for future projects? (y/N)
</div></code></pre>
<h3 id="tok-2-2">PWA非対応プロジェクト</h3>
<pre><code><div>cue create no-pwa

Manualを選択後、あとはデフォルトで突き進みます。

Vue CLI v3.4.1
┌───────────────────────────┐
│  Update available: 3.8.4  │
└───────────────────────────┘
? Please pick a preset: Manually select features
? Check the features needed for your project: (Press <space> to select, <a> to toggle all, <i> to invert selection)Babel
, Linter
? Pick a linter / formatter config: Basic
? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i> to invert selection)Lint on save
? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? In dedicated config files
? Save this as a preset for future projects? (y/N)

PWAとnot PWAの差分

早速、プロジェクトの差分を見てみます。

pacage.jsonの差分

PS C:\develop\workspace\pwa> diff (CAT .\pwa\package.json) (CAT  .\no-pwa\package.json)

InputObject                              SideIndicator
-----------                              -------------
  "name": "no-pwa",                      =>
  "name": "pwa",                         <=
    "register-service-worker": "^1.6.2", <=
    "@vue/cli-plugin-pwa": "^3.8.0",     <=

PWA化には、cli-plugin-pwaregister-sercie-workerの2種が必要みたいです。

main.jsの差分

PS C:\develop\workspace\pwa> diff (CAT .\pwa\src\main.js) (CAT  .\no-pwa\src\main.js)

InputObject                      SideIndicator
-----------                      -------------
import './registerServiceWorker' <=

Service Workerのimportが増えているだけ。

registerServiceWorkerの中身はこちら。(もちろんPWA対応プロジェクトのみ存在)
中身の理解は後回しでとりあえず進めます。

import { register } from 'register-service-worker'

if (process.env.NODE_ENV === 'production') {
  register(`${process.env.BASE_URL}service-worker.js`, {
    ready () {
      console.log(
        'App is being served from cache by a service worker.\n' +
        'For more details, visit https://goo.gl/AFskqB'
      )
    },
    registered () {
      console.log('Service worker has been registered.')
    },
    cached () {
      console.log('Content has been cached for offline use.')
    },
    updatefound () {
      console.log('New content is downloading.')
    },
    updated () {
      console.log('New content is available; please refresh.')
    },
    offline () {
      console.log('No internet connection found. App is running in offline mode.')
    },
    error (error) {
      console.error('Error during service worker registration:', error)
    }
  })
}

PWA対応プロジェクトの場合、public/img/iconsディレクトリが新たに作成されてます。

public/img/icons
    android-chrome-192x192.png
    android-chrome-512x512.png
    apple-touch-icon-120x120.png
    apple-touch-icon-152x152.png
    apple-touch-icon-180x180.png
    apple-touch-icon-60x60.png
    apple-touch-icon-76x76.png
    apple-touch-icon.png
    favicon-16x16.png
    favicon-32x32.png
    msapplication-icon-144x144.png
    mstile-150x150.png
    safari-pinned-tab.svg

public以下に、manifest.jsonrobots.txtも。

manifest.json

{
  "name": "pwa",
  "short_name": "pwa",
  "icons": [
    {
      "src": "./img/icons/android-chrome-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "./img/icons/android-chrome-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "start_url": "./index.html",
  "display": "standalone",
  "background_color": "#000000",
  "theme_color": "#4DBA87"
}

img以下のアイコンのパスだったりの情報が含まれてます。

robots.txt

User-agent: *
Disallow:

ホーム画面に配置する際のアイコン等ですね。それにしても多いな。。。

既存プロジェクトをPWA化

それでは早速、所得税・住民税・事業税・国保の計算シミュレーション用プロジェクトにPWAを組み込んでいきます。

cli-plugin-pwaのインストール

こちらのcli-plugin-pwaのドキュメントを参考にインストールします。
https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-pwa#installing-in-an-already-created-project

vueコマンドで行けるようです。

vue add @vue/pwa

C:\develop\workspace\calc-tax-insurance-v3>vue add @vue/pwa

📦  Installing @vue/cli-plugin-pwa...

+ @vue/cli-plugin-pwa@3.8.0
added 47 packages from 23 contributors and audited 45360 packages in 20.298s
found 111 vulnerabilities (66 low, 12 moderate, 32 high, 1 critical)
  run `npm audit fix` to fix them, or `npm audit` for details
✔  Successfully installed plugin: @vue/cli-plugin-pwa

🚀  Invoking generator for @vue/cli-plugin-pwa...
📦  Installing additional dependencies...

added 1 package from 1 contributor and audited 45361 packages in 18.931s
found 111 vulnerabilities (66 low, 12 moderate, 32 high, 1 critical)
  run `npm audit fix` to fix them, or `npm audit` for details
✔  Successfully invoked generator for plugin: @vue/cli-plugin-pwa
   The following files have been updated / added:

     public/img/icons/android-chrome-192x192.png
     public/img/icons/android-chrome-512x512.png
     public/img/icons/apple-touch-icon-120x120.png
     public/img/icons/apple-touch-icon-152x152.png
     public/img/icons/apple-touch-icon-180x180.png
     public/img/icons/apple-touch-icon-60x60.png
     public/img/icons/apple-touch-icon-76x76.png
     public/img/icons/apple-touch-icon.png
     public/img/icons/favicon-16x16.png
     public/img/icons/favicon-32x32.png
     public/img/icons/msapplication-icon-144x144.png
     public/img/icons/mstile-150x150.png
     public/img/icons/safari-pinned-tab.svg
     public/manifest.json
     public/robots.txt
     src/registerServiceWorker.js
     package-lock.json
     package.json
     src/main.js
     yarn.lock

   You should review these changes with git diff and commit them.

必要なファイルの追加やmain.jsまで自動更新してくれました。楽ちん。
※ main.jsの書き換え時に改行コードがLF→CRLFに変更されてしまい、全行差分としてでてきたので最初はびっくりしましたが、LFに変更したらregisterServiceWorkerのimportのみになったのでほっとしました。

動作確認(PC版)

Chromeのdevtool(F12で起動します)で確認してみます。
コンソールにServiceWorkerが登録されたログが出てきました。

Service worker has been registered.

こちらは、ApplicationのService Workers部。

Service Workerデバッグ

どうやら、プッシュ通知のテストもできるみたいですね。後で試してみましょう。

Cache Storageに、cssやJSなどがキャッシュされているのも確認できます。

CacheStorage デバッグ

Chromeのメニューからアプリのインストールが可能になりました。

アプリのインストール

インストールしてみます。 アイコンを変更していないので、Vue.jsのアイコンのままですが。。
アプリのインストール

インストールが終わったら別ウィンドウでシミュレーション画面が表示されました。URLとかの表示もないのでうまくいったようです。タイトル部の色がグリーンなのは、manifest.jsonの「theme_color」が反映されているようですね。

calc-tax-insurance-app

タスクバーにもChromeの横に別にアイコンが出てきました。Vue.jsですが。。。

task-bar

Chromeのアプリ一覧にも表示されてます!

chrome-app

※ ちなみにシークレットモードでアプリのインストールはできない模様です。ほかのサイトでは出てきてるような画面みたんですが、私の環境では出ませんでした。

シークレットモードでChromeのデバッグツールでApplication-Manifestを見ると、Installabilityに「Page is loaded in an incognito window」と表示されていたのでダメなのかなぁ。

シークレットモード

動作確認(Android版)

対象画面にアクセスすると、画面下部にホーム画面に追加のリンクが表示されるのでタップすると、
スマホインストール

確認画面がでてきて、追加をタップでホーム画面に追加できました。

スマホインストール2

PWAによるアプリ化は無事成功です。

アプリの更新を考える

今回の一番の目的は、オフラインでも使えるようにプリキャッシュ・ランタイムキャッシュを利用することです。オフラインでも動作するということは、常に最新のassetやjsで更新されるわけではなくなってくるので、まずはアプリの更新をどのようにするかのプロセスを考慮しないといけません。キャッシュが効いてしまって、いつまでも新しいバージョンに置き換わらないとか笑えないので。

調べて見ると、workboxというプラグインを使うとよさそうです。
workboxですが、Service Workerの更新に関する動作を設定項目は以下2つの模様。

設定値 動作
skipWaiting 待機フェーズをスキップし、すぐにacitivateするかどうか。
clientsClaim activeになったらすぐにすべてのクライアントを制御するか

Service Workerのライフサイクルに関連するみたいですが、説明をみても正直よく理解できませぬ。。。
とりあえず、すべてのパターンでテストしてみるのが手っ取り早い!

ということで試してみました。

テストの流れとしては、main.jsに以下のようなコンソールログを仕込み、ログの出方でアプリが更新されたかを確認します。

console.log('1 deploy.')      // 初回はこちらを有効
// console.log('2 deploy.')    // アプリ更新時はこちらを有効

workboxの設定

まずはworkboxを使う準備をするため、vue.config.jsにwebpack用の設定を追加します。
workboxでは、generateSWinjectManifestの2つのモードがあります。injectManifestの方がWebプッシュなど他のService Workerの機能を使えるなど拡張できるのですが、キャッシュだけ使いたいなどの簡単な使い方であればgenerateSWでいいみたいです。
本当はWebプッシュ使いたいけど、まずは簡単に組み込めるgenerateSWで進めます。

vue.config.js

const { GenerateSW } = require('workbox-webpack-plugin')

module.exports = {
…
  pwa: {
    name: '所得税・住民税・事業税・国保の計算【kawadeblog】',
    themeColor: '#efe37d',
    appleMobileWebAppCapable: 'yes',
    appleMobileWebAppStatusBarStyle: 'black',

    // configure the workbox plugin
    workboxPluginMode: 'GenerateSW',
    workboxOptions: {
      // swSrc is required in InjectManifest mode.
      // swSrc: 'dev/sw.js'
      // ...other Workbox options...
    }
  },
  configureWebpack: config => {
    config.plugins.push(
      new GenerateSW({
        cacheId: 'calc-tax-insurance-v3',
        skipWaiting: false,
        clientsClaim: false
      })
    )
  }
}

nameとかthemeColorはmanifest.jsonとだぶるようですが、念のためどちらにも同じ値を設定しました。優先されるのがどちらかも不明でした。。

registerServiceWorker.js

Service Workerのライフサイクルにより、どのようなタイミングでイベントが発火しどこでアプリを更新するかですが、cli-plugin-pwaをインストールするとすでに以下のJSが自動で生成されています。こちらで、各イベントのコンソールログ出力が組み込まれているので、このログ出力を確認しながら検証を進めます。

if (process.env.NODE_ENV === 'production') {
  register(`${process.env.BASE_URL}service-worker.js`, {
    ready () {
      console.log(
        'App is being served from cache by a service worker.\n' +
        'For more details, visit https://goo.gl/AFskqB'
      )
    },
    registered () {
      console.log('Service worker has been registered.')
    },
    cached () {
      console.log('Content has been cached for offline use.')
    },
    updatefound () {
      console.log('New content is downloading.')
    },
    updated () {
      console.log('New content is available; please refresh.')
    },
    offline () {
      console.log('No internet connection found. App is running in offline mode.')
    },
    error (error) {
      console.error('Error during service worker registration:', error)
    }
  })
}

skipWaiting: false, clientClaim: false

まずはデフォルトで。

初回アクセス

ServiceWokerの登録が行われ、準備が整った旨のログが出力されます。

1 deploy.
Service worker has been registered.
New content is downloading.
Content has been cached for offline use.
App is being served from cache by a service worker.
For more details, visit https://goo.gl/AFskqB

※ イベントの順序
registeredupdateFoundcachedready

デバッグツールでみてみると、ServiceWorkerが登録されたことを確認できます。

sw1

2回目のアクセス

すでにキャッシュされているのと新しいバージョンはないので、その分のイベントがなくなっただけですがreadyとregisteredの順序が逆になりました。

1 deploy.
App is being served from cache by a service worker.
For more details, visit https://goo.gl/AFskqB
Service worker has been registered.

※ イベントの順序
readyregistered

アプリを更新後、初回アクセス

メッセージが変わりました。新しいService Workerがダウンロードされ更新されたログが出てきました。ただ、ログは更新後のログではなく古い方が出力されています。

1 deploy.
App is being served from cache by a service worker.
For more details, visit https://goo.gl/AFskqB
Service worker has been registered.
New content is downloading.
New content is available; please refresh

※ イベントの順序
readyregisteredupdatefoundupdated

デバッグツールで見てみると、新しいService Workerが待ち状態になってます。アプリはまだ更新されていません。

sw2

アプリを更新後、2回目のアクセス

ブラウザを通常のリロード、スーパーリロードの双方で再読み込みしてみましたが、コンソールの表示は前回と同じ。待機状態のまま。

1 deploy.
App is being served from cache by a service worker.
For more details, visit https://goo.gl/AFskqB
Service worker has been registered.
New content is downloading.
New content is available; please refresh

※ イベントの順序
readyregisteredupdatefoundupdated

デバッグツールでも確認しましたが、待機状態は変わらずです。

sf4

ブラウザを再起動後にアクセス

最後に、ブラウザをいったん落としてから再アクセスします。
すると、Service Workerの登録のみのメッセージに代わりました。main.jsに仕込んでいるログも更新後のログが出力されています。無事にアプリの更新ができたようです。

2 deploy.
App is being served from cache by a service worker.
For more details, visit https://goo.gl/AFskqB
Service worker has been registered.

※ イベントの順序
readyregistered

デバッグツールでみてみましょう。

sw5

Service Wokerのバージョン?みたいのが#4865#4875に代わり無事に更新されました!

結論

どうもskipWatingとclientsClaimがどちらもfalseだと、アプリケーションの更新は自動でダウンロードまでしてくれますが、ブラウザの再読み込みでは更新されずブラウザの再起動が必要みたいです。
最近はタブを開きっぱなしとかよくあるので、この設定だと更新がいつまでもできずじまいになりそうです。

skipWaiting: true, clientClaim: false

次は、skipWaitingをtrueにしてテストしてみます。

初回アクセス

初回はskipWaiting=falseの場合と同じ。

</div></code></pre>

※ イベントの順序
registeredupdateFoundcachedready

デバッグツールの表示

sw20

2回目のアクセス

updateFoundが消えました。

1 deploy.
App is being served from cache by a service worker.
For more details, visit https://goo.gl/AFskqB
Service worker has been registered.

※ イベントの順序
readyregistered

デバッグツールは初回アクセスと同じ結果。

sw20

アプリを更新後、初回アクセス

新バージョンの更新が行われます。skipWating=falseの場合とイベント発火は同じです。

1 deploy.
App is being served from cache by a service worker.
For more details, visit https://goo.gl/AFskqB
Service worker has been registered.
New content is downloading.
New content is available; please refresh.

※ イベントの順序
readyregisteredupdatefoundupdated

デバッグツールで違いが出ました。待ち状態ではなくなっています。

sw21

Service Workerのバージョンも#4904#4906となってますが、ログは1 deploy.と出力されているのでアプリは古いバージョンで動作していることになります。

アプリを更新後、2回目のアクセス

お!!アプリも更新されました。

2 deploy.
App is being served from cache by a service worker.
For more details, visit https://goo.gl/AFskqB
Service worker has been registered.

※ イベントの順序
readyregistered

結論

skipWaiting=trueだとService Workerの更新待ち状態が言葉通りスキップされ、新バージョンの適用もブラウザ再起動ではなくリロードでできるようになりした。skipWaitingは待ち状態をスキップすることにより、新バージョンへ更新をブラウザの再起動が不要で次回アクセス時に更新まで進めることができるようです。おそらくプログラムで制御できるのでしょうけど、一旦このオプションは有効にしていた方がよさそうです。

skipWaiting: false, clientsClaim: true

それではどんどん行ってみます
つぎは、clientsClaim=trueにした場合です。説明からすると、最新バージョンが当たったら即座に画面に新バージョンが適用されるということなのでしょうか。。(結果的によくわかりませんでした!)

初回アクセス

初回はいつものやつ。

1 deploy.
Service worker has been registered.
New content is downloading.
Content has been cached for offline use.
App is being served from cache by a service worker.
For more details, visit https://goo.gl/AFskqB

デバッグツール

sw30

2回目のアクセス

いつものヤツ

1 deploy.
App is being served from cache by a service worker.
For more details, visit https://goo.gl/AFskqB
Service worker has been registered.

アプリを更新後、初回アクセス

更新時のイベント発火も同じ。

1 deploy.
App is being served from cache by a service worker.
For more details, visit https://goo.gl/AFskqB
Service worker has been registered.
New content is downloading.
New content is available; please refresh.

skipWaiting=falseにしたので、待ち状態になっています。

sw31

アプリを更新後、2回目のアクセス

とりあえず、通常のリロードでは変わりありません。アプリも古いまま。

1 deploy.
App is being served from cache by a service worker.
For more details, visit https://goo.gl/AFskqB
Service worker has been registered.
New content is downloading.
New content is available; please refresh.

相変わらず待ち状態

sw31

ここからはskipWaiting=falseの時と同じ。。ブラウザを再起動するまで新バージョンは適用されませんでした。

結論

この簡単なテストでは違いが出ませんでした。

skipWaiting: true, clientClaim: true

最後に、両方のオプションをtrueにした場合です。

初回アクセス

初回はいつも…

1 deploy.
Service worker has been registered.
New content is downloading.
Content has been cached for offline use.
App is being served from cache by a service worker.
For more details, visit https://goo.gl/AFskqB

sw40

2回目のアクセス

いつも…

1 deploy.
App is being served from cache by a service worker.
For more details, visit https://goo.gl/AFskqB
Service worker has been registered.

アプリを更新後、初回アクセス

更新時のイベント発火も同じ。。あれ?私の予想では、ここでは新バージョンが適用されてるとばかり思ってたのですが、まだ古いバージョンです。

1 deploy.
App is being served from cache by a service worker.
For more details, visit https://goo.gl/AFskqB
Service worker has been registered.
New content is downloading.
New content is available; please refresh.

アプリを更新後、2回目のアクセス

2回目でやっと更新されました。

2 deploy.
App is being served from cache by a service worker.
For more details, visit https://goo.gl/AFskqB
Service worker has been registered.

結論

うーん、skipWatingの動作はわかりましたが、clientsClaimはよくわからずでした。。。私の予想では、skipWaiting=trueであれば、updatedイベント発火後に即座に新バージョンが適用されるのではと思ったのですが。。。まだまだ、理解が足りないですね。
もっとドキュメントを読みライフサイクルを理解しないといけなそうです。

次回に続く…

もっと簡単にできるかと思いましたが、一つ一つ理解しようと進めると時間がかかります。他の記事とかみると、皆さんスルっとすすめてるのでスゴイですねぇ。
結構ながくなってしまったので、プリキャッシュとランタイムキャッシュを使ってオフライン対応する記事は次回に持ち越しです!