実践入門 Ember.js

第5回実践:ショッピングカート①(Array, Computed Property)

前回はユーザのインタラクションを受け取る方法を解説しました。今回はこれまでの連載内容を踏まえつつ、Ember.jsが提供する汎用的な機能を解説します。

今回取り扱うEmber.jsアプリケーションはこちらです。

Ember Starter Kit

「商品を選択してカートに入れ、まとめて購入できる」というアプリケーションを作りながら、汎用的な機能をひとつずつ紹介していきます。

前準備

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

第3回の記事を参考にして必要なファイルを作成してください。

ファイルの作成が完了したら、今回のアプリケーションを作成するために画面と初期データの準備を行います。

まずはルーティングとデータを準備します。

App = Ember.Application.create();

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

  this.resource('cart');
});

App.ProductsRoute = Ember.Route.extend({
  model: function() {
    return [{
      id: 1,
      name: 'ステッカー',
      price: 6.0,
      url: 'http://devswag.com/products/ember-sticker'
    }, {
      id: 2,
      name: 'Tシャツ',
      price: 22.0,
      url: 'http://devswag.com/products/ember-js-tshirt'
    }, {
      id: 3,
      name: 'ぬいぐるみ',
      price: 10.0,
      url: 'http://devswag.com/products/ember-mascot-tomster'
    }];
  }
});

App.ProductsShowRoute = Ember.Route.extend({
  model: function(params) {
    return this.modelFor('products').filter(function(product) {
      return product.id === Number(params.id);
    })[0];
  }
});

次はテンプレートを準備します。

<script type="text/x-handlebars">
  <header>
    {{link-to "商品一覧" "products"}}
    {{link-to "カート" "cart"}}
  </header>

  <main>
    {{outlet}}
  </main>
</script>

<script type="text/x-handlebars" data-template-name="products/index">
  <h1>注目の商品</h1>

  <ul>
    {{#each product in model}}
      <li>{{link-to product.name "products.show" product}}</li>
    {{/each}}
  </ul>
</script>

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

  <a target="_blank" {{bind-attr href=model.url}}>詳しく見る</a>

  ${{model.price}} USD
  <button>カートに入れる</button>
</script>

<script type="text/x-handlebars" data-template-name="cart">
  <h1>注文確認</h1>

  カートは空です。
</script>

さて、ここまでで基本となる画面を準備しました。

Ember Starter Kit

ではこれからカートを動くようにしていきましょう。

ArrayController

まずは、選択した商品を保持しておくための置き場所を作成します。

App.CartController = Ember.ArrayController.extend({
});

ここで初めて登場したArrayControllerについて説明します。Ember.ArrayControllerEmber.Controllerの仲間ですが、その名の通り配列を扱うことを得意とします。

ArrayControllerには配列操作のメソッドが数多く定義されており、modelプロパティに設定された配列に対して様々な操作ができます(具体的なメソッドについては後述します⁠⁠。

JavaScriptネイティブのArrayに定義されているメソッドと比較した時の大きな違いは、ArrayControllerはEmber.jsのデータバインディングと連携することを前提としていて、ArrayControllerへの操作は自動的にテンプレートを更新するようになっています[1]⁠。

今回、カートに追加した商品はこのCartControllerに保存することにします。

ProductsRouteactionsを追記してください。

App.ProductsRoute = Ember.Route.extend({
  // ...

  actions: {
    addCart: function(product) {
      this.controllerFor('cart').pushObject(product);

      this.transitionTo('cart');
    }
  }
});

products/showテンプレート中の「カートに入れる」ボタンにactionを指定します。

<button {{action "addCart" model}}>カートに入れる</button>

actionヘルパーは、アクション名に続いて引数を与えられます。ここでは商品を引数に指定しています。

次はcartのテンプレートを以下の内容で書き換えてください。

<h1>注文確認</h1>

{{model.length}}個の商品がカートに入っています。

<ul>
  {{#each product in model}}
    <li>{{product.name}}: ${{product.price}} USD</li>
  {{/each}}
</ul>

ここまでで、⁠カートに入れる」ボタンをクリックして商品を保存できるようになりました。

Ember Starter Kit

ではここで、新しく出てきた機能を解説します。

Route#controllerFor()

Routeに定義されているcontrollerForメソッドを使うことで、任意のコントローラのインスタンスを取得できます。ここではカートに商品を追加するために、ProductsRouteからCartControllerにアクセスしています。

ArrayController#pushObject

配列に要素を追加します。ネイティブのArraypushメソッドに相当します。実際はmodelプロパティに設定された配列に要素が追加されます。

さて、ArrayControllerのメソッドがでてきたところで、Ember.jsのArrayについて少し詳しく見てみることにしましょう。

Array

ArrayControllerに定義されている配列操作のメソッドは、実はEmber.jsによってJavaScriptネイティブのArrayのプロトタイプにも定義されています。

JavaScriptにおけるプロトタイプ拡張には賛否ありますが、Ember.jsでは前向きに取り入れることで便利なメソッドを開発者に提供しています。

ここでは、先ほど登場したpushObjectを含めEmber.jsが提供する配列操作のメソッドのいくつかを紹介します。

pushObject()

配列に要素を追加します。

var array = ['hi'];
array.pushObject('yo');
array; //=> ['hi', 'yo']
addObject()

pushObjectによく似ていますが、追加しようとしている要素がすでに存在する場合には何も行いません。

var array = ['hi'];
array.addObject('hi');
array; //=> ['hi']
findBy()

配列の中から特定の要素を探します。要素のプロパティに対して探索条件を指定できます。

var array = [{name: 'a'}, {name: 'b'}, {name: 'c'}];
array.findBy('name', 'b'); //=> {name: 'b'}
mapBy()

配列中の要素の特定のプロパティを取り出して新しい配列を返します。

var array = [{name: 'a'}, {name: 'b'}, {name: 'c'}];
array.mapBy('name'); //=> ['a', 'b', 'c']

この他にもたくさんのメソッドが提供されています。ぜひ公式リファレンスに目を通してみてください。

ちなみに、先ほど紹介したfindByを使うと、App.ProductsShowRoutemodelメソッドの実装を簡素化できます。

App.ProductsShowRoute = Ember.Route.extend({
  model: function(params) {
    return this.modelFor('products').findBy('id', Number(params.id));
  }
});

プロトタイプ拡張を禁止する

また、どうしてもプロトタイプ拡張が好きではない方は、Ember.jsを読み込む前に次の変数を定義しておくことでプロトタイプ拡張を禁止できます。

<script>
  window.EmberENV = {
    EXTEND_PROTOTYPES: false
  };
</script>
<script src="libs/ember.js"></script>

この状態でEmber.jsが提供するArrayのメソッドを利用したい場合、適宜Ember.Aメソッドを利用して配列を拡張する必要があります参考⁠。

var array = [];
typeof array.pushObject; //=> 'undefined'

array = Ember.A(array);
typeof array.pushObject; //=> 'function'

横道に逸れてしまいましたが、以上がEmber.jsが拡張しているArrayの話になります。

ではカートの開発に戻ることにしましょう。

Computed Property

ここまでで商品をカートに追加できるようになりました。ただ、今のカート画面は単純に商品を並べて表示しているだけなので親切ではありませんね。

そこで、⁠何種類の商品がカートに入っているか」⁠合計金額はいくらなのか」を表示するようにしましょう。

まずは「何種類の商品がカートに入っているか」を表示します。

CartControllerを次のように変更します。

App.CartController = Ember.ArrayController.extend({
  uniqProductCount: function() {
    return this.mapBy('id').uniq().get('length');
  }.property('length')
});

cartテンプレートの商品数を表示している部分を次のように変更します。

{{uniqProductCount}}種類({{model.length}}個)の商品がカートに入っています。

さて、ここでは次のような見慣れないコードが出てきました。

function() {
  // ...
}.property()

ここではComputed Propertyと呼ばれるEmber.jsの機能を使っています。

Computed Propertyとは

Computed Propertyについて一言で説明すると「他のプロパティから算出されるプロパティを定義する機能」です。これについて具体的に見ていくとしましょう。

このFunctionproperty()メソッドは、Ember.jsがネイティブのFunctionのプロトタイプを拡張して提供しているメソッドで、Ember.ComputedPropertyオブジェクトを返します。property()メソッドの引数には、依存するプロパティ名を指定します。

今回のCartControllerのケースだと、⁠カートに入っている商品の種類数uniqProductCount⁠」は「商品の入っている商品の総数length⁠」から算出されます。

もし複数のプロパティから算出されるComputed Propertyを定義したい場合には、そのプロパティ名を列挙します。

fullName: function() {
  return this.get('firstName') + ' ' + this.get('lastName');
}.property('firstName', 'lastName')

この例では、fullNameを参照するとfirstNamelastNameからfullNameが算出されます。一度算出された値はキャッシュされ、firstNamelastNameのどちらかが変更されるまでfullNameの結果は再計算されません。

ここまで見ると、Computed Propertyは少し変わったメソッドのように見えるかもしれません。次はメソッドとの決定的な違いを見てきましょう。

メソッドとの違い

Ember.jsのテンプレートの仕組みを考えてみると、その違いに気づくことができます。

Ember.jsのテンプレートはプロパティの変更を検知して自動でHTMLを更新します。通常のメソッドの場合、あるプロパティの変更によりメソッドの戻り値が変わったとしてもテンプレートからはそのことがわかりません。一方Computed Propertyの場合、依存するプロパティが変更されるとHTMLの再描画が必要であるとテンプレートが検知できます[2]⁠。

プロトタイプ拡張

Function.prototype.property()のプロトタイプ拡張を禁止したい場合は、先ほどのArrayの場合と同じ変数を定義することで禁止できます。

その場合、次のようにEmber.computedメソッドを経由してComputed Propertyを利用します。

Ember.computed([依存プロパティ名1, 依存プロパティ名2, ...], function() {
  // ...
});

さて、ではComputed Propertyについてより理解を深めるため、続いて商品の合計金額を表示してみます。

CartControllerにComputed Propertyの定義を追加します。

App.CartController = Ember.ArrayController.extend({
  // ...

  totalPrice: function() {
    return this.mapBy('price').reduce(function(total, price) {
      return total + price;
    }, 0);
  }.property('@each.price')
});

cartテンプレートに次の行を追記します。

合計金額: ${{totalPrice}} USD

property()メソッドに指定している@eachは、配列の各要の特定のプロパティへの依存を示す素識別子です。ここでは合計金額はカートに追加されている商品の金額に依存していることを表しています。

以上でComputed Propertyについての解説は終わりです。

最後に、CSSを追加して少しだけ見栄えを整えておきましょう。

html, body {
  margin: 20px;
  color: #222;
}

header, main {
  max-width: 800px;
  margin: auto;
}

header {
  border-bottom: #ddd solid 1px;
}

a {
  color: #222;
}

a:hover {
  color: #555;
}

header a {
  display: inline-block;
  text-decoration: none;
  padding: 10px 2px;
  width: 100px;
  text-align: center;
  border-radius: 4px 4px 0 0;
  border: 1px solid #ddd;
  border-bottom: none;
}

header a:hover {
  background-color: #eee;
}

header a.active, header a.active:hover {
  color: #222;
  cursor: default;
  background-color: white;
  border-bottom: white solid 1px;
  margin-bottom: -1px;
}

button {
  border: 1px solid transparent;
  border-radius: 4px;
  background-color: white;
  color: black;
  border: #ddd solid 1px;
  padding: 4px;
}

button:hover {
  background-color: #eee;
}

ここまでで、アプリケーションは次のようになりました。

Ember Starter Kit

まとめ

今回はEmber.jsが提供するArrayのメソッドと、Computed Propertyを解説しました。しかし、今回作成したアプリケーションはショッピングカートには次のような機能がなく、不十分です。

  • カートの中身が保存されない
  • 一度選択した商品は取り消しできない
  • 商品を購入できない

次回はこういったショッピングカートとしては当たり前の機能を作成しつつ、今回紹介できなかったEmber.jsの汎用機能を紹介する予定です。どうぞご期待ください。

おすすめ記事

記事・ニュース一覧