業務で半年ぐらいionic v3 でアプリを作ったので、その中で感じたアンチパターンとか、失敗したなーと思ったことをつらつら書いていきたいと思います。
ちなみに現時点でのコード量は8~9Kステップぐらい。所謂在庫管理アプリみたいなやつで、
バックエンドのRest APIサーバーと通信しながら、スマホやタブレットで備品管理できるよーって感じのアプリです。
アプリとしてはそこまで大規模でもないかなと思います。
失敗その1 データモデルをView層とModel層で分けなかった
アプリがそれなりの規模になってくると、独自のデータクラスを作りたくなってくると思います。ionicの場合は/src/interfaces
ディレクトリを作ってそこに型を定義していくやり方が一般的ではないでしょうか。このとき、どれ位細かく型を分けるかでいくつかやり方があると思います。
私達のチームで検討したのは以下の3パターンです。
①独自定義したデータクラスを一つのファイルで管理する
図にすると以下のような感じですね。アプリが小規模であるうちはこれで問題ないです。
②View層とModel層で分ける
View部分とModel部分で別々のinterfaceクラスを用意して管理するやり方です。View側には実際のページレンダリング時に扱いたい型を定義し、Model側には、主にバックエンドのRestAPIから返ってくるjsonの型を定義します
③Pageごとに独立して管理する
更に細かく分けて、Pageごとにデータクラスを定義していくやり方です。
また、Page間で共通で使うデータクラスを管理するためにview_interfaces
も残しています。
最初は①のパターンで実装していたのですが、ページが複雑になったり、扱うデータが増えたりしていくと、似たような名前のクラスが大量に生成されるようになってしまって収集がつかなくなったのでファイルを分けようということになり、最終的に③の形に落ち着きました。(正確にはComponentごとの型定義も行っていたりするので、もう少し細かいですが)
View層で扱うデータモデルとModel層で扱うデータモデルはそもそもレイヤーが違うので、小規模なプロジェクトであっても②のパターンぐらいには分けておいた方がコードの見通しがよくなります。
あと、View層のデータモデル作るときはModel層のデータモデルを合成した型にしとくと、View⇔Model間やView⇔View間の型変換がやりやすくておすすめです。変に継承したりすると、複数の型を継承したくなったり、逆に元の型に戻したくなったときにめんどくさくなることが多いです。「継承よりコンポジションを選ぶ」と偉い人も言っていました。
// Model層で定義するデータモデル
export interface UserModel {
id: string
name: string
}
// View層で定義するデータモデルその1
export interface UserWithMsg {
user: UserModel // Model層のデータモデル
msg: string
}
// View層で定義するデータモデルその2
export interface UserWithFriends {
user: UserModel // Model層のデータモデル
friends: UserModel[] // Model層のデータモデル
}
(ちょっと例が微妙ですが・・・)
失敗その2 子コンポーネントからProviderを呼び出した
開発初期の頃、「それ単体で動作が完結するコンポーネントを作れば、Page作る時楽じゃね?」という発想で、ステートフルで副作用起こしまくりなコンポーネントを量産したときがありました。
例えば「送信ボタンコンポーネント」があったとして、その「送信ボタンコンポーネント」内に、バリデーションからAPIの発行、返り値の取得まで一通りの処理を記述してしまうというやり方です。
親コンポーネント側は何も考えず「送信ボタンコンポーネント」を配置するだけで良いので、作ったばかりの頃は「内部の動作がうまく隠蔽されてていいじゃん」と思っていました。
実際うまく動けばとても良いアイデアだと今でも思うのですが、ionicでうまく動かすのはとても大変でした。
まず、子コンポーネントから直接Providerで管理しているグローバル変数(ここではアプリ全体で共有している変数ぐらいの意味で言ってます。例えばログインユーザーIDとか)を書き換えてしまうと、親コンポーネントや隣りにある従兄弟コンポーネントに状態の変更をうまく伝えることができません。
Providerの状態変化に合わせてViewに状態変化を伝える仕組みがAngulerには存在しないからです。なので、結局Provierの状態変更プロセスとは別に親や従兄弟コンポーネントの状態変更を行うプロセスを自分で作らなければいけなくなります。
そうなると結局、@input
と@Output
を使ってデータバインディングすることになり、であれば、状態変化を伴うような処理は、全てPage
に集めてしまって、各ComponentはViewの描画処理だけに集中する。
というやり方のほうが、うまくいきました。
ただ、やはり一つのPageで管理する情報量が多くなってくると、状態管理もコンポーネントごとに分けたいという考えも出てきていて、そうなってくると、Storeの状態変更に応じてViewを適切に(再)レンダリングしてくれるFluxモデルみたいなのが必要になってくるんじゃないかと思います。
このプロジェクトではまだ導入していないですが、AngularでもAkitaとかReduxとかFluxライクなStore管理ができるライブラリはいろいろあるみたいなので、少し触ってみようかなと思っています。
結局どんなフレームワークを使おうが、SPA開発で一番難しいのは状態管理ってことですね
失敗その3 オブジェクトを@Input
した
子コンポーネントにオブジェクトとか配列とかを渡したいときって結構あると思うんですが、@Input
で渡すと参照渡しになるっぽくて結構はまりました。
雑に言えば、@Input
しか書いてないのに双方向バインディングされてるっぽく動く・・・みたいな話です。子コンポーネント側で@Input
で受け取った値を変更すると親コンポーネントの値も変更されてしまいます。(詳しい話はこっちに書いてます。)
また、ライフサイクルメソッドの一つであるngOnChanges()
は、「オブジェクトの参照が変更されたときにしか発火せず、中身の値が変更されても発火しない」という特性があり、オブジェクトをバインドしてしまうと、バインドしたオブジェクト自体を再代入したりしないとngOnChanges()
が発火しなくなってしまいます。
一つ目の双方向バインディングっぽい挙動を示してしまう問題は、ngOnChages()
やsetter
の中で引数のDeepCopyを取ってしまうことでとりあえず解決しました。
ただ、このやり方は実行コストが高くなってしまうので頻繁に再描画が入るようなコンポーネントの場合は、あまり使いたくない方法です。
そういう時は、プリミティブ型のデータをバインディングしても問題なく書けるレベルまでコンポーネントの粒度を細かくすることで対応しました。
後は、そもそも値を変更するような処理は、component
側ではなくPage
側に書く。というやり方でも対応できます。@Output
を使って子コンポーネント側のイベントを拾いあげて、Page側で値を変更する。というようなフローにすれば問題は起こりません。
このやり方は、失敗その2で紹介した、「状態変化を伴うような処理をすべてPage
側に集める」というアプローチともマッチしていていい感じでした
ngOnChanges()
が一度しか発火しない問題の方は、そもそもngOnChanges()
使うようなコンポーネントを書かないというアプローチで問題を軽減することができました。
実際、「状態変化を伴うような処理をすべてPage
側に集める」ようにしておけば、ngOnChanges()
を使う機会はそんなに多くないです。
以上、私の半年の開発経験の中で失敗したな~と感じたことをつらつらと書いてみました。正直まだベストプラクティスは見つけられていないので、もっといい方法あるよ~って人がいれば教えてください(特にデータバインディング周り)。