arinoth's memo

arinothのメモ

【Meteor再開】flowers project managerを作ったときのメモ

だいぶ前に手を付けて放置していたMeteorですが、ようやくバージョン1.0になったのと同じ頃に仕事が落ち着いたので、 一からやり直してみたところ、あっさり目的のものが完成してしまいました。前はかなりつまづいたのに、今回は妙にすいすい進んで自分でもビックリ。

f:id:arinoth:20150103225219p:plain

github

Meteor自体の学習は、公式サイトのチュートリアルがとてもよくできているので、それに沿って一回作ればだいたい理解できます。

ここでは作成時に迷ってしばらく調べていた部分を中心にメモを残しておきます。

他のライブラリを使うには?

flowersPMではjQueryjQuery UI、Bootstrapを利用していますが、ほぼ通常どおりの方法で組み込めます(jQuery本体は標準でMeteorに付属しているものを使用)。

<head>
   <title>flowers project manager</title>
   <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.1/css/bootstrap.min.css">
   <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootswatch/3.3.0/flatly/bootstrap.min.css">
   <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.1/js/bootstrap.min.js"></script>
   <script src="bootbox.min.js"></script>
   <link rel="stylesheet" href="//ajax.googleapis.com/ajax/libs/jqueryui/1.11.2/themes/smoothness/jquery-ui.css" />
   <script src="//ajax.googleapis.com/ajax/libs/jqueryui/1.11.2/jquery-ui.min.js"></script>
   <meta name="viewport" content="width=device-width, initial-scale=0.5">
   <link rel="shortcut icon" href="icon/favicon.ico" type="image/vnd.microsoft.icon" />
   <link rel="icon" href="icon/favicon.ico" type="image/vnd.microsoft.icon" />
</head>

CDNはそのままです。ダウンロードしたライブラリを使う場合は、「public」フォルダ内に入れておきます。

Meteorでは、

  • clientフォルダ内に入れたものはクライアントサイドのコードになり、
  • serverフォルダ内に入れたものはサーバサイドのコードになるため、

どちらでもない画像やライブラリファイルなどはpublicフォルダに入れておくルールです。

また、クライアントサイドのコードを複数ファイルに分割する場合は、ファイルごとに(function(){ })でくるまれるため、スコープが別になってしまうという点に注意が必要です。 なので、変数・関数を複数ファイルで共用したい場合は、

  • varを付けずにグローバル宣言する
  • client > compatibilityフォルダ内にコードを入れる

のどちらかで対応します。

style属性でもテンプレートが効く

flowersPMのタスクを表す小さいボックスはすべてli要素です。 Meteorでは{{ヘルパー名}}で囲んだ部分にTemplate.テンプレート名.helpersで宣言しておいたヘルパー関数の結果が差し込まれる仕組みなのですが、HTML内のテキストだけでなく属性にも使用できます。

それを利用して、タスクの位置・サイズを更新しています。

<template name="task">
    <li class="task {{taskstatus}}" id="{{_id}}" style="left:{{taskxpos}}px; top: {{taskypos}}px; width: {{taskw}}px;">
        <!--    data-deadline="{{milideadline}}" -->
        <ul>
        <li class="task-title">{{ti}}</li>
        <li class="task-assignee"><span class="glyphicon glyphicon-user"></span>{{formatname}}</li>
        <li class="task-deadline"><span class="glyphicon glyphicon-time"></span>{{formatdeadline}}</li>
        </ul>
    </li>
</template>

ヘルパー関数のコードはこんな感じで、締め切り日や期間のデータからタスクの位置・幅を算出して返しています。

Template.task.helpers({
……中略……
  taskxpos: function(){
    var viewmonth = Session.get('viewmonth');
    var dayspan = Session.get('dayspan');
    var startdate = Math.round(viewmonth.getTime() / ONEDAYMILI);
    var x = Math.round(this.dl.getTime()/ONEDAYMILI);
    var w = dayspan;
    if(this.span){
      w = this.span * dayspan;
    }
    if(w<DAYSPAN_MIN) w = DAYSPAN_MIN;
    return (x - startdate + 1)*dayspan - w;
  },
……中略……
  //タスクの幅を返す
  taskw: function(){
    var dayspan = Session.get('dayspan');
    var w = dayspan;
    if(this.span){
      w = this.span * dayspan;
    }
    if(w<DAYSPAN_MIN) w = DAYSPAN_MIN;
    return w;
  },
……後略……

この方法なら、DB内のデータを更新しただけでタスクの表示位置が連動して変わってくれるので、 更新タイミングとかをまったく考えなくて済みます。 データは書き換えたい時に書き換えればOKです。さすがリアクティブ何とか!

テンプレートとセッション

セッションというのはクライアントサイドで一時的にデータを保存するしくみなのですが(キーバリュー型でHTML5のDataStorageに似ています)、 Session.getをテンプレート用のhelpersなどのコード内に書いておくと、コードのどこかでSession.setするだけで連動してHTMLが更新されます。

flowersPMでは、画面に表示する期間や、1日の表示幅(通常モードでは1日=50px、圧縮モードでは1日=15px)をセッションに記録しておき、 どこかでSession.setするだけで画面が書き換わるようにしています。

通常の方法でリアクションさせられない場合

上記2つの方法でだいたいのデータは画面に表示できるのですが、それで対応できない場合もあります。こういう場合は、observeメソッドを利用してリアクション発生時に特定の関数を呼び出すようにします。

例えばflowersPMの場合だと、タスク間をつなぐ線はcanvasで描いているため、テンプレートやセッションによるリアクションが利用できません。 そこでテンプレート内でタスクの一覧をDBから取得したときに、observeメソッドでupdateProjectAreaという関数を呼び出すように設定しています。

//プロジェクトテンプレートのヘルパー
Template.project.helpers({
  //プロジェクトが持つタスクの一覧を取得
  tasks: function(){
    var result;
    // userviewモードのときは特定のユーザーのタスクのみを表示する
    if(Session.get('taskquery')==='userview'){
      var assignie= Session.get('selecteduser');
      result = Tasks.find({prid: this._id, us: assignie, mbr:{$ne: true}}, {sort: {dl:1}});
    } else {
      result = Tasks.find({prid: this._id}, {sort: {dl:1}});
    }
    //他のユーザーによるタスク更新を感知
    var observetimer = null;
    result.observe({
      added: function (doc) {
        var sdoc = doc;
        Meteor.clearTimeout(observetimer);
        observetimer  = Meteor.setTimeout(function(){
          console.log('observe added');
          updateProjectArea(sdoc.prid, Tasks);          
        }, 500);
      },
      changed: function (newdoc, olddoc) {
        ……中略……
      },
      removed: function(olddoc){
        ……中略……
      }
    });
    //結果を返す
    return result;
  }

実際にやってみたところ、かなり大量にリアクションが発生するため、かなり重くなりました。 そこでsetTimeoutを使って0.5秒未満のリアクションは無視するようにしています。

また、observeメソッドで設定できるイベント(?)は、added(追加された)、changed(変更された)、removed(削除された)の3種類があるのですが、 こちらの意図としては変更なのにたいていaddedが発生していました。

Meteorでは通常のsetTimeoutではなくMeteor.setTimeoutを使えとドキュメントに書いてありました(理由は調べてません)。

リサイズイベントやスクロールイベントを書く方法

Meteorではイベント処理はTemplate.テンプレート名.eventsメソッドで定義します。ただしこの仕組みはリサイズやスクロールなどのイベントでは使えないっぽいです。 通常の方法でイベントを設定しようとしても、対象となるDOMが生成されていないので設定できない……ような気がします(ちょっと自信がない)。

ではどうするかというと、テンプレートの描画が完了したときに呼び出されるTemplate.テンプレート名.renderedメソッドの中で設定します。

//タイムラインの更新
Template.projectview.rendered = function(){
  updateTimeline();
  resizeAllArea();
  sortPinnedProject();

  //リサイズイベント
  var resizetimer = null;
  $(window).resize(function() {
      Meteor.clearTimeout(resizetimer);
      resizetimer = Meteor.setTimeout(function() {
        resizeAllArea();
        updateTimeline();
        updateAllProjectArea(Tasks);
      }, 500);
  });

……後略……

なお、renderedメソッドが呼び出されるのは最初にテンプレートのインスタンスが生成されたときで、内容が更新されたときは呼び出されません。 更新タイミングで何かしたいときは、さっきのobserveメソッドを利用します。

テンプレートで生成したliの順番を入れ替える

flowersPMにはユーザーごとにプロジェクトの順番を入れ替える機能があるのですが、 これを実現するために色々考えた結果、試しに普通にjQueryのprependメソッドで入れ替えてみたところうまくいきました。

// pinnedしたプロジェクトを先頭に
function sortPinnedProject(){
    var pinned = Meteor.user().pinned;
    if(pinned){
        for(var i=0; i<pinned.length; i++){
            $('#'+pinned[i]).prependTo('.projectlist');
        }
    }
}

テンプレートで自動生成したDOMをいじくって大丈夫なの?……という不安はありますが、とりあえず今のところ不具合はないみたいです。

ちなみにこの順番入れ替え用の関数は、サイズ更新時などに呼び出しているupdateAllProjectAreaという関数から呼び出しています。 だからうまくいっているのかもしれません。