はじめに
Angular でタブによるコンポーネントの切り替えを実装したい。
要件は
- なるべくタブの管理を抽象化したい
- つまり 「
ngIf
とかでコンポーネントを囲って切り替える」といったことはしたくない
で、これを実現するために NgComponentOutlet を使ってタブを実装する。
NgComponentOutlet
の特徴は コンポーネントを動的に切り替えることができる ことで、今回これで得られるメリットとしては
- メリット
- 要件にある「タブ切り替えを
ngIf
を使って実装しない」を満たせる - 上記が満たせるということは、タブが増えるたびに
ngIf
を増やさなくて済む - 関連して、タブ切り替えのための HTML タグも単純化できる
- 要件にある「タブ切り替えを
逆にデメリットは
- デメリット
-
NgComponentOutlet
は普段使うことのないであろう( と勝手に思っている )ディレクティブなのでとっつきづらい - 抽象化することで、パッと見ただけでは「なにをしているのか」を把握するのが若干難しくなる
-
といった点が挙げられる。
更新情報
2021/01/11
- 記事内で扱ったコードを Angular
v11.0.5
で確認しました
作業環境
環境 | バージョン | 備考 |
---|---|---|
Angular CLI |
|
$ ng --version |
Angular |
|
同上 |
TypeScript | v4.0.2 | 同上 |
Node.js |
|
$ node --version |
npm |
|
$ npm --version |
ng version の結果
$ ng version
_ _ ____ _ ___
/ \ _ __ __ _ _ _| | __ _ _ __ / ___| | |_ _|
/ △ \ | '_ \ / _` | | | | |/ _` | '__| | | | | | |
/ ___ \| | | | (_| | |_| | | (_| | | | |___| |___ | |
/_/ \_\_| |_|\__, |\__,_|_|\__,_|_| \____|_____|___|
|___/
Angular CLI: 11.0.5
Node: 12.18.3
OS: darwin x64
Angular: 11.0.5
... animations, cli, common, compiler, compiler-cli, core, forms
... platform-browser, platform-browser-dynamic, router
Ivy Workspace: Yes
Package Version
---------------------------------------------------------
@angular-devkit/architect 0.1100.5
@angular-devkit/build-angular 0.1100.5
@angular-devkit/core 11.0.5
@angular-devkit/schematics 11.0.5
@schematics/angular 11.0.5
@schematics/update 0.1100.5
rxjs 6.6.0
typescript 4.0.2
本記事で扱う構成
作成にあたってのアプリの構成は次の通り。
src/
└ app/
├ component/
│ ├ switch-tab/ # タブによて読み込むコンポーネントを動的に切り替える
│ │ ├ switch-tab.component.css
│ │ ├ switch-tab.component.html
│ │ └ switch-tab.component.ts
│ ├ tab-a/ # タブとして扱われるコンポーネント-A
│ │ ├ tab-a.component.css
│ │ ├ tab-a.component.html
│ │ └ tab-a.component.ts
│ └ tab-b/ # タブとして扱われるコンポーネント-B
│ ├ tab-b.component.css
│ ├ tab-b.component.html
│ └ tab-b.component.ts
├ model/ # switch-tab.service で管理する配列の型となるモデル
│ └ tab-model.ts
├ service/ # タブを管理するサービス
│ └ switch-tab.service.ts
├ app.component.css
├ app.component.html
├ app.component.ts
└ app.module.ts
実現したいこと
冒頭に書いた通り コンポーネントの切り替えをタブで行う こと。
つまり次の図を実現したい。
- Tab-A, Tab-B はコンポーネント
- タブをクリックすることで Tab-A と Tab-B が切り替わる
実現にあたって
大まかな実装方針は下記の通り。
- 選択されたタブのコンポーネントは
ngComponentOutlet
を使って動的に読み込む - タブは
<ul>タグ
と<li>タグ
で実現する - タブが増えるたびに
<li>タグ
を記述することは避けたいので、ここはngFor
ディレクティブを使う - 上記を満たすためにタブとして扱われるコンポーネントは配列で管理する
- 配列で管理するにあたり、配列の型を明示するためのモデルクラスを用意する
- tab-model で実装
NgComponentOutlet の使い方
さて、実際の実装の前にまずは NgComponentOutlet
の使い方について確認を。
( 少々しつこいが ) NgComponentOutlet
は動的にコンポーネントを読み込むことができるディレクティブで、テンプレートで ng-container
と共に使用する。
NgComponentOutletを使うテンプレート
<ng-container *ngComponentOutlet="hoge"></ng-container>
ここで、 hoge
は動的に読み込みたいコンポーネント。
ngComponentOutlet
にセットされているのはコンポーネントで定義されている変数 hoge
だが、この変数に動的に読み込む対象のコンポーネントがセットされている。
テンプレートでやることはこれだけ。
上記コードのように対象のコンポーネントを *ngComponentOutlet="hoge"
という形で指定してやるだけでよい。
なお、上記コードでコンポーネントを動的に読み込むためには
- app.module.ts
- NgComponentOutlet を使うコンポーネント
で下準備が必要となる。
app.module.ts における準備
// 省略
import { HogeComponent } from './hoge/hoge.component';
// 省略
@NgModule({
declarations: [
// 省略
HogeComponent
],
// ngComponentOutlet で動的にコンポーネントを読み込むにはこの宣言が必要
entryComponents: [
HogeComponent
],
// 省略
})
// 省略
通常、コンポーネントを使用する場合は declarations
に設定してやるだけでよいが、今回のケースでは entryComponents
にも設定してやる必要がある。
これを怠ると、次のエラーが出てブラウザに何も表示されない。
entryComponentsへの設定を怠った場合のエラー情報
ERROR Error: "No component factory found for hoge. Did you add it to @NgModule.entryComponents?"
逆に上記エラーが出たら entryComponents
がセットされていない可能性があるので、app.module を見直してみると良い。
NgComponentOutletを使うコンポーネント
// 省略
import { HogeComponent } from './hoge/hoge.component';
// 省略
export class AppComponent implements OnInit {
// 省略
hoge = HogeComponent;
// 省略
}
import
宣言したコンポーネントを、直接変数 hoge
にセットしている点がポイント。
- コンポーネントそのものを変数にセット
- セットした変数をテンプレートで
ngComponentOutlet
で参照 - このとき
ng-container
と共に使用する
これらを app.module.tsにおける準備 とあわせて行うことで、コンポーネント HogeComponent
を動的に読み込むという要件が実現される。
以上、簡単ながらサンプルコードで NgComponentOutlet
の使い方について見てきた。
それでは以降の項目で、今回の要件である 「NgComponentOutlet
を使ったタブの切り替え処理の実装」 について実際のコードを見ていく。
Component の実装
switch-tab.component
switch-tab.component については *ts, *.html, *.css についてコードを載せた。
それぞれについてポイントを見ていく。
switch-tab.component.ts
import { Component, OnInit } from '@angular/core';
import { SwitchTabService } from '../../service/switch-tab.service';
import { TabModel } from '../../model/tab-model';
@Component({
selector: 'app-switch-tab',
templateUrl: './switch-tab.component.html',
styleUrls: ['./switch-tab.component.css']
})
export class SwitchTabComponent implements OnInit {
private _currentTab: any;
private _tabs!: Array<TabModel>;
// 次のブロックは setter/getter
// 今回の実装ではプロパティに対してセット/ゲットするだけの単純なもの
public get currentTab(): any {
return this._currentTab;
}
public get tabs(): Array<TabModel> {
return this._tabs;
}
/**
* コンストラクタ ( 本コンポーネントではなにもしない )
*
* @memberof SwitchTabComponent
*/
constructor(
private switchTabService: SwitchTabService
) {}
/**
* 初期処理
*
* @memberof SwitchTabComponent
*/
ngOnInit() {
// view に表示するための情報をここでセットする
this._tabs = this.switchTabService.tabs;
this._currentTab = this.switchTabService.getCurrentContents();
}
/**
* ボタンがクリックされた時のイベントハンドラ
*
* @param {any} $event イベント情報
* @memberof SwitchTabComponent
*/
public onClick($event: any) {
// クリックされたタブに応じて表示するコンテンツ( component ) を切り替える
this._currentTab = this.switchTabService.changeCurrentContents($event.target.innerHTML);
}
}
ポイントは switch-tab.service を DI していること。
このコンポーネントの役割はタブによって表示するコンポーネントを切り替えることだが、その切り替えるコンポーネントを DI している switch-tab.service から取得している。
そして取得したデータを次の2つのプロパティを介してテンプレートで参照することで、コンポーネントの動的読み込みによるタブの切り替えを実現している。
-
currentTab プロパティ
- 現在表示しているコンポーネントを扱う
- 後述のテンプレートで
ngComponentOutlet
にセットされることで、このプロパティにセットされたコンポーネントが動的に読み込まれる
-
tabs プロパティ
- 本コンポーネントで扱うコンポーネントがセットされる配列
- 後述のテンプレートで
ngFor
で扱われ、配列にセットされているコンポーネントがタブ表示される
switch-tab.component.html
<div class="tab-area-base">
<ul class="tab-menu-base">
<ng-container *ngFor="let tab of tabs">
<li [class.current]="tab.current" (click)="onClick($event)">{{tab.name}}</li>
</ng-container>
</ul>
<!-- ngComponentOutlet で動的にコンポーネントを読み込む -->
<ng-container *ngComponentOutlet="currentTab"></ng-container>
</div>
ポイントは 2点。
-
ngFor
で DI した switch-tab.service から取得したコンポーネントの配列を回してタブ表示していること -
ngComponentOutlet
で現在表示対象となるコンポーネントの動的読み込みを行っていること
ngFor
でループ処理をしていることで <li>タグ
を何度も記述せずに済んでいるし、ngComponentOutlet
を使用することで、切り替え対象のコンポーネントを ngIf
で管理せずに済んでいる。
switch-tab.component.css
div.tab-area-base {
height: 50px;
width: 90%;
margin: auto;
}
ul.tab-menu-base {
overflow: hidden;
list-style: none;
margin: 0px auto;
padding: 0;
border-bottom: 5px solid rgb(240, 240, 240);
}
ul.tab-menu-base li {
float: left;
margin: 20px 8px 0 0 ;
padding: 0;
background: rgb(180, 180, 180);
border-radius: 10px 10px 0 0;
height: 100%;
width: 200px;
font-size: 1.5em;
text-align: center;
}
ul.tab-menu-base li.current {
background: rgb(240, 240, 240);
}
こちらについて特筆する点は無し。
CSS でタブUI を実現するための一般的なスタイルを記述している(ハズ)。
前掲の switch-tab.component.html とあわせて見ていただければ。。。
tab-a.component, tab-b.component
tab-a.component
, tab-b.component
は switch-tab.component
でタブ切り替えに使われるだけのコンポーネントなので説明を割愛する。
Model の実装
tab-model
export class TabModel {
private _name: string;
private _contents: any;
private _current: boolean;
/**
* コンストラクタ
*
* @param {string} _name タブ名
* @param {*} _contents コンテンツ( 実態はコンポーネントそのもの )
* @param {boolean} _current 現在表示中か否かを示すフラグ
* @memberof TabModel
*/
constructor(_name: string, _contents: any, _current: boolean) {
this._name = _name;
this._contents = _contents;
this._current = _current;
}
// 以降のブロックは setter/getter
// 今回の実装ではプロパティに対してセット/ゲットするだけの単純なもの
public set name(_name: string) {
this._name = _name;
}
public get name(): string {
return this._name;
}
public set contents(_contents: any) {
this._contents = _contents;
}
public get contents(): any {
return this._contents;
}
public set current(_current: boolean) {
this._current = _current;
}
public get current(): boolean {
return this._current;
}
}
tab-model でのポイントは
- タブとして扱うコンポーネントを管理する型
であるという点。
そのためのプロパティとして
- name: 表示するタブ名
- contents: 表示するコンテンツ( コンポーネント )
- current: 現在表示していることを示すフラグ
を用意している。
これを後述の switch-tab.service で配列の型として指定することで、 switch-tab.service 経由で扱う際に型推論のメリットを享受できる。
サービスの実装
switch-tab.service
import { Injectable } from '@angular/core';
import { TabModel } from '../model/tab-model';
@Injectable({
providedIn: 'root'
})
export class SwitchTabService {
private _tabs!: Array<TabModel>;
// 次のブロックは setter/getter
// 今回の実装ではプロパティに対してセット/ゲットするだけの単純なもの
public get tabs(): Array<TabModel> {
return this._tabs;
}
public set tabs(tabs: Array<TabModel>) {
this._tabs = tabs;
}
/**
* コンストラクタ
*
* @memberof SwitchTabService
*/
constructor() { }
/**
* 現在表示中のコンテンツを取得する
*
* @returns {any} 表示中のコンテンツ( 実態はコンポーネント... これも抽象クラス作ってやると型指定できるけど今回はそこまでしない )
* @memberof SwitchTabService
*/
public getCurrentContents(): any {
// this._tabs は Array 型なので「for-in」は使わないで無難に「for文」で回す
// 「for-of」でも良いけれど、それだとデバッグ時に「this」やら「_tabs」が何故か「undefined」になって気持ち悪い...
for (let index = 0; index < this._tabs.length; index++) {
const target = this._tabs[index];
if (target.current) {
return target.contents;
}
}
}
/**
* 表示するコンテンツを切り替える
*
* @param {string} name クリックされたタブのタブ名
* @returns {any} 切り替え先のコンテンツ( 実態はコンポーネント... これも抽象クラス作ってやると型指定できるけど今回はそこまでしない )
* @memberof SwitchTabService
*/
public changeCurrentContents(name: string): any {
let contents: any;
// this._tabs は Array 型なので「for-in」は使わないで無難に「for文」で回す
// 「for-of」でも良いけれど、それだとデバッグ時に「this」やら「_tabs」が何故か「undefined」になって気持ち悪い...
for (let index = 0; index < this._tabs.length; index++) {
const target = this._tabs[index];
target.current = false;
if (target.name === name) {
target.current = true;
contents = target.contents;
}
}
return contents;
}
}
switch-tab.service でのポイントは 3点。
- 一つ目は タブとして扱うコンポーネントを管理するためのプロパティとして tab-model 型の配列
_tabs
を用意していること - 二つ目、三つ目として、
_tabs
に対して以下のメソッドを用意していること- 現在表示しているコンテンツを取得するメソッド
- タブクリック時に現在表示しているコンテンツを切り替えるメソッド
app.module と app.component
app.module
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { SwitchTabComponent } from './component/switch-tab/switch-tab.component';
import { TabAComponent } from './component/tab-a/tab-a.component';
import { TabBComponent } from './component/tab-b/tab-b.component';
import { SwitchTabService } from './service/switch-tab.service';
@NgModule({
declarations: [
AppComponent,
SwitchTabComponent,
TabAComponent,
TabBComponent,
],
// ngComponentOutlet で動的にコンポーネントを読み込むにはこの宣言が必要
entryComponents: [
TabAComponent,
TabBComponent
],
imports: [
BrowserModule,
],
providers: [
SwitchTabService
],
bootstrap: [AppComponent]
})
export class AppModule { }
app.module でのポイントは 2点。
- 一つ目は app.module.tsにおける準備 で触れたとおり、
entryComponents
へのセットすること - 二つ目は SwitchTabService を DI のため
providers
へセットすること
app.component
import { Component, OnInit } from '@angular/core';
import { TabAComponent } from './component/tab-a/tab-a.component';
import { TabBComponent } from './component/tab-b/tab-b.component';
import { SwitchTabService } from './service/switch-tab.service';
import { TabModel } from './model/tab-model';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
private _tabs!: Array<TabModel> = new Array();
/**
* コンストラクタ ( 本コンポーネントではなにもしない )
*
* @param {SwitchTabService} switchTabService
* @memberof AppComponent
*/
constructor(
private switchTabService: SwitchTabService
) {
}
/**
* 初期処理
*
* @memberof AppComponent
*/
ngOnInit() {
// タブの情報をここでセットする
// サービスにセットされたタブ情報は、それを利用するコンポーネントで共有される
this._tabs.push(new TabModel('Tab-A', TabAComponent, true));
this._tabs.push(new TabModel('Tab-B', TabBComponent, false));
this.switchTabService.tabs = this._tabs;
}
}
app.component でのポイントは 1点。
- switch-tab.component で扱うコンポーネントを switch-tab.service にセットすること
switch-tab.service は DI によってインスタンスがアプリ全体で共有されるので、ここでセットしたコンポーネント情報が switch-tab.component で扱える、という流れになる。
もしタブとして扱いたいコンポーネントが増えた場合はここでセットしてやるだけで良い。それだけで switch-tab.component は扱う対象のコンポーネントが増えたことも意識せずに動作する。
実行結果
初期表示: Tab-A を表示
アプリ起動直後は Tab-A が表示される。
これは app.component の ngOnInit()
で TabModel のインスタンス生成時に true をセットしているため。
切り替え: Tab-B を表示
Tab-B をクリックすることで、Tab-B がちゃんと表示されている。
これは switch-tab.service の changeCurrentContents()
で選択したタブへの切り替え処理を行うことで実現している。
参考
- 本家の NgComponentOutlet のドキュメント(まだ日本語訳されていない)
- 本家の Structural Directives のドキュメント(まだ日本語訳されていない)
- 動的コンポーネントローダー(本家の日本語ドキュメント)
ソースコード
今回の記事で作成したコードは こちら にアップしてあるのでご参考まで。