余白

https://blog.lacolaco.net/ に移転しました

Angular CDKのPortalを使ったローディングラッパーの実装

今回はAngular CDK(Component Dev Kit)の Portal 機能を使って、ローディングラッパコンポーネントを実装する例の紹介です。 Angularの基本的な書き方はわかっている前提の内容になります。


ローディングラッパーとは次のようなテンプレートで、ローディング中はローディング表示を、ローディングが終わったら子要素を表示するようなコンポーネントを指しています。 たとえばこのようなテンプレートです。

<mat-card>
    <loading-wrapper [loading]="isLoading$ | async">
        <div>Done!</div>
    </loading-wrapper>
</mat-card>

このように、ローディング状態によってビューが差し替わります。

f:id:lacolaco:20180701162327g:plain

CdkPortalの使い方

@angular/cdk/portalからインポートできるPortalModuleによって、cdkPortalOutletなどのいくつかのディレクティブが有効になります。

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { PortalModule } from '@angular/cdk/portal';

import { AppComponent } from './app.component';
import { LoadingWrapperComponent } from './loading-wrapper.component';

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

cdkPortalOutletディレクティブは、渡されたCdkPortalに紐づくビューをその位置に表示します。

https://material.angular.io/cdk/portal/api#CdkPortalOutlet

  <ng-template [cdkPortalOutlet]="contentPortal"></ng-template>

つまり、ローディングラッパコンポーネントがおこなうことは、ローディング状態に応じてcontentPortalの中身を差し替えることです。

TemplatePortalの作成

CdkPortalはいくつかの種類がありますが、今回はTemplateRefをビューとして保持するTemplatePortalを使います。 ローディング状態のテンプレートをloadingContent、親コンポーネントから渡されるコンテンツ要素をcontentとして、それぞれViewChildコンポーネントから参照できるようにします。

<ng-template #loadingContent>
    <div>
        <div>Loading...</div>
        <mat-spinner color="accent"></mat-spinner>
    </div>
</ng-template>

<ng-template #content>
    <ng-content></ng-content>
</ng-template>

<ng-template [cdkPortalOutlet]="contentPortal"></ng-template>

コンポーネント側では、初期化時と、ローディング状態を制御するloadingプロパティが変わったときにビューをスイッチするようにします。 次のコードにおけるswitchViewメソッドが、TemplateOutletを作成している部分です。

@Component({
  selector: 'loading-wrapper',
  templateUrl: './loading-wrapper.component.html'
})
export class LoadingWrapperComponent implements OnInit, OnChanges {
  @Input() loading: boolean;

  @ViewChild('loadingContent') loadingContentTemplate: TemplateRef<any>;
  @ViewChild('content') contentTemplate: TemplateRef<any>;

  contentPortal: CdkPortal;

  constructor(private vcRef: ViewContainerRef) { }

  ngOnInit() {
    this.switchView();
  }

  ngOnChanges(changes: SimpleChanges) {
    if (changes.hasOwnProperty('loading')) {
      this.switchView();
    }
  }

  // 現在のローディング状態から適切なTemplatePortalを作成する
  switchView() {
    this.contentPortal = new TemplatePortal(this.getTemplate(), this.vcRef);
  }

  private getTemplate() {
    if (this.loading) {
      return this.loadingContentTemplate;
    }
    return this.contentTemplate;
  }
}

まとめ

  • CdkPortalを使って、状態に応じたビューの差し替えの実装が簡単にできる
  • TemplatePortalを使って、ng-templateから取り出したTemplateRefCdkPortalに変換できる

完成形がこちらです。

stackblitz.com

Patreonで投げ銭受け取りはじめました

Patreonという、個人を金銭的に支援できるサービスに登録して、投げ銭を受けられるようにしました。

www.patreon.com

簡単に$1〜の少額を毎月支援できます。いつでもキャンセルできるしクレカで払えるし気軽。

きっかけはこの記事

jp.techcrunch.com

Patreonは前から知っていたけど、あらためてこういうの当たり前にしていきたいなーと思い、まずは自分でやってみます。

別にOSSで生活したいわけではないので、集まったお金は僕のモチベーションアップになります。

まだ肌感がわかってないので、とりあえず用意したのはネタ2種類です。

  • たまご: $1から支援できて、特に特典はなしです。気持ちだけ贈るオプション
  • まぐろ: $5から支援できて、1対1のSlackチャンネルに招待します。プログラミングの相談とか、自由に話せるホットラインです。

f:id:lacolaco:20180628195601p:plain

らこらこホットライン、むしろプログラミング勉強してる学生とか応援したいし相談乗れるようにしたいんだけど、Patreonだと最低$1必要だし、そもそもクレカないパターンあるよね。 どうやってリーチするといいのかなーとぼんやり考え中。

まだ日本のOSS活動やってる系プログラマーでPatreonやってる人全然いないから、どんどん登録して相互支援で相互リンクみたいな楽しい感じの盛り上がりやりたいですね。

それでは。

AngularにおけるstrictPropertyInitializationのベストプラクティス

AngularコアチームのStephen Fluin氏が、こんなブログ記事をあげている。

https://fluin.io/blog/property-has-no-initializer-and-is-not-definitely-assigned

TypeScript 2.7から導入された、クラスプロパティの初期化をチェックするstrictPropertyInitializationオプションの話だ。

tsconfigのstrictPropertyInitialization オプションを有効にすると、undefinedを許容していないプロパティがプロパティ宣言時あるいはコンストラクタで初期化されていないときにコンパイルエラーになる。 これをstrictNullChecksオプションと併用することで、明示的に T?あるいは T | undefinedという宣言をしない限りかならず初期化を要求される。

たとえば次のようなコードがエラーになる。nameプロパティはstring型なのでundefinedを許容せず、初期化漏れのコンパイルエラーになる。

class Person {
    name: string; // Property 'name' has no initializer and is not definitely assigned in the constructor.

    constructor() {
    }
}

この設定は安全なTypeScriptを書くうえでかなり便利だが、Angularにおいては少し注意が必要である。

AngularでstrictPropertyInitializationを使う上で問題になるのは、クラスプロパティのうちAngularのデコレーターによって遅延して初期化されるものだ。

たとえば、@ViewChild@ContentChildrenなどは、クラスの初期化時ではなくコンポーネントのビューツリーの解決時に初期化されるので、strictPropertyInitializationがうまく噛み合わなくなる。

そのため、ビュー解決後は値を持っていることはほぼ確実だが、それまではundefinedになるので、プロパティを?としてオプショナルにするか、| undefinedとしてundefinedを許容することになる。

Stephenのベストプラクティス

AngularコアチームのStephenは、TypeScriptにしたがい、ビュー解決を待つプロパティは?でオプショナルにするのを推奨している。

理由は書かれていないが、推測するとコンポーネントのクラス実装とテンプレートは文字列あるいは別ファイルに存在した疎結合の関係であり、開発者の頭の中では確実に存在するとわかっていても、システム上は実行するまでViewChildで取得しようとしている子のビューが存在することは不定である。

次のように、childは基本的にオプショナルであり、存在が確認できるときだけ処理をするのがベストである。なぜならngIfによるスイッチングなど、コンポーネントの生存中に子ビューの参照が消えることは多々あるからだ。

class SomeComponent {
    @ViewChild() child?: SomeChildComponent;

    ngAfterViewInit() {
        if (this.child != null) {
           // ...
        }
    }
}

Inputプロパティについてのプラクティス

Angularで初期化が問題となるプロパティデコレーターのもうひとつは、@Inputデコレーターだ。

現実問題として、Inputにはオプショナルなものと必須なものがある。常に特定のInputが与えられることを前提として記述されるコンポーネントだ。たとえば次のような例が考えられる。

@Component({
  selector: 'user-card'
})
class UserCardComponent {
    @Input() user: User;
}

このコンポーネントで、 userをオプショナルにするのは意味論的に避けたいし、契約としてそういったコンポーネントの利用は禁止したい。そのためuserの型はNon-NullableなUser型である。 しかし、これをこのまま放置すると、strictPropertyInitializationオプションで初期化していないとエラーになる。

この問題について尋ねると、別のAngularコアチームメンバーであるRado Kirov氏からアドバイスをもらえた。

少し乱暴ではあるが、契約として必ず値が渡されることを求めるプロパティについては、Non-nullアサーションオペレータ !を使ってプロパティがundefinedじゃないことを明示的に示せばいいというものだ。

次のようなコードになる。プロパティの宣言時には必ず初期化されていることを明示し、コンポーネントの初期化後にはそれを確認する。 ?を使ったものと違い、プロパティの型をオプショナルにしていないので、プロパティを使用するたびにif文で型ガードを作らなくてもよい。

親からの値が必須であるInputプロパティについては、実行時アサーションとセットにしたNon-nullアサーションオペレータで解決するのが、現状のベストプラクティスになりそうだ。

@Component({
  selector: 'user-card'
})
class UserCardComponent {
    @Input() user!: User;

    ngOnInit() {
        if (this.user == null) {
            throw new Error('[user] is required');
        }

        this.someFunc(this.user); // no need `if` type guard
    }

    someFunc(user: User) {}
}

将来的にはcodelyzerやlanguage-serviceでこのへんをチェックして、undefinedを許容していないInputへの値渡しがテンプレート中で行われていないことを検知してもらいたい。

Observableの初期化

Storeとの接続や、リアルタイムDBとの接続など、コンポーネントがObservableを購読する必要があるときは、コンストラクタでObservableの初期化をおこなうのがよい。

よくあるRedux的な状態管理をしているアプリケーションだと、このようにコンポーネントとストアを接続する。 そしてコンポーネント内ではsubscribeせず、テンプレート内でasyncパイプを使って非同期ビューを構築する。

class UserListComponent {
    userList$: Observable<User[]>;

    constructor(store: Store) { 
        this.userList$ = this.store.select(state => state.userList);
    }
}

コンポーネント内でsubscribeする必要がある場合は、Observableの初期化だけをコンストラクタで行い、subscribeの開始はngOnInit以降に開始すべきである。 コンストラクタでsubscribeしてしまうと、コンポーネントの初期化より先に値の解決が始まってしまい、変更検知のタイミング制御が困難なり、デバッグしにくくなる。

まとめ

  • @ViewChild@ContentChildはオプショナルプロパティとして扱うべし
  • 必ず親から値を渡されないと困る@Inputは、実行時アサーションとセットでNon-nullアサーションオペレータを使うべし
  • Observableのプロパティ初期化はコンストラクタで行い、subscribeasyncパイプあるいはngOnInit以降にAngularのライフサイクルにあわせて開始するべし

ECMAScriptのimport/exportについてのメモ

js-primerというJavaScriptの本を書く上でES2015のimport/export構文の仕様について気になったところがあって調べたメモ

github.com

デフォルトエクスポートの扱い

まず、デフォルトエクスポートする方法がふたつある。ひとつは専用のexport default文によってエクスポートする方法。

export default function () {} 

また、defaultという名前で名前付きエクスポートすれば、それもデフォルトエクスポートしたことになる。

function foo() {}

export { foo as default };

デフォルトエクスポートがdefaultという固有名がつけられることは、Specの https://www.ecma-international.org/ecma-262/6.0/#sec-exports-static-semantics-exportentries にかかれている。

f:id:lacolaco:20180626135151p:plain

f:id:lacolaco:20180626135151p:plain
default export

デフォルトエクスポートをインポートする方法

デフォルトエクスポートされたものをインポートする方法もいくつかある。ひとつは一番シンプルなデフォルトインポート用の専用構文を使う。

import otherDefault from "other.js";

ところでこれは名前付きインポートで次のように書き換えられる。先程デフォルトエクスポートで書いたように、デフォルトエクスポートはdefaultという固有名でエクスポートされていることを利用できる。

import { default as otherDefault } from "other.js";

この構文、MDNのimport文のところには書かれていない。

developer.mozilla.org

ちなみに、default asというのは専用の構文ではなく、asによるエイリアス付きインポートの構文が適用されており、仕様上ではdefaultは特別なキーワードではなくただのIdentifierNameとして扱われているはず。 次のコードでdefaultとfooは仕様上同じもので区別できない。

import { default as otherDefault, foo as otherFoo } from "other.js";

https://www.ecma-international.org/ecma-262/6.0/#sec-imports-static-semantics-boundnames

f:id:lacolaco:20180626135841p:plain

しかし、次のコードはシンタックスエラーになる。asによるエイリアスを使わない場合には、defaultfooは等価ではなくなる。 なぜならdefaultECMAScript予約語であるからだ。

import { default, foo } from "other.js";

https://www.ecma-international.org/ecma-262/6.0/#sec-keywords

ここの肝は、Import構文のImportsListというもので、これはImportSpecifierのリストだが、ImportSpecifierはImportedBindingあるいはidentifierName as ImportedBinding と定義されている。

https://www.ecma-international.org/ecma-262/6.0/#sec-imports

f:id:lacolaco:20180626140548p:plain

つまり、import { default } と書いたときのdefaultImportedBindingとして扱われるが、これはシンボルとして参照可能なので予約語のチェックに違反する。

一方で、import { default as alias }と書いたときのdefaultidentifierNameであって、ImportedBindingではなくなり、予約語のチェックから外れる。

結果的に、default asはまるで普通のエイリアスされた名前付きインポートのように振る舞うことができる。 しかし仕様上はimport { default as ...}構文というのは存在していないが、デフォルトエクスポートや予約語の仕様が絡んだ結果、事実上の構文っぽいものになっているのがややこしい。

MDNの構文例はexportとimportどちらも、デフォルトエクスポートがdefaultという名前付きエクスポートとして振る舞っている例を扱っていないが、 これは仕様が複雑で説明が難しいからされてないんだろうか。

とりあえず自分の中で整理がついたので良し。

Angular v6.1で導入されるRouter Scrollerの紹介

こんにちは、lacoです。

Angularの次のマイナーアップデートで、久しぶりに新機能らしい新機能が増えます。 その名もRouter Scrollerです。 長くAngularを使っている人には涙が出るほど嬉しい待望の機能です。 この記事ではRouter Scrollerの紹介と、来週のbeta.1まで待てない!今すぐ試してみたい!という奇特な方のために、最新のビルドで試す方法も紹介します。

Router Scroller

Router Scrollerは、Angular Routerにスクロールに関連する機能を与えるものです。 Router Scrollerを使うと、次のようなことができます。

  • ブラウザバックしたときに遷移前のスクロール位置に復元する
  • #foo のようなフラグメント付きのURLで、対応するIDを持つ要素まで自動でスクロールする

どちらも、静的なHTMLページであればブラウザが自動的に行なってくれるものですが、Angular RouterによるSPAでも同じふるまいを簡単に導入できます。 それぞれについて紹介します。

Scroll Position Restoration

Router Scroller第一の機能は、スクロール位置の記憶と復元です。 Routerによるナビゲーションを行うたびに、その時点でのスクロール位置を記憶します。 そして、ブラウザの戻る操作で前の画面に遷移したときには、記憶したスクロール位置に自動的に復元します。 この復元処理はRouterによってタイミングが制御されているため、前の画面のルーティング処理が終わったあとにスクロールが移動します。 よって、先にスクロールが走ってしまい、あとからコンポーネントが描画されてしまい位置がずれる、ということはありません。

https://media.giphy.com/media/g0EEPgQPgyKDoAO6fh/giphy.gif

Anchor Scrolling

もうひとつの機能は、URLに#fooのようなフラグメントが付いている場合、対応するIDを持つ要素があればそこまでスクロールする機能です。 これも静的なHTMLページではよく使われている機能ですが、Angularではブラウザが要素を探すタイミングがRouterによるコンポーネント生成より早いためうまくいきませんでした。 今回RouterがAnchor Scrolling機能を持ったことで、Angularアプリケーションであっても#fooによるスクロールが可能になりました。 Routerによるナビゲーションだけでなく、リロードしても同様にスクロールしてくれます。

https://media.giphy.com/media/2WH6rWL48nbrLScyMM/giphy.gif

使い方

Scroll Position RestorationもAnchor Scrollingも、v6.1.0時点ではデフォルトで無効になっています。 そのため、有効にするにはRouterModuleに設定を行う必要があります。

RouterModule.forRootメソッドの第2引数のオプションに、scrollPositionRestorationanchorScrolling が追加されています。 それぞれ、'enabled'に設定すると機能が有効になります。これだけで完了です。

@NgModule({
  imports: [
    RouterModule.forRoot(routes, {
      scrollPositionRestoration: 'enabled',
      anchorScrolling: 'enabled',
    })
  ],
  exports: [RouterModule]
})
export class AppRoutingModule {}

Scroll Offset

Router Scrollerには、オフセットを指定することもできます。 ヘッダーがfixedやstickyな場合など、スクロール位置をずらしたい場合には、RouterModule.forRootメソッドの第2引数のオプションでscrollOffsetを設定します。 先程のAnchor Scrollingの例では、上部のヘッダーがstickyなので、y座標を64pxだけ下にずらしています。

@NgModule({
  imports: [
    RouterModule.forRoot(routes, {
      scrollPositionRestoration: 'enabled',
      anchorScrolling: 'enabled',
      scrollOffset: [0, 64] // [x, y]
    })
  ],
  exports: [RouterModule]
})
export class AppRoutingModule {}

今すぐ試すには

Router Scrollerはv6.1.0-beta.1からリリースに含められる予定です。 それより先に試したい方は、@angular/router@angular/common@angular/platform-browser@angular/platform-browser-dynamicの4つをパッケージを開発版ビルドに置き換える必要があります。次のようなバージョン指定でよいでしょう。

    "@angular/common": "angular/common-builds",
    "@angular/compiler": "^6.0.0",
    "@angular/core": "^6.0.0",
    "@angular/platform-browser": "angular/platform-browser-builds",
    "@angular/platform-browser-dynamic": "angular/platform-browser-dynamic-builds",
    "@angular/router": "angular/router-builds",

もっと知りたい

さらに詳しく知りたい方は、当該機能のコミット内容を読むと良いでしょう。

github.com

この記事のサンプルコードはGitHubで公開しています。

github.com