LoginSignup
33
33

Angularは15から使うべき8つの理由(始めよう、スタンドアロンコンポーネント)

Last updated at Posted at 2023-04-19

※公式ページにおいて、Angular14は2023年12月でサポート対象外となっていましたので、タイトルだけ刷新

日本ではVueとReactの人気が高く、なかなか市民権を得られないAngularだったのですが、Angularも14になってからReact、Vue、新勢力のSvelteなどに対抗しうる画期的な機能が搭載され、昨今のTypeScript隆盛に合わせ、アメリカではかなり盛り返しを見せている(Stack Overflowの質問記事は18%増)ようです。

それがスタンドアロンコンポーネントという、今までのAngularの常識をくつがえすような革命的機能(Vueのcomposition APIやReactの関数コンポーネント化ぐらいの飛躍的進化)です。そのスタンドアロンコンポーネントはどういうものかというと、VueやReactのように単一コンポーネントで独立した働きを持たせることができるようになる機能なので、それまでの汎用モジュール(app.module.ts)を使用しなくてもアプリケーションを作成でき、しかも簡単に実装可能です。

スタンドアロンコンポーネントはAngular14では評価版、Angular15で安定版となっています。基本的なスタンドアロンコンポーネントの部分において、そこまでの仕様変更はなかったのですが、Angular15からdependency injection(依存性注入)がスタンドアロン対応に刷新され、サービス周りが大幅に改善されました。

ang_banda.jpg

ところがQiitaには、このスタンドアロンコンポーネントに特化した記事がなかったので、この機会に作成してみました。

前半が情報収集結果と動作テストによるレポートとAngular15による変更点、後半が実践的開発を元にした解説(Angular15ベース)となります。

スタンドアロンコンポーネントの利点

海外サイト風にスタンドアロンコンポーネントの何が優れているのか利点をまとめてみました。

1:汎用モジュールに依存しないので軽量化を実現できる

なによりこれが最大の利点でしょう。汎用モジュールに依存しないということはアプリケーションを大幅に軽量化できるということで、海外サイトの分析によれば、メモリ消費量は60%以上軽減されています。つまり、AngularでもVueやReactのように小規模プロジェクトにも適したプロジェクトやアプリケーションを構築できるということです。

あと、軽量化による高速化も実現されているらしいですが、有効な分析結果情報を探索中です。

2:作業の分担やアプリケーションの切換が簡単になる

今まではどうしても汎用モジュールの設定ファイル、app.module.tsにあらゆる設定を記述しなければいけなかったため、アプリケーションを切り換えたり、コンポーネント作成作業を分担させたりするのはけっこう面倒でした。その上、Angular起動時に他の汎用モジュールと干渉したりもしました。ですが、スタンドアロンコンポーネントの場合、main.tsで一通りの情報をインポートし、制御できるようになっています。すなわち、簡単にプロジェクトやアプリケーションを切り換えられるようになっています。

例ではAppComponentインポート先を切り換えてブラウザで再ロードするだけで、別のプロジェクトとなります。

main.ts
import { enableProdMode,importProvidersFrom} from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
//import { AppComponent } from './project-one/app.component'; //ルートコンポーネント
import { AppComponent } from './project-two/app.component'; //切り替え対象のルートコンポーネント
import { environment } from './environments/environment';
import {RouterModule} from '@angular/router';
if (environment.production) {
  enableProdMode();
}

// StandaloneのBootstrap
bootstrapApplication(AppComponent,{
   providers:[]
}).catch(err => console.error(err));

3:CommonModule一つで最低限の動作を実現できる

スタンドアロンコンポーネントを用いれば、CommonModuleが標準実装されます。そしてこのCommonModuleはngModule、FormsModule、BrowserModuleといったコンポーネント作成に最低限必要なモジュールを一通りスートにしてくれているので、逐一それらを呼び出す必要がなく、簡単にテンプレート上でngディレクティブやフォーム部品を使用できるようになります。

また、Angular13から試験実装されたリアクティブフォームがAngular14で正規実装となったので、それらを活用するのにも便利です。

4:使用したいモジュールだけを簡単にカスタマイズできる

汎用モジュールの設定ファイルapp.module.tsは必須でなくなりましたが、CommonModule以外にもRouterModuleやFormsModuleに代わるReactiveFormsModuleは場合によっては必須になります。また、ngModuleデコレータも廃止になっていないので、これを利用してアプリケーション起動に必要なモジュールだけを集約したライブラリを作成することも可能になります。しかも、従来のようなモジュール側の宣言とコンポーネント側のセレクタ指定が不要なので、簡単に作成、紐づけできます。

custom.module.ts
import {NgModule} from '@angular/core';
import { CommonModule } from '@angular/common';
import {RouterModule } from '@angular/router'
import {ReactiveFormsModule} from '@angular/forms'

@NgModule({
    //アプリケーション起動に必要なモジュール
	imports:[
    CommonModule, RouterModule, ReactiveFormsModule,
	],
    //外部で制御させたいモジュール
    exports:[
    CommonModule, RouterModule, ReactiveFormsModule,
    ],
})
export class CustomModule { } //必要なモジュールだけ集めて外部にエクスポートできる

使用したいコンポーネントでは、ライブラリだけインポートすれば、冗長な記述を省略できるので、従来の記述より、シンプルに表記できます。

sample.component.ts
import { Component } from '@angular/core';
//import { CommonModule } from '@angular/common' //CommonModuleの呼出は不要
import { ActivatedRoute,Router } from '@angular/router' //RouterModuleの記述は不要
import { FormGroup,FormControl} from '@angular/forms' //ReactiveFormModuleの記述は不要
import { CustomModule } from '../custom.module' //先程設定したモジュールが内包される

@Component({
  standalone: true,
  imports: [CustomModule], //逐一、他のモジュールを記述する必要がなくなる
})

このライブラリで一括制御できるのはモジュールのほかコンポーネント、属性ディレクティブ、カスタムパイプも可能です(サービスは別の方法を用います、後述)。

5: 任意のコンポーネントに従属コンポーネントを設定できる

今までだとapp.module.tsに使用したい全コンポーネントを記述する必要があり、親子コンポーネント同士の従属関係が今一つ不明瞭な部分がありましたが、スタンドアロンコンポーネントはVueやReactなどのように任意のコンポーネント自体に従属のコンポーネントを持たせることができるので、コンポーネントの親子関係がより明瞭になります。

standalone-parent.component.ts
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import {StandaloneChildComponent } from './standalone-child.component'; //子コンポーネント 

@Component({
  standalone: true,
  imports: [CommonModule,StandaloneChildComponent], //ここに従属コンポーネントを記述
  //従属関係にある子コンポーネント
  template: `
            <ul>
              <app-standalone-child></app-standalone-child>
            </ul>
            `
})

※importsプロパティに従属コンポーネントを記述するのを忘れないでください。

6: ルーティング制御を分離、簡略化できる

app.module.tsが必須ではなくなったことで、main.tsでルーティング情報を制御させることができ、独立したファイルで管理することもできます。また、各コンポーネントにRouterModuleをエクスポートする必要もありません。

route-sample.ts
import { Routes } from "@angular/router";
//各種コンポーネント
import {TopComponent} from '../top.component';
import {RouteOneComponent} from '../route-one.component';
import {RouteTwoComponent} from '../route-two.component';
import {RouteThreeComponent} from '../route-three.component';
export const Route: Routes = [
	{path: '',component: TopComponent},
	{path: 'one',component: RouteOneComponent},
	{path: 'two',component: RouteTwoComponent,pathMatch: 'full'},
	{path: 'three',component: RouteThreeComponent,pathMatch: 'full'},
]

あとは、この設定ファイルをmain.tsだけに読み込ませれば、ルーティング設定は完了です。

main.ts
import { enableProdMode,importProvidersFrom} from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './project-one/app.component';
import { environment } from './environments/environment';
import {RouterModule,Routes} from '@angular/router';
import {Route} from './app/routes/route-sample' //ルーティングの設定ファイル
if (environment.production) {
  enableProdMode();
}

// StandaloneのBootstrap
bootstrapApplication(AppComponent,{
   providers:[importProvidersFrom(RouterModule.forRoot(Route))]
}).catch(err => console.error(err));

7:従属コンポーネントが存在しない場合はセレクタを省略できる

地味ですが、これもありがたい機能です。今まではapp.module.tsで宣言(declarationに設定)してから呼び出していたために、対象のコンポーネントを紐づけるためセレクタ名指定が必須となっていましたが、スタンドアロンコンポーネントではテンプレート上に従属関係を持たないコンポーネントの場合はセレクタ記述を省略することができます。

さらに宣言が不要ということは、属性ディレクティブやカスタムパイプなどもimportプロパティに設定するだけでダイレクトに制御できたりします。

standoalone-sub.componet.ts
import { CommonModule  } from '@angular/common';
import { Component } from '@angular/core';
import { MessageDirective } from "../directives/message.directive"; //nameプロパティはmessage
import { TickerPipe } from "../pipes/ticker.pipe"; //nameプロパティはticker

@Component({
  //selector: app-standalone-sub 従属関係がないコンポーネントは独立できるのでセレクタ指定不要
  standalone: true,
  template: `<p message>{{"従属コンポーネントがない場合はセレクタ不要"| ticker }}</p>`,
  imports: [CommonModule,MessageDirective,TickerPipe], //必要なコンポーネント類はそのまま使える
})

8: クラスコンポーネント上での変数を柔軟に処理できる

ngModuleに依存しなくてもよいので、ngOnInitも必須でなくなります。したがってコンストラクタ上で購読された変数を処理できます。そして、クラスコンポーネントがngOnInitで制御されなくなったため、ローカル上に変数を設定して、それをダイレクトにテンプレート上に返すこともできます(ngOnInitはあっても動くので、DOM呼出後に制御したい場合で使い分けるといいでしょう)。

プロバイダを用いたサービスの分配、インジェクタを用いた受け取りなども不要になり、直接サービスをコンストラクタ上で購読できます(ただ、この部分はAngular15で改善されたので、追記部分の「依存性注入」を参照してください)。

sample.ts
@component{
    standalone: true,
    template: `<p>{{ subview }}{{ localview }}</p>`
}
export class SampleComponent{
    subview: string
    constructor(private svs:CustomService){
       this.svs.sub.subscribe((v)=>{ this.subview = v}) //コンストラクタ上でサービスを購読できる
    }
    localview = "これをダイレクトに表示できる" //ローカル上に返す場合はそのままダイレクトに記述可能
}

このようにスタンドアロンコンポーネントは利点ばかりです(欠点もないことはなく、たとえばAngular14の時点ではサービスの分配などができないので、直接各種スタンドアロンコンポーネント毎にインポートする必要がありました)。

スタンドアロンコンポーネントは任意に使用が可能なので、スタンドアロン化したくない場合は従来のコンポーネントを併存させることもできるので、部分的で弾力的な改良、改修も可能となります。

Angular15の変更点

Angular15ではprovideRouterというルーティングにおいて高速化を図れるメソッドが導入されています。また、これを使用すればルーティング制御において幾つかのライブラリも不要になるので、非常に記述がすっきりします。

main.ts
import { enableProdMode} from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser'
import { AppComponent } from './app/todo-views/app.component'
import { environment } from './environments/environment'
import {provideRouter} from '@angular/router'
import {Route} from './app/routes/app-todo'
if (environment.production) {
  enableProdMode();
}
// StandaloneのBootstrap
bootstrapApplication(AppComponent,{
   providers:[provideRouter(Route)]
}).catch(err => console.error(err));

サービス周りの改善(依存性注入)

このprovideRouterを導入したのは理由があります。それはAngular14で保留扱いとなっていたサービス周りを改善するためです(Angularでいうサービスとは、ざっくばらんにいえばデータを扱うためのロジックを構築するクラスファイルのことです)。Angular15からはdependency injection(依存性注入)がスタンドアロンコンポーネントに対応したので、こっちからこそがスタンドアロンコンポーネントの本領発揮といえそうです(今まではコンストラクタからサービスを分配してきましたが、Angularとしてはあまり推奨されていない方法でもあるようです。そこでAngular15からはproviderによるサービス注入に対し、injectによるデータ受け取り《これをAngularでは購読と呼んでいる》が推奨されています)。

このサービス注入はルーティングファイル、アプリケーション、各種コンポーネントなどいずれでも注入可能で、そこから新たに登場したinjectメソッドを使って簡単にサービスを受け取ることができます。

※このinjectはデータをやりとりする際にデータを受け取るメソッドで、Vue3のinjectとほぼ同じ働きと見ていいでしょう(ただし、Vue3と違って直書きのトークンが通らないので堅牢性は上です)。

サービスを注入する

今回は共通のコンポーネントでサービスを受け取りたいので、アプリケーションを以下のように追記し、サービスを注入します(プロパティを省略した場合はサービスクラスそのものが分配されます)。

main.ts
import {HogeService} from './app/services/hoge-service' //インポートするサービス

// StandaloneのBootstrap
bootstrapApplication(AppComponent,{
   providers:[
    [HogeService], //ここを追記
    provideRouter(Route),
   ],
}).catch(err => console.error(err));

その他の方法でサービスを注入する場合

ルーティングファイルから注入する

  • ルーティングファイルの場合は任意のコンポーネントに対し、以下のように追記します。なので、サービスを渡したいコンポーネントとそうでないコンポーネントをはっきり分けることができます。
routes.ts
	{path: 'hoge/:id',component: HogeComponent,pathMatch: 'full',
    providers:[HogeService]}

コンポーネントから注入する

単一のコンポーネントから注入したい場合は以下のようにします。

hoge.component.ts
@Component({
  standalone: true,
  imports: [CustomModule],
  templateUrl: './hoge.component.html',
  styleUrls: ['./hoge.component.css'],
  providers:[HogeService],
})

サービスを受け取る

サービスを受け取る場合ですが、スタンドアロンコンポーネントではinjectメソッドを使ってダイレクトに受け取ることができます(今までのようにprovidersプロパティへの追記不要)。ただし、そのサービスの受け皿はngOnitに限られているようで、スタンドアロンコンポーネントの強みであるローカルに展開しようとするとエラーが起きます。

detail-todo.component.ts
import { Component,OnInit,inject } from '@angular/core';
import {HogeService} from '../services/hoge-service'

export class HogeComponent implements OnInit{
  svs = inject(HogeService) //先程のサービスを受け取る
  }
  //サービスはngOnitでないと展開できない
  ngOnInit(){
    console.log(this.svs)
  }
}

トークンを利用する

ダイレクトにサービスを分配したりすると効率も悪く、なにより安全性に大きな問題があります。なので、このサービス注入に対してトークン(受け渡し用パスワードのようなもの)を発行して、データを分配するのが一般的な方法です。

トークンを発行して、サービスを注入する

トークンの発行にはInjectionTokenクラスから発行できます。トークンは型定義ファイルにでも書いておくといいでしょう。その際には必ずInjectionTokenをインポートしておいてください。

types_todos.ts
import { InjectionToken } from '@angular/core'
export const token = new InjectionToken<any>("hoge") //hogeは任意のパスワード

トークンとサービスを紐付ける

main.tsでは以下のように記述しておく必要があります。provideプロパティにはトークンを設定することでトークンとサービスを紐付けることができます。また、任意の値を注入する場合はuseValueを使用するのですが、今回はサービスクラスそのものを注入するためuseClassを使用します。

main.ts
import {HogeService} from './app/services/hoge-service' //分配したいサービスクラス
import {token} from './app/types/types_hoge' //受け取るためのトークン

bootstrapApplication(AppComponent,{
   providers:[
    [{provide:token,useClass:HogeService}],
    provideRouter(Route),
   ]
   //providers:[]
}).catch(err => console.error(err));

サービスの受け取り側

受け取り側はトークンだけで注入されたサービスを受け取ることができるので、Vue3のように自在に値を受け取ることができるようになります。なので、ここではトークンだけインポートしておきましょう。

※inject('hoge')と決め打ちでパスワードを書いてもVue3のように受け取ることはできません。受け渡しに用いるのは変数そのものというよりインジェクショントークンというオブジェクトとなっているからです。

detail-todo.component.ts
import {token} from '../types/hoge_todo' //トークンのみをインポート

@Component({
  standalone: true,
  imports: [CustomModule],
  templateUrl: './hoge.component.html',
  styleUrls: ['./hoge.component.css'],
})
export class DetailTodoComponent implements OnInit{
  svs = inject(token) //先程のhogeを引数に用いることでサービスを受け取ることができる
  ngOnInit(){
     console.log(this.svs)
  }
}

任意の値を注入したい場合

任意の値を注入したい場合はuseValueを使うのですが、このuseValueは単体では使えません。provideと同じようにトークンを用意しておけば、任意の値を受け渡すことができます。JSONデータなどを受け取る場合もこの方法を用います。

また、injectメソッドに型定義を入れておくこともできます。

hoge.component.ts
import {token,token2} from '../types/types_hoge'

@Component({
  standalone: true,
  imports: [CustomModule],
  templateUrl: './hoge.component.html',
  styleUrls: ['./hoge.component.css'],
  providers:[{provide: token2,useValue:'任意の値をここに記述'}]
})
export class HogeComponent implements OnInit{
  svs = inject<any>(token)
  val = inject<string>(token2) //useValueの値が展開される
  ngOnInit(){
      console.log(this.val) //任意の値をここに記述と表示される
  }

この辺りはAngular8から導入された依存性注入の基本とほとんど変わっていません(式の計算結果を受け渡すuseFactoryなども使えます)。

スタンドアロンコンポーネントを作成しよう

では、実際にスタンドアロンコンポーネントを作成してみましょう。

新規に作成する

スタンドアロンコンポーネントの作成はngコマンドから簡単に作れます。gはgenerate、cはcomponentの省略形です。もし、任意のディレクトリも作成する場合は--flatを外してください。

#ng g c hoge-fuga --standalone --flat 

このようにすれば、以下のようなコンポーネントが自動作成されます。このCommonModuleが従来のNgModuleの代わりとなり、FormsModule、BrowserModuleなども兼ねてくれています。

hoge-fuga.component.ts
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-hoge-fuga',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './hoge-fuga.component.html',
  styleUrls: ['./hoge-fuga.component.css']
})
export class HogeFugaComponent implements OnInit {

  constructor() { }

  ngOnInit(): void {
  }

}

ただ、前述したようにスタンドアロンコンポーネントは必ずしもngOnInitを必要としないので、以下のように取っ払っても大丈夫のようです。また、従属関係のコンポーネントが存在しない場合はセレクタプロパティを削除しても問題ありません。

hoge-fuga.component.ts
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common'; 

@Component({
  selector: 'app-hoge-fuga', //場合によっては削除できる
  standalone: true,
  imports: [CommonModule],
  templateUrl: './hoge-fuga.component.html',
  styleUrls: ['./hoge-fuga.component.css']
})
//ngOninitを取り払う
export class HogeFugaComponent{
  constructor() { }
}

更に軽量化したスタンドアロンコンポーネントを作成する

次のように--minimalオプションを付与してスタンドアロンコンポーネントを作成すると更に軽量化できます(1プロジェクトあたり200KB程度で最低限のアプリケーションができるようになる)。

#ng g c hoge-piyo --standalone  --minimal --inline-template --inline-style --routing=false --style=css

既存のコンポーネントをスタンドアロンに変更する

既存のコンポーネントをスタンドアロンに変更することもできます。変更点に注釈を入れてみました。

一番大事なフラグが、①に示しているstandaloneプロパティで、これをtrueにすることで、このコンポーネントはスタンドアロンと宣言できます。

また、それまで必須だったBrowserModuleも全部CommonModuleの中に集約されることになるので②と③の記述は必須です。

before.component.ts
import { CommonModule  } from '@angular/common'; //②CommonModuleをインポートする
import { Component } from '@angular/core';
@Component({
  //selector: 'app-before',
  standalone: true, //①スタンドアロンコンポーネントの宣言
  styleUrls: ['./m.component.css'],
  template: `<p>従来のコンポーネントをスタンドアローンへ</p>`,
  imports:[CommonModule], //③使用モジュールを記述
})
//implements onInitとしなくても、データを転送できる
export class BeforeComponent{
  constructor()
  {
}

実践してみた

では、今から簡易なTodoアプリを作っていこうと思います。元々の開発環境はAngular14ですが、後にAngular15にアップデートして再構築した後に動作確認しています。

※ここでは、RxJSやナビゲーターなどはほとんど説明を入れていません。自作記事で恐縮ではありますが、そこから参考にしていただければと思います。

main.tsを設定する

スタンドアロンコンポーネントでは前述した通りapp.module.tsが不要です。そしてmain.tsが重要な役割を担うことになり、ここにルーティングも制御していきます。また任意のルーティングファイルを作っておけば、簡単に接続したいアプリケーションを切り替えられるので、分業も非常に楽になります。

それから今回はサービスも全体に分配したいので、main.tsから設定しておくといいでしょう。

※Angular15から採用されたprovideRouterはAngular/router内にあります。間違わないようにしましょう。

main.ts
import { enableProdMode } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { environment } from './environments/environment';
import {RouterModule,provideRouter} from '@angular/router'; //追記する
import {TodoService} from './app/services/todo-service'; //注入したいサービス
import {token} from './app/todo-types/types_todo'; //サービス注入用のトークン
import {Route} from './app/routes/app-todo' //ルーティング情報を外部ファイル化する
if (environment.production) {
  enableProdMode();
}

// StandaloneのBootstrap
bootstrapApplication(AppComponent,{
    [{provide:token,useClass:TodoService}], //分配したいサービス
    provideRouter(Route), //ルーティング設定
}).catch(err => console.error(err));

ルーティング情報の設定ファイルはこのように作ります。

app-todo.ts
import { Routes } from "@angular/router";
//各種コンポーネント
import {TodoComponent} from '../todo-views/todo.component';
import {AddTodoComponent} from '../todo-views/add-todo.component';
import {EditTodoComponent} from '../todo-views/edit-todo.component';
import {DetailTodoComponent} from '../todo-views/detail-todo.component';
export const Route: Routes = [
	{path: '',component: TodoComponent},
	{path: 'add',component: AddTodoComponent},
	{path: 'edit/:id',component: EditTodoComponent,pathMatch: 'full'},
	{path: 'detail/:id',component: DetailTodoComponent,pathMatch: 'full'},
]

次からはプロジェクトにTodoアプリのファイルを作成していきますが、その前にもうひとつ準備をしておきましょう。

ライブラリで制御する

スタンドアロンコンポーネントはNgModuleが不要になると前述しましたが、逆にSPAに必要なモジュールだけをインポートだけして、ライブラリとして各種コンポーネントに送り出すこともできます(宣言が不要)。その際にインポート先のコンポーネントで使用したいモジュールを必ずexportsで記述してください。これを忘れると外部のコンポーネントで各種モジュールを使用できません。

※各種コンポーネントをライブラリに記述することも可能ですが、従属関係が曖昧になるので今回はここにコンポーネントを設定しません。

custom.module.ts
import {NgModule} from '@angular/core';
import { CommonModule } from '@angular/common';
import {RouterModule } from '@angular/router' //ルーティング制御に必要
import {ReactiveFormsModule} from '@angular/forms' //リアクティブフォーム制御に必要

@NgModule({
	imports:[
    CommonModule, RouterModule, ReactiveFormsModule,
	],
    //外部で制御させたいモジュール
    exports:[
    CommonModule, RouterModule, ReactiveFormsModule,
    ],
})
export class CustomModule { }

これで使用したいコンポーネントでは、ライブラリだけインポートすれば、冗長な記述を省略できます。これで準備ができたので、今からTodoアプリを作成していきます。

Todoアプリを作る

Todoアプリのディレクトリ構造はこうなっています。●はコンポーネントの一連ファイル(コンポーネント、テンプレート、スタイル、スペック)です。ちなみに、大本になったアプリは以下の記事(Vueで作成)のもので、これを簡略化して更にAngularに書き換えています。

■app
    ■routes
        -app-todo.ts
    ■services
        -todo-service.ts
    ■todo-components
        -●todo-item //Todo各種の子コンポーネント
        -●todos //Todoリストの親コンポーネント
    ■todo-types
        -types-todo.ts //型定義とトークンを格納したファイル
    ■todo-views
        -●add-todo //新規登録
        -●app //ルートコンポーネント
        -●detail-todo //詳細
        -●edit-todo //編集
        -●todo //トップ画面
custom.module.ts #ライブラリ
main.ts

ルートコンポーネント

全ての基準となるルートコンポーネントは以下のようになります。注意点としてテンプレートにルーティング先を示すrouter-outletタグを用いているので、RouterModuleも使用されています(先程作成したライブラリによって機能が内包されている)。

またルートコンポーネントはmain.tsに紐づいているのでセレクタ名指定は必須です。

app.component.ts
import { Component } from '@angular/core';
import { CustomModule } from '../custom.module' //先程設定したモジュールが内包される

@Component({
  selector: 'app-root', //main.tsに紐づいている
  standalone: true,
  imports: [CustomModule], //ここに追記する
  template: `<router-outlet></router-outlet>`,
  styleUrls: ['./app.component.css'],
})
export class AppComponent{
}

トップページ

トップページはこのようになります。ここではTodosコンポーネントへの紐づけが必要なので、Todosコンポーネントをインポートしておくようにしておきます。これも、今までだと全てモジュールから制御しないといけませんでしたが、スタンドアロンコンポーネントによって、独立してコンポーネントを呼び出すことができるようになっています。したがって、コンポーネント同士の従属関係が非常に明白です。

また、この画面では新規登録画面への遷移用ボタンが実装されています。なので、ここも本来はRouterModuleが必要となります(これをインポートしておかないと画面遷移できません)。

todo.component.ts
import { Component } from '@angular/core';
import { CustomModule } from '../custom.module';
import { TodosComponent } from '../todo-components/todos.component' //Todo制御用のコンポーネント

@Component({
  standalone: true,
  imports: [CustomModule,TodosComponent], //必要なモジュールとコンポーネント
  template:`
    <h2>TODO一覧</h2>
      <app-todos></app-todos><!-- Todosコンポーネントに紐づけ -->
    <a routerLink="./add"><button>新規登録</button></a>
  `,
  styleUrls: ['./todo.component.css']
})
export class TodoComponent{}

型の定義ファイル

型の定義ファイルは以下のようになっています。ただし、Angularの仕様の都合上、メソッド制御の一部においてanyも使用しています。また、サービス注入用のトークンもここで発行しておきます。

types_todo.ts
import { InjectionToken } from '@angular/core'
export const token = new InjectionToken<any>("token")

import { Observable } from 'rxjs'
//ベースとなる型(|でor条件を作成できる)
export type Status = 'waiting'|'working'|'completed'|'pending'

export type Data = {
  id?: number
  title?: string,
  description?: string,
  str_status?: Status, //上で定義した任意のパラメータ
}

//アレンジされた型
//任意のオブジェクトから独自の型定義を作成したい場合(ここではDataオブジェクトのうち、title、description、sutatusの3つのプロパティを用いたParams型を作成している)
export type Params = Pick<Data,'title'|'description'|'str_status'>
export interface Todos{
  find(predicate: (item:Data) => boolean, thisArg?: any)
}

//注入されたサービス用
export interface Services{
  sub: Observable<any>,
  addTodo: (data:Data)=> void,
  updTodo: (data:Data)=> void,
  delTodo:(data:Data)=> void,
  todoReducer: (mode:string,data:Data)=> void 
}

Todo一覧制御

Todo一覧の作成はtodosコンポーネントによって制御されます。また、このコンポーネントは各Todoを制御する子コンポーネントと親子関係に当たるのですが、これもスタンドアロンコンポーネントによって、コンポーネントの紐付けを簡明にしています(Vue、React、Svelteのように関係がわかりやすくなっている)。一方、このTodo一覧はトップページのtodoコンポーネントの被従属関係に当たるので、セレクタによる従属指定が必須です。

また、サービスから呼び出されるTodoデータはトークンを用いて取得し、ngOninitメソッド上で処理できます。そして、サービス上で登録、修正、削除の各種処理を実行することで、更新後のデータを検知し、それを購読できるようになっています。

todos.component.ts
import { Component, OnInit,inject } from '@angular/core'
import { CustomModule } from '../modules/custom.module'
import {TodoItemComponent } from './todo-item.component' //子コンポーネント 
import { Todos,Services,token } from '../todo-types/types_todo'
@Component({
  selector: 'app-todos', //従属する親コンポーネントを紐づけるために必須
  standalone: true,
  imports: [CustomModule,TodoItemComponent],
  template: `
            <ul>
              <app-todo-item
                *ngFor="let todo of todos"
                [todo]="todo"
              ></app-todo-item>
            </ul>
            `
  ,
  styleUrls: ['../todo-components/todos.component.css']
})
export class TodosComponent implements OnInit{
  svs = inject<Services>(token)
  todos: Todos
  ngOnInit(){
    this.svs.sub.subscribe(v => this.todos = v) //更新を検知した値を購読
  }
}

各Todoを制御する

各Todoの制御は以下のようになっています。ここは親コンポーネントから値を受け取っているので、サービスの購読をしていません。また、ここもtodosコンポーネントの子コンポーネントなので、セレクタ指定が必須となります。

todo-item.component.ts
import { Component, Input,inject } from '@angular/core';
import { Services,Data,token } from '../todo-types/types_todo'
import {Router } from '@angular/router'
import { CustomModule } from '../modules/custom.module'

@Component({
  selector: 'app-todo-item',
  standalone: true,
  imports: [CustomModule],
  templateUrl: './todo-item.component.html',
  styleUrls: ['./todo-item.component.css']
})
export class TodoItemComponent{
  svs = inject<Services>(token)
  @Input() todo: Data
  constructor(
    private rtr: Router,
  ) {}
  postcall(mode,data){
    this.svs.todoReducer(mode,data)
  }
  getId(mode,id){
    this.rtr.navigate([`${mode}/${id}`])
  }
}
todo-item.component.html
<!-- 各Todo子コンポーネント -->
<div class="card">
  <div>
    <a routerLink="/" (click)="getId('detail',todo.id)">
      <span class="title">{{todo.title}}</span>
    </a>
    <span class="status">{{ todo.str_status }}</span>
  </div>
  <div class="action">
    <a routerLink="/" (click)="getId('edit',todo.id)"><button>修正</button></a>
    <button (click)="postcall('DEL_TODO',todo.id)">削除</button>
  </div>
</div>

サービスファイル

サービスファイルはそこまで変化はないと思います。今回のケースはObservableが便利です。ただ、削除はspliceで制御しないと更新を検知できない(filterは非破壊的なので、制御しても変数の値は更新されない)ので注意しましょう。

※今回はsubscribeしているだけなので問題は発生しませんが、もしpipeで中継処理を行う場合はObservableだと期待どおりの動きをしなくなる場合があります。その場合はBehavior Subjectで定期的にnextメソッドを使用するといいでしょう。

todo-service.ts
import { Injectable } from '@angular/core'
import { Observable } from 'rxjs'
import { Data } from '../todo-types/types_todo'

const todos = <any>[]

@Injectable({
  providedIn: 'root'
})


export class TodoService{
  sub: Observable<any>
  constructor(){
    this.sub = new Observable((ob)=>{
      ob.next(todos)
    })
  }
  //データ登録
  addTodo = (data)=>{
    todos.push(data)
  }
  //データ更新
  updTodo = (data)=>{
    const idx = todos.findIndex((item)=>item.id == data.id)
    todos[idx] = {...todos[idx],...data}
  }
  //データ削除
  delTodo = (id)=>{
    const idx = todos.findIndex((item)=>item.id == id)
    todos.splice(idx,1)
  }

  todoReducer =(mode,data)=>{
    switch(mode){
      case "ADD_TODO": this.addTodo(data)
      break
      case "UPD_TODO": this.updTodo(data)
      break
      case "DEL_TODO": this.delTodo(data)
      break
    }
  }
}

CRUDを制御する(リアクティブフォーム)

では、ここからAngular13で試験的に実装され、14で晴れて正規の実装となったリアクティブフォームの説明に入っていきます。このリアクティブフォームは簡潔に言えばフォーム構造を事前に設計する機能で、Angular上でフォーム制御をわかりやすくグループ化し、事前に値を設定したり、入力後の値をグループ毎に取得できたりします。また、13だとTypeScript制御でかなり困った不備も発生していましたが、14ではそれが改善されています。

ここで、スタンドアロンコンポーネントの際の記述に注意点があります。スタンドアロンコンポーネントの場合、ReactiveFormModuleというモジュールを取得しておきます(今回の例ではカスタムコンポーネントに内包済)。また、FormGroup、FormControlの各種オブジェクトをインポートしておく必要があります(スタンドアロンコンポーネントの場合はFormsModuleはCommonModuleに含まれているので、指定不要)。

ちなみに、この各種CRUD用コンポーネントはそれぞれ独立している(従属関係を持たない)ので、セレクタ指定は不要です(あってもなくても動きます)。

それから登録画面はinjectでサービスを受け取っていますが、使用しているのがsendDataメソッドだけなのでOnInitは使用しなくても大丈夫です。

Todoの新規登録

add-todo.component.ts
import { Component,inject } from '@angular/core';
import { CustomModule } from '../modules/custom.module';
import {Router } from '@angular/router'
import { Data,Todos,Status,Services,token } from '../todo-types/types_todo'
import {FormGroup,FormControl} from '@angular/forms'

@Component({
  standalone: true,
  imports: [CustomModule],
  templateUrl: './add-todo.component.html',
  styleUrls: ['./add-todo.component.css']
})
export class AddTodoComponent{
  svs = inject<Services>(token)
  data: Data
  constructor(
    private rtr:Router,
  ) {}
  //ダイレクトに変数を代入することもできる
  date = new Date()
  setid = this.date.getTime()
  todoForm = new FormGroup({
    id: new FormControl<number>(this.setid),
    title: new FormControl<string>(''),
    description: new FormControl<string>(''),
    str_status: new FormControl<Status>('waiting')
  })
  sendData(mode:string){
    const data = this.todoForm.value
    this.svs.todoReducer(mode,data)
    this.rtr.navigate(['/'])
  }
}

テンプレートの記述

テンプレートも今までのような双方向バインディングはお役御免となります。その代わりにformGroupというプロパティとformControlNameというプロパティが制御に必須となり、formControlNameプロパティ上の名称がformGroupに記載されたグループ名の所属フォーム名となります。そして、これらは先程のコンポーネントファイルで制御したフォーム設計に合致させておく必要があります。

具体的にいえば、フォームのグループ名todoFormに属する各種プロパティがtitle、description、そしてstr_statusとなります。

ただし、IDのようにreadonlyにする場合はプロパティバインディングを用います。

※何故かはわかりませんが、コンポーネントはパスカルケース(FormHoge)なのに対し、プロパティはキャメルケース(formHoge)で記述するようになっています。注意しましょう。

add-todo.component.html
<h2>TODOの作成</h2>
<form [formGroup]="todoForm" (ngSubmit)="sendData('ADD_TODO')">
<div>
  <label for="title"> ID</label>
  <input type="text" id="title" [value]="setid" readonly />
</div>
<div>
  <label for="title"> タイトル</label>
  <input type="text" id="title" formControlName="title" />
</div>
<div>
  <label for="description"></label>
  <textarea id="description" formControlName="description" ></textarea>
  </div>
  <div>
  <label for="status">ステータス</label>
  <select id="status"  formControlName="str_status">
    <option value="waiting">waiting</option>
    <option value="working">working</option>
    <option value="completed">completed</option>
    <option value="pending">pending</option>
  </select>
</div>
<button type="submit">作成する</button>
</form>

入力したフォームの値を転送する

フォームの値転送はformタグに埋め込まれたngSubmitディレクティブに記述されたsendDataに転送されます。sendDataは以下のように記述しており、フォームグループのプロトタイプにあるvalueプロパティに格納されます。あとはこれを制御用のサービスファイルに送るだけです。

add-todo.component.ts
  sendData(mode){
    const data = this.todoForm.value //フォームに入力された値
    const id = this.setid //idの値
     this.svs.todoReducer(mode,{...data,id}) //サービス上のメソッドへ転送
    this.rtr.navigate(['/']) //TOP画面へ遷移
  }

修正画面

では、修正画面の場合はどうやって制御するのでしょうか。修正画面の場合は、該当の値をフォームに一旦返さないといけません、そこで、役立つメソッドがpatchValueで、対象フォームのプロパティに対して、更新処理を行ってくれます。

※setValueというメソッドもありますが、こちらは全プロパティを設定する必要があります。今回はidは一旦、テンプレートに返さなくてもよいので、却ってsetValueを使用すると型不一致のエラーとなります。

また、今回は一旦サービスからデータを受け取る必要があるので、ngOnintを受け皿としてそこでpatchValueをかけています。その際の型はid以外のプロパティを抽出したParamsとなります。

edit.component.ts
import { Component,OnInit,inject } from '@angular/core'; //OnInitを忘れない
import { CustomModule } from '../modules/custom.module';
import { ActivatedRoute,Router } from '@angular/router'
import { Data,Params,Status,Todos,Services,token } from '../todo-types/types_todo'
import { FormGroup,FormControl} from '@angular/forms'

@Component({
  standalone: true,
  imports: [CustomModule],
  templateUrl: './edit-todo.component.html',
  styleUrls: ['./edit-todo.component.css']
})
export class EditTodoComponent implements OnInit{
  svs = inject<Services>(token)
  todos: Todos
  data: Data
  id: number
  todoForm = new FormGroup({
    id: new FormControl<number>(null),
    title: new FormControl<string>(''),
    description: new FormControl<string>(''),
    str_status: new FormControl<Status>('waiting')
  })
  constructor(
    private rt: ActivatedRoute,
    private rtr:Router,
  ) {}
  ngOnInit(){
    this.svs.sub.subscribe((v)=>{ this.todos = v})
    this.id = Number(this.rt.snapshot.params.id)
    this.data = this.todos.find((item:Data)=>item.id === this.id)
    this.todoForm.patchValue(<Params>this.data)
  }
  //転送されたデータ
  sendData(mode: string){
    this.todoForm.patchValue({
      id: this.data.id
    })
    const data = this.todoForm.value
    this.svs.todoReducer(mode,data)
    this.rtr.navigate(['/'])
  }
  backto(){
    this.rtr.navigate(['/'])
  }
}

そして値の更新の際に、忘れずにidに対してもpatchValueをかけておきましょう。valueで入力されたフォームの値を取得するのはidを返してからの作業になります。

ちなみに、TypeScriptの利点として、値を代入しなくても型を定義できるので、次のような記述方法でも大丈夫です。フォームに返す値を逐一確認したい場合は前述の方法で、そこまでのフォームが存在しない場合は以下の方法でいいでしょう。

    this.todoForm.patchValue(<Params>this.data)

編集画面のテンプレート

編集用のテンプレートは登録用とそこまで変わりません。編集用だからといっても、フォームの値はリアクティブフォームが制御してくれるので、プロパティバインディングも必要なのはformGroupだけで済みます。

edit-component.html
<h2>TODOの編集</h2>
<form [formGroup]="todoForm" (ngSubmit)="sendData('UPD_TODO')">
<div>
  <label for="title"> タイトル</label>
  <input type="text" id="title" formControlName="title" />
</div>
<div>
  <label for="description"></label>
  <textarea id="description" formControlName="description" ></textarea>
  </div>
  <div>
  <label for="status">ステータス</label>
  <select id="status"  formControlName="str_status">
    <option value="waiting">waiting</option>
    <option value="working">working</option>
    <option value="completed">completed</option>
    <option value="pending">pending</option>
  </select>
</div>
<button type="submit">更新する</button>
</form>
<button (click)="backto()">戻る</button>

詳細画面(属性ディレクティブとカスタムパイプ)

詳細画面制御のコンポーネントはフォーム制御は不要なので、記述もシンプルです。ただし、ここでは属性ディレクティブとカスタムパイプを使用しているのですが、それぞれスタンドアロン宣言が必要になります。

そして、これらも同様にimportするだけで使用可能になります。

detail-todo.component.ts
import { Component,OnInit,inject } from '@angular/core';
import { CustomModule } from '../custom.module';
import {ActivatedRoute,Router } from '@angular/router'
import { Data,Todos,Services,token } from '../todo-types/types_todo'
import { TableDirective } from '../directives/table.directive' //属性ディレクティブ
import { SetBrPipe } from '../pipes/set.br.pipe' //カスタムパイプ

@Component({
  standalone: true,
  imports: [CustomModule,SetBrPipe,TableDirective], //使用するコンポーネント類を記述する
  templateUrl: './detail-todo.component.html',
  styleUrls: ['./detail-todo.component.css']
})
export class DetailTodoComponent implement OnInit{
  svs = inject<Services>(token)
  todos: Todos
  id: number
  data: Data
  constructor(
    private rt: ActivatedRoute,
    private rtr:Router,
  ) {}
  ngOnInit(){
    this.svs.sub.subscribe((v)=>{ this.todos = v})
    const id = Number(this.rt.snapshot.params.id)
    this.data = this.todos.find((item)=>item.id === id)
  }
  //ルートコンポーネントに戻る
  backto(){
    this.rtr.navigate(['/'])
  }
}

詳細画面のテンプレート

詳細画面はフォームとは異なり、ダイレクトに変数を返しています。

テーブルタグには属性ディレクティブ(bs_tableが任意のプロパティ)でBootstrapを制御しています。また、テキストエリアの改行が反映されるように、カスタムパイプで改行コードを置換した上でプロパティバインディングの[innerHTML]を使用しています。

detail-todo.component.html
	<h2>TODOの詳細</h2>
  <ng-template *ngIf="data.title ==''; then t else f"></ng-template>
  <ng-template #t>
	<div>ID:{{ data.id }}のTODOが見つかりませんでした</div> 
	</ng-template>
  <ng-template #f>
    <table bs_table>
      <thead>
        <tr>
        <th>タイトル</th>
        <th>説明</th>
        <th>ステータス</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>{{ data.title }}</td>
          <td [innerHTML]="data.description | setbr"></td>
          <td>{{ data.str_status }}</td>
        </tr>
      </tbody>
    </table>
  </ng-template>
  <button (click)="backto()">戻る</button>

スタンドアロンコンポーネントで属性ディレクティブを使用する

属性ディレクティブはスタンドアロンコンポーネント上ではスタンドアロン化したものしか利用できません。ですが、スタンドアロン化することで、上記のようにコンポーネント上でimportするだけで使用できるので、スタンドアロンの方がずっと利便性が高いです。

※ちなみに、スタンドアロンコンポーネントはBootstrap標準実装です。

table.directive.ts
import { Directive,HostBinding } from '@angular/core';

@Directive({
  selector: '[bs_table]', //タグに付与するプロパティの名称
  standalone: true
})
export class TableDirective {
  @HostBinding("class") //class属性にバインド
  elementClass = "table table-bordered border-primary" //使用したいBootstrapのプロパティ
}

属性ディレクティブ作成のコマンド

属性ディレクティブを使用するときも同様にstandalone宣言が必要です。dはディレクティブの省略形です。また--flatを付与しないと余分なdirectivesフォルダが作成されることになります。

# cd directives
# ng g d 任意のディレクティブ名 --flat --standalone 

スタンドアロンコンポーネントでカスタムパイプを使用する

スタンドアロンコンポーネントでは、RxJSにおいてかなり大きな挙動変化があります。それはObservableがリアルタイムに起動しなくなるというもので、普通にsubscribeするだけなら問題ないのですが、中継処理用のpipeがうまく機能しません(読み込み時しか起動しない。BehaviorSubjectなら問題ない)。

その代替案としてカスタムパイプの使用が推奨されています。スタンドアロンコンポーネントはカスタムパイプに対してもスタンドアロン化で使用し、しかもコンポーネントと同じようにインポートするだけです。

ここではテキストエリアに入力された値に対し、改行コードをbrタグに変換する処理をカスタムパイプ化しています。

set.br.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
  name: 'setbr',
  standalone: true
})
export class SetBrPipe implements PipeTransform {
  transform( str : string){
    return str.replace(/\r?\n/g, '<br>') //テキストエリアの改行コードをbrタグに変換
  }
}

カスタムパイプ作成のコマンド

カスタムパイプを作成するときも同様にstandalone宣言が必要です。pはカスタムパイプの省略形です。また--flatを付与しないと、余分なpipesフォルダが作成されることになります。

# cd pipes
# ng g p 任意のパイプ名 --flat --standalone 

属性ディレクティブやカスタムパイプをライブラリに組み込む

前述したようにライブラリはディレクティブ、パイプ類も組み込むことができるので、利用頻度の高いディレクティブやパイプは一元管理した方がいい場合もあります。

custom.module.ts
import {NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import {RouterModule,Router } from '@angular/router'
import {ReactiveFormsModule } from '@angular/forms'
import {ButtonDirective} from '../directives/button.directive' //ボタンデザイン制御のディレクティブ

@NgModule({
	imports:[
    CommonModule,
    RouterModule,
    ReactiveFormsModule,
    ButtonDirective, //追加
	],
  exports: [
    CommonModule,
    RouterModule,
    ReactiveFormsModule,
    ButtonDirective, //追加
  ],
})
export class CustomModule {}

あとは使用したいコンポーネントでインポートするだけです。

補足

遅延ロード

AngularでNextやNuxtのような遅延ロード(リンクされた時だけ読み込みを行うローディング方法)もできます。それには以下のように記述するだけです。コールバック関数を記述するだけでも大丈夫です。修正画面と詳細画面の制御コンポーネントを遅延ロードに書き換えてみました。

loadComponent: ()=> コンポーネントを呼び出す式またはコールバック関数

app-todo.ts
import { Routes } from "@angular/router";
//各種コンポーネント
import {TodoComponent} from '../todo-views/todo.component';
import {AddTodoComponent} from '../todo-views/add-todo.component';
const lazy_EditTodoComponent = import('../todo-views/edit-todo.component').then(c => c.EditTodoComponent)
const lazy_DetailTodoComponent = import('../todo-views/detail-todo.component').then(c => c.DetailTodoComponent)
export const Route: Routes = [
	{path: '',component: TodoComponent},
	{path: 'add',component: AddTodoComponent},
	{path: 'edit/:id',
    loadComponent: ()=> lazy_EditTodoComponent, //コールバック関数にしてもよい
    pathMatch: 'full'},
	{path: 'detail/:id',
    loadComponent: ()=> lazy_DetailTodoComponent, //コールバック関数にしてもよい
    pathMatch: 'full'},
]

一般的には以下のように式を記述することが多いですが、これだと使用コンポーネントが散逸してしまって、個人的に分かりづらいと思ったからです。

app-todo.ts
loadComponent: import('../todo-views/detail-todo.component').then(c => c.DetailTodoComponent),

※loadChildrenを用いるとルーティングファイルごと子コンポーネント化させて、遅延ロードすることもできたりします。その際はパスが一致していることが条件となります。

33
33
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
33
33