実践入門 Ember.js

第4回ユーザのインタラクション(Controller, Component)

前回は画面遷移について解説しました。 今回はデータを画面に表示するところから一歩進んで、 ユーザのインタラクションを受け取って画面を更新する方法を解説します。

今回は次のEmber.jsアプリケーションの仕組みを理解することを目標にします。

Ember Starter Kit

『記事が長い場合は途中から省略して「もっと読む」をクリックすると続きが表示される』というアプリケーションです。

前準備

本稿の対象バージョンはこちらです。

前回の記事と本稿執筆時点でのEmber.jsの最新バージョンは同じです。 前回の記事を参考にして必要なファイルを作成してください。

ファイルの作成が完了したら、次は今回の解説の基礎となるルーティングとテンプレートを準備します。

ルーティング
App = Ember.Application.create();

App.Router.map(function() {
  this.resource('posts', {path: '/'}, function() {
    this.route('show', {path: '/posts/:post_id'});
  });
});

App.PostsRoute = Ember.Route.extend({
  model: function() {
    return [{
      id: 1,
      title: 'Ember.js 公式サイトの歩き方',
      body: 'Ember.js の公式サイト(http://emberjs.com/)では、まずトップページのサンプルを動かしてみるとよいでしょう。Ember.js でどんなことができるのかがざっくりわかります。'
    }, {
      id: 2,
      title: 'Ember.jsのディスカッションフォーラム',
      body: 'Ember.js についての疑問・質問・新しい提案など、Ember.js に関することが常に議論されています。 http://discuss.emberjs.com/'
    }];
  }
});

App.PostsShowRoute = Ember.Route.extend({
  model: function(params) {
    return this.modelFor('posts').filter(function(post) {
      return post.id === Number(params.post_id);
    })[0];
  }
});
テンプレート
<script type="text/x-handlebars" data-template-name="posts/index">
  <ul>
    {{#each post in model}}
      <li>{{link-to post.title "posts.show" post}}</li>
    {{/each}}
  </ul>
</script>

<script type="text/x-handlebars" data-template-name="posts/show">
  <h2>{{model.title}}</h2>

  <pre>
    {{model.body}}
  </pre>

  {{link-to "戻る" "posts.index"}}
</script>

さて、ここまでで記事が画面が表示されるようになりました。

Ember Starter Kit

ではここから、記事が長い場合に省略して表示するようにしましょう。

Controller

記事の詳細画面では「記事の全文を表示しているか、それとも省略して表示されているか」というフラグをどこかに持っておく必要があります。 そこで今回解説するのがControllerです[1]⁠。コントローラは、URLともmodelとも関係のない一時的なデータを保持できます。

これだけではイメージしづらいので、コントローラについて詳しく見ていきましょう。

Routeが画面を描画する際、modelと一緒にコントローラも準備されます。 そしてコントローラのmodelプロパティにRouteで解決されたmodelが設定され、 その状態でコントローラがテンプレートに描画されます。 テンプレートのコンテキストは、実はこのコントローラだったのです。

テンプレートではコントローラのプロパティにアクセスできるため、モデルにアクセスしたい場合はmodelをつけてプロパティを参照していました。

<h2>{{model.title}}</h2>

<pre>
  {{model.body}}
</pre>

つまり、コントローラ自身にプロパティを設定することで、そのプロパティをテンプレートから参照できることになります[2]⁠。

さっそく次のコードを追記して確認してみましょう。

コントローラ
App.PostsController = Ember.Controller.extend({
  pageTitle: 'Ember.js 関連の記事'
});
テンプレート
<script type="text/x-handlebars" data-template-name="posts">
  <h1>{{pageTitle}}</h1>

  {{outlet}}
</script>

コントローラのプロパティであるpageTitleが画面に表示されていますね。

Ember Starter Kit

ちなみに、Route名とコントローラ名の対応はEmber Inspectorで確認できます。 また、Route名に対応しないコントローラを利用したい場合、RoutecontrollerNameを設定することで変更可能です。

App.SomeRoute = Ember.Route.extend({
  controllerName: 'other'
});

この例の場合、App.SomeRouteのコントローラとしてApp.OtherControllerが設定されます。

Handlebars Helpers

では「記事を省略して表示をするかどうか」というフラグをコントローラにもたせて、⁠もっと見る」というリンクをクリックした際にその情報を更新することにします。

次のコントローラを作成してください。

App.PostsShowController = Ember.Controller.extend({
  isExpanded: false
});

テンプレートを書き換えてください。

<script type="text/x-handlebars" data-template-name="posts/show">
  <h2>{{model.title}}</h2>

  <pre>
    {{#if isExpanded}}
      {{model.body}}
    {{else}}
      {{truncate model.body length=20}}
    {{/if}}
  </pre>

  {{link-to "戻る" "posts.index"}}
</script>

{{truncate model.body length=20}}の部分は独自に定義したヘルパーを利用しています。 そのため、その定義を記述しておきましょう。

Ember.Handlebars.helper('truncate', function(value, options) {
  var length = options.hash.length;

  if (value.length > length) {
    return value.slice(0, length) + '...';
  } else {
    return value;
  }
});

ヘルパーの定義

Ember.Handlebars.helperで定義したヘルパーはテンプレートで利用できます。 ヘルパーの書式は次の通りです。

{{ヘルパー名 (引数1 引数2 ...) (オプション)}}
ヘルパー名

Ember.Handlebars.helperでヘルパーを定義する際に指定したヘルパー名です。

引数

ヘルパー関数の引数です。

オプション

key=valueの形で指定します、必要に応じていくつでも設定できます。ヘルパー関数では、一番最後の引数のhashプロパティから取得できます。

さて、ここまでで記事の本文が一定文字数を超える場合は「…」で表示されるようになりました。 次は、⁠もっと見る」リンクを作成してみましょう。

Ember Starter Kit

Actions

ユーザからのイベントを受け取るために、コントローラに任意のイベントハンドラを定義することができます。 この仕組みをEmber.jsではアクションと呼びます。

以下のコントローラとテンプレートを書き換えてください。

App.PostsShowController = Ember.Controller.extend({
  isExpanded: false,

  actions: {
    expand: function() {
      this.set('isExpanded', true);
    }
  }
});
<script type="text/x-handlebars" data-template-name="posts/show">
  <h2>{{model.title}}</h2>

  <pre>
    {{#if isExpanded}}
      {{model.body}}
    {{else}}
      {{truncate model.body length=20}} <a href="#" {{action "expand"}}>もっと見る</a>
    {{/if}}
  </pre>

  {{link-to "戻る" "posts.index"}}
</script>

テンプレートで{{action "expand"}}と記述している点がポイントです。 このactionヘルパーは、ユーザがDOMをクリックした際Controllerに対して発火するアクション名を指定します。 アクションがControllerでハンドルされない場合はRouteに発火します。

ちなみに、きっかけとなるDOMのイベントを変更したい場合はonオプションを利用し、アクションが発火するオブジェクトを変更したい場合はtargetオプションを利用します。

{{action "someEvent" on="mouseMove"}}
{{action "someEvent" target=otherObject}}

ここまでで、⁠もっと見る」が機能するようになりました。 しかしながら、一度「もっと見る」をクリックすると、他の記事を表示しても全文が表示されるようになってしまいます。

Ember Starter Kit

なぜこのような動きをするかというと、Controllerは一度生成されるとそれ以降は紐づくmodelが差し替わる形で再利用されるからです。 画面遷移しても状態を記憶しておきたい場合は便利ですが、今回の例では画面遷移で情報は初期化することにしましょう。

コントローラの初期化

画面を遷移する際、RouteがControllerとmodelを紐付ける部分を差し替えたり独自処理を付け加えることができます。 その際、利用できるのがRoutesetupControllerメソッドです。

setupControllerのデフォルトの実装は次のようになっています。

App.SomeRoute = Ember.Route.extend({
  setupController: function(controller, model) {
    controller.set('model', model);
  }
});

このsetupControllerを上書きすることで、独自処理を行います。

App.SomeRoute = Ember.Route.extend({
  setupController: function(controller, model) {
    controller.set('model', model);

    // ここで独自の処理を行います
  }
});

では、Routeを次の内容で書き換えてください。

App.PostsShowRoute = Ember.Route.extend({
  model: function(params) {
    return this.modelFor('posts').filter(function(post) {
      return post.id === Number(params.post_id);
    })[0];
  },

  setupController: function(controller, model) {
    controller.set('model', model);
    controller.set('isExpanded', false);
  }
});

ポイントはsetupControllerメソッドを定義して、その中でcontroller.set('isExpanded', false);としてコントローラのプロパティを初期化している部分です。

これにより、画面を切り替えるたびに記事の本文が省略して表示されるようになりました。

Ember Starter Kit

さて、今は記事の本文だけを省略して表示しましたが、もし他の画面でも省略して表示したい項目が出てきた場合はどうすればよいでしょうか? それぞれの画面を担当するコントローラとテンプレートに同じような処理を実装していくことはできそうですが、同じような処理を何度も書くのはできれば避けたいところです。

そんなときは、コントローラでの状態管理をやめて「省略して表示する機能を持った部品」を用意することにしましょう。 これを実現するためにEmber.jsにはComponentと呼ばれる機能があります。

Component

Componentは画面上のひとまとまりの部品をカプセル化したものです。

例えば、次のようなComponentを定義した場合を考えてみましょう。

Componentのクラス定義
App.HelloWorldComponent = Ember.Component.extend({
  message: 'Hello World'
});
Componentのテンプレート
<script type="text/x-handlebars" data-template-name="components/hello-world">
  <b>{{message}} !</b>
</script>

ポイントは次のとおりです。

  • クラスとテンプレートをセットで定義します。
  • Component名は、クラス名からComponentを削除し、単語の区切りを-(ハイフン)で表現したものになります。

    テンプレート名はcomponents/+Component名です。

    クラス名 Component名 テンプレート名
    HelloWorldComponent hello-world components/hello-world

    また、このテンプレートのコンテキストはComponent自身です。

  • Componentを使う際はテンプレートの任意の場所でComponent名を記述します。

    He said, {{hello-world}}
  • Componentを使う際、必要に応じてComponentのプロパティを設定できます。

    He said, {{hello-world message="This is awesome"}}
  • 描画されたHTMLはdivタグで囲まれます。

    他のタグに変更する場合、tagNameプロパティを上書きします。

    {{hello-world tagName="span"}}

    また、タグを生成したくない場合はtagNameに空文字を与えます。

    {{hello-world tagName=""}}
    
  • Component名は必ず2単語以上で-(ハイフン)を含む必要があります。

    これは、将来的にはWeb Componentsとの統合が視野に入れられているためです(Web Componentsはタグ名に-(ハイフン)を含む必要があります⁠⁠。

    Ember.js 1.11.0からは、Componentを利用する際は{{hello-world}}ではなく、<hello-world>と記述できるようになる予定です(参照:The Road to Ember 2.0 RFC #15⁠。

  • テンプレートのみで十分な場合、クラス定義は省略可能です。

今回はこのComponentを使って、テキストを省略して表示する機能をもったパーツを作成してみましょう。

App.TruncateTextComponent = Ember.Component.extend({
  text: null,
  length: 20,
  isExpanded: false,
  expandText: 'もっと見る',

  actions: {
    expand: function() {
      this.set('isExpanded', true);
    }
  }
});

ComponentはControllerと同様、アクションを定義できます。

次はComponentのテンプレートを作成します。

<script type="text/x-handlebars" data-template-name="components/truncate-text">
  {{#if isExpanded}}
    {{text}}
  {{else}}
    {{truncate text length=length}} <a href="#" {{action "expand"}}>{{expandText}}</a>
  {{/if}}
</script>

これでComponentを用意できました。 では、このComponentを実際に使ってみましょう。

記事詳細のテンプレートを以下のように書き換えてください。

<script type="text/x-handlebars" data-template-name="posts/show">
  <h2>{{model.title}}</h2>

  <pre>
    {{truncate-text text=model.body}}
  </pre>

  {{link-to "戻る" "posts.index"}}
</script>

Componentは画面が切り替わると破棄されるため、コントローラで「記事を省略して表示するかどうか」のフラグは不要になりました。 RoutesetupControllerでコントローラの初期化をしている部分を削除しましょう。

App.PostsShowRoute = Ember.Route.extend({
  model: function(params) {
    return this.modelFor('posts').filter(function(post) {
      return post.id === Number(params.post_id);
    })[0];
  }
});

そうすると、App.PostsShowControllerも定義する必要ななくなるので削除します。

これで、コントローラで実装した場合と違って、わざわざフラグを初期化しなくてもそれぞれの記事ごとに折り畳みの状態が保持されるようになりました。

Ember Starter Kit

まとめ

今回はユーザのインタラクションを受け取る方法を解説しました。

Controller、Componentは今回紹介したもの以外にもまだまだ機能があります。 それらについて今後の記事で随時解説する予定です。

次回はEmber.jsが提供する汎用的な機能を解説します。

おすすめ記事

記事・ニュース一覧