実践入門 Ember.js

第6回実践:ショッピングカート②(Ember.Object, Observer)

前回はショッピングカートを作成を通じて、Ember.jsの汎用的な機能を解説しました。 今回は前回作成したショッピングカートを拡張しつつ、さらに他のEmber.jsの機能を紹介します。

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

Ember Starter Kit

前回からの差分は次のとおりです。

  • カートの中身が保存される
  • 注文画面で商品の個数を変更できる
  • 注文を確定できる

前準備

さて、さっそくショッピングカートを拡張して……と進みたいところですが、まずは少しだけ準備をしてから進むことにしましょう。実はつい先日の2月7日にEmber.js 1.10.0がリリースされました。

このバージョンからテンプレート記述言語がHandlebarsからHTMLBarsに変更になりました。HTMLBarsはHnadlebarsを元にEmber.jsのために実装されたテンプレート記述言語で、より柔軟な記述とパフォーマンス改善を目的としています。とは言っても、まったく新しい文法になるわけではありません。Handlebarsの機能に追加して、今後段階的に新しい機能が追加されていく予定です。

詳しい変更点とEmber.js 1.9.1からのアップデート手順はEmber.js 1.10.0のリリースノートで解説されています。

本稿でもこの手順にしたがってEmber.js 1.10.0にアップデートすることにしましょう。

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

Ember.js のバージョンアップ

特に重要な点は次の2つです。

handlebars.jsの代わりにember-template-compiler.jsを読み込む

今までhandlebars.jsをscriptタグで読み込んで利用していましたが、Ember.js 1.10.0からはHTMLBarsを同梱したember-template-compiler.jsを利用します[1]⁠。

ember.jsの代わりにember.debug.jsを読み込む

開発用のデバッグ出力機能が充実したember.debug.jsと、本番用にデバッグ処理をなくしてパフォーマンスを向上させたember.prod.jsがあります。Ember.js 1.9.1まで開発用のファイル名はember.jsだったのですが、開発用・本番用の区別をしやすくするために名前が変更されました。

さて、これらを踏まえて準備しましょう。第5回で作成したアプリケーションを次のように変更します。

  1. まずは、必要なファイルをダウンロードしてlibsディレクトリに保存します。
  • jquery-2.1.3.min.js2.1.3
  • ember.debug.js1.10.0
  • ember-template-compiler1.10.0
  1. index.htmlのscriptタグを以下のように記述します。

    <script src="libs/jquery-2.1.3.js"></script>
    <script src="libs/ember-template-compiler.js"></script>
    <script src="libs/ember.debug.js"></script>

以上で準備完了です。では、さっそくショッピングカートに機能を追加していきましょう。

カートの保存

ここまでのショッピングカートだとブラウザをリロードするとカートの中身が失われてしまっていました。これでは安心して買い物ができません。そこでカートの中身をlocalStorageに保存することで、ブラウザをリロードしてもカートの中身が失われないようにします。

方針は次のとおりです。

  1. 商品をカートに入れたタイミングで商品IDをlocalStorageに保存する
  2. 画面を表示したタイミングでlocalStorageから商品のIDを読み出しカートの中身を復元する

商品IDを保存する

まずはCartControllerにカートの中身を保存するメソッドを追加します。

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

  save: function() {
    var ids = JSON.stringify(this.mapBy('id'));

    localStorage.setItem('cart-product-ids', ids);
  }
}

localStorageに保存できる値は文字列のみなので、JSON.stringify()しているのがポイントです。

続いて、ProductsRouteaddCartアクションでsave()メソッドを呼びます。

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

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

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

これでカートに追加した商品のIDをlocalStorageに保存できるようになりました。まだカートの内容を復元する処理を入れていないので画面からの確認はできませんが、開発者コンソールからであれば確認できます。

いくつかの商品をカートに入れた状態で次のコードをコンソールに打ち込んでみてください。

localStorage.getItem('cart-product-ids');

選択した商品のIDが表示されたでしょうか?

さて、ここまでで商品IDを保存できるようになりましたが、このIDから商品を辿るにはどうすればよいでしょうか? 商品一覧はProductsRouteの中にだけ存在しているので、今のままではCartControllerから商品一覧を取得できません。

そこで、商品管理の仕組みを整理して必要に応じて参照できるようにしておきましょう。Ember.ObjectというEmber.jsが提供するクラスの仕組みを利用して、商品オブジェクトを問い合わせるためのメソッドを持ったクラスを作成することにします[2]⁠。

Ember.Object

「商品」をクラスとして表現し、IDから商品を取得するクラスメソッドを作成します。

App.Product = Ember.Object.extend({
  id: null,
  name: null,
  price: 0,
  url: null
});

今まで特に解説していませんでしたが、extend()メソッドを使うことで既存のクラスを継承して新しいクラスを定義できます。その際、引数のオブジェクトに初期値とメソッドを指定します。

App.Product.reopenClass({
  data: [],

  all: function() {
    return App.Product.data;
  },

  find: function(id) {
    return this.all().findBy('id', Number(id));
  }
});

reopenClass()メソッドを使うと、クラスを拡張できます。引数としてオブジェクトを渡すと、プロパティやメソッドがクラスに定義されます。

上記の例では、App.Product.dataというプロパティとApp.Product.all()App.Product.find()というメソッドが定義されます。

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

商品データを用意します。ここで登場したpushObjects()メソッドは、複数の要素をまとめて配列に追加するためのメソッドです。

また、create()メソッドを使うことでインスタンスを生成できます。

これで商品データの準備と商品を問い合わせるメソッドが完成しました。App.Product.all()を使うと商品全件を、App.Product.find(id)を使うとIDを元に1件の商品を取得できます。

ProductsRoutemodel()メソッドでもApp.Product.all()メソッドを使うようにしましょう。

App.ProductsRoute = Ember.Route.extend({
  model: function() {
    return App.Product.all();
  },

  // ...
});

カートを復元する

次はカートを復元する処理を組み込みます。CartControllerにカートの中身を復元するメソッドを追加します。

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

  restore: function() {
    var idsString = localStorage.getItem('cart-product-ids');
    var ids;
    if (idsString) {
      ids = JSON.parse(idsString);
    } else {
      ids = [];
    }
    var products = ids.map(function(id) {
      return App.Product.find(id);
    });
    products = products.compact();
    this.set('model', products);
  }
});

商品IDはJSON.stringify()を使って文字列で保存してあるため、読み出すときにはJSON.parse()を使って配列に変換しています。

あとは、restore()メソッドを画面表示時に実行できればカートの中身を復元できそうですね。Ember.jsで「アプリケーション起動時に実行される処理」を設定するにはinitializerを利用します。

App.initializer({
  name: 'restore-cart',

  initialize: function(container, app) {
    container.lookup('controller:cart').restore();
  }
});

このinitializerについて解説します。

name

initializerの名前です。複数のinitializer同士の間で実行順序を制御したいときに利用できます。

initialize

アプリケーション初期化時に実行される関数です。実行したい処理を記述します。

containerinitializeの第一引数)

Ember.jsのDIコンテナです。lookupメソッドに「タイプ:名前」の形式で問い合わせると指定のオブジェクト取得できます。ここではCartControllerのインスタンスを取得しています。

appinitializeの第二引数)

Ember.jsアプリケーションのインスタンスです。Appと同じオブジェクトが取得できます。

以上、initializerの解説でした。

ここまでで、画面を表示した際にカートを復元できるようになりました。

Ember Starter Kit

まとめて表示する

カートの中身を保存できるようになりましたが、今のカートでは商品をひとつひとつ表示しているだけなので同じ商品を複数選択している場合の見通しがよくありません。そこで、⁠商品」という単位ではなく、商品+個数+小計という「明細」の単位でカートを表示してみましょう。さきほど登場したEmber.Objectを使って、⁠明細」を表すクラスを定義することにします。

App.OrderLine = Ember.Object.extend({
  product: null,
  count: 0,
  price: function() {
    return this.get('product.price') * this.get('count');
  }.property('product.price', 'count')
});

クラスを定義する際には、Computed Propertyも一緒に定義できます。

続いてCartControllerでは、Productを直接参照する代わりにこのOrderLineを利用して選択した商品を管理することにします。CartControllermodelを変更することになるため、今までmapBy()pushObject()など商品の配列に対して操作していた部分がそのままでは使えなくなります。CartController全体を書き直すことにしましょう。

App.CartController = Ember.ArrayController.extend({
  totalPrice: function() {
    return this.mapBy('price').reduce(function(total, price) {
      return total + price;
    }, 0);
  }.property('@each.price'),

  addProduct: function(product) {
    var orderLine = this.get('model').findBy('product', product);

    if (!orderLine) {
      orderLine = App.OrderLine.create({
        product: product
      });
      this.pushObject(orderLine);
    }

    orderLine.set('count', orderLine.get('count') + 1);

  save: function() {
    var ids = [];
    this.forEach(function(orderLine) {
      var i;
      var count = orderLine.get('count');
      var productId = orderLine.get('product.id');
      for (i = 0; i < count; i++) {
        ids.push(productId);
      }
    });
    localStorage.setItem('cart-product-ids', JSON.stringify(ids));
  },

  restore: function() {
    var idsString = localStorage.getItem('cart-product-ids');
    var ids;
    if (idsString) {
      ids = JSON.parse(idsString);
    } else {
      ids = [];
    }
    var products = ids.map(function(id) {
      return App.Product.find(id);
    });
    products = products.compact();
    products.forEach(function(product) {
      this.addProduct(product);
    }, this);
  }
});

同様に、ProductsRouteCartControllerpushObject()メソッドを呼んでいた部分もaddProduct()メソッドを使うように変更しましょう。

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

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

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

cartテンプレートも同様に変更します。

<h1>注文確認</h1>

<table>
  <tr>
    <th>商品名</th>
    <th>価格(USD)</th>
    <th>個数</th>
    <th>小計</th>
  </tr>
  {{#each orderLine in model}}
    <tr>
      <td>{{orderLine.product.name}}</td>
      <td>{{orderLine.product.price}}</td>
      <td>{{orderLine.count}}</td>
      <td>${{orderLine.price}}</td>
    </tr>
  {{/each}}
</table>

<hr>

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

見やすさのために<li>の代わりに<table>を使うようにしました。次のCSSを追記してもう少しだけ見栄えを整えておきましょう。

th, td {
  padding: 4px 16px;
}

td {
  border-top: 1px solid #ccc;
  border-bottom: 1px solid #ccc;
  text-align: right;
}

これでカートが明細行で表示されるようになりました。

Ember Starter Kit

商品の個数変更

さて、せっかく明細を表示するようにしたので、カートの商品を確認している途中で購入予定の商品の個数を変更できるようにしてみましょう。

cartテンプレートにボタンを追加します。

{{#each orderLine in model}}{{/each}}中の<tr>タグの中にボタンを追加してください。

<td>
  <button {{action "increment" orderLine}}>+1</button>
  <button {{action "decrement" orderLine}}>-1</button>
</td>

これに対応するactionsCartControllerに定義します。

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

  actions: {
    increment: function(orderLine) {
      orderLine.incrementProperty('count');
    },

    decrement: function(orderLine) {
      orderLine.decrementProperty('count');
    }
  }
});
Ember Starter Kit

incrementProperty()decrementProperty()メソッドはEmber.Objectが提供するメソッドです。指定したプロパティの値を増減できます。

さて、実はさきほどの機能追加が原因で今まで動いていた部分が一部動かなくなっていることに気がついたでしょうか? それが何かというと、カートの中身を保存する部分です。⁠商品の個数を変更する」という操作を追加しましたが、そのタイミングでカートの保存は行っていませんでした。

必要な箇所すべてでCartControllersave()を呼び出せば再びカートの中身が保存されるようになりますが、今回の例では「カートの中身が変更した時点で自動で保存される」ようにしたいだけです。それに、何かカートの操作を追加するたびにsave()メソッドを呼び出すのは煩雑なうえに忘れがちになってしまいます。

Observer

Ember.jsにはObserverという機能があり、これを使うと値が変更されたタイミングで自動で実行されるメソッドを定義できます。ここではこのObserverを使って、カートの中身を保存することにしましょう。

CartControllersave()メソッドに次の記述を追加します。

save: function() {
  // ...
}.observes('@each.count')

このobserves()メソッドがポイントです。これまでsave()メソッドは必要になったタイミングで適宜実行していましたが、observes()メソッドを使うとsave()メソッドがオブザーバーとしてオブジェクトに設定されます。

observes()メソッドの引数には、Computed Propertyと同じく監視するプロパティ名を指定します[3]⁠。

ここでひとつ注意したいことがあります。カートの内容を復元する際にmodelに明細を追加しているため、 このままではカート復元中にsave()メソッドが実行されてしまいます。

これを防ぐため、カートの準備が整ったあとで自動保存するようにしましょう。

App.CartController = Ember.ArrayController.extend({
  isReady: false,

  // ...

  save: function() {
    if (!this.get('isReady')) {
      return;
    }

    // ...
  }.observes('@each.count')

  restore: function() {
    // ...

    this.set('isReady', true);
  }

  // ...
});

CartControllerisReadyというプロパティを追加し、ここでカートの準備が整ったたどうかを管理します。もしカートの準備が整ってなければ、自動保存は行いません。

Ember Starter Kit

購入

さて、ここまででカートの機能が充実してきました。次はいよいよ購入処理を実装します。

とは言っても、実際に何かを購入できるようにするわけではありません。⁠購入する」というリクエストを自分のアプリケーションに投げたつもりになってリクエストパラメータを組み立てるところまでを実装します。

まずはcartテンプレートに購入ボタンを追加します。

<button {{action "submit"}}>購入する</button>

このボタンに対応するactionsCartControllerに追記します。

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

  actions: {
    // ...

    submit: function() {
      var params = [];
      this.get('model').forEach(function(orderLine) {
        params.push({
          product_id: orderLine.get('product.id'),
          count: orderLine.get('count')
        });
      });

      alert('購入が完了しました。(リクエストパラメータ: ' + JSON.stringify(params) + ')');

      this.get('model').clear();

      this.transitionToRoute('products');
    }
  }
});

alert()を呼ぶ際に、組み立てたリクエストパラメータを表示してみています。ここでは、次の形でリクエストパラメータを組み立てました。

[
  {"product_id": 1, "count": 1},
  ...
]

アプリケーションサーバを用意したとすると、この形のパラメータを受け取って実際に購入処理を行うことになるでしょう。もちろん、アプリケーションサーバの都合に合わせて組み立てるパラメータの形を変更してもかまいません。

ここまでで、商品の選択から商品の購入の雰囲気を感じることはできたでしょうか。本来であれば名前や住所の入力、クレジットカードによる決済などがさらに必要だと考えられますが、 いずれもEmber.jsの入門記事の枠を超えてしまうため本稿では扱いません。

今回のアプリケーションは最終的にこのようになりました。

Ember Starter Kit

まとめ

前回・今回を通じてEmber.jsの汎用的な機能を解説しました。Ember.jsには他にもまだまだ多くの機能がありますが、比較的に利用頻度が多いと筆者が感じているものを取り上げました。

またEmber.js公式ガイドの"THE OBJECT MODEL"にも今回取り上げた機能が解説されています。さらなる理解のために、ぜひこちらにも目を通してみてください。

おすすめ記事

記事・ニュース一覧