疎結合なUIコンポーネントの作成について

複数画面で使えるAjax formダイアログのコンポーネントを作る

初期状態

Google CalendarのようなWebアプリを想像してください。
1日が1個のセルになっていて、セルをクリックしたらスケジュール登録ダイアログが表示され、
入力して登録ボタンを押すとスケジュールが登録され、セルの中に登録されたスケジュールが表示されます。
イメージ

define([
    'schedule-model'
    ,'schedule-api'
    ,'schedule-to-selectors'
    ,'growl'
    ,'app/schedule-dialog-template'
    ,'debug'
    ,'jqueryui/dialog'
],function(
    ScheduleModel
    ,ScheduleApi
    ,scheduleToSelectors
    ,growl
    ,scheduleDialogTemplate
    ,debug){
  $('.cell').live('click',function(ev){
    // custom data attributeです
    var props = $(this).data('date-properties');
    var dateStr = props.date;
    // 祖先要素のどれかにeditable classが振られていないと更新系のダイアログは出しません
    if (!$(this).closest('.editable').size()) {
      return;
    }

    var initialValue = {startDate : dateStr};
    // scheduleDialogTemplateはオブジェクトを渡せば
    // HTML templateに食わせてHTML文字列化して返してくれる都合のいい関数です
    var html = scheduleDialogTemplate(new ScheduleModel(initialValue));
    $(html).dialog({
      width : 400
      ,buttons : {
        '登録' : function(){
          var $dialog = $(this);
          var $form = $dialog.find('form');
          var schedule = new ScheduleModel();
          // formの入力内容から値を取るメソッドとかModelに生えてます
          schedule.fromForm($form);
          // modelからパラメータを組み立ててWebAPIを叩いて新規登録するのに使うclassです
          // サーバサイドも都合がいいので決して失敗しません
          // なのでsuccess以外のcallbackは不要です
          var api = new ScheduleApi();
          api.success = function(createdSchedule){
            // グロール風に右上にメッセージを出します
            growl('スケジュール登録完了');
            // scheduleToSelectors関数はなんとmodelを渡すとそれを表示すべきセルのCSSセレクタを返してくれます
            // セルのredraw eventを叩くとそのセルに表示すべきスケジュールを全件よしなに取得して、表示してくれます
            $(scheduleToSelectors(createdSchedule)).trigger('redraw');
          };
          api.create(schedule);
          $(this).dialog('close');
        }
        ,
        'キャンセル' : function() {
          $(this).dialog('close');
        }
      }
    });
  });
});

click eventからの切り離し

仕様変更が入りました。内容は以下の通りです。

セルをクリックしたらダイアログが開く
↓
セルをクリックしたらアクティブになり、アクティブなセルをクリックしたらダイアログが開く

せっかくなので今後似たような微妙な仕様変更が来ても対応しやすいようにしましょう。

define([],function(){
  $('.cell').live('click',function(ev){
    $(this).addClass('active');
  });
  $('.cell.active').live('click',function(ev){
    $(this).removeClass('active');
    $(this).trigger('open-schedule-new');
  });
});
define([
    'schedule-model'
    ,'schedule-api'
    ,'schedule-to-selectors'
    ,'growl'
    ,'app/schedule-dialog-template'
    ,'debug'
    ,'jqueryui/dialog'
],function(
    ScheduleModel
    ,ScheduleApi
    ,scheduleToSelectors
    ,growl
    ,scheduleDialogTemplate
    ,debug){
  $('.cell').live('open-schedule-new',function(ev){
    var props = $(this).data('date-properties');
    var dateStr = props.date;
    if (!$(this).closest('.editable').size()) {
      return;
    }

    var initialValue = {startDate : dateStr};
    var html = scheduleDialogTemplate(new ScheduleModel(initialValue));
    $(html).dialog({
      width : 400
      ,buttons : {
        '登録' : function(){
          var $dialog = $(this);
          var $form = $dialog.find('form');
          var schedule = new ScheduleModel();
          schedule.fromForm($form);
          var api = new ScheduleApi();
          api.success = function(createdSchedule){
            growl('スケジュール登録完了');
            $(scheduleToSelectors(createdSchedule)).trigger('redraw');
          };
          api.create(schedule);
          $(this).dialog('close');
        }
        ,
        'キャンセル' : function() {
          $(this).dialog('close');
        }
      }
    });
  });
});

ダイアログを開くという挙動をopen-schedule-newというcustom eventにしました。
ダイアログを開くという挙動と、ダイアログを開く為のユーザのアクションを検知する箇所を分離したわけです。

呼び出し元からの依存の除去

仕様追加が入りました。

今まではカレンダー画面だけでしたが、別にスケジュールの一覧表画面も作る。
スケジュールの一覧表画面には、スケジュール新規追加ボタンがあって、
そこをクリックするとカレンダーのセルをクリックした時と同じようにスケジュール登録ダイアログを開いて、
そこからスケジュール登録を出来るようにして欲しい。

とのことです。

define([],function(){
  // カレンダー画面用
  $('.cell').live('click',function(ev){
    if (!$(this).closest('.editable').size()) {
      return;
    }
    $(this).addClass('active');
  });
  $('.cell.active').live('click',function(ev){
    $(this).removeClass('active');

    // custom data attributeから値を取得するという処理を移動したことで、
    // open-schedule-new eventの対象要素にcustom data attributeを設定することが必須ではなくなる
    var props = $(this).data('date-properties');
    var dateStr = props.date;

    // ダイアログの初期値は開始日が、そのセルの日付
    var initialValue = {startDate : dateStr};
    $(this).trigger('open-schedule-new',[initialValue]);
  });
});
define([],function(){
  // スケジュール一覧表画面用
  $('.cell.schedule-new-open-button').live('click',function(ev){
    $(this).removeClass('active');
    // ダイアログの初期値は全空欄
    var initialValue = {};
    $(this).trigger('open-schedule-new',[initialValue]);
  });
});
define([
    'schedule-model'
    ,'schedule-api'
    ,'schedule-to-selectors'
    ,'growl'
    ,'app/schedule-dialog-template'
    ,'debug'
    ,'jqueryui/dialog'
],function(
    ScheduleModel
    ,ScheduleApi
    ,scheduleToSelectors
    ,growl
    ,scheduleDialogTemplate
    ,debug){
  $('.cell').live('open-schedule-new',function(ev,initialValue){
    var html = scheduleDialogTemplate(new ScheduleModel(initialValue));
    $(html).dialog({
      width : 400
      ,buttons : {
        '登録' : function(){
          var $dialog = $(this);
          var $form = $dialog.find('form');
          var schedule = new ScheduleModel();
          schedule.fromForm($form);
          var api = new ScheduleApi();
          api.success = function(createdSchedule){
            growl('スケジュール登録完了');
            $(scheduleToSelectors(createdSchedule)).trigger('redraw');
          };
          api.create(schedule);
          $(this).dialog('close');
        }
        ,
        'キャンセル' : function() {
          $(this).dialog('close');
        }
      }
    });
  });
});

ユーザのアクションを検知するmoduleをカレンダー画面用とスケジュール一覧表画面用の二つに分けました。
また、ダイアログを開くmoduleの側で特定のマークアップに依存していた処理を、
ユーザアクション検知moduleの側に移動しました。
なお、jQuery custom event 応用編の引数付き関数呼び出しのパターンを使っています。


これで、ダイアログをどう開くか?についてはかなり自由度が上がりました。
cell classが振られた要素に対して、open-schedule-new eventをtriggerしてやればいいのです。
ユーザがどんなアクションをしたらダイアログが開くか、
あるいはユーザのアクション以外によって開くのか、
初期値はなんかのか、すべて自由です。
open-schedule-new eventをtriggerするmoduleで好きにその部分だけ好きに差し替えてやれば良いのです。


では登録完了後のcallbackの方はどうでしょうか?
グロール風メッセージ表示は今のところ両方で出したいのでいいですが、あくまで今のところです。
メッセージを出したくない箇所で使いたくなるかもしれません。
セルのCSSセレクタ→要素を取得し、.trigger('redraw')して再描画するところですが、
これではスケジュール一覧表画面では、エラーは起こらないものの再描画が出来ません。

callbackからの依存の除去

スケジュール一覧表画面でも再描画が効くようにしましょう。
ユーザアクション検知moduleは変更していないので省略します。

define([
    'schedule-model'
    ,'schedule-api'
    ,'app/schedule-dialog-template'
    ,'debug'
    ,'jqueryui/dialog'
],function(
    ScheduleModel
    ,ScheduleApi
    ,scheduleDialogTemplate
    ,debug){
  var $cell = $('.cell');
  $cell.live('open-schedule-new',function(ev,initialValue){
    var html = scheduleDialogTemplate(new ScheduleModel(initialValue));
    $(html).dialog({
      width : 400
      ,buttons : {
        '登録' : function(){
          var $dialog = $(this);
          var $form = $dialog.find('form');
          var schedule = new ScheduleModel();
          schedule.fromForm($form);
          var api = new ScheduleApi();
          api.success = function(createdSchedule){
            $cell.trigger('api-success',createdSchedule);
          };
          api.create(schedule);
          $(this).dialog('close');
        }
        ,
        'キャンセル' : function() {
          $(this).dialog('close');
        }
      }
    });
  });
});
define('api-success-growl',['growl'],function(growl){
  $('.cell').live('api-success',function(ev,schedule){
    growl('スケジュール登録完了');
  });
});
define('api-success-redraw-calendar',['schedule-to-selectors'],function(scheduleToSelectors){
  $('.cell').live('api-success',function(ev,schedule){
    $(scheduleToSelectors(schedule)).trigger('redraw');
  });
});
define('api-success-add-schedule-to-list',[],function(){
  $('.cell.schedule-new-open-button').live('api-success',function(ev,schedule){
    // addRowというメソッドが一覧表示を実現するためのjQuery pluginで定義されていて、
    // そこにmodelを渡すと行を追加してくれるということになっている
    $('#schedule-list').addRow(schedule);
  });
});

今までは登録完了callbackの中に直接処理を書いていましたが、
それを$cell.trigger('api-success',createdSchedule);だけにしました。
カレンダー画面ではapi-success-growlapi-success-redraw-calendarの2moduleを読み込んでおけば、
今まで通りgrowl風に登録完了メッセージを表示→セルの内容更新という風に動きます。
スケジュール一覧表画面ではapi-success-growlapi-success-add-schedule-to-listを読み込んでおけばOKです。


jQuery custom event 応用編で言えば、「callbackパターン」と「同名のn(≧0)個の関数の呼び出しパターン」を使っています。

まとめ

ユーザのアクションでwidgetの表示を行い、そのwidgetに対するユーザの操作によりメインの処理を行い、その結果を画面に反映させる。
ということをやりたい場合、

  • widget表示のきっかけになるユーザのアクションの検知
  • 結果の画面への反映

を取り除いたcustom eventベースのmoduleを作りましょう。
そうすれば様々な場所で再利用することが出来ます。

後付drag&drop

draggable未導入状態

先程のカレンダーのセル内再描画用custom eventです。

define('schedule-redraw',[
  'app/schedule-elem-template'
  ,'debug'
],function(
    template
    ,debug){
  $('.cell').live('redraw',function(ev){
    // 説明用のコードなので細かいところは置いておくとして、
    // セルに表示すべきスケジュール全件のmodelの配列を取得している
    var $cell = $(this);
    $cell.remove();
    var props = $cell.data('date-properties');
    var dateStr = props.date;
    var schedules = $cell.closest('#calendar').data('calendar').findSchedulesByStartDate(dateStr);
    schedules.forEach(function(schedule){
      // 細かいことはさておき1スケジュール分の部分HTMLを作ってくれる。
      var html = template(schedule);
      $(html).appendTo($cell);
    });
  });
});
draggable導入

仕様変更が来ました。

スケジュールをドラッグ&ドロップ出来るようにして欲しい

とりあえずdraggableを導入してdrag出来るようにします。
本当はdroppableで対応するdrop先についても色々やらないといけません。
でも今は、そんな事はどうでもいいんだ。重要な事じゃない

define('schedule-redraw',[
  'app/schedule-elem-template'
  ,'debug'
  ,'jqueryui/draggable'
],function(
    template
    ,debug){
  $('.cell').live('redraw',function(ev){
    var $cell = $(this);
    $cell.remove();
    var props = $cell.data('date-properties');
    var dateStr = props.date;
    var schedules = $cell.closest('#calendar').data('calendar').findSchedulesByStartDate(dateStr);
    schedules.forEach(function(schedule){
      var html = template(schedule);
      var $schedule = $(html);
      $schedule.appendTo($cell);
      $schedule.draggable();
    });
  });
});
draggableを後付可能にする

また仕様変更が来ました。

こっちの画面のカレンダーではドラッグ&ドロップしたいが、
あっちの画面のカレンダーではドラッグ&ドロップ出来ないようにして欲しい

moduleの中の一部だけを差し替えたい状況です。
サンプルコードだと1行ですが、まあ実際に書くとそこそこの分量になるでしょう。

define('schedule-redraw',[
  'app/schedule-elem-template'
  ,'debug'
],function(
    template
    ,debug){
  $('.cell').live('redraw',function(ev){
    var $cell = $(this);
    $cell.remove();
    var props = $cell.data('date-properties');
    var dateStr = props.date;
    var schedules = $cell.closest('#calendar').data('calendar').findSchedulesByStartDate(dateStr);
    schedules.forEach(function(schedule){
      var html = template(schedule);
      var $schedule = $(html);
      $schedule.appendTo($cell);
      $schedule.addClass('schedule');
      $schedule.trigger('create');
    });
  });
});
define(['jqueryui/draggable'],function(){
  $('.schedule').live('create',function(ev){
    $(this).draggable();
  });
});

こんなふうにしました。
まず、要素を生成追加後にcreate eventをtriggerしてやるようにします。
そして、create eventを拾って要素をdraggableにするmoduleを作ります。
draggableにするmoduleを読みこめばdrag出来、読み込まなければdrag出来ないという差し替えを、
schedule-redrawを一切変更せずに出来るようになりました。

まとめ

要素生成後にcreate eventをtriggerすることで、
要素生成後の処理に、プラガブルな拡張ポイントを用意することが出来ます。
jQuery UI Draggable等要素に手を加え機能を付加するようなものを使う場合大変有用です。

おまけjQuery Create Event plugin

ちなみに、jQuery Create Eventというpluginがあって、

$(selector).live('create',handler)

とやると、DOM操作の際にselectorを満たす要素が出来ると.trigger('create')してくれます。
多分、軽い画面なら手軽で便利だと思います。
しかし、DOM操作が大変多い画面でFirebugでprofileを取ってみたところ、
これのDOM操作の対象要素の中からselectorを満たすものを探索する関数が
全体のCPU時間の1割を消費していたので導入をやめました。

まとめ

jQuery custom event 応用編のテクニックを使えば、UIコンポーネントの機能から、
要件やコンポーネントの配置先の構造によって変わりやすい部分を外出しして、本体の再利用性を高めることが出来ます。
また、プラガブルな拡張ポイントに差し込むパーツをそれぞれ単機能にすることで、
こちらも再利用が容易になります。