puku0x.dev
Scully で PWA Friday, June 12, 2020
#angular
#pwa
#scully

概要

Web アプリケーションでオフライン対応する場合は Service Worker を導入するのが良いでしょう。Angular では @angular/pwa を追加することで簡単に実現できますが、Scully と組み合わせる場合、期待した動作とならないことが多いため Workbox を使うことをお勧めします。

Angular Service Worker での問題

まずは @angular/pwa を追加してみましょう。

ng add @angular/pwa

ngsw-config.json で Scully が出力するファイルもキャッシュするように設定します。

{
  ...,
  "assetGroups": [
    ...,
    {
      "name": "blog",
      "installMode": "lazy",
      "updateMode": "prefetch",
      "resources": {
        "files": ["/blog/**/*.html"]
      }
    }
  ]
}

Scully 実行後に index.html のハッシュ値が変わり Service Worker で正常にキャッシュされなくるため、忘れずに再生成しましょう(Issue 参照)。

{
  "scripts": {
    ...,
    "pwa:regenerate": "rm dist/static/ngsw.json;node_modules/.bin/ngsw-config dist/static ./ngsw-config.json"
  },
npm run build -- --prod
npm run scully
npm run pwa:regenerate

早速起動してみましょう。一見すると正常にキャッシュできているようです。

では次に記事詳細に移動してからページをリロードしてみましょう。

一瞬ですが、記事一覧が表示されます。

末尾スラッシュ / を含む URL をリロードした時にキャッシュの読み込み先が /index.html とならないため、Scully と Angular Service Worker の組み合わせは避けるか、今後の修正を待った方が良いかもしれません。

Workbox による PWA 化

代替案として Service Worker の実装を Workbox に変更することをお勧めします。

まず前述の Issue を参考に URL のシリアライズ処理を末尾スラッシュ対応に上書きします。

import { DefaultUrlSerializer, UrlTree } from '@angular/router';

/**
 * @see https://github.com/angular/angular/issues/16051
 */
export class TrailingSlashUrlSerializer extends DefaultUrlSerializer {
  private static _withTrailingSlash(url: string): string {
    const splitOn = url.indexOf('?') > -1 ? '?' : '#';
    const pathArr = url.split(splitOn);

    if (!pathArr[0].endsWith('/')) {
      const fileName: string = url.substring(url.lastIndexOf('/') + 1);
      if (fileName.indexOf('.') === -1) {
        pathArr[0] += '/';
      }
    }
    const result = pathArr.join(splitOn);
    return result;
  }

  serialize(tree: UrlTree): string {
    return TrailingSlashUrlSerializer._withTrailingSlash(super.serialize(tree));
  }
}

app.module.ts に追加しましょう。

import { UrlSerializer } from '@angular/router';

@NgModule({
  declarations: [AppComponent],
  imports: [...],
  providers:[
    { provide: UrlSerializer, useClass: TrailingSlashUrlSerializer }
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

Scully の設定に seoHrefOptimize 追加します。

import { ScullyConfig, setPluginConfig } from '@scullyio/scully';

export const config: ScullyConfig = {
  projectRoot: './src',
  projectName: 'website',
  outDir: './dist/static',
  routes: {
    '/blog/:slug': {
      type: 'contentFolder',
      slug: {
        folder: './blog',
      },
    },
  },
  defaultPostRenderers: ['seoHrefOptimise'],
};

Router を使わない場合は Angular を無効化してしまっても良いかもしれません。

import { ScullyConfig, setPluginConfig } from '@scullyio/scully';
import { DisableAngular } from 'scully-plugin-disable-angular';

setPluginConfig(DisableAngular, 'render', { removeState: true });

export const config: ScullyConfig = {
  projectRoot: './src',
  projectName: 'website',
  outDir: './dist/static',
  routes: {
    '/blog/:slug': {
      type: 'contentFolder',
      slug: {
        folder: './posts',
      },
    },
  },
  defaultPostRenderers: ['seoHrefOptimise', DisableAngular],
};

次にプロジェクトのルートディレクトリに workbox-config.js を作成します。

module.exports = {
  globDirectory: 'dist/static',
  globPatterns: [
    '**/*.{js,LICENSE,txt,png,svg,jpg,webp,ico,html,css,json,webmanifest}',
  ],
  swDest: 'dist/static/service-worker.js',
};

最後に index.html<head></head> 内に次のスクリプトを追加しましょう。レンダリングを阻害しないよう defer 属性をつけるのをお勧めします。

<script defer>
  (function () {
    if ('serviceWorker' in navigator) {
      window.addEventListener('load', function () {
        navigator.serviceWorker.register('/service-worker.js');
      });
    }
  })();
</script>

これで準備完了です。

あとはデプロイ前にアプリケーションのビルドと Scully によるプリレンダリング、Service Worker 用スクリプトの生成を実行すれば Scully を使いつつ PWA として公開できます。

npm run build -- --prod --statsJson
npm run scully
npx workbox-cli generateSW workbox-config.js

まとめ

現状では Scully と Angular Service Worker を組み合わせるのは難しいようです。

しかし、Workbox を使うことでも PWA 化は可能ですので興味のある方は是非試してみましょう。

次の投稿
Scully + Prism で TSX のシンタックスハイライト
前の投稿
Scullyでブログ作成