精通-Ember-js-全-

精通 Ember.js(全)

原文:zh.annas-archive.org/md5/525d4fd608589f9313f703180742822c

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

精通 Ember.js 是任何希望开始编写与原生 Web 应用相媲美的雄心勃勃 Web 开发者的必读书籍。它包含了大量的实用示例,展示了如何轻松构建这些应用程序。本书的灵感来源于对 Ember.js 资源的需要,该资源通过实际案例更好地解释了 Ember.js。

本书涵盖的内容

第一章, Ember.js 简介,介绍了 Ember.js 的关键概念。

第二章, 理解 Ember.js 对象和混入,讨论了 Ember.js 的原始对象,它们是其他高级概念的基础。

第三章, 路由和状态管理,详细说明了在 Ember.js 应用程序中如何实现基于浏览器位置的州管理。

第四章, 编写应用程序模板,讨论了在 Ember.js 应用程序中模板的定义和编写方式。

第五章, 控制器,解释了控制器如何作为路由模型的代理来工作。

第六章, 视图和事件管理,讨论了如何在视图层实现紧密的 DOM 特定逻辑。

第七章, 组件,向读者介绍了 Ember.js 组件,这些组件使作者能够创建自定义和模块化的 HTML 元素。

第八章, 通过 REST 实现数据持久性,讨论了 Ember.js 应用程序如何通过 REST 连接到远程数据源的不同方式。

第九章, 日志记录、调试和错误管理,讨论了如何在 Ember.js 应用程序中追踪和监控错误。

第十章, 测试你的应用程序,概述了可以采用的不同单元和集成测试技术,以确保开发出稳定的应用程序。

第十一章, 构建实时应用,解释了实时 Web 技术如何集成到 Ember.js 应用程序中。

第十二章, 模块化你的项目,深入解释了如何更好地组织大规模的 Ember.js 应用程序。

你需要这本书的内容

在很大程度上,本书要求您安装现代浏览器。此外,一些部分将要求您安装 Node.js 以运行提供的服务器程序。

本书面向对象

本书面向初学者和中级 Ember.js 用户。书中包含了许多实用的示例,非常适合任何希望开始创建雄心勃勃的 Web 应用的中级 JavaScript 开发者。

约定

在本书中,您将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号应如下显示:“app.js 文件包含我们所有的应用程序代码,但随着应用程序的增长,我们可能会将应用程序关注点分离到更多文件中。”

代码块应如下设置:

  <script src="img/jquery-1.10.2.js"></script>
  <script src="img/handlebars-1.1.2.js"></script>
  <script src="img/ember-1.7.0.js"></script>
  <script src="img/app.js"></script>

任何命令行输入或输出都应如下所示:

npm install
node server

新术语重要词汇将以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中如下显示:“如果一个应用程序使用 Ember.js 数据,数据选项卡将显示所有加载的模型。”

注意

警告或重要提示将以这样的框显示。

提示

技巧和窍门将如下显示。

读者反馈

我们始终欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正从中获得最大收益的标题非常重要。

要发送一般反馈,请简单地将电子邮件发送到 <feedback@packtpub.com>,并在邮件主题中提及书名。如果您在某个主题上有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南 www.packtpub.com/authors

客户支持

现在,您已经成为 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。

下载示例代码

您可以从您在 www.packtpub.com 的账户下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了此书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。

错误表

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在我们的书中发现错误——可能是文本或代码中的错误——我们非常感谢你向我们报告这一点。通过这样做,你可以帮助其他读者避免挫败感,并帮助我们改进本书的后续版本。如果你发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择你的书籍,点击勘误提交表单链接,并输入你的勘误详情。一旦你的勘误得到验证,你的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分下的现有勘误列表中。任何现有的勘误都可以通过从 www.packtpub.com/support 选择你的标题来查看。

盗版

在互联网上对版权材料的盗版是所有媒体中持续存在的问题。在 Packt,我们非常重视保护我们的版权和许可证。如果你在互联网上发现任何我们作品的非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过 <copyright@packtpub.com> 联系我们,并提供涉嫌盗版材料的链接。

我们感谢你在保护我们的作者和我们提供有价值内容的能力方面提供的帮助。

问题

如果你在本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。

第一章:Ember.js 简介

本章将介绍Ember.js,包括其起源、发布周期及其关键元素。它将专注于描述一旦创建应用程序后可以执行的不同功能。因此,在章节结束时,您将更好地理解以下内容:

  • Ember.js 的起源

  • 下载 Ember.js 及其依赖项

  • 创建基本的 Ember.js 应用程序

  • Ember.js 应用程序概念

Ember.js 的起源

Ember.js 是一个用于创建雄心勃勃的 Web 应用程序的有趣且高效的开源 JavaScript 框架。它通过使用常见的 Web 约定而不是琐碎的配置来保证开发效率。它的官方网站是emberjs.com

它是由 Yehuda Katz 和 Tom Dale 从 SproutCore 分叉出来的。SproutCore 是一个力求提供类似 Apple 的 Cocoa API 用于 Max OS X 的强大 JavaScript 小部件工具包的 MVC 框架。额外的用户界面小部件功能被发现对大多数开发者来说是不必要的,因此进行了分叉。结果是,一个更轻量级、易于使用的库,同时仍然实现了以下承诺:

  • 减少开发时间

  • 通过使用常见的客户端 Web 应用程序开发最佳实践来创建健壮的应用程序

  • 友好的 API 使客户端编程变得有趣

Ember.js 具有广泛的应用范围。它非常适合显示动态数据和具有增强用户交互的应用程序。这些应用程序包括任务管理器、仪表板、论坛、聊天和消息应用程序等。想想 Gmail、Facebook 和 Twitter 这样的应用程序。话虽如此,Ember.js 并不适合静态网站。

Ember.js 被世界各地的许多公司使用,包括但不限于 Apple、Groupon、Square、Zendesk 和 Tilde Inc.

下载 Ember.js

最常被问到的一个问题就是,我从哪里下载 Ember.js?该库的最稳定版本可以从emberjs.com/builds/#/release下载。然而,主页(emberjs.com/)通常包含一个指向包含所需依赖项的入门套件的链接。在撰写本书时,Ember.js 的当前稳定版本是 1.7.0,我们将全书使用这个版本。在我们的情况下,我们将使用来自github.com/emberjs/starter-kit/archive/v1.7.0.zip的相应入门套件,您应该下载并解压缩到您的工作目录中。

升级 Ember.js 变得容易多了。新版本通常在emberjs.com/blog/tags/releases.html上宣布,并详细讨论发布中可以期待的内容。

现在,在解压缩提供的入门套件后,在js/libs目录下,我们注意到运行 Ember.js 的两个基本要求:

  • jQuery:Ember.js 使用 jQuery 进行基本功能,如 HTTP 请求、DOM 操作和事件管理。jQuery 是最受欢迎的 DOM 操作库;因此,有经验的读者会感到很自在。这也意味着我们可以轻松地将我们喜欢的第三方 jQuery 库集成到我们的 Ember.js 应用程序中。

  • Handlebars:这是 Ember.js 使用的模板引擎库,通过自动更新和更好的用户交互向用户显示响应式页面。值得注意的是,我们仍然可以通过一些努力使用其他模板引擎,如 Ender 或 Jade。

索引文件以以下方式加载这些文件:

  <script src="img/jquery-1.10.2.js"></script>
  <script src="img/handlebars-1.1.2.js"></script>
  <script src="img/ember-1.7.0.js"></script>
  <script src="img/app.js"></script>

app.js文件包含我们所有的应用程序代码,但随着应用程序的增长,我们可能会将应用程序的关注点分离到更多的文件中。值得注意的是,脚本加载的顺序很重要。一旦页面加载,Ember.js 会记录使用的依赖项及其版本,如下面的截图所示:

下载 Ember.js

两个库和 Ember.js 可以从全局作用域作为jQuery(或$)、HandlebarsEmber(或Em)分别访问,如下面的代码所示:

console.log(jQuery);
console.log(Handlebars);
console.log(Ember);

小贴士

下载示例代码

您可以从www.packtpub.com的账户下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

创建您的第一个应用程序

入门套件中的应用程序脚本文件(js/app.js)包含一个基本的 Ember.js 应用程序。如果您在浏览器中加载index.html文件,您应该看到显示的三种主要颜色:

App = Ember.Application.create();

App.Router.map(function() {
  // put your routes here
});

App.IndexRoute = Ember.Route.extend({
  model: function() {
    return ['red', 'yellow', 'blue'];
  }
});

导致这一结果的是哪些步骤?

  • 首先,创建了一个 Ember.js 应用程序,然后创建了路由器。

  • 负责状态管理的路由器将应用程序转换成两种状态,第一种是应用程序状态。这种状态导致应用程序模板被渲染到 DOM 中,因此出现了欢迎使用 Ember.js的消息。

  • 取代应用程序状态的是索引状态,其路由App.IndexRoute在应用程序模板内渲染了索引模板。

  • 索引路由还提供了模板、列表和颜色作为模型上下文。

这可以总结如下图所示:

创建您的第一个应用程序

仅此一个示例就介绍了以下一些关键的 Ember.js 概念。

路由器

路由器协调应用程序的状态与浏览器的位置。它支持传统的 Web 功能,例如使用浏览器的后退和前进按钮导航应用程序的历史记录,以及通过链接返回到应用程序。

根据当前的 URL,它调用匹配的路由,在页面上渲染多个嵌套模板。每个模板都有一个模型上下文。路由器在初始化时自动创建。因此,我们只需要调用其map方法来定义应用程序路由,如下面的代码所示:

App.Router.map(function() {
  // put your routes here
});

路由

路由主要负责提供模板的模型上下文。它由Ember.Route类定义,如下所示:

App.IndexRoute = Ember.Route.extend();

它将在第三章中广泛介绍,路由和状态管理

控制器

控制器代理由路由提供的模型,并进一步用显示逻辑装饰它们。它们也是通过显式依赖指定在不同应用程序状态之间的通信渠道,正如我们将在第五章中学习的那样,控制器。要创建控制器,我们扩展Ember.Controller类,如下面的代码行所示:

App.IndexController = Ember.Controller.extend();

视图

视图用于管理事件。它们将用户生成的事件委派回控制器和路由。视图通常用于集成其他 DOM 操作库,例如第三方 jQuery 包。它们通常从Ember.View类创建:

App.IndexView = Ember.View.extend();

我们将在第六章中详细讨论它们,视图和事件管理

模板

模板是一组编译成 HTML 并渲染到 DOM 中的表达式。模板通常使用以下签名定义:

<script type="text/x-handlebars" id="index">
<!-- our template goes in here -- >
</script>

组件

组件是 Ember.js 中的一个新概念,它根据W3C Web Components规范允许创建可重用元素。这些元素理想上不是特定于应用程序的,因此可以在其他应用程序中重用。

初始化应用程序

Ember.js 应用程序通过实例化Ember.Application类来创建:

App = Ember.Application.create();

当应用程序首次创建时,会发生一些事情。

为我们定义视图、控制器和路由的应用程序创建了一个新的命名空间。这防止了我们污染全局作用域。因此,定义一个路由,例如,应该附加到它上,如下所示:

App.IndexRoute = Ember.Route.extend({
  model: function() {
    return ['red', 'yellow', 'blue'];
  }
});

Ember.js 通常通过调用其initialize方法来初始化应用程序。可以通过调用应用程序的deferReadiness方法来延迟初始化,然后通过advanceReadiness重新开始。例如,假设我们的应用程序需要先加载 Google 客户端库,如下所示:

<script src="img/client.js?onload=onLoadCallback">< /script>
<script src="img/app.js">

这是我们的应用程序在库加载后立即完成其准备的方式:

App = Em.Application.create();
App.deferReadiness();
window.onLoadCallback = function(){
  App.advanceReadiness();
}

现在我们应用程序准备使用 SDK,我们将通过一个初始化器加载任何当前登录的用户。初始化器在应用程序初始化时被调用,因此是执行各种功能的好机会,例如使用应用程序的容器注入依赖。这个容器用于组织应用程序内的不同组件,可以按以下方式引用:

App.__container__

例如,内部,可以按以下方式访问路由的一个实例:

App.__container__.lookup('route:index');

由于我们现在已经加载了第三方库,我们可以继续创建一个用户初始化器,该初始化器将加载任何当前登录的用户:

Ember.Application.initializer({
  name: 'user',
  initialize: function(container, App) {

    var user = new Ember.RSVP.Promise(function(resolve, reject) {

      var opts = {
        'client_id': '-- --',
        'scope': 'email',
        'immediate': false
      };
      gapi
        .auth
        .authorize(opts, function(res){

          if (!res || res.error) {
            return reject();
          }
          resolve(res);
        });

    });

    container.register('user:main', user);
    container.lookup('user:main');
  }
});

Ember.Application.initializer({
  name: 'injectUser',
  initialize: function(container, App) {
    container.typeInjection('controller', 'user', 'user:main');
    container.typeInjection('route', 'user', 'user:main');
  }
});

这两个模块演示了在前面代码中提到的应用程序容器的两种用途。第一个展示了如何注册一个可访问的应用程序组件;在这种情况下,用户现在将按以下方式访问:

App.__container__.lookup('user:main');

此用户最初在初始化器中作为一个承诺。承诺是一个有状态的对象,其值可以在稍后的时间点设置。我们将在本章中不深入讨论承诺,但有一点需要注意,一旦登录过程完成,登录回调 gapi.auth.authorize 要么拒绝要么解决承诺。解决承诺将用户对象从挂起状态转换为满足状态。

第二个模块演示了依赖注入,这是我们之前也讨论过的。在这种情况下,我们现在将能够通过以下代码在路由和控制器中访问此用户:

this.get('user');

嵌入 Ember.js 应用程序

可以通过指定应用程序的 rootElement 将 Ember.js 应用程序嵌入到现有页面中。此属性是一个 jQuery 选择器。例如,要将应用程序嵌入到 #chat-container 元素中,请使用以下代码行:

App = Ember.Application.create({
   rootElement: '#chat-container'
});

当我们创建此类应用程序(如小部件)时,这很有用。指定根元素确保只有在该元素内部引发的事件才由 Ember.js 应用程序管理。

摘要

本章是 Ember.js 的入门指南。它侧重于介绍构成 Ember.js 应用程序的关键元素。这些元素继承自 Ember.Object 原始类型,这将在下一章中讨论。

第二章:理解 Ember.js 对象和混入

在上一章学习了如何创建基本的 Ember.js 应用程序之后,本章将介绍 Ember.js 对象,这是其他基础类的基础。因此,本书中讨论的大部分概念将贯穿全书。到本章结束时,我们将能够:

  • 创建 Ember.js 对象

  • 定义、获取和设置对象属性

  • 定义计算属性

  • 注册属性变更观察者

  • 使用混入

在 Ember.js 中创建对象

我们都知道如何在 JavaScript 中定义和创建函数对象的实例,如下面的代码所示:

function Point(x, y, z){
  this.x = x;
  this.y = y;
  this.z = z;
}

Point.prototype.logX = function(){
  console.log(this.x);
}
Point.prototype.logY = function(){
  console.log(this.y);
}

Point.prototype.logZ = function(){
  console.log(this.z);
}

var point = new Point(3, 5, 7);
point.logX();
// 3point.logY();
// 5point.logZ();
// 7

上述示例创建了一个具有三个方法的定义的 Point 对象的实例。

Ember.js 使用 JavaScript 原型来模拟面向对象特性。更重要的是,它引入了便利性,使得在事件驱动的浏览器环境中更容易继承和管理对象。一个类,即对象定义,通常通过 扩展 另一个用户定义的或内置的类创建,通常是 Ember.Object。类有两个方法,createextend,分别用于创建对象实例和执行继承。例如,前面的代码片段在 Ember.js 中的实现如下:

var Point = Ember.Object.extend({
  x: null,
  y: null,
  z: null,
  logX: function(){
    console.log(this.get('x'));
  },
  logY: function(){
    console.log(this.get('y'));
  },
  logZ: function(){
    console.log(this.get('z'));
  }
});

我们刚刚创建了一个具有三个属性 xyz 以及它们相应的日志方法的 Ember.js 类。要创建这个类的新实例,我们将调用类上的 create() 方法,如下面的示例所示:

var point = Point.create({
  x: 3,
  y: 5,
  z: 7
}); point.logX();
// 3point.logY();
// 5point.logZ();
// 7

我们可以更进一步,通过使用 extend() 方法扩展我们的 Point 类来形成一个新的类。例如,我们可以定义一个 Vector 类,它定义了一个 add() 方法,该方法向提供的向量中添加内容,如下面的代码所示:

var Vector = Point.extend({
  add: function(vector){

   var x = this.get('x') + vector.get('x');
   var y = this.get('y') + vector.get('y');
   var z = this.get('z') + vector.get('z');

   this.set('x', x);
   this.set('y', y);
   this.set('z', z);

  }
});

var vectorA = Vector.create({
  x: 3,
  y: 5,
  z: 7
});

var vectorB = Vector.create({
  x: 1,
  y: 2,
  z: 3
});

vectorA.add(vectorB);

vectorA.logX();
// 4vectorA.logY();
// 7vectorA.logZ();
// 10

在示例中将 Point 类扩展为 Vector 类之后,我们创建了两个名为 vectorAvectorB 的向量,并最终将它们组合在一起。

访问对象属性

我们刚刚看到了 Ember.js 对象是如何创建的。你注意到 Ember.js 对象属性是如何访问的吗?Ember.js 提供了 getset 属性访问器方法。为什么不直接访问这些值呢?嗯,这些方法用于重新计算值,并在必要时通知所做的任何更改。例如:

var point = Point.create();
point.set('x', 3);
console.log(point.get('x')); // 3

属性也可以通过 getPropertiessetProperties 方法一起读取和设置。这可以防止 Ember.js 不必要地发出太多关于这些更改的通知,例如:

var point = Point.create();
point.setProperties({
  x: 1,
  y: 2,
  z: 3
});
console.log(point.getProperties('x', 'y', 'z'));
//{x: 1, y: 2, z: 3}

定义类实例方法

类也可以定义实例方法。这些方法与对象属性的签名类似,如下面的代码所示:

var MyClass = ClassName.extend({
  methodName: function(){
    // method implementation
  }
});

Ember.js 通过使用 _super() 方法提供了在扩展类中重用父方法实现的能力。例如,以下示例重新实现了 Point 类中的 logX 方法并重用它:

var MyPoint = Point.extend({
  logX: function(){
   var x = this._super(); // call parent method
   console.log('x: %s', x);
  }
});
var myPoint = MyPoint.create({
  x: 3
});
myPoint.logX(); // x: 3

Ember.js 对象通常定义一个名为 init() 的构造方法,它在实例创建时被调用。任何初始化都应该在这个方法内完成。值得注意的是,应该始终在继承的方法(如 init())上调用 _super() 方法,以避免丢失父实现,例如:

var Book = Ember.Object.extend({
  init: function(){
    this._super();
    this.set('name', 'Mastering Ember.js'); // initialization
  }
});

定义计算属性

什么是计算属性?计算属性是指其值由函数返回的属性。例如:

var Movie = Ember.Object.extend({
  name: function(){
    return 'Transformers';
  }.property()
});

var movie = Movie.create();
console.log(movie.get('name')); // Transformers

上述示例创建了一个计算属性 name,它返回电影实例的名称。

我们只是通过将 property() 函数链接到它来将一个方法转换为一个计算属性。计算属性的真正力量在于它们能够根据预定义的依赖属性产生不同的值。这些依赖属性通常作为参数传递给 property() 函数。例如:

var Movie = Ember.Object.extend({
  year: '2007',
  seriesNumber: '1',
  name: function(){
    return this.get('seriesNumber') + '. Transformers - ' + this.get('year');
  }.property('year', 'seriesNumber')
})

var movie = Movie.create();
console.log(movie.get('name')); // 1\. Transformers – 2007

movie.set('year', '2014');
movie.set('seriesNumber', '4');
console.log(movie.get('name')); // 4\. Transformers – 2014

在前述示例中,每当电影的 seriesNumberyear 发生变化时,name 属性都会被重新计算。

计算属性也可以有可枚举数据的属性依赖。可以使用 @each 辅助函数在这样类型的属性上设置计算属性。例如:

var Country = Ember.Object.extend({
  stateNames: function(){
  return this.get('states').map(function(state){
    return state['name'];
  });
  }.property('states.@each.name')
});

var country = Country.create({
  states: [ {name: 'Texas'}, {name: 'Ohio'} ],
});
console.log(country.get('stateNames')); // ['Texas', 'Ohio']

country.set('states', [ {name: 'Alabama'}, {name: 'Arizona'} ]);
console.log(country.get('stateNames')); // ['Alabama', 'Arizona']

在前述示例中,我们创建了一个具有两个 statescountry 对象。然后我们定义了一个计算属性 stateNames,它返回一个包含州名的数组。任何州名的更改都会导致属性重新计算。

定义属性观察者

除了计算属性外,您还可以将 observers 设置为属性。观察者是在它们订阅的属性发生变化时被调用的函数。它们具有与计算属性相同的签名,但使用 observers 函数,如下面的代码所示:

var MyClass = ClassName.extend({
  observerName: function(){
    // observer implementation
  }.observes([properties, ...])
});

以下示例定义了一个会话类,它设置了一个观察者,当会话过期时,用户需要重新登录:

var Session = Em.Object.extend({
  expiredChanged: function(){
    if (this.get('expired')){
      window.location.assign('/login');
    }
  }.observes('expired')
});

观察者没有严格的命名约定,但大多数开发者通过在观察的属性后附加 DidChange 来命名观察者,如下所示:

var Song = Em.Object.extend({
  playedDidChange: function(){

  }.observes('played')
});

就像计算属性一样,观察者也可以订阅无限数量的属性:

var Player = Em.Object.extend({
  inMotion: function(){

  }.observes('running', 'walking)
});

在这里,当 runningwalking 属性中的任何一个发生变化时,inMotion 属性将被重新计算。

观察者也可以使用 addObserver()removeObserver() 方法分别设置和拆除。当您想自己管理观察者时,这些方法变得非常有用。例如,前面的示例可以重写为:

var Session = Em.Object.extend({
  init: function(){

    var self = this;

    self._super();
    self.addObserver('expired', function(){
    if (self.get('expired')){
      self.removeObserver('expired');
      window.location.assign('/login');
    }
  });

}
});

如前述代码所示,addObserver 方法至少需要两个参数:要观察的属性以及属性变化时调用的函数。在我们的示例中,我们还通过调用 removeObserver 方法来拆除观察者监听器。此方法需要一个参数,即要解绑的属性。

Ember.js 还提供了一种方法来传递在 observer 函数中使用的上下文。例如:

var Session = Em.Object.extend({
  init: function(){

    this._super();
    this.addObserver('expired', this, function(){
      if (this.get('expired')){
        this.removeObserver('expired');
        window.location.assign('/login');
      }
    });

  }
});

需要注意的是,观察者只会在对象初始化之后发生的属性变化上触发。可以将一个 on('init') 方法应用于观察者,使其在对象初始化期间可能发生的任何变化上触发。例如:

var Song = Em.Object.extend({
  skipped: false,
  played: false,
  skippedDidChange: function(){
    // does not fire on object initialization
    console.log('song was skipped');
  }.observes('skipped'),
  playedDidChange: function(){
    // fires on object initialization
    console.log('song finished playing');
  }.observes('played').on('init'),
  init: function(){
    this._super();
    this.set('skipped', true);
    this.set('played', true);
  }
});

Song.create();

示例定义了两个观察者:skippedDidChangeplayedDidChange,其中,只有后者在对象初始化后会被调用。

创建属性绑定

Ember.js 提供了对单向和双向绑定的支持。绑定是同一对象或不同对象两个属性之间的链接,它们总是保持同步。这意味着对一个属性的更新会导致另一个属性更新为新值。绑定在以下签名中定义:

property: Ember.computed.alias('otherProperty'),

在这种情况下,两个属性 propertyotherProperty 总是保持同步。以下是一个例子:

var author = Em.Object.create({
  name: 'J. K. Rowling'
});

var book = Em.Object.create({
  name: 'Harry Potter',
  authorName: Ember.computed.alias('author.name')
});

console.log(book.get('authorName')); // J. K. Rowling

author.set('name', 'Joanne Rowling');
console.log(book.get('authorName')); //  Joanne Rowling

在前面的例子中,book 实例有一个绑定到创建的全局作者实例的 author 属性。对作者名字的任何更改都会反映在绑定的书籍作者属性中。同样,对书籍作者属性的任何更改都会传播回全局作者,如下所示:

book.set('authorName', 'Joanne Jo Rowling');
console.log(author.get('name')); // Joanne Jo Rowling

这是一个双向绑定的例子,其中对任一属性的更新都会导致另一个属性更新为新值。

Ember.js 也支持单向绑定,其中更新是单向的。一个属性可以订阅来自不同属性的更新,但如果前者发生变化,它不会更新后者。例如:

var author = Em.Object.create({
  name: 'J. K. Rowling'
});

var book = Em.Object.create({
  name: 'Harry Potter',
  authorName: Ember.computed.oneWay('author')
});

console.log(book.get('authorName')); // J. K. Rowling

book.set('author.name', 'Joanne Rowling');
// author's name remains unchanged
console.log(author.get('name')); //  J. K. Rowling

在前面的代码片段中,书籍属性的变化不会影响绑定的作者的名字属性。

使用混入

混入是抽象定义,它定义了类和对象可以重用的方法和属性。例如,考虑这两个对象:

var myView = Em.View.create({
  sum: function(a, b){
    return a+b;
  }
});

var myController = Em.Controller.create({
  sum: function(a, b){
    return a+b;
  }
});

这两个对象共享一个可以抽象为混入的公共函数:

var sumMixin = Em.Mixin.create({
  sum: function(a, b){
    return a+b;
  }
});

var myView = Em.View.createWithMixins(sumMixin);

var myController = Em.Controller.createWithMixins(sumMixin);

可以在创建或定义时向对象或类传递任意数量的混入:

App.Number = Em.Object.extend(sumMixin, diffMixin, productMixin);

需要注意的是,混入总是创建的,而不是扩展的。示例还显示,重用混入的对象在实例化时总是使用 createWithMixins 方法创建,而不是 create 方法。然而,当应用混入时,类仍然使用 extend 方法。

重新打开类和实例

有时,有必要更新类实现而不重新定义它们。通常在我们不希望扩展内置类,只想更新它们的实现时,这是必要的。Ember.js 将其称为类的重新打开和对象的重新打开。可以使用 reopenClass 方法重新实现类方法和属性,而实例方法和属性可以使用 reopen 方法更新。然而,不建议更改内置类,因为它们可能在未来的版本中发生变化。

例如:

var Book = Em.Object.extend();

Book.reopen({
  id: null,
  title: null,
  purchase: function(){
    console.log('sold');
  }
});

Book.reopenClass({
  getById: function(id){
      return Book.create({
        id: '456',
        title: 'Harry Potter'
      });
  } 
});

Book.create({
  id: 456,
  title: 'Harry Potter'
});

var book = Book.getById(456);

book.purchase();

在前面的示例中,我们向已定义的Book类添加了一个实例方法purchase和两个属性idname。我们还添加了一个类方法getById,而没有扩展该类。

事件订阅

在应用程序中通知更改的另一种方式是通过事件订阅。这种范式在 Node.js 中被广泛用于在不同组件之间传递消息和事件。Ember.js 提供了Ember.Evented混入,可以用来轻松地实现这一点。例如,在棋盘游戏中,棋块可以订阅来自执行器的指令,如下例所示:

var GRID_SIZE = 4; 
var actuator = Em.Object.createWithMixins(Em.Evented);

var block = Em.Object.createWithMixins(Em.Evented);
acuator.on('moveRight', function(){
  var x = block.get('x') + 1;
  x = x % GRID_SIZE; 
  block.set('x', x);
});

actuator.trigger('moveRight');

混入提供了五个基本方法,其中两个在先前的示例中已经展示:

  • on:用于订阅事件

  • off:用于禁用订阅

  • one:用于一次性订阅事件

  • trigger:用于触发事件

  • has:用于检查是否已订阅事件

概述

本章的重点是介绍 Ember.js 对象。我们将在下一章中广泛使用这些对象,我们将学习如何在 Ember.js 中使用路由实现状态管理。我们将讨论如何构建路由和路由器。话虽如此,你应该在本章中学习了以下 Ember.js 对象概念:

  • 在 Ember.js 中创建对象和类

  • 获取和设置对象属性

  • 定义计算属性

  • 定义属性观察者

  • 创建属性绑定

  • 使用混入

  • 重新打开 Ember.js 类

在下一章中,我们将讨论路由,这是从Ember.Object扩展出来的这些类之一。

第三章. 路由和状态管理

在本章中,我们将学习基于 URL 的状态管理在 Ember.js 中的应用,这构成了路由。路由使我们能够将应用程序中的不同状态转换为 URL,反之亦然。这是 Ember.js 中的一个关键概念,使开发者能够轻松地分离应用程序逻辑。它还使用户能够通过通常的 HTTP URL 链接回应用程序中的内容。话虽如此,到本章结束时,我们应该能够完成以下任务:

  • 创建一个路由器

  • 定义资源和路由

  • 定义路由的模型

  • 执行重定向

  • 实现异步路由

创建应用程序的路由器

我们都知道,在传统的 Web 开发中,每个请求都通过一个 URL 与服务器相连,这使得服务器能够对传入的请求做出决策。典型的操作包括发送资源文件或 JSON 有效负载、将请求重定向到不同的资源,或者在未经授权访问的情况下发送错误响应。

Ember.js 通过在浏览器环境中实现这些 URL 与应用程序状态的关联,力求保留这些理念。管理这些状态的主要组件是应用程序路由器。正如引言部分所述,它负责将应用程序恢复到与给定 URL 匹配的状态。它还允许用户按预期在应用程序的历史记录中导航。路由器在应用程序初始化时自动创建,可以引用为 MyApplicationNamespace.Router。在我们继续之前,我们将使用捆绑的章节示例来更好地理解这个极其方便的组件。该示例是 Contacts OS X 应用程序的简单实现,如下面的截图所示:

创建应用程序的路由器

它使用户能够添加新的联系人,以及编辑和删除现有的联系人。为了简单起见,我们不会支持头像,但这可以是读者在章节末尾的一个实现练习。

我们已经提到了一些应用程序可以转换到的状态。这些状态必须以与服务器端框架相同的方式进行注册,这些框架有 URL 分发器,后端程序员使用它将 URL 模式映射到视图。章节示例已经说明了如何定义这些可能的状态:

// app.js

var App = Ember.Application.create();

App.Router.map(function() {
  this.resource('contacts', function(){
    this.route('new');
    this.resource('contact', {path: '/:contact_id'}, function(){
      this.route('edit');
    });
  });
  this.route('about');
});

注意,已经实例化的路由器被引用为 App.Router。调用其 map 方法给应用程序一个机会来注册其可能的状态。此外,还使用了两种其他方法来将这些状态分类为路由资源

将 URL 映射到路由

map function takes a function as its only argument. Inside this function, we may define a resource using the corresponding method, which takes the following signature:
this.resource(resourceName, options, function);

第一个参数指定了资源的名称,巧合的是,这也是匹配请求 URL 的路径。下一个参数是可选的,它包含我们可能需要指定的配置,我们将在稍后看到。最后一个是一个函数,用于定义特定资源的路由。例如,在示例中第一个定义的资源表示,让contacts资源处理以/contacts开头的任何请求。它还指定了一个new路由,用于处理新联系人的创建。另一方面,路由接受函数参数的相同参数。

你可能正在想,“那么路由和资源有什么不同?”这两者本质上是一样的,只是前者提供了一种对在特定实体上执行操作的状态(路由)进行分类的方法。我们可以将 Ember.js 应用程序想象成一棵树,由树干(路由器)、树枝(资源)和叶子(路由)组成。例如,contact状态(一个资源)为特定的联系人提供服务。这个资源可以以两种模式显示:读取和写入;因此,分别有indexedit路由,如下所示:

this.resource('contact', {path: '/:contact_id'}, function(){
   this.route('index'); // auto defined
   this.route('edit');
});

由于 Ember.js 鼓励约定,路由和资源有两个组件总是自动定义的:

  • 默认应用程序资源:这是所有其他资源定义的主资源。因此,我们不需要在路由器中定义它。不是每个状态都必须定义资源。例如,我们的about状态是一个路由,因为它只需要向用户显示静态内容。然而,它可以被认为是已经自动定义的应用程序资源的一个路由。

  • 每个资源都有一个默认的index路由:再次强调,每个资源都有一个默认的索引路由。它是自动定义的,因为应用程序无法确定资源的状态。因此,如果在这个资源内部没有其他路由被打算使用,应用程序将使用这个路由。

资源嵌套

根据应用程序的架构,资源可以被嵌套。在我们的例子中,我们需要在向用户显示任何联系人之前,在侧边栏中加载联系人。因此,我们需要在contacts内部定义联系人资源。另一方面,在一个像 Twitter 这样的应用程序中,在tweets资源内部定义一个嵌套的tweet资源是没有意义的,因为当用户只想从外部应用程序查看单个推文时,将会产生额外的开销。

理解状态转换周期

请求的处理方式与水从根部(应用程序)向上流经树干,最终在叶子处流失的方式相同。我们这里所说的请求是指浏览器位置的变化,它可以以多种方式触发,我们将在下一章中了解更多。

在我们深入探讨路由的更详细细节之前,让我们讨论一下当应用程序首次加载时发生了什么。在启动时,发生了一些事情,如下所述:

  • 应用程序首先转换到应用程序状态,然后是索引状态。

  • 接下来,应用程序索引路由将请求重定向到联系人资源。

  • 我们的应用程序使用浏览器的本地存储来存储联系人,因此为了演示目的,联系人资源使用固定值(位于fixtures.js)填充了这个存储。

  • 应用程序随后转换到相应的联系人资源索引路由,contacts.index

  • 再次,我们根据我们的存储是否包含数据做出了一些决定。由于我们确实有数据,我们将应用程序重定向到联系人资源,并传递了第一个联系人的 ID。

  • 正如在前两个资源中一样,应用程序从最后一个资源转换到相应的索引路由,contact.index

下图给出了先前状态变化的好视角:

理解状态转换周期

配置路由器

路由器可以通过以下方式自定义:

  • 记录状态转换

  • 指定根应用 URL

  • 更改浏览器位置查找方法

在开发过程中,可能需要跟踪应用程序转换到的状态。启用这些日志非常简单:

var App = Ember.Application.create({
 LOG_TRANSITIONS: true
});

如上图所示,我们在创建应用程序时启用了LOG_TRANSITIONS标志。如果一个应用程序不是在网站域的根目录下提供服务,那么可能需要指定使用的路径名,如下例所示:

App.Router.reopen({
  rootURL: '/contacts/'
});

另一个我们可能需要进行的修改是围绕 Ember.js 用于订阅浏览器位置变化的技术。这使得路由器能够完成其将应用程序转换到匹配的 URL 状态的任务。以下两种方法如下:

  • 订阅hashchange事件

  • 使用history.pushState API

默认技术由文档中位于emberjs.com/api/classes/Ember.HashLocation.htmlHashLocation类提供。这意味着 URL 路径通常以哈希符号开头,例如,/#/contacts/1/edit。另一种由位于emberjs.com/api/classes/Ember.HistoryLocation.htmlHistoryLocation类提供。这并不区分传统 URL 和 URL,并且可以启用如下:

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

我们也可以选择让 Ember.js 根据以下代码选择最适合我们应用程序的方法:

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

如果我们不需要这些技术中的任何一种,我们可以选择这样做,尤其是在进行测试时:

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

指定路由的路径

现在我们知道,在定义一个路由或资源时,所使用的资源名称也作为路由器用来匹配请求 URL 的路径。有时,可能需要指定一个不同的路径来匹配状态。有两个常见的原因可能导致我们这样做,第一个原因是有利于将路由处理委托给另一个路由。尽管我们还没有介绍路由处理程序,但我们已经提到,我们的应用程序从index路由过渡到contacts.index状态。然而,我们可以指定联系人路由处理程序应该管理以下路径:

this.resource('contacts', {path: '/'}, function(){
});

因此,要指定一个路由的替代路径,只需在资源定义期间将所需的路由作为哈希的第二参数传递。这也适用于定义路由。

第二个原因可能是当资源包含动态段时。例如,我们的联系人资源处理那些显然应该有不同的 URL 链接回它们的联系人。Ember.js 使用其他开源项目(如 Ruby on Rails、Sinatra 和 Express.js)使用的 URL 模式匹配技术。因此,我们的联系人资源应该定义为:

this.resource('contact', {path: '/:contact_id'}, function(){
});
/:contact_id is the dynamic segment that will be replaced by the actual contact's ID. One thing to note is that nested resources prefix their paths with those of parent resources. Therefore, the contact resource's full path would be /contacts/:contact_id. It's also worth noting that the name of the dynamic segment  is not mandated and so we could have named the dynamic segment as /:id.

定义路由和资源处理程序

现在我们已经定义了应用程序可以过渡到的所有可能的状态,我们需要定义这些状态的处理程序。从现在开始,我们将交替使用术语路由资源处理程序。路由处理程序执行以下主要功能:

  • 提供当前状态使用的(模型)数据

  • 指定用于将提供的数据渲染给用户的视图和/或模板

  • 将应用程序重定向到另一个状态

在我们讨论这些角色之前,我们需要知道路由处理程序是从Ember.Route类定义的:

App.RouteHandlerNameRoute = Ember.Route.extend();

这个类用于定义资源和路由的处理程序,因此命名不应成为问题。正如路由和资源与路径和处理程序相关联一样,它们也与控制器、视图和模板相关联,使用 Ember.js 的命名约定。例如,当应用程序初始化时,它进入application状态,因此,以下对象被寻找:

  • 应用程序路由

  • 应用程序控制器

  • 应用程序视图

  • 应用程序模板

在“用更少的样板代码做更多”的精神下,Ember.js 自动生成这些对象,除非明确定义以覆盖默认实现。作为另一个例子,如果我们检查我们的应用程序,我们会注意到contact.edit路由有一个相应的App.ContactEditController控制器和contact/edit模板。

我们不需要定义其路由处理程序或视图。在看到这个示例后,当我们提到路由时,我们通常通过一个点将资源名称与路由名称分开,如下所示:

resourceName.routeName

在模板的情况下,我们可以使用一个点或一个正斜杠:

resourceName/routeName

其他对象通常采用驼峰式命名并附加类名后缀:

ResourcenameRoutenameClassname

例如,以下表格显示了我们在章节示例中使用的所有对象。如前所述,其中一些是自动生成的。

路由名称 控制器 路由处理器 视图 模板
applicationApplicationControllerApplicationRoute ApplicationViewapplication
indexIndexControllerIndexRoute IndexViewindex
about AboutController AboutRoute AboutView about
contactsContactsControllerContactsRoute ContactsView
contacts.indexContactsIndexControllerContactsIndexRoute ContactsIndexViewcontacts/index
contacts.newContactsNewController ContactsNewRoute
contact ContactController ContactRoute ContactView contact
contact.index ContactIndexController ContactIndexRoute ContactIndexView contact/index
contact.edit ContactEditController ContactEditRoute ContactEditView contact/edit

有一个需要注意的事项是,与中间应用程序状态关联的对象不需要携带后缀;因此,只需indexabout

指定路由的模型

在第一章中,我们提到路由处理器提供控制器和模板需要显示的数据。这些处理器有一个model钩子,可以用来以下格式提供这些数据:

AppNamespace.RouteHandlerName = Ember.Route.extend({
  model: function(){
  }
});

例如,章节示例中的contacts路由处理器从本地存储加载任何已保存的联系人,如下所示:

  model: function(){
    return App.Contact.find();
  }

我们已经将此逻辑抽象到我们的App.Contact模型中。注意我们如何重新打开类来定义这个静态方法。作为对第二章中本节课的回顾,理解 Ember.js 对象和混入,静态方法只能由该方法所属的类调用,而不能由其实例调用:

App.Contact.reopenClass({
  find: function(id){
    return (!!id)
      ? App.Contact.findOne(id)
      : App.Contact.findAll();
  },
  …
});

如果没有向该方法传递任何参数,它将直接调用findAll方法,该方法使用本地存储助手来检索联系人:

  findAll: function(){
    var contacts = store('contacts') || [];
    return contacts.map(function(contact){
      return App.Contact.create(contact);
    });
  }

因为我们想处理联系人对象,所以我们迭代地转换已加载联系人列表的内容。如果我们检查相应的模板contacts,我们会注意到我们能够像以下代码所示填充侧边栏:

<ul class="nav nav-pills nav-stacked">
{{#each model}}
  <li>
    {{#link-to "contact.index" this}}{{name}}{{/link-to}}
  </li>
  {{/each}}
</ul>

如果你刚接触 Ember.js,在这个阶段不必担心模板语法。重要的是要注意,模型是通过model变量访问的。当然,在那之前,我们检查模型是否有内容:

{{#if model.length}}
   ...    
{{else}}
    <h1>Create contact</h1>
{{/if}}

正如我们稍后将要看到的,如果列表为空,应用程序将被迫过渡到contacts.new状态,以便用户可以添加第一个联系人,如下面的截图所示:

指定路由的模型

contact处理器是一个不同的情况。记住我们提到它的路径有一个动态段,这个段会被传递给处理器。这个信息以选项哈希的形式传递给模型钩子:

App.ContactRoute = Ember.Route.extend({
  model: function(params){
    return App.Contact.find(params.contact_id);
  },
  ...
});

注意,我们能够通过哈希的contact_id属性访问联系人的 ID。这次,find方法调用联系人类别的findOne静态方法,该方法执行与提供的 ID 匹配的联系人搜索,如下面的代码所示:

  findOne: function(id){
    var contacts = store('contacts') || [];
    var contact = contacts.find(function(contact){
      return contact.id == id;
    });
    if (!contact) return;
    return App.Contact.create(contact);
  }

资源序列化

我们提到 Ember.js 支持将内容链接回外部。内部上,Ember.js 简化了在模板中创建这些链接的过程。在我们的示例应用程序中,当用户选择一个联系人时,应用程序会过渡到contact.index状态,并传递其 ID。这是通过使用link-to handlebars 表达式实现的:

{{#link-to "contact.index" this}}{{name}}{{/link-to}}

再次,我们将在第四章中详细回顾这个问题,编写应用程序模板,但现在,重要的是要注意,这个表达式使我们能够通过传递资源名称和受影响的模型来构建指向该资源的链接。目标资源或路由处理器负责生成构成序列化的路径。要序列化资源,我们需要像以下代码中的联系人处理器案例一样覆盖匹配的serialize钩子:

App.ContactRoute = Ember.Route.extend({
  ...
  serialize: function(model, params){
    var data = {}
    data[params[0]] = Ember.get(model, 'id');
    return data;
  }
});

序列化意味着钩子应该返回所有指定段落的值。它接收两个参数,第一个是受影响的资源,第二个是在资源定义期间指定的所有段落的数组。在我们的例子中,我们只有一个,所以我们返回了所需的类似以下代码的哈希:

{contact_id: 1}

例如,如果我们定义了一个具有多个段落的资源,如下面的代码所示:

this.resource(
  'book',
  {path: '/name/:name/:publish_year'},
  function(){
  }
);

序列化钩子需要返回类似以下内容:

{
  name: 'jon+doe',
  publish_year: '1990'
}

异步路由

在实际应用程序中,我们通常会以异步方式加载数据模型。有各种方法可以用来提供这类数据。加载异步数据最稳健的方式是通过使用承诺。承诺是可以在以后某个时间点设置未知值的对象。在 Ember.js 中创建承诺非常容易。例如,如果我们的联系人位于远程资源中,我们可以使用 jQuery 来加载它们,如下所示:

App.ContactsRoute = Ember.Route.extend({
  model: function(params){
    return Ember.$.getJSON('/contacts');
  }
});

jQuery 的 HTTP 工具同样返回 Ember.js 可以消费的承诺。顺便提一下,在 Ember.js 应用程序中,jQuery 也可以被引用为Ember.$。在前面提到的代码片段中,一旦数据加载完成,Ember.js 就会将其设置为资源的模型。然而,还有一件事是缺失的。我们需要将加载的数据转换为定义的联系人模型,如下面的简单修改所示:

App.ContactsRoute = Ember.Route.extend({
  model: function(params){
  var promise = Ember
    .Object
    .createWithMixins(Ember.DeferredMixin);

    Ember
      .$
      .getJSON('/contacts')
      .then(reject, resolve);

    function resolve(contacts){
      contacts = contacts.map(function(contact){
        return App.Contact.create(contact);
      });
      promise.resolve(contacts)
    }

    function reject(res){
      var err = new Error(res.responseText);
      promise.reject(err);
    }

    return promise;
  }
});

我们首先创建承诺,启动 XHR 请求,然后在请求仍在处理时返回承诺。Ember.js 将在承诺被拒绝或解决后恢复路由。XHR 调用也创建了一个承诺,因此我们需要将其附加到 then 方法,该方法本质上表示,在成功或失败加载时分别调用传递的解决或拒绝函数resolve 函数将加载的数据转换为解决承诺;传递数据从而恢复路由。如果承诺被拒绝,转换将因错误而失败。我们将在稍后看到如何处理此错误。

注意,我们还可以使用以下示例中所示的其他两种方式在 Ember.js 中创建承诺:

var promise = Ember.Deferred.create();

Ember
  .$
  .getJSON('/contacts')
  .then(success, fail);

function success(){
  contacts = contacts.map(function(contact){
    return App.Contact.create(contact);
  });
  promise.resolve(contacts)
}

function fail(res){
  var err = new Error(res.responseText);
  promise.reject(err);
}

return promise;

第二个示例如下:

return new Ember.RSVP.Promise(function(resolve, reject){

  Ember
    .$
    .getJSON('/contacts')
    .then(success, fail);

  function success(){
    contacts = contacts.map(function(contact){
      return App.Contact.create(contact);
    });
    resolve(contacts)
  }

  function fail(res){
    var err = new Error(res.responseText);
    reject(err);
  }

});

配置路由的控制器

我们刚刚了解到,路由提供它们对应的控制器、它们代理到模板和视图的数据。这通常发生在路由的 setupController 钩子中。例如:

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

虽然我们很少需要使用它,但此钩子提供了修改其他控制器的好机会。例如,我们可以在 application 控制器上设置一个属性,如下所示:

App.ContactsIndexRoute = Ember.Route.extend({
  setupController: function(controller, model){
   this._super(controller, model);
   this
        .controllerFor('application')
        .set('contacts', this.modelFor('contacts'));
  }
});
modelFor and controllerFor, that can be used to access the models and controllers of other handlers respectively. Note that the argument passed is the route's or resource's name. Here are more examples:
this.modelFor('contacts.index');
this.controllerFor('contact.edit');

有时,我们可能希望指定一个不同的控制器,处理程序应该使用它。例如,contact.edit 路由用于编辑 contact 资源模型。在这种情况下,我们需要通过 needs 属性指定前者依赖于后者。这样,正如我们将在第五章 中学习到的,控制器contact.edit 路由的模板能够访问控制器上设置的模型,如下所示:

{{#with controller.controllers.contact}}
...
{{/with}}

另一种方法是通过在处理程序中指定它来直接使用此控制器:

App.ContactEditRoute = Ember.Route.extend({
  controllerName: 'contact'
});

结果将是 contact.edit 模板将与 contacts.new 模板的类似,因此两者都可以在下一节中解释的情况下被删除。

渲染路由的模板

在我们讨论更多关于路由处理程序模板渲染之前,讨论一下在模板的上下文中应用程序在状态之间转换时发生的事情是值得的。这一节将在下一章中详细回顾。在我们的章节示例中,应用程序最终知道转换,按照以下顺序概述:

  • 应用状态

  • 联系人状态

  • 联系人状态

  • 联系人索引状态

因此,application 模板首先在屏幕上渲染。然后,下一个模板 contacts 被渲染到 application 模板中,以构成侧边栏。接下来,contact 模板被插入到 contacts 模板中。最后,contact.index 模板被插入到 contact 模板中,以完成过渡。每个模板都指定了一个 outlet 部分,子路由处理程序可以将它们的模板渲染到其中。例如,注意以下应用模板中的出口表达式:

  <script type="text/x-handlebars">
    <div class="container">
      {{outlet}}
    </div>
  </script>

路由处理器可以像控制器一样指定要使用的模板。再次回顾contact.edit路由模板,它使用了一个部分,我们将在下一章讨论,并将共享的contacts.form模板包含到宿主模板中。

renderTemplate钩子是处理器指定要使用的自定义模板的最后机会,通过调用render方法并传入要使用的模板来实现,如下所示:

App.ContactEditRoute = Ember.Route.extend({
  renderTemplate: function() {
    this.render('contacts/form');
  }
});

在这种情况下,我们可以因此去除contacts.newcontact.edit路由中定义的控制器和路由。最后,模板并不局限于单个输出。这意味着你可以在当前状态模板中渲染不同的模板,不同的控制器上下文。例如,在一个游戏应用中,我们可以定义两个输出以容纳两个不同的模板,它们服务于不同的目的,如下所示:

<script type="text/x-handlebars" data-template-name="game">
  <div id="leaderboard">{{outlet leaderboard}}</div>
  <div id="mainboard">{{outlet mainboard}}</div>
</script>

然后,通过处理器渲染它们,如下所示:

App.GameRoute = Ember.Route.extend({
  renderTemplate: function() {
    this.render('mainboard', {
      into: 'game',
      outlet: 'mainboard',
      controller: 'mainboard'
    });
    this.render('leaderboard', {
      into: 'game',
      outlet: 'leaderboard',
      controller: 'leaderboard'
    });
  }
});

重定向状态

处理器的一个常见用途是将应用程序重定向到同一状态中的另一个状态,就像如果请求的资源没有被底层服务器找到,我们可能会被重定向到404页面一样。在我们的示例应用中,索引控制器覆盖了路由处理器的redirect钩子,以便使用transitionTo方法将应用程序重定向到contacts状态,如下所示:

App.IndexRoute = Ember.Route.extend({
  redirect: function(){
    this.transitionTo('contacts');
  }});

可能需要执行此重定向的情况有两种。第一种是我们不需要知道路由处理器的模型。我们使用这些钩子之一,beforeModel,在相同的处理器开始加载之前,用固定值填充联系人列表本地存储,如下所示:

App.ContactsRoute = Ember.Route.extend({
  beforeModel: function(){
    var contacts = store('contacts') || CONTACTS;
    store('contacts', contacts);
  }
});

另一方面,如果我们需要等待处理器的模型加载,我们可以使用redirectafterModel钩子。实际上,后者实际上只是调用了前者。例如,我们在章节示例中的contacts路由处理器中使用了afterModel钩子,以确定是否需要强制用户添加新的联系人或将他们重定向到查看第一个联系人,如下所示:

App.ContactsIndexRoute = Ember.Route.extend({
  afterModel: function(){
    var model = this.modelFor('contacts') || [];
    var contact = model.get('firstObject');
    if (!contact) return this.transitionTo('contacts.new');
    return this.transitionTo('contact.index', contact);
  },
});

捕获路由错误

如果路由转换失败,例如,无法加载模型,Ember.js 会在该处理器中发出错误操作。尽管我们还没有涉及操作,但可以将它们视为可以从模板或其他路由处理器和控制器委托给处理器的事件。以下示例通过将应用程序重定向到适当的错误处理路由来捕获此类错误:

App.ContactsRoute = Ember.Route.extend({
  action: {
    error: function(error){
      this.controllerFor('error').set('error', error);
      this.transitionTo('error');
    } 
  }
});

摘要

本章详细介绍了 Ember.js 中状态的管理。我们特别讨论了 Ember.js 应用程序如何从一个应用程序状态启动到其他嵌套状态。本章示例中介绍了一些概念,将在下一章中详细讨论。因此,在下一章中,我们将讨论模板,特别是它们如何渲染由控制器代理的数据以及它们如何将用户生成操作委派回路由。因此,你应该对以下本章涵盖的主题有一个稳固的理解,因为它们将被频繁回顾:

  • 定义应用程序路由

  • 定义应用程序路由

  • 实现路由的模型

  • 设置路由的控制器

  • 指定路由的模板

  • 执行异步路由

下一章将描述如何使用脚本标签包含模板,或者从服务器编译和打包/运输模板。

第四章 编写应用程序模板

现在我们已经知道了如何使用路由来管理 Ember.js 应用程序中的状态,本章将帮助我们掌握如何使用模板向用户展示应用程序逻辑。你很快就会意识到,你应用程序的大部分内容都驻留在模板中。因此,本章将经常回顾我们迄今为止所学的内容。因此,在本章结束时,以下概念将被学习:

  • 创建模板

  • 编写绑定模板表达式和条件

  • 在模板中更改上下文

  • 在模板中创建事件监听器

  • 扩展模板

  • 编写自定义模板助手

注册模板

如承诺,我们将继续在模板的背景下探索上一章中引入的章节示例。当应用程序过渡到一个状态时,该状态路径中的每个路由处理程序都会在页面上渲染一个模板。这些模板在以下签名中定义:

<script type="text/x-handlebars" id="index">
 <h1>My Index Template</h1></script>

如所示,模板是通过text/x-handlebars类型的script标签进行注册的。使用iddata-template-name属性来识别模板。例如,章节示例中的contacts模板被定义为:

  <script type="text/x-handlebars" data-template-name="contacts">
    ...
  </script>

需要注意的一点是,使用data-template-name属性来识别模板比使用id属性更明智,因为前者更可能与其他现有元素冲突。此外,请注意,第一个模板没有被识别。这是因为任何未识别的模板都被视为application模板:

<script type="text/x-handlebars">
  <div class="container">
    {{outlet}}
  </div>
</script>

插入模板

在上一章中,我们讨论了应用程序中的状态是如何由各种路由组成的,这些路由的处理程序按顺序调用以执行构成此状态的各种功能。作为回顾,当用户在章节示例中加载应用程序时,应用程序过渡到application状态。然后,application路由处理程序将其相应的application模板渲染到 DOM 中。下一个要调用的路由处理程序是contacts路由处理程序,它也加载并渲染其模板到application模板中。我们已经讨论过,{{outlet}} Handlebars 表达式是application模板中被替换的部分。这个过程一直重复,直到应用程序稳定在目标状态。

正如我们稍后将要讨论的,一个模板可以指定多个模板可以渲染的命名出口。路由(不是资源)处理程序不需要包含这个表达式,因为它们通常渲染最终的模板。

输出模板

如前所述,Ember.js 模板是用 Handlebars(www.handlebarsjs.com)语法编写的,该库由相同的作者创建,以简化客户端模板的创建。Handlebars 是一个功能强大的模板库,它提供了许多功能,这些功能将在接下来的章节中讨论。

表达变量

我们刚刚提到模板由数据支持,这些数据由相应的控制器代理。Handlebars 遍历模板,用从这些数据中获得匹配值替换定义的表达式。这些表达式通常是括在花括号内的变量名。我们刚刚讨论的{{outlet}}表达式就是这样一种表达式。在章节示例中,contact.index状态负责在页面右侧显示联系人的详细信息。在其对应的模板中,我们注意到联系人的属性是用这些表达式表达的,但后来被替换,如下面的代码所示:

<script type="text/x-handlebars" data-template-name="contact/index">
  {{#with controller.controllers.contact}}
  <div class="row">
    <div class="col-sm-4 text-right">name</div>
    <div class="col-sm-8">{{name}}</div>
  </div>
  <br>
  ...
</script>

在前面的例子中,Handlebars 找到了名称表达式,从提供的模型中检索这个变量,并执行了交换。Handlebars 始终在提供的控制器上下文中工作,这反过来又代理了对模型的请求。因此,用于交换前面表达式的值被评估为:

model.name;

此值也可以是:

{{controller.model.name}}

每当发出对name变量引用的请求时,Ember.js 首先检查控制器是否定义了该变量。由于这不是真的,控制器代理这个请求到其模型。

编写绑定和未绑定表达式

我们刚刚了解到,表达式通过引用绑定上下文中指定的变量来解析。Ember.js 更进一步,使这些表达式变得响应式。这意味着如果底层变量发生变化,被替换的表达式部分也将更新。有时,我们可能不希望抑制这种行为,特别是当变量太大并且构成未绑定表达式时。

这些表达式在渲染时只解析一次,并且不会订阅相应变量的进一步更改。这些表达式使用三个花括号而不是两个,如下面的示例所示,其中 Ember.js 驱动的博客文章的主要内容可以被渲染:

{{{post}}}

在模板中添加注释

Handlebars 中的注释具有{{! … }}签名。例如,我们可以添加文档来表示页脚的结束:

  </footer> {{! end of footer}}

这些表达式与普通 HTML 注释具有相同的作用,只是它们实际上并没有被转换为后者。因此,使用它们的良好理由是当我们不希望注释出现在渲染的输出中时。

编写条件语句

Handlebars 支持使用 ifif...elseunlessunless...else 条件语句。这意味着我们可以根据指定的条件渲染模板的不同部分。它们是封装模板部分的块表达式,通常分别以 {{#{{/ 模板标签开始和结束。例如,如果用户在章节示例的 contacts 模板中没有存储联系人,应用程序将过渡到 contacts.new 状态,强制用户添加一个。因此,我们需要在页面现在空白的左侧显示一个占位符字符串。我们通过检查传递的联系人列表是否确实为空来实现这一点,如下面的代码所示:

{{#if model.length}}
  ...
{{else}}
    <h1>Create contact</h1>
{{/if}}

占位符元素放置在 else 块中。如图所示,块表达式仅在传递的值评估为 True 时才会满足。因此,以下值将导致渲染 else 块:

  • false

  • undefined

  • null

  • [] (空数组)

  • ''

  • 0

  • NaN

与此相反,unless 表达式仅在评估的变量为 False 时才会满足。

注意,Handlebars 是“无逻辑”的,因此我们无法使用位运算符来表示条件,如下面的情况:

  {{#if user.score > 1000 }}
     <span>Level passed.</span>
   {{else}}
     <span>Level failed.</span>
   {{/if}}

   {{#if (temp.high + temp.low)/2 > 100   }}
     <span>It's hot today</span>
   {{/if}}

然而,我们可以使用计算属性或绑定在控制器层定义这些条件。例如,前面的示例可以正确实现如下:

App.ApplicationController = Em.Controller.extend({
  levelPassed: function(){
     return this.get('user.score') > 1000;
  }.property('user.score')
});

{{#if controller.levelPassed }}
  <span>Level passed.</span>
{{else}}
  <span>Level failed.</span>
{{/if}}

App.ApplicationController = Em.Controller.extend({
  isHot: function(){
    var temp = this.get('temp');
    return (temp.high + temp.low)/2 > 100;
  }.property('temp.high', 'temp.high')
});

{{#if controller.isHot }}
  <span>It's hot today</span>
{{/if}}

切换上下文

如前所述,Ember.js 会将表达式解析为模型上下文。{{#with}}...{{/with}} 辅助函数允许我们在检查期间指定优先级较高的上下文。一个很好的例子是在章节示例中,我们需要重复使用创建或更新联系人的表单。这个表单包含在 contacts/form 模板中。唯一的问题是,虽然 contacts/new 模板的上下文是一个新创建的联系人对象,但 contact/edit 模板必须引用由联系人控制器代理的联系人。多亏了 with 辅助函数和控制器依赖项,我们能够改变后者的上下文,如下所示:

<script type="text/x-handlebars" data-template- name="contact/edit">
  {{#with controller.controllers.contact}}
  {{partial "contacts/form"}}
  {{/with}}
</script>

我们将在讨论 partial 辅助函数时再次回到这个案例,但重要的是要注意,主要上下文现在不是相应的路由处理程序模型,而是 contact 控制器。

就像 each 辅助函数一样,我们可以创建一个新的上下文而不丢失现有的上下文,如下面的示例所示:

  <script
   type="text/x-handlebars"
  data-template-name="contact/edit">
    {{#with controller.controllers.contact as contact}}
    {{partial "contacts/form"}}
    {{/with}}
  </script>

 <script
   type="text/x-handlebars"
  data-template-name="contacts/new">
    {{#with model as contact}}
    {{partial "contacts/form"}}
    {{/with}}
  </script>

在这两种情况下,表单中的 email 字段,例如,现在需要绑定到联系人上下文:

{{input type="text" id="form-email" value=contact.email}}

渲染可枚举数据

通常,应用程序需要显示可枚举数据,可以使用 {{#each}} ... {{/each}} 块表达式来实现。例如,我们的 contacts 模板使用这个表达式来显示左侧的联系人列表:

<ul class="nav nav-pills nav-stacked">
  {{#each model}}
  <li>
    ...{{name}}...
  </li>
  {{/each}}
</ul>

我们省略了link-to表达式,稍后我们将讨论它。each块表达式在每次迭代时切换工作上下文,正如前一小节所讨论的那样。如果我们不想这样做,我们可以指定当前迭代对象的名称,如下面的重新实现所示:

<ul class="nav nav-pills nav-stacked">
  {{#each contact in model}}
  <li>
    ...{{contact.name}}...
  </li>
  {{/each}}
</ul>

这个块表达式的优点之一是我们可以使用else表达式检查迭代器是否为空。例如,我们可以在contacts模板中减少elseif...else表达式的使用,如下所示:

  <ul class="nav nav-pills nav-stacked">
    {{#each model}}
    <li>
      ...{{name}}...
    </li>
    {{else}}
      <h1>Create contact</h1>
    {{/each}}
  </ul>

编写模板绑定

编写已绑定和未绑定表达式部分中,我们提到 Ember.js 处理器库允许在表达式中定义的变量订阅并因此更新绑定上下文的变化。该库还允许我们将这些变量绑定到 HTML 元素属性,包括使用{{bind-attr .. }}辅助器绑定类。以下示例定义了一个其href属性绑定到提供的用户配置文件的链接:

    <a {{bind-attr href="profile.link"}}>User Profile</a>

到目前为止,我们都知道配置文件上下文将由路由处理器的模型钩子提供。例如,如果这是应用程序的模板,相应的路由处理器将提供上下文如下:

  App.ApplicationRoute = Em.Route.extend({
    model: function(){
     return { profile: {
       link: '/@jondoe'
     }}
    }
  });

结果渲染的模板将如下所示:

    <a  HYPERLINK "mailto:href%3D'/@jondoe"href='/@jondoe'>User Profile</a>

每次配置文件链接更改时,链接元素的href属性将自动更新。

我们还可能希望切换属性的状态,例如,常用的requireddisabled属性。一个常见的用例是我们希望在电子商务应用程序中允许单次点击,如下面的代码所示:

<button {{bind-attr disabled='isCheckingOut'}}>
  Checkout
</button>

在前面的例子中,当用户点击结账按钮时,结账操作应该切换isCheckedOut属性,这将导致按钮被禁用。因此,如果传递的条件变为TrueFalse,则可以添加或删除 DOM 元素上的属性。

元素类名也可以以相同的方式动态更新,只是在绑定行为上略有不同。例如,我们可能希望在应用程序中向点击的链接添加一个active属性,如下所示:

    <a href='/'  {{bind-attr class='selected'}}>Click me</a>

当上下文的selected属性评估为active时,链接将更新为:

  <a href='/' class='active'>Click me</a>

另一方面,如果属性变为未定义,链接将更改为:

  <a href='/'>Click me</a>

就像属性的存在可以动态更新一样,类名也可以根据指定的绑定条件从元素中插入和删除。因此,前面的示例可以重新实现为:

<a href='/ {{bind-attr class='selected:active:inactive'}}> Click me
</a>

在这里,当上下文的selected属性变为TrueFalse时,元素的类将是activeinactive

如果在分号之后只传递一个参数,则传递的参数将用作类名。例如,以下代码演示了这一点:

<a href='/' {{bind-attr class='isSelected:selected'}}> Click me
</a>

如果上下文的isSelected属性变为True,则会产生以下结果:

     <a href='/' class='selected'>Click me</a> 

值得注意的是,camelCase 类名会被转换为 dasherized,如下面的示例所示:

<a href='/' {{bind-attr class='isSelected'}}>Click me</a>

这将变为以下形式:

<a href='/' class='is-selected'>Click me</a>

与其他属性不同,我们可以使用相同的签名绑定多个类,如下面的示例所示:

<a href='/' {{bind-attr class='isSelected isActive'}}> Click me
</a>

这将变为以下形式:

<a href='/' class='is-selected is-active'>Click me</a>

有时,我们可能想在元素中使用绑定的和未绑定的类。以下示例演示了这一点:

<a href='/'  {{bind-attr class='isSelected :active'}}> Click me
</a>

这就产生了以下结果:

<a href=' class='is-selected active'>Click me</a>

如所示,未绑定的类名以分号开头。请注意,以下示例将不会工作,因为如果其中一个类名被绑定,所有类名都应该定义在 bind-attr 表达式内部:

<a href='/'  class='active' {{bind-attr class='isSelected'}}> Click me
</a>

定义路由链接

一个典型的 Ember.js 应用程序有几个我们需要在模板中链接的路由。{{#link-to}}...{{/link-to}} 辅助函数用于此目的,并允许应用程序轻松创建指向这些路由的锚点。例如,我们样本应用程序左侧的列表由用户可以使用来查看各种联系人详情的链接组成。我们使用此辅助函数生成这些链接如下:

  <ul class="nav nav-pills nav-stacked">
  {{#each model}}
  <li>
    {{#link-to "contact.index" this}}{{name}}{{/link-to}}
  </li>
  {{/each}}
</ul>

如果我们检查生成的链接之一,我们会注意到它类似于以下代码行:

<a href="#/contacts/1">Jon Doe</a>

辅助函数将路由名称作为第一个处理程序,然后是相应路由所需的资源。如前一章所述,由于受影响的路由路径有动态段,其处理程序负责解析所需的参数以替换这些段。在这种情况下,contact 路由路径有一个动态段,即 contact_id,正如前一章所讨论的那样。

如果我们要链接到博客路由,我们只需指定路由名称如下:

{{#link-to "about"}}about{{/link-to}}

就像 bind-to 表达式一样,link-to 表达式也接受其他元素属性,如 reltargetclass。以下示例在新的标签页或窗口中打开链接:

{{#link-to "about" target="_blank"}}about{{/link-to}}

注册 DOM 元素事件监听器

在纯 JavaScript 中,应用程序脚本遍历 DOM,沿途设置事件监听器。一个典型的表单可能看起来如下:

<form name='tweet'>
    <textarea name='content' required></textarea>
     <input type='submit' value='tweet'></form>

<script>
    var form = document.forms.tweet;
    form.onsubmit = function(event){
      event.preventDefault();
       alert(form.content.value); 
};
</script>

Ember.js 提供了一个抽象层,允许开发者使用 {{action ...}} 辅助函数轻松订阅元素特定的事件。我们的章节包含如下所示的形式:

<form
    class="form form-horizontal"
    role="form"
    {{action "saveContact" this on="submit"}}>

  ...

  <button class="btn" type="submit">
  done
  </button>

  ...
</form>
A function to triggerAn optional context objectAn optional type of event to listen to which defaults to click on

要调用的函数通常定义在路由的 actions 属性中,并接受任意数量的参数。默认情况下,绑定的事件类型通常是 click 事件。然而,您可以使用 on 属性来指定此类型。actions 属性可以定义在相应的路由或控制器上。例如,前面动作的动作处理程序是在 contacts 路由中定义的:

App.ContactsRoute = Ember.Route.extend({
  actions: {
    saveContact: function(contact){
      var id = contact.get('id');
      contact.save();
      if (!id){
        this.controllerFor('contacts').pushObject(contact);
      }
      this.transitionTo('contact.index', contact);
    }
  }
});

如前所述,此动作仍然可以在控制器层中定义如下:

App.ContactsController = Ember.ArrayController.extend({
  actions: {
    saveContact: function(contact){
      var id = contact.get('id');
      contact.save();
      if (!id){
        this.pushObject(contact);
      }
      this.transitionToRoute('contact.index', contact);
    }
  }
});

在决定将动作放置何处时需要考虑的主要点是何时我们需要利用冒泡动作。当一个动作被触发时,会在相应的控制器中查找指定的函数。如果这个动作被定义,它就会被执行。如果没有定义这个动作,Ember.js 会在相应的路由处理程序中进行检查。如果这个动作在控制器或路由处理程序中定义,并且返回的值等于True,Ember.js 就会继续检查父路由处理程序中是否有类似的功能,直到其中一个不包含该功能或没有定义它。

需要注意的一个重要事项是,冒泡动作仅在路由处理程序层发生。这是我们选择在路由处理程序中定义函数的原因之一。例如,我们应该能够从contacts.newcontact.edit模板中调用saveContact动作,所以我们将其定义在contacts路由中。以下是两种情况下动作传播的说明图:

注册 DOM 元素事件监听器

如果我们指定了动作的target对象,先前的动作函数仍然可以位于任何控制器中。target对象包含 Ember.js 检查action函数的actions哈希。默认情况下,它通常是模板的相应控制器,如前图所示。因此,我们可以在contact控制器中定义action函数,然后将目标设置为:

<form
    class="form form-horizontal"
    role="form"
    {{action "saveContact" this target="controllers.contact" on="submit"}}>
 ...
</form>

编写表单输入

编写表单是 Ember.js 通过提供许多 HTML5 表单控件模板辅助函数来简化的常见做法。以下表格显示了这些常见控件及其可以接受的属性:

控件 属性
input value size name pattern placeholder disabled maxlength tabindex
textarea checked disabled tabindex indeterminate name
checkbox rows cols placeholder disabled maxlength tabindex

在示例应用程序中,用户可以通过点击页脚中的编辑按钮来编辑联系人,如下面的截图所示:

编写表单输入

如果用户编辑联系人的名字,我们会注意到侧边栏中的名字也进行了更新。input辅助函数在contacts/form模板中被用来绑定输入元素的值与联系人的名字,如下所示:

{{input
  type="text"
  id="form-first-name"
  value=first_name
  required='required'}}

在这里,我们定义了四个属性,它们要么是已绑定要么是未绑定的。已绑定的属性会在指定的变量更改时更新,反之亦然。例如,在前面的例子中,value属性是已绑定的,而required属性则不是。未绑定的属性用引号括起来,而绑定的则不用。

这里有一些示例,展示了如何使用其他两个表单辅助函数:

{{textarea value=model.content}}
{{checkbox checked=model.isPaid}}

扩展模板

在应用程序开发的过程中,您可能会发现需要抽象模板以供重用。有几个辅助函数可以帮助我们轻松实现这一点:

  • partial

  • view

  • render

  • named outlets

partial辅助函数用于在模板中包含其他模板。它简单地将所需的模板插入到partial表达式指定的位置。如前所述,章节示例在两个地方使用了此辅助函数:

  <script
   type="text/x-handlebars"
  data-template-name="contact/edit">
    {{#with controller.controllers.contact}}
    {{partial "contacts/form"}}
    {{/with}}
  </script>

 <script
   type="text/x-handlebars"
  data-template-name="contacts/new">
    {{partial "contacts/form"}}

  </script>

此辅助函数将应插入到当前模板中的模板作为唯一参数。需要注意的是,使用此辅助函数不会导致上下文丢失,正如在contact/edit案例中看到的那样。

我们可能还希望在其他模板中插入视图。在这种情况下,视图的模板将被插入到当前模板的指定部分,并且将设置定义的事件监听器。例如,我们之前看到的第一个名字输入也可以写成:

{{view
  Em.TextField
  id="form-first-name"
  value=first_name
  required='required'
}}

我们将在第六章中更详细地讨论这个问题,视图和事件管理,我们将处理视图。这里需要注意的是,所命名的输入辅助函数实际上是这些视图中定义的 Handlebars 辅助函数。我们将在稍后讨论这些辅助函数是如何创建的。render辅助函数与partial辅助函数的工作方式相同,不同之处在于它接受一个可选的上下文作为第二个参数。例如,我们将定义contact/editcontact/edit模板如下:

  <script
   type="text/x-handlebars"
  data-template-name="contact/edit">
    {{render "contacts/form" controller.controllers.contact}}
  </script>

 <script
   type="text/x-handlebars"
  data-template-name="contacts/new">
    {{render "contacts/form"}}
  </script>

我们不是在第一个模板中切换上下文,而是简单地传递了要使用的控制器作为上下文。请注意,默认情况下,传递的上下文是对应的控制器实例,因此在这种情况下我们不需要指定此上下文。

最后一种扩展模板的方法是使用我们在前一章中讨论过的命名出口。以下是我们的示例:

<script type="text/x-handlebars" data-template-name="game">
  <div id="leaderboard">{{outlet leaderboard}}</div>
  <div id="mainboard">{{outlet mainboard}}</div>
</script>

然后,我们通过处理程序渲染出口:

App.GameRoute = Ember.Route.extend({
  renderTemplate: function() {
    this.render('mainboard', {
      into: 'game',
      outlet: 'mainboard',
      controller: 'mainboard'
    });
    this.render('leaderboard', {
      into: 'game',
      outlet: 'leaderboard',
      controller: 'leaderboard'
    });
  }
});

这与partial辅助函数的使用非常相似,但在这个情况下,我们还在路由处理器的renderTemplate钩子中指定了模板。

定义自定义辅助函数

Handlebars 提供了创建自定义辅助函数的方法。以下是新注册辅助函数的格式:

Ember.Handlebars.register(helper_name, helper_function_or_class);

例如,让我们创建一个heading辅助函数,用于创建h1标签:

  Ember.Handlebars.register('heading', function(text, options){
    var escapedText = Handlebars.Utils.escapeExpression(text);
     var heading = '<h1>'+escapedText+'</h1>';
               return new Handlebars.SafeString(heading);
  });

这可以在我们的应用程序模板中使用,如下所示:

  {{heading 'Title'}}

现在,生成以下内容:

  <h1>Title</h1>

以下示例还演示了如何从现有视图中创建辅助函数:

    Ember.Handlebars.register('loader', App.LoaderView}}

现在可以简单地使用如下:

  {{loader}}

这相当于以下:

  {{view App.LoaderView}}

创建子表达式

子表达式,正如其名所示,是包含在其他表达式中的表达式,它具有以下签名:

   {{outer-helper (inner-helper 'arg1') 'arg2'}}

以下是一个实现Number.toFixed辅助函数的示例:

Ember.Handlebars.registerHelper('to-fixed', function(num, decimals) {
  return new Ember.Handlebars.SafeString(
    num.toFixed(decimals)
  );
});

这可以在link-to辅助函数中使用,如下所示:

  {{#link-to 'checkout' (to-fixed cart.total)}}
  checkout
  {{/link-to}}

这将导致以下类似的结果:

   <a href='#/checkout/10.10'>checkout</a>

摘要

这是一章令人兴奋的章节,它引导我们了解了模板层。当我们讨论下一章中的控制器时,这一章将会被重新回顾。以下是我们在本章中学到的关键概念,以及将在下一章中回顾的概念:

  • 编写绑定模板表达式

  • 在模板中编写条件语句

  • 模板中的上下文切换

  • 在模板中创建事件监听器

  • 扩展模板

  • 编写自定义模板助手

第五章. 控制器

在上一章中,我们讨论了 Ember.js 中模板如何用于向用户展示数据。我们还介绍了如何通过这些模板在我们的应用程序中轻松实现用户交互。我们指出,模板通过与其控制器通信来发挥作用。本章将对此进行深入探讨,并涵盖以下主题:

  • 定义控制器

  • 在控制器中存储模型和对象

  • 使用对象和数组控制器

  • 指定控制器依赖项

  • 在控制器中注册动作处理器

  • 控制器中的状态转换

定义控制器

就像路由处理器一样,可以通过扩展Ember.Controller类来定义控制器,如下面的代码行所示:

AppNamespace.ControllernameController = Ember.Controller.extend();

可以进一步扩展已定义的控制器以创建另一个新的控制器类:

App.TweetsController = Ember.Controller.extend();
App.RetweetsController = App.TweetsController.extend();

这些控制器类可以通过create方法进行实例化,如下面的示例所示:

var controller = Ember.Controller.create();
var tweetsController = App.TweetsController.create();

就像对象一样,如果我们需要在实例化控制器时使用混入,我们需要使用createWithMixins方法:

var mixin = Ember.Mixin.create({
  model: [1, 2, 3]
});
var controller = Ember.Controller.createWithMixins(mixin);

这相当于以下内容:

var Controller = Ember.Controller.extend({
  model: [1, 2, 3]
});
var controller  Controller.create();

我们很少自己实例化应用程序控制器,因为当需要时,Ember.js 会为我们做这件事。

向控制器提供模型

在我们继续之前,让我们回顾一下数据在控制器中的加载和存储方式。我们将构建的大多数应用程序都将与 REST 端点进行通信,因此,Ember.js 自带了一些使创建此类应用程序变得简单的功能。在第三章中,我们学习了数据可以通过路由处理器的model钩子以异步方式从服务器加载。例如,让我们定义一个博客文章路由,从我们的服务器加载特定的博客文章。首先,我们将定义我们的应用程序的路由如下:

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

我们刚刚定义了一个将处理对文章详情页请求的资源,如下面的代码行所示:

this.resource('post', {path: '/post/:post_id'});

如果用户访问文章的路径,比如/post/100,Ember.js 期望文章路由处理器将定义一个model钩子,该钩子将从服务器加载匹配的文章。以下是一个使用 jQuery 说明此点的示例:

App.PostRoute = Ember.Route.extend({
  model: function(params){
    // load matching post from server
    return Ember.$.getJSON('/posts/'+params.post_id);
  }
});

在前面的示例中,处理器的model钩子接受一个包含文章 ID 的options对象。然后,使用 jQuery 的getJSON()方法使用此 ID 从服务器加载匹配的文章,该方法返回一个承诺,我们的应用程序在加载时解决。一旦解决,Ember.js 期望此路由定义一个setupController钩子,将解决的文章存储到相应的控制器中。这是作为以下代码实现的默认行为:

App.PostRoute = Ember.Route.extend({
  model: function(params){
    return Ember.$.getJSON('/posts/'+params.post_id);
  },
  setupController: function(controller, model){
    controller.set('model', model);
  }
});

setupController 钩子接收两个参数:相应控制器的实例和解析的模型。此模型存储在控制器的 model 属性中。请注意,这只是默认实现;我们可以将模型存储在任何其他所需的属性或控制器中。

从控制器渲染动态数据

在从服务器加载数据后,控制器的作用是使这个模型可用于相应的模板进行显示。然后这些模板将注册对提供模型属性的绑定,并使用表单控件发送对这些属性所做的更改的更新。由于控制器是 Ember.Object 的扩展,它们通过以下方式实现了对浏览器环境事件特性的更好管理:

  • 属性

  • 计算属性

  • 可观察者

属性

模板可以使用表达式显示绑定控制器的属性。例如,在先前的示例中,帖子模板将显示已加载的帖子如下:

  {{! post.hbs}}

  <h1>{{model.title}}</h1>
  <p>{{model.body}}</p>

当帖子加载时,渲染的帖子模板将类似于以下内容:

<h1>Introduction to Ember.js.</h1>
<p>A gentle introduction to Ember.js.</p>

如果帖子标题在以后某个时间点发生变化,模板的标题部分将被重新渲染以显示新的标题,如下所示:

  controller.set(model.title', 'A guide to using Ember.js.');

模板还可以将更新推回控制器。这通常是通过 HTML 表单元素完成的。Ember 提供了 Handlebars 表达式,用于抽象这些控件的使用以创建双向绑定,正如我们在上一章中讨论的那样。为了说明这一点,让我们在我们的博客应用程序中添加一个新的路由,这将允许博客的管理员添加一个新的帖子条目,如下面的代码所示:

  this.resource('posts', function(){
    this.route('/new'); // route to add a new post

});

管理员当然会在 /posts/new 页面上创建新的帖子。Ember.js 会期望为这个路由提供一个 posts/new 模板,其代码如下:

{{! posts/new template }}

<form>
  {{input name='title' value=model.title}}
  {{textarea name='body' value=model.body}}
  <button type='submit'>Save post</button>
</form>

PostsNewRoute 处理器还需要为将作为其上下文的模板提供模型。正如你可能已经猜到的,它的 model 钩子将返回一个新帖子对象以供更新,如下面的示例所示:

App.PostsNewRoute = Ember.Route.extend({
  model: function(params){
    return Ember.Object.create();
  }
});

model 钩子返回一个 Ember.js 对象,该对象将作为管理员要创建的帖子。由于这是常见做法,我们将在后面的章节中学习如何使用 ember-data,这是一个高级库,有助于定义和创建此类模型。由于双向绑定,任何对文本控件的更新都将更新新帖子。

计算属性

计算属性是返回属性并依赖于其他属性的函数。我们可以使用计算属性创建依赖于其他属性的 states 和 properties。这在我们需要通过聚合或任何形式的 map reduce 获取状态时特别有用。以下是一个用例示例:

{{! application template }}
{{input name="firstName" value=model.firstName}
}
{{input name="lastName" value=model.lastName}}
Full name: {{fullName}}

// application controller

App.ApplicationController = Em.Controller.extend({
  fullName: function(){
    return this.get('model.firstName') + ' ' + this.get('model.lastName');
  }.property('model.firstName', 'model.lastName')
});

// application route
App.ApplicationRoute = Em.Route.extend({
  setupController: function(controller){
    controller.set('model', Em.Object.create({
      firstName: 'Jon',
      lastName: 'Doe',
    });
  });
     });

在这个例子中,模板能够根据用户的第一个和最后一个名字显示用户的 fullName。我们之前已经提到,仅使用模板层是无法完成这种实现的,如下面的示例所示:

  Full name: {{firstName+lastName}} // wrong

需要注意的一点是,除非对象实例被设计为静态的,否则它们永远不会在类定义上设置。例如,如果模板是一个更新模型值的表单,我们可能会倾向于提供控制器的默认模型为:

App.ApplicationController = Em.Controller.extend({
  model: Em.Object.create({
    firstName: 'Jon',
    lastName: 'Doe',
  },

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

});

之前实现的代码可能导致更新没有像预期那样被隔离。以下是我们博客应用中可以添加的另一个搜索功能示例:

{{! search template}}

{{input name="search" value=queryTerm}}
<ul>
  {{#each results}}
  <li>{{user}}: {{body}}<li>
  {{/each}}
</ul>

// search controller
App.SearchController = Em.Controller.extend({

  // require posts controller
  needs: ['posts'],

  // compute matching posts
  results: function(){

    var queryTerm = this.get('queryTerm');
    if (!queryTerm && queryTerm.trim() === '') return;

    return this
    .get('controllers.posts.model')
    .filter(function(){
      return post.match(queryTerm);
    });

  }.property('queryTerm')

});

此示例包含一些我们之前讨论过的功能。如果用户访问say/search搜索页面,他们将看到一个搜索输入框,该输入框会自动在输入时更新搜索控制器的queryTerm属性。正如你可能注意到的,这个控制器的results属性将重新计算,因为它依赖于控制器的queryTerm属性。

我们引入的一个功能是控制器能够引用其他控制器的能力。我们将在稍后的部分讨论这个问题,但重要的是要注意,我们能够通过过滤posts控制器的模型来生成结果。搜索模板会自动在用户输入时重新显示结果。这个示例演示了在单页应用中添加这种看似困难的功能是多么简单。以下是一些你可以尝试添加到应用中的其他功能:

  • 每次从服务器加载新帖子时都会显示一个旋转器。现在是一个很好的时机来回顾我们在第三章中讨论的加载和错误操作钩子,路由和状态管理

  • 为每个加载的帖子定义一个humanizedDate计算属性。让这个属性以可读的格式返回帖子的日期,例如Mon, 15th。Moment.js (momentjs.com)可能会很有用。

可观察对象

除了计算属性之外,我们还学习了如何使用可观察对象。这些是响应其他属性更改的函数。例如,让我们使之前提到的搜索功能更加用户友好。大多数用户期望在停止输入查询词后的几秒钟内启动搜索请求。因此,我们需要一种方法来防抖这个搜索。Ember.js 提供了一个提供此功能的函数,可以引用为Ember.run.debounce。以下是一个可能的实现:

App.SearchController = Em.Controller.extend({

  needs: ['posts'],

  queryTermDidChange: function(){
    Em.run.debounce(this, this.searchResults, 1000);
  }.observes('queryTerm'),
  searchResults: function(){
    var queryTerm = this.get('queryTerm');
    if (!queryTerm && queryTerm.trim() === '') return;
    var results = this
    .get('controllers.posts.model')
    .filter(function(){
      return post.match(queryTerm);
    });
    this.set('results', results);
  }
});

控制器定义了一个观察者queryTermDidChange,它在输入仅一秒钟后调用搜索函数。如图所示,debounce函数(emberjs.com/api/classes/Ember.run.html#method_debounce)接受三个参数——一个上下文、一个在指定上下文中调用的函数,以及在没有其他调用时等待调用的时长。

对象和数组控制器

Ember.js 附带以下控制器,旨在轻松表示对象和可枚举数据:

  • 对象控制器

  • 数组控制器

这些控制器在某种程度上与其他控制器不同,因为表示的数据通常被设置为控制器的model属性,例如:

Ember.ObjectController.create({
  model: {
    name: 'Jon Doe'
    age: 23
  }
});

Ember.ArrayController.create({
  model: [1, 2]
});

一个对象控制器

对象控制器用于代理表示的对象的属性。这意味着如果访问控制器属性,Ember.js 将首先在控制器中查找该属性,然后是模型。例如,让我们创建一个帖子模型类:

App.Post = Ember.Object.extend({
    title: null,
    body: null
});

然后,从这个模型创建一个新的帖子:

var post = App.Post.create({
    title: 'JavaScript prototypes.',
    body: 'This post will discuss JavaScript prototypes.'
});

可以显然使用对象的 getter 和 setter 方法访问此帖子的属性:

post.set('title', 'Design patterns.');
post.get('title'); // Design patterns.

当此帖子被设置为ObjectController实例的模型时,如图所示,访问控制器属性将转换为访问帖子,例如:

var postController = Ember.ObjectController.create();
postController.set('model', post);
postController.get('title'); // Design patterns.

注意,使用普通控制器不会产生相同的结果:

var postController = Ember.Controller.create();
postController.set('model', post);
postController.get('title'); //  undefined
  postController.get('model.title'); // Design patterns.

当我们希望在控制器上创建依赖于模型属性的计算属性时,对象属性非常有用。例如,让我们将前面定义的帖子作为路由的模型传递:

App.PostRoute = Ember.Route.extend({
  model: function(){
    return App.Post.create({
      title: 'JavaScript prototypes.',
      body: 'This post will discuss JavaScript prototypes.',
      tagIds: [1, 2, 3, 4]
    });
  }
});

现在,假设我们想要根据给定的 ID 数组计算一个tags属性,我们将在相应的控制器中实现它,如下所示:

App.PostController = Ember.ObjectController.extend({

  TAGS: {
     1: 'ember.js',
     2: 'javascript',
     3: 'web',
     4: 'mvc'
  },

  tags: function(){
    var tags = this.get('TAGS');
    return this.get('tagIds').map(function(id){
    return tags[id];
    });
  }.property('TAGS', 'tags.length')

});

定义了计算属性后,我们可以在帖子模板中使用它,如下所示:

<p>{{title}}</p>
<p>{{body}}</p>
<ul>
     {{#each tags}}
     <li>{{this}}</li>
    {{/each}}
</ul>

这将产生如下所示的结果:

<p>JavaScript prototypes.</p>
<p>This post will discuss JavaScript prototypes.</p>
<ul>
  <li>ember.js</li>
  <li>javascript</li>
  <li>web</li>
  <li>mvc</li>
</ul>

注意,我们不需要像前面章节中那样在变量前加上model.前缀,因为模板的上下文、控制器将这些请求转发给了模型。这种类型的控制器使用Ember.ObjectProxy(emberjs.com/api/classes/Ember.ObjectProxy.html)混合,这使得代理(在这种情况下,控制器)可以将所有请求转发到它未定义的属性以及其模型,正如我们之前讨论的那样。

一个数组控制器

同样,数组控制器用于表示可枚举数据。可枚举数据的一个例子是 JavaScript 的Array原始数据类型:

var controller = Ember.ArrayController.create({
  model: [1, 2, 3]
});
controller.get('length'); // 3

在这种情况下,相应的模板将列出项目,如下所示:

{{#each}}
   {{this}}
{{/each}}

注意,我们也不需要像以下情况那样引用模型:

{{#each model}}
{{this}}
{{/each}}

{{#each controller.model}}
{{this}}
{{/each}}

由于数组控制器表示可枚举数据,它们提供了以下有用的方法,可以用来操作它们的模型。

addObject(object)

addObject(object)方法如果控制器模型不包含该对象,则将给定的对象添加到控制器模型的末尾,如下例所示:

var controller = Ember.ArrayController.create({
  model: []
});
controller.addObject('a'); // ['a']
controller.addObject('b'); // ['a', 'b']
controller.addObject('b'); // ['a', 'b'] // already added

如果模型已经包含该对象,此方法调用将静默失败。

pushObject(object)

pushObject(object)方法始终添加对象,无论模型是否包含它,例如:

var controller = Ember.ArrayController.create({
  model: []
});
controller.pushObject('a');  // ['a']
controller.pushObject('b'); // ['a', 'b']
controller.pushObject('b'); // ['a', 'b', 'b'] // still added

removeObject(object)

removeObject(object)方法用于从控制器模型中移除指定的对象,如下例所示:

var controller = Ember.ArrayController.create({
  model: ['a', 'b', 'c']
});
controller.removeObject('a');    // ['b', 'c']
controller.removeObject('b');    // ['c']
controller.removeObject('b');    // ['c'] // fails silently

如果模型不包含该对象,此方法将不执行任何操作。

addObjects(objects), pushObjects(objects), 和 removeObjects(objects)

之前提到的三种方法用于使用多个对象执行我们刚才讨论的三个方法,例如:

var controller = Ember.ArrayController.create({
  model: []
});
controller.pushObjects(['a', 'b']);       // ['a', 'b']
controller.addObjects(['b', 'c']);        // ['a', 'b', 'c']
controller.removeObjects(['b', 'c']); // ['a']

contains(object)

要检查模型是否包含一个对象,我们可以使用返回布尔值的 contains(object) 方法:

var controller = Ember.ArrayController.create({
  model: ['a', 'c']
});
controller.contains('a');    // true
controller.contains('b');    // false

compact()

compact() 方法返回一个底层模型的副本,其中已删除 undefined 和 null 项:

var controller = Ember.ArrayController.create({
  model: ['a', 'b', 'c', undefined, null]
});
controller.compact();    // ['a', 'b', 'c']

every(callback)

every(callback) 方法用于检查模型中包含的每个项是否满足给定的条件:

var areEven = [2, 2, 4, 24, 80].every(condition); // true
var areEven = [1, 2, 3, 4, 5].every(condition);     // false

function condition(integer){
  return integer %2 === 0;
}

filter(object)

过滤器的工作方式与原生的 JavaScript 数组对象 Array.filter 相同:

[2, 2, 4, 24, 80].filter(condition); // [2, 2, 4, 24, 80]
[1, 2, 3, 4, 5].filter(condition);     // [2, 4]

function condition(integer){
  return integer %2 === 0;
}

filterBy(property)

有时候,我们想要压缩一个模型,但只有当包含的项定义了给定的属性时才这样做。我们可以使用前面的 filter 方法,如下所示:

var colors = [
  { name: 'red', isPrimary: true },
  { name: 'green', isPrimary: false },
  { name: 'black', isPrimary: undefined },
  { name: 'white', isPrimary: null },
];
colors.filter(condition);     // [{ name: 'red', isPrimary: true }]
function condition(color){
  return !!color.isPrimary;
}

我们也可以使用更简短的形式,如下所示:

var colors = [
  { name: 'red', isPrimary: true },
  { name: 'green', isPrimary: false },
  { name: 'black', isPrimary: undefined },
  { name: 'white', isPrimary: null },
];
colors.filterBy('isPrimary');     // [{ name: 'red', isPrimary: true }]

find(callback)

在前面的例子中,我们可以使用 filter 方法来返回第一个主要颜色的出现,如下所示:

var colors = [
  { name: 'red', isPrimary: true },
  { name: 'green', isPrimary: false },
  { name: 'black', isPrimary: undefined }
];
colors.filter(condition)[0];     // { name: 'red', isPrimary: true }
function condition(color){
  return !!color.isPrimary;
}

这是不高效的,因为我们总是遍历所有模型项。可以使用 find 方法来实现这个需求,如下所示:

var colors = [
  { name: 'red', isPrimary: true },
  { name: 'green', isPrimary: false },
  { name: 'black', isPrimary: undefined }
];
colors.find(condition);     // { name: 'red', isPrimary: true }
function condition(color){
  return !!color.isPrimary;
}

一旦找到匹配项,检查迭代就会终止。

findBy(key, value)

正如在 filterfilterBy 的情况下,我们可以使用 findBy 方法而不是 find 来重新实现前面的例子,如下所示:

var colors = [
  { name: 'red', isPrimary: true },
  { name: 'green', isPrimary: false },
  { name: 'black', isPrimary: undefined }
];
colors.findBy('isPrimary', true);     // { name: 'red', isPrimary: true }

insertAt(index, object), objectAt(index), 和 removeAt(index, length)

insertAt(index, object)objectAt(index)removeAt(index, length) 方法用于使用项索引执行操作。第一个方法用于在指定的索引处添加一个对象。如果索引超出范围,则抛出错误。第二个方法用于检索指定索引处的对象。如果索引超出范围,则返回 undefined 值。

注意,我们无法使用负索引进行查找,如下例所示:

var colors = ['red', 'blue'];
colors.insertAt(1, 'yellow'); // ['red',  'yellow', 'blue'];
colors.insertAt(10, 'green'); // Error: Index out of range
colors.objectAt(0); // 'red'
colors.objectAt(10); // undefined
colors.objectAt(-1); // undefined - negative index

最后一种方法通过可选的范围删除与给定索引匹配的对象:

var fruits = ['mango', 'apple, 'banana', 'orange', 'papaya', 'lemon'];
fruits.removeAt(0); // [apple, 'banana', 'orange', 'papaya', 'lemon'];
fruits.removeAt(1, 2); [apple, 'papaya', 'lemon'];
fruits.removeAt (10, 3); // Error: Index out of range

map(callback)

映射与 Array.map 的工作方式相同:

var fruits = ['mango', 'apple', 'banana', 'orange', 'papaya', 'lemon'];
fruits.map(function(fruit){
  return {name: fruit};
});
// [
//  { name: 'mango'},
//  { name: 'apple'},
//  { name: 'banana'},
//  { name: 'orange'},
//  { name: 'papaya'},
//  { name: 'lemon'}
// ];

mapBy(property)

使用前面例子中生成的结果,我们可以使用 mapBy 方法来获取原始数组,如下所示:

var fruits = [
 { name: 'mango'},
 { name: 'apple'},
 { name: 'banana'},
 { name: 'orange'},
 { name: 'papaya'},
 { name: 'lemon'}
];
fruits.mapBy('name');
// ['mango', 'apple', 'banana', 'orange', 'papaya', 'lemon'];

如上图所示,此方法返回一个新数组,包含在模型项上评估的值。

forEach(function)

这是一个常用的方法,它对模型中包含的每个项调用给定的函数:

var Dog = Em.Object.extend({
  bark: function(){
    console.log('woof: %s', this.get('name'));
  }
});
// model
var dogs = ['bo', 'sunny'].map(function(name){
  return Dog.create({
    name: name
  });
});
dogs.forEach(function(dog){
  dog.bark();
});
// woof: bo
// woof: sunny

uniq()

如其名所示,uniq() 方法返回一个不包含重复项的新数组:

['papaya', 'apple', 'banana', 'orange', 'papaya', 'apple'].uniq();
// ["papaya", "apple", "banana", "orange"]

sortProperties 和 sortAscending

sortPropertiessortAscending 方法用于对表示的数据进行排序。例如,我们可能有一个音乐目录,我们希望按专辑名称排序,然后按歌曲名称排序,如下表所示:

专辑名称 歌曲名称
双重幻想 Tiffany Blews
双重幻想 W.A.M.S
高地上的无限 惊悚

要完成这个任务,我们需要在控制器中定义以下属性:

  • sortProperties

  • sortAscending

第一个属性指定了排序项时使用的属性,而第二个属性指定了排序方向。在我们的例子中,我们将按以下方式对音乐目录进行排序:

var controller = Ember.ArrayControler.create({
  model: [
   {name: 'W.A.M.S', album: 'Folie a deux'},
   {name: 'Thriller', album: 'Infinity on high'},   {name: 'Tiffany blues', album: 'Folie a deux'},
  ],
  sortProperties: ['name', 'album'],
  sortAscending: true
});

要按相反顺序对歌曲进行排序,我们需要将sortAscending属性设置为False

这些只是Ember.ArrayProxy(emberjs.com/api/classes/Ember.ArrayProxy.html)混合提供的一些常见方法,而Ember.ArrayController正是使用了这些方法。

处理事件动作

在上一章中,我们学习了如何将用户动作从模板轻松委托给控制器和路由。让我们通过一个例子来回顾一下:

{{! posts/new template }}

<form {{action 'save' model on='submit'}}>
  {{input name='title' value=title}}
  {{textarea name='body' value=body}}
  <button type='submit'>Create</button>
  <button type='cancel' {{action 'cancel' this}}>Cancel</button>
</form>

在这个例子中,我们定义了两个将由相应控制器处理的动作,如下所示:

App.PostsNewController = Ember.ObjectController.extend({
  actions: {
    save: function(post){
      post.save();
    },
    cancel: function(post){
      post.rollback();
    }
  }
});

我们已经了解到,所有的动作处理器都定义在目标控制器或路由的actions属性中。在这种情况下,当用户通过点击提交按钮或按Enter键提交表单时,控制器中的save钩子会以 post 上下文作为唯一参数被调用。同样,点击取消按钮会调用相应的cancel钩子。

一个典型的由标签组成的部件可以以相同的方式实现,如下面的截图所示:

处理事件动作

在这种情况下,小部件模板可能看起来像以下代码:

<ul class="tabs">
  {{#each}}
  <li    {{bind-attr selected="selected"}} 
    {{action "selected" this}}>
      {{name}}
  </li>
  {{/each}}
<ul>

此模板包含由tabs上下文属性生成的tabs元素组。如果点击了任何标签,它们将需要获取一个selected类。以下适合的样式将实现此效果:

.tabs .selected{
  color: deepskyblue
}

控件的selected行为处理器将实现如下:

App.TabsRoute = Ember.Route.extend({

  model: function(){

    return [{

      name: 'tab 1',

      body: 'tab 1 content'

    }, {

      name: 'tab 2',

      body: 'tab 2 content'

    }];
  }

});

App.TabsController = Ember.ArrayController.extend({

  actions: {

    selected: function(selectedTab){

      this.forEach(function(tab){

        var selected = tab.get('name') === selectedTab.get('name');

        tab.set('selected', selected);

      });

    }

  }

});

注意,这些动作不需要像我们在上一章中讨论的那样在上下文控制器中捕获。当一个动作被触发时,通常是从模板元素触发的,Ember.js 会检查是否在直接控制器的actions属性中定义了适当的行为处理器。如果不是这种情况,Ember.js 将继续在相应的路由中搜索行为处理器。需要注意的是,如果一个行为处理器返回True,Ember.js 仍然会继续搜索这个处理器,这构成了动作冒泡

指定控制器依赖项

控制器依赖项使控制器能够关联。因此,每当一个控制器需要访问另一个控制器的属性时,它应该首先将控制器声明为依赖项,以便能够这样做。这些依赖项在受影响控制器的needs属性中定义。例如,假设我们决定向我们的博客应用程序添加一个评论系统:

  this.resource('post', {path: '/post/:post_id'}, function(){
    this.resource('comments', function(){
  });    });

在一个典型的博客中,评论通常显示在单独的页面上,在我们的案例中,是在路径如 /post/100/comments 的页面上。我们需要定义一个 comments 模板,该模板列出已加载的评论如下:

{{! comments template}}
<h1> Comments for <h1>
<ul>
  {{#each comments}}
  <li>{{user}}: {{body}}</li>
  {{/each}}
</ul>

如您可能已经注意到的,模板需要显示评论帖子的标题。为此,它需要能够访问 post 控制器中加载的帖子。通过指定对 post 控制器的依赖项,评论控制器将能够在其 controllers 对象属性中访问 post 控制器。例如:

App.CommentsController = Ember.Controller.extend({
  needs: ['post']
});

然后,comments 模板将更新为以下内容:

{{! comments template}}
<h1>Comment listing for {{controller.controllers.post.title}}</h1>
<ul>
     {{#each comments}}
  <li>{{user}}: {{body}}</li>
  {{/each}}
</ul>

您可能想知道这是否会引发无限依赖循环。好吧,控制器可以相互依赖而不会遭受这种命运:

App.AController = Ember.Controller.extend({
  needs: ['b']
}); 
App.BController = Ember.Controller.extend({
  needs: ['a']
}); 

这种关联充当了应用程序不同组件之间正确沟通的渠道。

控制器中的状态转换

在 第三章,路由和状态管理 中,我们了解到路由可以通过调用它们的 transitionTo 方法将应用程序的状态转换到其他路由,如下面的代码所示:

App.IndexRoute = Ember.Route.extend({
  redirect: function(){
    this.transintionTo('posts');
  }
});

同样,控制器也通过使用提供的 transitionToRoute 方法具有这种能力。例如,我们可以在控制器的事件处理程序中更改状态,如下所示:

App.PostsNewController = Ember.ObjectController.extend({
  actions: {
    cancel: function(post){
      this.transitionToRoute('posts');
    }
  }
});

摘要

这是一章令人兴奋的章节,帮助我们理解控制器的主要目的,即数据表示。我们学习了控制器是如何根据定义的应用程序路由来定义的。我们还学习了如何使用对象和数组控制器来表示模型。最后,我们学习了如何设置控制器之间的依赖关系,这些控制器可能处理应用程序的不同关注点。在本书的这个阶段,我们真的应该开始思考构建 Ember.js 应用程序的方法。下一章将涵盖视图层,这需要大量关于控制器使用的知识。

第六章。视图和事件管理

我们在前两章讨论了模板和控制器。我们注意到控制器呈现模型,这些模型由模板渲染给用户。我们还了解到,当用户与应用程序交互时,模板通常会使用动作模板助手将这些事件传播回控制器。实际上,这些动作表达式是视图,最初将事件委托给控制器,然后委托给路由。因此,在本章中,我们将学习如何将视图直接集成到模板中,特别是当出现以下应用程序需求时:

  • 应用程序的一个部分需要复杂的事件管理

  • 需要构建可重用的组件

  • 应用程序需要集成第三方库

因此,到本章结束时,你应该能够:

  • 定义视图

  • 创建视图实例

  • 自定义视图

  • 在视图中管理事件

  • 使用内置视图

  • 使用第三方库

如第一章所述,我们很少需要定义视图,除非我们确实需要严格控制 DOM 结构。在下一章中,我们将讨论如何使用 ember 组件,它是视图的高级结构。

定义视图

应用程序中的视图通过数据绑定和委托用户发起的事件来管理模板。就像控制器一样,视图类是从基类 Ember.View 定义的:

var View = Ember.View.extend({});

通过调用视图的 create 方法可以创建先前视图的实例:

var view = View.create();

我们仍然可以从已经定义的类中创建额外的视图,如下面的代码所示:

var UserView = View.extend({
  isLoggedIn: true,
  isAdmin: false
});

到现在为止,我们已经知道 Ember.js 类可以接受任何数量的混合,如前例所示。然而,使用混合创建的实例始终使用 createWithMixins 方法:

var mixinA = Ember.Mixin.create({
  isLoggedIn: true
});

var mixinB = Ember.Mixin.create({
  isAdmin: false
});

var userView = View.createWithMixins(mixinA, mixinB);

访问视图的控制器

视图通常由相应控制器的实例支持。一旦视图被插入到 DOM 中,相应的控制器可以通过 controller 属性访问,如下面的示例所示:

view.get('controller').getSortedBooks();

指定视图的模板

每个视图都将模板渲染到 DOM 中。视图可以通过多种方式分配要使用的模板。例如,让我们考虑以下路由:

App.Router.map(function(){
  this.route('new');
});

Ember.js 预期新路由将有一个定义的 App.NewRoute 类:

App.NewRoute = Ember.Route.extend({
  model: function(){
    return Em.Object.create();
  }
});

如果定义了此路由,任何访问此路由的操作都将使用以下 Ember.js 对象:

  • App.NewController 对象

  • Ember.TEMPLATES.new 模板

  • App.NewView 对象

默认行为是模板的名称决定了要使用的视图。如果我们希望使用不同的模板,例如 Ember.TEMPLATES.form,我们将在路由的 renderTemplates 钩子中实现它,如下所示:

App.NewRoute = Ember.Route.extend({
  model: function(){
    return Em.Object.create();
  },
  renderTemplate: function(controller, model){
    this.render('form');
  }
});

如预期的那样,此路由将使用 App.FormView 视图类。使用的模板通常作为脚本标签包含在应用程序中。例如,我们可以定义将用于 App.NewView 的新模板,如下所示:

<script type='text/x-handlebars' id='new'>
<input name='name' >
<input name='gender' >
<button type='submit'>save</button>
</script>

注意,之前的模板仍然可以通过 Ember.TEMPLATES.new 访问。因此,指定视图模板的另一种方法是通过实用地更新其值来使用所需的编译模板,如下面的代码所示:

var template = [
 '<input name='name' >',
 '<input name='gender' >',
'<button type='submit'>save</button>'
].join('');

Ember.TEMPLATES['new'] = Ember.Handlebars.compile(template);

在生产环境中,建议在服务器端编译这些模板,然后为了性能原因将它们捆绑起来。我们仍然可以通过视图类上的 templateName 属性指定模板,例如:

<script type='text/x-handlebars' id='form'>
   <input name='name' >
   <input name='gender' >
   <button type='submit'>save</button>
</script>

   App.NewView = Ember.View.extend({
     templateName: 'form'
  });

指定视图的元素标签

视图的模板通常默认包裹在一个 div 元素中,如下面的示例所示:

<div>{{name}}</div>
<div>{{gender}}</div>

这会产生以下结果:

<div id='ember10' class='ember-view'>
  <div>Jon Doe</div>
  <div>Male</div>
</div>

可以使用 view 类的 tagName 属性来更改元素类型,如下面的代码所示:

var View = Ember.View.extend({
  templateName: 'user',
  tagName: 'header'
});

之前的代码片段将产生类似以下的结果:

<header div id='ember10' class='ember-view'>
  <div>Jon Doe</div>
  <div>Male</div>
</header>

更新视图的元素类属性

在上一节中,我们了解到视图通常被包裹在一个可配置的 DOM 元素中。元素的类属性也可以使用视图的 classNames 数组属性静态指定。例如,可以创建一个 Twitter Bootstrap 按钮,如下所示:

var view = Ember.View.extend({
  tagName: 'button',
  classNames: ['btn', 'btn-primary]
});

这将产生类似以下的结果:

<button class='btn btn-primary'>Button</button>

元素的类也可以使用视图的 classNameBindings 数组属性动态更改,如下面的代码所示:

var View = Ember.View.extend({
  tagName: 'button',
  classNames: ['btn'],
  classNameBindings: ['btnWarning'],
  btnWarning: true
});

这个示例会产生以下结果:

<button class='btn btn-warning'>Button</button>

这些类名按照 Ember.js 命名约定进行了连字符化。因此,btnWarning 属性映射到 btn-warning 类名。

有时,你可能希望根据给定的状态指定要使用的类名。例如,我们在第四章中学习了这一点,编写应用模板

var View = Ember.View.extend({
  tagName: 'button',
  classNames: ['btn'],
  classNameBindings: ['warn:btnWarning'],
  warn: true
});

在前面的示例中,btn-warning 类将被添加到元素的类属性中,这是基于视图的 warn 属性。

最后,我们可以根据某种状态添加不同的类。例如,假设我们想要显示 Bootstrap 按钮的不同状态。这可以通过以下签名实现:

classNameBindings: ['property:truthyClassName:falsyClassName'],

例如:

var View = Ember.View.extend({
  tagName: 'button',
  classNames: ['btn'],
  classNameBindings: ['controller.warn:btnWarning:btnPrimary'],
});

在前面的示例中,当视图控制器的 warn 属性变为 true 时,将产生以下结果:

<button class='btn btn-warning'>checkout</button>

否则,将使用其他类:

<button class='btn btn-primary'>checkout</button>

到现在为止,你可能会注意到绑定行为与我们之前在第四章中学习的类似。

更新其他视图的元素属性

除了类属性之外,视图元素的其余属性也可以动态更改。例如,让我们创建一个缩略图视图,如下面的代码所示:

var thumb = Ember.View.create({
  tagName: 'img',
  attributeBindings: ['width', 'height', 'src'],
  width: 50,
  height: 50,
  src: 'http://www.google.com/doodles/new-years-day-2014'
});

这会产生以下结果:

<img src='http://www.google.com/doodles/new-years-day-2014' width='50' height='50'>

可以使用绑定的布尔属性来改变属性的存在性。例如,我们可以禁用表单中相应输入未填写时的 save 按钮,如下面的示例所示:

// view

App.FormButton = Em.View.extend({
  tagName: 'button',
  attributeBindings: ['disabled'],
  disabled: function(){
     return !this.get('controller.model.title');
  }.property('controller.model.title')
});

// route

App.NewRoute = Em.Route.extend({
  model: function(){
    return Em.Object.create({
   });
  }
});

{{! new template }}

<form {{action 'save' model on='submit'}}>
  {{input value=model.title}}
  {{#view App.FormButton}}save{{/view}}
</form>

当模型的 title 属性未定义时,视图的禁用属性将为真。因此,视图的元素将获得禁用属性,反之亦然。这个例子允许用户仅在表单有效时提交表单。请注意,定义的视图可以重新实现为:

App.FormButton = Em.View.extend({
  tagName: 'button',
  attributeBindings: ['modelIsValid::disabled'],
  modelIsValid: function(){
     return !this.get('model.title');
  }.property('model.title')
});

这个例子表明,任何属性,在这个例子中是 modelIsValid,都可以用来提供在状态改变时显示或隐藏的属性,只要它使用以下签名指定:

"propertyName:attributeWhenTrue:attributeWhenFalse" 

将视图插入到 DOM 中

我们刚刚了解到,视图有模板,它们将渲染到 DOM 中。需要手动执行此操作的应用程序需要利用视图实例的 appendTo 方法,如下面的示例所示:

view.appendTo('#header');

此方法接受一个 jQuery 查询选择器,这是我们已经在以下示例中熟悉的,如下所示:

view.appendTo('header');
view.appendTo('#header');
view.appendTo('.header');
view.appendTo('body > header');

注意,只使用了一个匹配的元素。因此,在第三个例子中,视图将被插入到最后找到的 header 元素中。

为了方便起见,视图有一个 append 方法,可以直接将其插入到 DOM 的主体部分:

view.append(); // appends view to the body section

您可能还希望使用 remove 方法从 DOM 中移除视图,如下所示:

view.remove();

注意,如果视图被销毁,它将自动从 DOM 中移除,如下面的代码行所示:

view.destroy();

将视图插入到模板中

视图是分层的,因此它们可以插入到构成模板层次结构的其他视图的模板中,这是我们讨论过的第四章,编写应用程序模板。例如,考虑以下应用程序模板:

<script type='text/x-handlebars' id='application'>
{{view App.HeaderView}}
{{view App.FooterView}}
</script>

如所示,定义的视图使用 view 表达式插入到所需的模板中。这些 view 表达式也可以被包裹在块子句中,如下面的代码所示。然后可以在此块表达式中插入额外的视图:

{{! application template}}
<script type='text/x-handlebars' id='application'>

 {{view App.HeaderView}}

 {{#view App.ContentView}}
   {{view App.SideView}}
   {{view App.PaneView}}
 {{/view}}

 {{view App.FooterView}}

</script>

指定视图布局

我们已经了解到,视图的模板被包裹在一个元素中,这通常由 tagName 属性指定。此外,此模板还可以被另一个模板包裹,如下面的图所示:

指定视图布局

通过添加 yield 表达式,可以将模板标记为布局,如下面的代码所示:

<script type='text/x-handlebars' id='container'>
  <div id='container'>
   {{yield}}
  </div>
</script>

就像 outlet 表达式一样,yield 表达式充当被包裹的模板将被插入的部分。然后我们在视图中指定此布局:

var View = Ember.View.extend({
  tagName: 'section',
  layoutName: 'container',
  templateName: 'book'
});

假设我们的 book 模板如下:

<script type='text/x-handlebars' id='book'>
  <p>Author: Jon Doe</p>
   </script>

这将产生以下结果:

<div id='container'>
 <section>
  <p>Author: Jon Doe</p>
 </section>
   </div>

重要的是要注意,具有自闭合 HTML 元素的视图不能有布局。这些视图包括 <input><img>

在视图中注册事件处理器

视图可以在它们渲染的模板中的元素上注册事件处理器,除了使用 action 模板表达式。例如,让我们重用第四章的一个例子,编写应用程序模板

  <button {{action 'checkout'}}>checkout</button>

此示例可以轻松地重新实现为一个视图,如下面的代码所示:

App.CheckoutButton = Ember.View.extend({
  tagName: 'button',
  click: function(event){
    this.get('controller').send('checkout');
  }
});

{{! template }}
{{#view App.CheckoutButton }}checkout{{/view}}

在此示例中,我们创建了一个自定义按钮视图,注册了一个点击事件处理程序。

每个视图仅管理从其模板调用的事件。然而,子视图通常将事件冒泡到父视图,直到根元素,直到事件被处理。

Ember.js 支持以下事件:

触摸事件 键盘事件 鼠标事件 表单事件 HTML5 拖放事件
touchStart keyDown mouseDown submit dragStart
touchMove keyUp mouseUp change drag
touchEnd keyPress contextMenu focusIn dragEnter
touchCancel click focusOut dragLeave
doubleClick input drop
mouseMove dragEnd
focusIn
focusOut
mouseEnter
mouseLeave

现在是尝试编写使用这些事件之一的视图的好机会。

从视图中发出动作

我们已经了解到,视图通过 controller 属性引用上下文控制器。视图可以使用控制器的 send 方法将用户发起的事件委派给相应的路由,如下面的示例所示:

App.CheckoutButton = Ember.View.extend({
  tagName: 'button',
  click: function(event){
    this.get('controller').send('checkout');
  }
});

使用内置视图(组件)

在 第四章,编写应用程序模板中,我们承诺要讨论 Ember.js 提供的内置视图。其中大部分是高级视图(组件),从控件中保证了表单设计的无痛苦。

文本字段

文本字段视图用于在表单中创建一个绑定文本输入。它通常由 Ember.TextField 类创建。我们可以通过实现视图的 change 事件处理程序来订阅输入值的变化,如下面的代码所示:

App.InputView = Ember.TextField.extend({
  change: function(event){
    console.log(this.get('value')); 
  }
});

就像任何其他视图一样,我们可以将此视图插入到模板中,如下所示:

{{view App.InputView name='name' valueBinding='controller.name'}}

在此示例中,我们创建了一个文本输入框,每当其值发生变化时,都会更新上下文控制器的名称属性。这是此类视图的许多用例之一。

文本区域

文本区域与文本字段非常相似,两者都接受一些额外的属性,例如 rowscols,例如:

{{view Ember.TextArea name='content' valueBinding='content' rows=10 cols=10}}

选择菜单

另一个常见的表单控件是选择菜单。Ember.js 提供了一个 Ember.Select 类,可以用来创建此控件。例如,让我们创建一个选择菜单,提示用户在此控件中选择他们最喜欢的水果:

// controller

App.ApplicationController = Ember.Controller.extend({
  selectedFruit: null,
  fruits: [{
    id: 1,
    name: 'mango'
  }, {
    id: 2,
    name: 'apple'
 }],
}); 

{{! template }}

{{view Ember.Select
  prompt='Select a fruit:'
  contentBinding='fruits'
  selectionBinding='selectedFruit'
  optionLabelPath='content.name'
  optionValuePath='content.id'}}

在前面的示例中,用户被提供了两种水果进行选择。他们首先会看到一个选择一个水果的提示,该提示是在定义时传递的。视图的content属性通常是应显示的选择项的数组,而selection属性则持有选中的选项。通常,这些选择项通常是对象而不是字符串,如前面的示例所示。因此,需要使用两个属性进行额外的自定义:

  • optionLabelPath属性:此属性指定选项的标签

  • optionValuePath属性:此属性指定要查找的选中选项的值

因此,前面的示例指定了水果的名称作为要显示的属性,以及 ID 作为确定选择的属性。

复选框

复选框也可以使用Ember.Checkbox视图类以相同的方式进行实现。这些控件使用户能够从给定集合中选择各种选项,例如:

{{view Ember.Checkbox name='is-complete' valueBinding='isComplete'}}
{{view Ember.Checkbox name='is-done' valueBinding='isDone'}}
{{view Ember.Checkbox name='is-empty' valueBinding='isEmpty'}}

这将产生如下所示的结果:

<input type='checkbox' name='is-complete' checked >
<input type='checkbox' name='is-done' >
<input type='checkbox' name='is-empty' >

此视图实例的绑定值通常是一个布尔值。

容器视图

我们已经了解到,可以使用view模板助手将视图插入到其他视图中,如下面的代码行所示:

{{#view App.ContentView}}
  {{view App.SideView}}
  {{view App.PaneView}}
{{/view}}

在某些情况下,我们可能希望父视图,在这种情况下是App.ContentView,能够手动管理子视图。Ember.ContainerView是一个可枚举的视图,应用程序可以实用地添加或从其中移除子视图,如下面的示例所示:

var sideView = Ember.View.create();
var paneView = Ember.View.create();
var contentView = Ember.ContainerView.create();
contentView.pushObjects([
  sideView, paneView
]);

这些子视图通常包含在childViews属性中。因此,你可以将前面的示例实现如下:

var compile = Em.Handlebars.compile;
var contentView = Ember.ContainerView.create({
  childViews: ['sideView', 'paneView'],
  sideView: Ember.View.create({
    template: compile('Side')
  }),
  paneView = Ember.View.create({
    template: compile('Pane')
  })
});

这会产生如下所示的结果:

<div>
  <div>Side</div>
  <div>Pane</div>
</div>

需要注意的是,由于容器视图包含其他视图,它们不能有模板或布局。因此,指定的模板或布局将被忽略。

其他 HTML 表单控件可以抽象出来以实现更简单的视图。因此,作为一个练习,创建一个Ember.Radios视图类,该类显示一组 HTML 单选按钮。请注意,实现将与Ember.Select非常相似。

与第三方 DOM 操作库集成

许多 jQuery 库主要操纵 DOM 以实现所需的效果。我们都知道,只有在 DOM 准备好时才需要初始化这些库:

$(docoment).ready(function(event){
  // initialize library
  $('#menu').dropdown();
});

jQuery 是 Ember.js 的依赖项,因此将此类库集成到应用程序中非常容易。想象一下,我们有一个想要应用到这个插件上的菜单视图。视图有willInsertElementdidInsertElement钩子,我们可以使用这些钩子来实现这样的需求,如下面的代码所示:

// view
App.MenuView = Ember.View.extend({
  didInsertElement: function(){
    this._super();
    Ember.run.schedule('afterRender', this, function() {
      this.$().dropdown();
    });
  }
});

{{! template}}
{{view App.MenuView}}

didInsertElement 钩子确保视图已插入到 DOM 中,因此,我们可以将其应用于任何插件。请注意,调用 this.$() 返回相对于视图的 jQuery 元素选择器。另外,请注意我们确保调用 _super 方法,因为可能存在我们无法失去的父实现。我们还将此代码安排在元素渲染到 DOM 之后运行。

在稍后的某个时间点,我们可能会决定从 DOM 中移除视图。因此,在移除视图之前,我们需要移除插件设置的所有事件。Ember.js 提供了 willDestroy 钩子,可用于完成此操作:

App.MenuView = Ember.View.extend({
  willDestroy: function(){
    this._super();
    this.$().tearDownDropdown();
  }
});

摘要

在本章中,我们讨论了视图的定义和创建方式,以及如何自定义它们。我们还学习了如何管理这些视图中的事件。最后,我们探讨了 Ember.js 提供的不同组件以及如何通过视图将第三方库(如 jQuery 插件)集成到应用程序中。

本章的结尾标志着 Ember.js 核心概念的完成。在接下来的章节中,我们将开始构建完整的示例应用程序,同时探索更多功能。因此,你应该熟悉以下 Ember.js 概念和对象:

  • 对象

  • 路由

  • 模板

  • 控制器

  • 视图

第七章. 组件

到本章为止,我们学习了 Ember.js 的基本概念,这些概念为我们提供了创建完整应用程序所需的工具。从本章开始,我们将通过探索更多高级 Ember.js 功能,引导您创建各种复杂的应用程序。本章将介绍 Ember.js 组件,这些组件使我们能够创建自定义的可重用元素,并将涵盖以下相关主题:

  • 理解组件

  • 定义组件

  • 自定义组件

  • 将组件用作模板布局

  • 在组件内部定义动作

  • 将组件与应用程序的其他部分接口

理解组件

网络组件是一个可重用的自定义 HTML 标签。万维网联盟已经在制定自定义网络元素(网络组件)规范(www.w3.org/TR/components-intro/),这将允许开发者创建具有自定义行为的这些自定义 HTML 元素,而不是始终依赖于提供的标准 HTML 元素。此规范仍在制定中,但在规范完成之前,有一些 JavaScript 开源项目(shims)可以帮助您开始:

Ember.js 提供了机制,将允许开发者在网络技术近未来创建和完成这些组件。一旦网络组件标准化,Ember.js 将继续使创建这些自定义元素变得容易。因此,开始利用 Ember.js 组件 API 是一个额外的优势。

定义组件

组件是 Ember.js 视图的高级结构,因此,要定义一个组件,我们需要定义以下两个 Ember.js 对象之一或两个:

  • 组件的类

  • 组件的模板

该类通常从以下签名中的 Ember.Component 类扩展:

MyAppNamespace.ComponentNameComponent = Ember.Component.extend();

组件模板随后使用 Ember.js 习惯用法进行定义和命名。例如,前面组件的模板将被命名为:

components/component-name

本章捆绑的示例包括一个简单应用程序,该应用程序利用了几个组件。此应用程序允许用户上传和评分照片,如下面的截图所示:

定义组件

应用程序定义了以下组件:

  • 帖子输入组件

  • 帖子日期组件

  • 帖子评分组件

  • 用户帖子组件

  • 帖子照片组件

我们已经注意到,这些组件中的一些是由类或模板定义的。例如,user-post组件没有定义类。此外,模板名称使用连字符进行命名空间,而类名称使用驼峰式。因此,将user-post组件模板注册为userpost是不正确的。这个规则对应于 W3C 描述的以下组件属性之一:

  • 组件自定义元素必须通过一个连字符进行命名空间。

  • 组件是沙箱化的,但可以通过事件进行通信。因此,组件和宿主 DOM JavaScript 不能相互操作。

因此,例如,post-input组件类和模板被定义为:

// class
App.PostInputComponent = Ember.Component.extend({
});

{{! template}}
<script type="text/x-handlebars" id="components/post-input">
add
</script>

一旦定义,一个组件就可以使用 Handlebars 表达式包含到任何应用模板中。例如,第一个组件是一个按钮,用于提示用户从磁盘中选择一个图像:

<script type="text/x-handlebars" id="components/post-input">
add
</script>

现在,我们的路由器定义了处理主页路径请求的photos路由:

App.Router.map(function() {
  this.resource('photos', {path: '/'})
});

因此,我们只需要将组件包含在相应的photos模板中,如下所示:

<script type="text/x-handlebars" id="photos">

  ...
  {{post-input posts=model}}

  ...
</script>

这将导致components模板被交换,从而导致:

<script type="text/x-handlebars" id="photos">
  ...
  <button>add</button>
  ...
</script>

不要担心生成的元素是一个按钮。重要的是要注意,我们只是定义并使用了一个自定义 HTML 元素,而不必担心其底层实现。

区分组件和视图

在我们继续之前,你可能想知道为什么组件和视图不同,因为两者都封装了模板。嗯,组件确实是视图的一个子类,但它们的控制器上下文与应用程序的其他部分是隔离的。虽然应用程序控制器可以被分配给任何视图,但定义的组件类不能分配给其他组件或视图。组件定义了一个接口,所需的上下文必须实现,因此它们更可重用和模块化,我们将在下一节中看到。

将属性传递给组件

尽管我们刚刚提到组件与应用程序的其他部分是隔离的,但它们可以通过几种方式与宿主应用程序进行通信。首先,它们能够绑定到宿主模板上下文中的属性。例如,我们刚刚提到,前面的按钮组件用于提示用户上传图像。该组件要求一个可枚举属性,该属性将作为照片存储库绑定到其posts属性:

{{post-input posts=model}}

这样,组件将能够存储提供的照片,我们将在后面的章节中讨论。然后,这些选定的照片将在同一模板中显示给用户:

<script type="text/x-handlebars" id="photos">
...
<div class='posts'>
  <ul>
    {{#each model}}
      <li>{{user-post post=this}}</li>
    {{/each}}
  </ul>
</div>
...
</script>

在这里,我们使用了另一个组件,user-post,它将给定的照片渲染到包含页面的部分。同样,我们不需要担心组件的底层实现。我们只需要满足其接口对绑定照片到其post属性的要求。

要了解这些组件如何使用绑定的属性,让我们考虑 post-date 组件,该组件被刚才讨论的 user-post 组件用来显示帖子的日期的人性化格式。该组件包含一个表达式,显示格式化的日期为:

<script type="text/x-handlebars" id="components/post-date">
  {{formatedDate}}
</script>

该表达式是一个计算属性,它使用 Moment.js 库(momentjs.com)来格式化日期,并在相应的类中定义为:

App.PostDateComponent = Ember.Component.extend({
  formatedDate: function(){
    return moment(this.get('date')).fromNow();
  }.property('date')
});

然后在 user-post 组件中将依赖的日期属性绑定:

{{post-date date=post.date}}

定制组件的元素标签

由于 W3C 组件规范仍在开发中,Ember.js 组件利用现有的标准 HTML 元素。在 第三章,路由和状态管理 中,我们了解到视图的模板被包裹在一个元素中,默认情况下是 div。然后可以使用视图的 tagName 属性来定制该元素。组件的模板也以相同的方式包裹在可定制的元素中。例如,我们承诺要讨论之前提到的 post-input 组件是如何渲染到 DOM 中的。

我们需要做的只是在该相应的类中定义属性:

App.PostInputComponent = Ember.Component.extend({
  ...
  tagName: 'button',
  ...
});

定制组件的元素类

由于组件是视图,它们的元素类可以使用组件类上的 classNamesclassNameBindings 数组属性静态或动态指定。例如,post-input 组件定义了一个静态类:

App.PostInputComponent = Ember.Component.extend({
  classNames: ['post-input'],
});

这导致组件被渲染为:

<button class='post-input'>add</button>

在示例应用程序中,我们提到用户可以对上传的图片进行评分。user-post 组件使用 post-rating 组件作为评分小部件:

{{post-rating content=post}}

后一部分中的每个星号也是一个组件(post-rating-item),它们水平排列以组成小部件:

<script type="text/x-handlebars" id="components/post-rating">
  <ul>
    {{#each rating in ratings}}
      <li>{{post-rating-item controller=post content=rating}}</li>
    {{/each}}
  </ul>
</script>

如预期的那样,彩色星号代表评分范围,因此在这种情况下,我们使用 active 类来设置它们的样式:

App.PostRatingItemComponent = Ember.Component.extend({
  classNameBindings: ['active'],
  active: function() {
   ...
  }.property('parentView.selected'),
});

这是一个动态类的例子,其中组件只有在定义的计算 active 类评估为 True 时才会获取该类。我们将在稍后的部分讨论这个评分是如何工作的,但最后要注意的一点是,在动态类的情况下,我们可以指定要使用的类名。例如,我们可以将前面的情况实现为:

App.PostRatingItemComponent = Ember.Component.extend({
  classNameBindings: ['isActive:active'],
  isActive: function() {
   ...
  }.property('parentView.selected'),
});

我们也可以将其实现为:

App.PostRatingItemComponent = Ember.Component.extend({
  classNameBindings: ['isActive:active:not-active'],
  isActive: function() {
   ...
  }.property('parentView.selected'),
});
not-active class will be acquired by nonactive stars.

定制组件的元素属性

组件的元素属性值也可以通过使用 attributeBindings 属性绑定到属性上。例如,考虑我们的 post-photo 组件,它以以下方式显示图片:

App.PostPhotoComponent = Ember.Component.extend({
  tagName: 'img',
  classNames: ['avatar'], 
  attributeBindings: ['src'],
  src: Ember.computed.oneWay('photo')
});

首先,我们使用 tagName 属性指定其元素是一个图像标签。我们还指定该元素将有一个 src 属性,该属性将被别名到绑定的 photo 属性。然后 user-post 组件使用此组件来显示图片:

{{post-photo photo=post.photo}}

一定要比较以下元素概念如何在视图、组件甚至模板中定制:

  • 标签名

  • 类属性

  • 属性

在组件中管理事件

就像视图一样,组件可以捕获用户生成的事件,例如来自键盘、鼠标和触摸设备的事件。

定义这些事件处理器的两种方式,第一种是将 .on 函数附加到事件订阅方法上。例如,post-input 组件使用此函数定义了两个处理器。此按钮组件实现了一个可以从不可见的文件输入中打开的文件选择对话框,具体描述见github.com/component/file-picker。组件一旦渲染,就会触发事件,导致包含单个输入文件的隐藏表单元素被附加到 DOM 中,如下所示:

createHiddenForm: function(){
  var tmpl = [
    '<form class="post-input-form">',
    '<input type="file" style="top: -1000px; position: absolute" aria-hidden="true">',
    '</form>'
  ].join('');

  Em.$('body').append(Em.$(tmpl));
}.on('didInsertElement'),

此表单将用于稍后上传图像。接下来,我们定义将启动文件对话框的处理器。请注意,我们使用 .on 方法订阅按钮的点击事件:

upload: function(){
  ...
}.on('click')

在此处理器内部,我们设置了一个监听器,当用户选择一个图像文件时会被调用,如下所示:

var input = Em.$('.post-input-form input');
input.one('change', upload);

这里是执行上传的处理器:

function upload(event){

  var file = input[0].files[0];
  var reader = new FileReader;
  reader.onload = post.bind(this, reader);
  reader.readAsDataURL(file);

};
FileReader instance and pass the uploaded image to it. We then read the dataUrl representation of the image, which then gets sent to the final bound handler:
    function post(reader){

      var data = {
        photo: reader.result,
        date: new Date
      }
      self.get('posts').pushObject(data);

    }

最后一个处理器将图像添加到照片控制器作为一个新的帖子。请注意,我们没有检查上传的 MIME 类型,因此用户可能会上传其他媒体类型,例如视频。这个检查被留给了读者作为实现练习。

其次,我们订阅这些事件以实现一个名称与目标事件相对应的方法,正如之前讨论的评分小部件组件所示。此组件记录以下两个属性:

  • selected: 这是选中/悬停的星星位置

  • _selected: 这是最后点击的星星的缓存位置

我们提到,小部件由代表每个星星的 post-rating-item 组件组成。当用户悬停在任何一个上时,我们更新父组件的 selected 属性,如下所示:

  mouseEnter: function(e) {
    var selected = this.get('content');
    this.set('parentView.selected', selected);
  }

如所示,我们定义了一个与 mouseEnter 事件相对应的方法。此处理器将所有左侧的评分项组件的 active 属性设置为 True,因为这里的技巧是按照预期将样式应用到当前选中星星左侧的所有星星上:

  classNameBindings: ['active'],

  active: (function() {
    var content = this.get('content');
    var selected = this.get('parentView.selected');
    return ~~content <= ~~selected;
  }).property('parentView.selected'),

另一方面,右侧的星星失去 active 类,因为它们的 active 属性被重新计算为 False

如果用户没有点击任何星星,他们期望恢复之前的评分。因此,离开当前聚焦的组件会使用缓存的 _selected 属性来重置 selected 属性,如下面的代码所示:

  mouseLeave: function(e) {
    var selected = this.get('parentView._selected');
    this.set('parentView.selected', selected);
  }

再次,我们只需要实现 mouseLeave 事件钩子。最后,点击任何组件都会给出实际的评分:

  click: function(e){
    var content = this.get('content');
    this.set('parentView.selected', content);
    this.set('parentView._selected', content);
  }

注意,我们缓存了父组件的_selected属性,因为这个属性将在前面的检查中使用。active类根据以下方式适当地更新组件的状态:

.rating:before{
  content: "☆"
}

.rating.active:before{
  content:"★"
}

定义组件动作

我们提到组件定义的类充当它们的控制器,这些控制器与应用程序的其余部分隔离。例如,应用程序控制器不能将其组件类定义为needs属性中的依赖项。然而,由于它们被视为控制器,它们可以在actions对象属性中定义处理程序,以处理它们相应模板中定义的动作表达式。例如,让我们定义一个消息框组件,该组件可用于任何需要实现聊天功能的应用程序:

{{! template }}]
<form {{action 'save' on='submit'}}>

  {{input value=message}}
</form>

// component classApp.MessageBoxComponent = Ember.Component.extend({
  message: '',
  classNames: ['message-box'],
  actions: {
    save: function(){
      var message = this.get('message').trim();
      if (message === '') return;
      var content = this.get('content');
      content.pushObject(message);
      this.set('message', '');
    }
  }
});

要使用此组件,只需提供一个Messages容器,其中将存储新消息。以下是一个可能的示例:

<script type="text/x-handlebars" id="messages">
  <h1>Messages</h1>
  <ul>
    {{#each model}}
      <li>{{this}}</li>
   {{/each}}
  </ul>
  <div>{{message-box content=model}}</div>
</script>

组件形式定义了一个动作,该动作将组件类的save动作处理程序绑定到表单的submit事件。当用户通过按下Enter键提交表单时,处理程序会在将其推送到提供的容器之前对消息进行清理。你会发现这些动作与我们之前在第四章中学习的动作类似,即编写应用模板。然而,组件中没有事件冒泡。如果在类中找不到处理程序,将抛出适当的错误。

将组件与应用程序的其余部分接口

如前所述,组件并非完全沙箱化,但它们可以通过以下方式与应用程序的其余部分进行交互:

  • 绑定到属性

  • 发送动作

我们已经看到组件如何通过在模板表达式中传递属性来绑定到其他应用程序属性:

  {{post-input posts=model}}

组件还具有将它们的行为发送到应用程序中的控制器的能力。为了演示这一点,让我们为电子商务网站创建一个简单的结账按钮:

{{! template}}
<script type="text/x-handlebars" id="components/checkout-button">
add to cart
</script>

// add to cart component
App.CheckoutButtonComponent = Ember.Component.extend({
  tagName: 'button',
  click: function(){
    this.sendAction();
  }
}); 

{{! cart template}}
<ul>
{{#each products}}
<li>
  {{name}}
  {{price}}
  {{checkout-button}}
</li>
</ul>

// cart controller
App.CartController = Ember.ArrayController.extend({
  actions: {
    click: function(product){
      this.pushObjects(product);
    }
  }
});

在前面的示例中,我们的意图是在点击相应的结账按钮时通过事件处理程序将产品添加到购物车。我们利用组件的sendAction方法将此动作冒泡到父控制器。然而,为了实现这一点,我们需要修复两件事。首先,我们需要将控制器中的事件处理程序重命名为更具描述性的名称。此外,相同的click事件处理程序可以捕获来自其他元素的事件:

// cart controller
App.CartController = Ember.ArrayController.extend({
  actions: {
    addToCart: function(product){
      this.pushObjects(product);
    }
  }
});

接下来,我们需要通过修改模板将选定的产品发送到addToCart处理程序:

{{! cart template}}
<ul>
{{#each products}}
<li>
  {{name}}
  {{price}}
  {{checkout-button product=this action='addToCart'}}
</li>
</ul>

这只是让组件能够访问产品。最后,我们将产品发送到控制器事件处理程序:

// add to cart component
App.CheckoutButtonComponent = Ember.Component.extend({
  tagName: 'button',
  template: Ember.Handlebars.compile('add to cart'),
  click: function(){
    this.sendAction('action', this.get('product'));
  }
});

注意,sendAction的第一个参数始终是action,后面跟着我们希望发送的对象(s)。

组件作为布局

组件的模板可以作为其他应用程序模板的布局。这些布局在视图层中未指定;它们使用块表达式。然后可以在这些模板内部插入附加内容,而不会丢失作用域。例如,想象一下我们希望创建一个将使用 content-editable 元素的组件。这类组件需要将某个 HTML 内容的部分包装为:

{{#content-editable}}
<p>Tweet content</p>
{{/content-editable}}

如所示,该组件使用与命名空间模板名称匹配的自定义 Handlebars 标签。组件内部和外部的内容仍将享受相同的范围。你能猜出这个组件将如何实现吗?一种实现方式是将包装内容在双击或聚焦时转换为 content-editable,当鼠标离开元素时恢复为 div,如下所示:

App.ContentEditableComponent = Ember.Component.extend({
  attributeBindings: ['isEditing:contenteditable'],
  doubleClick: function(){
    this.set('isEditing', true);
  }
  focusOut: function(){
    this.set('isEditing', false);
  }
});

两个事件定义的处理程序切换 isEditing 属性,这随后导致 content-editable 属性相应地被添加或从元素中移除。

为了让事情变得更有趣,想象一下我们想要将我们的 content-editable 组件升级为一个所见即所得(WYSIWYG)编辑器。我们需要定义一个模板,用于托管用于操作内容的各种控件,如下面的示例所示:

<script type="text/x-handlebars" id="components/content-editable">
  <div class="controls-toolbar">
    <button>bold</button>
    <button>italic</button>
    <button>underline</button>
    <button>strike-through</button>
  </div>
  <div class="content">
    {{yield}}
  </div>
</script>

首先,我们定义一个工具栏,用于存放标准编辑器控件。我们可以绑定之前章节中讨论的动作处理程序来执行操作;这将是一个值得读者尝试的练习。在内容部分,我们使用之前章节中讨论的 yield 表达式来告诉组件在这个模板的部分渲染包装内容。有了这个强大的功能,组件和包装内容都可以定义绑定到隔离上下文的表达式。

摘要

当你想模块化你的应用程序时,你会发现组件非常有用。有许多开源工具可以使你能够将这些组件发送到你的应用程序中。在本书的最后一章中,我们将学习如何使用组件的 (github.com/component) 资产管理器和构建将这些组件和混入(mixins)发送到你的应用程序中。因此,将你的应用程序中的模块化对象抽象为混入或组件是最佳实践。以下是我们关于组件学到的一些内容:

  • 定义组件

  • 自定义组件元素和属性

  • 在组件内管理动作

  • 将组件与应用程序的其他部分接口连接

在下一章中,我们将学习如何同步 Ember.js 应用程序和 REST 后端之间的数据。我们将特别学习如何使用 Ember.js 数据来简化这一需求。

第八章. 通过 REST 实现数据持久化

到目前为止,我们一直在处理由 Ember.js 驱动的应用程序的前端方面。然而,您的典型应用程序将需要连接到后端服务,例如数据库。Ember.js 通过集成满足此类需求解决方案使其变得简单。本章假设您对服务器端技术没有了解,但它将尽可能清晰地解释包含服务器端代码的任何示例。确保您也尝试完成给定的练习,以便理解以下内容:

  • 发送 Ajax 请求

  • 理解 Ember-data

  • 创建数据存储

  • 定义模型

  • 声明模型关系

  • 创建记录

  • 更新记录

  • 删除记录

  • 持久化数据

  • 查找记录

  • 定义存储适配器

  • 创建 REST API

  • 自定义存储序列化器

发送 Ajax 请求

大多数网络应用程序通过以下两种技术之一与后端服务进行通信:

  • Web sockets

  • Ajax

本章将主要处理 Ajax,它通过使用 XMLHttpRequests 允许客户端应用程序向远程服务发送异步请求。Web sockets 将在后面的章节中处理,但我们会发现许多概念是相关的。以下是一个向音乐目录端点发送 POST 请求的示例:

  var data = JSON.stringify({
    album: 'Folie A Deux',
    artiste: 'Fall Out Boy'
  });

  function onreadystatechange(event){
    if (event.target.readyState != 4) return;
    console.log('POST /albums %s', event.target.status);
  }

  var xhr = new XMLHttpRequest();
  xhr.onreadystatechange = onreadystatechange;
  xhr.open('POST', '/albums');
  xhr.setRequestHeader('Content-type', 'application/json');
  xhr.send(data);

这显然是样板代码,jQuery 使其变得非常简单:

$
  .post('/albums', data)
  .then(function(albut){
    console.log('POST /albums 200');
  });

我们有无数种方法可以将这些集成到 Ember.js 应用程序中。例如,如果这是在表单提交后启动的,我们可以在 save 动作中实现它,如下所示:

<script type='text/x-handlebars'>{{outlet}}</script>
<script type='text/x-handlebars' id='index'>

  {{#with model}}
  <form {{action 'saveAlbum' this on='submit'}}>
      {{input value=artiste}}
      {{input value=album}}
      {{input value='save' type='submit'}}
  </form>
  {{/with}}

</script>

<script>

  App = Ember.Application.create();

  App.Router.map(function(){});

  App.IndexRoute = Ember.Route.extend({
    model: function(){
      return {};
    },
    actions: {
      saveAlbum: function(album){
        var data =  {
          album: album.album,
          artiste: album.artiste
        };
      $
        .post('/albums', data)
        .then(function(album){
           console.log('POST /albums 200');
         }, function(response){
           alert('failed!');
         });
      }
    }
  });
</script>

当用户提交提供的表单时,索引控制器的 saveAlbum 动作会被调用,使用 jQuery 将专辑发布到服务器。理想情况下,我们可以创建一个 album 类来分离关注点,如下所示:

App.Album = Ember.Object.extend({
  toJSON: function(){
    return {
      album: this.get('album'),
      artiste: this.get('artiste')
    };
  },
  save: function(){ // function to persist album to the server
    $
      .post('/albums', this.toJSON())
      .then(function(event){
        console.log('POST /albums 200');
      }, function(event){
        alert('failed!');
      });
  }
});

通过这个课程,我们就可以进行最后的重构,如下所示:

App.IndexRoute = Ember.Route.extend({
  model: function(){
    return App.Album.create();
  },
  actions: {
    saveAlbum: function(album){
      album.save();
    }
  }
});

此外,我们可能希望从服务器加载已保存的专辑以向用户展示。一种简单的方法是实现一个 find 类方法来加载这些专辑,如下所示:

Album.reopenClass({
  find: function(){
    return $.getJSON('/albums');
  }
});

此示例向 Album 类添加了一个静态类方法,然后可以使用它来查询后端专辑,如下所示:

App.AlbumsRoute = Ember.Route.extend({
  model: function(){
    return App.Album.find();
  }
});

由于这是一个常见的做法,Ember.js 社区还维护了另一个名为 Ember-data 的项目(github.com/emberjs/data),该项目的目标是抽象化此类需求。因此,本章将带我们了解如何在 RESTful 应用程序中使用 Ember-data。这些应用程序使用 REST表示状态传输),正如我们所知,它允许我们消费使用以下 HTTP 动词的一些 API:

  • GET

  • POST

  • PUT

  • DELETE

理解 Ember-data

Ember-data 是另一个雄心勃勃、有观点的开源项目,用于开发需要与后端数据库服务通信的应用程序。可以从 builds.emberjs.com/ 下载合适的版本。在我们的案例中,我们将使用已包含在章节示例中的 1.0.0-beta.9 版本构建。这些示例定义了一个简单的 Todos 应用程序实现,位于 github.com/component/todo

理解 Ember-data

我们将首先使用 fixtures-adapter 示例,您可以通过 index.html 文件来加载它。此应用程序允许用户执行以下操作:

  • 加载保存的待办事项

  • 创建并保存新的待办事项

  • 通过状态(已完成与未完成)过滤已加载的待办事项

Ember-data 命名空间

Ember-data 库利用其自己的全局命名空间 DS,我们将从其中引用常用类,如 DS.StoreDS.Model

创建数据存储

使用 Ember-data 的应用程序通常使用单个存储库,该存储库存储所有可供应用程序使用的记录。此存储库由 DS.Store 类定义,如下所示:

App.ApplicationStore = DS.Store.extend({
});

上一段代码将由 Ember.js 自动执行,因此我们不需要做任何事情。就像路由器一样,这个类通常会被自动实例化,并作为 store 属性对所有路由和控制器可用。以下是一个演示如何访问应用程序存储的示例:

App.BooksRoute = Ember.Route.extend({
  model: function(){
    return this.store.find('book');
  }
   });

不要担心这会做什么。从前面代码片段中学习的重要事情是如何访问存储实例。

定义模型

在介绍性章节中,我们学习了如何将应用程序对象组织成可重用的类,称为 models。Ember-data 提供了对定义此类模型的支撑,这些模型扩展 DS.Model,然后可以从中创建记录。例如,让我们回顾一下在示例中定义的 Todo 模型:

App.Todo = DS.Model.extend({
  title: DS.attr('string'),
  complete: DS.attr('boolean', {
    defaultValue: false
  })
});

如上图所示,模型是通过扩展 DS.Model 类定义的。然后我们使用 DS.attr 类方法定义了两个属性,该方法接受两个参数:

  • 属性的名称

  • 可选的选项对象

属性的类型通常是以下之一:

  • 字符串

  • 数字

  • 日期

  • 布尔

然而,我们稍后了解到,可以定义其他自定义类型。选项对象通常包含一个 defaultValue 属性,它可以是值或函数,该函数评估为用作默认值的值。

声明关系

我们应用程序中的记录可能相关;因此,Ember-data 支持定义以下常见关系:

  • 一对一

  • 一对多

  • 多对多

一对一

在这种关系类型中,只有一个模型可以属于另一个。例如,我们可以定义两个对象,一个人和一个护照,其中这个人只拥有一本护照:

App.Person = DS.Model.extend({

查找记录

Ember-data 提供了各种方法来查询已加载的记录,以及从后端服务拉取新的记录。要查找特定模型的全部记录,我们可以简单地利用存储的find方法,如下所示:

// GET /todos

store.find('todo');

此方法通过一个我们随后消费的承诺从服务器加载所有待办事项,如下所示:

store
  .find('todo')
  .then(function(todos){
    todos.map(function(todo){
      todo.set('complete', false);
      return todo;
    });
  });

如果我们只想查询已加载的记录,我们可以使用存储的all方法,如下所示:

store.all('todo');

同样,我们可能想通过给定的id查询一个记录,如下所示:

// GET /todos/1

store.find('todo', id);

通过搜索词查询记录同样简单:

// GET /todos?complete=true

store.find('todo', {
  complete: true
});

定义商店的适配器

每个商店都需要一个位于网络层的适配器,在那里它执行实际的 API 请求调用。这就是我们的 Todos 应用程序的两个变体之间的区别,其中每个商店定义了一个与不同远程数据存储进行通信的适配器。例如,第一个示例如下定义其适配器:

App.ApplicationAdapter = DS.FixtureAdapter;

所有适配器都需要实现以下方法:

  • find

  • findAll

  • findQuery

  • createRecord

  • updateRecord

  • deleteRecord

这些适配器使应用程序能够与各种数据存储保持同步,例如:

  • 本地缓存

  • 浏览器的本地存储或 indexdb

  • 通过 REST 的远程数据库

  • 通过 RPC 的远程数据库

  • 通过 WebSockets 的远程数据库

因此,如果应用程序需要使用不同的数据提供者,这些适配器是可以互换的。Ember-data 自带两个内置适配器:fixtures-adapterrest-adapter

fixtures 适配器使用浏览器内的缓存来存储应用程序的记录。此适配器在项目的后端服务因测试不可用或仍在开发时特别有用。使用此适配器时,可能需要添加初始数据,称为 fixtures,以模拟现有记录。这些记录可以通过在受影响的模型的FIXTURES属性中添加它们来加载到应用程序的存储中,如下所示:

App.Todo.FIXTURES = [
  { id: 1, title: 'Bake cake', complete: true },
];

创建 REST API

一旦对模型的工作原理感到满意,我们就可以用rest-adapter替换掉 fixtures 适配器,正如你所猜想的,它通过 REST 与远程数据存储进行通信。第二个示例包括一个简单的 Node.js 服务器(Server.js),它使用 Express.js (expressjs.com) 来演示此适配器的使用。为了测试应用程序,你需要按照以下步骤安装 Node.js:

  1. nodejs.org/download/ 下载你平台的 Node.js 二进制文件。

  2. 解压下载的包。

  3. 将未存档目录中的bin目录的位置添加到你的环境PATH设置中。

  4. 在终端中运行node来测试安装。

要启动应用程序,请导航到rest-adapter示例目录,然后在你的 shell 模拟器中简单地运行以下两个命令:

npm install
node server

然后,在您的浏览器中访问http://localhost:5000。我们注意到,两个应用程序的不同之处在于后者将数据持久化到运行的后端。如果我们添加新的待办事项并访问新标签页,我们会意识到新更改已反映出来。然而,该应用程序不会将更改持久化到真实数据库中,因为这超出了本书的范围。因此,作为一个练习,尝试在您喜欢的服务器端堆栈中重新实现此示例。

Rest-adapter 对我们的待办事项服务器 API 必须遵守的一些假设如下表所示:

动作 请求 HTTP 动词 请求 URL 请求 JSON 有效负载 响应 JSON 数据
Create POST /todos {todo: data} {todo: data} or id
Find all GET /todos None {todo: data}
Find query GET /todos?complete=true None {todo: data}
Find one GET /todos/1 None {todo: data}
Update UPDATE /todos/1 {todo: data} {todo: data} or None
Delete DELETE /todos/1 None None

此实现可以在api.js模块中找到。因此,在创建主要供 Ember-data 应用程序消费的新 API 时使用此格式是明智的。此约定也在jsonapi.org/中进行了记录,这可能是一个对您非常有用的资源。

相关对象也可以以类似的方式加载。例如,在我们的tweet-retweet案例中,我们可以加载特定推文的转发,如下所示:

{ tweet: {
  id: 1,
  title: 'New book out',
  retweets: [{
    id: 1,
    title: 'RT New book out',
    user: 'Jon',
  }, {
    id: 2,
    title: 'RT  New book out',
    user: 'Doe'
  }]
}}

注意,Ember-data 期望响应数据中包含相关对象的属性应命名为相关模型的复数形式:

retweets: DS.hasMany('retweet')

retweet => retweets
person => people

或者,API 可以只发送相关对象的 ID,如下所示:

{ tweet: {
  id: 1,
  title: 'New book out',
  retweets: [1, 2, 3]
}}

Ember-data 将随后将相应的对象侧加载到数据存储中。

有时,一个模型可能具有多个相同模型的关联关系。例如,典型的 Facebook 用户有粉丝和关注者,其模型可以定义为如下:

App.User = DS.Model.extend({
  followers: DS.hasMany('user'),
  followings: DS.hasMany('user')
});

Ember-data 将期望响应数据包含一个名为users的相关对象列表。然而,由于有多个属性依赖于用户模型,我们可以通过使用inverse选项轻松解决这个问题,如下所示:

App.User = DS.Model.extend({
  followers: DS.hasMany('user', { inverse: 'followers'} ),
  followings: DS.hasMany('user', { inverse: 'followings'} )
});

使用这种方式,我们就可以返回如下所示的响应:

{ user: {
  id: 1,
  followers: [],
  following: []
}}

如果应用程序从不同的端点消费 API,我们需要为每个模型定义不同的适配器,如下所示:

App.BookAdapter = DS.RESTAdapter.extend({
  namespace: 'v3/',
  host: 'http://books.example.com'
});

App.PenAdapter = DS.RESTAdapter.extend({
  namespace: 'v3/',
  host: 'http://pens.example.com'
});

如前述代码所示,适配器可以以多种不同的方式自定义,以满足您的 API 和领域逻辑的需求。这确保了现有的 API 仍然可以轻松消费,而不是必须为 Ember-data 应用程序构建单独的 API 端点。

自定义存储序列化器

除了存储的适配器之外,所有存储都包含一个序列化器,该序列化器将应用程序进出数据序列化和反序列化。例如,如果我们的后端数据模型使用除 id 之外的主键,我们可以轻松地做到这一点:

DS.RESTSerializer.reopen({
  primaryKey: 'key'
});

注意,这也可以按模型指定,如下所示:

App.PhoneSerializer = DS.RESTSerializer.extend({
  primaryKey: 'phone_id'
});

创建自定义转换

转换是不同类型的模型属性。应用程序作者不仅限于内置的转换,因此他们可以轻松地定义自己的转换。例如,我们的后端服务可能将布尔值表示为零和一:

1 – true
0 – false

我们可以创建一个转换,在必要时解决这些值:

App.BinaryBoolean = DS.Transform.extend({
  serialize: function(boolean){
    return (!boolean)
     ? 0
     : 1;
  },
  deserialize: function(binary){
    return (!!!binary)
     ? false
     : true;
  }
});

我们通过扩展 DS.Transform 并定义以下两个作用于属性值的方法来创建了一个新的转换:

  • 序列化: 这将属性值转换为服务器可接受的格式

  • 反序列化: 这将服务器加载的值转换为应用程序将使用的格式

然后,我们可以轻松地使用这种新类型,如下所示:

App.Todo = DS.Todo.extend({
  complete: DS.attr('binaryBoolean')
});
var todo = store.createRecord('todo', {
  complete: true
});
todo.save(); // POST /todos {'todo': {complete: 1}}

摘要

在本章中,我们讨论了如何使用 Ember-data 创建需要通过 REST 与后端存储服务通信的应用程序。我们学习了如何从定义的模型中创建记录以及更新和删除它们。我们还学习了为了尽可能多地使用现有 API,我们需要进行的不同定制。因此,我们应该足够舒适地开始编写任何由 REST API 支持的客户端应用程序。随着我们进入其他令人兴奋的章节,我们应该开始思考如何无缝地将 WebSocket、JSONP 和 RPC 集成到 Ember-data 中。

第九章:记录、调试和错误管理

到目前为止,我们已经学习了构建 Ember.js 应用程序的基础知识。在本章中,我们将学习如何调试这些应用程序,不仅为了减少开发时间,还为了使开发更加有趣。因此,我们将涵盖以下主题:

  • 记录

  • 跟踪事件

  • 调试错误

  • 使用 Ember.js 检查器

记录和调试

Ember.js 可以以两种格式下载,分别适用于开发和生产环境。建议在应用程序开发期间使用放大(开发)构建,以便更容易进行调试。有各种方法可以记录和检查应用程序内部创建的对象。我们将详细讨论如何记录和调试这些对象中的每一个。

对象

除了浏览器console对象提供的记录函数之外,Ember.js 还提供了以下Ember.Logger记录工具,这些工具专门用于记录 Ember.js 对象:

  • assert

  • debug

  • error

  • info

  • log

  • warn

Ember.js 绑定可以在它们发生时进行记录。要启用此记录,请在应用程序初始化之前将以下代码添加到程序中:

Ember.LOG_BINDINGS = true;

大多数浏览器允许在应用程序的预定点设置断点。断点使用debugger关键字暂停程序的执行。暂停程序可以帮助解决问题以及跟踪事件。例如,我们可以设置一个断点,以了解一个属性是否按预期计算:

App.Book = Ember.Object.extend({
  summary: function(){
    debugger;
  }.property('title', 'publisher')
});

这将创建一个断点,如下面的截图所示:

对象

按下F8键可以恢复应用程序的执行。可以设置多个断点来跟踪事件的执行。然后可以使用开发者工具右侧的侧边栏来启用、禁用或检查这些点。

路由和路由

当应用程序从一个路由转换到另一个路由时,在出现异常行为的情况下,可能需要跟踪这些事件。启用此行为很简单:

var App = Em.Application.create({
  LOG_TRANSITION: true
});

通过此外传递LOG_TRANSITIONS_INTERNAL选项为true可以启用更详细的记录:

var App = Em.Application.create({
  LOG_TRANSITIONS_INTERNAL: true
});

即使是这样一个简单的应用程序,运行它也会记录以下转换:

Transitioned into 'index'

应用程序控制器包含有关当前应用程序状态的两个有用信息。要获取当前应用程序的路由名称,我们将从应用程序控制器引用,如下所示:

var currentRouteName = this
  .controllerFor("application")
  .get("currentRouteName");
Ember.Logger.log(currentRouteName); // post.like

当前路由的完整路径可以适当查找,如下所示:

var currentPath = this
  .controllerFor("application")
  .get("currentPath");
Ember.Logger.log(currentPath); // user.post.like

任何实例化的路由都可以从应用程序容器中引用,如下所示:

App.__container__.lookup("route:index");

模板

正如我们一次又一次看到的,可以从Ember.TEMPLATES对象中查找模板;例如:

Ember.TEMPLATES['index'];

断点也可以直接从模板中设置!例如,考虑我们有一个如下定义的index模板:

<script type='text/x-handlebars' id='index'>
     {{#link-to 'books'}}books{{/link-to}}
  {{#link-to 'pens'}}pens{{/link-to}}
</script>

我们可能想通过使用 debugger 表达式来检查此模板的渲染:

<script type='text/x-handlebars' id='index'>
     {{#link-to 'books'}}books{{/link-to}}
     {{debugger}}
  {{#link-to 'pens'}}pens{{/link-to}}
   </script>

使用 log 表达式也可以从模板中进行记录:

{{log model}}

这会将路由的模型记录到浏览器的控制台。

控制器

可以通过主应用程序容器全局查找特定的控制器:

App.__container__.lookup("controller:index");

此应用程序容器注册由应用程序实例化的类,这些类反过来可以被引用。请注意,前面的示例仅应用于调试目的。控制器依赖项应代替从路由和控制器中访问其他控制器,如下面的示例所示:

App.ApplicationController = Em.Controller.extend({
  title: 'My app'
});

App.IndexController = Em.Controller.extend({
  needs: [
    'application'
  ],
  actions: {
    save: function(){
      var title =this.get('controllers.application');
      console.log(title);
    }
  }
});

最后,我们可以在应用程序实例化期间传递另一个选项来启用指示控制器生成的日志,如下面的示例所示:

App = Ember.Application.create({
  LOG_ACTIVE_GENERATION: true
});

视图

实例化的视图具有唯一的 ID,因此可以相应地查找,如下所示:

Ember.Logger.log(Ember.View.views['ember1']);

就像路由一样,我们也可以在路由转换时记录视图事件。在需要验证注册的视图类是否被使用的情况下,这可能很有用。此行为可以通过以下方式启用:

var App = Ember.Application.create({
  LOG_VIEW_LOOKUPS: true
});

使用 Ember.js 检查器

Ember.js 应用程序可以通过适用于 Chrome、Opera 和 Firefox 的浏览器扩展进行检查。此扩展允许您从开发者工具中创建的 Ember.js 标签页检查您的应用程序中的对象。要在 Chrome 中开始使用,您需要执行以下操作:

  1. 访问 chrome://flags 并确保 实验性扩展 API 已启用。

  2. chrome.google.com/webstore/detail/ember-inspector/bmdblncegkenkacieihfhpjfppoconhi 安装扩展。

  3. 重新启动 Chrome。

  4. 打开您的 Ember.js 应用程序并按 Ctrl + U 键以启动开发者工具。

应该在 控制台 标签旁边创建一个 Ember 标签,如下面的截图所示:

使用 Ember.js 检查器

从侧边栏中,点击 视图树 可以获得有关应用程序当前状态的详细信息,如下面的截图所示:

使用 Ember.js 检查器

下一张标签页显示了应用程序中注册的所有路由、视图、控制器和模板。以下是从上一章中使用的 Todos 应用程序中捕获的截图:

使用 Ember.js 检查器

如果应用程序使用 Ember.js 数据,数据 标签将显示所有加载的模型:

使用 Ember.js 检查器

客户端跟踪

在开发 Ember.js 或任何其他 MVC 应用程序时,追踪应用程序中发生的事件可能是一个明智的选择。追踪事件的好处是,当以图形形式呈现时,可以产生有意义的统计数据。可以通过记录进行中的事件预定的点的戳记来实现一个简单的追踪器。例如,让我们创建一个追踪从服务器加载模型进度的应用程序:

App.ApplicationRoute = Ember.Route.extend({
  trace: function(event){
    var timestamp = Date.now();
    var data = {
      timestamp: timestamp,
      event: event
    };
    Ember.$.ajax('/logs', {
      type: 'POST',
      data: data
    });
  },
  model: function(){
    this.trace('load-application-model');
    return this.getJSON('/books');
  },
  setupController: function(controller, model){
   this._super(controller, model);
   this.trace('load-application-model');
  }
});

这将产生类似于以下代码的日志:

{
  timestamp: 1396376883120,
    event: 'load-application-model'
}
{
  timestamp: 1396376883130,
    event: 'load-application-model'
}

绘制这些数据可以帮助我们深入了解我们应用程序的性能。

错误管理

除了将日志保存回服务器外,我们还可以通过以下签名POST应用程序中可能发生的任何错误:

Ember.onerror = function(error) {
  var data = {
    stack: error.stack,
    event: 'error'
  };
  Ember.$.ajax('/logs', {
    type: 'POST',
    data: data
  });
};

摘要

我们刚刚学习了如何在 Ember.js 应用程序中记录事件以及调试瓶颈。通过在客户端应用程序中正确记录和跟踪事件,可以节省大量的开发时间。在下一章中,我们将学习如何为我们的应用程序编写和运行测试。

第十章. 测试您的应用程序

测试是在任何软件项目中进行的重要活动。测试自动化了错误检查,并确保新功能不仅按预期工作,而且不会引入不希望的行为。因此,雄心勃勃的 Ember.js 项目需要经过良好的测试,以确保其稳定性和确保满意的用户体验。因此,在本章结束时,我们应该能够:

  • 测试对象计算属性

  • 测试对象观察者

  • 测试控制器

  • 测试视图

  • 测试组件

  • 测试用户旅程

编写测试

Ember.js 支持编写以下两种常见的测试类型:

  • 单元

  • 集成

单元测试测试应用程序中定义的类(或实例)的特定属性。例如,考虑以下场景:

  • 创建的用户对象有一个名称

  • 从用户的首名和姓氏正确计算出用户的完整姓名

  • 在保存到服务器之前,图书模型得到了正确的验证

  • 观察者正确地响应了变化

集成测试另一方面,测试用户旅程和重要的应用程序工作流程;例如:

  • 只有经过身份验证的用户才能访问应用程序

  • 提交表单将表单数据持久化到存储中,并将用户重定向回列表页

  • 点击结账按钮将产品添加到购物车

在本章中,我们将测试一个典型的电子商务网站的简单实现,您可以通过位于章节示例中的index.html文件来加载它。

编写测试

前一截图中的网站具有以下功能:

  • 用户可以订购要送至指定地点的餐点

  • 管理员可以登录网站添加新餐点

  • 管理员可以查看订单

要测试管理员界面,请使用用户名admin和密码pass登录。该应用程序已通过以下库进行测试:

在浏览器中加载test.html将运行位于test.integration.jstest.unit.js中的两种测试类型,如下截图所示:

编写测试

如果我们检查测试加载器文件的内容,我们会看到测试框架需要创建以下元素:

<div id='mocha'></div> 

这是测试报告渲染的元素。我们还需要以相同的方式创建我们应用程序的根元素:

<div id='ember'></div> 

在文件底部附近,按照以下顺序加载了应用程序脚本:

<script src="img/jquery.js"></script> 
<script src="img/bootstrap.min.js"></script> 
<script src="img/handlebars.js"></script> 
<script src="img/ember.js"></script> 
<script src="img/ember-data.js"></script> 
<script src="img/app.js"></script> 
<script src="img/fixtures.js"></script> 

最后,加载了测试库:

<script src='lib/mocha.js'></script> 
<script src='lib/ember-mocha-adapter.js'></script> 
<script src='lib/chai.js'></script> 
<script src='lib/sinon.js'></script> 

Ember.js 自带了测试工具,有助于编写这些测试。这些测试助手旨在与任何你选择的测试库一起使用。在我们的例子中,我们使用了 Mocha.js,这是一个流行且易于使用的库。我们首先需要完成的任务是设置测试环境。这是通过首先定义 Ember.js 应用程序的根元素来完成的。这确保了 Ember.js 应用程序只在该元素内执行,不会影响测试环境的其他部分:

App.rootElement = '#ember';

然后我们需要运行应用程序的setupForTesting方法。这延迟了应用程序的可用性,以便在测试期间稍后执行。它还防止了测试操作窗口的位置:

App.setupForTesting();

我们还需要调用另一个方法,injectTestHelpers,它将 Ember.js 测试助手注入到测试环境中:

App.injectTestHelpers();

我们最终加载并执行了两个文件中包含的测试,如下所示:

Em.$(function() { 
  mocha.run(); 
});

<script src='test.unit.js'></script> 
<script src='test.integration.js'></script>

你会注意到,对于每个测试,我们都定义了beforeEachafterEach钩子,分别用于在测试执行前后被调用:

 beforeEach(App.beforeEach); 
 afterEach(App.afterEach);

例如,默认的预测试运行钩子使用了visit助手将应用程序过渡到index路由,如下所示:

App.beforeEach = function() { 
  visit('/'); 
}

另一方面,测试后的钩子通过销毁如 Ember-data 存储这样的实例来在每个测试后重置应用程序状态:

App.afterEach = function() {
  App.reset();
}

Ember.js 自带了许多测试助手,我们将使用它们来帮助我们编写测试。

异步测试助手

这些助手用于执行异步操作。这意味着如果使用它们,下一组测试需要被包裹在一个运行循环函数中。它们包括以下内容:

  • visit(url): 这个方法执行异步应用程序转换到提供的 URL 路由。

  • fillIn(selector, text): 这个方法用于异步设置匹配给定选择器的输入的值,并使用给定的文本。

  • click(selector): 这个方法用于在匹配给定选择器的元素上触发点击事件。这对于triggerEvent(selector, "click")助手很有用。

  • keyEvent(selector, type, keyCode): 这个方法用于在给定的选择器上触发一个按键事件。

  • triggerEvent(selector, type, options): 这个方法用于在给定的选择器上触发其他 DOM 事件。

同步测试助手

这些助手用于执行同步操作。它们包括以下内容:

  • find(selector, context): 这个助手用于在给定的可选上下文中执行元素选择。

  • currentPath(): 这个方法用于获取当前应用程序的路由路径

  • currentRouteName(): 这个方法用于获取当前应用程序路由的名称

  • currentURL(): 这个方法用于获取当前路由的 URL

等待助手

目前只有一种此类助手:andThen。它在之前的异步操作完成后运行一系列测试操作。

编写单元测试

单元测试涉及测试对象计算属性、观察者和方法调用。

测试计算属性

让我们从本章示例中的第一个测试对象-计算属性开始,即App.CartItemtotal属性,它是由App.TotalMixin应用而来的:

App.CartItem = Em.Object.extend(App.TotalMixin, { 
  product: null, 
  quantity: null, 
  price: null 
});

当用户点击餐点的订单按钮时,我们期望购物车被新项目填充。我们还期望项目的total属性会增加,这如下所示:

describe('App.CartItem', function() {

  describe('total', function(){

    beforeEach(App.beforeEach);

    afterEach(App.afterEach);

    it('should be computed from .price and .quantity', function() {

      andThen(function(){
        var order = App.CartItem.create();
        order.get('total').should.equal(0);
        order.set('price', 10);
        order.get('total').should.equal(0);
        order.set('quantity', 1);
        order.get('total').should.equal(10);
        order.set('quantity', 2);
        order.get('total').should.equal(20);
        order.set('price', 20);
        order.get('total').should.equal(40);

      });

    });

  });

});

首先,我们创建了一个订单并验证其总额默认为零。然后我们更新了其数量和价格,并确认在每种情况下总额都被正确计算。我们也对App.Order模型做了同样的操作,这次是从商店创建订单:

var store = App.__container__.lookup('store:main');
var order = store.createRecord('order', {});

注意如何从主应用程序容器中引用存储实例。

测试方法调用

应用程序包含一个混合App.OnhasMixin,它定义了一个onhas函数,如果正在绑定的属性已定义或一旦被设置,就会调用给定的回调。这相当于一种情况,即如果您有足够的钱,您现在可以购买一台新电脑,或者等到您收到工资支票。我们首先测试了第一种情况:

var object = Em.Object.createWithMixins(App.OnhasMixin, {
  id: 1
});
object.onhas('id', done);

在这种情况下,函数在对象已经包含id属性时被触发。我们只需要传递由 Mocha 测试运行器提供的回调done。第二个测试用例断言只有当绑定属性按如下方式设置时,回调才会被调用:

var object = Em.Object.createWithMixins(App.OnhasMixin, {
  id: null
});
object.onhas('id', andThen.bind(null, done.bind(null, null)));
object.set('id', 1);

测试观察者

其中一个观察者App.UserController#storeUser将当前登录用户的用户名存储到本地存储中:

storeUser: function(){
  var username = this.get('content.username');
  window.localStorage.setItem('user', username);
}.observes('content.username')

为了测试观察者,我们在预测试钩子中首先清除了本地存储中存储的任何用户:

window.localStorage.setItem('user', null);

然后,我们创建了用户控制器,设置了用户,并断言用户实际上被存储在浏览器的本地存储中:

var controller = App.UserController.create({
  content: {}
});

// spy
var spy = sinon.spy(controller, 'storeUser');

controller.set('username', 'username-1');  // 1
window.localStorage.getItem('user').should.equal('username-1');

我们还使用Sinon.js设置了一个间谍来记录观察者的调用:

spy.callCount.should.to.equal(2);
spy.restore();

如前代码所示,间谍(spy)使我们能够验证观察者是否正确订阅了属性变化。

编写集成测试

我们现在已经知道集成测试测试应用程序中的导入工作流程和用户旅程。Ember.js 框架经过了良好的测试;审查这些测试对于学习如何为您的应用程序编写测试非常有帮助。许多功能,如绑定和观察者,已经过测试,因此您将编写比单元测试更多的集成测试。本章示例中的集成测试涵盖了应用程序中几乎所有的用户交互。我们将首先遍历所有与消费者相关的案例,首先是确认用户可以在第一页看到可用的餐点:

find('.products li').length.should.equal(4);

此示例展示了同步find辅助函数的使用,它返回了列出餐点的元素。我们还检查了餐点属性是否被正确地显示给用户:

// name label

find('.products li:nth-of-type(1) .product-name')
  .text()
  .trim()
  .should
  .equal(product.get('name'));

// price label

find('.products li:nth-of-type(1) .product-price')
  .text()
  .trim()
  .should
  .equal('$'+product.get('price'));

// order button

find('.products li:nth-of-type(1) .product-add-to-cart')
  .text()
  .trim()
  .should
  .equal('order now');

我们接下来的检查验证了导航栏上的购物车链接表明购物车是空的:

find('.nav-cart').text().should.equal('cart 0');

在购物车链接旁边,我们还确认用户能够看到登录链接:

find('.nav-login').text().should.equal('login');

最后,我们检查了客户是否也能看到网站品牌:

find('.brand').text().should.equal('LocalLunch.io');

下一个测试用例集验证了用户是否能够将产品添加到购物车中。在每个测试用例之前,我们在第一顿饭的结账按钮上触发一个点击事件,将饭添加到购物车:

click('.products li:nth-of-type(1) .product-add-to-cart');

第一个测试检查了是否将所需的饭菜实际添加到了购物车:

var store = App.__container__.lookup('store:main');
var controller = App.__container__.lookup('controller:cart');

store
  .find('product', 1)
  .then(function(product){

    controller.get('length').should.equal(1);

    var item = controller.objectAt(0);
    item.get('product.id').should.equal(product.get('id'));
    item.get('price').should.equal(product.get('price'));
    item.get('quantity').should.equal(1);

  });

我们首先从商店查询了这个产品,然后断言它确实被添加到了购物车控制器中。我们还断言购物车项目包含预期的属性:productpricequantity

到那时,我们预计购物车链接计数指示器会增加:

find('.nav-cart').text().should.equal('cart 1');

我们还预计用户已经过渡到购物车页面:

currentPath().should.equal('cart');

接下来的测试测试了用户过渡到的购物车页面。首先,我们测试了查看空购物车页面会显示适当的消息:

find('.message').text().should.equal('Cart is empty.');

然后我们测试了当购物车被填满时的页面,首先在预测试钩子中向购物车添加两个项目:

var controller = App.__container__.lookup('controller:cart');
var store = App.__container__.lookup('store:main');

store
  .find('product', 1)
  .then(function(product){
    controller.addItem(product);
    controller.addItem(product);
  });

我们使用controller.addItem方法将两个产品添加到购物车。就像先前的测试用例一样,我们断言购物车链接表明已向购物车添加了两个购物车项目,如下面的代码所示:

find('.nav-cart').text().should.equal('cart 2');

购物车页面包含一个显示购物车详情的表格。它还包含左侧的附加信息;其中之一是购物车总金额:

var store = App.__container__.lookup('store:main');
store
  .find('product', 1)
  .then(function(product){

    var total = product.get('price') * 2;
    find('label.checkout')
    .text()
    .trim()
    .should
    .equal(total.toString());

  });

find辅助函数返回显示订单总金额的标签。我们获取其textContent并断言它等于订单总金额。然后我们继续测试购物车表格,首先确保相关的表头单元格按正确的顺序显示:

[
  'Product',
  'Price',
  'Quantity'
].forEach(function(cell, i){
  find(find('thead td')[i]).text().should.equal(cell);
});

然后我们测试了表格体:

find(find('tbody td'[0])
  .text()
  .should
  .equal(product.get('name'));
find(find('tbody td')[1])
  .text()
  .should
  .equal(product.get('price').toString());
find(find('tbody input'))
  .val()
  .should
  .equal('2');

上述代码通过断言它显示预期的值来测试表格的第一行。对于第三列,我们测试输入包含预期的产品数量。用户可以使用此输入自由调整产品数量。我们在下一个测试用例中测试这一点。首先,我们将输入更新为新值,如下所示:

fillIn(find('tbody input'), 4);

然后我们允许任何触发的操作完成,在我们断言购物车中的数量已更新之前:

andThen(function(){

  find('.nav-cart')
  .text()
  .should
  .equal('cart 4');

});

这个断言展示了如何使用fillIn辅助函数异步更新输入的值。我们还断言购物车总金额已更新:

  var total = product.get('price') * 4;
  find('label.checkout')
  .text()
  .trim()
  .should
  .equal(total.toString());

一旦用户对订单详情满意,他们应该可以通过点击支付按钮转到支付页面:

click('.pay');
andThen(function(){
  currentPath().should.equal('checkout');
});

一旦用户进入结账页面,如果他们的购物车为空,将显示适当的消息:

find('.message').text().should.equal('Cart is empty.');

如果购物车不为空,用户会看到一个总结订单的表格:

[
  'Name',
  'Quantity'
].forEach(function(cell, i){
  find(find('thead td')[i]).text().should.equal(cell);
});

表格体列出了订单的产品名称及其数量:

[
  product.get('name'),
  '2'
].forEach(function(cell, i){
  find(find('tbody td')[i]).text().should.equal(cell);
});

表格还显示了订单总金额:

var total = product.get('price') * 2;
find('.checkout').text().trim().should.equal(total.toString());

测试的表格位于页面左侧。主页包含一个表单,用户在提交前输入他们的支付信息。我们使用triggerEvent辅助函数来调用此事件:

triggerEvent('.form-pay', 'submit');

显示一个成功消息给用户,通知他们订单将很快被送到他们手中:

find('.message')
  .text()
  .should
  .equal('Success! Your order will arrive in 20 minutes. Thank you.');

这测试了结账控制器中定义的支付动作:

pay: function(model){
  var self = this;
  var controller = self.get('controllers.cart');
  controller.forEach(function(item){
    self.store.createRecord('order', item).save();
  });
  model.set('success', true);
  controller.set('content', []);
}

如所示,购物车项目被转换为商店管理员可以看到的实际订单:

store
  .find('order')
  .then(function(orders){

    orders.get('length').should.equal(1);

    var order = orders.objectAt(0);
    order.get('product.id').should.equal(product.get('id'));
    order.get('quantity').should.equal(2);
    order.get('price').should.equal(product.get('price'));

  });

作为回顾,你可以看到前面的测试用例测试了用户从索引页面通过购物车页面到结账页面的旅程。这就是集成测试的构成。

下一个案例将测试管理员的用户旅程。管理员可以通过在登录页面使用正确的凭据登录应用程序来访问管理员仪表板:

fillIn('.input-username', user.get('username'));
fillIn('.input-password', user.get('password'));
triggerEvent('.form-login', 'submit');

andThen(function(){
  currentPath().should.equal('index');
});

成功登录会将用户重定向到索引路由。我们还测试了当用户尝试使用错误的凭据访问管理员仪表板的情况:

fillIn('.input-username', 'username');
fillIn('.input-password', 'password');
triggerEvent('.form-login', 'submit');

andThen(function(){
  currentPath().should.equal('login');
});

我们期望用户在登录失败时保持在登录路由上。

一旦登录,管理员应该能够添加餐点以及编辑或删除现有的餐点。这些在下一个测试中得到了覆盖,我们首先通过文件底部定义的登录助手以管理员身份登录到网站:

fillIn('.input-username', username);
fillIn('.input-password', 'pass');
triggerEvent('.form-login', 'submit');

andThen(function(){
  if (done){
    done();
  } else {
    visit('/');
  }
});

应用程序包含一个隐藏的表单,其文件输入提示用户在点击列表产品组件时选择餐点图片:

<script type="text/x-handlebars" id="components/list-product">
  add
</script>

<script type="text/x-handlebars">
  <form class="list-product-form">
  <input
    class='list-product-form-input'
    type="file"
    style="top: -1000px; position: absolute"
    aria-hidden="true">
  </form>
</script>

相应的App.ListProductComponent定义了一个点击事件处理程序,当组件被点击时会被调用:

var self = this;

// submit

var input = Em.$('.list-product-form input');
input.one('change', upload);

// show file chooser

input.click();

// upload

function upload(event){

  var file = input[0].files[0];
  var reader = new FileReader;
  reader.onload = post.bind(this, reader);
  reader.readAsDataURL(file);

};

// post

function post(reader){
  self.sendAction('action', reader.result);
}

一旦用户选择文件,前面的post方法就会调用,并带有FileReader实例参数,我们从其中获取所选图片的 URL 表示形式以进行存储。显然,在实际应用中,你会将此文件上传到存储服务,如 S3 或 Google Cloud Storage。我们最终创建产品,它将自动列在页面上。由于无法更新文件输入的值,我们的下一个测试规范通过手动将产品添加到购物车来部分确认这一点:

Em.run(function(){

  var controller = App.__container__.lookup('controller:index');
  controller.send('createProduct', 'data:');

  Em.run.next(function(){     
    find('.products li').length.should.equal(5);
    done();
  });

});

在这里,我们在索引控制器上触发了createProduct动作,这会将产品添加到购物车中。请注意,这是由post函数调用的同一个动作。在下一个运行循环中,我们断言一个新的产品被添加到了列表中。由于这个产品的名称和价格尚未定义,下一个测试用例检查管理员是否真的可以编辑这些产品:

var name = 'test';
var price = 1000;

fillIn('.products li:nth-of-type(1) .product-name', name);
fillIn('.products li:nth-of-type(1) .product-price', price);

andThen(function(){
  product.get('name').should.equal(name);
  product.get('price').should.equal(price.toString());
});

前一个测试用例表明管理员能够通过提供的相应输入更新产品的名称和价格。在实际应用中,你可能会添加一个保存按钮,以将更改持久化到后端。

在导航栏中,有一个用户可以点击以过渡到订单页面的orders链接:

{{#link-to "orders" class='nav-orders'}}orders{{/link-to}}

下一个测试确认点击链接会将用户过渡到订单页面:

click('.nav-orders');

andThen(function(){
  find('.message')
  .text()
  .should
  .equal('No new orders have been made.');
});

最后,如果还没有订单,管理员会看到一个消息。然而,如果有订单,管理员应该能够看到列出它们的表格:

visit('/orders');

[
  'ID',
  'Product',
  'Price',
  'Quantity',
  'Total'
].forEach(function(cell, i){
  find(find('thead td')[i]).text().should.equal(cell);
});

[
  product.get('name'),
  product.get('price').toString(),
  order.get('quantity').toString(),
  order.get('total').toString()
].forEach(function(cell, i){
  find(find('tbody td')[++i]).text().trim().should.equal(cell);
});

摘要

我们刚刚学习了你可以使用的各种测试技术来确保应用程序的稳定性。由于你将主要编写集成测试,最佳方法是将你的应用程序分解为清晰的用户旅程。然后,测试任何预期的交互以及转换。这显然会在开发新功能时进行。当测试外部资源时,你可以使用如 Sinon 这样的库来模拟这些服务或扩展测试超时,如下所示:

describe('visit /orders', function(){
  this.timeout(5000);
});

在下一章中,我们将学习如何构建由外部实时数据和资源支持的应用程序。我们将特别学习如何在 Ember.js 应用程序中使用流行的 socket.io 库。

第十一章。构建实时应用程序

到目前为止,我们创建的应用程序并不需要任何实时功能。然而,现代应用程序力求通过减少页面刷新、高效的数据传输和改进的性能来提供最佳的用户体验。此外,这些应用程序可能还需要尽可能快地与服务器发送和接收数据。有几种网络技术可以用来满足这一需求:

  • Adobe Flash sockets

  • JSONP 轮询

  • XHR 长轮询

  • XHR 多部分流

  • ActiveX HTMLFile

  • Web sockets

  • 服务器发送事件

  • WebRTC

在本章中,我们将学习如何使用Socket.io(socket.io)库,该库使 Web 客户端和服务器之间的双向通信成为可能。它通过提供与上述机制类似的 API 来实现这一点,除了最后两个。此外,它根据多个因素(如浏览器支持等)选择最佳机制来使用。

在深入使用 Socket.io 之前,值得注意的是,如果客户端应用程序旨在从后端持续接收更新而几乎不进行推送,那么服务器发送事件是一个不错的选择。Facebook 新闻源和 Twitter 时间线是这种技术可以带来好处的用例的好例子。以下资源可以帮助开发此类应用程序:

WebRTC是适用于需要点对点通信的应用程序的良好选择,例如音频和视频流。

设置 Socket.io

为了帮助掌握 Socket.io,我们将探索捆绑的章节示例,这是一个简单的 IRC 风格的聊天应用程序,其后端是用 Node.js 构建的,如下截图所示:

设置 Socket.io

唯一的前提是 Node.js,可以从nodejs.org/download下载。然后可以执行以下测试以验证安装:

node --version
v0.10.29

npm --version
1.4.14

然后,使用以下命令启动应用程序:

npm install
node server.js

后端使用Express.io(express.io)库,该库将流行的 Express.js(expressjs.com) Web 应用程序框架与 Socket.io 集成,以减少设置样板代码到以下内容:

var app = require('express.io')();
app.http().io();
app.listen(3000);

Express.io 随附 Socket.io 客户端库,并在启动时在/socket.io/socket.io.js上提供服务。因此,我们只需要将其与其他应用程序文件一起加载,如下所示:

<script src="img/socket.io.js"></script>
<script src="img/moment.js"></script>
<script src="img/jquery-1.10.2.js"></script>
<script src="img/handlebars-1.1.2.js"></script>
<script src="img/ember-1.2.0.js"></script>
<script src="img/app.js"></script>

客户端库当然也可以从socket.io/download/下载并相应地提供。客户端库通常通过在公开的io全局变量上调用connect方法来初始化,如下面的代码所示:

App = Ember.Application.create({
  rootElement: '#wrap'
});
App.io = io.connect();

注意,还有其他用其他语言实现的 Socket.io 库可以用作官方 Node.js 库的替代品。

连接用户

当应用程序在http://localhost:3000加载时,用户在通过/join <desired nick>格式提交期望的昵称之前,需要指定要使用的处理程序。App.MessageField视图将此事件委派给index控制器的chat操作,如下所示:

{{view App.MessageField
  required="required"
  placeholder="message"
  action="chat"
  id="message-input"
  value=controller.message}}

App.MessageField = Em.TextField.extend({
  insertNewline: function(){
    this.triggerAction();
  }
});

这是我们从第六章中学习到的内容,即视图和事件管理部分下的从视图中发出操作。正如我们稍后将看到的,应用程序的其他可见性直到用户第一次成功连接才会被隐藏。在相应的控制器chat操作App.IndexController中,我们首先确保提交的消息不为空:

var message = self.get('message');
if (message) message = message.trim();
if (!message || message === '') return;

我们随后检查用户是否意图加入聊天:

var match = message.match(/\/join (\w+)/);
if (match){
  if (nick){
    self.send('tip', 'Already connected!');
  } else {
    self.send('join', match[1], view);
  }
}

当然,如果用户已经连接,我们将通过工具提示通知他们。这个工具提示是通过tip操作显示的,该操作将显示的消息作为其唯一参数:

$('.tooltip') 
  .text(msg)
  .show()
  .click(function(){
    $(this).fadeOut();
  });

如果匹配到昵称,我们将其传递给join操作,该操作首先订阅三个服务器更新。第一个更新是在新用户加入聊天时触发的,并将此特定用户添加到控制器的nicks数组中,以便在用户界面上显示,如下面的代码所示:

App.io.on('join', function(data){
  self.get('nicks').pushObject(data.nick);
});

下一个处理器,另一方面,从集合中移除断开连接的用户:

App.io.on('quit', function(data){
  self.get('nicks').removeObject(data.nick);
});

我们还订阅传入的消息并将它们存储在控制器的messages属性中:

App.io.on('chat', function(data) {
  self.get('messages').pushObject(App.Message.create(data));
});

Socket.io 的.on实例方法用于通过订阅后端发出的事件来执行拉取操作,如前三个处理器所示。另一方面,.emit方法用于将数据推送到后端。在这种情况下,我们通知服务器有新用户希望加入聊天,如下所示:

App.io.emit('ready', { nick: nick }, function(data) {
});

前一个调用将一个ready事件和昵称一起发送到后端。同样地,后端也设置了事件处理器,其中第一个处理器监听ready事件:

// server.js
app.io.route('ready', function(req) {
});

监听器首先检查昵称是否被现有用户占用,以及现有用户是否占用了该昵称:

var success = nicks.indexOf(req.data.nick) === -1;

如果确实已被占用,我们将通过另一个更新通知用户此失败:

req.io.respond({
  success: success
});

客户端应用程序需要做的只是通过工具提示显示通知:

if (data.success){
} else {
  self.send('tip', 'Nick is taken');
}

然而,如果昵称未被占用,我们首先将昵称存储在session中以供以后参考:

req.session.nick = req.data.nick;

在群聊应用程序中,我们期望已登录到聊天的用户在其他人加入时收到通知,正如在不同的标签页上加载应用程序所展示的那样。因此,当用户连接时,我们首先将适当的消息添加到消息存储中:

var message = {
  isjoin: true,
  nick: req.data.nick,
  message: req.data.nick+' joined',
  date: (new Date).toISOString()
};
messages.push(message);

然后,我们广播此事件给其他已登录用户:

req.io.broadcast('join', req.data);
req.io.broadcast('chat', message);

Socket.io 提供了通过.broadcast方法向所有活动连接发送事件(除了当前连接)的途径。前面的广播事件导致用户被添加到客户端应用程序右侧的昵称列表中,并伴随加入通知:

<ul class="nicks">
  {{#each nicks}}
  <li> {{this}}</li>
  {{/each}}
</ul>

我们最终使用respond方法通知连接用户连接成功:

req.io.respond({
  success: success,
  nicks: nicks,
  messages: messages.slice(0, -1)
});

此外,我们还向他们发送当前登录用户的列表以及最近的五条消息。一旦连接,我们清除消息字段并填充usersnicks集合,如下所示:

self.set('message', '');
self.set('connected', true);
self.set('nick', nick);
self.get('nicks').pushObjects(data.nicks);
self.get('messages').pushObjects(data.messages.map(function(data){
  var message = App.Message.create(data);
  if (message.get('nick') === nick) message.set('isme', true);
  return message;
}));

当消息流进来时,我们通过使用观察者使消息列表的底部可见于视口:

  messagesLengthDidChange: function(){
    Em.run.debounce(this, 'send', 'scrollToBottom', 200);
  }.observes('messages.length'),

这所做的就是触发控制器的scrollToBottom动作,在 200 毫秒的窗口中显示,如下所示:

$('html, body')
  .animate({ scrollTop: $(document).height() }, 'slow');

一旦用户连接,应用程序的其余部分可见性由绑定实现:

<div id="content" {{bind-attr class="connected:show"}}></div>

在这种情况下,消息容器元素获取了show属性,从而将不透明度重置为1

#title,
#content{
  opacity: 0.1;
}

#title.show,
#content.show{
  opacity: 1;
}

后续消息通过聊天操作转发到服务器并广播给其他用户:

if (!nick) return self.send('tip', '/join <nick>');

var msg = App.Message.create({
  isme: true,
  message: message,
  nick: nick
});
self.get('messages').pushObject(msg);
App.io.emit('chat', msg.toJSON());
self.set('message', '');

再次,在后台,我们订阅此事件并将消息转发给其他用户:

app.io.route('chat', function(req) {
  messages.push(req.data);
  req.io.broadcast('chat', req.data);
});
In the client app, conveniently use a table, for convenience, to list the messages:
{{#each messages}}
<tr {{bind-attr class="isnotice:notice isjoin:join isquit:quit isme:me"}}>
  <td {{bind-attr class=":nick"}}>
    <span>{{#if isme}}me{{else}}{{nick}}{{/if}}</span>
  </td>
  <td class="message">
    <span>{{message}}</span>
  </td>
  <td class="date" data-timestamp="{{timestamp}}">
    <span>{{message-date date=date}}</span>
  </td>
</tr>
{{/each}}

你会注意到我们定义了一个message-date组件,该组件使用 Moment.js 库格式化显示消息的日期:

<script type="text/x-handlebars" id="components/message-date">
  {{formatedDate}}
</script>

App.MessageDateComponent = Ember.Component.extend({

  tagName: 'span',

  didInsertElement: function(){

    var self = this;

    var id = setInterval(fn, 15000);
    this.set('intervalId', id);
    fn();
    function fn(){
      self.set(
'formatedDate', moment((new Date(self.get('date')))).fromNow()
     );
    }

  },

  willDestroyElement: function(){
    clearInterval(this.get('intervalId'));
  }

});

为了提供更好的实时体验,该组件每 15 秒更新一次格式化日期以展示老化效果:

最后,当用户断开连接时,我们从nicks集合中删除他们的句柄并通知其他用户:

app.io.route('disconnect', function(req) {

  var nick = req.session.nick;
  if (!nick) return;

  var message = {
    isquit: true,
    nick: nick,
    message: nick+' quit',
    date: (new Date).toISOString()
  };
  messages.push(message);

  var index = nicks.indexOf(nick);
  if (index > -1) {
    nicks.splice(index, 1);
  }

  req.io.broadcast('chat', message);
  req.io.broadcast('quit', {
    nick: nick
  });

});

你会注意到我们从session中引用了保存的用户句柄:

可以对应用程序进行一些改进,例如多频道、表情符号和用户头像支持等。

摘要

这是对构建与实时网络技术集成的 Ember.js 应用程序的简要介绍。它展示了在不花费太多时间进行琐碎选择的情况下使用第三方库是多么容易。我们学习了如何初始化 Socket.io 库,并订阅和从服务器接收和发送更新。在下一章中,我们将学习如何将我们的应用程序组件化成可重用的组件。

第十二章 模块化你的项目

许多 Ember.js 项目可能会变得复杂,因此可能需要通过以下任何一种组合对项目进行模块化,以提高可维护性:

  • 将项目拆分为多个脚本文件并单独加载它们。

  • 将脚本文件连接成一个构建文件。这减少了浏览器需要向后端发出的请求数量;因此,页面加载时间减少。

  • 维护可重用组件,这些组件可以在多个项目中使用。

有许多开源工具可以用来执行此类任务。这些工具可能包含以下内容:

  • 一个可以安装外部可重用组件的包管理器

  • 一个智能地将所有项目文件连接成一个单一构建文件的构建过程

以下是一些这些流行的工具:

  • Grunt(一个构建工具)

  • Gulp(一个构建工具)

  • Bower(一个包管理器)

  • NPM(一个包管理器)

  • Browserify(一个构建工具)

  • Ember CLI(一个构建工具)

  • Brocolli(一个构建工具)

  • Ember 插件(emberaddons.com)

  • Duojs(基于 Component 和 Browserify 的构建工具和包管理器)

  • Component(一个构建工具和包管理器)

这些都可以以任何组合方式完成任务。在本章中,我们将讨论如何使用Component轻松管理复杂的 Ember.js 项目。值得注意的是,ES6 模块功能正在开发中,以解决这些问题。幸运的是,一些这些工具符合规范,从而使得迁移变得容易。

安装 Component 构建工具

组件是一个客户端 Web 项目的包管理器和构建工具。它提供了安装外部项目依赖项以及将项目组织成几个本地组件的能力,这些组件稍后可以构建成一个单独的构建文件。本章示例使用此工具安装和使用所需的项目依赖项。要运行此示例,我们首先需要安装 Node.js,可以从nodejs.org/download下载。要运行项目,只需在终端外壳中执行make命令。如果系统没有安装make,可以使用以下命令运行项目,其中第一个命令安装了工具:

npm install

Component 是一个 NPM 包,因此可以通过将依赖项添加到package.json中来进行安装:

{ 
  "name": "2048", 
  "private": true, 
  "dependencies": { 
    "component": "⁰.19.9" 
  } 
} 

接下来,我们使用以下代码安装外部组件依赖项并构建文件:

./node_modules/component/bin/component install
./node_modules/component/bin/component build -n public -o public

最后,可以通过在浏览器中加载index.html来打开应用程序。此应用程序是 Gabriele Cirulli 开发的流行 2048 游戏的实现(gabrielecirulli.github.io/2048/),并利用了 Ember.js 的运行时。

如下截图所示的开源游戏是 Web 游戏开发的良好介绍:

安装 Component 构建工具

代码组织

组件工具要求项目组织到 components。组件是一个可重用的模块,可以包含在 component.json 配置文件中定义的脚本、样式、图像、模板和字体。它还可以可选地定义依赖项,这些依赖项本身也是组件。这些依赖项可以是本地或远程的。因此,可以将项目视为一个组件的树,如下面的截图所示:

代码组织

在这个项目中,项目的根目录定义了一个组件 2048,如下所示:

  "name": "2048", 
  "local": ["app"], 
  "paths": ["lib"] 
}

这个组件不包含任何脚本或样式,因为这些都在子组件中,所以我们不需要指定它们。然而,它指定了它依赖于一个名为 app 的本地组件,该组件位于 lib 相对目录中,它反过来又定义了另一个本地组件依赖 game

"local": [ 
  "game" 
],

安装组件

组件可以定义远程组件作为依赖项。远程组件是托管在 Github 或 BitBucket 上的外部 Git 仓库。一些组件版本允许从其他远程组件安装组件,只要它们遵循 <username>/<repo> 格式。在这种情况下,应用程序组件定义了一个依赖项,该依赖项将从 github.com/kelonye/ember 安装:

"dependencies": { 
  "kelonye/ember": "1.7.0" 
}

另一方面,游戏组件也定义了三个依赖项:

"dependencies": { 
  "yields/keycode": "1.1.0" ,
  "component/raf": "1.1.3", 
  "yields/store": "0.2.0"
}

这些组件可以通过在项目根目录中调用以下命令安装到 components/ 相对目录中:

./node_modules/component/bin/component install

我们也可以简单地询问 component install,如果模块通过 npm install -g component 全局安装,或者如果 ./node_modules/.bin 被添加到 bash 配置文件 PATH 中,那么模块是否已安装:

构建组件

一旦远程组件被安装,可以通过调用以下命令来构建项目:

./node_modules/component/bin/component build

这将脚本和样式连接到 builds 文件夹中。默认情况下,文件夹命名为 build,但可以通过传递 –out-o 标志来更改。此外,默认情况下,构建文件命名为 build.jsbuild.css,但可以使用 –name-n 标志来更改,例如:

component build -o public -n public

加载构建文件

构建文件如下所示从构建文件夹中引用:

<link rel="stylesheet" href="public/public.css">

<script src="img/public.js"></script> 
<script>require('app');</script>

注意,应用程序是通过 require app 组件来启动的。app 组件包含一个 index.js 文件,该文件在进程中被执行。每个可要求的组件都需要在配置文件中的 .scripts 属性中指定此文件。它也可以通过 .main 标志指定主文件,因此我们可以将 index.js 文件命名为 app.js,然后设置主标志为 "app.js"。脚本需要远程安装的 ember.js 组件,创建应用程序,并定义路由器:

require('ember'); 

window.App = Em.Application.create(); 

require('game'); 

App.Router.map(function(){ 
  this.route('game'); 
});

组织本地组件的一个好方法是将它们根据路由器中定义的路由或资源分开;在这种情况下,是appgame组件。每个组件都包含相应的控制器、视图、模型、路由和模板脚本,这些都在配置文件中明确定义:

"scripts": [ 
  "index.js", 
  "views.js", 
  "models.js", 
  "routes.js", 
  "templates.js", 
  "controllers.js" 
],

这些在主脚本中如下所示:

require('./templates'); 
require('./models'); 
require('./views'); 
require('./controllers'); 
require('./routes'); 

app组件定义了两个相应所需的模板:

// component.json
"templates": [ 
  "templates/application.html", 
  "templates/index.html" 
],

// template.js
function compile (template){ 
  return Em.Handlebars.compile(require(template)); 
}; 
[ 
  'application', 
  'index' 
].forEach(function(tmpl){ 
  Em.TEMPLATES[tmpl] = compile('./templates/'+tmpl+'.html'); 
});
application and index templates to Em.TEMPLATES. The application, index controllers, and routes are then required accordingly, as follows:
App.ApplicationController = Em.Controller.extend({ 
}); 

App.IndexController = Em.Controller.extend({ 
  needs: ['application'] 
});

App.ApplicationRoute = Em.Route.extend(); 

App.IndexRoute = Em.Route.extend({ 
  redirect: function(){ 
    this.transitionTo('game'); 
  } 
});

注意,index路由将应用程序状态重定向到游戏路由。

游戏逻辑

在我们讨论game组件之前,讨论游戏逻辑是个好主意:

  • 游戏是一个 4x4 的网格,通过使用键盘箭头键滑动方块进行移动

  • 如果方块的大小相等,它们将合并

  • 每次移动都会生成一个新的随机方块

  • 游戏的目标是将这些方块滑动,直到其中一个合并到 2048 的值

网格单元格由位于models.js中的App.Cell模型表示:

App.Cell = Em.Object.extend({ 

  x: null, 
  y: null, 
  value: null, 

  move: function(cell){ 
    var value = this.get('value'); 
    var nvalue = cell.get('value'); 
    var score = nvalue+this.get('value'); 
    cell.set('value', score); 
    this.set('value', 0); 
    if (value && nvalue) return score; 
    return 0;
  },

  isTile: function(){ 
    return !!this.get('value'); 
  }.property('value'),

  is2048: function(){ 
    return this.get('value') == 2048; 
  }.property('value'),

});

当单元格的值被定义时,它被视为一个方块。当游戏开始时,我们首先用单元格填充游戏:

App.GameRoute = Em.Route.extend({ 
  setupController: function(controller, model) { 
    this._super(); 
    controller.addStartTiles(); 
  } 
});

游戏控制器包含单元格,因此我们相应地填充它:

App.GameController = Em.ArrayController.extend({ 
  needs: ['application'], 
  size: 4, 
  score: 0, 

  addStartTiles: function () { 

    var size = this.get('size'); 
    var rows; 

    this.setProperties({ 
      model: [], 
      tiles: [] 
    }); 

    for (var x = 0; x < 4; x++){ 
      for (var y = 0; y < 4; y++){ 
        var cell = App.Cell.create({ 
          x: x, 
          y: y 
        }); 
        this.pushObject(cell); 
      } 
    } 
  } 
});

在这个阶段,我们使用store组件从本地存储中恢复保存的游戏:

// restore 

var tiles = store('tiles'); 
var score = store('score'); 
var restored = tiles && score; 
if (restored){ 

  tiles.forEach(function(tile){ 
    var x = Em.get(tile, 'next.x') || Em.get(tile, 'prev.x'); 
    var y = Em.get(tile, 'next.y') || Em.get(tile, 'prev.y'); 
    var value = Em.get(tile, 'next.value') || Em.get(tile, 'prev.value'); 
    var cell = self.find(function(_cell){ 
      return _cell.get('x') == x && _cell.get('y') == y; 
    }); 
    if (cell) cell.set('value', value); 
  }); 

  this.get('tiles').pushObjects(tiles); 
  this.set('score', Number(score)); 

  store('tiles', undefined); 
  store('score', undefined); 
} 

此外,我们缓存了在这个阶段需要进行的四个可能的游戏移动方向的遍历。例如,当用户向上移动时,我们将从左到右向下遍历单元格:

var traversals = Em.Object.create(); 

// 'up' 

rows = []; 
for (var x = 0; x < size; x++){ 
  var row = []; 
  for (var y = 0; y < size; y++){ 
    var cell = this.find(function(_cell){ 
      return _cell.get('x') == x && _cell.get('y') == y; 
    }); 
    row.pushObject(cell); 
  } 
  rows.pushObject(row); 
} 
traversals.set('up', rows);

接下来,如果游戏状态未恢复,我们将生成游戏的前两个方块:

// generate 2 random tiles 

if (!restored){ 
  for (var i = 0; i < 2; i++) { 
    this.get('tiles').pushObject(this.getRandomTile()); 
  } 
}

随机方块生成器简单地选择一个随机单元格,并将其转换为方块,通过将其值设置为24(如果游戏尚未结束):

getRandomTile: function () { 
  if (this.hasAvailableCells()) { 
    var value = Math.random() < 0.9 
      ? 2 
      : 4; 
    var tile = this.getRandomAvailableCell(); 
    tile.set('value', value); 
    return { 
      prev: tile 
    }; 
  } 
},

游戏视图设置一个监听器来播放移动:

App.GameView = Em.View.extend({ 
  didInsertElement: function(){ 
    var self = this; 
    this._super(); 
    $(document).on('keydown', function(event){ 
      event.preventDefault(); 
      self.get('controller').send('onMove', event.which); 
    }); 
  }, 
});

当用户进行操作时,我们将按下的键的键码发送到游戏控制器的onMove动作。如果游戏尚未结束,该动作将首先确定操作的方向,借助keycode组件:

if (self.hasEnded()) return self.endGame(false);

var dir = [ 
  { 
    name: 'up', 
    vector: {x: 0, y: -1} 
  }, { 
    name: 'right', 
    vector: {x: 1, y: 0} 
  }, { 
    name: 'down', 
    vector: {x: 0, y: 1} 
  }, { 
    name: 'left', 
    vector: {x: -1, y: 0} 
  } 
].find(function(_dir){ 
  return keycode(_dir.name) == code; 
});

下一步是找到与游戏方向相对应的遍历矩阵:

if (dir){ 
  var traversals = self.get('traversals.'+dir.name); 
}

然后我们遍历单元格,并尝试移动方块,如果符合条件的话:

try { 
  var tiles = [];
  var moved;
  var traversals = self.get('traversals.'+dir.name); 
  traversals.forEach(function(row){ 
    row.forEach(function(cell){ 
      if (cell.get('isTile')){ 

      } 
    }); 
  }); 

} catch (e) { 
  self.endGame(e.won); 
}

对于这些方块中的每一个,我们通过调用getNewFarthestCell控制器方法来找到它可以占据的最远的新的单元格:

var ncell = self.getNewFarthestCell(cell, dir.vector, 0);

此方法接受移动的轨迹向量和移动的幅度:

getNewFarthestCell: function(cell, dir, mag){

},

此方法旨在是递归的,即我们逐步将单元格移动到游戏的方向,直到我们找到最远的单元格。首先,我们找到当前、前一个和下一个单元格的位置,如下所示:

++mag; 

var traversals = this.get('traversals.up'); 
var x = cell.get('x'); 
var y = cell.get('y'); 
var value = cell.get('value'); 

var nx = x + dir.x * mag; 
var ny = y + dir.y * mag; 

var px = x + dir.x * (mag - 1); 
var py = y + dir.y * (mag - 1);
  var pcell = traversals[px][py];

然后我们检查单元格是否超出网格:

var nrow = traversals[nx]; // cell is x outbound 
if (!nrow) return ret();

  var ncell = nrow[ny]; // cell is y outbound 
  if (!ncell) return ret(); 

如果新的单元格确实超出了边界,我们将返回前一个单元格作为方块的新位置。然而,如果我们遇到一个方块,我们将测试这两个方块是否可以合并:

// cell cannot be merged 
var nvalue = ncell.get('value'); 
if (nvalue && value && nvalue != value) return ret();

如果前面的条件没有满足,我们将继续测试下一个单元格:

return this.getNewFarthestCell(cell, dir, mag);

接下来,我们检查单元格是否能够移动到新位置:

if (ncell && ncell != cell){
}

然后,我们需要一个状态来确定在该迭代中是否有单元格移动,以便稍后参考:

if (!moved){ 
  moved = true; 
}

如果已经发生了合并,那么我们需要使用它之前的单元格:

if (merged && ncell.get('isTile')) { 
  ncell = pcell; 
}

最后,我们移动瓷砖并增加游戏的分数:

var score = cell.move(ncell); 
self.set('score', self.get('score') + score); 

由于瓷砖已经移动,我们将它添加到将在重绘时渲染的新瓷砖集合中:

tiles.pushObject({ 
  prev: cell, 
  next: ncell 
});

对于迭代的每次合并,我们需要检查游戏是否已经获胜或失败:

if (self.hasEnded()){ 
  var err = new Error; 
  err.type = 'end-game'; 
  err.won = false; 
  throw err; 
} else if (ncell.get('is2048')){ 
  var err = new Error; 
  err.type = 'end-game'; 
  err.won = true; 
  throw err; 
}

根据 Gabriele 的游戏规范,只有在迭代结束后任何现有瓷砖移动的情况下,才能生成新的随机瓷砖:

if (moved){ 
  tiles.pushObject(self.getRandomTile()); 
  self.set('tiles', tiles); 
}

最后,我们需要通过捕获抛出的异常来检查游戏是否结束:

if (e.type == 'end-game'){ 
  self.set('tiles', tiles); 
  self.endGame(e.won); 
} else { 
  console.err(err); 
}

被调用的方法会显示一个消息覆盖层,指示游戏是否已经获胜:

endGame: function(won){ 
  var classes = ['game-won', 'game-over']; 
  var type    = won ? classes[0] : classes[1]; 
  var message = won ? 'You win!' : 'Game over!'; 
  $('.game-message') 
    .removeClass(classes) 
    .addClass(type) 
    .html('<p>'+message+'</p>') 
    .show(); 
  }, 

templates/game.html中找到的game模板会响应绑定控制器的变化。首先,New Game按钮的点击事件绑定到createNewGame操作:

<a class="restart-button" {{action 'createNewGame'}}>New Game</a>

createNewGame: function(){ 
  this.addStartTiles(); 
  this.set('score', 0); 
  $('.game-message').hide(); 
}

分数标签绑定到控制器的score属性:

<div class="score-container">{{score}}</div>

网格由覆盖在瓷砖上的单元格组成,因此我们首先按照以下方式布局 16 个单元格:

<div class="grid-container"> 
  {{#each row in traversals.up}} 
  <div class="grid-row"> 
    {{#each cell in row}} 
    <div class="grid-cell"></div> 
    {{/each}} 
  </div> 
  {{/each}} 
</div>

接下来,我们布局瓷砖:

<div class="tile-container"> 

  {{#each tiles}} 
  {{#view App.TileView prevBinding='prev' nextBinding='next'}} 
    <div class="tile-inner">{{view.value}}</div> 
  {{/view}} 
  {{/each}}

</div>

App.TileView的主要作用是动画化瓷砖移动以及根据它们的值显示不同的瓷砖阴影。现在,获得平滑的滑动过渡有点棘手。一旦瓷砖视图被插入到 DOM 中,我们首先在didInsertElement中设置瓷砖的初始位置:

var prev = classes('prev'); 
var next = classes('next'); 
var hasNext = !!self.get('next.value'); 

if (!hasNext) prev.pushObject('tile-new'); 

self.set('tileClasses', prev.join(' ')); 

如果瓷砖正在移动,我们使用requestAnimationFrame组件在下一个浏览器重绘之前设置新的位置:

if (hasNext){ 
  raf(function(){ 
    self.set('tileClasses', next.join(' ')); 
  }); 
}

服务器端的图像和字体

在样式表中指定的图像和字体可以使用它们对应的相对路径进行引用,如游戏组件样式表的第一行所示:

@import url(fonts/clear-sans.css);

组件会自动解析这些路径:

@import url("game/styles/fonts/clear-sans.css");

注意,资源是符号链接到构建文件夹的。如果需要,我们可以用所需的静态根前缀这些路径。例如,如果使用 Django 来服务器这些文件,我们会在构建命令中添加一个前缀标志:

component build –prefix /static

这将导致如下路径:

@import url("/static/game/styles/fonts/clear-sans.css");

在某些平台上,符号链接可能会引起问题,因此你可以传递copy标志来复制文件而不是链接它们,如下所示:

component build --copy

摘要

组件是一个你可以用来组织你的 Ember.js 项目的强大工具。它是一个可以用来安装和重用托管在 Github 上的小型组件的强大工具。一般来说,将 Ember.js 组件发布为混合或扩展Em.Component类的组件。例如,位于github.com/kelonye/ember-link的组件是一个很好的最小组件示例,它可以扩展到任何项目视图中:

require('ember'); 

module.exports = Em.Mixin.create({ 
  tagName: 'a', 
  href: 'javascript:', 
  attributeBindings: 'href target'.w() 
});

这样,Ember.js 社区就可以从常用的代码片段中受益,从而加快项目开发速度。

posted @ 2025-10-26 08:54  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报