LoginSignup
39
47

More than 5 years have passed since last update.

アダプティブなGUIコンポーネントの考察

Posted at

よくあるユースケースである、ヘッダーのハンバーガーメニューとドロワー。どうやって実装するのが良いでしょうか?

よくある実装としては、メニューとドロワーが置いてあるところの親のコンポーネントを置いて、ヘッダーのボタンが押されたコールバックを受け取り、ドロワーの表示フラグをオンにする、みたいな実装かと思います。明示的に関係を記述する、という感じ。

ですが、Angularではおそらく別の回答になります。Angularには暗黙的なインタラクションを可能にするAPIの数々があって、実際それを活用したコンポーネントなどもあるので、React/Vue/Mithrilとはちょっと違う世界が見える気がしたので、それを紹介します。

最近の主流のフロントエンドの世界観

まずは、Angularの話の前に、このエントリーの前提となる認識を合わせておきましょう。

最近は、フロントエンド界隈では、まずコンポーネントという形式(実態は各フレームワークによって異なる)で、HTMLの独自タグのようなものを作り、それでHTMLページを組み立てるようにしてウェブサイトを作っていきます。

Reactではrenderの中でstate変更をすると警告が出ます。Mithrilは一般的なobserverパターンではない少々勝手が違いますが、変更を明示的に伝えるredraw()メソッドは画面表示を含むライフサイクルメソッドで呼び出してはいけない、とされています。

また、関数型のエッセンスを取り入れたコーディングが流行りです。画面表示(ビューの組み立て)のプロセスではデータに変更を加えてはいけません。immutableで、表示操作自体ではデータを変更する副作用を起こさないことが推奨されます。コンポーネントもクラスで実装するものが多いのですが、状態を持たない場合は、ReactはStateless Function Componentという、関数でコンポーネントを表現できます。親や、Reduxのようなデータストアからデータを参照だけして表示に使いましょう、みたいな。参照だけであれば管理は不要ですからね。

状態を変更する部分を絞り、なおかつ管理する状態をどんどん減らしていこうぜ、という世界ですね。

アダプティブなコンポーネントとは

ここでは、明示的に対象を設定しないで、自分が操作したい対象の部品を見つけて何かしらのアクションを行う、みたいな意味で書いています。

DOMとかUI部品は一般に、木構造で部品が並べられています。それに対して、コンポーネントが次のことをすることをアダプティブと読んでみます。

  • 親コンポーネントが、自分と関連する子供を探しに行って(具体的にどこにあるかは知らない)アクセスする
  • 子供コンポーネントが、が関連する親(何番目の親かは知らない)を探しに行ってアクセスする

アクセスというのは情報の取得やら、イベントの接続などです。関数型が流行りの今時のウェブとは異なり、状態を持って行います。

他のフレームワークでよく見かけるのは、イベントなどの動きは何かしらのコードが必要でしょう。Reactでstateでオープン状態のフラグを管理して、あるいはfluxなどの仕組みを使って状態を外に持って制御する方法ですね。

Screen Shot 2018-10-25 at 11.47.43.png

僕の考えるアダプティブなコンポーネントの世界観のイメージはこんな感じです。接続のためのコードを書かないでも、タグをならべるだけでインタラクションします。

Screen Shot 2018-10-25 at 11.47.47.png

仮想DOMではないHTMLの生のDOMではいろいろなところで、この手のタグ間の相互インタラクションが利用されています。labelタグをクリックすると、その下にあるinput[checkbox]が選択されるとか、input[radio]で選択時に他の同じnameのラジオボタンを全部選択外すとか、formのリセットボタンとか、タブでフォーカス移動とか。

Angular Materialで見かける例

Angularチームが公式で提供している、Material DesignのUI部品であるAngular Materialでは、子供から親へのアクセスというのがいくつかありました。

たとえば、ダイアログ内部に置いたボタンに、mat-dialog-closeというディレクティブを設定すると、ほかに何もコードを書かなくても、ボタンがおかれている親を探しに行ってクローズします。

<button mat-dialog-close>CLOSE</button>

それ以外にもサイドーバーやドロワーのコードでも使われています。使う側では、

<mat-drawer-container>
  <mat-drawer>ドロワーの中身</mat-drawer>
  <mat-drawer-content>本文側の中身</mat-drawer-content>
</mat-drawer-container>

という風にタグを並べます。

サイドバー・ドロワーのコードでは、以下のようになっています。mat-drawer-contentというタグをmat-drawer-containerというタグの中に書きますが、この子供側のコードでは、コンストラクタで@Injectデコレータをつけたコンストラクタ引数で、親を取得してきて、マージン変更イベントをsubscribeしています。

src/lib/sidenav/drawer.ts
//いくつか説明に関係ないところはシンプルにしています。
export class MatDrawerContent implements AfterContentInit {
  constructor(
      private _changeDetectorRef: ChangeDetectorRef,
      @Inject(forwardRef(() => MatDrawerContainer)) public _container: MatDrawerContainer) {
  }

  ngAfterContentInit() {
    this._container._contentMarginChanges.subscribe(() => {
      this._changeDetectorRef.markForCheck();
    });
  }
}

逆に子供を探索する例もあります。Angularではng-contentというのが、Reactでいうprops.children、Mithrilで言う所のvnode.childrenにあたります。で、こいつはいくつも書けるついでに、selectorで特定の要素だけを抽出して表示というのができます。さっきのmat-drawer-contentの親にあたる、mat-drawer-containerのテンプレートでは、子供の中の要素を並び替えて表示しています。

src/lib/sidenav/drawer-container.html
<ng-content select="mat-drawer"></ng-content>
<ng-content select="mat-drawer-content">
</ng-content>
<mat-drawer-content *ngIf="!_content">
  <ng-content></ng-content>
</mat-drawer-content>

このmat-drawer-contentを省略しても同じ動作になるようにしているというコードです。子供を参照するのはテンプレート内だけではなくて、コンポーネントのコードでも属性名に@ContentChildデコレータをつけておくとAngularのランタイムがどこからともなく探してインスタンスの初期化後に代入しておいてくれます。

@ContentChild(SomeComponent)
child: SomeComponent;
  • @Inject: 親コンポーネント、もしくはサービスなどを探してくる
  • @ViewChild, @ViewChildren: コンポーネントのテンプレート内の要素を取得してくる
  • @ContentChild, @ContentChildren: コンポーネント利用側で設定された子要素を取得してくる

もし、親クラスをインタフェースで指定したいなどの場合は次のエントリーが役に立つかもしれません。

アダプティブなコンポーネントを実装してみる

では、ちょっと実装してみます。Angular Materialのドロワーをラップして、タグを並べるだけでヘッダーのメニューのボタン操作とイベントを連動させててみます。

app.component.html(完成形)
<app-container>
  <app-header>ヘッダー</app-header>
  <mat-drawer>ドロワーの中身</mat-drawer>
  :
  (ここはmain部分のコンテンツ)
  :
</app-container>

ヘッダーの実装

まずはヘッダーの実装です。ボタンをおいて、押されたらイベントを発信するようにします。このヘッダーはドロワーが兄弟に存在する時だけ(Subjectのリスナーが存在する時だけ)ハンバーガーメニューを表示するようにしています。

app-header.component.html
<mat-toolbar class="toolbar">
  <button mat-icon-button (click)="onClickMenu()" *ngIf="hasDrawer">
    <mat-icon>menu</mat-icon>
  </button>
  <div>
    <ng-content></ng-content>
  </div>
</mat-toolbar>
header.component.ts
import { Component, OnInit, Output } from '@angular/core';
import { Subject } from 'rxjs';

@Component({
  selector: 'app-header',
  templateUrl: './header.component.html',
  styleUrls: ['./header.component.scss']
})
export class HeaderComponent {
   public openDrawer$ = new Subject<void>();
   get hasDrawer(): boolean {
     return this.openDrawer$.observers.length > 0;
   }
   onClickMenu() {
    this.openDrawer.emit();
  }
}

最初はEventEmitterを使って実装したけど、テンプレートのHTMLで設定するイベントハンドラ以外の、プログラマブルにsubscribeするやつには使ってはいけないらしいので、rxjsのSubjectを使っています。

コンテナの実装

次に、コンテナのラッパーを作ります。タグの存在を確認していろいろやるアダプティブなロジックはこちらに実装されています。子要素の中から、mat-drawerと先ほどのヘッダーコンポーネントだけ取り出して、順序を入れ替えて特別な構造で表示しています。

container.component.html
<mat-drawer-container>
  <ng-content select="mat-drawer"></ng-content>
  <mat-drawer-content>
    <ng-content select="app-header"></ng-content>
    <ng-content></ng-content>
  </mat-drawer-content>
</mat-drawer-container>

コンポーネントの方は、子要素の中からヘッダーとドロワーを取り出してきて、両方が存在するときは、ヘッダーのクリックイベントと、ドロワーのオープンを結びつけています。

container.component.ts
import {
  Component,
  ContentChild,
  OnDestroy,
  OnInit,
} from '@angular/core';
import { MatDrawer } from '@angular/material/sidenav';
import { Subscription } from 'rxjs';
import { HeaderComponent } from '../header/header.component';

@Component({
  selector: 'app-container',
  templateUrl: './container.component.html',
  styleUrls: ['./container.component.scss']
})
export class ContainerComponent implements OnInit, OnDestroy {
  @ContentChild(HeaderComponent) header: HeaderComponent;
  @ContentChild(MatDrawer) drawer: MatDrawer;

  drawerOpenSubscription: Subscription;

  ngOnInit() {
    if (this.header && this.drawer) {
      this.drawerOpenSubscription = this.header.openDrawer$.subscribe(() => {
        this.drawer.open();
      });
    }
  }

  ngOnDestroy() {
    if (this.drawerOpenSubscription) {
      this.drawerOpenSubscription.unsubscribe();
    }
  }
}

これでおしまいです。@ContentChildのクラス名指定、ng-contentのセレクターが強力ですね。こちらでは子供要素方向のアクセスだけを使いました。@ContentChildrenで複数の要素にアクセスもできます。

Angular以外のフレームワークでの実装を考える

基本的には、親とか子にアクセスできればこのようなコンポーネントの実装は難しくないと思うので、そこにフォーカスして見ていきます。

React

Reactでは親方向へのアクセスは一応可能ですが、非公開API経由でかなり黒魔術です。

ここによると、Ver.16では次のようにして親にアクセスできるとのことです。公開APIはありません。

const parent = this._reactInternalInstance._currentElement._owner._instance;

子供方向へのアクセスはReact.Childrenというヘルパー機能があります。これでprops.childrenへのアクセスが少し楽になります。この要素はすでにコンポーネントのインスタンスです。propsでもなんでもアクセスできます。また、type属性にクラスが入っているので、Angularのセレクターは、属性の条件でフィルタリングもできるので、これを完璧に実装するのは難しいですが、クラスでフィルタリングぐらいは難しくありません。ただ、孫とか子孫を完全に探索するのは少々大変でしょう。

結論としては

  • 親へのアクセスは非公開API経由で
  • 子供へのアクセスは比較的簡単で、クラスでのフィルタリングは可能。それよりも下の子孫へのアクセスは手間がかかる

Mithril

まず、親へのアクセス方法が提供されていません。親から子供にコールバック関数を渡して、子供から親を呼び出してもらうしかないようです。

子供方向のアクセスの場合、VNodeの結果のオブジェクトの構造はドキュメント化されているためいくつかの属性へのアクセスは可能です。クラス形式のコンポーネントの場合、tag属性がクラス、state属性がインスタンスです。instance属性はviewが返した結果ですので、instanceではないです。

なお、childrenの1番目はタイトル、2番目は本文、のような使い方はアンチパターンとされていますが、ng-contentのセレクターのように、タグ名を見てフィルタリングとかはまだ許容範囲な気がします。

結論としては

  • 親へアクセスするのはできない
  • 子供へのアクセスは比較的簡単で、クラスでのフィルタリングは可能。それよりも下の子孫へのアクセスは手間がかかる

Vue.js

特別な問題に対処するのページでさまざまなAPIが紹介されています。ただし、どれもなるべくなら使わないように、とされています。

親は$parentでアクセスできますが、依存性注入で親の属性やメソッドを子供に提供できます。

refで子供にアクセスする方法は提供されていますが、参照IDの範囲ってどんな感じなんでしょうかね?基本的に自分のコポーネントの内部用な気がするため、Angularでいうところの、@ContextChildではなく、@ViewChild相当かと思います。

結論としては

  • 親へのアクセスは可能だし、依存性注入で親の属性やメソッドを子供に渡せる
  • 子供へのアクセスでは範囲の制約が厳しそう

アダプティブなコンポーネントはAngular以外でこの先生きのこるのか?

Angularでは親方向にも、子供方向にも強力なアクセス機能が提供されており、気軽に自働するコンポーネントが作れます。一方、React/Mithril/Vueでは、片方向のアクセスだったり、非推奨だったり、undocumentedないつ変更されるかわからない機能を使わざるを得なかったり、子供方向のアクセスでも、子孫への探索はループで探し回る必要があったりと制約がかなりあります。そのため、アダプティブな実装を許容するかどうかは、Angular以外の人と、Angularでカルチャーギャップを生みうる思想の違いのように見えます。

この方法にはデメリットがあります。それは各フレームワークのドキュメントで説明されているように、デバッグが難しくなる点にあります。特に、アダプターが正しく動作しなかった場合に、何が悪かったのかを見つけるのは難しいです。CSSで名前を少し間違っていたせいで、うまくCSSが当たらない、というのと同じです。また、そもそもそのようなコーディングを想定してないため、Angular以外のフレームワークでは未定義の動作と戦ったりすることになるかもしれません。宣言的な実装にありがちなデバッグの難しさが導入される恐れがあるということです。Immutableいいよ!という流行も、状態を持たなければデバッグとかテスト楽になるよ!というところにあるので、そちらに背を向けるからにはデバッグやテストが多少大変になりえます。

しかし、それを上回るメリットもあります。ライブラリの作成側で少しがんばることで、クライアント側のコードが短くなります。細かいつなぎこみの調整のコードが減ったり無くなったりします。うまーくカプセル化すると、とても賢いライブラリのように見えるでしょう。たとえば、C++のように自分できちんとdeleteしなければならない言語だと、あとから削除しないといけないために、参照をきちんと持たせておいて、順番ワをまもって削除するというコードを残しておかないといけませんでした。つなぎこみを省略できるという世界は、というのはガベージコレクタを使って、メモリ削除のためのコード行数を節約できる、という世界と似ています。

デバッグのしにくさや挙動の複雑さを減らすためには、乱用はもちろんさけるべきだと思います(乱用していい技術なんてないですが)。直接の子供とのみインタラクションする、といった制約を加えるのはありだと思います。Angularの@ContextChild/@ContextChildren{descendants: true}オプションをつけなければ直接の子供のみの参照となります。直接の子供とのみのインタラクションであれば、他のフレームワークでも実現は可能でしょう。できるからといって、階層をむやみやたらにトラバースして、親以外の別のツリー(親の兄弟)にアクセスしたりとかはもってのほかでしょう。

最近ではほとんど絶滅しているように見えますが、今後またビジュアルなGUIレイアウトツールとかが出てきたとすると、ロジックで記述する部分が減る方が、GUIで完結する範囲が広がるため、ツールとしての使い勝手はよくなっていくことが期待されます。将来、GUIの設計ツールを作るとしたら、作りやすいのはAngularだろうなって思います。また、ノードベース言語なんかも、レキシカルスコープで変数参照を済ますことができずに、参照を個別に明示する必要があるため、コード量削減は強力な武器になりえます。

もちろん、Angularでは現在進行形でこういうコードが出てくるので、コードを読むときには理解しておく方がもちろん良いですし、他のフレームワークを使っている人も、良いかもしれません。狭義の依存性注入と言えるし、マイクロサービスのような分野とかでも急に流行ったりするかもしれませんし。

39
47
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
39
47