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

Angularでコンポーネントの依存関係を簡単に管理する新しいコンセプト「Dep」

More than 1 year has passed since last update.

チームコラボレーションツール「Goalous」開発でリードエンジニアを務めている「だ〜まさ」こと吉田です。

つい先日Angularでコンポーネントの依存関係を簡単に管理する仕組みを作り、プロジェクトに導入したので共有します。
恐らく同じ様にAngularのコンポーネント管理について困っている方はいるはず・・!

ここで言う依存関係とは具体的に親コンポーネントが必要な子コンポーネントまたはモジュールを指します。
もし仕組みだけ知りたい方はコンポーネントの依存関係を楽に管理出来る「Dep」とはから読み進めて下さい。

■対象読者

  • Angularでよりコンポーネント指向でアプリケーションを作りたい人(特にAtomic Designのようなコンポーネントのレイヤーを段階的に分けているプロジェクト)
  • NgModuleに必要なコンポーネントやモジュールを全部把握して登録するのにうんざりしている人
  • 同じ様にAngular+StoryBookでもmoduleMetadataに必要な(以下同文)
  • Angularを嫌いになりかけている人

AngularのNgModuleのイケてなさは半端ないって

まず最初に言っておきます。 
僕はAngularは書いていて楽しいフレームワークだと思います。
少なくともReact+Reduxを書いてた時に感じていた「単純なイベント処理でなんでこんなめんどくさいことをしなければいけないんだ」というモヤっと感はありません。

しかし本当にコンポーネント指向で中規模以上のAngularアプリケーションを作ろうとした時に大きな障壁があります。
それがNgModuleです。

なぜ使う部品の中身を知らなければいけないの?

僕達は今サービスのデザイン・フロントエンドリニューアルを進めていて、今まで超カオスだったフロントエンドを全てAngular最新バージョンで書き換えることに決めました。

そしてもう一つ、デザイン仕様の変更に強くしコンポーネントの責任をより明確にする為にAtomic Designという手法を採用しています。
Atomic Designの詳細を知りたい方はここら辺の記事を読んでみてください。
Atomic Designを分かったつもりになる
Atomic Designの考え方と利点・欠点
Atomic Design を実案件に導入 - UI コンポーネントの粒度を明確化した結果と副産物

AngularのコンポーネントもAtomic Designの各単位であるAtom, Molecule, Organism, Pageに沿って構成しています。(僕らのプロジェクトではTemplateは使っていません)
image.png

小さいコンポーネントをどんどん作成するとこまでは多少Angularのやり方につまづきながらもStoryBookでコンポーネントを確認しながらスムーズに開発出来ていたと思います。
問題は各ページに必要なコンポーネントをNgModuleに登録するところです。

実際、少し前までログイン画面表示に必要なコンポーネントやモジュールを登録しているLoginModuleの中身はこのようになっていました。
image.png

うん、どのコンポーネントが何に依存しているのか全く分からんですね\(^o^)/
これがAngularの「とりあえず使うコンポーネントやパイプ、ディレクティブ、モジュールとか全部登録しておけばいいんでしょ」ウェーイです。

一部の親子関係を説明すると、LoginFormComponentというログインフォーム用のコンポーネントの中でメールアドレス入力用のEmailFieldComponentとパスワード入力用のPasswordFieldComponentを使っています。
EmailFieldComponentPasswordFieldComponentInputFieldComponentというベースのフォームコンポーネントをextendしています。
image.png

更に言えばInputFieldComponentはAngular MaterialのMatInputModuleを拡張しています。

つまりLoginFormComponentを使うためには
- EmailFieldComponentPasswordFieldComponentInputFieldComponentをextendしていることを把握してNgModuleのdeclarationに登録する
- InputFieldComponentMatInputModuleに依存していることも理解した上でNgModuleのimportsに登録する
が必要になります。

// LoginFormComponent利用に必要なNgModuleへの登録
@NgModule({
  imports: [MatInputModule],
  declarations: [
    LoginFormComponent,
    EmailFieldComponent,
    PasswordFieldComponent,
    InputFieldComponent
  ]
})

全くもってナンセンスですね。
なんで使う部品の中身を全部把握しなければならんのか・・:disappointed_relieved:

↑は一例ですが、複雑なページのコンポーネントとなると依存関係は膨大になります。
子孫全ての依存関係を把握することなんてやってられません。

Angularが本当の意味でコンポーネント指向のアプリケーション作成に向いていない原因がまさにこのNgModuleにあります。
コンポーネントを切り離す時にも何に依存しているかを前もって把握した上でNgModuleの登録箇所を修正する必要があるので、容易に出来ません。

しかしこんな馬鹿げたことを続けたらシステムが破綻することは分かりきっていたので、Angulaコンポーネントの依存関係を楽に管理出来る仕組み作りに取り組みました。
その結果生み出したのが、新しいコンセプト「Dep」です。

コンポーネントの依存関係を楽に管理出来る「Dep」とは

まず僕が考えたのが各コンポーネントごとに必要な分だけNgModuleに登録できないかということです。
先程挙げたLoginFormComponentの場合、イメージとしてはこうです。
image.png

ただし、もちろんこれはAngularでは不可能です。
なぜなら必ず「Type ***Component is part of the declarations of 2 modules: **Module and **Module」というNgModuleのdeclarations重複登録エラーが発生するからです。

ではどうすれば良いか。最終的な結論はこうなります。

image.png

「何を言ってるかちょっと分かんない」という声が聞こえてきそうなので、ここからDepの仕組みの詳細を説明します汗

独自のClass Decoratorとrefrect-metadataを活用

Depでは独自のClass Decoratorとrefrect-metadataを使います。

Class DecoratorとはAngularでよく使う@NgModule@Componentのようにクラス宣言の直前で宣言してクラスのコンストラクタに適用され、クラスの定義の検査、修正、置換に使われるTypeScriptのexperimental feature(実験的機能)の一つです。

一方refrect-metadataはクラスやオブジェクトに対してメタデータを追加出来る仕組みを指します。

導入準備

まずはこれらを利用する準備をします。

refrect-metadataのパッケージをnpmインストールします。

npm install reflect-metadata

yarnの場合

yarn add reflect-metadata

次に一応tsconfig.jsonでcompilerOptionsexperimentalDecoratorsがtrueになっていることを確認します。
この設定がfalseになっていることはあまり無いと思いますが念のため。

  "compilerOptions": {
    "experimentalDecorators": true,
    ...
  }

依存関係を管理する「DepManager」を作成

DepManagerはrefrect-metadataを利用して依存関係の取得・設定を行うクラスです。
具体的にはimportしたクラスにどんなdeclarationsとimportsが必要になるのか依存関係のメタデータを付与します。

core/dep-manager.ts
import { NgModule } from '@angular/core';
import 'reflect-metadata';

export class DepManager {
  static requiredProps = ['declarations', 'imports'];
  static setDeps(component: Object, deps: NgModule): void {
    Reflect.defineMetadata('deps', deps, component);
  }

  static getDeps(component: Object): NgModule {
    let deps = Reflect.getMetadata('deps', component);
    deps = deps || {};
    for (const prop of DepManager.requiredProps) {
      if (!deps.hasOwnProperty(prop)) {
        deps[prop] = new Array();
      }
    }
    return deps;
  }
}

Class Decorator「Dep」を作成

任意の場所にClass Decoratorとなる「Dep」クラスを作成します。
僕達のプロジェクトではapp/core/decoratorsの下に置いていますが、どこでも構いません。

core/decorators/dep.ts
import { NgModule } from '@angular/core';
import { DepManager } from '@core/dep-manager';

export function Dep(deps: NgModule) {
  return (target): void => {
    const mergedDeps = Object.assign({}, deps);
    if (!deps.hasOwnProperty('declarations')) {
      DepManager.setDeps(target, deps);
      return;
    }

    // Merge from child component's dependencies
    for (const childComponent of deps.declarations) {
      const childDeps = DepManager.getDeps(childComponent);
      if (!childDeps) {
        continue;
      }
      for (const k of Object.keys(childDeps)) {
        if (!mergedDeps.hasOwnProperty(k)) {
          mergedDeps[k] = new Array();
        }
        mergedDeps[k] = [...mergedDeps[k], ...childDeps[k]];
      }
    }

    // Remove duplicates
    for (const k of Object.keys(mergedDeps)) {
      mergedDeps[k] = Array.from(new Set(mergedDeps[k]));
    }

    DepManager.setDeps(target, mergedDeps);
  };
}

コードを見ると分かる様にMerge from child component's dependenciesの辺りで子コンポーネントに必要な依存関係を親コンポーネントの依存関係にどんどんマージし、最後にはDepManager経由でComponentのメタデータとして全ての依存関係を付与します。
こうすることによって特定のコンポーネントを利用するのに必要な依存関係が全て管理出来る様になっています。

Dep Decoratorを使ってみる

各コンポーネントでDep Decoratorを使ってみましょう。
例として上記で挙げたLoginFormComponentの依存関係を子コンポーネントと併せて定義します。

login-form.component.ts
@Dep({
  declarations: [
    PasswordFieldComponent,
    EmailFieldComponent,
  ]
})
@Component({
  ...
})
export class LoginFormComponent
email-field.component.ts
@Dep({
  declarations: [InputFieldComponent]
})
@Component({
  ...
})
export class EmailFieldComponent extends InputFieldComponent
password-field.component.ts
@Dep({
  declarations: [InputFieldComponent]
})
@Component({
  ...
})
export class PasswordFieldComponent extends InputFieldComponent
input-field.component.ts
@Dep({
  imports: [MatInputModule]
})
@Component({
  ...
})
export class InputFieldComponent

一通り定義しましたね。ではいよいよNgModuleに登録します。
上記で一度記載しましたがLoginFormComponent利用に必要なNgModuleの登録はこうでした。

// Before
@NgModule({
  imports: [MatInputModule],
  declarations: [
    LoginFormComponent,
    EmailFieldComponent,
    PasswordFieldComponent,
    InputFieldComponent
  ]
})

しかしDepを使った場合はこのようになります

// After
const LoginFormComponentDep = DepManager.getDeps(LoginFormComponent);

@NgModule({
  imports: [...LoginFormComponentDep.imports],
  declarations: [
    LoginFormComponent,
    ...LoginFormComponentDep.declarations
  ]
})

どうでしょうか? NgModule登録の際は必要な依存関係をいちいち把握する必要がありません。
既にコンポーネントのメタデータに必要な依存関係の情報が付与されているからです。
ここでやることはコンポーネントをDepManagerに渡して取得した依存関係をNgModuleに登録するだけ!
以上!

この仕組みはAtomic Designのような複数のコンポーネント階層から構成されていたり細かくコンポーネントを分割するアプリケーションほど効果を発揮します。

最後に

Depはつい数日前プロジェクトに導入したばかりなので、これから様子を見て必要があれば改善していくつもりです。
ただAngularでリニューアルを進めていって後で導入する大変さを考えると、まだ実装した機能が少ない今の時点で依存関係の管理を楽に出来たのはやって良かったなと思いました。

皆さんのAngularアプリケーション作成もこの仕組みで少しでも楽になれば幸いです。

endam
東京で働くフルスタックエンジニア
admin-guild
「Webサービスの運営に必要なあらゆる知見」を共有できる場として作られた、運営者のためのコミュニティです。
https://admin-guild.slack.com
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