実践入門 Ember.js

第3回画面遷移(Routing)

前回はURLと画面表示の基礎を解説しました。今回はEmber.jsでの画面遷移について、前回の内容を踏まえた上でもう少し踏み込んだ解説をします。

Ember.jsの「画面遷移」とはページ全体を再描画するのではなく、画面を部分的に書き換える動きをします。これがどういうことかを、例えばヘッダとサイドメニュー、メインコンテンツの表示部分があるアプリケーションを例にあげて考えてみましょう。

サイドメニューをクリックしてメインコンテンツを切り替えるという挙動を考えた場合、一般的なWebサイトであれば画面遷移が発生しヘッダ・サイドメニューを含めた画面のすべてが再描画されることでしょう。その一方でEmber.jsアプリケーションの場合はメインコンテンツのみが書き換えられます。

さて、それではさっそく今回の解説を進めていきましょう。今回は次のようなEmber.jsアプリケーションの仕組みを理解することを目標にします。

Ember Starter Kit

前準備

  1. まずはEmber.jsを使うために必要なライブラリをダウンロードします。

  2. 次のindex.htmlapp.jsを作成します。

    index.html
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="utf-8" />
        <title>実践入門 Ember.js</title>
        <link rel="stylesheet" href="style.css">
        <script src="libs/jquery-2.1.3.min.js"></script>
        <script src="libs/handlebars-v2.0.0.js"></script>
        <script src="libs/ember.js"></script>
        <script src="app.js"></script>
      </head>
      <body>
      </body>
    </html>
    app.js
    App = Ember.Application.create();

    また、style.cssという名前で、中身は空のCSSファイルを作成してください。

  3. これらを以下のディレクトリ構成で配置します。

    .
    ├─⁠─ app.js
    ├─⁠─ index.html
    ├─⁠─ libs
    │    ├─⁠─ ember.js
    │    ├─⁠─ handlebars-v2.0.0.js
    │    └─⁠─ jquery-2.1.3.min.js
    └─⁠─ style.css

以降、特に指定のない場合、JavaScriptはapp.jsに、Handlebarsのテンプレートはindex.htmlのbodyタグの中に、CSSはstyle.cssに記述することにします。

Routing

前回の解説では、アプリケーションの画面1つひとつがRouteに対応していると解説しました。では、画面を部分的に書き換えるというのはどう実現すればよいのでしょうか?

実は、Ember.jsではRouteを階層化することができ、入れ子になった子のRouteを切り替えることで画面を部分的に更新できます。

まずはルーティングを定義しておきましょう。

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

あわせてテンプレートを定義しておきます。

<script type="text/x-handlebars">
  <header>
    <h1>Ember.js ブログ</h1>
  </header>

  {{outlet}}
</script>

<script type="text/x-handlebars" data-template-name="index">
  <div id="hello-message">ようこそ</div>

  {{link-to "記事を見る" "posts"}}
</script>

<script type="text/x-handlebars" data-template-name="posts">
  <aside id="sidebar">
    <ul>
      {{#each post in model}}
        <li>{{link-to post.title "posts.show" post}}</li>
      {{/each}}
    </ul>
  </aside>

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

<script type="text/x-handlebars" data-template-name="posts/index">
  記事を選択してください。
</script>

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

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

また、動作確認用のデータとRouteを定義します。ここではJavaScriptファイルの中にデータを埋め込みましたが、実際にアプリケーションを作成する際はサーバからデータを取得してくることになるでしょう。この方法については後ほど解説します。

// データ
var posts = [{
  id: 1,
  title: 'はじめての Ember.js',
  body: 'これから Ember.js を始めようという方向けの記事です。'
}, {
  id: 2,
  title: '公式サイトの歩き方',
  body: 'http://emberjs.com/ の解説です。'
}, {
  id: 3,
  title: '2.0 のロードマップ',
  body: 'Ember.js 2.0 のロードマップはこちらで公開されています。https://github.com/emberjs/rfcs/pull/15'
}];

// Route 定義

App.PostsRoute = Ember.Route.extend({
  model: function() {
    return posts;
  }
});

App.PostsShowRoute = Ember.Route.extend({
  model: function(params) {
    var id = Number(params.post_id);
    var posts = this.modelFor('posts');

    return posts.filter(function(post) {
      return post.id === id;
    })[0];
  }
});

見栄えを整えるために、CSSも作成しておきます。

html, body {
  margin: 0;
  color: #444;
}

header {
  padding: 0 10px;
  background-color: #e15e45;
  color: #fcfcfc;
  text-shadow: rgba(0, 0, 0, 0.8) 0 1px 0;
  box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.2) inset;
}

header h1 {
  margin: 0;
}

#sidebar {
  float: left;
  width: 200px;
  height: 300px;
  color: #ba6051;
  text-shadow: 0 1px #faeeec;
  border: 1px solid #ccc;
}

#sidebar ul {
  padding: 0;
  margin: 0;
  list-style: none;
}

#sidebar li {
  padding: 5px 10px;
}

#sidebar li a {
  display: inline-block;
  height: 25px;
  width: 100%;
  color: #ba6051;
  text-shadow: rgba(0, 0, 0, 0.8) 0 1px 0;
  padding: 10px 2px;
  text-decoration: none;
}

#sidebar li .active {
  background-color: #eae0e1;
}

main {
  margin-left: 200px;
  height: 300px;
  padding-left: 10px;
  background-color: #faf2f1;
  border-top: 1px solid #ccc;
  border-right: 1px solid #ccc;
  border-bottom: 1px solid #ccc;
}

main ul {
  padding: 0;
  margin: 0;
  list-style: none;
}

では、この状態でどのようなURLが定義されるのかEmber Inspectorを使って確認してみましょう[1]⁠。

いくつか自動で生成されるRouteがありますが、これについては後ほど解説します。ここでは次のRouteに着目してください。

Nested Route
画像

これらのRouteについて詳しく見ていきます。

  1. PostsRoute/#/postsで始まるURLにアクセスされた際にアクティブになります。記事一覧を表示します。以下2つのRouteの親です。
  2. PostsIndexRoute/#/postsにアクセスされた際にアクティブになります。まだ記事が選択されていない状態なので、⁠記事を選択してください。」というメッセージを表示します。
  3. PostsShowRoute/#/posts/:post_idにアクセスされた際にアクティブになります。一件の記事を表示します。

特定のRouteがアクティブになる際、まずその親のRouteがアクティブになってから子のRouteがアクティブになります。これがどういうことか具体例で説明します。

/#/postsにアクセスされた場合

対応するRouteはPostsIndexRouteなので、まずはその親のPostsRouteがアクティブになりテンプレートを描画します。

次に、PostsIndexRouteがアクティブになり、親であるPostsRouteのテンプレート中のoutletPostsIndexRoute自身のテンプレートを描画します。

/#/posts/:post_idにアクセスされた場合

対応するRouteはPostsShowRouteなので、まずはその親のPostsRouteがアクティブになりテンプレートを描画します。

次に、PostsShowRouteがアクティブになり、親であるPostsRouteのテンプレート中のoutletPostsShowRoute自身のテンプレートを描画します。

前回の記事で、アプリケーションのレイアウトを指定する際にはapplicationテンプレートでoutletを使うと解説しましたが、その正体はここで紹介した仕組みと同じです。実はすべてのRouteの親を辿ると、ApplicationRouteに行き着くのです。

Routing
画像

ここで作成したアプリケーションを実際に動かしてみて、画面とURLが対応していることを確認してみてください。

さて、イメージが確認できたところでコードを細かく見ていきましょう。

Route / Resource

まずはRouterで記述可能なroute()resource()について解説します。

さきほどのルーティングの例ではreoute()resource()を使っていました。これらは似たような機能を持つメソッドなのですが、 実はURLに対応させるRoute名に違いがあります。

route()だと階層化した際に親Routeの名前を引き継ぐのに対し、resource()では親の有無に関係なくRoute名が決まります。

reoute()resource()の違い
App.Router.map(function() {
  this.resource('hi', function() {
    this.resource('hoi'); // HoiRoute  -> 親の Route 名 Hi を受け継ぎません
    this.route('ho');     // HiHoRoute -> 親の Route 名 Hi を受け継ぎます
  });
});

では、route()resource()はどうやって使い分ければよいのでしょうか?

公式ガイドによると、URLの対象が名詞である場合はresource()を、動詞もしくは形容詞の場合はroute()を使うべきであると記述されています。

これにしたがって今回のサンプルコードでは、次のように利用しています。

  • posts(記事)」(名詞)には resource()
  • show(表示する)」(動詞)には route()

Nested Route

Router.mapresource()の入れ子としてroute()を呼び出しています。こうして入れ子にすることで、Routeの親子関係を定義できます。

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

  • Route はいくつでも階層化することができます。

    また、route() / resource() を任意の組み合わせで入れ子にすることができます。

  • テンプレート名(data-template-name) は 親子関係にある Route 名を / で区切った名前が対応します。

  • this.modelFor('Route 名') では、親の Route の model() を参照できます。

    同一の親 Route 以下での画面遷移であれば model() の呼び出し結果は再計算されないので、高速に画面を描画することができます。

実は今回扱うアプリケーションは、前回扱ったアプリケーションとほとんど同じような機能を持っています。

  • ブログ記事一覧を表示できる
  • ブログ記事の詳細を表示できる

大きく違うのは、今回のアプリケーションではRouteを階層化して常に記事一覧を表示しているところです。ではどういう場合にRouteを階層化させるとよいのでしょうか?

それは、アプリケーションの画面設計次第です。画面のパーツで共通で利用したい部分があればRouteを階層化させますし、切り替えたい場合には階層化しません。

Active

Routeがアクティブになっているとき、link-toヘルパが生成した「そのRouteへ画面遷移を行うリンク」にはactiveというCSSのクラスが自動で付与されます。このactiveクラスにスタイルをあてることで、現在表示されているリンクをハイライトすることができます。

今回のサンプルでもそのようなCSSを利用しています。

#sidebar li .active {
  background-color: #eae0e1;
}

また、このactiveというクラス名はカスタマイズ可能です。次のようにactiveClassオプションを利用して、任意のクラス名を指定することができます。

{{link-to post.title "posts.show" post activeClass="current"}}

上記の例では、PostsShowRouteがアクティブなときにはcurrentというクラスが付与されます。

Loading Data

さて、ここまでの例では手元にデータを用意して動作確認しました。しかし実際にアプリケーションを組み立てるとなると、リモートサーバから取得したデータを取り扱うというケースが多くなることでしょう。この項ではその方法を紹介します。

さきほど用意した手元のデータを、JavaScriptファイルから抜き出してJSONファイルとして保存します。

[{
  "id": 1,
  "title": "はじめての Ember.js",
  "body": "これから Ember.js を始めようという方向けの記事です。"
}, {
  "id": 2,
  "title": "公式サイトの歩き方",
  "body": "http://emberjs.com/ の解説です。"
}, {
  "id": 3,
  "title": "2.0 のロードマップ",
  "body": "Ember.js 2.0 のロードマップはこちらで公開されています。https://github.com/emberjs/rfcs/pull/15"
}]

これからこのJSONをXHRで取得するようコードを書き換えます。XHRではhttpもしくはhttpsプロトコルを利用する必要があるため、ここでは以下のURLに保存したJSONを利用します。

このURLはみなさまのお手元からも利用できます[2]⁠。

では、PostsRouteを書き換えましょう。

App.PostsRoute = Ember.Route.extend({
  model: function() {
    return $.getJSON('http://emberjs.jsbin.com/goqene/2.json');
  }
});

そしてブラウザをリロードしてみましょう。今までと変わらず記事一覧が表示されているでしょうか?

ブラウザの開発者ツールを利用して、JSONにHTTPリクエストが発行されていることを確認してみましょう。

Google Chrome 39だと、次のようにHTTPリクエストが発生していることが確認できます。

HTTP Request
画像

ところで、jQueryでJSONを扱うコードを書いたことがある方ほど、このコードに違和感を覚えたのではないでしょうか? なぜなら本来サーバから取得したJSONを扱うためには、次のように$.getJSONの引数として渡したコールバックの中からデータにアクセスする必要があるからです。

$.getJSON('http://emberjs.jsbin.com/goqene/2.json', function(posts) {
  // ここで posts にアクセスできる
});

では、ここで起こっていることを詳しく見てみましょう。

まず、$.getJSON()の戻り値はjqXHRオブジェクトです。このjqXHRオブジェクトはPromiseのインターフェースを実装しています。Promiseとは非同期を扱うためのパターンのひとつで、非同期処理開始時にコールバックを与えるの代わりに、任意のタイミングでコールバックを設定できます。今回は詳しく踏み込みませんので、詳しくはJavaScript Promiseの本をご覧ください。

コールバックを与えていた例をPromiseのインターフェースで書き直すと、次のように書けます。

$.getJSON('http://emberjs.jsbin.com/goqene/2.json').then(function(posts) {
  // ここで posts にアクセスできる
});

そしてEmber.jsのRouteにはmodel()でPromiseが返されると、非同期処理を待ってから画面遷移するという仕組みがあります。この仕組みのおかげで、データが手元にある場合でもリモートサーバから取得してくる場合でも、これらを特別に区別することなくデータを扱うことができます[3]⁠。

さて、ここまででリモートのデータを扱う方法をご紹介しました。しかし、リモートのサーバを相手にするとなると、通信に時間がかかったり失敗したりした場合のことを考えなくてはいけません。そんなとき、Ember.jsではどのようにすればよいのでしょうか?

Error handling

実は、そんな状況に対処する方法がEmber.jsには用意されています。

Substates

すべてのRouteにはloadingerrorという子Routeが自動的に用意される仕組みがあります。これはEmber.jsの用語でSubstatesと呼ばれ、次のタイミングでアクティブになります。

  • loadingRouteのmodel()が実行されている間アクティブになります。
  • errorRouteがテンプレートを描画するまでの間なんらかの例外が発生した場合にアクティブになります。

Ember.jsの命名規約で、それぞれRoute名にLoading / Errorという名前を追加したものがSubstatesのRoute名になります。例えば、PostsRouteの場合はPostsLoadingRoute / PostsErrorRouteです。また、一番親のRouteであるApplicationRouteに対応するSubstatesはLoadingRoute / ErrorRouteです。

この仕組みを利用すれば、modelを読込中であったりエラーが発生したという状況を開発者がハンドルすることができます。

では、それぞれの動作を確認してみましょう。

Loading Substate

次のRouteを作成してください。

App.LoadingRoute = Ember.Route.extend({
  activate: function() {
    console.log('読み込み中です');
  }
});

ここで利用しているactivate()メソッドは、Routeが有効になった際に実行されるメソッドです。ログの出力や画面表示に際して前処理を行いたい場合に利用します。

ここまでのJavaScriptを記述したところでアプリケーションを動かしてみましょう。開発者コンソールのログに「読み込み中です」と表示されたでしょうか?

コンソールに出力されるだけだと開発者にしかありがたみはないので、画面にも見込み中であることを表示しましょう。次のテンプレートを作成してください。

<script type="text/x-handlebars" data-template-name="loading">
  現在データを読込中です…
</script>

アプリケーションをリロードすると、JSON を取得している間は「現在データを読込中です…」が表示されていることを確認できます。

Error Substate

では、エラー時の挙動も確認してみましょう。次のRouteを定義してください。

App.ErrorRoute = Ember.Route.extend({
  activate: function() {
    console.log('エラーです');
  }
});

次はこのErrorRouteの挙動を確認するために、JSONのリクエスト先を変更しておきましょう。

App.PostsRoute = Ember.Route.extend({
  model: function() {
    return $.getJSON('/error.json');
  }
});

/error.jsonに応答できるファイルは存在しないので、サーバからはステータスコード404が返ってくるはずです。

この状態でアプリケーションを動かしてみると、コンソールにメッセージが出力されていることを確認できます。

次はエラーが発生したことを画面に表示しましょう。次のテンプレートを作成してください。

<script type="text/x-handlebars" data-template-name="error">
  おや、何か様子がおかしいです。
  {{link-to "もう一度読み込む" "posts"}}
</script>

アプリケーションを動かしてみると、JSONの取得に失敗して「おや、何か様子がおかしいです。」と表示されていることを確認できます。ここで「もう一度読み込む」のリンクををクリックすると、PostsRouteがアクティブになり、再度JSONの取得を試みることができます。ただ、先ほどJSONの URLを無効なものに書き換えてしまったため、何度試しても失敗するだけです。

ErrorRouteの動作が確認できたところで、PostsRoutemodel()を元に戻しておきましょう。

App.PostsRoute = Ember.Route.extend({
  model: function() {
    return $.getJSON('http://emberjs.jsbin.com/goqene/2.json');
  }
});

また、特にRouteで行いたい処理がなくエラー画面さえあれば十分だという場合、Route定義を省略してテンプレートだけを定義しておくことが可能です。独自処理が不要な場合に記述を省略できるのは、その他のRouteと全く同じですね。

今回の例ではPostsRouteでJSONを取得しているため、このRouteのloadingに対応しているLoadingRouteを利用しました。もしPostsShowRouteで一件の記事のJSONを取得するような場合であれば、PostsLoadingRouteを使うこともできます。

Substateでは子のイベントはハンドルできますが、親または兄弟のRouteのイベントはハンドルできません。また、親でハンドルされなければ、さらにその親のRouteでハンドルすることができます。

今回の解説の最初でRoute定義を確認した際に見慣れないRouteがいくつかありましたが、これらがここで紹介したRouteです。

Substates
画像

Events

Substatesの仕組みだと手軽にエラーをハンドリングできる一方で、エラーの種類によって処理を切り替えたいというケースには不向きです。そのような場合に利用できる仕組みがEventsです。

Routeは特定のタイミングでloading / errorイベントが発火します。このイベントをハンドルするためにはactionsというプロパティを設定し、そこでerror / loadingというイベントハンドラを定義します。

App.PostsRoute = Ember.Route.extend({
  // ...
  actions: {
    loading: function() {
      console.log('データを読込中です');
    },
    error: function() {
      console.log('エラーが発生しました');
    }
  }
});

errorイベントハンドラの第一引数には、エラーオブジェクトまたはPromiseがエラーとみなしたオブジェクトが渡ってきます。$.getJSON() の場合、HTTP リクエストに失敗するとjQueryが提供するjqXHRオブジェクトが渡されます。そこで次のようにして、HTTPステータスを参照してエラー時の処理を切り替えることができます。

App.PostsRoute = Ember.Route.extend({
  // ...
  actions: {
    error: function(jqXHR) {
      if (jqXHR.status === 404) {
        this.transitionTo('not_found');
      } else {
        this.transitionTo('something_went_wrong');
      }
    }
  }
});

ここで利用しているRoutetransitionTo('route 名', [モデル...])メソッドは画面遷移を行うためのメソッドです。テンプレートで利用しているlink-toヘルパと同じ使い方ができます。

ここでは次のようなテンプレートを定義することで、HTTPリクエストが404だった場合のエラーを特別扱いすることができます。

<script type="text/x-handlebars" data-template-name="not_found">
  お探しの記事は見つかりませんでした。
</script>

<script type="text/x-handlebars" data-template-name="something_went_wrong">
  おや、何か様子がおかしいです。
</script>

またEventsはSubstatesと同じく、Routeでハンドルされなかったイベントはその親のRouteでハンドルすることが可能です。

Location

ここまで、Ember.jsアプリケーションに割り当てられたURLは#から始まるフラグメントハッシュと呼ばれるものでした。ただ、状況によっては#/postsのようなURLではなく、/postsのようなURLでアプリケーションを管理したいという場合があります。

そのようなときは、次のようにHistory APIを利用して/で始まるURLを利用できます。

App.Router.reopen({
  location: 'history'
});

ただ、この場合にEmberアプリケーションで管理しているURLを完全にサポートするためには、Webサーバ側でも対応が必要になります。

また、Webページ中にEmberアプリケーションを埋め込んで利用したいため、Ember.jsアプリケーションの画面遷移の際にURLを更新してほしくないこともあります。

その場合には次の設定の行うことで、URLを更新せずにアプリケーションで画面遷移を行うことができます。

App.Router.reopen({
  location: 'none'
});

まとめ

今回はEmber.jsアプリケーションでの画面遷移について解説しました。Ember.jsでの画面遷移とは、画面を部分的に更新することで実現されます。また、Routeを適切に定義しておくことで、画面の状態をURLで表現可能になります。

次回はControllerを扱って一時データの保持とユーザの操作を受け取る方法を解説する予定です。

おすすめ記事

記事・ニュース一覧