Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

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 さんです。

future
ITを武器とした課題解決型のコンサルティングサービスを提供します
http://future-architect.github.io/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away