JavaScript
Angular

AngularのNgModuleを使って、アプリの構成を管理する


この記事はAngular+Firebaseでチャットアプリを作るのエントリーです。

前記事:Angular+FirebaseRTDBでCRUD(CREATE, READ, UPDATE, DELETE)を実装する

次記事:Angularのルーティング設定(基礎編)



この記事で行うこと

ここまでの記事では、ルーティング(ページ遷移)のない単一ページのWEBアプリを構築してきました。

ですが、これから認証機能を実装していくにあたって、ログインページなど他ページへの遷移や、ヘッダーなどサイト全体に共有するテンプレートが必要になってきます。

本稿ではそうした中規模以上のWEBアプリ構築をするため、モジュール構成の整理とヘッダーのテンプレート作成を行います。


Angularのモジュラリティシステム

以前の記事でも少し触れましたが、AngularはNgModuleというモジュラリティシステムを導入しています。

モジュールは、モジュール内部で扱うコンポーネント、サービス、外部ライブラリなどを定義しており、独立して動作するものになっています。これらのモジュールはLazy Loading(遅延読み込み)の対象とすることができ、一度に読み込ませる範囲の指定にもなります。


Lazy Loadingの例

検索にかかる機能を「サーチモジュール」とした場合、それにかかるページに遷移、ないしはその機能を使用しない限り読み込みが実行されない→ローディング時間が短くなる。


Angularのガイドではモジュールの種類を、最初に読み込まれるルートモジュール、アプリ全体に適用させるコアモジュール、機能単位で切り出された機能モジュール、共通部品を格納する共有モジュールの4つに分類しています。

下記ではコアモジュールと共有モジュールの実装方法を紹介します。


(2018/1追記)記述を現時点で最新のものに差し替えました。

(2018/9追記)記述を現時点で最新のものに差し替えました。



実装内容


モジュールを整理する

ここまでで作成した単一ページのAngularプロジェクト(/src/app以下)は次のようになっています。

 app

├── class
│ └── chat.ts
├── pipe
│ ├── chat-date.pipe.spec.ts
│ └── chat-date.pipe.ts
├── app.component.css
├── app.component.html
├── app.component.spec.ts
├── app.component.ts
└── app.module.ts

この構成にAngular CLIを使ってモジュールとコンポーネントを追加します。

ng g module core

ng g module shared
ng g component chat

app.module.tsルートモジュールとし、新しく作成されたcore.module.tsコアモジュールshared.module.ts共有モジュールとして扱っていきます。

コアモジュールは後半で扱っていくので、まずはルートモジュールと共有モジュールの内容を見ていきます。


子コンポーネントを表示する

ユーザーがアプリを起動後、最初に実行されるのがルートモジュールです。

ルートモジュールであるapp.module.tsを見てみると、@NgModuleというデコレータのbootstrapというプロパティにAppComponentが指定されています。

これは最初に読み込むコンポーネントをAppComponentに指定したという意味になります。

指定されたAppComponentapp.component.tsに書かれているコンポーネントで、app.component.htmlをhtmlテンプレートとして指定しています。今回はこのテンプレートに上記で作成したChatComponentを表示させます。


chat.component.ts

@Component({

selector: 'app-chat',
templateUrl: './chat.component.html',
styleUrls: ['./chat.component.css']
})

chat.component.tsを開くと、@Componentデコレータの中にselector: 'app-chat'という記述があります。これがこのコンポーネントを指定するカスタムタグ名となります。

では、app-chatをカスタムタグとしてapp.component.htmlに挿入します。


app.component.html

    <div class="card-header">

NgChat <app-chat></app-chat>
</div>


実行結果

NgChat (8).png

ChatComponentのテンプレート内容がapp.component.htmlに表示されました。このとき、ChatComponentAppComponentの「子コンポーネント」である、ということができます。

ルートのコンポーネントであるAppComponentには全ページに共通するものを残していくので、AppComponentの内容をChatComponentに移していきます。


app.component.ts

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

@Component({
selector: 'app-root',
template: `<app-chat></app-chat>`, // templateに変更
// styleUrlsを削除
})

export class AppComponent {

constructor() {
}

}



app.component.html

<!--内容をchat.component.htmlに移動して削除-->



app.component.css

/* 内容をchat.component.cssに移動 */



chat.component.ts

import { Component, OnInit } from '@angular/core';

import { Observable } from "rxjs";
import { Comment, User } from "../class/chat";
import { AngularFirestore } from "@angular/fire/firestore";
import { map } from "rxjs/operators";

const CURRENT_USER: User = new User(1, 'Tanaka Jiro');
const ANOTHER_USER: User = new User(2, 'Suzuki Taro');

@Component({
selector: 'app-chat',
templateUrl: './chat.component.html',
styleUrls: ['./chat.component.css']
})
export class ChatComponent implements OnInit {

public content = '';
public comments: Observable<Comment[]>;
public current_user = CURRENT_USER;

// DI(依存性注入する機能を指定)
constructor(private db: AngularFirestore) {
}

ngOnInit() { // コンストラクタの内容を移す
this.comments = this.db // thisを追加
.collection<Comment>('comments', ref => {
return ref.orderBy('date', 'asc')
})
.snapshotChanges()
.pipe(
map(actions => actions.map(action => {
// 日付をセットしたコメントを返す
const data = action.payload.doc.data() as Comment;
const key = action.payload.doc.id;
const comment_data = new Comment(data.user, data.content);
comment_data.setData(data.date, key);
return comment_data;
})));
}

// 新しいコメントを追加
addComment(e: Event, comment: string) {
e.preventDefault();
if (comment) {
this.db
.collection('comments')
.add(new Comment(this.current_user, comment).deserialize());
this.content = '';
}
}

// 編集フィールドの切り替え
toggleEditComment(comment: Comment) {
comment.edit_flag = (!comment.edit_flag);
}

// コメントを更新する
saveEditComment(comment: Comment) {
this.db
.collection('comments')
.doc(comment.key)
.update({
content: comment.content,
date: comment.date
})
.then(() => {
alert('コメントを更新しました');
comment.edit_flag = false;
});
}

// コメントをリセットする
resetEditComment(comment: Comment) {
comment.content = '';
}

// コメントを削除する
deleteComment(key: string) {
this.db
.collection('comments')
.doc(key)
.delete()
.then(() => {
alert('コメントを削除しました');
});
}

}


これでAppComponentにかかる記述がかなりすっきりしました。内容を移すにあたり、変更を加えた点を列記しておきます。


templateに変更

app.component.tsではこれまで外部ファイルをhtmlテンプレートに指定するtemplateUrlというプロパティを使っていましたが、これをtemplateに変更しています。

templateはデコレータ内に直接テンプレートを書き込むためのプロパティです。テンプレートの記述内容が少ない時などに使用します。


constructor()の記述をngOnInit()に移す

ngOnInit()はコンポーネントが読み込まれる際に最初に実行される関数です。役割的にはconstructor()と同じなのですが、データのライフサイクルをわかりやすくしておくため、最初に読み込まれるものは極力ngOnInit()に記述するようにしていきます。


Angularのライフサイクル。機会があったら取り上げてみます。



共有モジュールを読み込む

さて、次は共有モジュールを作成していきます。上記でも触れましたが、共有モジュールはアプリ内で使用する共通部品を管理するモジュールです。これまでに作成したものだとChatDatePipeが共通部品として使えそうです。

ngIFやngForを扱うCommonModule、ngModelなどを扱うFormsModuleと合わせて共有モジュールに登録します。

まず/src/app/shared配下に/src/app/pipeを移動し、SharedModuleChatDatePipeCommonModuleFormsModuleを追加します。


src/app/shared/shared.module.ts

import { NgModule } from '@angular/core';

import { CommonModule } from '@angular/common';
import { FormsModule } from "@angular/forms"; // 追加

import { ChatDatePipe } from "./pipe/chat-date.pipe"; // 追加

@NgModule({
imports: [
CommonModule,
FormsModule, // 追加
],
exports: [ // 追加
CommonModule,
FormsModule,
ChatDatePipe
],
declarations: [ // 追加
ChatDatePipe
]
})
export class SharedModule { }


注意すべき点として、SharedModuleの外部で使う部品についてはexportsにも登録する必要があります。これを忘れるとDIをする時に「宣言されていません」というエラーが出るので、必ず登録しておくようにします。

次にAppModuleを更新します。先ほどSharedModuleに追加したものを削除し、代わりにSharedModuleをインポートします。

ここではAppModuleに登録を行っていますが、SharedModuleは必ずしもルートモジュールに登録する必要はありません。

SharedModuleを必要とするモジュールがあればその都度登録を行い、そのモジュールが読み込まれる際にSharedModuleも合わせて読み込まれます。


src/app/app.module.ts

import { BrowserModule } from '@angular/platform-browser';

import { NgModule } from '@angular/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
// FormsModuleを削除
import { environment } from '../environments/environment';
import { AngularFireModule } from '@angular/fire';
import { AngularFirestoreModule } from '@angular/fire/firestore';
import { AngularFireAuthModule } from '@angular/fire/auth';

import { SharedModule } from './shared/shared.module'; // 追加
import { AppComponent } from './app.component';
import { ChatComponent } from './chat/chat.component'; // 追加
// ChatDatePipeを削除

@NgModule({
declarations: [
AppComponent,
ChatComponent
],
imports: [
NgbModule.forRoot(),
// FormsModuleを削除
BrowserModule,
SharedModule, // 追加
AngularFireModule.initializeApp(environment.firebase),
AngularFirestoreModule,
AngularFireAuthModule,
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }


これで共有モジュールの読み込みができるようになりました。

この時点で/src/app配下は次のようになっています。

 app

├── chart
│ ├── chat.component.css
│ ├── chat.component.html
│ ├── chat.component.spec.ts
│ └── chat.component.ts
├── class
│ └── chat.ts
├── core
│ ├── core.module.spec.ts
│ └── core.module.ts
├── shared
│ ├── pipe
│ │   ├── chat-date.pipe.spec.ts
│ │   └── chat-date.pipe.ts
│ ├── shared.module.spec.ts
│ └── shared.module.ts
├── app.component.spec.ts
├── app.component.ts
└── app.module.ts


ヘッダーテンプレートを作る

共有モジュールを作ったので、次はコアモジュールを作っていきます。

コアモジュールは、アプリ全体で一度だけ読み込まれるモジュールです。共有モジュールの場合は登録しているモジュールが読み込まれる都度読み込みが発生しますが、コアモジュールはルートモジュールにのみ登録を行い、他のモジュールからは読み込みがされないよう設定します。

コアモジュールに登録すべき部品としては、タイトルを扱うコンポーネントや、ユーザーを扱うサービスが公式の例として挙げられています。これらはセキュリティや再利用性の面から「アプリ全体で一度」という要件が必要になるものです。

ヘッダーも同様に「アプリ全体で一度」という要件を満たすので、コアモジュールに登録しておきます。


コアモジュールを読み込む

それでは、ルートモジュールにコアモジュールを登録します。


src/app/app.module.ts

import { BrowserModule } from '@angular/platform-browser';

import { NgModule } from '@angular/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { environment } from '../environments/environment';
import { AngularFireModule } from '@angular/fire';
import { AngularFirestoreModule } from '@angular/fire/firestore';
import { AngularFireAuthModule } from '@angular/fire/auth';

import { CoreModule } from './core/core.module'; // 追加
import { SharedModule } from './shared/shared.module';
import { AppComponent } from './app.component';
import { ChatComponent } from './chat/chat.component';

@NgModule({
declarations: [
AppComponent,
ChatComponent
],
imports: [
NgbModule.forRoot(),
CoreModule, // 追加
SharedModule,
AngularFireModule.initializeApp(environment.firebase),
AngularFirestoreModule,
AngularFireAuthModule,
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }


これでコアモジュールが使えるようになりましたが、コアモジュールの読み込みをルートモジュールだけに限定するため、コアモジュール内に保険をかけておきます。


src/app/core/core.module.ts

import { NgModule, Optional, SkipSelf } from '@angular/core'; // 追加

import { CommonModule } from '@angular/common';

@NgModule({
imports: [
CommonModule
],
declarations: []
})
export class CoreModule {

constructor (@Optional() @SkipSelf() parentModule: CoreModule) { // 追加
if (parentModule) {
throw new Error(
'CoreModule is already loaded. Import it in the AppModule only');
}
}

}


公式で紹介されている方法ですが、要はルートモジュール以外から読み込もうとするとエラーを吐くよ、というおまじないです。@Optionalデコレータの使い方などを詳しく知りたい場合は、公式を参照してください。


コアモジュール内のCommonModule

このコアモジュールの中身をみてみると、CommonModuleがインポートされています。これはAngular CLIで自動的に挿入されるものですが、共有モジュールのCommonModuleとぶつからないか、という疑問がわくかもしれません。というか、私は最初はここでかなり悩みました。

結論から言ってしまうと、コアモジュール内のCommonModuleの読み込みは必要です。コアモジュールはその役割の違いから、共有モジュールを読み込むことができません。なので、共有モジュールによって共有されるモジュールのうち、コアモジュールでも必要となるモジュールは個別にインポートしておく必要があります。



ヘッダーコンポーネントを作る

ここでようやく、ビューを触る作業に入ります。

Angular CLIでコアモジュール内にヘッダーコンポーネントを作成します。

ng g component core/header

上記コマンドを叩くと自動的にコンポーネントが作成されますが、合わせてコアモジュールも更新がかかります。Angular CLIはディレクトリの階層構造をみて、該当するモジュールに自動でコンポーネントを登録する機能を備えています。慣れるとかなり楽チンです。

ただ、ここで更新されたコアモジュールのままではHeaderComponentをルートコンポーネントで使うことはできません。

共有モジュールでも使ったexportsプロパティにHeaderComponentを追加しておきます。


src/app/core/core.module.ts

@NgModule({

imports: [
CommonModule
],
exports: [ // 追加
HeaderComponent
],
declarations: [
HeaderComponent
]
})

これでヘッダーコンポーネントのコアモジュールへの登録が完了しました。

ルートコンポーネントでヘッダーのカスタムタグが使えるようになったので、テンプレートに追加します。


src/app/app.component.ts

@Component({

selector: 'app-root',
template: `
<app-header></app-header> <!--追加-->
<app-chat></app-chat>
`
,
styleUrls: ['./app.component.css']
})

あとはヘッダーのテンプレートをモリモリ更新するだけです。

ヘッダー固定のレイアウトにするので、chat.component.cssの調整もしておきます。


src/app/core/header/header.component.html

<nav class="navbar fixed-top navbar-dark bg-primary">

<a class="navbar-brand" href="#">NgChat</a>
<span class="navbar-text">
<a href="#">Login</a>
</span>
</nav>


src/app/chat/chat.component.css

.page section{

margin: 65px 10px 30px; /*margin-topを65pxに*/
}


実行結果

localhost_4200_ (1).png

これで中規模アプリを構築していく準備が整いました。

次はログインページへのルーティングを扱います。


ソースコード

この時点でのソースコード

※firebaseのapiKeyは削除しているので、試すときは自身で作成したapiKeyを入れてください。