angular
ServiceWorker
AngularDay 17

Angular Service Workerを導入する

はじめに

この記事は Angular Advent Calendar 2017 17日目の記事です。

この記事では、Angular v5でリリースされた@angular/service-workerを、既存のAngularプロジェクトに導入していきます。

Angularプロジェクト

https://github.com/Ismaestro/angular5-example-app

(´-`).。oO(自分で用意する時間がなかったので、このいい感じのサンプルアプリを使って進めていきます)

Service Workerを登録する前の状態で、Lighthouseで解析しておきましょう。

$ npm i
$ ng build --prod
$ http-server dist

スクリーンショット 2017-12-18 00.58.17.png
スクリーンショット 2017-12-18 01.05.30.png

Angular Service Workerの導入

日本語の記事だと、lacoさんの Angular CLI 1.5によるAngular Service Workerクイックスタート – lacolaco-blog – Mediumがとても素晴らしく、この記事でも大いに参考にしています。

$ npm i @angular/service-worker
$ ng set apps.0.serviceWorker=true

これで .angular-cli.json に "serviceWorker": true の設定が書き足されます。

次に、Angular Service Workerは次の2つのファイルを使用します。

  • ngsw-worker.js: Angular Service Workerの本体
  • ngsw.json: ngsw-worker.jsが使う設定ファイル

ngsw-worker.jsファイルは、node_modulesの中からコピーします。Angular CLIのassets機能を使うことで、ビルドのたびに node_modules/@angular/service-worker/ngsw-worker.js ファイルが、出力先のルートディレクトリにコピーされます。

angular-cli.json
  "assets": [
    "assets",
    "favicon.ico",
    "sitemap.xml",
    "googled41787c6aae2151b.html",
    "CNAME",
    {
      "input": "../node_modules/@angular/service-worker",
      "glob": "ngsw-worker.js",
      "output": "."
    }
  ],

ngsw.jsonファイルはAngular Service Workerが提供する ngsw-config コマンドを使って生成します。設定元となるファイル src/ngsw-config.json を新たに作成します。

src/ngsw-config.json
{
  "index": "/index.html",
  "assetGroups": [
    {
      "name": "app",
      "installMode": "prefetch",
      "resources": {
        "files": ["/favicon.ico", "/index.html"],
        "versionedFiles": ["/*.bundle.css", "/*.bundle.js", "/*.chunk.js"]
      }
    }
  ]
}

package.jsonに新しいビルドスクリプトを登録します。

package.json
  "scripts": {
    "build:ngsw": "ng build -prod && node_modules/.bin/ngsw-config dist src/ngsw-config.json"
  },

最後に、ビルドに含まれるようになったngsw-worker.jsファイルをService Workerとして登録するためにServiceWorkerModuleをアプリケーションに導入します。app.module.tsファイルを開き、次のように編集します。

app.module.ts
import {NgModule} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';
import {FormsModule} from '@angular/forms';
// Import NGSW module
import {ServiceWorkerModule} from '@angular/service-worker';

import {APP_CONFIG, AppConfig} from './config/app.config';

import {AppRoutingModule} from './app-routing.module';
import {SharedModule} from './shared/modules/shared.module';
import {CoreModule} from './core/core.module';

import {AppComponent} from './app.component';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {HTTP_INTERCEPTORS, HttpClient, HttpClientModule} from '@angular/common/http';
import {TranslateLoader, TranslateModule} from '@ngx-translate/core';
import {HttpLoaderFactory} from './app.translate.factory';
import {HeroTopComponent} from './heroes/hero-top/hero-top.component';
import {ProgressBarService} from './core/progress-bar.service';
import {ProgressInterceptor} from './shared/interceptors/progress.interceptor';
import {TimingInterceptor} from './shared/interceptors/timing.interceptor';

@NgModule({
  imports: [
    BrowserModule,
    BrowserAnimationsModule,
    FormsModule,
    HttpClientModule,
    TranslateModule.forRoot({
      loader: {
        provide: TranslateLoader,
        useFactory: HttpLoaderFactory,
        deps: [HttpClient]
      }
    }),
    SharedModule.forRoot(),
    CoreModule,
    AppRoutingModule,
    // Register ngsw-worker.js as SW
    ServiceWorkerModule.register('/ngsw-worker.js'),
  ],
  declarations: [
    AppComponent,
    HeroTopComponent
  ],
  providers: [
    {provide: APP_CONFIG, useValue: AppConfig},
    {provide: HTTP_INTERCEPTORS, useClass: ProgressInterceptor, multi: true, deps: [ProgressBarService]},
    {provide: HTTP_INTERCEPTORS, useClass: TimingInterceptor, multi: true}
  ],
  bootstrap: [AppComponent]
})

export class AppModule {
}

それではこの状態でアプリケーションを実行してみましょう。

$ npm run build:ngsw
$ http-server dist

スクリーンショット 2017-12-18 01.34.15.png

スクリーンショット 2017-12-18 01.34.38.png

DevToolsやLighthouseで確認すると、Service Workerが登録されていることがわかると思います。
またngsw-config.jsonで記述したキャッシュ設定により、外部APIからデータを取得する箇所以外のオフライン対応も完了しています。実際に試してみましょう。

スクリーンショット 2017-12-18 01.57.42.png

スクリーンショット 2017-12-18 01.57.55.png

「Offline」にチェックしてからリロード・ページ遷移を行うと、外側のApplication Shellは維持されていますが、Heroの情報は表示されずローディング状態が続きます。

オフラインアクセスに備えて、Heroの情報もキャッシュに入れておきましょう。

Runtime caching

このために、先ほど使ったassetGroupsとは別のdataGroupsを使用します。

  • assetGroups
    • app [shell]のバージョンを追跡していて、グループのうち1つ以上のリソースが更新された場合、利用可能な新しいバージョンのアプリがあるとみなし、対応する更新フローを開始する
  • dataGroups
    • appのバージョンとは独立していて、独自のキャッシュポリシーを使ってキャッシュを行う。APIレスポンスを処理するのに適切

ngsw-config.jsonにdataGroupsの設定を加えて、以下のように編集します。

ngsw-config.json
{
  "index": "/index.html",
  "assetGroups": [
    {
      "name": "app",
      "installMode": "prefetch",
      "resources": {
        "files": [
          "/favicon.ico",
          "/index.html"
        ],
        "versionedFiles": [
          "/*.bundle.css",
          "/*.bundle.js",
          "/*.chunk.js"
        ]
      }
    }
  ],
  "dataGroups": [
    {
      "name": "api-freshness",
      "urls": [
        "/",
        "/heroes"
      ],
      "cacheConfig": {
        "strategy": "freshness",
        "maxSize": 100,
        "maxAge": "3d",
        "timeout": "10s"
      }
    }
  ]
}

Service Workerとキャッシュをすべて破棄した状態で、新たにビルドしたアプリケーションを実行してみましょう。
Untitled.gif

オフラインになった状態でも、Heroの情報がキャッシュから無事表示されるようになりました!

参考にしたもの

おわりに

ngsw-config.jsonの設定に関しては、公式ドキュメントに詳しく載っているのでそちらも読んでみてください。