Marginalia

[日本語訳] ViewChildren and ContentChildren in Angular 2


この記事ではAngular 2の View ChildrenContent Children の違いについて解説します。

それぞれの子要素に対して、親コンポーネントからどのようにアクセスするのかを理解し、 そして@Componentデコレータの providersviewProviders の2つのプロパティの違いについても述べていきます。

プリミティブの合成

まず初めに、Angular 2のコンポーネントとディレクティブの関係についておさらいしましょう。 ユーザーインタフェースを作るのに一般的なデザインはCompositeパターンです。 このデザインパターンは異なるプリミティブを合成し、1つのインスタンスとして同じ方法で扱えるようにします。 関数型プラグラミングの世界では関数を合成することもできます。例えば次のように

map ((*2).(+1)) [1, 2, 3]
-- [4,6,8]

このHaskellのコードは(*2)(+1)という2つの関数を合成し、 リスト内の各アイテムnに対してn -> + 1 -> * 2という流れで適用されます。

UIにおける合成

ユーザーインタフェースにおいても同じことがいえます。 コンポーネントを1つの関数として見てみましょう。 各関数は順序に従って合成可能であり、結果として複雑なコンポーネントを作ることができます。

この構造は次の図で表すことができます。

この図には2つの要素があります。

  • Directive: ロジックを持った要素ですが、構造は内包していません。
  • Component: 1つの要素であり、1つのディレクティブでもあります。そして複数のディレクティブを内包しています。(コンポーネントを内包することもできます)

これはつまり、次のような構造を作ることができるということです。

上の図を見ると、コンポーネントとディレクティブによる階層構造ができていることがわかります。 末端の要素はディレクティブか、ディレクティブを内包しないコンポーネントのどちらかになります。

Angular 2におけるコンポーネントの合成

さて、そろそろAngular 2の具体的な話をしましょう。

これから説明することをわかりやすくするために、簡単なアプリケーションを作っていきます。

// ...@Component({  selector: 'todo-app',  providers: [TodoList],  directives: [TodoCmp, TodoInputCmp],  template: `    <section>      Add todo:      <todo-input (onTodo)="addTodo($event)"></todo-input>    </section>    <section>      <h4 *ngIf="todos.getAll().length">Todo list</h4>      <todo *ngFor="var todo of todos.getAll()" [todo]="todo">      </todo>    </section>    <ng-content select="footer"></ng-content>  `})class TodoAppCmp {  constructor(private todos: TodoList) {}  addTodo(todo) {    this.todos.add(todo);  }}// ...

これは Yet another MV* todo application です. (訳注:アプリケーションの名前なのでそのまま残しています。)

上では todo-app というセレクタで、テンプレートを持ったコンポーネントを定義しています。 そして、子要素として使われるディレクティブのセットを定義しています。

このコンポーネントは次のように使われます。

<todo-app></todo-app>

これは標準的なXMLの構文なので、 開始タグと終了タグの間に任意のcontentを挿入することができます。

<todo-app>  <footer>    Yet another todo app!  </footer></todo-app>

ng-content による基本的なcontentの表示

ここで todo-app コンポーネントの定義にちょっと戻りましょう。 テンプレートの最後の要素 <ng-content select="footer"></ng-content> に気づきましたか? ng-content を使うと、そのコンポーネントの開始タグと終了タグの間に置かれたcontentを、テンプレートの中に投影することができます! ng-content に与えている select 属性はCSSのセレクタで、contentの中から投影するものを選ぶことができます。 上の例では footertodo-app コンポーネントの一番下に挿入されます。

もし select 要素を省いた場合は、content全体が投影されます。

<todo-input><todo> の2つのコンポーネントについては今回の話には関係ないので実装部分を省略していますが、 このアプリケーションの完成形はこんな感じになります。

ViewChildren と ContentChildren

ここまでは簡単でしたね!さて、ようやくView ChildrenとContent Childrenという概念を定義する準備ができました。

その定義とは、「テンプレートの中に配置された子要素を View Children と呼ぶ」、 そして「開始タグと終了タグの間に置かれた要素を Content Children と呼ぶ」です。

つまり、todo-inputtodo の2つの要素は todo-app にとっての View Childrenであり、 footer はContent Childであるというわけです。

子要素にアクセスする

ようやく本題に入れます!2種類の子要素にどうやってアクセスし、操作するのか、その方法を見ていきましょう!

View Childrenを使った例

Angular 2では次のプロパティデコレータを angular2/core パッケージで提供しています。:

  • @ViewChildren
  • @ViewChild
  • @ContentChildren
  • @ContentChild

これは次のように使います。

import {ViewChild, ViewChildren, Component...} from 'angular2/core';

// ...

@Component({
  selector: 'todo-app',
  providers: [TodoList],
  directives: [TodoCmp, TodoInputCmp],
  template: `...`
})
class TodoAppCmp {
  @ViewChild(TodoInputCmp)
  inputComponent: TodoInputCmp
  
  @ViewChildren(TodoCmp)
  todoComponents: QueryList<TodoCmp>;

  constructor(private todos: TodoList) {}
  ngAfterViewInit() {
    // available here
  }
}

// ...

上の例では、 @ViewChildren@ViewChild を使っています。 プロパティをデコレートすると、要素をクエリできるようになります。 上の例だと、子コンポーネントである TodoInputCmp@ViewChildで、 TodoCmp@ViewChildren でクエリしています。 2つのデコレータを使い分けている理由は、 TodoInputCmp は1つしかないので @ViewChild を使えますが、 TodoCmp は複数個が表示されるので @ViewChildren を使う必要があるからです。

もう一つ重要なことは、 inputComponenttodoComponents の型です。 前者のプロパティの型は TodoInputCmp になっています。 これはクエリした結果要素が見つからなければnullになり、見つかればそのコンポーネントのインスタンスが代入されます。 一方、 todoComponents プロパティの型は QueryList<TodoCmp> で、動的に増えたり減ったりする TodoCmpのインスタンスを扱えます。 QueryList はObservableなコレクションなので、新しく追加されたり、要素が削除された時にはイベントを発生してくれます。

AngularのDOMコンパイラは todo-app コンポーネントを先に初期化し、その後子要素を初期化します。 つまり todo-app コンポーネントの初期化の間は inputComponenttodoComponents もまだ初期化されていません これらは ngAfterCiewInit ライフサイクルフックのタイミングで使用可能になります

Content Childrenにアクセスする

Content Childrenについてもほとんど同じルールが通用しますが、少しだけ違いがあります。 それを説明するために、 TodoAppCmp を使う側のルートコンポーネントを見てみましょう。

@Component({
  selector: 'footer',
  template: '<ng-content></ng-content>'
})
class Footer {}

@Component(...)
class TodoAppCmp {...}

@Component({
  selector: 'app',
  styles: [
    'todo-app { margin-top: 20px; margin-left: 20px; }'
  ],
  template: `
    <content>
      <todo-app>
        <footer>
          <small>Yet another todo app!</small>
        </footer>
      </todo-app>
    </content>
  `,
  directives: [TodoAppCmp, NgModel, Footer]
})
export class AppCmp {}

ここでは2つのコンポーネント FooterAppCmp を定義しています。 Footer は開始タグと終了タグの間に渡された要素をすべて投影します( <footer>ここが表示されます</footer> ) 一方、 AppCmpTodoAppCmp を使い、さらに Footer を開始タグと終了タグの間に渡しています。 つまり、これは我々の用語では Footer はContent Childであるといえます。これにアクセスするには次の例のようにします。

// ...
@Component(...)
class TodoAppCmp {
  @ContentChild(Footer)
  footer: Footer;
  
  ngAfterContentInit() {
    // this.footer is now with value set
  }
}
// ...

View ChildrenとContent Childrenの違いは、使っているデコレータとライフサイクルフックの種類だけです。 Content Childrenには @ContentChild または @ContentChildren デコレータを使い、 ngAfterContentInit ライフサイクルで使用可能になります。

viewProvidersproviders

もうほとんど説明は終わってしまいました! 最後のステップは、 providersviewProviders の違いを理解することです。 (もしあなたがAngular 2のDIのメカニズムを理解していなければ、まず 私の本 を読むといいでしょう)

さて、それでは TodoAppCmp の宣言部分を覗いてみましょう。

class TodoList {
  private todos: Todo[] = [];
  add(todo: Todo) {}
  remove(todo: Todo) {}
  set(todo: Todo, index: number) {}
  get(index: number) {}
  getAll() {}
}

@Component({
  // ...
  viewProviders: [TodoList],
  directives: [TodoCmp, TodoInputCmp],
  // ...
})
class TodoAppCmp {
  constructor(private todos: TodoList) {}
  // ...
}

@Component デコレータの中で viewProviders プロパティがセットされ、 TodoList サービスが渡されています。 TodoList サービスはすべてのTodoのアイテムを保持しているサービスです。

TodoAppCmp のコンストラクタでは TodoList をインジェクトしていますが、 これは TodoAppCmp コンポーネントの中で使われている他のディレクティブ(もちろんコンポーネントも)でもインジェクト可能です。 つまり、 TodoList は次の場所からアクセス可能です

  • TodoAppCmp
  • TodoCmp
  • TodoInputCmp

ところが、 Footer コンポーネントのコンストラクタでこのサービスをインジェクトしようとすると、次のようなエラーが表示されるでしょう。

EXCEPTION: No provider for TodoList! (Footer -> TodoList)

これは viewProviders によって宣言されたプロバイダはそのコンポーネントとView Childrenにだけアクセス可能になるということです

Footer でも TodoList サービスにアクセスしたい場合は、 viewProviders から providers に変える必要があります。

viewProviders はいつ使うべき?

いったいどういう時に、Content Childrenからアクセスできないように viewProviders を使うべきなのでしょうか? 仮にあなたがサードパーティのライブラリを作り、その中でインターナルなサービスを使うとします。 そのサービスはライブラリの内部的なAPIの一部で、他のユーザーからはアクセスさせたくないものです。 そのようなプライベートな依存性を providers を使って登録し、ライブラリで公開しているコンポーネントの中にユーザーがContent Childrenを渡すと、 その人はアクセスできてしまいます。 しかし、あなたが viewProviders を使えば、そのサービスは外からは使えなくなります。

まとめ

この記事ではコンポーネントとディレクティブの合成の方法について解説しました。 また、View ChildrenとContent Childrenの違いと、それらにどうやってアクセスするのかについても紹介しました。

そして最後に、 viewProvidersproviders の意味についても説明しました。 もしこのテーマにもっと興味がある場合は、私が書いているSwitching to Angular 2を読むことをおすすめします!