LoginSignup
1
0
記事投稿キャンペーン 「2024年!初アウトプットをしよう」

[Angular] @Input を Signal Inputs に置き換えた

Last updated at Posted at 2024-01-28

はじめに

Angular v17.1 で Signal Inputs がリリースされました。
本記事では従来の @Input で記述された構文を Signal Inputs で書き換えてみます。

Signal Inputs について

Signal Inputs については こちら をご参照ください。
ここで見出しを拝借しますと

といった内容で Signal Inputs に変えることによるメリットを簡潔にわかりやすく説明してくださっています。

環境

今回の記事の内容は次の環境で実施したものです。

環境 バージョン 備考
Angular CLI v17.1.0 ng version で確認
Angular v17.1.0 同上
TypeScript v5.3.3 同上
zone.js v0.14.3 同上
Node.js v18.19.0 同上
npm v10.2.3 同上

今回扱うソースコード

対象のソースコードは私の学習リポジトリである こちら になります。

作業は手動で行う

@Input から Signal Inputs へのマイグレーションコマンドは用意されていないようです。
従いまして今回は手動で移行を行いました。

差分をみてみる

単純な置き換え

対象ソースコード

差分(child.component.ts)

差分
% git diff src/app/component/parent-child/child/child.component.ts
diff --git a/src/app/component/parent-child/child/child.component.ts b/src/app/component/parent-child/child/child.component.ts
index d97c8bc..7394a23 100644
--- a/src/app/component/parent-child/child/child.component.ts
+++ b/src/app/component/parent-child/child/child.component.ts
@@ -1,7 +1,4 @@
-import { Component, OnInit } from '@angular/core';
-
-// @Input デコレータを使用するための import
-import { Input } from '@angular/core';
+import { Component, OnInit, input } from '@angular/core';
 
 // @Output デコレータを使用するための import
 import { Output, EventEmitter } from '@angular/core';
@@ -14,10 +11,8 @@ import { Output, EventEmitter } from '@angular/core';
 export class ChildComponent implements OnInit {
   /**
    * 親コンポーネントから受け取るデータ(文字列)をセットするパラメータ
-   *
-   * @type {string}
    */
-  @Input() dataFromParent = '';
+  dataFromParent = input<string>('');
 
   /**
    * 親コンポーネントに対してイベントを発火するためのパラメータ

以下、ポイントを抜き出します。( import 文やコメントの差分は除きます )

移行前のコード
-  @Input() dataFromParent = '';

移行後のコード
+  dataFromParent = input<string>('');

に書き換えています。
これにより dataFromParent の型も string から InputSignal<string, string> に変わり、computedeffect によるリアクティブな処理ができるようになる、とのことです。

( 移行前後のキャプチャ )

スクリーンショット 2024-01-28 16.39.31.png

スクリーンショット 2024-01-28 17.08.44.png

補足-required の指定

なお親コンポーネントから値を必ず受け取りたい場合は required を指定します。
上記のコードを例に取ると

requiredを使う例
  dataFromParent = input.required<string>();

となります。
このとき引数の値に '' を指定すると次のエラーになります。

Argument needs to be an object literal that is statically analyzable.(-991010)

( こちらはキャプチャ )
スクリーンショット 2024-01-28 16.30.06.png

差分(child.component.html)

% git diff src/app/component/parent-child/child/child.component.html
diff --git a/src/app/component/parent-child/child/child.component.html b/src/app/component/parent-child/child/child.component.html
index 997180c..2cdd85b 100644
--- a/src/app/component/parent-child/child/child.component.html
+++ b/src/app/component/parent-child/child/child.component.html
@@ -1,7 +1,7 @@
 <div id="child-wrap">
   <!--
     親コンポーネントから受け取ったデータを表示する
-    @Inputデコレータで修飾されたパラメータに対して「親コンポーネント側のテンプレートでセット済み」なので
+    Signal Inputs であるパラメータに対して「親コンポーネント側のテンプレートでセット済み」なので
     ここでは単純にパラメータを単方向データバインドで扱うだけで良い
   -->
   <div id="parent-to-child">
@@ -9,7 +9,7 @@
       <strong>子コンポーネント</strong>
     </p>
     <p id="sent-data">
-      親コンポーネントから渡された文字列は 「<strong>{{dataFromParent}}</strong>」です。
+      親コンポーネントから渡された文字列は 「<strong>{{dataFromParent()}}</strong>」です。
     </p>
   </div>

以下、ポイントを抜き出します。( コメントの差分は除きます )

移行前のコード
-      親コンポーネントから渡された文字列は 「<strong>{{dataFromParent}}</strong>」です。

移行後のコード
+      親コンポーネントから渡された文字列は 「<strong>{{dataFromParent()}}</strong>」です。

に書き換えています。
dataFromParent の呼び出しが () をつけた関数呼び出し になっていることに注目です。
() をつけないと次の警告とエラーがでるのでご注意ください。( 私の環境で出力されたログをそのまま出しています )

警告とエラー
Warning: src/app/component/parent-child/child/child.component.html:12:37 - warning NG8109: dataFromParent is a function and should be invoked: dataFromParent()

12       親コンポーネントから渡された文字列は 「<strong>{{dataFromParent}}</strong>」です。
                                       ~~~~~~~~~~~~~~

  src/app/component/parent-child/child/child.component.ts:8:16
    8   templateUrl: './child.component.html',
                     ~~~~~~~~~~~~~~~~~~~~~~~~
    Error occurs in the template of component ChildComponent.

次は Directive の例です。

Directive での置き換え

対象ソースコード

差分

差分
% git diff
diff --git a/src/app/directive/attribute/template/template.directive.ts b/src/app/directive/attribute/template/template.directive.ts
index e649704..0475414 100644
--- a/src/app/directive/attribute/template/template.directive.ts
+++ b/src/app/directive/attribute/template/template.directive.ts
@@ -1,4 +1,4 @@
-import { Directive, ElementRef, Input, OnInit } from '@angular/core';
+import { Directive, ElementRef, OnInit, input } from '@angular/core';
 
 @Directive({
   selector: '[appTemplate]',
@@ -6,22 +6,16 @@ import { Directive, ElementRef, Input, OnInit } from '@angular/core';
 export class TemplateDirective implements OnInit {
   /**
    * 親コンポーネントから受け取るデータ-1
-   *
-   * @type {string}
    */
-  @Input() public greet = '';
+  public greet = input<string>('');
 
   /**
    * 親コンポーネントから受け取るデータ-2
-   *
-   * @type {string}
    */
-  @Input() public name = '';
+  public name = input<string>('');
 
   /**
    * コンストラクタ
-   *
-   * @param elementRef このディレクティブがセットされたDOMへの参照
    */
   constructor(private elementRef: ElementRef) {}
 
@@ -32,6 +26,6 @@ export class TemplateDirective implements OnInit {
    */
   ngOnInit(): void {
     const element = this.elementRef.nativeElement;
-    element.innerHTML = this.greet + ' ' + this.name + '.';
+    element.innerHTML = this.greet() + ' ' + this.name() + '.';
   }
 }

この差分のうち

差分
-  @Input() public greet = '';
+  public greet = input<string>('');

差分
-  @Input() public name = '';
+  public name = input<string>('');

前項 で挙げたものと同じです。
Directive におけるポイントは次の差分です。

差分
-    element.innerHTML = this.greet + ' ' + this.name + '.';
+    element.innerHTML = this.greet() + ' ' + this.name() + '.';

Signal Inputs に置き換えたことにより、greetname を関数呼び出しに書き換えました。
移行前のコードのままですと、次のエラーが出ます。

function inputValueFn() { // Record that someone looked at this signal. (0,_angular_core_primitives_signals__WEBPACK_IMPORTED_MODULE_0__.producerAccessed)(node); if (node.value === REQUIRED_UNSET_VALUE) { throw new RuntimeError(-950 /* RuntimeErrorCode.REQUIRED_INPUT_NO_VALUE */, ngDevMode && 'Input is required but no value is available yet.'); } return node.value; } function inputValueFn() { // Record that someone looked at this signal. (0,_angular_core_primitives_signals__WEBPACK_IMPORTED_MODULE_0__.producerAccessed)(node); if (node.value === REQUIRED_UNSET_VALUE) { throw new RuntimeError(-950 /* RuntimeErrorCode.REQUIRED_INPUT_NO_VALUE */, ngDevMode && 'Input is required but no value is available yet.'); } return node.value; }.

前項 のテンプレートの書き換えでも類似のエラーが出ていますが、これは型が Signal に変わったことによるものです。
Signal Inputs に置き換えた場合、その変数を参照する場合は () を付与した関数呼び出しに注意しましょう。

これは こちらのガイド で説明されています。

ngOnChagnes を含む置き換え

対象ソースコード

差分(on-change-verification.component.ts)

(html テンプレートの差分はこれまでに挙げたものと同じなので割愛します)

差分
% git diff src/app/component/lifecycle/on-change/on-change-verification/on-change-verification.component.ts
diff --git a/src/app/component/lifecycle/on-change/on-change-verification/on-change-verification.component.ts b/src/app/component/lifecycle/on-change/on-change-verification/on-change-verification.component.ts
index 953c69b..13be5f9 100644
--- a/src/app/component/lifecycle/on-change/on-change-verification/on-change-verification.component.ts
+++ b/src/app/component/lifecycle/on-change/on-change-verification/on-change-verification.component.ts
@@ -1,4 +1,4 @@
-import { Component, ElementRef, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
+import { AfterViewInit, Component, ElementRef, SimpleChanges, effect, input } from '@angular/core';
 import { Logging } from 'src/app/utils/logging';

 @Component({
@@ -6,8 +6,8 @@ import { Logging } from 'src/app/utils/logging';
   templateUrl: './on-change-verification.component.html',
   styleUrls: ['../../../../style/common.css', './on-change-verification.component.css'],
 })
-export class OnChangeVerificationComponent implements OnInit, OnChanges {
-  @Input() ngOnChangesValue = '';
+export class OnChangeVerificationComponent implements AfterViewInit {
+  signalInputValue = input<string>('');

   /**
    * ログ出力を行うテキストエリアの HTML エレメント
@@ -16,56 +16,32 @@ export class OnChangeVerificationComponent implements OnInit, OnChanges {

   constructor(private element: ElementRef) {
     // コンストラクタは画面描画の前に実行されるので画面上の要素を取得することができない
-    // よって、ここではコンソールログに出力するだけになる
     console.log('[constructor] execute');
-  }

-  ngOnInit(): void {
-    // こちらはコンソールログと画面上の両方に出力する
-    const message = '[ngOnInit] fired';
-    console.log(message);
-    Logging.info(this.textAreaElement, message);
+    effect(() => {
+      // コンソールログと画面上の両方に出力する
+      let message = '[effect] fired';
+      console.log(message);
+      Logging.info(this.textAreaElement, message);
+
+      // effect を使って変更前の値と変更後の値、そして変更されているかをログ出力する
+      const messageJson = {
+        currentValue: this.signalInputValue(),
+      };
+      message = `[ngOnChanges] ${JSON.stringify(messageJson)}`;
+      console.log(message);
+      Logging.info(this.textAreaElement, message);
+    });
   }

-  ngOnChanges(changes: SimpleChanges) {
-    // ngOnchanges はライフサイクルの順序上、 ( @Input で修飾されたデータが存在する場合は )ngOninit の先に実行される.
-    // なので、ちょっと気持ち悪いがここで
-    //
-    // - 画面上のログ出力先となる HTML 要素の取得
-    //
-    // を行う
+  ngAfterViewInit(): void {
+    // 画面上のログ出力先となる HTML 要素の取得を行う
     // ( ngOninit で行うと HTML 要素が取得される前にログ出力を行おうとするので例外が発生する )
     this.textAreaElement = this.element.nativeElement.querySelector('.log-text-area');

-    // こちらもコンソールログと画面上の両方に出力する
-    let message = '[ngOnChanges] fired';
+    // コンソールログと画面上の両方に出力する
+    const message = '[ngOnInit] fired';
     console.log(message);
     Logging.info(this.textAreaElement, message);
-
-    // SimpleChanges を使って変更前の値と変更後の値、そして変更されているかをログ出力する
-    for (const prop in changes) {
-      if (!changes.hasOwnProperty(prop)) {
-        continue;
-      }
-
-      const change = changes[prop];
-      const messageJson = {
-        property: prop,
-        isFirstChange: change.firstChange,
-        previousValue: ((previousValue): string => {
-          // 初回実行時、`previousValue` は「前回の値が存在しない」ので `undefined` が設定される
-          // だが JSON.stringfy で文字列に変換する際、 `undefined` は文字列化の対象から除外されてしまうので
-          // 文字列としての `undefined` を返却したい
-          if (previousValue === undefined) {
-            return 'undefined';
-          }
-          return previousValue;
-        })(change.previousValue),
-        currentValue: change.currentValue,
-      };
-      message = `[ngOnChanges] ${JSON.stringify(messageJson)}`;
-      console.log(message);
-      Logging.info(this.textAreaElement, message);
-    }
   }
 }

この差分ではわけがわからないので、注目したい部分について移行前後のコードを示します。
※ Signal Ipnuts への置き換え部分については、前項までに挙げたものと同じなので割愛します
※ またコード中のコメントはここでは冗長なので削除してます

移行前
  constructor(private element: ElementRef) {
    console.log('[constructor] execute');
  }

  ngOnInit(): void {
    const message = '[ngOnInit] fired';
    console.log(message);
    Logging.info(this.textAreaElement, message);
  }

  ngOnChanges(changes: SimpleChanges) {
    this.textAreaElement = this.element.nativeElement.querySelector('.log-text-area');

    let message = '[ngOnChanges] fired';
    console.log(message);
    Logging.info(this.textAreaElement, message);

    for (const prop in changes) {
      if (!changes.hasOwnProperty(prop)) {
        continue;
      }

      const change = changes[prop];
      const messageJson = {
        property: prop,
        isFirstChange: change.firstChange,
        previousValue: ((previousValue): string => {
          if (previousValue === undefined) {
            return 'undefined';
          }
          return previousValue;
        })(change.previousValue),
        currentValue: change.currentValue,
      };
      message = `[ngOnChanges] ${JSON.stringify(messageJson)}`;
      console.log(message);
      Logging.info(this.textAreaElement, message);
    }
  }
  • 移行前のコードのポイント
    • constructorngOnInit はログ出力だけで他には何もしていない
    • ngOnChanges では SimpleChanges オブジェクトを参照して以下を行っている
      • firstChange を参照して最初の変更か否かをチェック
      • previousValue を参照して更新前の値を取得
      • currentValue を参照して更新後( 現在 )の値を取得

これが effect に置き換えることで次のようになります。

移行後
  constructor(private element: ElementRef) {
    console.log('[constructor] execute');

    effect(() => {
      let message = '[effect] fired';
      console.log(message);
      Logging.info(this.textAreaElement, message);

      const messageJson = {
        currentValue: this.signalInputValue(),
      };
      message = `[ngOnChanges] ${JSON.stringify(messageJson)}`;
      console.log(message);
      Logging.info(this.textAreaElement, message);
    });
  }

  ngAfterViewInit(): void {
    this.textAreaElement = this.element.nativeElement.querySelector('.log-text-area');

    const message = '[ngOnInit] fired';
    console.log(message);
    Logging.info(this.textAreaElement, message);
  }
  • 移行後のコードのポイント
    • effect を実装
      • ngOnChanges で行っていた値が更新されたときのハンドラを constructor 内の effect に移した
      • SimpleChanges オブジェクトは参照しなくなったので、それに関する処理を削除
      • firstChange の参照を削除
      • previousValue の参照を削除
      • currentValue の参照を削除
    • 副次的な変更として
      • ngOnChanges で取得していた HTMLElement の参照が取得できなくなったので、 ngAfterViewInit を用意してそちらに移した

effect にすることでコードはかなりスッキリしました。SimpleChanges オブジェクトから対象となる値を抜き出すことなく、直接 Signal Inputs で定義されたインプット変数を参照できるのは嬉しいです。
なお effect では古い値を提供してくれないようです。

反面、SimpleChanges にあった firstChangepreviousValue を参照した処理が書けなくなる、もしくは別の方法を模索しなければならなくなりました。
その点において若干の不便さはあると感じました。

補足

effectngOnInit とか ngAfterViewInit で実装すると次のエラーが出ます。

ERROR Error: NG0203: effect() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`. Find more at https://angular.io/errors/NG0203

まとめにかえて

Angular v16 で搭載された Signal がベースになっているので、そちらをまず理解しておくことが大事ですね。
こちらのようなスタートガイド もありますので活用していきます。

あとは記事中でも触れていますが、 ngOnChanges を使わないことでコードがかなりスッキリと、かつ見やすくなります。これは嬉しいですね。

また Signal Inputs に置き換えることで、それらのインプットプロパティは Signal型のオブジェクトで初期化される のも嬉しいです。
本記事では試していませんが、参考記事の こちら によりますと、これによって strictPropertyInitialization を常に有効化することが可能になるそうです。
そうすると変数宣言時の初期化が必須となり、より安全にプログラムに臨めるということで、これも嬉しいメリットです。

課題

次が今回の記事を書く中で分からなかったので今後の課題として挙げます。

  • @Input から Signal Inputs への置き換えのように @Output の置き換えはあるのか、とか
  • ngOnChangeseffect に置き換わったように ngDocheck についても置き換えはあるのか、とか

参考

1
0
0

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
1
0