1画面だけのWEBアプリをオフラインでも動くようにするためにServiceWorkerを使う

# 概要

このエントリでは、1画面だけのWEBアプリをオフライン（コンピュータがインターネットにつながっていない時）でも動作するために、Service Workerを使うときについて紹介します。
Service Worker に関する詳しい説明は、末尾の参照先をご確認ください。

## できること

- Qiitaに「[PDFをブラウザ全画面モードにしてスライドショーができる、ビデオ会議で便利かもしれないツールを作ってみた](https://qiita.com/hrkt/items/18e53ce031859e8a059b)」というエントリを以前書いたのですが、こういった簡単なWEBアプリをオフラインでも動かせるようになります。

以下を含みます。

- このエントリでは、ServiceWorkerのAPIを直接利用するときについて説明しています。
- 初回インストール後のアップデートについて説明しています。
- なるべく小さな変更で実現できるようにしています。（Service Workerの機能をすべて使うことを目的とはしません）

## このエントリだけだと扱っていないこと

- より高度なSPA(Single Page Application)で問題なく動くこと
- ライブラリをつかったり、ビルド時のツールチェインと合わせて、開発を省力化すること（例：PWA(Progressive web apps)の開発に便利な「[Workbox](https://developers.google.com/web/tools/workbox)」を使うことができるでしょう）
- いわゆる全部入りの「[PWA](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps)」にすること - インストール、プッシュ通知については扱っていません。

## 想定読者、前提

- WEBで簡単なアプリが作れるが、オフラインでの動作についてはまだ試したことが無い方
- PC、Macでのブラウザを使った例です。モバイルで使える要素（例：ページのテーマカラーや、iOSでショートカットを作るときの属性など）については扱っていません。

# 仕組み

## WEBアプリがオフラインで動くために

WEBアプリを構成するために必要な素材をWEBブラウザがダウンロードしようとするとき、インターネットの向こうのサーバ側に取りに行くのではなく、ブラウザの手元にあるキャッシュから取得することで、インターネット接続が無くてもWEBアプリが動作できるようになります。これを実現するために、[ServieWorker](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)を利用します。

```
簡単に書くと、

これを
[ブラウザ] ----------------------> (インターネット) --> [サーバ]

こうします
[ブラウザ] --> [ServiceWorker] --> (インターネット) --> [サーバ]
```

# 実装例

前述のアプリを題材に、実装例を紹介します。
全体は、例として使うアプリに対する[Pull Request - enable offline](https://github.com/hrkt/my-pdf-slideshow/compare/main...enable-offline)で確認できます。以下、この内容を取り上げます。

## Manifestファイルを作る

下記のような内容のファイルを作成します。ディレクトリ階層は、index.html内で任意に指定できますが、筆者はindex.htmlと同階層にしました。ファイル名も任意であり、筆者は「mypdfslideshow.webmanifest.json」として保存しました。

["display"のパラメータ](https://developer.mozilla.org/en-US/docs/Web/Manifest/display)で、このアプリの見た目を指定できます。そのほかの値として「fullscreen」「standalone」「minimal-ui」などがありますが、今回オフライン化したいアプリはタブ内で動作することがポイントなので、「browser」としています。

アイコンは今回の目的では特に指定しなくてもよかったのですが、アプリとして動作する実験を後々やることも考えて、[Chromiumで必要とされる分の192x192と512x512](https://web.dev/add-manifest/)だけ登録しておきました。

パラメータの詳細は、[MDNのサイト](https://developer.mozilla.org/en-US/docs/Web/Manifest)などで確認できます。


```json
{
    "short_name": "MyPdfSlideshow",
    "name": "My PDF Slideshow",
    "display": "browser",
    "start_url": "index.html",
    "icons": [
        {
            "src": "images/mps-icon-128-192x192.png",
            "sizes": "192x192",
            "type": "image/png"
        },
        {
            "src": "images/mps-icon-128-512x512.png",
            "sizes": "512x512",
            "type": "image/png"
        }
    ]
}
```

### index.htmlでManifestファイルを指定する

<head>内に以下を追加します。

```html
    <link rel="manifest" href="mypdfslideshow.webmanifest.json">
```

## Service Worker

Service Workerの[ソースはGitHubのこちらから](https://github.com/hrkt/my-pdf-slideshow/blob/main/service-worker.js)参照できます。以下、順に説明します。

### 先頭部分

以下は、ベタ打ちを避けるための変数です。後述の更新時にも使います。

```js
const serviceWorkerVersion = '0.1.0'
const cacheName = 'MyPdfSlideshowPWA-v' + serviceWorkerVersion
```

### 保持するファイルのリスト

Service Workerは、オフラインで動作するためにファイルをキャッシュに保持することができます。以下では、保持したいファイルのリストを作っています。

```js
const appShellFiles = [
  './index.html',
  './app.js',
  './app.css',
  './images/mps-icon-128-16x16.png',
  './images/mps-icon-128-32x32.png',
  './images/mps-icon-128-48x48.png',
  './images/mps-icon-128-64x64.png',
  './images/mps-icon-128-128x128.png',
  './lib/lodash/full.min.js',
  './lib/pdfjs/pdf.js',
  './lib/pdfjs/pdf.worker.js'
]
const otherFiles = []
const contentToCache = appShellFiles.concat(otherFiles)
```

### インストール

Service Workerがブラウザの機能でインストールされるとき、「install」イベントが呼ばれます。以下では、このイベントの中で、キャッシュ内に、前述のファイル群を登録しています。

self.skipWaiting()は、インストール後、（次のページのロードまで待たずに）すぐに新しいService Workerで動作したいことを宣言しています。こちらについては、「アップデート」の部分で後述します。

```js
self.addEventListener('install', function (evt) {
  console.log('[Service Worker] Installing... version: ' + serviceWorkerVersion + ' cacheName:' + cacheName) 
  // use newly installed service worker.
  // returned Promise from skipWaiting() can be safely ignored.
  self.skipWaiting()
  evt.waitUntil(
    caches.open(cacheName).then(function (cache) {
      console.log('[Service Worker] Caching all: app shell and content')
      return cache.addAll(contentToCache)
    })
  )
})
```

ファイルがキャッシュされている状況は、ブラウザの開発者コンソールで確認することができます。下図はFirefox 95での例です

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65002/22c1103c-9ae3-4dfe-9ff2-735ce0137117.png)



### ファイルの取得

前述の通り、オフラインでWEBアプリが動作するために、ブラウザからサーバへのリクエストが、Service Workerでハンドルされます。キャッシュにあるファイルについては、キャッシュから取得して返すようにしているのが以下の部分です。

```js
self.addEventListener('fetch', function (evt) {
  evt.respondWith(
    caches.match(evt.request).then(function (r) {
      console.log('[Service Worker] Fetching resource: ' + evt.request.url)
      // we don't store falsy objects, so '||' works fine here.
      return r || fetch(evt.request).then(function (response) {
        return caches.open(cacheName).then(function (cache) {
          console.log('[Service Worker] Caching new resource: ' + evt.request.url)
          cache.put(evt.request, response.clone())
          return response
        })
      })
    })
  )
})
```

### 有効化

Service Workerが有効化されるタイミングで、「activate」イベントが発生します。以下では、現在必要としている以外のキャッシュについて、ブラウザのストレージから破棄しています。

```js
self.addEventListener('activate', (evt) => {
  console.log('Activating new service worker...')
  const cacheAllowlist = [cacheName]

  evt.waitUntil(
    caches.keys().then((keyList) => {
      // eslint-disable-next-line array-callback-return
      return Promise.all(keyList.map((key) => {
        if (cacheAllowlist.indexOf(key) === -1) {
          console.log('[Service Worker] deleting old cache: ' + cacheName)
          return caches.delete(key)
        }
      }))
    })
  )
  console.log('done.')
})

```

サービスワーカが有効化されるている状況は、ブラウザの開発者コンソールで確認することができます。下図はFirefox 95での例です
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65002/773438d8-f9e6-6d68-6eb6-66acb9b813d4.png)

### Service Workerの指定

WEBアプリの中のエントリポイントで、以下のようにService Workerを指定します。

navigator.serviceWorker.registerに指定したオプションのうち、「updateViaCache: 'none'」の部分では、Service Workerと、それからimportされているスクリプトについて、ブラウザが持っているWEBコンテンツのキャッシュを使わないことを指定しています。Chromeでは、68からServiceWorkerについてはWEBのキャッシュを使わずに更新を確認しにいく挙動になっており、これはオプションでは'imports'に該当します（参照：[Fresher service workers, by default](https://developers.google.com/web/updates/2019/09/fresher-sw)）。このエントリの範囲では、今のところimportをしていないので必要がないといえばない、、指定ではあるのですが、importを使ったときでもキャッシュを使わないようにするため、指定しておきました。

'scope'のオプションで、WEBサイトのうち、このWEBページのパス以下に対してのみ動作するように設定しています。

```js
// enable service worker
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('./service-worker.js', { updateViaCache: 'none', scope: './' })
    .then((reg) => {
      // registration worked
      console.log('Registration succeeded. Scope is ' + reg.scope)
    }).catch((error) => {
      // registration failed
      console.log('Registration failed with ' + error)
    })
}
```

### アップデート

WEBアプリを作って公開したあと、WEBアプリに何か更新をした場合に、キャッシュしたファイルや、サービスワーカそのものをアップデートしたくなった時について説明します。

#### キャッシュしたファイル

ファイルの増減があったときには、前述の「保持するファイルのリスト」内を更新します。
前述の「先頭部分」のcacheNameを別の名前にし、'install'イベント内で、ファイル全体を新しいキャッシュに登録しなおします。

応用編としては、全体をキャッシュしなおさずに部分だけ更新ということも考えられますが、このエントリの範囲ではすべてダウンロードしなおしで十分な範囲だったため、このようにしています。

#### Service Worker

Service Workerは、ブラウザがバックグラウンドで更新確認に行き、更新があるようなら、ブラウザがインストールを開始します。

ここで、更新後に新しいServiceWorkerに切り替わるタイミングが問題になりますが、何も指定しない場合、ブラウザのフルリロード後に、新しいService Workerが使われます。これは、動いているページに影響を与えない目的には合致した挙動です。

一方、Service Workerを更新してもページの挙動に影響がないケースなどでは、更新をすぐに適用したい場合もあると思います。このエントリでは、前述の「インストール」で示した通り、下記のように指定しています。

```js
self.skipWaiting()
```

より細かな制御については、末尾の「関連Qiita外部エントリ」に参考サイトへのリンクを置きました。

ここまでの内容を合わせて、Service Workerのファイル内容を更新すると、下図のように更新されることが確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65002/5806c143-6ea9-de03-75c5-75f44c5e1656.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65002/ef7c99e7-7d36-2e9a-5b82-8220c849b4aa.png)

# おわりに

このエントリでは、1画面だけのWEBアプリをオフラインでも動作するために、Service Workerを使うときについての実装例を紹介しました。



# 参照

## Service Worker

- [Service Worker API](https://developer.mozilla.org/ja/docs/Web/API/Service_Worker_API) ... MDN内のページ
- [Service Worker の紹介](https://developers.google.com/web/fundamentals/primers/service-workers) ... GoogleのWeb Fundamentals内のページ

## 関連Qiitaエントリ

- [PDFをブラウザ全画面モードにしてスライドショーができる、ビデオ会議で便利かもしれないツールを作ってみた](https://qiita.com/hrkt/items/18e53ce031859e8a059b) ... このエントリで、オフライン動作化にする対象として使用したアプリを作ってみたことをまとめた、筆者のエントリです。
- [PWAをもっと簡単に初めてみる](https://qiita.com/poster-keisuke/items/6651140fa20c7aa18474) ... 入門編のシンプルな内容について取り扱っているQiitaエントリです。
- [Service Workerの基本とそれを使ってできること](https://qiita.com/y_fujieda/items/f9e765ac9d89ba241154) ... Service Workerについて広く説明されているエントリです。

## 関連Qiita外部エントリ

- [Handling Service Worker updates – how to keep the app updated and stay sane](https://whatwebcando.today/articles/handling-service-worker-updates/) ... こちらのページで、アップデート時に取りうるいくつかの方式について取り上げられている記事がありました。