Svelte で PWA (台本ビューア) <第3回>

[PWA で作った台本ビューア](https://github.com/satamame/pscv/tree/0.0.2) を [Svelte](https://svelte.jp) で作り直すことにしたので、やったことをメモしていきます。

- 前回 : [<第2回> PWA にする](https://qiita.com/satamame/items/2af28e120676df9ab375)
- 次回 : [<第4回> Back ボタン対応](https://qiita.com/satamame/items/ff499e66fe55aa0fd551)
- [シリーズ記事一覧](https://qiita.com/satamame/items/3e0683a1fc6ecd6b66ee#%E3%82%B7%E3%83%AA%E3%83%BC%E3%82%BA%E8%A8%98%E4%BA%8B%E4%B8%80%E8%A6%A7)

# <第3回> 更新プロンプトを出す

**今回の概要**

- Service Worker を更新するためのプロンプトを作ります。
- バージョン情報の UI を作ってアプリが更新されたことを確認します。
- バージョン情報の UI からも Service Worker を更新できるようにします。

今回使うソースコードは [GitHub のこちらのコミット](https://github.com/satamame/pscv/tree/qiita.03) にあります。

# Service Worker の更新について

この「Service Worker の更新」というのは、デバイス上の Service Worker のことを言っています。サーバ上の Service Worker については (文脈で明かな場合以外は) 「サーバ上の」とつけるようにします。

また、この「更新」は、すでにデバイスにダウンロードされている新しい Service Worker を使い始めることを言っています。
新しい Service Worker は PWA の起動中にダウンロードされるので、ダウンロードされたことをユーザに知らせるのが良いです。知らせずに次に起動した時に更新されているというやり方もありますが、ここではユーザに知らせてユーザの意思で更新するようにします。

**PWA が更新される流れ**

1. サーバ上でコンテンツが更新される。
1. デバイス上の PWA がそれを検知する。
1. デバイス上の PWA がバックグラウンドで Service Worker をダウンロードする。
1. PWA が再起動 (リロード) される。
1. 新しい Service Worker が動き出す。

「アプリ」とは Service Worker によってキャッシュされている PWA コンテンツのことですから、新しい Service Worker が動き出した時がアプリが更新された時ということになります。

# vite-plugin-pwa による更新検知

サーバ上の Service Worker の更新を検知する部分も、前回導入した [vite-plugin-pwa](https://vite-plugin-pwa.netlify.app) がやってくれます。
vite-plugin-pwa の公式ドキュメントに [Prompt for new content refreshing](https://vite-plugin-pwa.netlify.app/guide/prompt-for-update.html) というページがあって、このとおりやれば出来そうなのですが、Svelte プロジェクトの場合は違いました。
Svelte の場合のやり方が [こちら](https://vite-plugin-pwa.netlify.app/frameworks/svelte.html) にあります。

## workbox-window をインストールする

上記のリンク先に workbox-window というパッケージが必要だと書いてあるので、インストールしました。

```
> yarn add workbox-window -D
```

また念のため src/vite-env.d.ts に vite-plugin-pwa/client への参照も追加しました。

```typescript:src/vite-env.d.ts
/// <reference types="svelte" />
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />
```

## 更新を検知するコンポーネント

先ほどのページに [コンポーネントのサンプル](https://vite-plugin-pwa.netlify.app/frameworks/svelte.html#prompt-for-update) があるので、これを改善して App.svelte にマウントすることで機能するようにしました。
ざっくり言うと、プラグインの仮想モジュールから `useRegisterSW` という関数をインポートして、その関数から得た情報を使ってポップアップの表示や Service Worker の更新をしています。

**改善点**

- `useRegisterSW()` に渡すオプションの `onRegistered` が deprecated だったので `onRegisteredSW` に変えた。
- `if` 文で `offlineReady` と `needRefresh` が両方とも `true` だった場合を考慮した。
- 「閉じる」を押した後でも [ストア](https://svelte.jp/docs#run-time-svelte-store) を経由して `updateServiceWorker()` を呼べるようにした。
- transition を追加した。

`appUpdateFunc` というストアを読み込んでいますが、これはこの後すぐ作ります。
省略しているスタイル部分については [ソース全文](https://github.com/satamame/pscv/blob/qiita.03/src/components/ReloadPrompt.svelte) を参照してください。

```html:src/components/ReloadPrompt.svelte (抜粋)
<script lang="ts">
  import { fade } from 'svelte/transition'
  import { createEventDispatcher } from 'svelte'
  import { appUpdateFunc } from '../lib/store'
  import { useRegisterSW } from 'virtual:pwa-register/svelte'

  const dispatch = createEventDispatcher()

  let gone = false

  // 表示や更新に使う情報を取得する
  const { offlineReady, needRefresh, updateServiceWorker } = useRegisterSW({
    onRegisteredSW(url, swr) {
      console.log(`SW registered: ${url}, ${swr}`)
    },
    onRegisterError(error) {
      console.log('SW registration error', error)
    }
  });

  function close() {
    if (gone) { return }
    gone = true
    // 更新があるのに「閉じる」を押した場合、ストアに関数を保存する
    if ($needRefresh) { appUpdateFunc.set(updateServiceWorker) }
    setTimeout(() => { dispatch('close') }, 100)
  }

  $: toast = !gone && ($offlineReady || $needRefresh)
</script>

{#if toast}
  <div class="pwa-toast" role="alert" transition:fade="{{ duration: 100 }}">
    <div class="message">
      {#if $needRefresh}
        <span>
          台本ビューアの新しいバージョンがあります。今すぐ更新しますか？
        </span>
      {:else}
        <span>
          台本ビューアがキャッシュされてオフラインで使用可能になりました。
        </span>
      {/if}
    </div>
    {#if $needRefresh}
      <button on:click|once="{() => updateServiceWorker(true)}">
        更新する
      </button>
    {/if}
    <button on:click="{close}">
      閉じる
    </button>
  </div>
{/if}

.....
```

ポップアップが表示されている間も他の UI を操作できるので、App.svelte で他のコンポーネントよりも手前に表示するようにしました ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.03/src/App.svelte))。

```html:src/App.svelte (抜粋)
<script lang="ts">
  /* 中略 */
  import ReloadPrompt from "./components/ReloadPrompt.svelte"
  let reloadIsOpen = true
  /* 中略 */
</script>

..... (他のコンポーネント)

{#if reloadIsOpen}
  <ReloadPrompt on:close="{() => { reloadIsOpen = false }}" />
{/if}

.....
```

## 更新用関数を保持するストア

src/lib/store.ts を新規で作って、`ReloadPrompt` コンポーネントからアクセスしているストアを作りました。

```typescript:src/lib/store.ts
import { writable } from 'svelte/store'

export const appUpdateFunc = writable(null)
```

型の指定を省いています。気になる方は `updateServiceWorker` の型を調べて `writable<型>` 等としてください。

前節の ReloadPrompt.svelte の `close()` 関数で、新しい Service Worker がダウンロードされたにも関わらず「閉じる」を押した時に、このストアに `updateServiceWorker` をセットするようにしました。
これによって、更新用のポップアップを閉じた後でも更新ができるようにしています。実際に更新する部分もあとで作ります。

ここまで実装すると、以下のようなポップアップが出るようになります。
左は最初に Service Worker が PWA コンテンツをキャッシュし終えた時に出るポップアップで、右は新しい Service Worker がダウンロードされて更新の準備ができた時に出るポップアップです。

![03_update_prompt.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/26ef31a5-2b9f-3cee-4014-b2407edb050e.png)

# バージョンを表示する

Service Worker の更新の仕組みが正しく動いていることを確認するために、アプリのバージョンを表示するようにします。
先ほどのスクリーンショットでは App.svelte の `main` 要素にバージョンを表示していますが、これは開発中の便宜的なものなのでいずれ無くなります。

## バージョンを定数定義する

src/lib/const.ts というファイルの中でアプリのバージョンを定義するようにしました ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.03/src/lib/const.ts))。

```typescript:src/lib/const.ts (抜粋)
export const APP_VERSION = '0.1.0-alpha.22'
```

package.json の中の `"version"` を勝手に参照するようにできると良いのですが、簡単には出来なさそうなのでやめました。

## 更新の仕組みの確認方法

バージョンを表示する場所を作る前に、更新の仕組みの確認方法について少し説明します。

### サーバ上のコンテンツの更新

上でも書きましたが、サーバ上のコンテンツが更新されると、今動いている PWA が裏で新しい Service Worker をダウンロードします。vite-plugin-pwa の仮想モジュールがこれを勝手にやってくれます。

「サーバ上のコンテンツ」と書きましたが、これはサーバ上の Service Worker (sw.js) のことです。サーバ上の sw.js が、デバイスにダウンロード済みの Service Worker と1バイトでも違えば、更新されたとみなされます。

sw.js の中にはローカルにキャッシュすべきファイルのパスが書かれていて、さらにそれらのファイル名にはビルド時につけられるハッシュ値があるので、コンテンツの一部でも書き換えてビルドすれば sw.js の内容が変化して、更新されたとみなされる状態になります。

### 2回更新する必要がある

更新の仕組みを実装したり変更したりした場合、その動作確認をするにはデバイス上の PWA を2回更新する必要があります。
1回目はその実装を取り込むための更新で、2回目は更新の動作を確認するための更新です。

1回目は実装が変わっているので普通に更新されます。更新の仕組みを入れる前はキャッシュをクリアして再起動する必要があるかも知れません。
2回目は実装を変えずに sw.js の内容を変化させないといけないので、先ほどの src/lib/const.ts の中のバージョン定義を変更するという方法にしました。

## About コンポーネント

`main` 要素での暫定的な表示以外に、アプリのバージョンを表示する場所として `About` コンポーネントを作ることにしました。
以下が完成イメージです。

![03_about.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/c5390ef5-204b-73a4-11bc-3b8ca285922e.png)

中身はあとにして、まずコンポーネントを開くまでの部分を見てみます。

### About を開くまでの部分

`About` コンポーネントは `MainMenu` コンポーネントから開くようにしました。
`MainMenu` コンポーネントは [第1回](https://qiita.com/satamame/items/3e0683a1fc6ecd6b66ee) でドロワーとして開く部分だけ実装して中身は適当だったので、メニューらしく項目をリスト表示するようにしました。

![03_main_menu.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/e96c0dae-24b5-727c-3f31-12310bc60b69.png)

ドロワーとして開く部分もすっきりした書き方に改善しました。
具体的には、閉じた状態のクラスを `gone` という1個のクラスにして、`class:` ディレクティブで制御するようにしました。
これにより、`bind:this` や `.classList.add/remove()` を書く必要がなくなりました。

以下ソースですが、スタイルは一部省略しています ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.03/src/components/MainMenu.svelte))。

```html:src/components/MainMenu.svelte (抜粋)
<script lang="ts">
  import { createEventDispatcher, onMount } from 'svelte'
  import Overlay from "./Overlay.svelte"
  import closeIcon from '/ui_icon/close_black_24dp.svg'

  const dispatch = createEventDispatcher()
  let gone = true

  onMount(async () => {
    setTimeout(() => { gone = false }, 0)
  })

  function close() {
    if (gone) { return }
    gone = true
    setTimeout(() => { dispatch('close') }, 200)
  }

  // 「バージョン情報」を押した時のハンドラ
  function openAbout() {
    close()
    dispatch('openAbout')
  }
</script>

<div class="overlay" class:gone>
  <Overlay on:click="{close}" />
</div>

<div class="panel" class:gone>
  <h1>メニュー</h1>
  <button class="icon-button close-button">
    <img alt="閉じる" src="{closeIcon}" on:click="{close}" />
  </button>
  <ul>
    <li>表示</li>
    <li>台本データ</li>
    <li on:click="{openAbout}">バージョン情報</li>
  </ul>
</div>

<style>
  .panel {
    /* 中略 */
    transition: transform 0.2s, opacity 0.2s;
  }
  .panel.gone {
    transform: translateX(180px);
    opacity: 0;
  }
  .overlay {
    transition: opacity 0.2s;
  }
  .overlay.gone {
    opacity: 0;
  }
  /* 中略 */
</style>
```

「バージョン情報」を押すと `openAbout` イベントを親にフォワードするようにしたので、App.svelte でそれを受けます。
ついでに `About` コンポーネントもマウントして、App.svelte は以下のようになりました ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.03/src/App.svelte))。

```html:src/App.svelte (抜粋)
<script lang="ts">
  /* 中略 */
  import About from './components/About.svelte'
  let aboutIsOpen = false
  /* 中略 */
</script>

.....

{#if menuIsOpen}
  <MainMenu
    on:close="{() => { menuIsOpen = false }}"
    on:openAbout="{() => { aboutIsOpen = true }}"
  />
{/if}

{#if aboutIsOpen}
  <!-- メニューより手前、更新ポップアップより奥に表示する -->
  <About on:close="{() => { aboutIsOpen = false }}" />
{/if}

{#if reloadIsOpen}
  <ReloadPrompt on:close="{() => { reloadIsOpen = false }}" />
{/if}

.....
```

About.svelte の内容は次で説明します。

# About からの更新

`About` コンポーネントでやることは以下の3つです。

1. 前出のスクリーンショットの内容を表示する
2. ダイアログらしく表示する
3. `ReloadPrompt` で更新しなかった場合にここで更新できるようにする

1 については src/lib/const.ts からバージョンの定数をインポートするだけです。
2 についてはドロワーでやっているのと同じです。ただドロワーと違ってビュー全体を覆うのでオーバーレイはつけていません。
3 については `ReloadPrompt` でストアに保存した `appUpdateFunc` を使えば実現できます。

ソースコードは以下のようになりました ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.03/src/components/About.svelte))。

```html:src/components/About.svelte (抜粋)
<script lang="ts">
  /* 中略 */
  import { APP_VERSION } from '../lib/const'
  import { appUpdateFunc } from '../lib/store'
  import closeIcon from '/ui_icon/close_black_24dp.svg'

  // ドロワーと同様にアニメーションや閉じるボタンの仕組みを入れる
  // .....
</script>

<div class="panel" class:gone>
  <h1>台本ビューアについて</h1>
  <button class="icon-button close-button">
    <img alt="閉じる" src="{closeIcon}" on:click="{close}" />
  </button>

  <!-- アプリのバージョンの表示 -->
  <div class="container">
    <p>バージョン<br>{APP_VERSION}</p>

    <!-- ストアに更新用関数が保存されていればボタンを表示 -->
    {#if $appUpdateFunc}
      <button on:click|once="{() => $appUpdateFunc(true)}">
        今すぐ台本ビューアを更新する
      </button>
    {/if}
    <p>&copy;2022 satamame</p>
  </div>
</div>

<style>
  /* 全画面パネルが下から現れる設定 */
  .panel {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    /* 中略 */
    transition: transform 0.2s, opacity 0.2s;
  }
  .panel.gone {
    transform: translateY(540px);
    opacity: 0;
  }
  /* 中略 */
</style>
```

![03_about_update_button.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/375e24bb-3d80-3ccd-f08f-6d46e7e2b8aa.png)

更新ボタンは `{#if}` でストアの内容を評価して表示を切り替えているので、先に `About` を開いてから `ReloadPrompt` を閉じてもちゃんと表示されます。

アプリ全体の話になりますが、このアプリでは Router などは使わずにコンポーネントを出したり消したりして画面遷移をしようと (今のところは) 考えています。
Router を使う場合は [SvelteKit](https://kit.svelte.jp/) 等を使うことになると思います。