Quantcast
Browsing Latest Articles All 25 Live
Mark channel Not-Safe-For-Work? (0 votes)
Are you the publisher? or about this channel.
No ratings yet.
Articles:

Angular でアプリケーションを作成したらやること 4 点

この記事は Angular Advent Calendar 2018 の 1 日目の記事です。
Angular でアプリケーションを作成する時にやっておくと役立つオススメ 4 点の紹介をします。

今回の環境

  • 以下の設定で確認
    • macOS
    • Angular CLI : 7.1.0

Angular アプリケーションの作成

  • 下記コマンドでアプリケーションを作成
$ ng new lets-start-angular --style=scss --routing
  • Angular CLI v7.x からオプションを渡さなくてもインタラクティブに質問してもらえるようになった
    • オプションを渡さないと下記のようになる
$ ng new lets-start-angular
? Would you like to add Angular routing? Yes
? Which stylesheet format would you like to use? SCSS   [ http://sass-lang.com   ]

その :one: : TSLint

  • TSLint 自体は最初からインストールされているので、ルールを見直す
  • 個人的に見直したルールは下記
    • コードのメンテナンス性を下げないために循環的複雑度をチェックしたり 1 ファイルあたりの行数に制限をかけたりしている
見直したルール 説明 対応
cyclomatic-complexity 循環的複雑度をチェックする 追加
encoding エンコーディングの指定(UTF-8) 追加
linebreak-style 改行コードの指定(LF) 追加
max-file-line-count 1 ファイルあたりの行数(200) 追加
no-boolean-literal-compare 真偽値のチェックを冗長的に書かない 追加
no-consecutive-blank-lines 2 行以上の空行を禁止する 追加
no-console console メソッドの使用を禁止する(log 禁止) 変更
no-duplicate-imports 重複 import を禁止する 追加
prefer-template 文字列連結にテンプレートリテラルの使用を推奨 追加
trailing-comma 行末カンマについてのルール(複数行必須) 追加

その :two: : Prettier

  • Linter とは別に Formatter も導入しておくと複数人での開発に役立つので Prettier を入れる
  • 下記でインストール
$ yarn add prettier --dev --exact
  • Prettier でフォーマットするのは *.html と *.ts
  • 設定ファイルを配置
.prettierrc.json
{
  "arrowParens": "always",
  "printWidth": 140,
  "singleQuote": true,
  "trailingComma": "all"
}
  • ignore ファイルを配置
.prettierignore
dist/
coverage/
node_modules/
  • npm scripts を登録
    • html と ts では parser が異なるので個別に設定し、それをまとめた script も登録
diff --git a/package.json b/package.json
index 552ef29..ff5b61d 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,10 @@
     "build": "ng build",
     "test": "ng test",
     "lint": "ng lint",
-    "e2e": "ng e2e"
+    "e2e": "ng e2e",
+    "prettier": "yarn prettier:ts && yarn prettier:html",
+    "prettier:ts": "prettier --write --parser 'typescript' './**/*.ts'",
+    "prettier:html": "prettier --write --parser 'angular' './**/*.html'"
   },
   "private": true,
   "dependencies": {
  • $ yarn prettier でフォーマットを実行

その :three: : stylelint

  • CSS にも Lint をかけたいので stylelint を導入する
    • stylelint-config-standard に standard なルールが定義されているのでこれを使う
$ yarn add stylelint stylelint-config-standard -D
  • stylelint の設定ファイルを用意
.stylelintrc
{
  "extends": "stylelint-config-standard",
  "rules": {
  }
}
  • npm scripts を登録
diff --git a/package.json b/package.json
index 9553923..274ec34 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
     "test": "ng test",
     "lint": "ng lint",
     "e2e": "ng e2e",
+    "stylelint": "stylelint 'src/**/*.scss'",
     "prettier": "yarn prettier:ts && yarn prettier:html",
     "prettier:ts": "prettier --write --parser 'typescript' './**/*.ts'",
     "prettier:html": "prettier --write --parser 'angular' './**/*.html'"
  • デフォルトで生成される app.component.scss が空なので $ yarn stylelint を実行すると no-empty-source のルールで怒られるのでよしなに対応する

その :four: : Code coverage の閾値

  • Angular CLI はテストランナーに Karma が使われている
  • テスト実行には $ yarn test コマンドを使うが --code-coverage オプションを付けるとコードカバレッジを出力する
  • 例えば、下記のように src/karma.conf.js を変更するとコードカバレッジの閾値を 80% に設定できる
diff --git a/src/karma.conf.js b/src/karma.conf.js
index ee9caa1..37d3155 100644
--- a/src/karma.conf.js
+++ b/src/karma.conf.js
@@ -18,7 +18,15 @@ module.exports = function (config) {
     coverageIstanbulReporter: {
       dir: require('path').join(__dirname, '../coverage'),
       reports: ['html', 'lcovonly', 'text-summary'],
-      fixWebpackSourcePaths: true
+      fixWebpackSourcePaths: true,
+      thresholds: {
+        global: {
+          statements: 80,
+          lines: 80,
+          branches: 80,
+          functions: 80,
+        },
+      },
     },
     reporters: ['progress', 'kjhtml'],
     port: 9876,
  • 閾値を下回るとエラーとして扱われるが emitWarning: true を追加するとエラーではなく警告として扱われるので、プロジェクトの途中でも閾値を設定するのは有用

まとめ

今回はアプリケーション開発時にオススメの設定 4 つを紹介しました。これらを CI でチェックすることでより保守しやすくなったり、チーム開発する場合は、コードレビューコストの削減にもなると思います。

明日は @shibukawa さんです。

Angularの便利タグng-container, ng-content, ng-template

Angularアドベントカレンダー2日目です。昨日は @kasaharu さんでした。

ReactやMithrilのテンプレートは基本的に、render()/view()メソッド内のJavaScriptの関数呼び出しですので、JavaScriptの文法でいろいろコードをいじることができます。それに対して、VueとAngularはテンプレート言語を持っていて、それを実行時に評価して(パースは事前に行うが)HTMLを生成します。

で、Angularの方には、テンプレートを構造化するためのもろもろの便利タグがあります。

<ng-container>

ReactでいうところのFragmentです。ちょっとタグのようにみえるけど、タグではない、でも少しタグっっぽいタグです。表示するときには何も表示されません。Angularではタグに*ngIfとか*ngFor構造化ディレクティブをつけて、タグのON/OFFや、ループを行いますが、ng-containerを使えば、余計なタグが生成されません。

例えば、divタグに*ngForをつけると、

divタグでfor
<div *ngFor="let station of stations">
  <span>{{station.name}}</span>
</div>

このdivタグも繰り返されます。

divタグでforの結果
<div><span>渋谷</span></div>
<div><span>新宿</span></div>
<div><span>池袋</span></div>

ng-containerにつけると、タグ自体の出力がされなくなります。

ng-containerタグでfor
<ng-container *ngFor="let station of stations">
  <span>{{station.name}}</span>
</ng-container>
ng-containerタグでforの結果
<span>渋谷</span>
<span>新宿</span>
<span>池袋</span>

<ng-content>

アダプティブなGUIコンポーネントの考察のエントリーで紹介したのが<ng-content>です。

コンポーネントが利用させるときに、コンポーネントのタグに挟まっている子要素を取得してきてテンプレート内部に展開します。例えば、blinkを再現するコンポーネントapp-blinkを作ったとして、次のようにテンプレートを作成します(実際にはCSSを付与するだけならディレクティブの方が良いです)。

blink.component.html
<div class="blinking">
  <ng-content></ng-content>
</div>

利用側のコードでは次のようになりますが、このタグの中の子供要素(ここではテキスト)が<ng-content>のところに展開されます。

app-blinkコンポーネントを利用
<app-blink>Hello World</app-blink>

ng-contentを何回も使うことができます。select属性をつけると、それにマッチしたものだけを展開します。例えば次のようなテンプレートの場合、imgタグだけを前に並べて、それ以外の要素を後ろに並べます。同じタグがなんども出力されることはありません。イメージとしては、子タグは最初にすべて見えないリストに積まれます。最初のng-contentにヒットしたタグは、子供タグリストから削られて、最後にselectなしの時に残りのリストの要素が全部表示されます。同じselectをなんども使うと、2つ目移行はヒットしなくなるので、何も出力されません。

複数のng-contentを使う
<ng-content select="img"></ng-content>
<ng-content></ng-content>

3/14追記

Angularでi18nを行うときに、公式以上に使われているというngx-translateを使う場合、<ng-content>を使ったライブラリの翻訳はそのままではうまくいきません。

通常の翻訳
<p translate>翻訳前のテキスト</p>

通常はこのように書いておくと、抽出ツールで 翻訳前のテキスト をキーにした項目が辞書ファイルに追加され、翻訳文を入れておくと、実行時にリプレースされて翻訳が実行されます。ですが、内部で<ng-content>を使って子要素を表示する自作のタグコンポーネントでは翻訳されません。

通常の翻訳
<!-- 抽出はうまくいくが実行時の置き換えがされない -->
<my-component translate>翻訳前のテキスト</my-component>

次のように書く必要があります。

通常の翻訳
<!-- こうかけばOK -->
<my-component>{{ '翻訳前のテキスト' | translate }}</my-component>

<ng-template>

<ng-template>はデフォルトでは表示されない(コメント化される)テンプレートを作ります。HTMLの<template>に似ていますが、<template>の方は、中身のタグも実態として生成される点が少し違います。#で名前をつけます。

例えば、ロード済みでなければ、loadingの方を出してあげる、みたいに、単純なifのON/OFFではなくて、完全に別の要素を表示するときに使います。Angular 4.0からの機能みたいですね。

else時にテンプレート化した内容を表示
<div *ngIf="loaded; else loading">
  ロード済みの時に表示される
</div>

<ng-template #loading>
  <div>Loading...</div>
</ng-template>

thenも書けば両方を外だしできます。

else時にテンプレート化した内容を表示
<div *ngIf="loaded; then loaded; else loading"></div>

<ng-template #loaded>
  <div>ロード済みの時に表示される</div>
</ng-template>

<ng-template #loading>
  <div>Loading...</div>
</ng-template>

ViewChildでアクセスもできます。

@ViewChild('loading') template: TemplateRef<any>;

<ng-template><ng-container>

<ng-template><ng-container>は一緒に使うと便利です。

先ほどのloadingのテンプレートは、if文がマッチしなかったときのフォールバックとしてテンプレートを表示していましたが、テンプレート自体のインスタンス化は<ng-container>*ngTemplateOutletでテンプレート名を指定すると、いつでもテンプレートの展開ができます。

テンプレートを展開
<ng-container *ngTemplateOutlet="loading"></ng-container>

<ng-template><ng-container><ng-content>

さて、条件によってまったく違う見た目をさせたいテンプレートがあったとします。どちらの条件時もng-contentで子要素を表示させたいとします。例えば、次の例では、モバイルではないときはサイドバーを表示しています(この例であれば*ngIfをsidebarにつけるだけで十分ですが多目にみてください)。

if文とng-content(失敗例)
<ng-container *ngIf="mobile else desktop">
  <ng-content></ng-content>
</ng-container>

<ng-template #desktop>
  <sidebar>サイドバー</sidebar>
  <ng-content></ng-content>
</ng-template>

はい。これはうまくいきません。<ng-content>のときに、子供の要素は内部の見えないリストに入れてから取り出されると説明しました。この場合、if文によって<ng-content>は一個ずつしか表示されないのですが、デスクトップの時も、モバイル側の<ng-content>側に要素が入ってしまい、表示されないのです。if文の評価とか関係なく、上から<ng-content>が処理されるようです。

この場合は、<ng-template>を使って、<ng-content>をまとめてあげると大丈夫になります。

if文とng-content(成功例)
<ng-template #child>
  <ng-content></ng-content>
</ng-template>

<ng-container *ngIf="mobile else desktop">
  <ng-container *ngTemplateOutlet="child"></ng-container>
</ng-container>

<ng-template #desktop>
  <sidebar>サイドバー</sidebar>
  <ng-container *ngTemplateOutlet="child"></ng-container>
</ng-template>

おまけ: タグの中のテキストをプログラム的に利用したい (<template><ng-content>)

Angularのテンプレートは、中身の要素が空でも、閉じタグを書かなければなりません。Angular MaterialでSVG Iconを使うには次のように書きます。

空タグでも閉じタグを省略できない
<mat-icon svgIcon="home"></mat-icon>

ReactのMaterial UIのタグは次のように書きます。Angularはタグのプリフィックス必須なのでタグ名が長いのをおいといても長いです。

Reactの例
<Icon>home</Icon>

せめてこうしたいですよね?

<mat-icon>home</mat-icon>

ですが、前回紹介した@ContentChildではテキストタグにヒットさせる方法がなく、一度HTMLに書き出してから読み出す、という方法しかうまくいきませんでした。らこらこさんに教えていただいた方法を参考に試行錯誤した結果の方法が次の結果です。

まずは、<template>タグを使ってテキストの中身を書き出します。<ng-content>ではコメントになってしまうので、ふつうのHTMLの方のタグを使います。

icon.component.html
<template #iconName><ng-content></ng-content></template>
<mat-icon [svgIcon]="name"></mat-icon>

次にコンポーネント側のコードです。まずは、 @ViewChild を使ってこのタグを取得します。名前を使ってセレクトしています。その後実行されるのがngAfterViewInit()メソッドで、この中でこのテンプレート内のテキストを取り出してthis._name変数に設定します。これはテンプレートの中でsvgIconに渡されるようになっているため、正しくAngular Materialのアイコンが利用できます。

icon.component.ts
import {
  Component,
  ElementRef,
  ViewChild,
  AfterViewInit,
  Input
} from '@angular/core';

@Component({
  selector: 'app-icon',
  templateUrl: './icon.component.html',
  styleUrls: ['./icon.component.scss']
})
export class IconComponent implements AfterViewInit {
  private _name: string;
  get name() {
    return this._name;
  }

  @ViewChild('iconName')
  private iconName: ElementRef<HTMLTemplateElement>;

  constructor() {}

  ngAfterViewInit() {
    this._name = this.iconName.nativeElement.innerText;
  }
}

なお、一度表示してから評価して、その結果をまた評価しているのでパフォーマンス上はよくない気がしています。

明日は @puku0x さんです。

Schematicsを作ってみよう

この記事は Angular Advent Calendar 2018 の 3 日目の記事です。

みなさん今日もAngular CLI使っていますか?

Angular CLIは、ビルド周りや開発サーバの面倒を見てくれるだけでなく、ng generateng addといったコマンドを実行するだけで必要なファイルの生成はもちろん、モジュールの追加や設定の変更も行ってくれるので非常に便利ですよね。

これらの処理はAngular Schematicsによって定義されており、自分で作ることもできます。また、既存のSchematicsを拡張することも可能です。

どうでしょう?試してみたくありませんか?
それでは、早速やってみましょう!

はじめよう

まずはschematics-cliをインストールしましょう。

$ npm install -g @angular-devkit/schematics-cli

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

$ schematics blank my-schematics
$ cd my-schematics

blankの他にschematicを指定すると実装サンプル付きのプロジェクトが作られます

生成されたファイルを見てみましょう。

package.json
{
  "name": "my-schematics",
  "schematics": "./src/collection.json",
  "dependencies": {
  }
}

package.jsonschematicsに指定されているcollection.jsonに実行対象となるSchematicsが入っています。

src/collection.json
{
  "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
  "schematics": {
    "my-schematics": {
      "description": "A blank schematic.",
      "factory": "./my-schematics/index#mySchematics"
    }
  }
}

my-schematicsが呼ばれたときに実行されるメソッドが./my-schematics/index.tsmySchematicsという事がわかります。

$schemaはIDEでの補完等に使われるものなので必須ではありません。

Schematicsの中身はRules(= Treeを返す関数)を返す関数です。Treeはファイルシステムやファイルの変更が含まれています。

src/my-schematics/index.ts
import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';

export function mySchematics(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    return tree;
  };
}

作ってみよう

基本となるのはTreeの操作です。

src/my-schematics/index.ts
export function mySchematics(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    tree.create(_options.path + '/hi', 'Hello world!');
    return tree;
  };
}

ビルドして実行すると、Schematicsの出力を見ることができます。

$ npm run build
$ schematics .:my-schematics --path=./
CREATE /hi (12 bytes)

デフォルトではプレビューだけですが、--dry-run=falseを指定することで実際にファイルが生成されるようになります。

$ schematics .:my-schematics --path=./ --dry-run=false
CREATE /hi (12 bytes)
$ cat hi
Hello world!

ログ出力

進捗やエラー表示などにはloggerを用います。用途に応じて、

  • debug
  • info
  • warn
  • error

などがあります。

export function mySchematics(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    _context.logger.info('🎉 Hello, schematics!');
    return tree;
  };
}

テンプレート

コンポーネント等に使うファイル一式を生成する際はテンプレートを利用しましょう。

src/my-schematics/index.ts
import { strings } from '@angular-devkit/core';
import {
  Rule,
  SchematicContext,
  apply,
  mergeWith,
  template,
  url,
} from '@angular-devkit/schematics';

export function mySchematics(_options: any): Rule {
  return (_, _context: SchematicContext) => {
    return mergeWith(apply(url('./files'), [
      template({
        ...strings,
        name: _options.name,
      }),
    ]));
  };
}

url()でテンプレートのパスを指定し、template()を実行しましょう。

ファイル名は__name__のようにアンダースコアで囲まれた部分が置換されます。

src/my-schematics/files/__name__.component.css
/* styles */

テンプレートはEJSのような構文で、 <% %>で囲んだ部分に条件式を書いたり、<%= %>で囲んだ部分に文字列を代入することができます。

src/my-schematics/files/__name__.component.ts
<% if (name) { %>
  Hello <%= name %>, I'm a schematic.
<% } else { %>
  Why don't you give me your name with --name?
<% } %>

@angular-devkit/coreには、セレクタ→クラス名の変換(classify)やケバブケースへの変換(dasherize)といった処理のための便利なユーティリティもあるので是非活用しましょう。

src/my-schematics/files/__name__.component.css
import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'my-<%= name %>',
  templateUrl: './<%= name %>.component.html',
  styleUrls: ['./<%= name %>.component.css'] }
})
export class <%= classify(name) %>Component implements OnInit {

  constructor() { }

  ngOnInit() {
  }

}

それでは、実行してみましょう。

$ schematics .:my-schematics --name=sample --dry-run=false
CREATE /sample.component.css (12 bytes)
CREATE /sample.component.html (35 bytes)
CREATE /sample.component.ts (270 bytes)

コンポーネントが生成されました :tada:

公開する

いい感じのSchematicsができたら是非公開してみましょう!

$ npm run build
$ npm publish

おわりに

今回は単純なサンプルの紹介でしたが、ng addなど他のSchematicsの実装例は以下のリポジトリを参考にすると良いと思います。

https://github.com/angular/material2/tree/master/src/lib/schematics
https://github.com/ng-bootstrap/schematics
https://github.com/ionic-team/ionic/tree/master/angular/src/schematics

Onsen UIのSchematicsも開発中です!(宣伝)
https://github.com/OnsenUI/OnsenUI/pull/2591

ちなみにschematics-cliのヘルプは↓で表示できます。

$ schematics --help

利用可能なAPIについてはGitHubのドキュメントを読みましょう。
https://github.com/angular/angular-cli/tree/master/packages/angular_devkit/schematics

明日は@MasanobuAkibaさんです!

参考文献

https://medium.com/@michael.warneke/merging-custom-angular-schematics-c14a303f63b6
https://github.com/angular/angular-cli/tree/master/packages/schematics/angular
https://blog.angular.io/schematics-an-introduction-dc1dfbc2a2b2

SSR の知識ゼロから始める Angular Universal

はじめに

これは Angular Advent Calendar 2018 4日目の記事です。

こんにちは (。・ω・。)
Angular で CGM サービスを運用・構築したり、ng-japan の slack で emoji を追加することを生業としている者です。(コミュニケーションの場は本格的にspectrum へ移行することが決定したため emoji 業者としての活動は終わりになりそうです;;)


自分は今年の中旬くらいから担当している Angular プロジェクトを SSR 化していたのですが、実践的な流れを網羅する情報が存在せず非常に苦労しました。
今回はその経験を生かして Angular の Universal 化に関する実践的なまとめを作成することにします。

この記事は SSR の知識 0 の方でも読み進められるように大きく分けて

の 3 部構成となっています。
SSR を導入するか迷っている方は最初から、すでに SSR の知識を持っている方は #Angular での構築・実装 から読んでいただければ幸いです。

#SSR を導入した結果

いきなり結果から書きます!
そもそも物事を行った結果に満足できないのであれば、その過程を学ぶ意味が薄くなります。
結果がわからないまま長文を読み進め、最後に落胆してしまっては時間の無駄となってしまいますね。
そのため最初に実際に導入した結果を見て、本当に SPA × SSR で良いのか、他の選択肢はないのか?という点を考えていただく構成としました。

特に効果が現れた指標

LightHouse の改善

FCP、FMP が大きく改善。Performance のスコアが緑に乗りました。
ちなみに SSR 前は 20 程でした...(小声

スクリーンショット 2018-12-04 7.33.11.png

Index 数の増加

SSR 導入以前も Fetch as Google で認識され、インデックスもされていました。しかし SSR 導入後は比較にならないスピードで反映され始めています。
ただし、この結果はサイトによって様々だと考えられます。

今回の例では、もともとのパフォーマンスが悪かったため改善率が高かった可能性があり、現状で充分なパフォーマンスを示せているのであれば SSR は必要ないかもしれません。

※ 実数値は伏せます

スクリーンショット 2018-12-02 19.31.43.png

検索流入の増加

Index の増加から少し遅れて検索流入数も増え始めました。

スクリーンショット 2018-11-30 19.05.31.png

サーチコンソールのエラー減少

SPA のインデックスは SSR をしなくてもパフォーマンス次第で達成可能です。
しかし、サーチコンソールのエラーだけは SSR を導入しないと解消が難しいものあります。
SSR 導入以前に対処不能で増え続けていたサーチコンソール上のエラーは減り続け、日に日に 0 へ近づいています。

スクリーンショット 2018-12-02 18.11.59.png

新規ユーザーの直帰率改善

Bot に対しての数値的効果は見えましたが、肝心のユーザーに対して目に見える効果はほとんどありませんでした。
これは SSR がユーザーに対して効果を及ぼす範囲が、実質的に新規ユーザーの 1 ページ目だけだからだと考えられます。
(2 ページ目以降は、SPA。二回目以降は ServiceWorker からページを取得します)
そう考えると効果がある指標は新規ユーザーの直帰率がメインでしょうか・・・?
実際この指標に関しては、SSR 導入後からじわじわ減り始めました。

スクリーンショット 2018-12-02 18.33.44.png

このサービスの規模感

こんな効果があったよ!と言われてもそのサービスの規模感がわからなければなんとも言えません。
1 ⇢ 2 と 100 ⇢ 200 の難易度は全然違いますから。

今回例に出したサービスは現在月間 4,000 万PV程の規模で、約 1 年前にリリースしたものになります。
主な使用技術は Angular + ngrx + firestore + gcp です。
エンジニアは自分 1 人で、デザイナーが 2 人、ディレクターが 3,4 人というチーム構成でやっています。

#SSR についての基礎知識

サーバーサイドレンダリング(SSR)とは、その名の通りサーバー側でアプリケーションの HTML を生成し、レスポンスとして返すことを言います。

一般的に利用されている MPA(Multiple Page Application)では言うまでもなく行われていることなので、SSR というワードは自ずと SPA(Single Page Application)を構築する際のオプション機能を指します。
オプションという言葉を用いた理由は、SSR が SPA を構築する際の必須項目ではないからです。
次節からその理由を説明していきます。

SSR の目的

SSR を行う目的は、Web アプリケーションを SPA にすることと引き換えに失った

① 初回描画速度(First Meaningful Paint => FMP)
② ページごとの静的HTML

を取り戻すことです。

image.png

参照:Next.js on Cloud Functions for Firebase with Firebase Hosting

初回描画速度について

SPA は初回アクセス時に全ページの描画に必要な JavaScript ファイルをダウンロードします。
そしてページを切り替え時に対象ページの描画に必要な部分だけを取得・更新することで、非常に快適な操作性を実現しました。
しかし初アクセス時のリソースファイル量及び初期化処理も重くなってしまい、初回描画速度低下につながります。

とある研究結果 によると、ページの表示に 3 秒以上かかるサイトではモバイルユーザーの 53% が離脱するようです。
中規模以上の SPA の場合、アプリケーションの立ち上げに数秒程度を要してしまうため、サービスの規模が大きくなるに連れて、この問題は無視できないものになっていきます。

ページごとの静的HTMLについて

SPA の各ページへアクセスした際に、レスポンスとして返ってくる HTML は常に index.html です。
この index.html には共通のソースコードが記述されており、アクセスしたページによって差はありません。
SPA では index.html 内の初期化処理で現在自分がいる URL を把握し、そのページに対応したコンテンツを動的に生成するからです。

この処理に関してユーザーへの影響はありませんが、サイトを確認しに来たクローラーにとっては大問題です。
検索エンジン以外のクローラーは JavaScript を実行できず、常に index.html の内容を認識してしまいます。

もう少し具体的に言うと、Fetch as Google に関しては問題ありません が、Twitter や FaceBook、LINE、Slack 等のビジュアライザーが正常に機能しません。
2018 年現在 SNS からの流入が無視できない規模になっている現状を考えると、どうしても最低限の SSR は必要だと考えられます。

逆に Twitter 等のクローラーがアップデートされ、JavaScript を正常に実行してくれるようになりさえすれば、SEO 的観点からの SSR は ほぼ不要 となるかもしれません。

様々な種類のレンダリングアプローチ

SSR の情報収集を始めていくと、SSR にもいくつかの種類があることがわかります。
今回はそれらのレンダリングアプローチのどれを使用すればよいのかという点に絞ってフローチャートにまとめてみました。

Which Rendering-Approach is better_ (13).png

※ この記事を書いている最中に、SPA の SEO に関する 素晴らしい 記事が公開されました。同様のフローチャートも掲載されているので合わせてご覧いただくとより情報の精度が高まると思います。
参照:【記事版】State of SEO for SPA 2018

各アプローチの補足・解説

※ TTFB のマイナス評価(✕)はCDNキャッシュ等を適切に利用することで緩和できます。

CSR ( Client Side Rendering )

TTFB ( Time To First Byte ) FCP ( First Contentful Paint ) FMP ( First Meaningful Paint ) TTI ( Time To Interactive)

クライアント ( ブラウザ上 ) 側でパスに対応したコンテンツを動的に生成する方式。

ホスティングサーバーは常に index.html を返し、同一の JavaScript が現在の URL からコンテンツを出し分けます。
サーバー側の処理がないため、当然 TTFB は早くなります。

App Shell(一部 Pre Rendering)

TTFB ( Time To First Byte ) FCP ( First Contentful Paint ) FMP ( First Meaningful Paint ) TTI ( Time To Interactive )

ネイティブ アプリのように瞬時に、そしてネットワーク環境に依存せず確実に読み込めるPWA を構築するアーキテクチャの一つ。
これがどういうものかというと、まずアプリケーションを構築する静的なパーツであるナビゲーション等を予めレンダリングして index.html に組み込んでおきます。(表現方法を変えると、アプリケーションの一部分をプリレンダリングしているということです)
そしてローカルキャッシュからそれらの要素を瞬時に表示し、ユーザーの意識を引きつけた後にコンテンツの取得・表示を行います。
その結果、ユーザーはアプリケーションが瞬時に応答したと認識し体感レスポンスが向上します。
また、コンテンツ部分のオフライン対応をすると index.html が取得できない電波状況でもアプリケーションを起動することができます。

image.png

参照:App Shell モデル | Web | Google Developers

Angular であれば、CLI の generate app-shell を利用することですぐに導入できます。
初めてこの概念を知って試してみた際、上記手順になぜサーバー側のアプリケーションモジュールが必要か疑問に思ったのですが、App Shell モデルがプリレンダリングの一種だと認識することで解消されました。
(;・∀・)

参照:stories app shell - angular/angular-cli Wiki

Pre Rendering

TTFB ( Time To First Byte ) FCP ( First Contentful Paint ) FMP ( First Meaningful Paint ) TTI ( Time To Interactive )

最強(ただし静的で小規模なコンテンツに限る)

事前に URL のリストから各ページのレンダリング処理を行い、その結果を html として保存しておく方式。
静的なファイル配信のため TTFB も早く、完全にレンダリングされたページをすぐに描画できるため FCP、FMP、TTI も早い。

レンダリング方法を決定する上で、コンテンツが動的か否かという点は非常に重要です。
コンテンツが会社概要等の静的なページ、または運営側が少数の記事を追加する程度であれば動的な SSR は実装する必要がないからです。
そういったページしか必要のないプロジェクトであれば、ローカルや CI ツール上で全てのページを事前にレンダリング(プリレンダリング)することで、静的な HTML を配信できます。
この方法はサーバー側の処理が一切必要ないため、ホスティングコストがほとんど発生しませんし、配信速度もこれ以上なく高速です。
その上プロジェクトの SSR 対応を行う必要がないため、ソースコードの変更もメタタグ関連程度です。

プリレンダリングが採用可能なプロジェクトなのであれば、簡単かつ低コスト、それでいて高パフォーマンスなため、これを選ばない理由がありません。
プロジェクトの特性をよく確認し、今一度本当に SSR が必要か考えてください。

参照:Angular Universal on Google App Engine ※GAE成分控えめ
参照:https://github.com/Angular-RU/angular-universal-starter/blob/master/prerender.ts

Dynamic Rendering

TTFB ( Time To First Byte ) FCP ( First Contentful Paint ) FMP ( First Meaningful Paint ) TTI ( Time To Interactive )
Client
Crawler ✕✕✕

Google、Yahoo、Bing 等の検索エンジンのクローラー及び Twitter や Facebook に代表される SNS のクローラーに対してはプリレンダリングした結果を返し、ユーザーのアクセスに対しては index.html を返す( CSR する )方式です。
クローラーとユーザーに返す結果が異なるとクローキングとみなされそうで心配ですが、最終的な結果が同じであれば問題ないようです。
(参照サイトではプリレンダリングではなくサーバーサイドレンダリングと記載してありましたが、SSR が実装済であればそれをそのまま返したほうが良いと思われるので、ここでいうサーバーサイドレンダリングとは Rendertron 等の外部レンダリングソリューションを介してプリレンダリングしたものと解釈しました。)

この方法を利用することで JavaScript を実行できない SNS のクローラーも動的なメタタグを認識できるようになり SPA で最も問題となる部分をクリアできます。
また、この方法は実装コストも低く一日あれば充分リリースまで視野に入るレベルです。

良いことづくめのように思えますが、やはり SSR 用にチューニングされたものと比べると速度面で大きく劣ります。
キャッシュが存在しない状態で状態でアクセスすると Lighthouse のパフォーマンススコアが 100 のページでさえ数秒程度かかるので、現実的なサイトでは 10 秒ほどかかってしまうかもしれません。
https://render-tron.appspot.com/ から試せます)

対象のページ数が少なければ新しいバージョンをリリースした際に順次キャッシュを作っていくという手法もとれるかもしれませんが、ページ数が多いとそれも難しくなります。
キャッシュ前に各種クローラーが見に来てしまうと正常に反映されない等の問題が生じてしまいますし、グーグルに低速なサイトと判断されてしまうかもしれません。
特に Twitter のクローラーはレスポンスを 10 秒までに返さないと離脱し、おそらく数日その結果がキャッシュされてしまうため注意が必要です。

Dynamic Rendering (1).png

Rendertron で SNS のクローラーだけに対応したいという場合は、index.html に window.renderComplete = false; を追記し、メタタグを設置し終えるタイミングで window.renderComplete = true; を実行すると最低限の処理で切り上げてくれます。

自分も上図の構成で SSR 対応するまでのつなぎとして利用していましたが、やはり速度面がネックでたまに Twitter のクローラーに相手にされないことがありました。
しかし実装コストがとても低いため、本格的な SSR を実装するまでのつなぎとして一時的に OGP 対応を任せても良いと思います。無いよりはマシですから。

参照:Angular SEO with Rendertron
参照:JSサイトのための第4のレンダリング構成としてダイナミックレンダリングをGoogleが発表 #io18 #io18jp

Hybrid Rendering

TTFB ( Time To First Byte ) FCP ( First Contentful Paint ) FMP ( First Meaningful Paint ) TTI ( Time To Interactive )

MPA のレンダリング方法をわざわざ SSR とは表さないので、おそらく一般的にサーバーサイドレンダリングと呼んで頭に浮かぶのがこの Hybrid Rendering のフローかと思います。
Hybrid Rendering はリクエストされたページのレンダリング結果を index.html に埋め込んでクライアント側に返却し、別のページに遷移したりコンテンツを追加する場合は CSR を行う手法です。
SSR 知識ゼロの場合に意識してほしいのは、単純に SSR をして一見ページの表示が早くなったように見えてもその裏では CSR と同じ処理が動いているということです。
言い換えるとローディングアニメーション代わりにそのページのレンダリング結果を利用しているのです。

image.png

従って、ただ単に SSR しても TTI までの時間は変わりません。
それどころかページの描画処理や通信量が増えてしまうため遅くなる可能性すらあります。

この問題は後の節で解説するハイドレーションという工程を行うことで、ある程度緩和することができます。
また、SPA にページや処理を追加し続けていくと main.bundle の容量が凄いことになってしまうため、小規模サイトを除きページや機能ごとに bundle を分割して lazy load する実装も必須となってきます。

参照:Universally speaking | Craig Spence | AngularConnect 2018

SSR + App Shell

TTFB ( Time To First Byte ) FCP ( First Contentful Paint ) FMP ( First Meaningful Paint ) TTI ( Time To Interactive )
CSR
SSR

② と ⑤ の合わせ技です。
初回に訪問したユーザー、またはクローラーに対しては SSR を行い完全な HTML を返します。
二回目以降に訪問したユーザーに対しては、初回にキャッシュした App Shell を表示し CSR を行います。

これにより、ユーザーとクローラーそれぞれに対しての挙動が最適化されます。

しかし TTI までの時間が最適化されてないとその分 FMP が遅れてしまうため、UX が悪化する可能性があります。場合によっては ⑤ のままの方が良いでしょう。

#Angular での構築・実装

ここからは 様々な種類のレンダリングアプローチ でいう ⑤ にあたる Hybrid Rendering を Angular フレームワーク上でどうやって実装していけば良いのか解説していきます。

ベースの環境構築

素振り

いきなり本番に入ると情報量が多すぎて収拾がつかなくなってしまうため、まずは最小限の構成で素振りをするのが無難かと思います。

素振りをするのに一番向いている記事は stories universal rendering - angular/angular-cli Wiki です。余計な文章が一切存在しないためゴールまでが非常に明快でオススメです。また、CLI ツールの Wiki は比較的メンテナンスされていてバージョン違いで陳腐化した記述が少ない印象です。

一旦コピペで動かしてみて、主要な要素をなんとなく理解しておくと後の作業が捗りやすくなるでしょう。

ベースプロジェクト選定

Universal プロジェクトのベースを構築する上で一番悩んだのが SSR 開発時のビルドについてです。
なんせ公式のドキュメントや主なシードプレジェクトではだいたい ↓ これでビルドしてと書いてあるのです。

npm run build:ssr && npm run serve:ssr

これはつまり、何かを変更するたびに手動でビルドを行わなければならないことを意味します。
Nuxt はそういうの自動でやってくれたんだから Angular でもあるだろうなと思い、似たようなプロジェクトを探していたのですが一向に見つかりません。
仕方がないのでタスクランナーや ng build --watch を駆使してソースコードの変更に反応してビルド後サーバーを再起動 + ブラウザをリロードしてたりもしたのですが、もちろん開発体験が良くありませんでした。

そんな状況で騙し騙し開発を続けていたのですが、ある日素晴らしいプロジェクトを発見しました。
それが enten/udk ( Universal Development Kit ) です。

このプロジェクトは上記の変更検知 ~ ブラウザに反映までの一連の流れを抽象化し、どんなプロジェクトにでも組み込めるよう整備したものです。
そしてこれを利用した Angular 版のサンプルプロジェクトが enten/angular-universal になります。
現時点ではおそらくこのプロジェクトをベースに始めないとビルド関連の不毛な設定で苦しむことになります。angular のバージョンやちょっとしたパスのミスで全然違うところのエラーが発生し、原因の特定に時間を浪費するため本当に辛い。

udk を利用することで、そういった面倒な部分を全て吸収してくれます;;
しかも SSR しながら HMR できるため、変更の反映が非常に高速です。

68747470733a2f2f692e696d6775722e636f6d2f76507a434d426b2e676966.gif

参照:https://github.com/enten/udk


断定的な書き方をしてしまいましたが、もしかしたら他にも良い方法があるかもしれません。しかし、Angular Connect に代表される直近の発表でもビルド周りには苦労している発言があったため望み薄かと思われます。ただしこれは 2018/11 時点での話ですので、未来でこの記事を読んでいる方はプロジェクトを始める前に github のリポジトリを一通り検索してみることをお勧めします。

nestjs

参照:https://nestjs.com/

nestjs とは

nestjs は、Angular ライクにサーバーサイドの処理を記述できるプログレッシブ NodeJS フレームワークです。プログレッシブとなっているのは、現在利用している express 等他のフレームワークと共存もできるからです。例えば、豊富な express のミドルウェア資産を使いつつそれを拡張して nestjs 化できます。
自分の中では js でいう Typescript、css でいう scss みたいなイメージですね。
スター数は 12/2 時点で 10,517。直近のカンファレンス等では毎回名前が上がるほどの勢いを持っています。

Angular Universal のチュートリアルやサンプル記事では、サーバーサイドのフレームワークとして express が使われている事例が多いででしょう。しかしアプリケーション側の Angular とは記述法が異なるため、サーバー ⇠⇢ アプリケーションで頭を切り替えないといけませんし、express で不足している部分に関しては独自の拡張を繰り返す結果だんだんと統一感がなくなっていってしまいます。
そこで nestjs を使うと Angular と同じ世界観をサーバーサイドに提供し、一定のルールで縛ってくれます。Angular で利用されているモジュール、DI、サービス、ガード等の概念が同一のシンタックスで利用できるため、アプリケーション側で Angular を利用している方ならドキュメントをさらっと眺めるだけですぐに使えるようになると思います。
Angular を Universal 化するなら是非組み込んでおきたいフレームワークです。
(express 以外のフレームワークを使っている方にもお薦めです!)

もちろんサーバーサイドでやりたいことが SSR だけなのであれば無理に導入する必要はありません。
しかし Universal 化を行うということは SEO を最適化したいということで、そうすると最低でもサイトマップを生成する機能が必要となってきます。他にもちょっとした API を生やしたり、ログインしている人に対しては別のロジックで SSR を行いたい等、後の拡張を予想するのであれば使っておいて損はないと思います。

また、公式から nestjs × Angular Universal 導入用のモジュールも提供されているため、普通にやるよりむしろ簡単かもしれません。(nestjs/ng-universal

使用例

最近ドキュメントが再整備されたため非常に始めやすい環境になっていると思います。
導入にしてみようかなと思った方は、こちらのスライドにも目を通しておくことをお勧めします。
参照:Nest the backend for your Angular Application @AngularConnect

コントローラー

import { Controller, Get } from '@nestjs/common';

@Controller('cats')
export class CatsController {
  @Get()
  findAll() {
    return 'This action returns all cats';
  }
}

サービス

import { Injectable } from '@nestjs/common';
import { Cat } from './interfaces/cat.interface';

@Injectable()
export class CatsService {
  private readonly cats: Cat[] = [];

  create(cat: Cat) {
    this.cats.push(cat);
  }

  findAll(): Cat[] {
    return this.cats;
  }
}

モジュール

import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class CatsModule {}

ガード

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    return validateRequest(request);
  }
}

使用時の注意事項

noImplicitAny: true で実装されていないのでコレがマージされるまでは、tsconfig.server.jsonnoImplicitAnyfalse にしておく必要があります。

Client と Server での処理の切り分け

Universal における大きな問題の一つに Client で動作しているコードがそのままサーバーサイドで動かないという点があります。この問題の多くはブラウザ上に存在する Window 等のグローバルオブジェクトがサーバーサイドには存在しないことが原因で発生します。

この節ではそういったグローバルオブジェクトを利用し、そのままではサーバーサイドでエラーとなってしまう部分をどうやって回避するかを説明していきます。

モックオブジェクトを利用した切り分け

最も問題となる、つまり出現頻度の高いオブジェクトは Window です。あまりにも使われる場所が多く、下手すると外部ライブラリに紛れ込んでいる可能性もあります。Universal 対応をしていると window is not define というエラー文言にはほぼ 100% 遭遇するでしょう。

そういった部分に対していちいち対応していてはキリがないため、サーバーサイドでは Window オブジェクトのモックを作成しグローバルに適応することにしました。Window のモック生成には公式で採用されている Domino というライブラリを利用します。
先程挙げた nestjs/ng-universal 内に applyDomino というとてもわかり易い名前の関数が定義されているので利用すると良いでしょう。

これで大部分のエラーは解消するはずですが、モックが対応していないプロパティもいくつか存在します。そのため Window オブジェクト自体はラッパーサービスからのみ参照するようにし、アプリケーション側で直に参照する実装は控えましょう。問題が発生した際に対処のしやすさが全く異なります。

platformId を利用した切り分け

クライアントとサーバーの切り分けで一番作業量が多くなるのが platformId での切り分けです。
platformId はその名の通り、現在 Angular が動作している環境を示すオブジェクトが入っている定数で、@angular/commonで公開されている isPlatformServerisPlatformBrowser と組み合わせることでクライアントとサーバーの処理を切り分けられます。使い方は簡単で下記のように DI して if 文の条件に使うだけです。

import { PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';

...

constructor(@Inject(PLATFORM_ID) private platformId: Object) {}

...

ngOnInit() {
  if (isPlatformBrowser(this.platformId)) {
    // Client only
  }

  if (isPlatformServer(this.platformId)) {
    // Server only
  }
}

...

しかしこれでは、ソースコードのいたるところにごちゃごちゃと if 文が混入し、コードの見通しが悪くなってしまいますね...
そこで少しでもこの問題を緩和するため、クライアントサイドでのみ実行する補助コンポーネントを作成し極力コンポーネントの外部でそういった関係性を記述するようにしました。

page.component.html
<app-client-only>
  <app-ad clientId="xxx-xxx-xxx"></app-ad>
</app-client-only>
client-only.component.html
<ng-content *ngIf="isClient"></ng-content>

DI を利用した切り分け

platformId での分岐作業を進めていくと、上記までの分岐ロジックはほとんどサービスクラスに集中してくることが分かります。そういった場合には、DI を利用してリファクタすることでコードの見通しが格段に良くなります。

具体例として、自分は Firestore を利用した DB サービスの切り分け等に利用しています。
実装イメージはこんな感じ。

db.client.service.ts
@Injectable()
export class DbService implements DbServiceInterface {
  public getDocument() {
    // リアルタイム通信で取得
  }

  public getCollection() {
    // リアルタイム通信で取得
  }
}
db.server.service.ts
@Injectable()
export class DbServiceForServer implements DbServiceInterface {
  public getDocument() {
    // 単発で取得
  }

  public getCollection() {
    // 単発で取得
  }
}
app.server.module.ts
@NgModule({
    ...
    providers: [
        ...
        { provide: DbService, useClass: DbServiceForServer },
    ],
})
export class AppServerModule {}

Firestore の売りとして WebSocket を介したリアルタム通信があるのですが、サーバーサイドでは呼び出し時点のデータだけ取得できればよく、リアルタイム通信はむしろ邪魔です。
そのため Firestore のデータ取得部分を共通のインターフェースでサービスとして切り出しました。
そしてクライアント側はリアルタイム通信で取得するメソッド、サーバー側は単発で取得するメソッドをコールすることで、それらを束ねるサービスクラスではクライアントとサーバーの差異を意識することなく実装ができます。

ハイドレーション

ハイドレーションとは

ハイドレーションとはサーバーサイドでの通信結果やランダム・重い処理の実行結果を json として html に埋め込み、クライアントではその結果を流用することで瞬時に DOM を再現する手法です。
この工程を行うことで時間のかかってしまう処理をスキップすることができ、結果として TTI までの時間が短縮されます。
また副次的な効果として画面のチラつきを抑制し、シームレスな CSR 移行を実現できます。

Hydration (1).png

TransferState API

Angular でのハイドレーションには、@angular/platform-browser/TransferState API を使用します。
この API を利用することでサーバー側でキーに対して登録した値を index.html に埋め込み、クライアント側でその値を復元して取得する流れを簡単に実装することができます。

下準備

ServerTransferStateModuleBrowserTransferStateModule をサーバー側とクライアント側のモジュールに import します。

app.server.module.ts
@NgModule({
  imports: [
    ...
    ServerTransferStateModule
  ],
  ...
})
export class AppServerModule {}
app.browser.module.ts
@NgModule({
  imports: [
    ...
    BrowserTransferStateModule
  ],
  ...
})
export class AppBrowserModule {}

State Key の作成

サーバーの結果をブラウザに転送する際に登録及び参照を行うためのキーの発行には @angular/platform-browser/makeStateKey を利用します。

export const ARTICLE_DETAIL_STATE_KEY = makeStateKey('ARTICLE_DETAIL');

サーバー側で登録

article-detail.component.ts
async ngOnInit() {
  const article = await this.articleService.getCurrentItem();
  ...
  if (isPlatformServer(this.platformId)) {
    this.transferState.onSerialize(STATE_KEY, () => {
      return article;
    });
  }
}

クライアント側で取得

article-detail.component.ts
async ngOnInit() {
  const article = this.transferState.get<Article>(STATE_KEY, null);
}

TransferState API の汎用化

TransferState API を使うことで比較的簡単にハイドレーションができると言っても、ソースコードのあちこちに if (this.isServer) だの this.transferState.get が入り込んでしまうと変更に弱い上にコードの見通しが悪くなってしまいます。

そこで下記コードを

article-list.component.ts[before]
ngOnInit() {
  this.articles$ = this.articleDb.findList();
}

この程度の手間で Universal 化できるようにしました。
stateKey を作る際にクライアントとサーバーで共通する unique な文字列が必要なのですが、ベストプラクティスが見つからなかったためコンポーネントのタグ名をキーにすることが多々あります。コンポーネントのタグは DI するだけで下準備なしに参照でき、フレームワーク側でユニーク性を担保してくれるためキーにうってつけでした。

article-list.component.ts[after]
constructor(
  private elementRef: ElementRef,
  ...
) {}

ngOnInit() {
  this.articles$ = this.transferStateService.getItems(
    this.elementRef.nativeElement.tagName,
    this.articleDb.findList()
  );
}

共通部分の処理はこんな感じです。
若干やることが変わるため getItemgetItemsgetValue の三種類を定義し使い分けています。

transfer-state.service.ts
    public getItems<T>(baseString: string, stream$: Observable<T[]>): Observable<T[]> {
        const key = `${baseString}-ITEMS`;
        this.transferStateKeys[key] = this.transferStateKeys[key] || makeStateKey(key);
        const serverResult = this.get<T[]>(this.transferStateKeys[key]);

        if (serverResult) {
            this.remove(this.transferStateKeys[key]);

            return of(serverResult);
        }

        return stream$.pipe(map(items => this.setServerValue(key, items)));
    }

    private setServerValue<T>(key: string, value: T): T {
        if (this.isServer) {
            this.onSerialize(this.transferStateKeys[key], () => value);
        }

        return value;
    }

NgRx でのハイドレーション例

NgRx の転送は上記までの基本と Root の State をセットするアクションを定義することで実装できます。

root-store.module.ts
export const SET_ROOT_STATE_TYPE = '[Init] SetRootState';
export const NGRX_STATE = makeStateKey(NgrxTransferStateKey);
export const MetaReducers: MetaReducer<fromRoot.State>[] = [stateSetter];

export function stateSetter(reducer: ActionReducer<any>): ActionReducer<any> {
    return function(state: any, action: any) {
        if (action.type === SET_ROOT_STATE_TYPE && action.payload) {
            return action.payload;
        }

        return reducer(state, action);
    };
}

@NgModule({
    imports: [
        StoreModule.forRoot(fromRoot.Reducers, { metaReducers: MetaReducers }),
        ...
    ],
    ...
})
export class RootStoreModule {
    constructor(
        @Inject(PLATFORM_ID) private platformId: Object,
        ...
        private readonly store: Store<fromRoot.State>,
        private readonly transferState: TransferState,
    ) {
        ...
        if (isPlatformBrowser(this.platformId)) {
            this.onBrowser();
        } else {
            this.onServer();
        }
    }

    private onServer() {
        ...
        this.transferState.onSerialize(NGRX_STATE, () => {
            let state;
            this.store
                .subscribe((saveState: any) => {
                    state = saveState;
                })
                .unsubscribe();

            return state;
        });
    }

    private onBrowser() {
        const state = this.transferState.get<any>(NGRX_STATE);

        this.transferState.remove(NGRX_STATE);
        this.store.dispatch({ type: SET_ROOT_STATE_TYPE, payload: state });
    }
}

参照:https://github.com/ngrx/platform/issues/101#issuecomment-351998548

その他の Tips

初回アニメーションの無効

ページを移動した時やページ内で新しい要素が出現する際、それらの挙動をわかりやすく伝えるためにアニメーションを使うことが多々あると思います。最近マイクロインタラクションというワードもバズっていますし尚更ですね。

しかしそういった処理を普通に書いたままだと SSR から CSR に移行する際に非常に強い違和感を覚えます。ハイドレーションをしないでクライアントサイドでも API 通信が走り、その間に一瞬チラつく現象に近い違和感です。そのチラつきの部分がアニメーションに置き換わったと言えばよいでしょうか。

その現象がわかりやすく発生しているのがとあるメジャーなシードプロジェクトです。一旦描画されたものが一瞬消え、その直後にスライドインアニメーションでページが表示されます。(発生しているのは 2018/11 時点)
http://ng-seed.fulls1z3.com/

この現象を食い止めるためにはサーバーサイドレンダリングされた際、ページを移動するまでアニメーションを無効にしなければなりません。調べてみたところ、そういった実装には @angular/animations[@.disabled] が使えそうでした。現在はこれでページを移動するまでアニメーションを無効にしていますが、もしかするともっと良い方法があるかもしれません。

グローバル css が反映されない

Angular で SSR する際、コンポーネントに付属する css はインラインで展開され html に注入されます。
しかし、angular.json の styles に登録したグローバルな css は js 実行時に展開されるため、反映が遅れてしまいますし、SSR をする目的の一つである js が満足に実行できない状態でも閲覧可能というメリットが薄れてしまいます。

この問題を発見した当初、ビルドが完了した後に index.html 内の link タグに指定されているの css ファイルの中身で置換してみましたが、ngsw のハッシュ値がずれるためキャッシュコントロールが正常に機能しなくなってしまいました。どうやらビルド結果をいじってはいけないようです。

さほど影響もないため一旦放置していたのですが、先日の Node 学園で inline-style 用のコンポーネントを作成すれば良いという情報を入手。現在はこの解決策のおかげで js を off にしていてもページレイアウトが崩れることなく最低限の閲覧はできるようになりました。

参照:https://github.com/Angular-RU/angular-universal-starter/commit/dbb413b5422f23ba50b812522168ae7497b5d9ef

initialNavigation

RouterModule.forRootinitialNavigationenabled にしておかないと遅延読込するルーティングが CSR になり SSR を行う旨味が激減します。

app.module.ts
@NgModule({
    imports: [
        ...
        RouterModule.forRoot(ROUTES, { initialNavigation: 'enabled' }),
    ],
    declarations: [AppComponent],
    bootstrap: [AppComponent],
})
export class AppModule {}

stylePreprocessorOptions

stylePreprocessorOptions は css で @import する際のパスを短縮するオプションです。
登録されたパス配下のファイルはディレクトリ部分の記述無しで呼び出すことができます。

hoge.component.scss
@import 'variables';

stylePreprocessorOptions を使っている場合は server のビルド設定にも追加しておいてください。

angular.json
                "build": {
                    "builder": "@angular-devkit/build-angular:browser",
                    "options": {
                        "styles": [{
                                "input": "node_modules/@angular/material/_theming.scss"
                            },
                            {
                                "input": "src/client/styles/main.scss"
                            }
                        ],
                        "stylePreprocessorOptions": {
                            "includePaths": ["src/client/styles"]
                        },
                    }
                },
                "server": {
                    "builder": "@angular-devkit/build-angular:server",
                    "options": {
+                        "stylePreprocessorOptions": {
+                            "includePaths": ["src/client/styles"]
+                        }
                    },

本番ビルド時のメモリ不足

SSR アプリケーションをある程度の規模で作っていると本番ビルド時にメモリ不足で落ちてしまうことがありました。
そのため、「中規模以上の規模のプロジェクト」あるいは「増築を繰り返した場合にこの問題に陥ってハマりたくない」という方は、npm scripts に登録する ng コマンドを下記形式に変更しておくと良いかもしれません。

package.json
{
  "scripts": {
    "build:dev": "node --max_old_space_size=8192 ./node_modules/@angular/cli/bin/ng run project:udk:production"
  }
}

tsconfig.server.json の target 変更

サーバーサイドでは最新の JavaScript が問題なく動作するため、target を es5 のままにする必要はありません。
target のバージョンを上げることで無駄なコードを生成する必要がなくなり、bundle サイズが 1 割程減少します。

tsconfig.server.json
{
    "compilerOptions": {
        "target": "es2018",
        ...
    },

NoopAnimationsModule

サーバーサイドではアニメーションを実行する必要がないため、app.server.module.ts には NoopAnimationsModule をインポートして無効にしておきます。

app.server.module.ts
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
...
@NgModule({
    imports: [
        NoopAnimationsModule,
        ...
    ],
    ...
})
export class AppServerModule {}

つらみ

やって良かったことは結果として最初に書いたので、辛かった点も書き残しておこうと思います。

ググれない

とにかくサンプル以外の情報がネット上に存在しませんでした。もちろん Qiita にも TransferState が実装された Angular5 以上の記事で有用なものは存在しません。
しょうがないので github で本家や最近更新された angular universal を使用しているリポジトリを一つずつチェックして不足部分の知識を補完していきました。大変でしたがそのおかげでいち早く nestjs に巡り会えたりもしたので良い面もありました。

そんな状況でしたが最近立て続けに素晴らしい発表があったりして分散していた SSR・Universal 周辺の情報がまとまりつつあります。そのため、半年前よりは大分始めやすい環境にはなっていると思います。
すでに何回か参照しているものありますが、直近で特に参考になったスライドを今一度ここにまとめておきます。

フレームワーク本体の破壊的変更

これは完全に自分が悪いのですが、プロジェクトの Universal 化を始めたタイミングが最悪でちょうど Angular v5 ⇢ v6 に切り替わるくらいの時期でした。このときに何があったかというと RxJS の大幅な仕様変更を取り入れたことが原因で本体や依存ライブラリがしばらく不安定となりました。
Universal 状態で使用する人は、CSR で使用する人の数に比べて圧倒的に少ないため、サーバーサイドのみで発生するバグの発見・解消は通常よりも大分遅くなります。

そういったバグはしばらくすると勝手に直っているので、厄介そうなバグを踏んだら迂回して別の場所を実装したりしていました。結局その時踏んだバグは、現在では全て解消しています。
今更詳しく振り返ってもノイズでしか無いため、「Angular 本体に大きな変更が入る時期には、Universal 化を始めないほうが良いかも」という教訓だけ残しておきます。
(v8 の ivy は下位バージョンとの完全な互換性を約束しているため v6 特有の問題になるかもしれません)

ビルド時間が長い

プロジェクトの規模が大きくなるにつれて、最初はほぼ 0 秒だったビルド時間が伸びてきました。自分のプロジェクトでは 200 コンポーネント弱で 40 ~ 50 秒ほどかかってしまいます。この問題については次世代レンダリングエンジンの ivy が解決してくれるみたいなので Angular v8 に期待しています。

おわりに

今回は Universal 化を始めてから一段落つくまでに詰まった部分をなるべく思い出しながらまとめてみました。とても長くなってしまったので、ちょくちょく漏れている・誤っている点もあるかもしれません。そういった部分は見つけ次第アップデートしていこうと思っています^^;

これから Universal 化を始める方のお役に少しでも立てたなら、それはとっても嬉しいなって。

※ 万が一記事内の図が使いたい という方がいましたら許可なくご利用いただいて大丈夫です
(ただし直下に参照リンクが付いていないものに限ります


5日目は @shioyang さんです。よろしくお願いしますm(__)m

データ可視化への一歩目 〜Angular + Chart.js〜

この記事は Angular Advent Calendar 2018 の 5 日目の記事です。

はじめに

IoT や機械学習などデータに触れる機会が多くなったいま、データ収集が積極的に行われています。
当然データを収集するだけでは宝の持ち腐れになってしまいます。
そのデータから

  • どの部分を抽出して
  • どのように加工して
  • どこをどう利用するのか
  • どんな知見を得るのか

それによって、持っているデータの価値は大きく変わります。

さらに、得られたデータや知見をどう見せるかという伝え方にも価値があります。
ここではデータをうまく表現する助けとなるように、AngularChart.js を使ってデータを可視化する方法を紹介します。

こんな方へ

  • 全体のフレームワークは Angular で、データの可視化ツールを使いたい
  • Angular はチュートリアル程度はわかる
  • なにかしらのデータを試しに可視化してみたい

あることないこと

書いてあること

  • Angular から Chart.js を利用する方法
  • 棒グラフを表示する方法
  • チャートのクリック・イベントをハンドルする方法
  • クリックされたデータの詳細をハンドラから知る方法

下記リンクのページができあがります。
https://charts-sample-2699e.firebaseapp.com/

書いてないこと

  • Node.js のインストール方法
  • Angular CLI のインストール方法
  • Angular の文法および書き方

環境

  • Node.js: 10.13.0
  • Angular CLI: 7.0.3

用語

Chart.js Documentation に習って、グラフではなく チャート と書いています。
但し、Bar Chart については 棒グラフ と書いています。

準備

まずは、チャートを表示するための下準備をします。

新しいプロジェクトを作成します。
使用するスタイルシート・フォーマットを聞かれますが、ここでは本質にかかわらないので何でも構いません。

$ ng new charts-sample --routing

ng2-charts@types/chart.js を追加します。

$ cd charts-sample
$ yarn add ng2-charts
$ yarn add @types/chart.js --dev

src/app/app.modules.tsChatsModuleimports に記述しておきます。

src/app/app.modules.ts
import { ChatsModule } from 'ng2-chats'

@NgModule({
  imports: [
    ...
    ChartsModule
  ],
  ...
})

チャート表示用のコンポーネントを作成します。
今回は棒グラフを表示しますのでコンポーネントの名前を bar-chart とします。

$ ng generate component bar-chart

src/app/app-routing.module.tsBarChartComponent へのルーティングを設定をします。

src/app/app-routing.module.ts
import { BarChartComponent } from './bar-chart/bar-chart.component'

const routes: Routes = [
  { path: '**', component: BarChartComponent }
]

チャートの表示

さて、ここからが Angular での Chart.js を使う記述です。

まずは、テンプレートです。
src/app/bar-chart/bar-chart.component.html には canvas タグを使って各属性を次のように記述しておきます。
baseChart 以外の下記値はそれぞれ変数を設定して値が代入されるようにしています。

  • chartType
  • options
  • labels
  • legend
  • datasets
src/app/bar-chart/bar-chart.component.html
<div>
  <div style="display: block">
    <canvas baseChart
            [chartType]="chartType"
            [options]="chartOptions"
            [labels]="chartLabels"
            [legend]="chartLegend"
            [datasets]="chartData" >
  </div>
</div>

src/app/bar-chart/bar-chart.component.ts では、先ほどテンプレートで設定した変数を定義します。

棒グラフを表示するので chatType の値は bar にします。
(他には、折れ線グラフ line、円グラフ pie などがあります)

chartOptions は今回指定していません。

chartLabels には月を列挙しています。
これらの値が x 軸のラベルになります。

chartLegendtrue にしておくと、チャートの上に凡例が表示されます。

chartData には、表示させたい datalabel をひとつのデータセットとして列挙します。
今回は、東京および京都の降水量データ 1 を使います。

src/app/bar-chart/bar-chart.component.ts
export class BarChartComponent implements OnInit {

  chartType = 'bar'

  chartOptions = { }

  chartLabels = [
    'January', 'February', 'March', 'April', 'May', 'June', 'July',
    'August', 'September', 'October', 'November', 'December' ]

  chartLegend = true

  chartData = [
    { data: [52.3, 56.1, 117.5, 124.5, 137.8, 167.7, 153.5, 168.2, 209.9, 197.8, 92.5, 51.0], label: 'Tokyo' },
    { data: [50.3, 68.3, 113.3, 115.7, 160.8, 214.0, 220.4, 132.1, 176.2, 120.9, 71.3, 48.0], label: 'Kyoto' }
  ]

  constructor() { }
  ngOnInit() { }
}

実行結果

ビルドしてブラウザで表示すると、下のようなチャートが表示されます。
Screen Shot 2018-12-03 at 11.20.10.png

各データセットにはそれぞれ色が付いていて、棒はアニメーションされ、マウスをホバーするとツールチップでデータの詳細が表示されます。
上部に表示されている凡例からデータセットをクリックすることで、選択したデータセットの表示/非表示を切り替えることができます。

クリック・イベント

ここまでで簡単なチャートの表示ができました。
次は、棒グラフがクリックされたときのイベント・ハンドラを定義する方法です。

クリック・イベントを扱えると、ユーザーのマウス操作でさまざまな表示切り替えができるようになります。

まず、先ほどのテンプレートに (chartClick)="chartClicked($event)" という記述を加えます。
これでチャート上でクリック・イベントが起こったときに chartClicked($event) が呼ばれます。

src/app/bar-chart/bar-chart.component.html
<div>
  <div style="display: block">
    <canvas baseChart
            [chartType]="barChartType"
            [options]="barChartOptions"
            [labels]="barChartLabels"
            [legend]="barChartLegend"
            [datasets]="barChartData"
            (chartClick)="chartClicked($event)" >
  </div>
</div>

クリックされた棒のデータを表示するために、変数を定義しておきます。

src/app/bar-chart/bar-chart.component.ts
  clickedLabel        = ''
  clickedDatasetLabel = ''
  clickedValue        = 0

  constructor() { }

テンプレートで、定義した変数が表示されるようにしておきます。

src/app/bar-chart/bar-chart.component.html
<div>
  ...
  <div>
    <table>
      <tr>
        <td>Label</td>
        <td>{{clickedLabel}}</td>
      </tr>
      <tr>
        <td>Dataset Label</td>
        <td>{{clickedDatasetLabel}}</td>
      </tr>
      <tr>
        <td>Value</td>
        <td>{{clickedValue}}</td>
      </tr>
    </table>
  </div>
</div>

クリック・イベントが起こると chartClicked にはイベント・データが渡ってきます。
そこから modelindexdatasetIndex を取り出します。
model にはクリックされた棒のモデル定義が入っており、x 軸のラベルやデータセットのラベルが含まれています。
index はクリックされた棒のデータのインデックスです。
datasetIndex はクリックされた棒のデータセットのインデックスです。今回の例では Tokyo データセットが 0、Kyoto データセットが 1 になります。

これらを使って、クリックされた棒のラベル、データセットのラベル、データの値を取得することができます。

src/app/bar-chart/bar-chart.component.ts
  // Event Handler
  public chartClicked(e: any): void {
    if (e.active.length > 0) {
      const chart        = e.active[0]._chart
      const element      = chart.getElementAtEvent(event)[0]
      const model        = element._model
      const index        = element._index
      const datasetIndex = element._datasetIndex

      this.clickedLabel        = model.label
      this.clickedDatasetLabel = model.datasetLabel
      this.clickedValue        = chart.config.data.datasets[datasetIndex].data[index]
      }
  }

実行結果

ビルドしてブラウザで表示すると、下のようなチャートが表示されます。
ひとつの棒をクリックすると、左下の表にクリックした棒の x 軸のラベル、データセットのラベル、およびデータ値が表示されます。
Screen Shot 2018-12-03 at 18.26.00.png

まとめ

Angular から可視化ツールである Chart.js を使って、棒グラフを表示する手順を紹介しました。
これを参考にすることで、他の chartType でも Chart.js Charts を参照することで簡単に扱えるはずです。

棒グラフの棒をクリックしたときに発生するクリック・イベントをハンドルして、各種値を取得する方法を紹介しました。
ユーザーの操作によって、ページやチャートを変化させることができます。

こちらで動きを確認できます。
https://charts-sample-2699e.firebaseapp.com/

明日の 6 日目は @miyatomo さんです。

おまけ (JSON データ)

データを JSON ファイルから読み込みたいときには、tsconfig.json で下記の設定をしておきます。
こうすることで、JSON ファイルをモジュールのように直接インポートすることができます。

tsconfig.json
   "resolveJsonModule": true,
   "allowSyntheticDefaultImports": true

例えば、下記のように記述すれば直接インポートすることができます。

import datajson from '../../data/opendata01.json'

おまけ (ホバー・イベント)

マウス・ホバーのイベントは、クリック・イベント同様にテンプレートに (chartHover)="chartHovered($event)" のように記述することでハンドラが呼ばれます。
しかし、不具合があるようで現在 (2018/12/4) はうまく動かずハンドラが呼ばれません 2

src/app/bar-chart/bar-chart.component.html
<div>
  <div style="display: block">
    <canvas baseChart
            [chartType]="chartType"
            [options]="chartOptions"
            [labels]="chartLabels"
            [legend]="chartLegend"
            [datasets]="chartData"
            (chartHover)="chartHovered($event)" >
  </div>
</div>

テンプレートではなく chartOptions に、onHover を持つオブジェクトを hover として定義します。
こう書くことでホバー・イベントで onHover ハンドラが呼ばれます。

src/app/bar-chart/bar-chart.component.ts
export class BarChartComponent implements OnInit {

  chartType = 'bar'

  chartOptions = {
    hover: {
      onHover: (event, active) => {
        if (active && active.length) {
          // Your code is here.
        }
      }
    }
  }

リファレンス

Chart.js
Chart.js Documentation
ng2-charts
Angular & Chart.js (with ng2-charts)

GitHubを活用して、クライアントと協働してWebサイトをAngularでつくろう。

この記事はAngular Advent Calendar 2018の6日目の記事です。

概要

  • GitHubもCMSだよね
  • クライアントがGitHub上で修正したらもっと幸せな世界が待ってる
  • リスク管理や速度の部分はAngularのAOTビルドが担ってくれるよ
  • Webサイト構築でこそ、Angularを使おう

CMSに代わるGitHubという見方

CMSって皆さん利用されていますか?Contents Management System、簡単にいうと動的に入出力できるシステムで国内で最も活用されているWordPressもそのひとつです。
ちなみに、よく聞く「導入する理由」は「クライアントが更新できるから」。

ちなみに、CMSがない世界だと受託業務の業務負担はこんな感じです。

スクリーンショット 2018-12-06 12.54.12.png

まぁ今だと修正依頼はメールがメインだと思いますが、せっかくクライアントが文面をデータ入力してくれても(もうFAXで修正依頼くる世界は滅んだはず!)、Web制作会社はそれをHTMLに挿入または差し替えるという仕事が発生します。
保守されるWebサイトほど、クライアントの仕事量はそのままWeb制作会社の仕事量へと直結します。

それを変えてくれたのが、WordPressをはじめとしたCMS。

スクリーンショット 2018-12-06 12.54.22.png

Webサイトのタイトルから、文面まで、「クライアントが更新できる部分」を用意することで、タスクをクライアントとWeb制作会社が分け合えるようになりました。

めでたしめでたし。


とならないのが、Webの世界です。
具体的にいうと、ここで「カスタムフィールド職人」が生まれました。ブログの本文などはそのまま全部クライアントがさわってくれたらいいのですが、会社情報や細かいデザイン・レイアウトで組まれた本文をクライアントにさわってもらおうとするために、1ページあたりのカスタムフィールドが10個20個になってしまい、Web制作者の中には「私はWebサイトをつくるのが仕事なのか、カスタムフィールド製造業をしてるのかよくわからない」と嘆く人が生まれるまでになりました。

本来、作業を分担して楽になるはずなのに、あれ・・・?

GitHubというCMS

GitHubを、GitのWebサービスと捉えるか、CMSととらえるかによるのですが、GitHubにはCMS的役割があります。そうです、Web上で編集・更新ができます。かつユーザ別に、しかも承認機能(プルリク)までついてる!

スクリーンショット 2018-12-06 13.03.18.png

誰がどれだけ更新したか、バージョン管理まで完璧にこなしてくれます。CMS的機能がついてるというか、GitHubがCMSでは。

見方を変えると、

  • 直接ホスティングしているサーバをさわることなく
  • つまりはクライアントがFTPやSFTPをつかって直接HTMLをさわることなく
  • Webサイトのソースコードをさわることができる

という画期的なシステムなのです。GitHubすごい!

クライアントはHTMLをさわれない?なら、Angularだ。

こういう話をすると「クライアントが直接HTMLをさわるのはハードルが高い」という声をよく聞きます。もう平成も終わるのに、HTML上のテキストも編集できないクライアントさんはどれだけいるの?という意見もありますが、まぁわかります。特にクライアントさんによっては「何かあって、Webサイトを壊してしまっては・・・」とリスク面をおっしゃる方が多いです。

そこで、でてくるのがJavaScriptフレームワークのAngularです。

1. コンテンツは別ファイルにまとめよう

クライアントさんが怖いのは、こういうHTML内で必要な部分だけを抜き出してさわることです。

スクリーンショット 2018-12-06 13.09.27.png

具体的にいうと、全体に占める日本語の割合がよくて10%程度というか。Web制作をしてる私たちはざっと読めてしまいますが、HTMLに慣れてない方だと日本語だけを探し出すのがまず大変らしいです。まぁ、私もシェイクスピアの原書に10%は日本語だけだから、そこを読めといわれるとハードルが高い。

なので、コンテンツ更新の必要があるところだけを抜き出しましょう。そして、専用のフォルダ・ファイルをつくって保存しましょう。

スクリーンショット 2018-12-06 13.12.11.png

ちなみにこのスクリーンキャプチャした画面だけで、10のフィールドがあります。これをひとつひとつカスタムフィールド職人してると保守も管理も大変。でも、GitHubとAngularがあれば大丈夫。

このオブジェクトを export で他ファイルから呼び出せるようにして、使うページで import するしてバインディングするだけです。

import { WritingText } from '../../writing/home';
@Component(...)
export class HomePage implements OnInit, OnDestroy {
writing = WritingText;

改行には <br /> が必要?

export const txt = `改行を
    反映させる`;

として、innerTextでバインディングすれば改行は直接反映します。

簡単ですね。

2. 複雑なオブジェクトには型で対応しよう

といっても、複雑なオブジェクトを用意しないといけない場合もあります。そういう場合はTypeScriptの型をちゃんと用意しましょう。例えばこんな感じです。

export interface ISchool {
  public: boolean;
  name: string;
  org: string;
  email: string;
  fragment: string;
  description: string;
  option: string;
  challengeBooks: boolean;
  overview: {
    description: string;
    term: string;
    price: string;
    condition: string;
    capacity?: string;
    option: string[];
  };
  schedule: {
    name: string;
    schoolId: number;
    term: {
      label: string;
      line: {
        period: string;
        content: string;
        active?: boolean;
      }[];
    }[];
  }[];
  curriculum: {
    process: {
      name: string;
      description: string;
    }[];
    option: string[];
  };
  curriculumGroup: {
    description: string;
    process: {
      name: string;
      description: string;
    }[];
  };
  curriculumJob: {
    description: string;
    process: {
      name: string;
      description: string;
    }[];
  };
  lecturer: {
    name: string;
    image: string;
    title: string;
    messageLink?: string;
  }[];
  question?: string[];
  discount?: {
    name: string;
    price: number;
    type: string;
    description: string;
    option: string[];
    label?: string;
    labelOption?: string;
  }[];
}

3. 更新失敗したらビルドに失敗するから安心

なぜ型を用意するかなのですが、ビルドに失敗するためです。クライアントさんがさわるのはローカルにDLしてきて静的解析がされている場ではなく、ただテキストを編集するGitHub上なのです。

なので、,を間違って消してしまった、閉じタグが抜けてる、キーを一文字消してしまった、一行まるまる消したことに気づかなかったなど、ミスを考えだしたらきりはありません。

けどですね、Angularで型を用意して、AOTビルドしてる限り、そういったミスによってWebサイトが表示されなくなったり、デザインが崩れたりすることはありません。
だって、ビルドに失敗しますもの。

これは最大のメリットだと思ってまして、 export で他ファイルからオブジェクトを読み込んでくるのはどういった環境でもさほど難しくありませんが、ビルドにちゃんと失敗してくれるのはHTMLのDOMを
構文解析してくれたり、TypeScriptを採用してるAngularの特徴です。
閉じタグひとつ忘れただけでもビルドに失敗してくれるので、クライアントがライティング部分を直接さわって壊れることはありえません。安心してクライアントにタスクを振ってください。

4. パフォーマンスはどうなの?

ajax HTTPクライアント など外部コンテンツとしてとってきたら当然遅延しますが、ビルドしてるので内部コンテンツとして遅延なく表示してくれます。AOTコンパイルを信じよう。(ざっと流し読みした感じ、jsonとしてComponents側に保持してくれてる模様)

5. 実際どうだった

この手法で https://www.ppp-ps.net/ をリリースしました。文面の9割はクライアントがさわることができるようになっていて、コミット履歴をみてみると大学事務局の方もコミットしてくださってます。リリースしたばかりなのでさほどHTMLがわからない人のコミット数はさほどありませんが、それでも合計24commits、200行+、180行-ぐらいの変更がすでに行われています。なお、commitの失敗も度々ある模様。

スクリーンショット 2018-12-06 13.39.26.png

commitに成功してたら5分後ぐらいにビルド終了して反映されるので、クライアント自身が「反映されない。ミスったか」と更に修正した箇所もあります。

まとめ

WebアプリケーションでこそJavaScriptフレームワークをつくろうといった意見や、AngularはWebサイト構築ではオーバースペックという意見もありますが、こういったクライアント協働型では力を発揮します。

それでは、また。

Angularのシンプルな状態管理ライブラリ Akita について

はじめに

この記事は、Angular Advent Calendar 2018の7日目の記事です。

ここではngrxと比べればまだ認知度が低い、Akitaという状態管理ライブラリをご紹介します。

Akitaって?

assets%2F-LDIcOEJiLYk8yWho34E%2F-LHClIF5qciDQfD0m-qQ%2F-LHClK11ZuAPlZtWGAJl%2FA.png

Introduction - State Management Tailored-Made for JS Applications

Akita is a state management pattern, built on top of RxJS, which takes the idea of multiple data stores from Flux and the immutable updates from Redux, along with the concept of streaming data, to create the Observable Data Store model.

秋田犬のロゴがかわいくて癒やされますね。

AkitaはFluxから複数のデータストア、Reduxからimmutable updates, streaming dataのコンセプトを取り入れた、RxJSのObservableに最適化された状態管理パターンだと謳っています。

Akitaの特徴

  • Store, Query, Serviceの役割が明確に分かれている
    • Storeを直接触ることはできなくて、
    • 読み込みはQueryを通じて行う
    • 書き込みはServiceを通じて行う
    • コマンド・クエリ分離原則に則っていて治安がよい
    • ファイルの置き場所・ディレクトリ構成に悩まないのが楽
  • 気の利いたAPIがデフォルトで実装されている
    • StoreのcreateOrReplaceメソッド
    • QueryのisEmpty selectAllメソッド
    • などなど自分でいちから実装することがあまりない
    • ファイル・コードが冗長で管理が大変というReduxにありがちな問題を回避できる
  • 関数型ではなくオブジェクト指向のデザイン設計
    • Query, Serviceなどはクラスで実装されている
    • 型安全で治安がよい
  • CLIが用意されている

公式ドキュメントに記されているアーキテクチャ図も以下に載せておきます。

assets%2F-LDIcOEJiLYk8yWho34E%2F-LEFMbbD5BNkHxecdUde%2F-LEFMe1nMjDF-0kBdGY5%2Fakita-arc.jpg

コード例

CLIを試すと、以下のようなコードが生成されます。
コードを見ると、Akitaの設計がより理解しやすいかと思います。(ここではtodoという名前を仮で指定しました)

todo.model.ts

import { ID } from '@datorama/akita';

export interface Todo {
  id: ID;
}

export function createTodo(params: Partial<Todo>) {
  return {

  } as Todo;
}

todo.query.ts

import { Injectable } from '@angular/core';
import { QueryEntity } from '@datorama/akita';
import { TodoStore, TodoState } from './todo.store';
import { Todo } from './todo.model';

@Injectable({ providedIn: 'root' })
export class TodoQuery extends QueryEntity<TodoState, Todo> {

  constructor(protected store: TodoStore) {
    super(store);
  }

}

todo.service.ts

import { Injectable } from '@angular/core';
import { ID } from '@datorama/akita';
import { TodoStore } from './todo.store';
import { HttpClient } from '@angular/common/http';

@Injectable({ providedIn: 'root' })
export class TodoService {

  constructor(private todoStore: TodoStore,
              private http: HttpClient) {
  }

  get() {
    // this.http.get().subscribe((entities: ServerResponse) => {
      // this.todoStore.set(entities);
    // });
  }

  add() {
    // this.http.post().subscribe((entity: ServerResponse) => {
      // this.todoStore.add(entity);
    // });
  }

}

todo.store.ts

import { Injectable } from '@angular/core';
import { EntityState, EntityStore, StoreConfig } from '@datorama/akita';
import { Todo } from './todo.model';

export interface TodoState extends EntityState<Todo> {}

@Injectable({ providedIn: 'root' })
@StoreConfig({ name: 'todo' })
export class TodoStore extends EntityStore<TodoState, Todo> {

  constructor() {
    super();
  }

}

まとめ

すごく簡単にAkitaについて紹介しました。

個人的にはngrx(Redux)の冗長さに疲弊していたので、このAkitaのシンプルさはとても気に入ってます。できることや自由度は減ってるけど、「そうそうこれくらいでいいんだよ」感があってとてもいいですね。

Akitaの公式ドキュメント・ブログが充実しているので、興味をもった方はぜひチェックしてみてください :eyes:

ComponentDevKit(DragAndDrop)を使ってみる

はじめに

この記事はAngular Advent Calendar 2018の8日目の記事です

Angular7がリリースされてComponentDevKitにDragAndDropが入ってきました

ということで簡単なTODOアプリを作ってみます

CDKとは

簡単に言うと一般的なWebアプリケーションで使うUIを提供してくれるライブラリ群です

少ないコード記述量・短時間で必要な機能を構築できます

Angular公式が作っているので安心感がありますね

動作環境

$ npx ng --version

     _                      _                 ____ _     ___
    / \   _ __   __ _ _   _| | __ _ _ __     / ___| |   |_ _|
   / △ \ | '_ \ / _` | | | | |/ _` | '__|   | |   | |    | |
  / ___ \| | | | (_| | |_| | | (_| | |      | |___| |___ | |
 /_/   \_\_| |_|\__, |\__,_|_|\__,_|_|       \____|_____|___|
                |___/


Angular CLI: 7.0.2
Node: 9.3.0
OS: linux x64
Angular: 7.0.0
... animations, common, compiler, compiler-cli, core, forms
... http, language-service, platform-browser
... platform-browser-dynamic, router

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.10.2
@angular-devkit/build-angular     0.10.2
@angular-devkit/build-optimizer   0.10.2
@angular-devkit/build-webpack     0.10.2
@angular-devkit/core              7.0.2
@angular-devkit/schematics        7.0.2
@angular/cdk                      7.0.1
@angular/cli                      7.0.2
@angular/material                 7.0.1
@ngtools/webpack                  7.0.2
@schematics/angular               7.0.2
@schematics/update                0.10.2
rxjs                              6.3.3
typescript                        3.1.3
webpack                           4.19.1

install,project作成

cliでnewすると対話形式でいくつか質問される(7から)ので答えます

$ npx ng add @angular/cdk
$ npx ng add @angular/material
+ @angular/material@7.0.1
added 68 packages in 14.639s
Installed packages for tooling via npm.
? Enter a prebuilt theme name, or "custom" for a custom theme: indigo-pink
? Set up HammerJS for gesture recognition? Yes
? Set up browser animations for Angular Material? Yes

materialの雛形追加

とりあえずナビだけ

npx ng g @angular/material:material-nav --name side-nav

サーバ起動

npx ng s

実装

準備が整ったのでTODOリストを作っていきます

ドキュメントは下記

Drag and Drop | Angular Material

[https://material.angular.io/cdk/drag-drop/overview:embed:cite]

サンプルにすでにそれっぽく動かせるコードがあるのでそこからすこし手を加えてTODOリストを作ってみます

  • サンプル

Drag&Drop connected sorting - StackBlitz

[https://stackblitz.com/angular/mrlygxvavkm?file=app%2Fcdk-drag-drop-connected-sorting-example.ts:embed:cite]

  • テンプレート側

#todoList="cdkDropList" でテンプレート変数にディレクティブのインスタンスを代入

[cdkDropListConnecedTo]="[doneList]"でどの箱とつながっているかを定義

配列になっているので複数記述できる感じですね

サンプルだと箱が2つなのでもう片方の箱のインスタンスを指定しています

[cdkDropListData]="todo"で対象の箱の初期データを渡しています

データの内容はtsファイルの方に記述されています

  • (cdkDropListDropped)

cdkDropList配下のcdkDragの要素に対するドラッグが終わったらイベントを受け取るようになっています

drop関数では対象のタスクが同じ箱内で順番だけが変わった場合はmoveItemInArray

別の箱へ移動した場合はtransferArrayItemを呼び出してそれぞれ適切なデータをとってきて移動させています

めちゃくちゃわかりやすい!

今回はこのサンプルに下記追加してみます

  • doingのリスト
  • タスクの追加機能
  • タスクの削除機能

doingのリスト

  • 他2つの箱にならって箱を追加

  • 各箱のcdkDropListConnecedToを3つ相互に接続させるように修正

これだけですね

データの追加

テキストフィールドを用意してクリックイベントで対象データ(todo)に追加するだけ

  • todo-list.component.ts
  addTask(task: string): void {
    this.todo.push(task);
  }
  • todo-list.component.html
  <mat-form-field class="example-full-width">
    <input #task matInput placeholder="Task" value="task">
  </mat-form-field>
  <button mat-stroked-button color="primary" (click)="addTask(task.value)">Add</button>

あとはAngular側がよしなにやってくれます

データの削除

こちらも対象のデータから削除するだけ

  • todo-list.component.ts
  deleteTask(data: any[], index: number): void {
    data.splice(index,1);
  }
  • todo-list.component.html
    <div class="example-box" *ngFor="let item of doing; let i=index" cdkDrag>
      {{item}}
      <button mat-icon-button color="warn" (click)="deleteTask(doing,i)">
        <mat-icon aria-label="Example icon-button with a heart icon">
          delete
        </mat-icon>
      </button>
    </div>

最終的にこんな感じになりました

cdk-dnd01.gif

まとめ

なんて楽なんだ!という印象でした

ドラッグアンドドロップのUIは1から実装ってなるとちょっと気が重くなる感じのイメージだったのですがCDKを使って実装すれば簡単に実装できそうです

これ使って自分用にTODO作るか!っていうくらい簡単でした

CDKは他にも色々機能があるので調べて使ってみたいと思います

今後さらに増えてくれることに期待してます

今回使ったサンプルのコードは下記に置きました

swfz/todo-sample: angular todo list

[https://github.com/swfz/todo-sample:embed:cite]

Angularで同じ絵文字をクリックするだけのクソゲーを作りました

この記事は Angular Advent Calendar 2018 の 9 日目の記事です。
クソアプリ Advent Calendarでやってくれとツッコミの入りそうなネタですが、Angular関連なのでこちらで書かせてもらいます。

作ったもの

Firebase Hostingにデプロイしてあるので、「 same-emoji 」から遊べます。
(PCのブラウザから遊ぶ際はビューポートをSPサイズにすることを推奨です...)

タイトルなし.gif

2つ1組でランダムに表示される絵文字をタップして消し続け、タイムを競うという単純なゲーム(クソゲー)です。
画面構成もシンプルで、次の画面で構成されています。

  • スタート画面
  • レベル選択画面
  • ゲームプレイ画面
  • ゲーム結果画面

リポジトリ
https://github.com/daikiojm/same-emoji

モチベーション

Angular MokuMoku Nightというイベントがあり、これに参加した際に 「そうだ! クソゲーを作ろう」 と思い立ったのがきっかけ。
このイベントはAngular日本ユーザー会が主催するイベントで、自分が参加したのは初回の1回のみでしたが、現在も定期的に開催されており、ただもくもくするだけではなく、メンター枠で参加されている方に直接質問できる場もあり、かなり有意義なイベントだと思います。

また、冒頭でも話題に挙げたクソアプリ Advent CalendarVue.jsで絵文字をクリックするだけのクソゲーを作りましたという記事で紹介されているアプリがおもしろかったので、自分が作ったemoji系クソアプリもこのタイミングで紹介しておきたいと思ってこの記事を書いています。

技術面

とくに難しいことはやっていないのですが、はじめての試みがいくつあったので参考リンクと共に紹介します。

構成

  • Angular (v7) + Angular Material (v7)
    • 現時点での最新版で開発
  • Firebase Hosting
    • Firebase CLI(firebase-tools)を使ってデプロイのセットアップ

Angular CLI & Angular Material

Angular CLIはいくつかのオプションを選択していくだけでアプリケーションの雛形が作成できるので、こういったぱっと出のアイデアを形にする際にも非常に有効だと思います。
Angularでのクソゲー開発にも欠かせないツールです。
こちらも、Angular CLIで簡単にセットアップすることができ、ユーザビリティの高いクソゲーUIを実現できるので、Angularでのクソゲー開発にも欠かせません。

i18n

これは完全試してみたかっただけです。 Angular公式のi18n機能を使ってリソースファイルを作成し、ゲームのトップページからベースパスの切り替えによって日本語/英語の切り替えができるように実装しています。
リソースファイルの作成は、Angular CLIで提供されている次のコマンドで行なえます。

$ ng xi18n --i18n-format=xlf --out-file locale/messages.ja.xlf

クソアプリ開発の副産物として、Angularのi18nに関して次のような知見を得ることができました。

Router Transiton

ゲームっぽさを出すために、ページ遷移時にアニメーションを入れています。
routerに metadata を設定して、ページ種類ごとにアニメーションを制御しています。

参考

jest

かなりざっくりとですが、部分的にjestでテストを書いてました。
このアプリを作り始めた頃、早いと噂だったので、Angularでも使ってみたかった。

設定に関しては、 jest-preset-angularに従っておけば間違いなと思っています。
Angular CLIの設定との兼ね合いで ng newした際に作成される karma/jasminのconfigたちは基本いじらないでおいておくのが良さそう。将来的にはkarama/jasminへの依存も断ち切りたいところです。

参考

各種Angularコンポーネント(router, service, guard, pipe, component)のテスト方法に関しては次の記事が参考になりました。

振り返り

ぱっと出のアイデアを継続して少しずつ開発していくすスタイルは続かないということを学びました。
この教訓は、今後のクソアプリ開発に活かそうと思います...。

実案件で使う事になりそうな機能の検証や、Angularの新バージョンへのアップデートの検証などを行いたいときに、新たにプロジェクトを作るのではなく自分で構築して定期的にメンテナンスしているプロジェクトが手元にあるという状況は有用だという印象があり、そういった点はよかったと思っています。

ぜひ皆さんも、Angularのキャッチアップのためにクソアプリを作ってみてはいかがでしょうか。
来年は、有益な情報を発信しつつもクソアプリの開発に専念できればいいなぁと思っています。

明日は @tatsuya-takahashi さんです。

Angular+sass(scss)によるカラーテーマ切り替えベストプラクティス

Angular+sass(scss)によるカラーテーマ切り替えベストプラクティス

https___angular-changetheme.stackblitz.io-Google-Chrome-2018_12_09-19_27_33.gif

この記事は,Angular Advent Calendar 2018,10日目の記事です.

TL;DR

目指したかったもの

  • WEBで,各要素の色を,瞬時に切り替えたい
  • 全部css(sass,もといscss)で管理したい
  • 色は,scssの変数で1か所で管理したい
  • このご時世にaddClassとか,removeClassとかしたくない…….
  • 各要素ごとに,テーマごとのcssを全部定義するのも,メンテできなくなるのでしたくない.
  • 結論,テーマごとに定義した色変数を用いて,それをスマートに切り替えてくれるもの

執筆動機

  • 上記要件を満たした実装が,検索してもでてこなかった
  • よくあるのは,要素ごとに色を定義して,上位クラス(wrapperとか)のクラスをremoveClass,addClassで付け替えることによる切り替え例.
  • もっとスマートにできるはず,かつ色も変数化できるはず,と頭を悩ませて,一応落としどころを見つけたので共有したい.

考えた構成

  • 1,Wrapperクラスを,テーマ分生成する.ただし,色の定義は1回のみとする.
    image.png

  • 2,Wrapperクラスを,Angularのバインドによって切り替える.
    image.png

コード解説

  • 1,Wrapperクラスを,テーマ分生成する.ただし,色の定義は1回のみとする.
// 適当な値で変数を初期化しておく
$back: #000000;
$font: #000000;
$bttn: #000000;
$btnf: #000000;

// テーマの数だけループする
@for $i from 1 through 2 {

  .wrapper#{$i} {
    @if $i == 1 {
        // テーマごとに色を定義する,1回で良い.
        $back : #F4F3EE;
        $font : #463F3A;
        $bttn : #E0AFA0;
        $btnf : #FFFFFF;
    } @else {
        $back : #000000;
        $font : #FFFFFF;
        $bttn : #32FFFB;
        $btnf : #38005B;
    }

    // スタイリングは,テーマごとに切り替えなくてよい.
    background-color: $back;
    color: $font;
    width: 100%;
    height: 100%;
    padding: 0;
    margin: 0;

    .block{
      padding: 20px;
      h1 {
        font-size: 24px;
        padding: 0;
        margin: 0;
      }
      button {
        background-color: $bttn;
        color: $btnf;
      }
    }
  }
}

  • 2,Wrapperクラスを,Angularのバインドによって切り替える.

.html

<!-- クラス名をバインドしている -->
<div [(class)]="colorTheme">
  <div class="block">
    <h1>Change Color Theme with .scss and .ts</h1>
    <p>
      toggle and change theme :)
    </p>
  </div>
  <div class="block">
    <button mat-button>This is the Button</button>
  </div>
  <div class="block">
    <mat-slide-toggle (change)="changeColor($event)">
        On Dark
    </mat-slide-toggle>    
  </div>
</div>

.ts

import { Component } from '@angular/core';
import { MatSlideToggle } from '@angular/material';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: [ './app.component.scss' ]
})
export class AppComponent  {
  name = 'Angular';

  // バインドするテーマ
  public colorTheme = 'wrapper1';

  constructor(){
    this.colorTheme = 'wrapper1';
  }

  // toggle event
  // トグルされるたびに,バインドするクラス名を変更する
  public changeColor(e){
    if(e.checked) {
      // to dark
      this.colorTheme = 'wrapper2';
    } else {
      // to light
      this.colorTheme = 'wrapper1';
    }
  }
}

終わりに

  • この仕組みの優れたポイントは,色の定義を1極集中管理しながら,色の切り替えが行えるところである.
  • とはいえラッパーの切り替えや,scssのループなどは決して可読性は高くないので,もう少し改良ができればしたい.

明日は@kimamulaさんの記事です.
よろしくお願いいたします.

TypeScriptで実現する型安全な多言語対応(Angularを例に)

External article

Angularの複数プロジェクト構成とFirebeseホスティング

からあげ🐔とおんせん♨の国のkpondaです。

この記事はAngular Advent Calendar 2018 12日目の記事です。
Angularでの複数プロジェクト構成とFirebase Hostingを使った公開方法について書こうと思います。

はじめに

Angularのプロジェクト構成にはAngular CLIを、Firebaseの設定などはFirebase Toolsを使用します。

複数プロジェクト構成

Angularでアプリケーションを書いていて、ユーザ向けと管理者向けなどユーザ種別毎に別のアプリケーションとして作成したいことはありませんか?
例えば利用者向けのアプリケーションと管理者向けのアプリケーションを用意したい状況などあるかと思います。

別のアプリケーションとして構築する

ng newで別々のAngularアプリケーションとして作成することができます。
この場合API呼び出しなど共通して使用するコードの共有の仕方などを別途考える必要があります。

$ ng new userapp
$ ng new adminapp

Angular CLIの複数プロジェクト

Angular CLIでは複数プロジェクトを扱う機能が提供されています。
Angular CLIで作成したアプリケーションの中で、ng generate applicationを実行します。

$ ng new userapp
$ cd userapp
$ ng g application adminapp

ng g applicationを実行するとprojects配下にファイルが生成されます。

userapp
  ├-projects
  │    ├-adminapp
  │        └-adminapp-e2e

複数プロジェクトの実行

ng serve等実行する時はプロジェクト名を指定します。

$ ng serve userapp
$ ng serve adminapp

Userappの方はプロジェクト名を省略して実行できます。
プロジェクト名を省略した時は、angular.json の defaultProject の設定に応じます。

開発時には同時に両方起動させておきたいので、ポート番号を指定して起動させると便利です。
package.json に 次のような"all"スクリプトを追加しています。

  "scripts": {
    "ng": "ng",
    "start": "ng serve",
    "build": "ng build",
    "test": "ng test",
    "lint": "ng lint",
    "e2e": "ng e2e",
    "all": "ng serve & ng serve adminapp --port 4210"
  },
$ npm run all

を実行するとuserapp, adminappが両方起動します。
上記の設定ではuserappは4200番ポート、adminappは4210番ポートで接続できます。

ビルドを行う際もプロジェクト名を指定して行います。

$ ng build userapp —-prod
$ ng build adminapp --prod

Firebase Hosting

ビルドしたアプリケーションを公開するのにFirebase Hosting便利ですね。
複数プロジェクト構成のAngularアプリケーションをFirebase Hostingで公開する方法を試してみました。

既述のプロジェクト構成でビルドを行うと、dist配下にuserapp, adminappが作られています。
これらをデプロイする方法として以下の2つの方法があります。

  • ディレクトリで分ける
  • サイト(ホスト名)で分ける

サイト(ホスト名)で分ける場合は、Firebaseの無償プランでは行うことができないので注意が必要です。

Firebase Tools

FirebaseをCLIから操作するFirebase Toolsは下記のページなどを参照してください。
https://firebase.google.com/docs/cli/?hl=ja

firebase initコマンドでHostingを有効にします。

public directoryの指定はdistを、single-page appの設定にはYesでひとまず設定しておきます。

=== Hosting Setup

Your public directory is the folder (relative to your project directory) that
will contain Hosting assets to be uploaded with firebase deploy. If you
have a build process for your assets, use your build's output directory.

? What do you want to use as your public directory? dist
? Configure as a single-page app (rewrite all urls to /index.html)? Yes
✔  Wrote dist/index.html

i  Writing configuration info to firebase.json...
i  Writing project information to .firebaserc...

✔  Firebase initialization complete!

ディレクトリを区切ってデプロイ

dist配下をそのままデプロイして使用します。

ビルドオプション

ディレクトリで区切ってデプロイする際には、ビルドオプションを指定してbase urlを設定します。
--base-hrefオプションを使用します。次のような感じでビルドを行います。

$ ng build --prod --base-href /userapp/ userapp
$ ng build --prod --base-href /adminapp/ adminapp

firebase.jsonを、サブディレクトリでうまく動くように変更します。
sigle-page appを指定しているとfirebase.jsonのrewritesは次のようになっています。

    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }

この部分をuserapp, adminappに合わせて次のように変更します。

    "rewrites": [
      {
        "source": "adminapp/**",
        "destination": "/adminapp/index.html"
      },
      {
        "source": "userapp/**",
        "destination": "/userapp/index.html"
      }
    ]

firebase deployを行うと、それぞれのディレクトリを指定してアクセスできるようになります。

ルートディレクトリの表示

ディレクトリ毎に表示ができるようになりますが、ルートを表示するとFirebase Hosting Setup Completeなんて表示されます。
スクリーンショット 2018-12-13 0.31.24.png

/index.html が存在しないから表示されるので、dist/index.htmlを作ってデプロイすれば表示されなくなります。

Firebase Hostingにはリダイレクト機能があるので、ルートディレクトリが指定された際にはサブディレクトリにリダイレクトさせてもよいでしょう。
userappディレクトリにリダイレクトさせる際には、firebase.jsonに以下のような設定を追加します。

    "redirects": [
      {
        "source": "/",
        "destination": "/userapp",
        "type": 302
      }
     ]

複数サイトにデプロイ

ディレクトリを区切ったデプロイで機能的には問題ありませんが、firebase delployを行う際に、dist配下をまとめてデプロイするのでアプリケーションの規模が大きくなってくると少し心配になってきます。

失敗しそうなことを想像してみると、dist配下をクリーンにしたあとに、一部のプロジェクトだけビルドした状態でデプロイしてしまい、アクセスできなくなってしまうことなど考えられます。恐ろしいですね。

Firebaseの従量制プランにすると複数サイトを扱えるようになります。
プロジェクト毎にFirebase Hostingのサイトを割り当ててデプロイしてみます。

Firebaseのプランを変更する

FirebaseコンソールのHostingを開くとこんな商売上手なメッセージが表示されています。
スクリーンショット 2018-12-13 0.46.02.png

複数サイトを扱えるようにアップグレードボタンをポチッとなして、課金設定を行います。

スクリーンショット 2018-12-13 0.48.11.png

サイトを追加

スクリーンショット 2018-12-13 0.51.17.png
料金プランの変更ができたら「アップグレード」が表示されていあたりが「別のサイトを追加」ボタンに変わっているので、ホスト名を指定して新しいサイトを追加します。

デプロイターゲットの設定

firebase target:apply コマンドを使ってデプロイターゲットを設定します

$ firebase target:apply hosting ターゲット名 サイト名

で指定します。

ターゲット名はこのあとfirebase.jsonの設定で使用する名前になります。
サイト名がFirebase Hostingで作成したホスト名の部分になります。

ユーザ向けサイトが user.firebase.com
管理者向けサイトが admin.firebase.com
の場合、次のように実行します。

$ firebase target:apply hosting user user
$ firebase target:apply hosting admin admin

ホスティング設定

firebase.jsonを編集してデプロイターゲット毎に設定を行います。

変更前の"hostings"の値はオブジェクトが1つ設定されていますが、複数サイト指定するため配列で設定します。
"hostings": {}
だったのを
"hostings": [{}, {}]
という感じに変更します。

userapp, adminapp用の設定は下記のようになります。
"target"にデプロイターゲットの設定で指定したターゲット名を指定します。
"public"にはそれぞれのアプリケーションのdist配下のディレクトリを指定しています。

{
  "hosting": [
    {
      "target": "user",
      "public": "dist/userapp",
      "ignore": [
        "firebase.json",
        "**/.*",
        "**/node_modules/**"
      ],
      "rewrites": [
        {
          "source": "**",
          "destination": "/index.html"
        }
      ]
    },
    {
      "target": "admin",
      "public": "dist/adminapp",
      "ignore": [
        "firebase.json",
        "**/.*",
        "**/node_modules/**"
      ],
      "rewrites": [
        {
          "source": "**",
          "destination": "/index.html"
        }
      ]
    }
  ]
}

設定完了後 firebase deploy を実行するとそれぞれにサイトにデプロイされます。
※ディレクトリを区切ってデプロイをためしたままの状態ではdist配下が --base-href をつけた状態になっているので注意してください

どちらか1つのサイトだけデプロイをかけたい時は --only オプションを指定します。

$ firebase deploy --only hosting:ターゲット名

adminappだけデプロイする時は次のようになります。

$ firebase deploy --only hosting:admin

最後に

複数プロジェクト構成は、Angular 4の頃はMultiple Apps構成としてファイル構成が異なる形で提供されていました。
Multipe Apps構成のプロジェクトを5に更新する際に、設定に少し手を加える必要がありちょっと面倒でした。

複数プロジェクト構成ではアプリケーションの他にライブラリプロジェクトを追加したりすることができます。(ng g library...)
が、このライブラリプロジェクトの扱いが微妙な感じなので、もう少し手が加わって改善されるとより使いやすくなりそうです。

明日のAngular Advent Calendarは@Quramyさんです。

実践!Schematics

これは Angular アドベントカレンダー2018 の13日目の記事です。

はじめに

このエントリでは、Angular CLIで利用されているSchematicsについて書きたいと思います。

Schematicsは "A scaffolding library for the modern web" と謳われているとおり、Webフロントエンドプロジェクトのための汎用的なスキャフォルド・ワークフローエンジンです。
Angular CLIを触ったことがある方は、 ng new my-projectng generate component MyComponent のようにAngularプロジェクトを管理するためのコマンドを実行した記憶があると思いますが、これらのタスクも裏ではSchematicsを利用することで実現されています。

@puku0x さんがSchematicsを作ってみようの記事の中で、Schematicsの基本的な始め方や、独自Schematicsの作り方について解説されているので、僕のエントリでは実際にSchematicsを公開するにあたって感じた所感のような部分に重点をおいて書いていきます。

ng add で動作するSchematicsを作ってみた

このエントリを書くにあたって、折角ですので自分でSchematicsを作って公開してみました。

https://github.com/Quramy/angular-language-service-schematics

このSchematicsはAngular CLIで作成したプロジェクトに対して、@angular/language-service を利用可能にします。

次のように実行します。

$ ng new my-project
$ cd my-project
$ ng add @quramy/angular-lang-service

@angular/language-service は、Angular向けのTypeScript Language Service Pluginです。
設定するとAngularのHTMLテンプレートでの補完やエラーチェック機能をエディタから利用できるようになります1
僕はVimでTypeScriptを書いているため、Angularを書くときは必須といっていい機能です。

Language Serviceを適用するには次の手順で設定をおこないます。

  • 必要なpluginをNPMでインストールする
  • tsconfigにpluginを利用する設定を追記する

今回作成したSchematicsはこの手順を自動化するものです。

初めてつくるSchematicsとしてはちょうどよい粒度かなと思い、このテーマにしてみました。
コードとしても相当にシンプルなので、以下に記載します。

src/lang-service/index.ts
import { Rule, SchematicContext, Tree, SchematicsException, chain } from '@angular-devkit/schematics';
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';

export function install(_options: any): Rule {
  return chain([addDevDependencies, modifyTsConfig]);
}

export function addDevDependencies(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {

    const buf = tree.read('package.json');
    if (!buf) {
      throw new SchematicsException('cannot find package.json');
    }
    const content = JSON.parse(buf.toString('utf-8'));
    content.devDependencies = {
      ...content.devDependencies, 
      '@angular/language-service': 'latest',
    };
    tree.overwrite('package.json', JSON.stringify(content, null, 2));

    _context.addTask(new NodePackageInstallTask());
    return tree;
  };
}

export function modifyTsConfig(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {

    const buf = tree.read('tsconfig.json');
    if (!buf) {
      throw new SchematicsException('cannot find tsconfig.json');
    }
    const content = JSON.parse(buf.toString('utf-8'));
    content.compilerOptions = {
      ...content.compilerOptions,
      plugins: [
        ...content.compilerOptions.plugins || [],
        { "name": "@angular/language-service" },
      ],
    };
    tree.overwrite('tsconfig.json', JSON.stringify(content, null, 2));
    return tree;
  };
}

単純ですね。コードのポイントとしては下記くらいです。

  • addDevDependenciesでpackage.jsonの更新 + その後のNPM installを予約
  • modifyTsConfigでtsconfig.jsonへのplugin設定追記
  • install関数で、addDevDependenciesとmodifyTsConfigをチェインさせる

ちょっと気づかなかったのは、 ng add で実行可能なSchematicsをつくるためにはSchematicsの定義体であるcollection.jsonにて、 ng-add というキーで登録しておく必要がある、という点くらいでしょうか。

collection.json
{
  "$schema": "./node_modules/@angular-devkit/schematics/collection-schema.json",
  "schematics": {
    "ng-add": {
      "description": "Add angular language service plugin",
      "factory": "./built/lang-service/index#install"
    }
  }
}

Schematicsのテスト

今回、初めてSchematicsを触ってみて感じたのは、すばりテストの書きやすさです。

フロントエンドにおけるスキャフォルドやプロジェクトジェネレートという文脈では、過去にも様々なツールが出回っています。
有名どころだとyeomanなどです。

自分でyeomanのgeneratorを書いたことがあればわかると思うのですが、これのテストって結構面倒くさいんですよね。

  • テスト用にfixtureとしてプロジェクト相当のディレクトリ構造を用意する
  • テストで特定のディレクトリのファイルを変更するが、適切にロールバックさせる必要がある
  • 上記にまつわる fs 関連のユーティリティを自前で用意する
  • etc...

Schematicsでは、プロジェクトのファイル構造がTreeというインターフェイスで抽象化されており、ファイルの変更や追加はこのTreeに対して行います。
仮想ファイルシステム、みたいな感じでしょうか。

抽象化されている、ということはテスト用のTreeを作って、それに対して操作を行えばよいわけです。実際に今回つくったSchematicsの場合、テストコードは次のようにしましました。

src/lang-service/index_spec.ts
import * as path from 'path';

import {
  SchematicTestRunner,
  UnitTestTree,
} from '@angular-devkit/schematics/testing';

import { getFileContent } from '@schematics/angular/utility/test';

const collectionPath = path.join(__dirname, '../../collection.json');

function createTestApp(appOptions: any = { }): UnitTestTree {
  const baseRunner = new SchematicTestRunner('schematics', collectionPath);

  const workspaceTree = baseRunner.runExternalSchematic(
    '@schematics/angular',
    'workspace',
    {
      name: 'workspace',
      version: '7.1.2',
      newProjectRoot: 'projects',
    },
  );

  return baseRunner.runExternalSchematic(
    '@schematics/angular',
    'application',
    {
      ...appOptions,
      name: 'example-app',
    },
    workspaceTree,
  );
}

describe('lang-service', () => {
  it('should modify package.json and tsconfig.json', () => {

    const runner = new SchematicTestRunner('schematics', collectionPath);
    const tree = runner.runSchematic('ng-add', {}, createTestApp());

    expect(tree.files).toContain('/package.json');
    expect(tree.files).toContain('/tsconfig.json');

    const packageJson = JSON.parse(getFileContent(tree, '/package.json'));

    expect(packageJson.devDependencies['@angular/language-service']).toBe('latest');

    const tsconfig = JSON.parse(getFileContent(tree, '/tsconfig.json'));
    expect(tsconfig.compilerOptions.plugins.map((_: { name: string }) => _.name)).toContain('@angular/language-service');
  });
});

Schematicsによって書き換えられたjsonファイルの中身を検証する、というだけのシンプルなケースです。

ここで注目してほしいのは createTestApp というヘルパー関数です。
ここでテスト用のTreeを作成するようにするために SchematicTestRunner というテスト用のrunnerを用いています。

また、今回のSchematicsは「Angular CLIで作成されたプロジェクトに対して」という事前条件を敷いています。
したがって、「Angular CLIが作成するプロジェクト」というfixtureが必要になるわけですが、Schematicsの「別のSchematicsをTreeに対して実行可能」という特徴がここで活きてきます。
Angular CLI自体がもっているSchematics(実体は @schematics/angular でNPM install可能)を事前に適用させるだけで必要なfixtureが作れます。
わざわざテスト用の package.jsontsconfig.json を含むようなfixture directoryなどを用意する必要もありませんでした。

テストがちゃんと書けるというのは本当に良いもので、実際、今回の初Schematics作成にあたって実際に僕が自分で自分のSchematicsを ng add したのは最後に公開した際の一度切りです。

また、説明が前後しましたがSchematicsのテストで利用しているjasmineの設定などはSchematics本体に組み込みの blank というSchematicsできちんとスキャフォルドされるように設計されていて、この部分にも尊みを感じました。

おわりに

この記事では実際に簡単なSchematicsを作り、旧来の類似ツールとの違いについて書いてきました。

TreeやContextといった Schematics組み込みのインターフェイスについてはあまり詳しく触れませんでしたが、Schematicsは旧来のツールよりもテスタビリティの点で優れているということが伝われば幸いです。

またSchematics自体はAngularに依存していないので、例えばいまReactやVue.jsなどの別フレームワークを使っている方でも、ちょっとしたスキャフォルドタスクをSchematicsで用意する、といった使い方もできそうです。
積極的に使っていけるとよいと思った次第です。

明日は @kiita312 さんです。乞うご期待!

Ngrxをこれから始める人への入門ガイド

はじめに

この記事はAngular AdventCalendar2018 14日目の記事です。

今までReact、Vueを使うことが多かったんですが、半年前からプロダクトでAngular + Ngrxを扱うことになりました。
Angularを使い始めて思ったのは、ググっても他のFWと比べると記事が少ないなと思うのと、Ngrxについてはさらに少ないな、と思ったのでこれからNgrxを使おうと思っている人が増えるようにNgrx初心向けの導入記事です。

Ngrxとは

Ngrx公式ページ。Angular.ioにあわせたドキュメントページが最近作られました。
https://ngrx.io/

NgRx Store provides reactive state management for Angular apps inspired by Redux. Unify the events in your application and derive state using Rxjs

Ngexは、Reduxを参考にAngularに状態管理を提供するライブラリです。Fluxの思想に従って、Componentで扱うStateをすべて一箇所のストアで一元管理し、Component間でのstateのやりとりを扱いしやすくします。
そもそもReduxって何?って方はReduxの入門記事を以前書いたのでご参照ください。

Ngrxの要素

前述の通り、NgRxはReduxと構成はほぼ同じです。データフローの簡単な遷移図がこちらです。

スクリーンショット 2018-12-04 18.20.47.png

  • Viewからイベントが発火され、Actionを作成
  • ActionをStoreへdispatchする
  • ReducerがActionを受けて新しいStateを作成&更新
  • Selectorを通りViewへ新しいStateが渡る

それぞれの構成要素をチュートリアルのサンプルコードと共に解説していきます。

Action

Storeのstateを変更したい場合に直接変更は行えず、必ずActinonを作成してStoreへdispatchすることで変更を指示します。

src/app/counter.actions.ts
import { Action } from '@ngrx/store';

export enum ActionTypes {
  Increment = '[Counter Component] Increment',
}

export class Increment implements Action {
  readonly type = '[Counter Component] Increment';
}

Actionはプロパティにユニーク値であるtypeを持ちます。

コンポーネントではActionをStoreへdispatchするために、コンストラクタからstoreを注入しておきます。ユーザーイベントに応じて毎回新規のActionを作成し、storeのdispatchメソッドを呼び出します。

src/app/my-counter/my-counter.component.ts
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { Increment } from '../counter.actions';
export class MyCounterComponent {

  constructor(private store: Store<{ count: number }>) {
  }

  increment() {
    this.store.dispatch(new Increment());
  }
}
src/app/my-counter/my-counter.component.html
<button (click)="increment()">Increment</button>

Store

アプリケーションのstateを保持する場所です。アプリケーション内に一つのみ存在します。
AppModuleにStoreModule.forRootを使いReducerと共に登録します。

src/app/app.module.ts
import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { counterReducer } from './counter';

@NgModule({
  imports: [StoreModule.forRoot({ count: counterReducer })],
})
export class AppModule {}

Reducer

Reducerは、Actionと現在のstateに応じて新しいstateを作成するピュアなメソッドです。
また、stateの初期値の設定も行います。

src/app/counter.reducer.ts
import { Action } from '@ngrx/store';
import { ActionTypes } from './counter.actions';

export const initialState = 0;

export function counterReducer(state = initialState, action: Action) {
  switch (action.type) {
    case ActionTypes.Increment:
      return state + 1; 
    default:
      return state;
  }
}

selector

selectorは、Storeのstateの必要な部分のみを取得する為のものです。createSelectorのメソッドを使い、各stateを取得するselectorを登録します。

reducers.ts
import { createSelector } from '@ngrx/store';

export interface FeatureState {
  counter: number;
}

export interface AppState {
  feature: FeatureState;
}

export const selectFeature = (state: AppState) => state.feature;
export const selectFeatureCount = createSelector(
  selectFeature,
  (state: FeatureState) => state.counter
);

Effect

Effectは本来のReduxの機能には含まれておらず、redux-thunkやredux-sagaに該当するものです。外部APIとのHTTP通信など非同期処理を行う部分を担う箇所です。
Effectは、StoreへdispatchしたActionをキャッチして処理を行い、新しいActionをdispatchします。

Ngrxでもeffectはstoreのモジュールとは別になっています。

yarn add @ngrx/effects
/effects/auth.effects.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Action } from '@ngrx/store';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { Observable, of } from 'rxjs';
import { catchError, map, mergeMap } from 'rxjs/operators';

@Injectable()
export class AuthEffects {
  // Listen for the 'LOGIN' action
  @Effect()
  login$: Observable<Action> = this.actions$.pipe(
    ofType('LOGIN'),
    mergeMap(action =>
      this.http.post('/auth', action.payload).pipe(
        // If successful, dispatch success action with result
        map(data => ({ type: 'LOGIN_SUCCESS', payload: data })),
        // If request fails, dispatch failed action
        catchError(() => of({ type: 'LOGIN_FAILED' }))
      )
    )
  );

  constructor(private http: HttpClient, private actions$: Actions) {}
}

Ngrxの内部実装

さて、Ngrxの概要について言及してきましたが、これをrxでどのように実現しているのか、内部実装を見て理解を深めましょう。

スクリーンショット 2018-12-07 14.11.48.png

storeのdispatchメソッド

dispatchメソッドではactionObseverに対してactionをnextしています。
actionObserverはBehaviorSubjectで、disptchされるactionのストリームです。

store/src/store.ts
dispatch<V extends Action = Action>(action: V) {
 this.actionsObserver.next(action);
}

stateとreducer

state自身はBehaviorSubjectで、actionOvserverをsubscribeしています。pipeしてreducerのストリームから登録されているredeucerをwithLatestFromで取得し、scanでreducerを実行して作られた新しいstateを自身のストリームに流しています。
この時同時にscannedActionsストリームにも新しいstateを流しています。

store/src/state.ts
const actionsOnQueue$: Observable<Action> = actions$.pipe(
    observeOn(queueScheduler)
);
const withLatestReducer$: Observable<
    [Action, ActionReducer<any, Action>]
> = actionsOnQueue$.pipe(withLatestFrom(reducer$));

const seed: StateActionPair<T> = { state: initialState };
const stateAndAction$: Observable<{
    state: any;
    action?: Action;
}> = withLatestReducer$.pipe(
    scan<[Action, ActionReducer<T, Action>], StateActionPair<T>>(
    reduceState,
    seed
    )
);

this.stateSubscription = stateAndAction$.subscribe(({ state, action }) => {
    this.next(state);
    scannedActions.next(action);
});

effect

import { Actions, Effect, ofType } from '@ngrx/effects';

effectモジュールで使用するActionsは、上記でのscannedActionsにあたります。つまり、effectの実行タイミングは、storeがreducerの処理を行って新しいstateに変わった後のタイミングということがわかります。

effects/src/actions.ts
constructor(@Inject(ScannedActionsSubject) source?: Observable<V>) {
    super();

    if (source) {
      this.source = source;
    }
  }

さいごに

Ngrxを導入するかどうかは、アプリの規模や開発人数に依ると思います。
とくにAngularsではserviceがStoreのようにsingletonな存在なので、service内に関連するstateを保持すれば状態管理はそんなに困らないです。
開発人数が増えてstateの更新が煩雑になってきている、テストしづらい、などで状態管理をしっかりしたいと感じた時にNgrxを入れることを考えてみてはどうでしょうか。

date inputの救世主!? Angular Materialのdatepickerを使ってみた

はじめに

この記事はAngular Advent Calendar 2018 15日目の記事です。

Webアプリケーションでよく使う入力系のUIに「日付の入力」があると思いますが
これがなかなかサポートするブラウザによっては使い物にならない場合が多く
それをカバーするために様々な日付入力用のライブラリが出ていると思います。

パッと思いつきレベルではjQuery系とかが豊富なイメージですが、
お仕事で作っているプロジェクトではAngular + Angular JSのハイブリッド環境で
「ここにさらにjQueryを入れる」という選択も微妙だったので
Angular Materialのdatepickerを調査として使ってみた記録になります。

日付入力のブラウザ間の違いを見てみる

Angular Materialを触り始める前に、ブラウザごとの <input type="date">
見た目や使い勝手の違いを整理していきたいと思います。

サポートしたいブラウザで最低限のことができれば、Materialを入れないほうが
バンドルサイズも減るだろうし…という感じのテンションで
MDNのドキュメントにあるサンプルで試してみました。

macOS

Safari Chrome Firefox
mac safari.png mac chrome 71.png mac firefox.png

macOSですが、標準で入っているSafariがカレンダーから選択するUIを提供しておらず、ちょっとガッカリです。
ちなみに、iOSのSafariはドラムロールのUIを提供しているので大丈夫です。
Chrome、Firefoxはカレンダーから選択するUIを提供しているので、良さそうですね。個人的にはFirefoxのUIが
スッキリしてて好印象です。

Windows 10

IE11 Chrome Edge
windows ie11.png windows chrome 70.png windows edge.png

IE11がSafari同様にカレンダーのUIを提供しておらず、残念感が出ています。
ChromeはmacOSと同じくカレンダーのUIを、EdgeもiOS SafariのようなUIが提供されます。
Firefoxのスクショを撮り忘れちゃいましたが、macOSで出ているので大丈夫そうな気がします。

IE11とSafariをサポートするWebサイトは対応が必須

macOSのSafari、WindowsのIE11では<input type="date">
カレンダー型のUIを出してくれないので何かしらの対応が必要になります。

何かの開始日を指定するような画面でユーザーに「yyyy-MM-dd形式で入力してね」は
さすがに不親切すぎるのでカレンダー型のUIを提供したほうが良さそうです。
というわけで次のセクションからAngular Materialのdatepickerを触っていきます。

開発環境

お仕事で使っているのはAngular + Angular JSのハイブリッド環境(絶賛移行中)ですが
今回はサクッとやりたいことができるか検証したかったので、
新しいCLIプロジェクトを作成してみます。
(StackBlitzだといろんなブラウザで確認できなかった、気のせいかな…?)

Angular CLI: 7.1.1
Node: 8.11.3
OS: macOS 10.14.2

AngularCLIプロジェクトを作成

まずは空のAngularプロジェクトをCLIで生成

ng new angular-material-test --routing --style=styl

試すだけだからルーティングとかいらないですが、いつものクセです。

Angular Materialの導入

CLIプロジェクトなので ng add @angular/material と入れるだけでほぼ終わります。
途中でマテリアルデザインのカラーパレットを選ばされるので、デフォルトから選ぶもよし、オリジナルを設定するもよしです。
昔Materialを導入しようとしたときはもっとステップあったのに、めちゃくちゃ簡単になってますね。ng add最高。

なんとなく、カラーテーマをオリジナルで設定したかったので、そこだけいい感じに。
http://takasdev.hatenablog.com/entry/2017/10/08/125524 を参考にしながら
http://mcg.mbitson.com/#!?mcgpalette0=%23489bc6&themename=mcgtheme で色を作って組み込みました。

Datepickerを実装する

Inputそのものにマテリアルデザインを適用したくなかったので
ボタンでカレンダーを開く、みたいな実装を行いました。CSSでbuttonは透明にして
.date-inputに被せるような感じにしています。

app.module.ts
// ...
import { FormsModule } from '@angular/forms';
import { MatDatepickerModule, MatNativeDateModule } from '@angular/material';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    BrowserAnimationsModule,
    FormsModule. // 追加
    MatDatepickerModule, // 追加
    MatNativeDateModule  // 追加
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
app.component.html
<div class="date-input-container">
  <input class="date-input" [matDatepicker]="picker" [(ngModel)]="date" placeholder="Choose a date">
  <mat-datepicker #picker></mat-datepicker>
  <div class="button" (click)="picker.open()"></div>
</div>

これだけで、Angular Materialのdatepickerを使えるようになりました。
上で出したどのブラウザでも同じUIで日付を選択できるようになって、統一感が増しました :clap: :clap:
[(ngModel)]ではDate型で入ってくるのも嬉しいポイントでした。いちいちパースとかしなくていいのも嬉しい!

日本語化する

このままでも十分最高な日付入力UIですが、サービスによって
はカレンダーの表示が英語だと厳しいところもあるかと思います。
inputの中身も14/12/2018とかいう日本人に馴染みがない表記になっちゃうし。
image.png

そこで、サクッと
ドキュメントを参考に app.module.ts に以下のような記述を追加したところ

app.module.ts
providers: [
  {provide: MAT_DATE_LOCALE, useValue: 'ja-JP'}
]

スクリーンショット 2018-12-13 23.56.25.png
日本語になりました 👏👏

あとは「◯日」っていう表示がちょっとゴチャついて見えてしまうので
英語のときみたいに「1, 2, 3, 4」という表記に戻してあげたいと思います。

jp-date-adapter.ts
// NativeDateAdapterの一部を上書きしたJPDateAdapterを作る
export class JPDateAdapter extends NativeDateAdapter {
  getDateNames(): string[] {
    return Array.from(Array(31), (v, k) => `${k + 1}`);
  }
}
app.module.ts
providers: [
   {provide: DateAdapter, useClass: JPDateAdapter}
]

image.png

いい感じになりました!

日付入力をアツくカスタマイズする

入力可能な日付を制限する

Angular Materialならいつからいつまで、といった範囲を指定して
ユーザーに日付を入力させることが可能です。

app.component.ts
minDate: Date;
maxDate: Date;

ngOnInit () {
  // 今日の日付から…
  this.minDate = new Date();

  // 一週間後の日付を指定したい
  const date = new Date();
  date.setDate(this.minDate.getDate() + 7);
  this.maxDate = date;
}
app.component.html
<input
  class="date-input"
  [min]="minDate"
  [max]="maxDate"
  [(ngModel)]="date"
  [matDatepicker]="picker"
  placeholder="Choose a date">

カレンダーを開いたときに最初に出す日付

カレンダーを最初に表示したときは今日の日付じゃなくて、特定の日がいい〜
みたいなケースもパパっとできちゃいます。

app.component.html
 <mat-datepicker [startAt]="startAt" #picker></mat-datepicker>
app.component.ts
startAt = new Date(1996, 2, 10)

カレンダーを開いたときに最初に表示させるUI

誕生日を選ばせるんだし、最初は年を選ばせたいなあ、みたいなケースにもバッチリです。

app.component.html
<!-- 'month'(月表示) | 'year'(年表示) | 'multi-year'(複数年表示) -->
 <mat-datepicker [startView]="'multi-year'" #picker></mat-datepicker>

まとめ

  • 日付入力の闇をまるっと吸収してくれるAngular Material最高かよ :beers:
  • Angular Material、サンプルが充実してて最高だった :hugging:
  • APIドキュメントを見ているだけでも面白い 🕶️

明日のAngular Advent Calendar 2018 担当は @shira_ さんです!

Anguar Material CDK の Virtual Scrolling を触ってみた

はじめに

この記事は Angular Advent Calendar 2018 16日目の記事です。

Angular7がリリースされ、CDK に DragDropModule や ScrollingModule が追加されました。これらをインポートすると Drag and Drop や Virtual Scrolling が利用できます。
触ってみたかったのでサンプルに沿って少し触ってみました。

Drag and Drop に関しては Angular Advent Calendar 2018 8日目の記事@swfz さんが書かれています!
本記事では Virtual Scrolling を触ってみた件について書きます。

認識違い等ありましたらご指摘いただけるとありがたいです。

まずv7にアップグレードする

ローカル環境がv6だったのでまずv7にあげました。

こちらを参照し該当する条件を選択
image.png

「Show me how to update!」クリック
image.png
表示された手順に沿ってアップグレードします。

ng update @angular/cli @angular/coreを実行したところ
Could not find a package.json. Are you in a Node project?と出たので、先に
ng new [任意のプロジェクト名]で新規プロジェクト作成後そのフォルダに移動して再度
ng update @angular/cli @angular/coreを実行しました。

バージョンを確認します。
image.png

v7になりました。

この方法だとプロジェクト単位のアップグレードになるので開発環境がまるっとv7になってOKであればnpm install -g @angular/cliを実行すればOKです。

上記手順を踏んだ後、npm install -g @angular/cliも試してみた所、先ほどのプロジェクト配下以外でもv7になったことが確認できました。この後はv7でプロジェクトを作成して進めていきます。

新しくプロジェクトを作成

ng new virtual-scrolling-sample
で新規プロジェクトを作成します。
routingはなし、cssはscssを選択しました。

$ ng new virtual-scrolling-sample
? Would you like to add Angular routing? No
? Which stylesheet format would you like to use? SCSS   [ http://sass-lang.com  
 ]

ng serve -oで起動します。
image.png
起動できました。

CDK を入れてみる

新規作成したプロジェクト配下で以下コマンドを実行します。(参考
npm install --save @angular/cdk

サンプルを参考に Virtual Scrolling を入れてみる

ではサンプルを見つつVirtual Scrallを使ってみたいと思います。
一番シンプルそうなサンプルを参照。
https://material.angular.io/cdk/scrolling/overview#creating-items-in-the-viewport

まず、src/app/app.module.tsScrollingModuleをインポートします。

src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { ScrollingModule } from '@angular/cdk/scrolling';

import { AppComponent } from './app.component';

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, ScrollingModule],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule {}

次にsrc/app/app.component.tsを修正します。
サンプルのコードのように
items = Array.from({ length: 100000 }).map((_, i) => `Item #${i}`);を追加してデータを用意します。

src/app/app.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  items = Array.from({ length: 100000 }).map((_, i) => `Item #${i}`);
}

htmlとcssもサンプルからコピーします。

src/app/app.component.html
<cdk-virtual-scroll-viewport itemSize="50" class="example-viewport">
  <div *cdkVirtualFor="let item of items" class="example-item">{{ item }}</div>
</cdk-virtual-scroll-viewport>
src/app/app.component.scss
.example-viewport {
  height: 200px;
  width: 200px;
  border: 1px solid black;
}

.example-item {
  height: 50px;
}

動いた:clap:

scroll1.mov.gif

簡単、サクサク。

コンテキスト変数を使ってみる

*cdkVirtualForでは以下コンテキスト変数がサポートされています。(詳細はドキュメント参照。)

コンテキスト変数 説明
index データソース内のアイテムのインデックス
count データソース内のアイテムの合計数
first データソースの最初の項目であるかどうか
last データソースの最後の項目かどうか
even indexが偶数であるかどうか
odd indexが奇数であるかどうか

以下のようにlet even = even;,let odd = odd"のように書くことでテンプレートで使えるようになります。
奇数行、偶数行で表示する画像を変えるようにしてみました。

src/app/app.component.html
<cdk-virtual-scroll-viewport itemSize="150" class="example-viewport">
  <div
    *cdkVirtualFor="let item of items; 
                    let even = even; 
                    let odd = odd"
                    class="example-item">
    <p>{{ item }}</p>
    <ng-container *ngIf="even">
      <img src="../assets/img/me.jpg" />
    </ng-container>
    <ng-container *ngIf="odd">
      <img src="../assets/img/nise.jpg" />
    </ng-container>
  </div>
</cdk-virtual-scroll-viewport>

cssも少し調整。

src/app/app.component.scss
.example-viewport {
  height: 500px;
  width: 200px;
  border: 1px solid black;
}

.example-item {
  height: 150px;
}

img {
  width: 100px;
}

奇数行、偶数行で画像が変わりました:clap:
scroll2.mov.gif

まとめ

実は Angular Material を触ったことがなかったのでこれを機に触れて良かったです。
試してみるのはとっても簡単でした!他の機能も触って見ようと思います。

明日のAngular Advent Calendar 2018 担当は @ynishimura0922 さんです!

超簡単にサイト公開できるAWS Amplify Consoleを使ってAngularのCI/CD環境を作ってみる

External article

NgRx MockStoreについて

この記事は Angular Advent Calendar 2018 の 18日目の記事です。
今回は最近NgRxの新機能として追加されたMockStoreについて書きたいと思います。

NgRxの基本的な使い方については14日目でkiita312さんが書かれていますので、ぜひそちらをご参考ください。
Ngrxをこれから始める人への入門ガイド

MockStoreとは

MockStoreとは、Storeを使ったComponentでのテストを簡単に行えるようにする為のモジュールです。

10月末にmasterにマージされ、v7.0.0でリリース予定となりました。

まだドキュメントが作成されておらずngrx.ioにも記載がないのですが、機能自体はstableとなっています。
非常に便利なので、v7(beta)を使用している方はこの記事を参考にぜひ使い始めてみてください。

残念ながらv7は現在(2018-12-18時点)まだstableとしてリリースされていないため、6系を使用している方はもう少しだけ待つ必要があります。

MockStoreの目的

MockStoreはStoreを使用しているComponentのテストをシンプルに書けるようにするために作られています。

NgRxではComponentはStoreに対してselect/dispatchを行うだけの関係で、StoreやReducerの実装を知る必要がありません。これらは完全な疎結合な関係になっています。しかし、実際にテストを書く際は通常のモジュール設定を行うのと同様にStoreとReducerを設定する必要があります。つまりComponentのテストを書く為には必要なStoreとReducerの関係を理解して一つ一つ設定する必要があります。これは本来不要な作業で面倒なだけです。

MockStoreを使用すればこれらの設定をすることなくStoreを使用することができます。

例として、MockStoreを使用しない場合とした場合のComponentのテスト設定を比較してみましょう。

MockStoreを使わないComponentのテスト設定
TestBed.configureTestingModule({
  imports: [
    StoreModule.forRoot({
      users: combineReducers(userReducers),
    }),
  ],
})

Componentのテスト準備としてStoreModuleの設定と関連するReducerの設定が必要になります。

MockStoreを使ったComponentのテスト設定
TestBed.configureTestingModule({
  providers: provideMockStore(),
});

これだけです。StoreやReducerの設定がなくなり非常にシンプルになります。

MockStoreの使い方

では実際にMockStoreの使用方法を見ていきましょう。

基本的にStateを設定するだけのモジュールなので、初期値設定と値変更の2種類の機能しかありません。

Importの設定

MockStoreは @ngrx/store/testing のモジュールから提供されています。v7を使用している場合は追加でインストールすることなくすぐに利用できます。

import { MockStore, provideMockStore } from '@ngrx/store/testing';

Stateの初期値の設定

MockStoreは先程の provideMockStore に初期値となるStateを渡すことが出来ます。指定しない場合は undefined が初期値として使われます。

interface MockStoreConfig<T> {
  initialState?: T;
}

function provideMockStore<T = any>(config: MockStoreConfig<T> = {}): Provider[];

実際に以下のように使用することができます。

describe('Stateの初期値の設定', () => {
  let mockStore: MockStore<any>;
  const initialState = {
    users: [{ id: 1, name: 'user1' }, { id: 2, name: 'user2' }],
  };

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: provideMockStore({ initialState }),
    });

    mockStore = TestBed.get(Store);
  });

  it('Storeの初期値はinitialStateで設定されている', (done: any) => {
    mockStore.subscribe(val => {
      expect(val).toEqual(initialState);
      done();
    });
  });
});

この記事ではComponentのテストのサンプルコードは割愛しますが、OnInit内で store.select() をしている場合はこの初期設定を使用すると良いでしょう。

Stateの値の変更

MockStoreではReducerを使用しないので、Stateの値の更新方法が変わります。Stateの値を変更したい時はActionをdispatchするのではなく MockStore.setState というメソッドを使用して、任意のタイミングで手動でStateを設定します。

export class MockStore<T> extends Store<T> {
  setState(nextState: T): void;
}

実際に以下のように使用することができます。

describe('Stateの値の変更', () => {
  let mockStore: MockStore<any>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: provideMockStore(),
    });

    mockStore = TestBed.get(Store);
  });

  it('Stateの値はsetStateを使用していつでも変更することが出来る', (done: any) => {
    const newState = {
      users: [{ id: 3, name: 'user3' }, { id: 4, name: 'user4' }],
    };

    mockStore.setState(newState);

    mockStore.subscribe(val => {
      expect(val).toEqual(newState);
      done();
    });
  });
});

なかなか直感的に使用することが出来ます。
Stateの状態ごとにComponentをテストする際などだいぶ見通し良く書くことが出来るようになるかと思います。

おわりに

以上がMockStoreの機能になります。
この記事では概要までの紹介として、実際にComponentのテストの書き方などは別の記事で書ければと思います。

余談ですが provideMockStore のような機能がEffectsにも存在していて、 provideMockActions という関数を使うことでEffectsのテストを簡単に書けたりします。
NgRxは非常にテストが書きやすい設計になっていて個人的にとても良いライブラリだと思います。MockStore、ぜひこれから使用していってください。

明日19日目の記事はaoshiさんです!

Angularの状態管理にMobXはいかがでしょうか?

External article

Angular fakeAsyncTest 使い方の纏め

元々Zoneのテスト周りの新機能を書きたいですが、まだ実装完了していないので、fakeAsync の使い方を纏めさせて頂きます。

fakeAsyncオフィシャルのドキュメントがこちらです、https://angular.io/guide/testing#async-test-with-fakeasync 、一部が私が最近更新したもので(RxJS/Jasmine.clockなど)、この記事がサンプルコードで使い方を説明したいと思います。

fakeAsync はなに?

fakeAsyncがAngularでfakeAsyncTestZoneSpecを利用して、非同期の操作(setTimeoutなど)を同期の形でテストできるようなライブラリです。
例えば:setTimeoutをテストするため、かきのようなコードになります。

it('test setTimeout`, (done: DoneFn) => {
  let a = 0;
  setTimeout(() => a ++, 100);
  setTimeout(() => {expect(a).toBe(1); done(); }, 100);
});

このような書き方で、テストするには時間がかかりますし、複雑なテストを書くときのexpectも面倒になります。このケースをfakeAsyncで書きかえると、下記の様になりました。

it('test setTimeout with fakeAsync', fakeAsync(() => {
  let a = 0;
  setTimeout(() => a ++, 100);
  tick(100);
  expect(a).toBe(1);
}));

このようなやり方で、非同期のテストが同期になりました。delayを待たずにテストが進められますし、expectも書きやすくなりました。

fakeAsyncをサポートする非同期操作

  • setTimeout
  • setInterval
  • Promise
  • setImmediate
  • requestAnimationFrame

他のFunctionが今zone.jsでどんどん対応じゅうです。

fakeAsync実際利用するときのTips

  • async/await と連携して、componentをテストする. 例えば、下記の様なcomponent.spec.tsで、
it('should show title correctly', () => {
  component.title = 'hello';
  fixture.detectChanges();
  fixture.whenStable().then(() => {
    expect(fixture.nativeElement.querySelector(By.css('title')).textContent).toContain('hello');
  });
});

これをasync/await+fakeAsyncでかきかえると、読みやすくなれます。

// Utility function
async function runChangeDetection<T>(fixture: ComponentFixture<T>) {
  fixture.detectChanges();
  tick();
  return await fixture.whenStable();
}

it('should show title correctly', async () => {
  component.title = 'hello';
  await runChangeDetection<TestComponent>(fixture);
  expect(fixture.nativeElement.querySelector(By.css('title')).textContent).toContain('hello');
});
  • Date.nowと連携する
it('should get Date diff correctly in fakeAsync', fakeAsync(() => {
  const start = Date.now();
  tick(100);
  const end = Date.now();
  expect(end - start).toBe(100);
}));
  • jasmine.clock と連携する
describe('use jasmine.clock()', () => {

  beforeEach(() => { jasmine.clock().install(); });
  afterEach(() => { jasmine.clock().uninstall(); });
  it('should tick jasmine.clock with fakeAsync.tick', fakeAsync(() => {
    // is in fakeAsync now, don't need to call fakeAsync(testFn)
    let called = false;
    setTimeout(() => { called = true; }, 100);
    jasmine.clock().tick(100);
    expect(called).toBe(true);
  }));
});

さらに、jasmine.clockを利用するとき、自動てきにfakeAsyncにはいることもできます。
まずsrc/test.tsで、zone-testingをimportするまえに、かきのコードを追加して、

(window as any)['__zone_symbol__fakeAsyncPatchLock'] = true;
import 'zone.js/dist/zone-testing';

そしたら、上記のケースで、fakeAsyncの呼び出しがいらなくなって、テストケースが自動てきにfakeAsyncに入りました。

describe('use jasmine.clock()', () => {
  // need to config __zone_symbol__fakeAsyncPatchLock flag
  // before loading zone.js/dist/zone-testing
  beforeEach(() => { jasmine.clock().install(); });
  afterEach(() => { jasmine.clock().uninstall(); });
  it('should auto enter fakeAsync', () => {
    // is in fakeAsync now, don't need to call fakeAsync(testFn)
    let called = false;
    setTimeout(() => { called = true; }, 100);
    jasmine.clock().tick(100);
    expect(called).toBe(true);
  });
});
  • Rxjs Schedulerとの連携

Rxjsでいろいろ時間に関するSchedulerがあって、delayとか、intervalとか、これらもfakeAsyncと連携することができます。
まずsrc/test.tsで、zone-testingをimportするまえに、かきのコードを追加して、

import 'zone.js/dist/zone-patch-rxjs-fake-async';
import 'zone.js/dist/zone-testing';

そしたら、下記のrxjs schedulerのケースがfakeAsyncで実行できます。

it('should get Date diff correctly in fakeAsync with rxjs scheduler', fakeAsync(() => {
  // need to add `import 'zone.js/dist/zone-patch-rxjs-fake-async'
  // to patch rxjs scheduler
  let result = null;
  of ('hello').pipe(delay(1000)).subscribe(v => { result = v; });
  expect(result).toBeNull();
  tick(1000);
  expect(result).toBe('hello');

  const start = new Date().getTime();
  let dateDiff = 0;
  interval(1000).pipe(take(2)).subscribe(() => dateDiff = (new Date().getTime() - start));

  tick(1000);
  expect(dateDiff).toBe(1000);
  tick(1000);
  expect(dateDiff).toBe(2000);
}));
  • Intervalのテスト

もしsetIntervalがテストコードで制御できるなら、テストが完了する前に、intervalをclear必要があります。

it('test setInterval', fakeAsync(() => {
  let a = 0;
  const intervalId = setInterval(() => a ++, 100);
  tick(100);
  expect(a).toBe(1);
  tick(100);
  expect(a).toBe(1);
  // need to clearInterval, otherwise fakeAsync will throw error
  clearInterval(intervalId);
}));

もしsetIntervalがほかの関数あるいはライブラリのなかで呼びされる場合、discardPeriodicTasksを呼び出す必要があります。

it('test interval lib', fakeAsync(() => {
  let a = 0;
  funcWithIntervalInside(() => a ++, 100);
  tick(100);
  expect(a).toBe(1);
  tick(100);
  expect(a).toBe(1);
  // need to discardPeriodicTasks, otherwise fakeAsync will throw error
  discardPeriodicTasks();
}));
  • 今Pendingの非同期操作をすべて実行したい場合

例えば、ある関数をテストするとき、この関数の中にsetTimeoutがあることが分かって、でも具体的なdelayが分からないとき、flushを利用したら、実行することができます。

it('test', fakeASync(() => {
  someFuncWithTimeout();
  flush();
}));
  • 今PendingのMicrotasksを実行

Microtasksといえば、基本てきにはPromise.thenになります。Macrotaskを実行したくなくて、Microtaskだけ実行したい場合、
flushMicrotasksを利用してください。

it('test', fakeASync(() => {
  let a = 0;
  let b = 0;
  setTimeout(() => a ++);
  Promise.resolve().then(() => {
    b ++;
  });
  flushMicrotasks();
  expect(a).toBe(0);
  expect(b).toBe(1);
}));

これから

zone.jsでいろいろほかの非同期操作をfakeAsyncテストできるように改修中で、Googleの開発者から聞いて、Google内部のテストケースが大部async/await + fakeAsyncになるらしくて、これからもっとfakeAsyncの利用できるケースを広げるために頑張ります!

どうもありがとうございました!

angular.jsonの中身

angular-cli 7.1.2で確認しました。
ng newで生成されるプロジェクトのangular.jsonの中身について、よく使う項目の調査です。

ルートオブジェクト

  • $schema
     このファイルに関するJSON Schemaの定義ファイル
     "./node_modules/@angular/cli/lib/config/schema.json"
     固定
     https://github.com/angular/angular-cli/blob/master/packages/angular/cli/lib/config/schema.json

  • version
     "1" 固定

  • cli
     生成時にはない。
     ngコマンド全般に対するデフォルトオプションを設定できる。

  • schematics
     生成時にはない。
     generate系のコマンドライン引数のデフォルトを設定できる。
     また、ngコマンドで生成するものを拡張していける(自作可)。

  • newProjectRoot
     "projects"
     ng generate libraryした際の保存先フォルダ

  • defaultProject
     複数のprojectがある場合のデフォルトを指定する
     ng generateやng buildする際にproject名を指定していない場合に利用される

  • projects
     プロジェクト名をキーにした各プロジェクトの詳細設定がある。

cliの中身

項目名 説明
defaultCollection schematicsのデフォルトを設定する
packageManager "npm", "cnpm", "yarn"からパッケージマネージャーを設定する
warnings ※1

※1 warningsの中身

項目名 説明
versionMismatch グローバルとローカルのバージョン違いの警告を表示する
typescriptMismatch typescriptのバージョンの警告を表示する

projectsの中身

項目名 説明
cli ルートオブジェクトと同様
schematics ルートオブジェクトと同様
prefix generateするものに対してつくprefix
root プロジェクトのrootになるフォルダ
sourceRoot assetsやindex.htmlがあるソースのフォルダ
projectType "application", "library"から選択する
architect 名称をキーにした詳細設定 ※2
targets 名称をキーにした詳細設定 ※2

※2 architectとtargetsはどちらか一つ。デフォルトではarchitectのみ。

※2 architectとtargetsの中身

項目名 説明
builder builderの設定。
@angular-devkit/build-angular:app-shell
@angular-devkit/build-angular:browser
@angular-devkit/build-angular:dev-server
@angular-devkit/build-angular:extract-i18n
@angular-devkit/build-angular:karma
@angular-devkit/build-angular:protractor
@angular-devkit/build-angular:server
@angular-devkit/build-angular:tslint
options builderに対するoption設定
configurations cliで実行時に切り替えするoption設定

build-angular:appShell の option設定の中身

項目名 初期値 説明
browserTarget ビルドターゲットとなるブラウザ
serverTarget app shellをレンダリングするサーバ
appModuleBundle サーバ用のAppModule
route / レンダーするroute
inputIndexPath
outputIndexPath

build-angular:browser の option設定の中身

項目名 初期値 説明
assets [] アセットとして含めるファイルのリスト
main エントリポイントになるスクリプトファイル
polyfills polyfill用のスクリプトファイル
tsConfig TypeScript設定ファイルのパス
scripts [] グローバルな(index.htmlに直接読み込ませる)スクリプトファイルのリスト
styles [] グローバルなスタイルシートのリスト。extractCssがtrueだと単独のstyles.cssに書き出される。
stylePreprocessorOptions scss等のプリプロセッサのオプション(includePaths)
optimization 最適化する。{scripts: true, styles: false} というような個別設定も可能
fileReplacements ファイル差し替え。主にenvironmentファイルに使われる。
outputPath ビルドした生成物が出力されるフォルダ
resourcesOutputPath styleの出力フォルダ。outputPathからの相対。
aot false Ahead of Time compilation(事前コンパイル)を有効にする
sourceMap true sourceMapを出力する。{scripts: true, styles: true, hidden: false, vendor: true}というような個別設定も可能
vendorSourceMap false ライブラリのsourceMapを出力する
evalSourceMap false in-file evalスタイルのsourceMapを出力する
vendorChunk true ライブラリだけで単独のファイルにする。ライブラリは変更頻度が低いため。
commonChunk true 複数にまたがるコードを共通ファイルにする。
baseHref index.htmlにタグとして出力されるパス。サブフォルダで使うような場合に設定する。
deployUrl 出力ファイルを読み込む際のパス。Urlという名前だけどパス指定。
verbose false ビルド時の出力
progress true ビルド時の進捗度合い表示
i18nFile i18n(国際化)で利用するファイル
i18nFormat i18nで利用するフォーマット
i18nLocale i18nで利用するロケール
i18nMissingTranslation i18nで訳がない場合の出力
extractCss false グローバル指定のcssを展開する
watch false ファイル変更を監視して自動的に再ビルドする
outputHashing "none" 出力ファイル名にハッシュをつける。キャッシュバスター。"none","all","media","bundles"
poll watchする際の監視間隔をミリ秒で設定
deleteOutputPath true ビルド前にoutputPathを削除する
preserveSymlinks false シンボリックリンクをリアルパスに変換する
extractLicenses true 利用ライブラリのライセンスファイルをまとめる
showCircularDependencies true 循環参照を表示する
buildOptimizer false aot利用時、@angular-devkit/build-optimizerを有効にする
namedChunks true 遅延読み込みのファイルに名前をつける
subresourceIntegrity false サブリソース。(よくわかっていない)
serviceWorker false ServiceWorkerのconfigを出力する
ngswConfigPath ngsw-configファイルへのパス。ServiceWorker関連。
skipAppShell false app-shell関連をビルドしない
index index.htmlファイルのパス
statsJson false webpack-bundle-analyzer で使うstats.jsonの出力
forkTypeChecker true TypeScriptの型チェッカーをforkモードで使う
lazyModules [] 遅延読み込みするNgModuleのリスト。大抵はrouterモジュールが自動的にやってくれる。
budgets [] 生成ファイルのファイルサイズ制限を設定できる。気軽に巨大なライブラリをimportすると使わないコードが大量に含まれてしまったりするのを警告する。

build-angular:devServer の option設定の中身

項目名 初期値 説明
browserTarget 対象にするbuild
port 4200 ポート。複数アプリ開発時に4201等違うポートに設定できる。
host "localhost" ホスト。0.0.0.0等にすると同じLAN内の違うマシンからアクセスできる。別OSやスマホからの確認に。
proxyConfig proxyConfigファイルのパス
ssl false SSL設定
sslKey SSL設定
sslCert SSL設定
open false 初回ビルド時にデフォルトブラウザを開く
liveReload true ビルド時に自動リロードする
publicHost ホスト名の制限
servePath サーブするパス
disableHostCheck false ホスト名のチェック
hmr false hot module replacementを有効にする
watch true ファイル変更を監視して自動的に再ビルドする
hmrWarning true hmr有効にする時の警告を表示する
servePathDefaultWarning true deploy-url/base-hrefが利用されている際の警告を表示する
optimization 最適化する。{scripts: true, styles: false} というような個別設定も可能
aot Ahead of Time compilation(事前コンパイル)を有効にする
sourceMap true sourceMapを出力する。{scripts: true, styles: true, hidden: false, vendor: true}というような個別設定も可能
vendorSourceMap false ライブラリのsourceMapを出力する
evalSourceMap in-file evalスタイルのsourceMapを出力する
vendorChunk ライブラリだけで単独のファイルにする。ライブラリは変更頻度が低いため。
commonChunk 複数にまたがるコードを共通ファイルにする。
baseHref index.htmlにタグとして出力されるパス。サブフォルダで使うような場合に設定する。
deployUrl 出力ファイルを読み込む際のパス。Urlという名前だけどパス指定。
verbose ビルド時の出力
progress ビルド時の進捗度合い表示

AWS AppSync + ApolloではじめるGraphQL

この記事は Angular Advent Calendar 2018 の 22 日目の記事です。

概要

  • AWS AppSyncを使って画面にデータを表示してみたい方
  • フロントエンドエンジニアでも簡単にGraphQLを触ってみたい方
  • GraphQLを導入するか迷っている方向けの記事

RESTがつらい

(例)AWS構成図
aws.png

上の図のようにAngular + AWSを使い
REST APIから返ってきた情報を表示するサービスを運用しています。
ただ、サービスの要件が増えるにつれて画面で取得したい情報など多くなるもので

  • 複数のAPIを呼び出さないと欲しい情報が取れない
  • レスポンス内の一部のデータしか利用しないのに、余分なデータも返される
  • APIのリクエスト数やRTTの増加

など色々つらい部分が多くなってきました…。

なぜGraphQL?

じゃあこの問題をどう解決するか?

  • 欲しい情報だけ返す新しいAPIを増やす
  • 既存のAPIにfieldsパラメータなどを実装する

など方法は色々あると思うのですが

GraphQLの場合、スキーマ設定次第で

  • 複数のサービスからシンプルに無駄なく欲しい情報を取得できる
  • リポジトリに対してクエリを生やすことでデータの取得が可能になるため、既存のプロジェクトに影響を与えず小さく導入できそう

という点で魅力的に思えたので、
今回、GraphQLのサービスである AWS AppSync を使い
Angular + Apolloを利用してデータを実装してみたので、その情報を共有していきたいと思います。

AWS + Angularで簡単にGraphQLをはじめるには?

おおまかな流れ

  1. こちらを参考にしながらAWS AppSyncを作成 (構築の手順は省略させていただきます。)
  2. Apollo + AWS AppSync JavaScript SDK のインストール
  3. Angularで実装

AWS構成図のイメージはこんな感じです。
appsync.png

完成コード

今回の完成コードはこちらに置いてあります。
https://github.com/hiroyuki-nishi/apollo-angular

今回利用するサービスの説明

AWS AppSync(バックエンド)
appsync (1).png

AWS AppSyncは、AWSが提供しているフルマネージドなサーバーレスなGraphQLサービスです。
他のAWS リソースとの連携が容易であったり、DynamoDB をデータソースとすると、GraphQLのスキーマが自動生成や、AppSyncのコンソール上で結果を確認することができます。

Apollo(フロントエンド)
apollo-logo.png

Apolloは、Meteor Development Groupが開発している
バックエンド && フロントエンドでGraphQLが使えるライブラリです。

Angular、React、Vue、Meteor、Ember、Polymerなどのフレームワークなどに対応しており
今回はApolloを使ってGraphQLのクライアントサイドを実装してみたいと思います。

まずはインストール!

AWS AppSyncが無事作成できましたら、Angularの実装を行なっていきます。
まずは、新しいプロジェクトを作成しApolloとAWS AppSyncSDKを追加します。

$ ng new apollo-in-angular
$ ng add apollo-angular
$ npm install --save aws-appsync

Angular 6.x以降でAWS AppSyncSDKを動作させるために、polyfills.tsに以下を追加します。

// polyfills.ts
(window as any).global = window;

Angularからデータを取得

次に、AWS AppSyncからデータを取得するサービスを作成します。
ここでは主に、AWS AppSyncのエンドポイントの設定を行なっています。

//graphql.service.ts
import AWSAppSyncClient from 'aws-appsync';
import { AUTH_TYPE } from 'aws-appsync/lib';
import { Apollo } from 'apollo-angular';
import { Injectable } from '@angular/core';


@Injectable()
export class GraphqlService {
  constructor(private apollo: Apollo) {}

  hydrated() {
    const appsyncClient = new AWSAppSyncClient({
      disableOffline: true,
      url: '<Your AppSync Api Url>',
      region: '<Your AppSync Api Region>',
      auth: {
        type: AUTH_TYPE.API_KEY,
        apiKey: '<Your AppSync ApiKey>',
      },
    });
    this.apollo.setClient(appsyncClient);
    return appsyncClient.hydrated();
  }
}

次にQueryサービスを定義します。
Apolloのドキュメントを参照しながら
今回は、複数のリポジトリから情報を取得することを想定したクエリを実装しました。

//query.service.ts
import {Injectable} from '@angular/core';
import {Query} from 'apollo-angular';
import gql from 'graphql-tag';

export interface Application {
  id: string;
  itunesstore_id: string;
  updated: string;
}

export interface Profile {
  identifier: string;
}

export interface ApplicationsWithProfiles {
  listApplications: {
    items: Application[];
  };
  listProfiles: {
    items: Profile[];
  };
}


@Injectable({
  providedIn: 'root',
})
export class ApplicationsGQL extends Query<ApplicationsWithProfiles> {
  document = gql`
    query {
      listApplications {
        items {
          id
          itunesstore_id
          updated
        }
      }
    listProfiles {
        items {
          identifier
        }
      }
    }
  `;
}

最後に表示するコンポーネントを作成します。

// app.component.ts
import { Component, OnInit } from '@angular/core';
import { map } from 'rxjs/operators';
import { Application, QueryGQL, Profile } from './query.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
  title = 'GraphQL with Apollo';
  applications: Application[];
  profiles: Profile[];

  constructor(private service: QueryGQL) {}

  ngOnInit(): void {
    this.service.watch().valueChanges.pipe(
      map(v => v.data)
    ).subscribe(res => {
      this.applications = res.listApplications.items;
      this.profiles = res.listProfiles.items;
    });
  }
}

こちらが完成した画面になります。
無事1リクエストで複数のリポジトリから、欲しい情報だけ取得できました。すごい。

スクリーンショット 2018-12-21 23.07.51.png

まとめ

実際にAWS AppSyncを実運用するにあたって
AWSの制限や実際に使ってみないとわからないことがあると思いますが
一つのクエリで複数のデータソースを組み合わせ欲しい情報に到達することができるのは
魅力的で、それを AWS リソースに対し手軽に行えるのはやはりよかったです。

今年も残りわずかですが
今からでもGraphQLを始めてみてはいかがでしょうか!

23日目は @studioTeaTwo さんです。よろしくお願いします。

AngularElementsでAMP htmlを動的にロードする

本記事ではAngularElementsを使ってAMP htmlをロードするという試みをします。
AMPは、ページを高速に表示してSEOがよくなるやつですね。1

Angular Elementsについて

ブートストラップ以下のツリーを通さず直接DOMにAngularComponentを追加できるようになるです。

ユースケースは1年前の記事でごちゃごちゃ書きましたが、相変わらず2つパラレルに走っています。

Angularアプリ内でElementsを使う方法

公式ドキュメントのサンプルが現在このユースケースです。Angularアプリ内で動的コンポーネントをどうやって実現するかという方向です。サンプルでは、AngularComponentでそのままやる方法と、customElementを用いる方法の2つが併記されています。

「サンプル: ポップアップサービス」

単発のcustomElementsとしてビルドして配布し、他のアプリケーションから使用する方法

この記事が非常にクオリティ高いです。
customElementのブートストラップや他のelementとの外部インターフェースについて理解が深まります。結論としてはivy待ちですね。

「Angular Elementsことはじめ – Custom Elementsを実装する方法」

また、公式ドキュメントですが、配布用のユースケースを載せてよというイシューが上がっているため、そのうち出てきそうです。

AMP htmlをAngularElementsでロードする

  • AngularElementsを使います。
  • サーバからAMP htmlを読み込み、elementsのiframeにセットします。

実装

公式ドキュメントのサンプルをベースに進めていきます。

AMP表示用のcomponentを用意する

いつものようにcomponentを作ります。iframeタグを用意してsrcdoc属性にXHRで取得したAMPhtmlソースをセットしようとしています。このcomponentは後にcusutomElement化します。そうすると@InputがHTMLElementのproperty属性になります。

amp-display.component.ts
@Component({
  selector: 'app-amp-display',
  template: `
    <iframe [srcdoc]="safeMsg"></iframe>
    <button (click)="closed.next()">&#x2716;</button>
  `
})
export class AmpDisplayComponent {
  safeMsg: SafeHtml;

  // customElement化した時にproperty属性になります。
  @Input()
  set message(message: string) {
    this.safeMsg = this.sanitizer.bypassSecurityTrustHtml(message);
  }

  @Output()
  closed = new EventEmitter();

  constructor(private sanitizer: DomSanitizer) {}
}

customElement化する

ここは公式ドキュメントのサンプルそのままです。ソースコメントにいたっては完全にコピペです。
AppComponentのコンストラクタ内で先ほどのcomponentをNgElementという変換器にかけた上でwindow.customElementsに登録します。これでもう標準のカスタム要素になりました。DOM APIから<app-amp-display>で操作可能です。逆にAngular内で扱おうとすると、例えばtemplateに<app-amp-display>をセットしても様々な困難がやってきます。

app.component.ts
@Component({
  selector: 'app-root',
  template: `
    <button (click)="ampDisplay.showAsElement()">Show as element</button>
  `
})
export class AppComponent {
  constructor(injector: Injector, public ampDisplay: AmpDisplayService) {
    // Convert `PopupComponent` to a custom element.
    const AmpDisplayElement = createCustomElement(AmpDisplayComponent, {
      injector
    });
    // Register the custom element with the browser.
    customElements.define('amp-display-element', AmpDisplayElement);
  }
}

AMPを読み込む

serviceを起こして、responseType: 'text'でサーバからAMPソースを読み込むようにします。AMPソースは、URLを叩けば普通に表示できる<html>タグ以下のフルhtmlドキュメントです。

@Injectable()
export class AmpDisplayService {
  private ampUrl = 'http://localhost:8080/amp';

  constructor(
    private http: HttpClient
  ) {}

  private getContent() {
    return this.http.get(this.ampUrl, { responseType: 'text' });
  }
}

Angularアプリ内からcustomElementを呼び出す

serviceにcustomElementをDOMに付与するメソッドを追加します。すでにwindowには登録済みなので、document.createElement()で作成できるようになっています。@Input() messageがHTMLElementのproperty属性として変換されているため、取得したAMPソースを渡してやります。

@Injectable()
export class AmpDisplayService {
  private ampUrl = 'http://localhost:8080/amp';

  constructor(
    private injector: Injector,
    private http: HttpClient
  ) {}

  // This uses the new custom-element method to add the popup to the DOM.
  showAsElement() {
    // Create element
    const ampDisplayEl: NgElement &
      WithProperties<AmpDisplayComponent> = document.createElement(
      'amp-display-element'
    ) as any;

    // Listen to the close event
    ampDisplayEl.addEventListener('closed', () =>
      document.body.removeChild(ampDisplayEl)
    );

    this.getContent().subscribe(text => {
      // Set the message
      ampDisplayEl.message = text;
      // Add to the DOM
      document.body.appendChild(ampDisplayEl);
    });
  }
}

APMコンテンツを用意する

以下のAMPチュートリアルを参考にHTMLドキュメントを用意しました。expressサーバから供給します。

https://www.ampproject.org/ja/docs/getting_started/create/basic_markup

結果

ボタンを押すと、AMPソースをサーバーから取得して、トーストに表示しています。グレーの背景がAMPソースです。

トリム.mov.gif

リポジトリ

今回のコードはこちらにアップしています。

https://github.com/studioTeaTwo/elements-amp

ほんとうにやりたかったこと

正直、iframeは逃げました。ほんとうにやりたかったことは、iframeの代わりにshadowDOMを使ってAMPを表示する2という話がありました。そこまで来るとAngularElementsを使ってcustomElementにする意味もあるというもので。しかし、shadowRootに<html>タグ以下をそのまま追加すればいいってものでもなく、AMP projectで用意されているwindow.AMP.attachShadowDoc()という謎モジュールの解明で時間切れとなりました。reactのサンプル実装があるので、ご興味ある方はトライしてみてください。

「AMP を埋め込んでデータソースとして使用する」

デモサイト3を見るとおおよそやってることはわかるかと思います。今回やろうとしていた特定領域に出力先をコントールするのではなくドキュメント全体に適用するような手法で、実はそれならAngularでもwindow.AMP.attachShadowDoc()を使ってできました。機会を見てまた記事にするかもしれません。

明日は@Czernyさんです。


  1. https://www.ampproject.org/ja/learn/overview/ 

  2. https://html5experts.jp/shumpei-shiraishi/24795/ 

  3. https://choumx.github.io/amp-pwa AMPhtmlから<style><body>タグだけを取り出してshadowRootに適用している感じです。<meta><script>はホストのreactドキュメントに適用しているような動きに見えます。shadow-v0.jsというのがOSS化されてないと思うので出力結果からの憶測です。 

Angular MaterialのWrapper Componentを作る

こんにちは。24日目、思いっきり遅刻してしまい申し訳ありません。Angularを使い始めて半年も経ってないですが、Angular Material ComponentのWrapper component(特にmat-radio-button)を作りながらいくつか気づいたところを書いていきたいと思います。

@Input

まず基本的なInputプロパティ。普通のやり方でdisabledで例えると

  • Wrapperを使うクライアントからのBinding
app.component.html
<app-radio-button [disabled]="true">Option 1</app-radio-button>
  • WrapperのInputプロパティを経由して
radio-button.component.ts
@Component({
...
})
export class RadioButtonComponent {
  @Input()
  public disabled: boolean;
...
  • WrapされるComponentプロパティにBinding
radio-button.component.html
<mat-radio-button [disabled]="disabled">
  <ng-content></ng-content>
</mat-radio-button>

の順番にデータが流れます。

HTML attribute対応

app.component.html
<app-radio-button disabled>Option 1</app-radio-button>

ここで上のようにdisabledをHTML attributeにも対応するには
(ここでdisalbedは空の文字列が入る)

radio-button.component.ts
...
export class RadioButtonComponent {
  private _disabled: boolean;
  @Input()
  get disabled(): boolean { return this._disabled; }
  set disabled(value) {
    this._disabled = (value === '' || !!value);
  }
...

のように強引にstring型にも対応した感じですが、実はAngular Material側ですでに対策済みであるため、ただプロパティを流すだけでHTML attributeにも対応ができてしまいます。(というか上の方法ではTSエラーになってしまいます。)

coerceBooleanProperty()

対策されているAngular Materialを覗くと下記のようにCDKのヘルパー関数が使われています。

radio-button.component.ts
import { coerceBooleanProperty } from '@angular/cdk/coercion';
...
  set disabled(value) {
    this._disabled = coerceBooleanProperty(value);
  }
...

HTML attributeで取得されるvalueはstring型になり、上記の関数はvalueが文字列なら'false'以外すべてをtrueに変換するようです。
もしプロパティの行き先がAngular MaterialのComponentじゃないときに使うと便利そうですね。

mat-radio-group

 Front-end開発において、Radio buttonはその性質上、複数のComponentで単一のフォームを持つ必要があります。ReactiveFormsだったらFormControl、Template-drivenだったらNgModelどのタグに持たせるか。Angular Materialでは<mat-radio-group>という親タグその役割を担っています。
 しかし、Radio buttonはまた、他のinputフォームと違って、name属性によってGroupingされる特殊な仕様があります。各radio-buttonタグでnameを指定してまとめる手もありますが、そうしてしまうと親のradio-groupタグの存在意義がなくなる。
 これをどう対処するか悩ましいところですが、そもそもwrapしようとするmat-radio-groupを覗くと、

material2/src/lib/radio/radio.html
    <input #input class="...
        ...
        [attr.name]="name"
material2/src/lib/radio/radio.ts
...
let nextUniqueId = 0;
...
@Directive({
...
})
export class MatRadioGroup extends ... {
  ...
  // nameはgroupコンポーネントで決まる
  private _name: string = `mat-radio-group-${nextUniqueId++}`;
  ...
  // content内の子コンポーネントのリストを取得
  @ContentChildren(forwardRef(() => MatRadioButton), { descendants: true })
  _radios: QueryList<MatRadioButton>;
  ...
  @Input()
  get name(): string { return this._name; }
  set name(value: string) {
    this._name = value;
    // nameが変わったら子コンポーネントにも反映する
    this._updateRadioButtonNames();
  }
  ...
  private _updateRadioButtonNames(): void {
    if (this._radios) {
      this._radios.forEach(radio => {
        radio.name = this.name;
      });
    }
  }

なるほど、@ContentChildrenで子コンポーネントを取得し、親と同じnameをセットする仕組みのようです。

 今度はMatRadioButtonを見ると、

material2/src/lib/radio/radio.ts
...
@Component({
...
})
export class MatRadioButton extends ... implements OnInit, ... {
  ...
  @Input() name: string;
  ...
  constructor(
    @Optional() radioGroup: MatRadioGroup,
    ...
  ) {
    ...
  }
  ...
  ngOnInit() {
    if (this.radioGroup) {
      ...
      // 親groupのnameを引き継ぐ
      this.name = this.radioGroup.name;
    }
  }
...

DIで親コンポーネントを取得してそのnameを引き継ぎます。

Circular dependency

 ソースを見て気づいた方も多いと思いますが、MatRadioButtonMatRadioGroupが自分のクラスの中で互いを参照する状態になっています。上記の例のようにradio.tsという一つのファイルの中ならforwardRefを使うだけでなんとかなっています。ずるい。
 しかし、Wrapper Componentを作る際はAngular流儀上、1コンポーネント/1ファイルになるため、そのまま真似してしまうと、

WARNING in Circular dependency detected:
src/app/radio-button/radio-button.component.ts -> src/app/radio-group/radio-group.component.ts -> src/app/radio-button/radio-button.component.ts

WARNING in Circular dependency detected:
src/app/radio-group/radio-group.component.ts -> src/app/radio-button/radio-button.component.ts -> src/app/radio-group/radio-group.component.ts

を食らってしまうし、一度決まったnameをプログラム途中で変更するユースケースは想定しにくいので、

  • ButtonからGroupのnameを取りに行く
  • Groupからは何もしない(Buttonを参照しない)

の方針で、依存をButton(子)→Group(親)のように一方向にするのが妥当かなと思います。

ControlValueAccessor

 Custom Form Controlを作るためにControlValueAccessorを実装します。ControlValueAccessorとはAngular Form API(FormControl)とDOMのNative(or Custom) Elementをつなぐinterfaceで、<input>などフォームタグ(Native element)に対応するDirectiveはすべてこのinterfaceを実装しており、以下のように定義されています。

Accessor Form Element
DefaultValueAccessor input, textarea
CheckboxControlValueAccessor input[type=checkbox]
NumberValueAccessor input[type=number]
RadioControlValueAccessor input[type=radio]
RangeValueAccessor input[type=range]
SelectControlValueAccessor select
SelectMultipleControlValueAccessor select[multiple]

ControlValueAccessorの各メソッドの実装が済んだら、あとはNG_VALUE_ACCESSORというDI tokenを持ってProviderに登録することで、FormControlが使えるようになります。

radio-group.component.ts
@Component({
  ...
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => RadioGroupComponent),
    multi: true
  }],
  ...
})
export class RadioGroupComponent implements ControlValueAccessor, ... {
  ...
  writeValue(obj: any) {...}
  registerOnChange(fn: any) {...}
  registerOnTouched(fn: any) {...}
  setDisabledState(isDisabled: boolean) {...}
}

DI tokenとかProviderとか調べるとどういう仕組になってるのか、あれこれ説明されているところは多いですが、いくらソースコードを読んでみても、そもそものAngularのDIコンテナの仕組み自体あまり理解できず、雰囲気だけで作ってる感があるので、詳しく説明していただける方がいらっしゃれば…

References

2019年のAngularを考える

External article
Browsing Latest Articles All 25 Live