序
師走ですね。年の瀬が近づいてくると、酔っ払った元社員に絡まれることが稀によくあります。
私は jQuery から Vue.js への置き換えで何をやらかしたのか - Qiita
可能ならいきなりフロントエンドのライブラリを導入するよりも jQuery のみで MVVM パターンへ移行したほうがよかったかなぁと今になると思います。 結局のところ、jQuery で苦しんでいたのは、複雑な「状態」が表示やイベントハンドル系のコードとごっちゃになっていたから です。
うん、分かる。当時、この取組みを「大変そうだなー」と思いながら横で眺めていました。
まさか、続きを自分でやることになるとは夢にも思っていませんでしたが(。◉ᆺ◉)
ごあいさつ
どうも、 @cesare と申します。
クラウドワークスでサービスの開発や運用を手がける傍ら、たまに機械学習とか VR とかに手を出して遊んでいます。
このエントリーはクラウドワークス Advent Calendar 2018 の 14 日目の記事です。
ちなみに昨日は @sawadashota さんが OpenID Connectの始め方 - Qiita というエントリーを書いていますので、良ければそちらも合わせてどうぞ。
今年は ex-crowdworks Advent Calendar 2018 という、クラウドワークスを退職した元社員の人たちが書いているアドベントカレンダーもありまして。冒頭で紹介した @ayasuda さんの記事もその一つでした。て言うかお前らどんだけクラウドワークスのこと好きやねん。
で、せっかく @ayasuda さんが前振りを務めてくれたので1、冒頭で紹介したエントリーで言及されている正にその、弊社サービスが抱える超複雑な入力フォームを持つ UI を、フレームワークなどの飛び道具を用いることなく再編した顛末をご紹介します。
とても長い話になりますので、お手元に や などをご用意いただけると良いかなと。
なぜリファクタリングすることになったのか
問題の画面は、以下でも紹介するようにカオスを極めており、過去に @ayasuda さんも含めて数々の猛者が改善に挑戦するも悉く返り討ちにされ、ここ数年は禁忌の地として誰も近寄ろうとしない場所になっていました。
しかし、お客様に依頼を出していただいてクラウドワーカーさんが解決する場を提供しているサービスである以上、依頼を投稿するという機能はサービスの根幹に関わる箇所であり、依頼入力画面はサービスの心臓部と言える存在です。
依頼画面を改善してより良いユーザー体験を提供できるようになれば、サービス全体の活性化も期待できるでしょうし、より質の高い依頼が多く投稿されるような施策も考えられるようになるかもしれません。逆に、メンテナンスもできないまま放置することはすなわちサービスの衰退にも繋がりかねない危険を孕んでいると言えます。
よって、この画面のカオスに秩序を取り戻し、様々な施策を打てるように整理するのはいつか誰かがやらないといけないミッションでした。その**「いつか」は今年の春に訪れました。そして「誰か」**の方は、より良い依頼投稿のユーザー体験を提供することを目標に掲げた我々のチームに回ってきたという次第です。
そもそも依頼入力画面とは
クラウドワークスのアカウントをお持ちの方は
https://crowdworks.jp/job_offers/new
で実物を見ることができます。
入力項目は大きく分けて6つのステップに分かれています。例えばステップ1では案件が属するカテゴリーを選択する UI があり、ステップ2では依頼の形式 (コンペとかタスクとか、一般的なプロジェクトとか) を選択する UI があり、ステップ3では依頼のタイトルや詳細を入力する UI がまとめられており・・・(以下省略) といった塩梅です。
画面を見られる方は、カテゴリーの選択肢から何か選択するとステップ2以降の項目が全て見えるようになりますので、試してみてください。
たとえば「システム開発」を選ぶとステップ3の入力項目は
- 依頼タイトル
- 依頼詳細
- 求めるスキル
- 添付ファイル
- 特記事項
の5つが表示されます。
ここでカテゴリーの選択を、大カテゴリーを「デザイン」少カテゴリーを「ロゴ作成」に変えると、ステップ3の入力項目は
- 依頼タイトル
- 依頼詳細
- ロゴ文字列
- ロゴイメージ
- 希望イメージ
- 希望する色
- 参考URL
- 利用用途
- 商標登録予定
- 納品ファイル
- 求めるスキル
- 添付ファイル
- 特記事項
と、変わります。
ロゴ作成に特化した項目が増えていることにお気づきかと思います。
このような、カテゴリー選択などに伴って出てきたり出てこなかったりする入力項目がこれ以外にもいろいろあります。ご興味のある方は、ステップ1のカテゴリー選択で「ランディング・記事作成」を選び、ステップ2の依頼形式選択で「タスク形式」を選んでみてください。興味深いものが出てくると思います。
実は、リファクタリング以前のかつての画面では、これらの「画面に出てくる可能性がある入力項目」のほとんど全てがサーバーサイドのレンダリング時に描画されていました。つまり、DOM 上は全ての入力要素が存在していて、それらの表示を on/off しているだけという造りになっていたわけです。
そして、見た目だけでも複雑なこの画面の裏側は、主に jQuery を活用したイベントハンドリングと DOM 操作によって支えられていました。コードの行数は、コア部分だけでも数千行2。
・・・そろそろ嫌な予感がしてきましたね?w
改変前の残念だったところ
縦横無尽に張り巡らされたイベントハンドラー群
jQuery で作られたアプリケーションにありがちですが、基本的な骨格はイベントハンドラーで構成されています。あるボタンが押されたらこの処理が発動する、みたいなやつですね。敢えてコード例を出すまでもないかもしれませんが、こんな感じのやつです。
$("#submit-button").click(function(event) {
// ボタンを押された時に発動させたい処理をここに書く
});
上に出したような「送信ボタンが押されたらこの処理を行う」ぐらいのシンプルなものが一つだけ存在しているのであれば問題にはならないのですが、数が増えると複雑度が増します。例を挙げてみると、
- ラジオボタンの選択が変更されたら、画面に出ている入力項目のうち、いくつかを表示したり、あるいは非表示に変更したりする
- ボタンが押されると、入力項目を一つ生成して画面に差し込む
- ラジオボタンの選択が変更されたら、関連する項目のスタイルを変更してハイライトされるように変える (その一方で選択解除された方に関連していた項目のスタイルを通常に戻す)
などなど。単純に数が多くなるということもありますが、一つのイベントから複数の操作が発生するということも起きるようになります。
上の例だと、ラジオボタンの選択が変更されたら、
- ある場所では枠が出たり消えたりして、
- 別の場所ではスタイルが変更されてハイライト表示に変わったりする
というように2つの変化が起きるようになっていて、一箇所で起きたイベントをきっかけに様々な箇所に影響が起きるという状態になります。
イベントハンドラー連鎖
とは言え、イベントハンドラーの処理が画面の見た目を調整するぐらいにとどまっていれば、そこまで複雑なことにはならないでしょう。ラジオボタンの選択が変更されたらこことここに影響がある、ということを把握できていれば大丈夫。
しかし、イベントハンドラーの処理で別のイベントを発生させるということをやり始めると、複雑性が加速的に増大します。たとえばこんな感じ。
$("#radio-button").change(function(event) {
if (ある条件) {
$("#checkbox").click();
}
// それ以外の処理が続く
// ...
});
$("#checkbox").click(function() {
// 何かの処理
});
この例だと、
- ラジオボタンの選択が変更されたときのイベントハンドラー
- チェックボックスがクリックされたときのイベントハンドラー
の2つが存在していて、それぞれユーザーが操作を行ったときの処理を記述してあるわけですが、前者のラジオボタンのイベントハンドリング処理の中で後者のチェックボックスをクリックされた時と同じ処理が走るようにしたいという場合に、単にそのチェックボックスがクリックされたイベントを発生させてやることで済ませています。
一見して、このような構成は手軽に書けて便利そうに思えるのですが、その便利さの陰に複雑性を生み出しています。その複雑性とは、ラジオボタンの選択を変更すると、別の場所にあるチェックボックスがクリックされた時に起きるべき変化が発動するということで、一般化すると
- ある場所で起きたイベントが別のイベントを発火させる
ということが起きています。これはすなわち、
- ある場所で起きたイベントが別のイベントを発火させる
- その別の箇所で発火したイベントが、さらに別の場所のイベントを起こす
というように数珠つなぎになっていく可能性があります。
さらに、あるイベントハンドラーの影響する先が複数ある可能性も考慮に入れると、
- ある場所Aで起きたイベントが別の箇所BとCにそれぞれイベントを発火させる
- その別の箇所Bで発火したイベントが、さらに別の場所D, Eでイベントを起こす
- その別の箇所Cで発火したイベントが、さらに別の場所F, G, Hでイベントを起こす
のようにツリー状に際限なく広がっていく可能性を秘めています。
さながら「北京で蝶が羽ばたくとテキサスで竜巻が起こり、ペルー沿岸がエルニーニョに見舞われ、東京で桶屋が儲かる」ぐらいのカオスな状況が出現します。
依頼入力画面のカオスぶり
クラウドワークスの依頼入力画面が、まさにこのような状態に陥っていました。(以下、リファクタリング前の状況を説明しています)
たとえば画面を開いたばかりの初期状態では、まずカテゴリーを選択できるだけの表示になっていますが、
ここで大カテゴリー「デザイン」を選んでみます。
このイベントは、
- 右側に小カテゴリー枠を表示し、
- その中に表示されているカテゴリーの中からデフォルトのものを選んでクリックするアクションをトリガーする
ということを行っていました。大カテゴリーで「デザイン」を選んだ場合のデフォルトの小カテゴリーは「ロゴ作成」ということになっているので、対応する input:radio
要素をクリックするような状態だと理解していただければ。
次に、この小カテゴリーを選択してクリックするというイベントが、
- ステップ2にある依頼形式のボタンのうち、選択可能なものを表示する
- さらに、デフォルトの依頼形式のボタンをクリックするアクションをトリガーする
という動作を行います。
ここまでをまとめると、
- 大カテゴリーから「デザイン」を選ぶ (これはユーザーの操作)
- 小カテゴリー枠を出し、デフォルトのカテゴリーをクリックして選択 (JS による処理)
- 小カテゴリーのクリックの副作用で依頼形式ボタンを表示し、(JS による処理)
- デフォルトの依頼形式ボタンをクリックして選択 (JS による処理)
という形で、大カテゴリー選択→少カテゴリーが自動的に選択され→依頼形式が自動的に選択されるというようにアクションが繋がっていきます。
さらに、話はここで終わりません。
ここまでの動作でカテゴリーと依頼形式が確定できたわけですが、この2つが決まったことによって別のイベントが発動します。簡単に言うとステップ3以降の入力項目が全て表示されて入力可能状態になるわけですが、裏側ではさまざまな処理が動いていて
- デフォルト状態で表示されていた、ステップ3以降を覆っていた半透明のレイヤーを取り除く
- ステップ3〜6の枠内を表示状態に変える
- ステップ3〜6の枠内に表示される入力項目のうち、いま選択されているカテゴリーと依頼形式のペアに必要なものだけを表示状態に変える
といった動きが起こります。
ひとまず初期状態から大カテゴリーを選択すると何が起きるのかを説明してみました。
ここからさらにカテゴリーや依頼形式を別のものに変更すると、またいろいろと処理が走ります。たとえば
- 選択されたカテゴリーでは必要がない入力項目を画面から消す
- 選択されたカテゴリーで必要になる入力項目のうち、まだ画面に出ていないものを表示する
- いくつかの入力項目は、変更前に設置されていた場所から別の場所へ移動させる
などの処理があります。
表示の on/off ぐらいであればまだ良かったのですが、この「要素を別の場所へ移動させる」のが曲者でした。移動先の決め方が「特定の id が付いているノードの下を置き換え」ではなくて「特定のクラスが振られているノードの後ろへ追加」みたいになっていると大変です。その「特定のクラスが振られているノード」が何かの理由で別の場所へ移動していたら、移動対象のノードもそれに釣られてすぐ下の位置に出てくることになります。またノードを移動する作業の順序も大事になってきます。順番を間違えたら入力項目が意図しない場所に現れたりするなどの怪奇現象が起こるようになります。実際、「この項目はなぜこんな場所に表示されているのであろうか?」と首をひねるような現象が起きることが知られていました。
我々が直面したのは、このようなカオスを抱えた巨大なコードに、どうにかして秩序を取り戻さなければいけないという試練でした。
カオスに秩序をもたらす試み
作戦
まず、「一から作り直す」という選択肢はありませんでした。作り直すには現状の依頼入力画面の正しい状態・振る舞いを把握できていることが前提になりますが、残念ながらこの前提を満たしていませんでした。そして、まさにこの「誰も何が正解なのか分かっていない」ことこそが、この問題の解決を困難にしている最大の要因だったのです。
また、「フレームワークを導入する」という選択肢もありません。フレームワークを導入することはすなわち、既存コードはほぼ全て捨てて「一から作り直す」のと変わらないと判断したためです。
したがって、我々の取りうる作戦の選択肢はあまり多くなく、ひとことで言うと「リファクタリングを頑張る」しかありませんでした。
既存コードの整理
まず、我々は既存コードを整理するところから始める必要がありました。対象のコードは、コア部分のファイルだけでも数千行というボリュームで、長年の機能追加やリファクタリングによって様々な機能が思い思いの場所に書いてあるという、大変に見通しの悪いものでした。まず、このコード群を一つずつ読み解き、整理していきます。
整理の方針としては、依頼入力画面の各ステップごとにコードをまとめていくようにしました。たとえば「このコードはステップ1に関連するので上の方にまとめる」というように、既存コードを移動させて、同じステップに属するものが隣接するように編集していきます。この過程で、「このコードはいったい何をしているのか」が理解できるので、その理解をコメントとして追記していくことも同時に行います3。
このようにして、カオスだったコード群の見通しが少し改善できました。もっとも、全てのコードがきれいにどこかのステップに分類できるとは限らず、全体を横断してさまざまな処理を行うようなものも存在するので、完全に整理できたというわけではないのですが。それでも、以前よりは見通しが良くなったことによって、次の段階へ進む準備はできました。
表示パターンの整理
コードの整理を行う傍らで、プロダクトオーナーやデザイナーも巻き込んで、画面の見た目レベルでの整理を行いました。依頼入力画面が見た目にも複雑になっている原因は、200以上も存在する仕事カテゴリーひとつひとつに対して項目の表示 on/off や表示位置などを細かく定義できる仕組みを構築していたことにありました。カテゴリーごとに細かくカスタマイズできるのは柔軟であるというメリットがある反面で、複雑性を生んでしまうというデメリットを抱えています。
チームで議論した結果、200以上あるカテゴリーを6つのグループに分類し、同じグループに属するカテゴリー同士は同じ見た目の依頼入力画面が表示されるということに決めました。具体的には、すべてのカテゴリーは
- デザインの仕事を依頼する画面
- デザインの中でもロゴ制作に特化した依頼をする画面
- web デザインの仕事を依頼する画面
- タスクの仕事を依頼する画面
- ライティングの仕事を依頼する画面
- デフォルトの画面
のどれかに分類されるイメージです。
かつ、それぞれの入力項目がどこに配置されるかを固定にしました。これによって、カテゴリー選択が変更されると入力項目がどこか別の場所へ移動されるということはなくなり、複雑さが減少するとともに、以前よりは画面の一貫性が出るようになりました。
ちなみに、同じチームに所属する @shiba_319 がプロダクトオーナーから見た回顧録 (?) を書いていますので、もし良ければそちらも合わせてどうぞ。
6万行の大規模リファクタリングを完遂する上でPOとしてやってよかった5つのこと - Qiita
画面の裏側の再設計
次の段階では、イベントハンドリングと DOM 操作を統制の取れた形に変えようということになりました。
この2つをカオスにしていた原因とは、
- ある DOM を監視しているイベントハンドラーがどこから仕掛けられてるか分かったもんじゃない
- ある DOM を操作しに来るコードがどこにいるか分かったもんじゃない
ということなのではないかとの仮説を持っていました。
以下、このイベントハンドラーを仕掛けるとDOMを操作するの2つをまとめて、DOM に干渉すると表現することにします。
この仮説が正しければ、ある DOM へ干渉できるモノは一つしか存在しないという形に持ち込めれば統制が取れそうです。
ということで、全体の設計を以下のようにしようと決めました。
- 画面中の限られた範囲の DOM 操作やイベントハンドリングに責務を持つクラスを設け、これを「ビュー (view)」と呼ぶことにする
- あるビューは自分の管轄下の DOM に排他的に責務を持つ
- 言い換えると、別のビューの管轄下には干渉しない
- あるビューは子のビューを持つことができる
- 子のビューも自らの責任範囲を持つが、その範囲は必ず親の責任範囲のサブセットになるようにする
- 親は子に任せた責任範囲には干渉しない (= 子に任せる)
具体的には、次のようになります。
- 画面全体を統括するビューが一つ存在する (以降、全体統括ビューと呼びます)
- 全体統括ビューは、ステップ1〜6にそれぞれ対応する子ビューを持つ
- 各ステップごとのビューは、自らの配下にある入力項目ごとの子ビューを持つ
あるビューが責任を持つ DOM の範囲は限定されているので、そのビューの子供たちの責任範囲は、自らの範囲から逸脱することはありませんし、責任範囲は排他的になるので、兄弟要素同士で範囲が重複することもありません。また、子ビューを持つということは、親は子に任せた範囲には干渉しないのが原則になります。
このようにして、全体統括ビューをルートにしてビューのツリー構造ができあがることがお分かりかと思います。
これ以降、ビューのツリー構造はルートが一番上にあって、子要素が下方向に繋がる形を想定して説明します。つまり上と言ったら親や先祖を指しています。下なら子孫です。
コードに起こす
ビュークラス
まず、ビューを表現するためのクラスを作ります。
ちなみに、依頼入力画面の JS は CoffeeScript で書かれているので4、このエントリーの説明コードもそれに倣います。
コードにするとこんな感じ。
class FormView
FormView というクラスを作りました。これを、全てのビューの基本となる抽象クラスと位置づけます。
全体統括ビューも一つのビューなので、このクラスを継承して
class RootFormView extends FormView
と表現できますし、その全体統括ビュー直下にある、各ステップごとのビュー群はこういう感じになります。
class Step1View extends FormView
class Step2View extends FormView
# 以下、ステップ6まで同様に
これらの子要素となるビューはこんな感じ。
# タイトル入力の枠を管轄するビュー
class TitleInputView extends FormView
# 依頼本文の入力枠を管轄するビュー
class DescriptionInputView extends FormView
# これ以外の入力項目についても同様に
管轄する DOM の範囲を規定する
FormView クラスは、自身が責任を持つ DOM の範囲を知っている必要があります。これは、対象 DOM のトップレベルにある要素を把握していれば良さそう。ということで、責任範囲のトップレベル要素をコンストラクタで渡すようにします。
class FormView
constructor: (@rootNode) ->
なぜ外から渡すようにしたかと言うと、この要素を決定するのは親であるということにしたいからです。親ビューが責任を持っている範囲の中から該当要素を確定し、その要素を使って子ビューを生成するという関係にします。
ちなみに、@rootNode
が指すのは jQuery オブジェクトです。たとえば全体統括ビューだと
rootNode = $("#form-root")
new RootFormView(rootNode)
のような形で生成していると考えてもらえれば。
さらに、この @rootNode
に基づいて、自らの責任範囲から任意の要素を探すメソッドを用意します。
class FormView
findNode: (pattern) ->
@rootNode.find(pattern)
たとえば、あるビューが配下に持っているラジオボタンにイベントハンドラーを仕掛ける場合はこんな感じに書けるようになります。
class CategorySelectionView extends FormView
registerEventHandler: ->
@findNode("input:radio").click (event) =>
# 以下略
そして
- FormView の実装は、配下の DOM 要素を探すときは必ずこのメソッドを介して行う
- findNode を使う場合も
parent
やsiblings
など上や横方向には検索しない
ということをコーディング規約で縛ります。
規約なので破ることは簡単にできてしまうのですが、findNode()
を使わずに検索しようとすると jQuery を使って
$("#some-other-node .foo")
みたいなコードが出てくることになるので目立ちますし、コードレビューで「これはなぜ findNode() を使っていないのですか?」という指摘がしやすくなります。
もっとも、どうしても管轄外の箇所に手を出さないといけないような例外的な事情というのは出てくるものですが、そういう場合でも、まず設計に問題がないかどうかを考えるということにしました。考えた上でやはりしょうがないね、という話になればコード中のコメントに事情を記しておきつつ容認するといったやり方にしました5。
ここまでで、それぞれのビューが勝手気ままに任意の DOM を操作するという心配をしなくて済むようになりました。
子ビューとの関係
あるビューは子を持つことができます。もう少し正確には、
- あるビューは別のビューを生成し、子として持っておくことができる
と言えます。
ということを以下のようなコードで実現しました。
class FormView
constructor: (@rootNode) ->
@subviews = {}
@setupSubviews()
registerSubview: (name, view) ->
@subviews[name] = view
setupSubviews: ->
# 派生クラスでこの中身を実装する
# 子となるビューを生成して @registerSubview() を呼び出すコードを並べる
派生クラスでは例えば
class Step3View extends FormView
setupSubviews: ->
# タイトル入力のビュー
titleInputView = new TitleInputView(@findNode("#title-form"))
@registerSubview("titleInputView", titleInputView)
# 依頼詳細入力のビュー
descriptionInputView = new DescriptionInputView(@findNode("#description-form"))
@registerSubview("descriptionInputView", descriptionInputView)
# 以下同様に
というような形になります。
こうして、親→子への関係が作られます。
親戚づきあいの作法
あるビューは、
- 直接の子にのみ、介入することができる
- 親が誰なのかは知らないし、介入もしない
- 兄弟に誰がいるのかは知らないし、介入もしない
- 直接の子が持つ子孫については知らないし、介入もしない
ここで言う知っているとは相手となるビューのインスタンスへの参照を持っていること、介入とは、相手となるビューのインスタンスに対してメソッドを呼び出して何らかの指示を出すことを指します6。
見ての通り、何かのアクションを起こし得る相手は直接の子しかありません。
それ以外の存在は知らないことにします。
コード的には、
class Step3View extends FormView
doSomeAction: ->
@subviews.titleInputForm.doThis()
のような形で子が持つメソッドを呼び出すことによって指示を出したりすることができる形にします。
一方で、親や兄弟については @subviews
のような参照はないので、指示を出したりすることはできません。
子ではないビューへ影響を及ぼす方法
ここまでの説明で「遠戚のビューに影響を及ぼすにはどうすれば良いんだ?」と疑問に思われた方もいるでしょう。
たとえば、ステップ1にあるカテゴリー選択の変更をトリガーにして、隣のステップ2の状態を変化させたい場合はどうすれば良いでしょう?
兄弟同士は存在すら知らない間柄だということにしたので、Step1View の実装で Step2View インスタンスの参照を取得してきてメソッドを呼び出すといったことはできません7。
答えは「親に任せる」です。
ステップ1でカテゴリー選択の変更が発生したら、Step1View はその変更を親ビューに伝えます。変更を知った親は、ステップ2のビューである Step2View インスタンスを子として持っているため、指示を出すことができます。
しかし、先に説明した通り、ビューは親を知らないのでした。親ビューのインスタンスにカテゴリー選択の変更を伝えるメソッドを用意しておいて、それを呼び出すという手は使えません。
代わりに、オブザーバーを登録しておいて何かのイベントが起きた場合に通知を出せる仕組みを作りました。
class FormView
constructor: (@rootNode) ->
@observers = []
registerObserver: (name, receiver, callbackFunction) ->
@observers.push({name: name, receiver: receiver, callback: callbackFunction})
notify: (name, notificationObject) ->
for observer in @observers
if observer.name == name
observer.callback.call(observer.receiver, notificationObject)
2つのメソッドが出てきましたが、 registerObserver
を呼び出すのは親です。たとえばこんな感じにします。
class RootView extends FormView
registerChildren: ->
step1view = new Step1View(@findNode("#step1-root"))
@registerSubview("step1view", step1view)
# ステップ1で categorySelected という名のイベントが起きたら
# categorySelectionEventHandler() が呼ばれるように登録する
step1view.registerObserver("categorySelected", this, @categorySelectionEventHandler)
categorySelectionEventHandler: (notification) ->
# ステップ1で起きたカテゴリー選択変更が通知されてきたら、
# ステップ2にそれを伝える
@subviews.step2view.handleCategoryChange(...)
もう一つのメソッド notify
を使うのは自分です。
class Step1View extends FormView
setupEventHandler: ->
@findNode("input:radio").click (event) =>
# 配下のラジオボタンがクリックされたら
# 選択されたカテゴリーをオブザーバーに通知する
selectedCategory = ...
notification = {
category: selectedCateogry
}
@notify("categorySelected", notification)
ここに挙げた例だと、
- ステップ1でカテゴリー選択のラジオボタンがクリックされると、クリックの結果生じたカテゴリー選択変更の内容がオブザーバーに通知される
- ステップ1のオブザーバーは親ビューであるので、したがって親にカテゴリー選択変更の詳細が伝わる
- 親ビューは自らの子であるステップ2に対して、ステップ1で生じたカテゴリー選択変更を伝え、どう振る舞うかを一任する
という流れを作ることができ、めでたくステップ1で生じたイベントを起点にステップ2に影響を及ぼすことができました。
ちなみに、勘の良い方はすでにお気づきかもしれませんが、registerObserver()
を呼び出せるのは親しかいません。なぜなら、自分を知っているのは親以外に存在しないからです。すなわち、オブザーバーとは親のことに他なりません。
ということを考えると、こんなややこしい仕組みを作らずに素直に親への参照を持つようにして、親の持つメソッドを直接呼び出すようにしても良かったのでは、という気もしないでもないのですがw
ともあれ、基本的にはこの
- 親に通知を送る
- 子に指示を出して任せる
- 自分でどうにかする
の組み合わせだけで、任意の場所で起きたイベントを任意の別の場所に影響させることができるようになります。
管轄範囲の取扱い
ここまでの設計方針でビュー同士の繋がりは整理できました。
残るは自らの管轄する範囲の取扱い方を考えるだけです。やるべきことは
- DOM イベントの監視 (= イベントハンドラーの設置)
- DOM 操作
です。くどいようですが、管轄外の DOM には干渉しない原則なので、これらのコードは自らの管轄下のみを対象に振る舞いを定義していく形になります。
以前であれば画面内全域を対象に特定のクラスが振られているノードにまとめてイベントハンドラーを仕掛けるということが行われていましたが、今後はそういうやり方はダメということにしました。面倒でも自分の管轄下だけを対象にするようにします8。結果、複数箇所に同じような処理が記述される場合も出てきますが DRY 性よりも他所に干渉しない原則の方を優先します。
実際のリファクタリング作業は、すでにビューのクラスが存在して且つビューインスタンス同士の結びつきが定義されている状態になっていたので、既存コードに書かれていたイベントハンドラーや関数などを、対応するビュークラスのメソッドになるような形で移動してくるのが主な作業になりました。
若干面倒だったのは、既存の DOM イベントハンドラーが
$("#some-botton").click (event) ->
# ボタンがクリックされたら、押されたそのボタンを disabled にする
# 「押されたそのボタン」を this を使って特定しているところがポイント
$(@).prop("disabled", true)
というように、関数に渡される this
に頼っていることが多かったことですね。これをビュークラスに持ってくると、こういう書き方にしたいわけですが
class SomeButtonView extends FormView
setupButtonClickedHandler: ->
@findButtonElement().click (event) ->
$(@).prop("disabled", true) # これは意図通りに動くが・・・
# この this は自オブジェクトを指しているのではないので、間違い
@notify("some-botton-clicked", {...})
このように this
が自オブジェクトの方を指していてほしいときに困ります。
CoffeeScript 的には上の例の @notify
を参照できるようにするのは簡単で fat arrow を使う形に変えれば良いのですが、これだと
class SomeButtonView extends FormView
setupButtonClickedHandler: ->
@findButtonElement().click (event) => # ← fat arrow に変えた
$(@).prop("disabled", true) # 今度はこの $(@) が間違いになる
# この this は自オブジェクトを指している
@notify("some-botton-clicked", {...})
このように別の問題が起きてしまいます。要するに this
の取り合いみたいな状況が発生しているわけです。
しょうがないので、次のように書き換えて解決しました。
class SomeButtonView extends FormView
setupButtonClickedHandler: ->
@findButtonElement().click (event) => # ← fat arrow を使う
# this を使わず、引数に渡された event から該当ノードを特定する
$(event.currentTarget).prop("disabled", true)
# この this は自オブジェクトを指している
@notify("some-botton-clicked", {...})
というような細かい調整が随所に発生しましたが、概ね既存コードの移動 (= すなわちコピペ) + 微調整で何とかなりました。
ビュークラスへ移動を終えて用済みになったオリジナルのコードは、めでたく古い方のファイルから削除できるようになります。このような作業を地道に進めていくにつれて、カオスだったオリジナルのファイルからは徐々にコードが減っていき、新しく作ったビュークラスを記述するファイルの方が育っていく流れになります。
複雑さの鍵を握っていたモノ
コードの整理を進めていく中で、過去のコードでカオスを引き起こしていたモノの正体が浮き彫りになってきました。まぁ「浮き彫りに」とか言いつつも、実際には予想通りのモノであったことが改めて明らかになったぐらいで、感想としてや「せやな」というところではありますが。
結局のところ、鍵を握っていたのはカテゴリーと依頼形式の状態と変更でした。この2つの情報の状態によって画面のあるべき形が決まり、状態遷移によって画面の様々なところが影響を受けて変化する。この状態と画面上の表現の結びつきが強すぎたり、自由気ままに振る舞いすぎていたせいでカオスが生じていたのでした。
実際、カテゴリー選択の状態とは、ラジオボタン要素のどれが選択状態になっているかで管理されているようなものでしたし、状態遷移とはすなわちラジオボタンの選択状態の変更を指すといった造りになっていました。
ということで、この2つの情報 (= カテゴリーと依頼形式の選択状態) を DOM から分離し、状態を表すものとして管理するようにします。こんな感じのオブジェクトを作りました。
// category がカテゴリーのID、jobType が依頼形式を表すイメージ
// この例だと、カテゴリーID が123 で依頼形式がコンペが選択されているという意味
{
"category": 123,
"jobType": "competition"
}
この情報の変化は画面各所に影響を及ぼすので、トップレベルにいる全体統括ビューが管理するのが適切でしょう。全体統括ビューは、子ビューからカテゴリーまたは依頼形式の選択を変更したいというリクエストが起きたという通知を待っています。通知が届いたら、果たしてその選択変更は可能かどうかを判断し、ok であれば、状態データの category
を書き換え、直下にいるステップ1〜6のそれぞれの子ビューにその変更を伝え、画面の表示切り替えなどを任せます。
ここで「ステップ1〜6のそれぞれの子ビューにその変更を伝え」と書いたことにお気づきでしょうか? 変更を伝える相手には、起点となったイベントが発生した当のステップ1も含まれています。
カテゴリーの選択を行うための UI はステップ1に配置されていますが、ラジオボタンがクリックされても該当 input:radio
要素の選択状態は変化させません。クリックされた場合のイベントハンドラーの仕事は、カテゴリーの選択を変更したいというリクエストが起きたという通知を投げることだけです。投げられた通知は親である全体統括ビューに拾われます。そしてこの変更リクエストを受け入れて良いと判断されて状態が変化すると、今度は全体統括ビューからカテゴリーの選択が変更されたので、各自画面表示を調整せよという指示が降りてきます。このタイミングでやっと、ステップ1のカテゴリー選択 UI の input:radio
要素を checked
にするなどの処理が走るようになります。
こういう回り道をして何が嬉しいかと言うと、選択の変更を許可しない場合とか、間に「本当にカテゴリー変えても良いですか?」とダイアログをはさみたい場合などの制御がやりやすくなることです9。
もし、ラジオボタンのクリックが即その要素が checked
になってしまう造りだと、変更を許可しない場合は元の状態に戻してやる必要が出てきます。ということは、変更前に checked
だった要素はどれなのかを覚えておく必要があるでしょう。
あるいは、イベントハンドラー内で画面内の状況を確認しつつ、checked
に変えてよいかどうかを判断するという手もあります。が、切り替え可否の判断は画面内各所の状態に左右されるという事情があるため、しがらみを持っている各要素に現状を問い合わせつつ複雑な条件分岐を構成するということになります10。
どちらにしても複雑になるイメージしかないですね。これよりは、
- イベントハンドラーはクリックされた事実を伝えるだけ
- 状態遷移に責任を持つ親が判断を行い、画面全体に号令を出す
という造りの方が遥かにシンプルで分かりやすいと判断して、このような構成にしました。結果として、イベントハンドリングと状態の管理, UIへの反映が分離され、コードの見通しが改善されました。
まとめ
以上のような方針に基づいてコードの整理を進めた結果、以前とは比較にならないぐらいに見通しが良くなりました。
ひと通り終わってから振り返ってみると、いちばん効いていたのはビューというクラスを作ったことであるように感じています。クラスが存在しているおかげで、そのビューに関連しているコードを記述する場所が自ずから決まってくるので、どこに書こうかと迷うことがありません。また、特定のビューに関連するコードを探すときも、書いてある場所が明確に決まっているおかげで、すぐに特定できます。
また、管轄外の DOM には干渉しない原則のおかげで、予期しない方角から DOM 操作が行われる心配がなくなりました。当初の問題であった縦横無尽に張り巡らされたイベントハンドラー群は概ね解消されましたし、イベントハンドラー連鎖も起こり得なくなりました。
反面で、遠隔地に影響を及ぼしたい場合は親を経由しないといけないなどの手間は発生していますし、イベントハンドラーを一つ一つ個別に設定していく必要があるので似たようなコードが複数箇所に現れて DRY じゃないといった課題はあります。実際、リファクタリング前と後とでは、後の方がコードの行数的には増えています。
しかし、それらのデメリットを上回って、コードの見通しが改善された恩恵が大きい。今なら、画面の表示が何かおかしい場合は、
- 該当 view の処理が間違っている
- 親から間違った指示が来た
- 子から間違った通知が来た
のどれかだと予想がつきます。もはや、数千行のコードの海のどこかにおかしなことをしている奴がいるのではないかと探し回る必要はありません。
かつてのカオスだったコードは秩序を取り戻し、再びメンテナンスや機能拡張・再編が可能になりました。
感想
無事に最後までやり終えられて良かったですw
かなり大規模な改修になってしまったこともあってリリース作業がかなり緊張感ある感じになりましたが、幸いにして大きな問題は起こらず。あまりに平和で逆に拍子抜けしてしまったぐらいでした。
一方で、本来やりたかった依頼入力画面を改善してより良いユーザー体験を提供するという施策はまだ何も前進してなくてこれからなのですが、それでもやっと何かの施策を始めるための準備は整ったとは言えるので、時間をかけて整理した価値はあったかな、と思います。
@ayasuda さんへの返信
つまり、本来やるべきだったのは、「状態」「表示変更」「イベントハンドリング」のコードの分離 だったのでした。
私は jQuery から Vue.js への置き換えで何をやらかしたのか - Qiita
全部やったよ!
-
というのは半分嘘で、最初からこの話を書く予定だったところに件のエントリーが投下されたので乗っかったというのが正しいです。 ↩
-
ちなみに、一部だけ Vue.js で置き換えられているというオマケが付いています。つまり jQuery と Vue.js が共存する状態。幸いにして、両者の境界線がはっきりしていたので、既存 Vue.js コードに振り回されることはありませんでした。 ↩
-
逆に言うと、以前はそれすらもちゃんとできていなかった、ということでもあります ↩
-
そこ、「まだ CoffeeScript なんて使ってるの?」とか言わない ↩
-
実際には、管轄外に手を出さないといけない事態はほとんど起こりませんでしたが ↩
-
先に出てきた干渉と紛らわしいですが、ここでは干渉とは DOM に対する監視や操作、介入とはビューインスタンス同士の関係を表すもの、という使い分けをしています ↩
-
と言うか、そういうことができないように、このような設計にしたのでした ↩
-
具体例としては、テキストボックスに入力された文字列の長さをカウントして上限と比較しつつ、近くに「xxx/yyy文字」みたいな表示を出すやつが挙げられます。リファクタリング前は、特定クラスが振られているノードに一括で設定されていましたが、個別ビューごとに設定する形に改められました。 ↩
-
選択されたカテゴリーによって入力項目が異なるため、変更先いかんによっては入力項目が消える場合があって、それはすなわち入力した内容が失われるということなので、そういうときは警告用のダイアログを出したりしています。もちろん、ダイアログを見たユーザーが拒否すれば、選択変更は行われなかったことにしたい。 ↩
-
整理前のコードがまさにこんな状態でした ↩