精通-Backbone-js-全-

精通 Backbone.js(全)

原文:zh.annas-archive.org/md5/561cb71a41f17d64642b37bc386d7c07

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Backbone 是构建 Web 应用的一个惊人的库;它小巧、简单,但功能强大。它提供了一套小型且专注的对象,用作构建前端应用时的砖块。

Backbone 的美在于它给你自由,让你按照自己的规则构建应用。然而,权力越大,责任越大;Backbone 并没有告诉你如何构建应用的结构。记住,Backbone 不是一个框架,而是一个库。

在多年的 Backbone 项目工作中,进行代码实验,以及探索其他开发者的代码后,我确定了在 Backbone 构建前端 Web 应用时的模式和最佳实践。

本书解释了如何给你的应用赋予结构。它为你提供了创建健壮和可维护 Web 应用的工具和策略。它将帮助你定义和分配正确的责任给 Backbone 对象,并定义一套新的粘合对象。

在这本书中,你将构建一个功能性的应用,应用在这里展示的概念。这个应用足够简单,可以在使用 Backbone 构建可扩展的前端应用时实践核心概念。任何时候,你都可以在书的项目仓库中查看项目代码,网址为 github.com/abiee/mastering-backbone

本书涵盖的内容

第一章, Backbone 应用架构,处理项目的组织结构,分为逻辑和物理两个层面。在逻辑层面,你将学习如何连接 Backbone 对象,而在物理层面,你将看到放置脚本的位置。

第二章, 管理视图,帮助你提取视图的常见模式,并创建一套通用视图,这些视图可以在任何 Backbone 应用中使用。这些视图在管理视图时将大大减少样板代码。

第三章, 模型绑定,解释了如何处理复杂的 REST 资源,并帮助你处理嵌入式资源,并使其与视图保持同步。

第四章, 模块化代码,涵盖了使用 Browserify 进行依赖管理和脚本打包。现代应用正变得越来越 JavaScript 依赖,因此以更智能的方式处理依赖是一个好主意。

第五章, 处理文件,涵盖了网络应用上传文件到服务器的常见需求,本章将告诉你如何在 Backbone 和 REST 服务器上实现这一功能。

第六章, 在浏览器中存储数据,展示了如何在浏览器中存储数据以及如何从 Backbone 的角度来实现。这一章展示了如何构建两个驱动程序,以透明的方式将 Backbone 模型存储在 localStorage 和 indexedDB 中,而不是远程服务器。如果你想要创建离线应用程序,这可能会很有用。

第七章,像专业人士一样构建,告诉您如何在脚本中自动化常见和重复的任务。这将大大提高您的生产力。它描述了您如何构建一个开发工作流程,每次您进行小的更改时,它都会自动刷新您的项目。

第八章, 测试 Backbone 应用程序,展示了在测试前端代码时的策略和最佳实践。

第九章,部署到生产,展示了如何将项目部署到生产服务器。虽然高需求的应用程序需要一个复杂平台,但这一章为您的小型应用程序提供了起点。

第十章,安全,教您如何对 REST 服务器进行认证以及如何从 Backbone 端管理会话。

你需要这本书的内容

尽管这本书是为前端应用程序编写的,但您需要安装 Node 版本 5 或更高版本。Node 将运行示例 REST 服务器,自动化常见任务,并管理项目依赖。

这本书面向谁

这本书是为已经了解 Backbone 但想要创建更优秀项目的开发者编写的;它不会从头解释 Backbone。相反,我将向您展示如何提高您的技能,以有效的方式组织和结构化您的应用程序。

惯例

在这本书中,你会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名将如下所示:"我们可以通过使用include指令来包含其他上下文。"

代码块将以如下方式设置:

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

当我们希望将您的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

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

# cp /usr/src/asterisk-addons/configs/cdr_mysql.conf.sample
 /etc/asterisk/cdr_mysql.conf

新术语重要词汇将以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,将在文本中如下显示:"点击下一步按钮将您带到下一屏幕。"

注意

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

提示

技巧和窍门将如下所示。

读者反馈

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

要发送一般反馈,请简单地发送电子邮件至<feedback@packtpub.com>,并在邮件主题中提及书籍的标题。

如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

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

下载示例代码

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

勘误

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

要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍的名称。所需信息将出现在勘误部分下。

盗版

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

请通过链接www.packtpub.com与我们联系,并提供疑似盗版材料的链接。

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

询问

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

第一章. Backbone 应用程序的架构

Backbone 最好的特性之一是能够自由地使用你选择的库来构建应用程序,无需附带任何电池。请注意,Backbone 不是一个框架,而是一个库;因此,使用 Backbone 构建应用程序可能会具有挑战性,因为它不提供任何结构。作为开发者,你负责代码的组织以及如何在应用程序中连接代码的各个部分;这是一项重大的责任。不良的决策可能导致存在缺陷且难以维护的应用程序,没有人愿意与之合作。

在小型 Backbone 应用程序中进行代码组织并不是什么大问题。为模型、集合和视图创建一个目录;为所有可能的路由放置一个路由器;并将业务逻辑直接写入视图。然而,这种方式开发 Backbone 应用程序并不适合大型项目。应该有更好的方法来分离责任和文件组织,以便创建可维护的应用程序。

如果你根本不了解 Backbone,那么这一章可能会很难理解;为了更好地理解这里所展示的原则,你需要至少了解 Backbone 的基础知识。因此,如果你是 Backbone 的初学者,我鼓励你首先了解 Backbone 是什么以及它是如何工作的。

本章的目标是探讨在两个主要层面上的项目组织最佳实践:逻辑组织和文件结构。在本章中,你将学习以下内容:

  • 将适当的职责委托给 Backbone 提供的对象

  • 定义普通的 JavaScript 对象以处理超出 Backbone 对象作用域的逻辑

  • 将应用程序拆分为小型且易于维护的脚本

  • 为你的项目创建一个清晰的文件结构

基于子应用程序的架构

我们可以用许多独立的子应用程序来组合一个 Backbone 应用程序。子应用程序应该独立工作。你可以将每个子应用程序视为一个小型 Backbone 应用程序,具有自己的依赖项和职责;它不应直接依赖于其他子应用程序。

子应用程序应专注于特定的领域区域。例如,你可以有一个用于发票的子应用程序,另一个用于邮箱,还有一个用于支付;有了这些子应用程序,你可以构建一个应用程序来通过电子邮件管理支付。

为了使子应用程序相互解耦,我们可以构建一个负责管理子应用程序、启动整个应用程序以及为子应用程序提供公共功能和服务的应用程序基础设施:

基于子应用程序的架构

图 1.1. 基于子应用程序的 Backbone 应用程序的组成

您可以使用基础设施应用程序为您的子应用程序提供诸如确认和对话框消息、通知弹出窗口、模态框等服务。基础设施应用程序本身不执行任何操作,它作为子应用程序的框架存在。

当子应用程序想要与其他子应用程序通信时,可以使用基础设施应用程序作为通信通道,它可以利用 Backbone.Event 对象来发送和接收消息。

在以下图中,您可以看到子应用程序通过基础设施应用程序进行通信的场景。当用户在邮箱子应用程序中点击撰写消息时,基础设施应用程序创建并渲染撰写邮件子应用程序,并允许用户撰写电子邮件。

当用户完成操作后,他们必须在撰写子应用程序中点击发送按钮;然后电子邮件通过 RESTful API 或使用纯 SMTP 发送,不重要的是,重要的是,当它完成时,它会在 email:sent 基础设施应用程序中触发一个事件。

基础设施应用程序将事件转发到邮箱子应用程序,以便更新已发送电子邮件的列表。另一个有趣的事情是,基础设施应用程序可以使用 email:sent 事件向用户显示一个成功的弹出消息,告诉他们电子邮件已成功发送:

基于子应用程序的架构

图 1.2. 子应用程序之间的通信

子应用程序结构

如前所述,子应用程序就像一个小型的 Backbone 应用程序;它们应该独立于其他子应用程序,并作为一个独立的应用程序运行。您应该能够在没有任何其他子应用程序的空白页面上放置撰写邮件子应用程序,并且仍然能够发送电子邮件。

为了实现这一点,子应用程序应包含所有必要的对象,以便它们可以自动包含。您可以看到子应用程序的入口点是 Backbone.Router。当浏览器更改 URL 并为特定子应用程序匹配到一个路由时,路由器创建一个子应用程序控制器并将其委托处理路由。

子应用程序控制器协调模型/集合以及它们的显示方式。控制器可以指示应用程序基础设施在数据检索时显示加载消息,并在完成后,控制器可以使用最近检索到的模型和集合构建必要的视图,以便在 DOM 中显示。

简而言之,子应用程序的行为与一个小型的 Backbone 应用程序完全相同,主要区别在于它使用应用程序基础设施来委托常见任务以及子应用程序之间的通信通道。

在下一节中,我们将检查这些部分是如何连接的,我会向你展示一个工作联系人应用的代码。以下图显示了子应用的结构视图:

子应用结构

图 1.3. 子应用结构

Backbone 对象的责任

Backbone 文档最大的问题之一是不知道如何使用其对象。作为开发者,你应该弄清楚应用程序中每个对象的责任;如果你有一些使用 Backbone 的经验,那么你就会知道构建 Backbone 应用程序有多困难。

在本节中,我将描述 Backbone 对象的最佳用途。从这一点开始,你将对 Backbone 中责任的范围有一个更清晰的认识,这将引导我们应用架构的设计。记住,Backbone 是一个仅包含基础对象的库;因此,你需要自己带来对象和结构,以创建可扩展、可测试和健壮的 Backbone 应用程序。

视图

视图的唯一责任是处理 文档对象模型 (DOM) 和监听低级事件(jQuery/DOM 事件),并将它们转换为领域事件。Backbone 视图与模板引擎紧密合作,以创建代表模型和集合中包含的信息的标记。

视图抽象用户交互,将它们的操作转换为应用程序的业务价值数据结构。例如,当 DOM 中的保存按钮触发点击事件时,视图应将事件转换为类似于 save:contact 事件,使用 Backbone Events 将表单中写入的数据。然后,一个特定领域的对象可以对数据进行一些业务逻辑处理,并显示结果。

规则是避免在视图中使用业务逻辑;然而,基本表单验证,如只接受数字,是允许的。复杂的验证仍然应该在模型或控制器上完成。

模型

Backbone 模型在服务器端类似于数据库网关,它们的主要用途是从 RESTful 服务器获取和保存数据,然后为应用程序的其余部分提供一个 API 以处理信息。它们可以运行通用业务逻辑,例如验证和数据转换,处理其他服务器连接,并为模型上传图片。

模型对视图一无所知;然而,它们可以实现对视图有用的功能。例如,你可以有一个显示发票总额的视图,而发票模型可以实现一个执行计算的方法,使视图无需了解计算过程。

收藏

你可以将 Backbone 集合视为一组 Backbone 模型的容器,例如,Contacts模型的集合。使用模型,你一次只能获取一个文档;然而,集合允许我们获取模型列表。

与模型相比,集合应该用作只读,它们获取数据但不应在服务器上写入;此外,在这里看到业务逻辑也不常见。

集合的另一个用途是将 RESTful API 响应抽象化,因为每个服务器都有不同的方式来处理资源列表。例如,有些服务器接受skip参数用于分页,而其他服务器则有一个page参数用于相同的目的。另一个情况是在响应中,服务器可以返回一个纯数组,而其他服务器则更倾向于发送一个包含datalist或其他键的对象,其中对象数组被放置。没有标准的方式。集合可以处理这些问题,使服务器请求对应用程序的其他部分来说是透明的。

路由器

路由器有一个简单的职责:监听浏览器中的 URL 变化并将它们转换为对处理器的调用。路由器知道对于给定的 URL 应该调用哪个处理器。此外,它们还需要解码 URL 参数并将它们传递给处理器。基础设施应用程序启动应用程序;然而,路由器决定哪个子应用程序将被执行。因此,路由器是一种入口点。

Backbone 未提供对象

只使用前一小节中描述的 Backbone 对象来开发 Backbone 应用程序是可能的;然而,对于中型到大型应用程序来说,这还不够。我们需要引入一种具有明确职责的新对象,这些对象使用并协调 Backbone 基础对象。

子应用程序外观

此对象是子应用程序的公共接口。任何与子应用程序的交互都应该通过其方法来完成。直接调用子应用程序内部对象的调用是不被鼓励的。通常,此控制器上的方法是从路由器调用的;然而,它们也可以从任何地方调用。

此对象的主要责任是简化子应用程序的内部结构。其主要工作是通过对模型或集合从服务器获取数据,如果在过程中发生错误,它负责向用户显示错误消息。一旦数据被加载到模型或集合中,它就创建一个子应用程序控制器,该控制器知道应该渲染哪些视图,并让处理器处理其事件。

子应用程序控制器

控制器充当视图、模型和集合的空中交通控制器。当给定一个 Backbone 数据对象时,它将实例化和渲染适当的视图,然后协调它们。在复杂的布局中,协调视图与模型和集合并不是一件容易的事情。

应在此处实现用例的业务逻辑。子应用控制器实现了一个中介者模式,允许其他基本对象,如视图和模型保持简单和松散耦合。

由于松散耦合的原因,视图不应直接调用其他视图的方法或事件。相反,视图触发事件,控制器处理事件并在必要时编排视图的行为。注意视图是如何隔离的,仅处理其拥有的 DOM 部分,并在需要通信时触发事件。

联系人应用

在本书中,我们将开发一个简单的联系人应用来演示如何根据本书中解释的原则开发 Backbone 应用。该应用应能够列出所有可用的联系人,并通过 RESTful API 提供显示和编辑它们的机制。

应用程序在浏览器中加载应用程序基础设施并调用其上的start()方法时启动。它将引导所有通用组件,然后实例化子应用中所有可用的路由器:

联系人应用

图 1.4. 应用程序实例化子应用中所有可用的路由器

// app.js
var App = {
  Models: {},
  Collections: {},
  Routers: {},
  start() {
    // Initialize all available routes
    _.each(_.values(this.Routers), function(Router) {
      new Router();
    });

    // Create a global router to enable sub-applications to
    // redirect to other urls
    App.router = new DefaultRouter();
    Backbone.history.start();
  }
}

子应用的入口点由其路由给出,理想情况下这些路由共享相同的命名空间。例如,在联系人子应用中,所有路由都以contacts/前缀开始:

  • Contacts:列出所有可用的联系人

  • contacts/new:显示一个表单来创建新的联系人

  • contacts/view/:id:根据其 ID 显示发票

  • contacts/edit/:id:显示一个表单来编辑联系人

子应用应将其路由器注册到App.Routers全局对象中以便初始化。对于联系人子应用,ContactsRouter负责这项工作:

// apps/contacts/router.js
'use strict';

App.Routers = App.Routers || {};

class ContactsRouter extends Backbone.Router {
  constructor(options) {
    super(options);
    this.routes = {
      'contacts': 'showContactList',
      'contacts/page/:page': 'showContactList',
      'contacts/new': 'createContact',
      'contacts/view/:id': 'showContact',
      'contacts/edit/:id': 'editContact'
    };
    this._bindRoutes();
  }

  showContactList(page) {
    // Page should be a postive number grater than 0
    page = page || 1;
    page = page > 0 ? page : 1;

    var app = this.startApp();
    app.showContactList(page);
  }

  createContact() {
    var app = this.startApp();
    app.showNewContactForm();
  }

  showContact(contactId) {
    var app = this.startApp();
    app.showContactById(contactId);
  }

  editContact(contactId) {
    var app = this.startApp();
    app.showContactEditorById(contactId);
  }

  startApp() {
    return App.startSubApplication(ContactsApp);
  }
}

// Register the router to be initialized by the infrastructure
// Application
App.Routers.ContactsRouter = ContactsRouter;

当用户将其浏览器指向这些路由之一时,会触发一个路由处理器。处理器函数解析 URL 并将请求委派给子应用门面:

联系人应用

图 1.5. 路由委派到子应用门面

App对象中的startSubApplication()方法启动一个新的子应用并关闭在给定时间运行的任何其他子应用,这有助于在用户的浏览器中释放资源:

var App = {
  // ...
  // Only a subapplication can be running at once, destroy any
  // current running subapplication and start the asked one
  startSubApplication(SubApplication) {
    // Do not run the same subapplication twice
    if (this.currentSubapp &&
        this.currentSubapp instanceof SubApplication) {
      return this.currentSubapp;
    }

    // Destroy any previous subapplication if we can
    if (this.currentSubapp && this.currentSubapp.destroy) {
      this.currentSubapp.destroy();
    }

    // Run subapplication
    this.currentSubapp = new SubApplication({
      region: App.mainRegion
    });
    return this.currentSubapp;
  },
}

小贴士

下载示例代码

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

App.mainRegion 属性是一个指向页面中 DOM 元素的 Region 对象实例;区域对于在 DOM 的包含区域中渲染视图非常有用。我们将在第二章中了解更多关于这个对象的信息,管理视图

当子应用启动时,将对其调用外观方法来处理用户请求。外观的责任是从 RESTful API 获取必要的数据并将数据传递给控制器:

联系人应用程序

图 1.6. 外观责任

// apps/contacts/app.js
'use strict';

class ContactsApp {
  constructor(options) {
    this.region = options.region;
  }

  showContactList() {
    App.trigger('loading:start');
    App.trigger('app:contacts:started');

    new ContactCollection().fetch({
      success: (collection) => {
        // Show the contact list subapplication if
        // the list can be fetched
        this.showList(collection);
        App.trigger('loading:stop');
      },
      fail: (collection, response) => {
        // Show error message if something goes wrong
        App.trigger('loading:stop');
        App.trigger('server:error', response);
      }
    });
  }

  showNewContactForm() {
    App.trigger('app:contacts:new:started');
    this.showEditor(new Contact());
  }

  showContactEditorById(contactId) {
    App.trigger('loading:start');
    App.trigger('app:contacts:started');

    new Contact({id: contactId}).fetch({
      success: (model) => {
        this.showEditor(model);
        App.trigger('loading:stop');
      },
      fail: (collection, response) => {
        App.trigger('loading:stop');
        App.trigger('server:error', response);
      }
    });
  }

  showContactById(contactId) {
    App.trigger('loading:start');
    App.trigger('app:contacts:started');

    new Contact({id: contactId}).fetch({
      success: (model) => {
        this.showViewer(model);
        App.trigger('loading:stop');
      },
      fail: (collection, response) => {
        App.trigger('loading:stop');
        App.trigger('server:error', response);
      }
    });
  }

  showList(contacts) {
    var contactList = this.startController(ContactList);
    contactList.showList(contacts);
  }

  showEditor(contact) {
    var contactEditor = this.startController(ContactEditor);
    contactEditor.showEditor(contact);
  }

  showViewer(contact) {
    var contactViewer = this.startController(ContactViewer);
    contactViewer.showContact(contact);
  }

  startController(Controller) {
    if (this.currentController &&
        this.currentController instanceof Controller) {
      return this.currentController;
    }

    if (this.currentController &&
        this.currentController.destroy) {
      this.currentController.destroy();
    }

    this.currentController = new Controller({
      region: this.region
    });
    return this.currentController;
  }
}

外观对象接收一个区域对象作为参数,以指示子应用应在何处渲染。Region 对象将在第二章中详细解释,管理视图

当外观从 RESTful 服务器获取数据时,会在 App 对象上发出 loading:start 事件,以便我们可以向用户显示正在进行的加载视图。当加载完成后,它创建并使用一个知道如何处理模型或获取的集合的控制器。

当控制器被调用时,业务逻辑开始,它将为请求渲染所有必要的视图并展示给用户,然后它将监听视图中的用户交互:

联系人应用程序

图 1.7. 控制器创建必要的视图

对于 ContactList 控制器,以下是一段非常简单的代码:

// apps/contacts/contactLst.js
class ContactList {
  constructor(options) {
    // Region where the application will be placed
    this.region = options.region;

    // Allow subapplication to listen and trigger events,
    // useful for subapplication wide events
    _.extend(this, Backbone.Events);
  }

  showList(contacts) {
    // Create the views
    var layout = new ContactListLayout();
    var actionBar = new ContactListActionBar();
    var contactList = new ContactListView({collection: contacts});

    // Show the views
    this.region.show(layout);
    layout.getRegion('actions').show(actionBar);
    layout.getRegion('list').show(contactList);

    this.listenTo(contactList, 'item:contact:delete',
      this.deleteContact);
  }

  createContact() {
    App.router.navigate('contacts/new', true);
  }

  deleteContact(view, contact) {
    let message = 'The contact will be deleted';
    App.askConfirmation(message, (isConfirm) => {
      if (isConfirm) {
        contact.destroy({
          success() {
            App.notifySuccess('Contact was deleted');
          },
          error() {
            App.notifyError('Ooops... Something went wrong');
          }
        });
      }
    });
  }

  // Close any active view and remove event listeners
  // to prevent zombie functions
  destroy() {
    this.region.remove();
    this.stopListening();
  }
}

处理请求的功能非常简单,并且所有其他控制器都遵循相同的模式,如下所示:

  • 它使用传递的模型或集合创建所有必要的视图

  • 它在 DOM 的一个区域中渲染视图

  • 它监听视图中的事件

如果你完全不理解区域和布局的含义,不要担心,我将在第二章中详细解释这些对象的实现,管理视图。在这里,重要的是之前描述的算法:

联系人应用程序

图 1.8. ContactList 控制器结果

如上图所示,联系人列表为列表中的每个联系人显示一组卡片。用户可以通过点击删除按钮来删除联系人。当发生这种情况时,会触发 contact:delete 事件,控制器正在监听该事件并使用 deleteContact() 方法来处理该事件:

  deleteContact(view, contact) {
    let message = 'The contact will be deleted';
    App.askConfirmation(message, (isConfirm) => {
      if (isConfirm) {
        contact.destroy({
          success() {
            App.notifySuccess('Contact was deleted');
          },
          error() {
            App.notifyError('Ooops... Something went wrong');
          }
        });
      }
    });
  }

处理器很容易理解,它使用基础设施应用中的 askConfirmation() 方法来请求用户确认。如果用户确认删除,则联系人将被销毁。基础设施应用提供了两种方法向用户显示通知:notifySuccess()notifyError()

这些 App 方法的一个优点是控制器不需要了解确认和通知机制的具体细节。从控制器的角度来看,它只是正常工作。

请求确认的方法可以是一个简单的confirm()调用,如下所示:

// app.js
var App = {
  // ...
  askConfirmation(message, callback) {
    var isConfirm = confirm(message);
    callback(isConfirm);
  }
};

然而,在现代 Web 应用程序中,使用普通的confirm()函数并不是请求确认的最佳方式。相反,我们可以显示一个 Bootstrap 对话框或使用可用的库。为了简单起见,我们将使用漂亮的 JavaScript SweetAlert库;然而,你可以使用任何你想要的:

// app.js
var App = {
  // ...

  askConfirmation(message, callback) {
    var options = {
      title: 'Are you sure?',
      type: 'warning',
      text: message,
      showCancelButton: true,
      confirmButtonText: 'Yes, do it!',
      confirmButtonColor: '#5cb85c',
      cancelButtonText: 'No'
    };

    // Show the message
    swal(options, function(isConfirm) {
      callback(isConfirm);
    });
  }
};

联系人应用程序

图 1.9. 使用 SweetAlert 进行确认消息

我们可以以类似的方式实现通知方法。我们将使用 JavaScript noty库;然而,你可以使用任何你想要的:

// app.js
var App = {
  // ...

    notifySuccess(message) {
    new noty({
      text: message,
      layout: 'topRight',
      theme: 'relax',
      type: 'success',
      timeout: 3000 // close automatically
    });
  },

  notifyError(message) {
    new noty({
      text: message,
      layout: 'topRight',
      theme: 'relax',
      type: 'error',
      timeout: 3000 // close automatically
    });
  }
};	

联系人应用程序

图 1.10. 使用 noty 显示通知消息

这就是你可以实现一个健壮且可维护的 Backbone 应用程序的方法;请访问本书的 GitHub 仓库,以查看应用程序的完整代码。由于我们将在第二章中详细讨论,本章没有涵盖视图。

文件组织

当你与 MVC 框架一起工作时,文件组织是微不足道的。然而,Backbone 不是一个 MVC 框架,因此,自己带来文件结构是规则。你可以按照这些路径组织代码:

  • apps/:这个目录是模块或子应用程序所在的位置。所有子应用程序都应该在这个路径上

  • Components/:这些是多个子应用程序需要或在使用公共布局时作为面包屑组件使用的通用组件

  • core/:在这个路径下,我们可以放置所有核心功能,如工具、助手、适配器等

  • vendor/:在供应商下,你可以放置所有第三方库;这里你可以放置 Backbone 及其依赖项。

  • app.js:这是应用程序的入口点,从index.html加载

  • 子应用程序可以有自己的文件结构,因为它们是小的 Backbone 应用程序。

  • models/:这定义了模型和集合

  • app.js:这是从index.html加载的应用程序外观,由路由器调用

  • router.js:这是由根应用程序在引导过程中实例化的应用程序路由器

  • contactList.jscontactEditor.jscontactViewer.js:这些是应用程序的控制器

对于一个联系人应用程序,代码组织可以像以下所示:

文件组织

图 1.11. 文件结构

摘要

我们首先以一般的方式描述了 Backbone 应用程序的工作方式。它描述了两个主要部分:根应用程序和子应用程序。根应用程序为其他较小且专注的应用程序提供公共基础设施,我们称之为子应用程序。

子应用应该与其他子应用松散耦合,并拥有自己的资源,例如视图、控制器、路由器等。子应用通过一个由Backbone.Events驱动的事件总线与基础设施应用进行通信,管理系统的一部分,具有明确的业务价值。

用户通过子应用渲染的视图与应用程序进行交互。子应用控制器协调视图、模型和集合之间的交互,并拥有用例的业务逻辑。

最后,文件系统组织解释了放置文件的正确位置,以保持项目整洁和有序。这种组织不遵循 MVC 模式;然而,它强大且简单。它将模块的所有必要代码封装在单个路径(子应用路径)中,而不是将所有代码分散在多个路径上。

以这种方式,Backbone 应用程序的结构已被证明是健壮的,这一点可以从遵循(或多或少)此处公开的原则的几个开源应用程序(如 TodoMVC)中得到证明。由于职责分离,这有助于提高代码的可测试性,因此每个对象都可以单独进行测试。

大型 Backbone 应用程序通常建立在 Backbone Marionette 之上,因为它减少了样板代码;然而,Marionette 使用自己的约定来工作。如果您对使用自己的约定感到满意,那么您将很高兴在 Backbone 之上使用 Marionette。

然而,如果您喜欢按照自己的方式做事的自由,您可能更喜欢纯 Backbone,并创建自己的实用工具和类。

在下一章中,我将向您展示如何管理和组织视图,简化复杂的布局,并识别视图的常见用法。您将构建通用视图,这些视图将对所有项目都有用,并且可以忘记render()方法的实现。

第二章 管理视图

正如我们在上一章所看到的,Backbone 视图负责管理用户与应用程序之间的DOM(文档对象模型)交互。一个典型的 Backbone 应用程序由许多具有非常特定行为的视图组成;例如,我们可以有一个用于显示联系数据的视图,另一个用于编辑它。正如你所知,渲染单个视图是一个简单的任务,但协调多个视图的复杂布局可能会很痛苦。

开发更好的策略来处理复杂的视图交互,以使项目更容易维护和开发,这一点很重要。如果你不重视视图的组织,你可能会得到一个脏 DOM 和混乱的代码,这使得引入新功能或更改现有功能变得困难。

正如我们在上一章所做的那样,我们将通过识别常见的视图用例来分离责任,然后我们将学习如何通过使用小视图来组合布局。

在本章中,你将学习如何:

  • 识别常见的视图类型

  • 实现可重用的视图以处理常见类型

  • 使用可重用的视图类型轻松组合复杂视图

识别视图类型

在使用 Backbone 一段时间后,你可以看到视图的常见用例出现;它们如此常见,以至于可以用于不同的无关项目。这些视图可以被提取出来,并且如果它们被正确构建,可以在任何项目中使用。查看 Backbone 文档,视图不实现默认的渲染方法,所以这里的技巧是为不同的用例定义一组具有默认渲染方法的视图:

  • 带有模型的视图 – 使用模型数据渲染模板。

  • 带有集合的视图 – 使用集合数据渲染视图集合;它应该在集合更改时自动更新视图列表。

  • 区域 – 这个视图像一个容器;它指向特定的 DOM 节点,并管理该节点的内。它用于渲染其他视图。

  • 布局 – 布局由一个或多个区域组成;它定义了一个 HTML 结构来组织区域将放置的位置。

图 2.1 显示了一个应用程序的简单线框;正如你所见,这是一个在 Web 应用程序中非常常见的布局,对于理解常见的视图类型之间的关系非常有用。

识别视图类型

图 2.1:视图、区域和布局关系

通过这些基础视图,你将拥有一个简单但强大的框架来管理你的视图,因此你不再需要实现render()方法。

ModelView

最简单的实现是渲染单个模型;这是一个非常直接的算法。从模型中提取数据,并使用模板引擎使用数据实际渲染;最后,将结果附加到 DOM 中:

class MyView extends Backbone.View {
  constructor(options) {
    super(options);
    template: _.template($("#my-template").html());
  }

  render() {
       var data = this.model.toJSON();
    var renderedHtml = this.template(data);
    this.$el.html(renderedHtml);
    return this;
  }
}

在下面的代码中,我们可以识别渲染视图的五个步骤。

  1. 获取模板:

    $("#my-template").html()
    
    
  2. 编译模板:

    _.template($("#my-template").html())
    
    
  3. 从模型获取数据:

    var data = this.model.toJSON()
    
    
  4. 使用模型数据渲染模板:

    renderedHtml = this.template(data)
    
    
  5. 将结果放入 DOM 中:

    this.$el.html(renderedHtml)
    
    

注意,我们在render()方法中返回这个对象;这对于链式调用很有用。这些步骤适用于所有需要渲染模型的视图,因此我们可以将这种行为提取到一个新的视图类型中。这个视图将拥有通用的算法,并允许特定的部分进行扩展:

class ModelView extends Backbone.View {
  render() {
    var data = this.serializeData();

    // Compile the template
    var renderedHtml = _.template(this.template, data);

    // Put the result in the DOM
    this.$el.html(renderedHtml);
    return this;
  }

  serializeData() {
    var data;

    // Serialize only if a model is present
    if (this.model) {
      data = this.model.toJSON();
    }

    return data;
  }
});

模型数据现在是在一个单独的方法serializeData()中完成的,这允许我们以不同的方式向视图提供数据;然而,它实现了一个在大多数情况下需要的默认行为。

现在模板使用 Underscore 模板引擎在过程中编译,因此您必须提供模板文本,让它完成其余工作。但这使得视图与模板引擎高度耦合;如果您需要使用不同的模板引擎,如 Handlebars,怎么办?

我们可以在serializedData()方法中使用与之前相同的策略,并将此行为放入一个单独的方法中。所有模板引擎都需要两样东西:模板文本和数据。模板文本可以通过 jQuery 选择器、字符串变量、预编译模板等方式获得。因此,我们将将其留给最终实现:

  class ModelView extends Backbone.View {  
    // Compile template with underscore templates. This method
    // can be redefined to implemente another template engine
    // like Handlebars or Jade
    compileTemplate() {
      var $el = $(this.template);
      return _.template($el.html());
    }

    // ...
  }

而且,就像我们对serializedData()所做的那样,实现了一个默认行为。

class ModelView extends Backbone.View {
  render() {
    // Get JSON representation of the model
    var data = this.serializeData();
    var renderedHtml;

    // If template is a function assume that is a compiled
    // template, if not assume that is a CSS selector where
    // the template is defined and is compatible with
    // underscore templates
    if (_.isFunction(this.template)) {
      renderedHtml = this.template(data);
    } else if (_.isString(this.template)) {
      var compiledTemplate = this.compileTemplate();
      renderedHtml = compiledTemplate(data);
    }

    this.$el.html(renderedHtml);
    return this;
  }

  // …
}

在这种情况下,template属性可以是函数或字符串。如果使用字符串,则默认行为将使用 Underscore 模板引擎。如果使用函数,则函数为我们提供了使用任何模板引擎的自由。

如果我们想在视图中渲染模型,我们可以这样做:

var contact = new Backbone.Model({
  name: 'John Doe',
  phone: '5555555'
});

var ContactView extends ModelView {
  constructor(options) {
    super(options);
    this.template = $('#contact-template').html();
  }

  // ... anything else like event handlers
}

var contactView = new ContactView({
  model: contact,
  el: 'body'
});
contactView.render();

您只需指定模板和模型,然后就可以完成了!

CollectionView

Backbone 集合由许多模型组成,因此当渲染集合时,我们需要渲染Views的列表:

class CollectionView extends Backbone.View {
  render() {
    // Render a view for each model in the collection
    var html = this.collection.map(model => {
      var view = new this.modelView(model);
      view.render();
      return view.$el;
    });

    // Put the rendered items in the DOM
    this.$el.html(html);
    return this;
  }
}

注意,modelView属性应该是视图类;它可以是上一节中的ModelView类或任何其他视图。看看对于集合中的每个模型,它如何实例化和渲染一个带有当前模型的this.modelView。结果,html变量将包含所有渲染视图的数组。最后,html数组可以轻松地附加到$el元素上。

有关如何使用CollectionView的示例,请参阅以下示例:

class MyModelView extends ModelView {
  // …
)

class MyView extends CollectionView {
  constructor(options) {
    super(options);
    this.el = '#main';
    this.modelView = MyModelView;
  }
}

var view = new MyView({collection: someCollection});
view.render();

这段代码将完成这项工作,它将为someCollection对象中的每个模型渲染一个MyModelView,并将结果列表放入#main元素中。

然而,如果您向集合中添加模型或删除它们,视图将不会更新。这不是期望的行为。当添加模型时,它应该在列表末尾添加一个新的视图;如果从集合中删除模型,则应删除与该模型关联的视图。

一种快速且简单的方法是在集合的每次更改时重新渲染整个视图,以同步集合更改和视图,但这种方法非常低效,因为当重新渲染不需要更改的视图时,会消耗客户端资源。应该存在更好的方法。

添加新模型

当模型被添加到集合中时,会触发一个 add 事件;我们可以创建一个事件处理器来更新视图:

class CollectionView extends Backbone.View {
  initialize() {
    this.listenTo(this.collection, 'add', this.addModel);
  }

  // ...
}

当调用 addModel 方法时,它应该创建并渲染一个新的视图,包含添加的模型的数据,并将其放在列表的末尾。

var CollectionView = Backbone.View.extend({
  // ...
  // Render a model when is added to the collection
  modelAdded(model) {
    var view = this.renderModel(model);
    this.$el.append(view.$el);
  }

  render() {
    // Render a view for each model in the collection
    var html = this.collection.map(model => {
      var view = this.renderModel(model);
      return view.$el;
    });

    // Put the rendered items in the DOM
    this.$el.html(html);
    return this;
  }

  renderModel(model) {
    // Create a new view instance, modelView should be
    // redefined as a subclass of Backbone.View
    var view = new this.modelView({model: model});

    // Keep track of which view belongs to a model
    this.children[model.cid] = view;

    // Re-trigger all events in the children views, so that
    // you can listen events of the children views from the
    // collection view
    this.listenTo(view, 'all', eventName => {
      this.trigger('item:' + eventName, view, model);
    });

    view.render();
    return view;
  }
}

添加了 renderModel() 方法,因为 render()modelAdded() 两个方法都需要以相同的方式渲染模型。这里应用了 DRY 原则。

当一个子视图被渲染时,监听给定视图的所有事件是有用的,这样我们就可以监听来自集合的子事件。

var myCollectionView = new CollectionView({...});

myCollectionView.on('item:does:something', (view, model) => {
  // Do something with the model or the view
});

我们的事件处理器非常简单;它使用 renderModel() 方法渲染添加的模型,为视图中的任何事件附加事件处理器,并将结果追加到 DOM 元素的末尾。

删除模型

当模型从集合中移除时,包含该模型的视图应该从 DOM 中删除,以反映集合的当前状态。考虑一个用于 removed 事件的处理器:

function modelRemoved(model) {
  var view = getViewForModel(model); // Find view for this model
  view.destroy();
}

我们如何获取与模型关联的视图?我们目前没有简单的方法来做这件事。为了使其更容易,我们可以跟踪模型-视图关联;这样,获取视图就非常容易:

class CollectionView extends Backbone.View {
  initialize() {
    this.children = {};
    this.listenTo(this.collection, 'add', this.modelAdded);
    this.listenTo(this.collection, 'remove', this.modelRemoved);
  }

  // ...

  // Close view of model when is removed from the collection
  modelRemoved(model) {
    var view = this.children[model.cid];

    if (view) {
      view.remove();
      this.children[model.cid] = undefined;
    }
  }

  // ...

  renderModel(model) {
    // Create a new view instance, modelView should be
    // redefined as a subclass of Backbone.View
    var view = new this.modelView({model: model});

    // Keep track of which view belongs to a model
    this.children[model.cid] = view;

    // Re-trigger all events in the children views, so that
    // you can listen events of the children views from the
    // collection view
    this.listenTo(view, 'all', eventName => {
      this.trigger('item:' + eventName, view, model);
    });

    view.render();
    return view;
  }
}

在渲染时间,我们在 this.children 哈希表中存储视图的引用,以便将来参考,因为 render()modelAdded() 使用相同的方法进行渲染;这个更改在一个地方完成,即 renderModel() 方法。

当一个模型被移除时,modelRemoved() 方法可以轻松找到视图,并通过调用标准的 remove() 方法以及销毁 this.children 哈希表中的引用来移除它。

销毁视图

当一个 CollectionView 被销毁时,它应该移除所有子视图以正确清理内存。这应该通过扩展 remove() 方法来完成:

class CollectionView extends Backbone.View {
  // ...

  // Close view of model when is removed from the collection
  modelRemoved(model) {
    if (!model) return;

    var view = this.children[model.cid];
    this.closeChildView(view);
  }

  // ...

  // Called to close the collection view, should close
  // itself and all the live childrens
  remove() {
    Backbone.View.prototype.remove.call(this);
    this.closeChildren();
  }

  // Close all the live childrens
  closeChildren() {
    var children = this.children || {};

    // Use the arrow function to bind correctly the "this" object
    _.each(children, child => this.closeChildView(child));
  }

  closeChildView(view) {
    // Ignore if view is not valid
    if (!view) return;

    // Call the remove function only if available
    if (_.isFunction(view.remove)) {
      view.remove();
    }

    // Remove event handlers for the view
    this.stopListening(view);

    // Stop tracking the model-view relationship for the
    // closed view
    if (view.model) {
      this.children[view.model.cid] = undefined;
    }
  }
}

现在,当视图需要被移除时,它将执行此操作并清理所有子视图。

重置集合

当一个集合被清除时,视图应该重新渲染整个集合,因为所有项目都被替换了:

class CollectionView extends Backbone.View {
  initialize() {
    // ...
    this.listenTo(this.collection, 'reset', this.render);
  }

  // ...
}

这可以工作,但之前的视图也应该关闭;正如我们在上一节中看到的,最佳做法是在渲染方法中完成它:

class CollectionView extends Backbone.View.extend({
  // ...
  render () {
    // Clean up any previous elements rendered
    this.closeChildren();

    // Render a view for each model in the collection
    var html = this.collection.map(model => {
      var view = this.renderModel(model);
      return view.$el;
    });

    // Put the rendered items in the DOM
    this.$el.html(html);
    return this;
  }

  // ...
}

如果一个视图还没有项目,closeChildren() 方法将不会做任何事情。

区域

一个常见的用例是在一个常见的 DOM 元素之间切换视图;这可以通过在两个视图中使用相同的 el 属性并在你想要看到的视图中调用 render() 方法来实现。但这种方式不会清理内存和事件绑定,因为两个视图都将保留在内存中,即使它们不在 DOM 中。

一个特别有用的场景是在需要切换子应用程序时,因为子应用程序通常在同一个 DOM 元素中渲染。例如,当用户想要编辑联系信息时,他/她将点击一个 编辑 按钮,当前视图将被替换为编辑表单。

区域

图 2.2:使用区域交换视图

要在视图之间切换,可以使用Region类,如下所示:

var mainRegion = new Region({el: '#main'});
var contactViewer = new ContactViewer({model: contact});

contactViewer.on('edit:contact', function(contact) {
  var editContact = new EditContactView({ model: contact });
  mainRegion.show(editContact);
});

mainRegion.show(contactViewer);

Region对象指向一个现有的 DOM 元素;要在该元素上显示一个视图,应在Region对象上调用show()方法。请注意,视图没有设置el属性,因为区域会将元素放入 DOM 中,而不是视图本身。这给我们带来了一个额外功能,视图不再需要设置el属性,并且可以在任何可用的区域上渲染。

可以使用以下代码实现一个基本的区域管理器:

class Region {
  constructor(options) {
    this.el = options.el;
  }

  // Closes any active view and render a new one
  show(view) {
    this.closeView(this.currentView);
    this.currentView = view;
    this.openView(view);
  }

  closeView(view) {
    // Only remove the view when the remove function
    // is available
    if (view && view.remove) {
      view.remove();
    }
  }

  openView(view) {
    // Be sure that this.$el exists
    this.ensureEl();

    // Render the view on the this.$el element
    view.render();
    this.$el.html(view.el);
  }

  // Create the this.$el attribute if do not exists
  ensureEl() {
    if (this.$el) return;
    this.$el = $(this.el);
  }

  // Close the Region and any view on it
  remove() {
    this.closeView(this.currentView);
  }
}

当调用show()方法时,如果存在当前视图,则关闭它,然后分配一个新的currentView并打开视图。当一个视图打开时,Region确保存在$el属性,首先调用ensureEl()方法。然后发生有趣的部分:

view.render();
this.$el.html(view.el);

Backbone 文档解释了视图的工作方式:

所有视图始终都有一个 DOM 元素(el 属性),无论它们是否已经插入到页面中。以这种方式,视图可以在任何时候渲染,并一次性插入到 DOM 中 [...]

这里发生的就是这个:我们首先在内存中渲染视图,调用view.render(),然后将结果插入由 Region 的$el属性指向的 DOM 中。

还实现了remove()方法,以便使区域与 Backbone Views 兼容。当一个区域被移除时,它需要关闭拥有的视图,因此这使我们能够轻松地做到这一点。

假设我们有一个拥有许多视图的CollectionView的区域;当在区域上调用remove()方法时,它将调用CollectionView上的remove()方法,该方法将调用每个子视图的remove()方法。

布局

布局用于定义结构;其目的是创建一个骨架,其他视图将放置在其中。一个常见的 Web 应用程序布局由一个页眉、一个侧边栏、页脚和一个公共区域组成,例如。使用布局,我们可以以声明的方式定义这些元素将放置的区域。在布局渲染后,我们可以在这些视图上显示我们想要的视图。

在下面的图中,我们可以看到一个布局;这些元素中的每一个都是一个区域,因此应该创建其他视图来填充这些区域——例如,为头部区域创建一个 HeaderView 类:

布局

图 2.3:一个常见的 Web 应用程序布局

这个示例的实现可能如下所示:

var AppLayout = new Layout({
  template: $('#app-layout').html(),
  regions: {
    header: 'header',
    sicebar: '#sidebar',
    footer: 'footer',
    main: '#main'
  }
});

Var layout = new AppLayout({ el: 'body' });
var header = new HeaderView();

layout.render();
layout.getRegion('header').show(header);

看看区域是如何声明的:一对名称和一个选择器。布局将通过getRegion()方法公开区域,该方法接收区域的名称并返回一个Region类的实例,可以在前一个部分中看到其用法。

还要注意,布局需要定义一个template属性;它应遵循在ModelView实现中使用的相同规则。该模板将定义区域将被指向的 HTML。

下面的代码展示了如何创建一个布局视图:

class Layout extends ModelView {
  render() {
    // Clean up any rendered DOM
    this.closeRegions();

    // Render the layout template
    var result = ModelView.prototype.render.call(this);

    // Creand and expose the configurated regions
    this.configureRegions();
    return result;
  }

  configureRegions() {
    var regionDefinitions = this.regions || {};

    if (!this._regions) {
      this._regions = {};
    }

    // Create the configurated regions and save a reference
    // in the this._regions attribute
    _.each(regionDefinitions, (selector, name) => {
      let $el = this.$(selector);
      this._regions[name] = new Region({el: $el});
    });
  }

  // Get a Region instance for a named region
  getRegion(regionName) {
    // Ensure that regions is a valid object
    var regions = this._regions || {};
    return regions[regionName];
  }

  // Close the layout and all the regions on it
  remove(options) {
    ModelView.prototype.remove.call(this, options);
    this.closeRegions();
  }

  closeRegions() {
    var regions = this._regions || {};

    // Close each active region
    _.each(regions, region => {
      if (region && region.remove) region.remove();
    });
  }
}

布局直接扩展自ModelView,因此render()方法的行为类似于ModelView,但在渲染后扩展其行为,创建必要的区域。configurateRegions()方法为regions属性上声明的每个区域创建一个区域。区域名称与Region实例之间的关联存储在_regions属性中,以供未来引用。

当布局被移除时,它应该关闭任何打开的区域,以便所有资源都能干净地释放。这是closeRegions()方法的工作;它遍历所有使用configurateRegions()创建的区域,并对每个区域调用remove()方法。

由于区域存储在名为_regions的私有属性中,因此需要一个访问区域的方法;getRegion()方法返回与区域名称关联的区域实例。

将所有内容整合在一起

我们已经创建了四种简单但强大的新视图类型,可以在项目中轻松使用,最大限度地减少工作量并减少冗余代码。在下一节中,我们将把我们的联系人项目转换成一个更复杂的项目,使用在这里学到的知识:

将所有内容整合在一起

图 2.4:应用程序根布局

我们的应用程序将有一个包含三个部分的根布局:

  • 页眉 – 将包含导航栏

  • 页脚 – 版权信息

  • 主视图 – 此元素根据需要显示所有子应用程序

这个布局描述不是布局对象;相反,它描述了 HTML 根内容:

<!doctype html>
<html lang="">
  <head>
    <meta charset="utf-8">
    <title>mastering backbone design</title>
    <meta name="description" content="">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <link rel="shortcut icon" href="/favicon.ico">
    <link rel="apple-touch-icon" href="/apple-touch-icon.png">
    <!-- Place favicon.ico and apple-touch-icon.png in the root directory -->

    <link rel="stylesheet" href="css/bootstrap.min.css">
    <link rel="stylesheet" href="css/sweetalert.css">
    <link rel="stylesheet" href="css/pnotify.custom.min.css">
    <link rel="stylesheet" href="css/font-awesome.min.css">
    <link rel="stylesheet" href="css/main.css">
  </head>
  <body>
    <!--[if lt IE 10]>
      <p class="browsehappy">You are using an <strong>outdated</strong> browser. Please <a href="http://browsehappy.com/">upgrade your browser</a> to improve your experience.</p>
    <![endif]-->

    <nav class="navbar">
      <div class="container">
        <div class="navbar-header">
          <a class="navbar-brand" href="#">
            Mastering Backbone.js
          </a>
        </div>
      </div>
    </nav>

    <div id="main" class="container"></div>

    <script src="img/jquery-2.1.4.min.js"></script>
    <script src="img/bootstrap.min.js"></script>
    <script src="img/sweetalert.min.js"></script>
    <script src="img/pnotify.custom.min.js"></script>
    <script src="img/underscore-min.js"></script>
    <script src="img/backbone-min.js"></script>

  </body>
</html>

在这个根布局中,页眉和页脚非常简单,因此没有必要为它们创建单独的视图。有一个main div,它将成为我们的主要区域,用于整个应用程序。

显示列表

ContactList子应用程序负责在 DOM 中渲染集合。因此,ContactList对象将实例化必要的视图:

// apps/contacts/contactList.js
showList(contacts) {
  // Create the views
  var layout = new ContactListLayout();
  var actionBar = new ContactListActionBar();
  var contactList = new ContactListView({collection: contacts});

  // Show the views
  this.region.show(layout);
  layout.getRegion('actions').show(actionBar);
  layout.getRegion('list').show(contactList);

  this.listenTo(contactList, 'item:contact:delete',
    this.deleteContact);
}

创建一个布局,将CollectionView放入其中;布局模板有一个div,其contact-list-layout ID 将用作目标区域:

// index.html
<script id="contact-list-layout" type="text/template">
  <div class="actions-bar-container"></div>
  <div class="list-container"></div>
  <div class="footer text-muted">
    © 2015\. <a href="#">Mastering Backbone.js</a> by <a href="https://twitter.com/abieealejandro" target="_blank">Abiee Alejandro</a>
  </div>
</script>

布局代码非常简单:

// apps/contacts/contactList.js
class ContactListLayout extends Layout {
  constructor(options) {
    super(options);
    this.template = '#contact-list-layout';
    this.regions = {
      actions: '.actions-bar-container',
      list: '.list-container'
    };
  }

  get className() {
    return 'row page-container';
  }
}

展示联系人群的视图非常简单明了,因为它只需要指定modelView属性:

// apps/contacts/contactList.js
class ContactListView extends CollectionView {
  constructor(options) {
    super(options);
    this.modelView = ContactListItemView;
  }

  get className() {
    return 'contact-list';
  }
}

联系人卡片模板显示联系人姓名、电话号码、电子邮件及其社交网络:

// index.html
<script id="contact-list-item" type="text/template">
  <div class="box thumbnail">
    <div class="photo">
      <img src="img/250x250"
        alt="Contact photo" />
      <div class="action-bar clearfix">
        <div class="action-buttons pull-right">
          <button id="delete"
            class="btn btn-danger btn-xs">delete</button>
          <button id="view"
            class="btn btn-primary btn-xs">view</button>
        </div>
      </div>
    </div>
    <div class="caption-container">
      <div class="caption">
        <h5><%= name %></h5>
        <% if (phone) { %>
          <p class="phone no-margin"><%= phone %></p>
        <% } %>
        <% if (email) { %>
          <p class="email no-margin"><%= email %></p>
        <% } %>
        <div class="bottom">
          <ul class="social-networks">
            <% if (facebook) { %>
            <li>
              <a href="<%= facebook %>" title="Google Drive">
                <i class="fa fa-facebook"></i>
              </a>
            </li>
            <% } %>
            <% if (twitter) { %>
            <li>
              <a href="<%= twitter %>" title="Twitter">
                <i class="fa fa-twitter"></i>
              </a>
            </li>
            <% } %>
            <% if (google) { %>
            <li>
              <a href="<%= google %>" title="Google Drive">
                <i class="fa fa-google-plus"></i>
              </a>
            </li>
            <% } %>
            <% if (github) { %>
            <li>
              <a href="<%= github %>" title="Github">
                <i class="fa fa-github"></i>
              </a>
            </li>
            <% } %>
          </ul>
        </div>
      </div>
    </div>
  </div>
</script>

ContactListItemView类应处理删除和视图事件:

// apps/contacts/contactList.js
class ContactListItemView extends ModelView {
  constructor(options) {
    super(options);
    this.template = '#contact-list-item';
  }

  get className() {
    return 'col-xs-12 col-sm-6 col-md-3';
  }

  get events() {
    return {
      'click #delete': 'deleteContact',
      'click #view': 'viewContact'
    };
  }

  initialize(options) {
    this.listenTo(options.model, 'change', this.render);
  }

  deleteContact() {
    this.trigger('contact:delete', this.model);
  }

  viewContact() {
    var contactId = this.model.get('id');
    App.router.navigate(`contacts/view/${contactId}`, true);
  }
}

当用户点击删除按钮时,视图触发contact:delete事件,并让控制器处理删除过程。因为视图按钮比删除按钮简单,所以我们可以从视图重定向用户到联系人列表;请注意,将这个非常简单的任务委托给控制器将增加更多开销而没有好处。

操作栏允许用户添加新用户。

<script id="contact-list-action-bar" type="text/template">
  <button class="btn btn-lg btn-success">
    Create a new contact
  </button>
</script>

ContactListActionBar仅渲染其模板并等待对其按钮的点击。

// apps/contacts/contactList.js
class ContactListActionBar extends ModelView {
  constructor(options) {
    super(options);
    this.template = '#contact-list-action-bar';
  }

  get className() {
    return 'options-bar col-xs-12';
  }

  get events() {
    return {
      'click button': 'createContact'
    };
  }

  createContact() {
    App.router.navigate('contacts/new', true);
  }
}

当按钮被点击时,我们将用户重定向到联系表单以创建新用户。

显示详细信息

联系人详细信息显示单个联系人的只读版本;在这里,你可以看到给定联系人的所有详细信息,但不能编辑。以下截图显示了它的外观:

显示详细信息

图 2.5:联系人详细信息

要显示联系人的只读版本,我们首先需要定义一个布局:

<script id="contact-view-layout" type="text/template">
  <div class="row page-container">
    <div id="contact-widget"
      class="col-xs-12 col-sm-4 col-md-3"></div>
    <div class="col-xs-12 col-sm-8 col-md-9">
      <div class="row">
        <div id="about-container"></div>
        <div id="call-log-container"></div>
      </div>
    </div>
  </div>
  <div class="footer text-muted">
    © 2015\. <a href="#">Mastering Backbone.js</a> by <a href="https://twitter.com/abieealejandro" target="_blank">Abiee Alejandro</a>
  </div>
</script>

布局定义了两个区域,一个用于左侧的小部件,另一个用于主要内容:

// apps/contacts/contactViewer.js
class ContactViewLayout extends Layout {
  constructor(options) {
    super(options);
    this.template = '#contact-view-layout';
    this.regions = {
      widget: '#contact-widget',
      about: '#about-container'
    };
  }

  get className() {
    return 'row page-container';
  }
}

ContactViewLayout被渲染时,小部件和关于信息应该被渲染。这些视图的模板非常简单,所以为了节省空间,这里将不会展示;如果你想看到实现细节,请访问这本书的 GitHub 仓库。

ContactAbout视图包括三个按钮,用于返回列表,另一个用于删除联系人,最后一个用于编辑它。

// apps/contacts/contactViewer.js
class ContactAbout extends ModelView {
  constructor(options) {
    super(options);
    this.template = '#contact-view-about';
  }

  get className() {
    return 'panel panel-simple';
  }

  get events() {
    return {
      'click #back': 'goToList',
      'click #delete': 'deleteContact',
      'click #edit': 'editContact'
    };
  }

  goToList() {
    App.router.navigate('contacts', true);
  }

  deleteContact() {
    this.trigger('contact:delete', this.model);
  }

  editContact() {
    var contactId = this.model.get('id');
    App.router.navigate(`contacts/edit/${contactId}`, true);
  }
}

正如我们在ContactList中所做的那样,我们将删除过程委托给控制器;视图不应该处理这个业务逻辑。然而,编辑和返回按钮是简单的 URL 重定向,可以直接在视图中实现。

编辑信息

图 2.6 显示了联系人编辑表单的外观。表单视图应该能够从输入框中获取信息并更新传递给它的联系人模型。

应在此处创建一个布局模板,以将左侧的小部件与右侧的表单视图分开:

<script id="contact-form-layout" type="text/template">
  <div id="preview-container"
    class="col-xs-12 col-sm-4 col-md-3"></div>
  <div id="form-container"
    class="col-xs-12 col-sm-8 col-md-9"></div>

  <div class="footer text-muted">
    © 2015\. <a href="#">Mastering Backbone.js</a> by <a href="http://themeforest.net/user/Kopyov" target="_blank">Abiee Alejandro</a>
  </div>
</script>

编辑信息

图 2.6:编辑联系人表单

布局定义了两个区域:

// apps/contacts/contactEditor.js
class ContactFormLayout extends Layout {
  constructor(options) {
    super(options);
    this.template = '#contact-form-layout';
    this.regions = {
      preview: '#preview-container',
      form: '#form-container'
    };
  }

  get className() {
    return 'row page-container';
  }
}

为了编辑联系人,我们需要定义一个表单:

// index.html
<script id="contact-form" type="text/template">
  <div class="panel panel-simple">
    <div class="panel-heading">Edit contact</div>
    <div class="panel-body">
      <form class="form-horizontal">
        <div class="form-group">
          <label for="name"
            class="col-sm-2 control-label">Name</label>
          <div class="col-sm-10">
            <input id="name" type="text"
              class="form-control" placeholder="Full name"
              value="<%= name %>" />
          </div>
        </div>
        // ...

        <hr />

        <h4>Contact info</h4>
        <div class="form-group">
          <label for="name"
            class="col-sm-2 control-label">Phone</label>
          <div class="col-sm-10">
            <input id="name" type="text"
              class="form-control"
              placeholder="(123) 456 7890" value="<%= phone %>" />
          </div>
        </div>
        // ...
      </form>
    </div>
    <div class="panel-footer clearfix">
      <div class="panel-buttons">
        <button id="cancel" class="btn btn-default">Cancel</button>
        <button id="save" class="btn btn-success">Save</button>
      </div>
    </div>
  </div>
</script>

由于空间原因,我在书中删除了重复的代码,但你可以在 GitHub 仓库中看到完整的代码。请注意,此表单将用于编辑和创建新的联系人。对于模型中的每个属性,都会渲染一个包含属性内容的输入:

// apps/contacts/contactEditor.js 
class ContactForm extends ModelView {
  constructor(options) {
    super(options);
    this.template = '#contact-form';
  }

  get className() {
    return 'form-horizontal';
  }

  get events() {
    return {
      'click #save': 'saveContact',
      'click #cancel': 'cancel'
    };
  }

  serializeData() {
    return _.defaults(this.model.toJSON(), {
      name: '',
      age: '',
      phone: '',
      email: '',
      address1: '',
      address2: ''
    });
  }

  saveContact(event) {
    event.preventDefault();
    this.model.set('name', this.getInput('#name'));
    this.model.set('phone', this.getInput('#phone'));
    this.model.set('email', this.getInput('#email'));
    this.model.set('address1', this.getInput('#address1'));
    this.model.set('address2', this.getInput('#address2'));
    this.model.set('facebook', this.getInput('#facebook'));
    this.model.set('twitter', this.getInput('#twitter'));
    this.model.set('google', this.getInput('#google'));
    this.model.set('github', this.getInput('#github'));
    this.trigger('form:save', this.model);
  }

  getInput(selector) {
    return this.$el.find(selector).val();
  }

  cancel() {
    this.trigger('form:cancel');
  }
}

当用户点击取消按钮时,会触发一个由ContactEditor子应用控制器处理的form:cancel事件。

// apps/contacts/contactEditor.js 
  cancel() {
    // Warn user before make redirection to prevent accidental
    // cencel
    App.askConfirmation('Changes will be lost', isConfirm => {
      if (isConfirm) {
        App.router.navigate('contacts', true);
      }
    });
  }

当模型被渲染时,它可能包含或不包含属性,这取决于服务器响应;因此,我们扩展了serializeData()方法来分配默认值。

当用户点击保存按钮时,会调用saveContact(),它从输入中获取数据并将新值分配给模型,然后触发一个form:save事件,由ContactEditor子应用控制器处理。

// apps/contacts/edit-contact.js 
  saveContact(contact) {
    contact.save(null, {
      success() {
        // Redirect user to contact list after save
        App.notifySuccess('Contact saved');
        App.router.navigate('contacts', true);
      },
      error() {
        // Show error message if something goes wrong
        App.notifyError('Something goes wrong');
      }
    });
  }

渲染第三方插件

在渲染视图时,一个常见的问题是未能渲染其他人的插件,因为它们是为传统的 Web 应用程序设计的,而不是为 SPA 设计的;这是因为许多插件依赖于 DOM,这意味着目标元素应该存在于实际的 DOM 中。为了更清楚地了解这个问题,让我用一个 jQueryUI 日历插件示例来展示。让我们在我们的ContactEditor中添加一个birthdate字段,替换年龄字段。

// index.html
// ...
<div class="form-group">
  <label for="birthdate">Birth date</label>
  <input id="birthdate " type="text"
    class="form-control" value="<%= birthdate %>" />
//...

并在视图中进行适当的更改:

class ContactForm extends ModelView {
  // ...
  serializeData() {
    return _.defaults(this.model.toJSON(), {
      name: '',
      birthdate: '',
      // ...
    });
  },
  saveContact(event) {
    event.preventDefault();
    this.model.set('name', this.$el.find('#name').val());
    this.model.set('birthdate',
      this.$el.find('#birthdate').val()
    );
    // ...
  },
// ...
});

要在birthdate字段上显示日历,我们需要在某个地方调用$('#birthdate').datepicker(),但最佳位置在哪里呢?

// ... edit-contact.js
class ContactEditor {
  // ...

  showEditor(contact) {
    var contactForm = new ContactForm({model: contact});
    this.region.show(contactForm);
    contactForm.$('#birthdate').datepicker();

    this.listenTo(contactForm, 'form:save', this.saveContact);
    this.listenTo(contactForm, 'form:cancel', this.cancel);
  };
};

在对region对象调用show()方法后,contactForm视图在 DOM 中是活跃的,因此在该之后调用datepicker()方法是有意义的。然而,这不是一个好的策略,因为我们的控制器对象知道 DOM 元素,而这不是它的职责。

视图应该负责处理 DOM,因此渲染第三方插件也包括在内。另一种方法可能是扩展FormView类的render()方法,但我们已经有了在渲染过程之后被调用的onRender()回调。

// ... edit-contact.js
var ContactForm extends ModelView {
  // ...
  onRender() {
    this.$('#birthdate').datepicker();
  },
  //...
});

但这不会起作用,因为我们是在一个区域上渲染视图。你记得show()方法吗?

class Region {
// ...
  openView(view) {
    this.ensureEl();
    view.render();
    this.$el.html(view.el);
  }
// ...
}

显示过程首先在内存中渲染视图,然后使其在 DOM 中可用。这就是为什么这不起作用的原因。onRender()方法的目的是在将模板更改提供给 DOM 之前进行更改。我们需要添加一个新的回调方法,当视图在 DOM 中时将被调用。

class Region {
// ...
  openView(view) {
    this.ensureEl();
    view.render();
    this.$el.html(view.el);

    // Callback when the view is in the DOM
    if (view.onShow) {
      view.onShow();
    }
  }
// ...
}

记得也要在CollectionView中实现这个功能。

// common.js
class CollectionView extends Backbone.View {
  // ...
  onShow() {
    var children = this.children || {};
    _.each(children, child => {
      if (child.onShow) {
        child.onShow();
      }
    });
  }
}

因此,我们的ContactForm将以类似以下的方式结束。

// apps/contacts/contactEditor.js
class ContactForm extends ModelView {
  // ...

  // Call the onShow method for each children
  onShow() {
    // Ensure that children exists
    var children = this.children || {};

    _.each(children, child => {
      if (child.onShow) {
        child.onShow();
      }
    });
  }
  //...
}

记住,大多数第三方插件需要在 DOM 中具有元素才能工作,否则它们将无法运行,因此你应该在渲染视图之后调用插件。调用插件的最佳位置是在扩展的视图类中,这样 DOM 操作的责任就被封装在视图中。

结论

我们首先创建了适用于几乎所有项目的通用视图类型。这些视图在原则上很简单但功能强大;我们可以有效地管理嵌套视图,而不用担心内存不足。

我们了解到,通过在render()方法中封装常见模式,我们可以创建有用的视图类型;在本章中,我们看到了四种,但如果你好奇,我鼓励你查看 Marionette 框架,它建立在 Backbone 之上。

Marionette 包括这里公开的所有视图:ItemViewCollectionViewLayoutView、区域,以及其他有用的视图类型。Marionette 对象的行为与我们在这里看到的行为非常相似,因此你可以轻松地交换 Marionette 对象和本章中描述的对象。

插件应该在视图进入 DOM 之后调用,因为大多数插件都是依赖于 DOM 的。在渲染插件时,请记住在视图中而不是外部进行;onShow()回调策略确保视图在 DOM 中可用,因此这是渲染第三方插件的最佳位置。

在下一章中,你将学习更多关于如何同步视图和模型的内容。你将看到如何管理复杂数据并在视图中有效地渲染它。验证是应用程序的一个重要功能;你将学习如何验证模型并使用这些信息在视图中显示错误消息。

第三章。模型绑定

保持模型与其他对象(如视图)同步可能具有挑战性,如果做得不正确,可能会导致代码混乱。在本章中,我们将探讨如何处理数据同步以简化数据绑定。但是,什么是数据绑定?维基百科将数据绑定定义为:

数据绑定是建立应用程序 UI(用户界面)和业务逻辑之间连接的过程。如果设置和通知设置正确,数据会在更改时反映变化。它也可以意味着当 UI 发生变化时,底层数据将反映这一变化。

模型绑定中常见的问题是如何处理包含其他嵌入对象或列表的复杂模型结构;在本章中,我们将定义一种处理这些场景的策略。Backbone 缺少的功能是双向绑定;在接下来的几节中,我们将看到如何在不头疼的情况下实现它。

让我们从描述如何手动将模型数据与视图绑定开始本章,以了解 Backbone 的工作原理;之后,我们可以使用 Backbone.Stickit 使其变得更加容易。在了解如何同步模型数据和视图之后,我们将探讨如何在模型上执行验证以及如何显示错误消息。

手动绑定

为了简化,想象我们有一个具有简单布局的表单:姓名、电话和电子邮件地址:

<script id="form-template" type="text/template">
<form>
<div class="form-group">
<label for="name">Name</label>
<input id="name" class="form-control" type="text"
value="<%= name %>" />
</div>
<div class="form-group">
<label for="phone">Name</label>
<input id="phone" class="form-control" type="text"
value="<%= phone %>" />
</div>
<div class="form-group">
<label for="email">Name</label>
<input id="email" class="form-control" type="text"
value="<%= email %>" />
</div>
<button type="submit"class="btn btn-default">Save now</button>
</form>
</script>

<script id="preview-template" type="text/template">
<h3><%= name %></h3>
<ul>
<li><%= phone %></li>
<li><%= email %></li>
</ul>
</script>

在这个片段中,我们有两个将在同一时间渲染的视图。当用户点击表单中的 保存 按钮时,预览模板将使用模型数据更新:

'use strict';

var contact = new Backbone.Model({
  name: 'John Doe',
  phone: '555555555',
  email: 'john.doe@example.com'
});

class FormView extends ModelView {
  constructor(options) {
    super(options);
    this.template = '#form-template';
    this.model = contact;
  }
}

class ContactPreview extends ModelView {
  constructor(options) {
    super(options);
    this.template = '#preview-template';
    this.model = contact;

    // Re-render the view if something in the model
    // changes
    this.model.on('change', this.render, this);
  }
}

var form = new FormView({
  el: '#contact-form'
});

var preview = new ContactPreview({
  el: '#contact-preview'
});

form.render();
preview.render();

这段代码将在表单和预览中渲染 contact 模型的内容。当点击 立即保存 按钮时,没有任何操作发生,因为它还没有被编程,所以让我们在模型中保存这些更改:

var FormView = ModelView.extend({
  // ...
  events() {
    return {
      'click button[type="submit"]': 'saveContact'
    };
  }

  saveContact(event) {
    event.preventDefault();
    this.model.set('name', this.$('#name').val());
    this.model.set('phone', this.$('#phone').val());
    this.model.set('email', this.$('#email').val());
  }
});

让我们看看这里发生了什么。在 FormView 中,我们使用表单输入中的数据更新模型;这个动作同步了表单数据与模型,触发模型上的 'change' 事件。因为 ContactPreview 正在监听变化,所以事件将使用模型中的数据更新自己。

Backbone 不是通过 自动魔法 视图-模型绑定构建的,因此这是开发者的责任来实现它。幸运的是,有一些 Backbone 插件可以帮助我们使其不那么痛苦;其中之一是 纽约时报 开发的 Backbone.Stickit

双向绑定

Angular.js 在前端普及了双向数据绑定;双向数据绑定的理念是保持视图和模型同步。当你在一个输入字段中做出更改时,视图应该立即更新模型,如果你在模型中更改一个属性,视图应该立即显示当前值:

双向绑定

图 3.1 使用 Backbone 的双向数据绑定

Backbone 不提供实现此功能的简单机制;然而,我们可以使用 Backbone 模型提供的事件系统来完成它。图 3.1 显示了如何实现。

Backbone.View监听 DOM 中的输入控件上的keyupchange事件;当从 DOM 触发更改时,Backbone.View可以从输入中提取新值并设置模型:

class FormView extends ModelView {
  // ...

  events() {
    return {
      'click button[type="submit"]': 'saveContact',
      'keyup input': 'inputChanged',
      'change input': 'inputChanged'
    };
  }

  inputChanged(event) {
    var $target = $(event.target);
    var value = $target.val();
    var id = $target.attr('id');
    this.model.set(id, value);
  }

// ...
}

当你在Backbone.View上调用set()方法时,至少会触发两个事件:changechange:<fieldname>。我们可以使用这些事件来更新必要的视图:

var myModel = new Backbone.Model();

myModel.on('change:foo', event => {
  console.log('foo changed to', event.changed.foo);
});
myModel.on('change', event => {
  var changedKeys = _.keys(event.changed);

  changedKeys.forEach(key => {
    console.log(key, 'changed to', event.changed[key]);
  });
});

myModel.set('foo', 'bar');
myModel.set('baz', 'xyz');
myModel.set({
  foo: 'stuff',
  baz: 'zxy'
});

你可以在以下图中看到前面代码片段的输出:

双向绑定

图 3.2 更改事件的输出

我们可以使用这些事件在必要时更新视图。实际上,我们已有的代码足以保持ContactFormContactPreview视图同步。

this.model.on('change', this.render, this);

ContactPreview正在监听模型中的每一个变化,并在有变化时重新渲染视图。然而,每次都重新渲染整个视图是一个耗时的过程;如果只在必要时进行更改会更好。

首先,你需要使用标识符识别每个字段:

<script id="preview-template" type="text/template">
<h3 id="name"><%= name %></h3>
<ul>
<li id="phone"><%= phone %></li>
<li id="email"><%= email %></li>
</ul>
</script>

并且更改事件处理程序将只更新已识别元素的內容:

class ContactPreview extends ModelView {
  constructor(options) {
    //...

    // Re-render the view if something in the model
    // changes
    this.model.on('change', this.handleChange, this);
  }

  handleChange(event) {
    var changedKeys = _.keys(event.changed);

    changedKeys.forEach(key => {
      let $target = this.$('#' + key);
      if ($target) {
        $target.html(event.changed[key]);
      }
    });
  }
}

尽管双向数据绑定的结果应该谨慎使用;有些人认为双向数据绑定不是一个好主意,并将其视为反模式。

参考文献

有关更多信息,请参阅以下链接:

使用插件进行数据绑定

如前节所示,Backbone 不提供同步你的模型及其使用视图的简单机制。一些 Backbone 插件已被开发出来以最小化这个问题;其中之一是Backbone.Stickit

如果你想要一种简单而强大的方式来绑定 DOM 节点和 Backbone 模型,Backbone.Stickit将做得很好:

var FormView = ModelView.extend({
template: '#form-template',
  bindings: {
    '#name': 'name',
    '#phone': 'phone',
    '#email': 'email'
  },
  onRender: function() {
    this.stickit();
  }
});

以下代码示例显示了其外观;请查阅项目文档以了解更多信息。

绑定嵌入数据

Backbone 最常见的问题之一是如何处理复杂模型数据:

{
"name": "John Doe",
"address": {
"street": "Seleme",
"number": "1975 int 6",
"city": "Culiacán"
  },
"phones": [{
"label": "Home",
"number": "55 555 123"
  }, {
"label": "Office",
"number": "55 555 234"
  }],
"emails": [{
"label": "Work",
"email": "john.doe@example.com"
  }]
}

对于这个模型数据,渲染只读视图可能很简单;然而,真正的挑战是如何将表单操作与嵌入数组绑定。在 Backbone 中,很难在数组对象上使用事件系统;如果你在列表中推入一个新项目,不会触发任何事件。这使得难以保持模型数据与编辑其内容的视图同步。

绑定嵌入列表

想象一下,我们的联系人应用现在将允许我们添加多个电话和电子邮件。我们需要更改编辑表单视图以支持添加、删除和修改电话和电子邮件数组中的项目:

绑定嵌入式列表

图 3.3.带有电话和电子邮件列表的联系人表单布局

图 3.3 显示了添加新建按钮的结果,允许用户动态添加他们想要的电话和电子邮件数量。列表中的每个项目还应包括一个删除按钮,以便用户可以删除它们。

为了渲染电话和电子邮件列表以及将表单与模型同步,我们将采用不同的策略;图 3.4 展示了我们的策略将如何呈现:

绑定嵌入式列表

图 3.4 嵌入式数组渲染策略

我们将创建两个新的 Backbone 集合,一个用于电话,另一个用于电子邮件。有了 Contact 模型中的数据,我们可以初始化这些集合并将它们作为常规 CollectionView 渲染。

正如我们在上一章中看到的,CollectionView 对象负责它所渲染的集合中的更改,因此我们可以修改集合对象,视图将按预期行为。

当用户点击保存按钮时,我们可以在调用 save() 方法之前序列化这些集合的内容并更新模型。

电话和电子邮件的每个项目都将有一个非常相似的模板:

<script id="contact-form-phone-item" type="text/template">
<div class="col-sm-4 col-md-2">
<input type="text" class="form-control description" 
placeholder="home, office, mobile"
 value="<%= description %>" />
</div>
<div class="col-sm-6 col-md-8">
<input type="text" class="form-control phone" 
placeholder="(123) 456 7890" value="<%= phone %>" />
</div>
<div class="col-sm-2 col-md-2 action-links">
<a href="#" class="pull-rigth delete">delete</a>
</div>
</script>

此模板将用作 CollectionViewModelView

// apps/contacts/contactEditor.js
class PhoneListItemView extends ModelView {
  constructor(options) {
    super(options);
    this.template = '#contact-form-phone-item';
  }

  get className() {
    return 'form-group';
  }
}

class PhoneListView extends CollectionView {
  constructor(options) {
    super(options);
    this.modelView = PhoneListItemView;
  }
}

联系人表单现在应包括两个区域用于 PhoneListViewEmailListView

<div class="panel panel-simple">
<div class="panel-heading">
    Phones
<button id="new-phone"
class="btn btn-primary btn-sm pull-right">New</button>
</div>
<div class="panel-body">
<form class="form-horizontal phone-list-container"></form>
</div>
</div>

<div class="panel panel-simple">
<div class="panel-heading">
    Emails
<button id="new-email"
 class="btn btn-primary btn-sm pull-right">New</button>
</div>
<div class="panel-body">
<form class="form-horizontal email-list-container"></form>
</div>
</div>

ContactForm 应该改为支持区域;我们将从 Layout 扩展而不是 ModelView

// apps/contacts/contactEditor.js
class ContactForm extends Layout {
  constructor(options) {
    super(options);
    this.template = '#contact-form';
    this.regions = {
      phones: '.phone-list-container',
      emails: '.email-list-container'
    };
  }

  // ...
}

我们将需要两个新的模型:PhoneEmail。由于这两个模型非常相似,我将只展示 Phone

// apps/contacts/models/phone.js
'use strict';

App.Models = App.Models || {};

class Phone extends Backbone.Model {
  get defaults() {
    return {
      description: '',
      phone: ''
    };
  }
}

App.Models.Phone = Phone;

使用 Phone 模型的集合:

// apps/contacts/collections/phoneCollection.js
'use strict';

App.Collections = App.Collections || {};

class PhoneCollection extends Backbone.Collection {
  constructor(options) {
    super(options);
  }

  get model() {
    return App.Models.Phone;
  }
}

App.Collections.PhoneCollection = PhoneCollection;

现在我们有了渲染表单所需的必要对象,让我们在控制器中将它们全部组合起来。首先,我们需要从模型数据中创建集合实例:

// apps/contacts/contactEditor.js
class ContactEditor {
  // ...

  showEditor(contact) {
// Data
    var phonesData = contact.get('phones') || [];
    var emailsData = contact.get('emails') || [];
this.phones = new App.Collections.PhoneCollection(phonesData);
this.emails = new App.Collections.EmailCollection(emailsData);

    // ...
  }

  // ...
}

在设置了集合之后,我们可以正确地构建 CollectionViews:

// apps/contacts/contactEditor.js
class ContactEditor {
  // ...

  showEditor(contact) {
    // ...

    // Create the views
    var layout = new ContactFormLayout({model: contact});
    var phonesView = new PhoneListView({collection: this.phones});
    var emailsView = new EmailListView({collection: this.emails});
    var contactForm = new ContactForm({model: contact});
    var contactPreview = new ContactPreview({model: contact});

    // ...
  }

  // ...
}

phonesViewemailsView 可以在 contactForm 对象公开的区域中渲染:

// apps/contacts/contactEditor.js
class ContactEditor {
  // ...

  showEditor(contact) {
    // ...

    // Render the views
    this.region.show(layout);
    layout.getRegion('form').show(contactForm);
    layout.getRegion('preview').show(contactPreview);
    contactForm.getRegion('phones').show(phonesView);
    contactForm.getRegion('emails').show(emailsView);

    // ...
  }

  // ...
}

当用户点击新建按钮时,应在适当的列表中添加一个新项目:

// apps/contacts/contactEditor.js
class ContactForm extends Layout {
  // ...

  get events() {
    return {
      'click #new-phone': 'addPhone',
      'click #new-email': 'addEmail',
      'click #save': 'saveContact',
      'click #cancel': 'cancel'
    };
  }

  addPhone() {
    this.trigger('phone:add');
  }

  addEmail() {
    this.trigger('email:add');
  }

  // ...
}

ContactForm 对我们在控制器中使用的集合一无所知,因此它们不能直接在集合中添加项目;控制器应该监听 contactForm 中的事件并更新集合:

// apps/contacts/contactEditor.js
class ContactEditor {
  // ...

  showEditor(contact) {
    // ...

    this.listenTo(contactForm, 'phone:add', this.addPhone);
    this.listenTo(contactForm, 'email:add', this.addEmail);

    // ...
  }

  addPhone() {
    this.phones.add({});
  }

  addEmail() {
    this.emals.add({});
  }

  // ...
}

当用户点击列表中某个项目的删除链接时,该项目应从集合中移除:

// apps/contacts/contactEditor.js
class PhoneListItemView extends ModelView {
  //...

  get events() {
    return {
      'click a': 'deletePhone'
    };
  }

  deletePhone(event) {
    event.preventDefault();
    this.trigger('phone:deleted', this.model);
  }
}

就像我们对 add 所做的那样,控制器将通过在列表视图中附加事件监听器来管理集合数据:

// apps/contacts/contactEditor.js
class ContactEditor {
  // ...

  showEditor(contact) {
    // ...

    this.listenTo(phonesView, 'item:phone:deleted',
(view, phone) => {
this.deletePhone(phone);
}
);
    this.listenTo(emailsView, 'item:email:deleted',
 (view, email) => {
this.deleteEmail(email);
}
);

    // ...
  }

  deletePhone(phone) {
    this.phones.remove(phone);
  }

  deleteEmail(email) {
    this.emails.remove(email);
  }

  // ...
}

如前文片段所示,向列表中添加项目(以及移除它们)相当简单;我们只需更新底层集合,视图将自动更新。我们在上一章中对 CollectionViews 做得很好。

为了在模型中保存电话和电子邮件属性,我们需要从集合中提取存储的数据并替换模型中的现有数据:

// apps/contacts/contactEditor.js
class ContactEditor {
  // ...

  saveContact(contact) {
    var phonesData = this.phones.toJSON();
    var emailsData = this.emails.toJSON();

    contact.set({
      phones: phonesData,
      emails: emailsData
    });

    contact.save(null, {
      success() {
        // Redirect user to contact list after save
        App.notifySuccess('Contact saved');
        App.router.navigate('contacts', true);
      },
      error() {
        // Show error message if something goes wrong
        App.notifyError('Something goes wrong');
      }
    });
  }
  // ...
}

然而,集合与表单不同步,您最终会得到空邮箱和电话号码。为了解决这个问题,我们需要将模型与输入绑定:

// apps/contacts/contactEditor.js
class PhoneListItemView extends ModelView {
  // ...

  get events() {
    return {
      'change .description': 'updateDescription',
      'change .phone': 'updatePhone',
      'click a': 'deletePhone'
    };
  }

  updateDescription() {
    var $el = this.$('.description');
    this.model.set('description', $el.val());
  }

  updatePhone() {
    var $el = this.$('.phone');
    this.model.set('phone', $el.val());
  }

  // ...
}

现在,如果您点击 保存 按钮,电话和电子邮件的数据将按预期存储。

通过中间集合将嵌入式数组绑定到视图中的这种方式简化了您与列表的工作方式,并将使您的代码更加简单和易于维护。

验证模型数据

通常,在前端应用程序中,输入验证是通过 UI 插件如 jQuery Validation 进行的,它侧重于用户界面。换句话说,数据是在 DOM 上直接验证的。然而,对于更大的应用程序,这并不是最佳方法。

Backbone 中的验证可以是手动进行或通过插件进行。当然,最佳方法是使用插件,因为它可以节省时间和精力,但在我们学习如何使用 backbone.validation 插件之前,我想向您展示原生验证是如何工作的。

手动验证

Backbone 模型有三个属性帮助我们验证模型数据:validate()validationError()isValid()。如果模型数据正确,validate() 方法应该返回空值,否则返回其他值。

Backbone 留下了 validate() 方法应该返回什么内容,因此您可以返回一个纯字符串消息或一个复杂对象:

class Chapter extends Backbone.Model{
  validate(attrs, options) {
    if (attrs.end < attrs.start) {
      return "can't end before it starts";
    }
  }
}

您可以调用 isValid() 方法来确保您的模型处于有效状态;内部,Backbone 将调用 validate() 方法,并根据返回值返回一个布尔值:如果 validate() 返回空值,则返回 true,如果返回其他值,则返回 false

使用 validationError,您可以获取模型中的最新验证错误——例如:

var one = new Chapter({
  title : "Chapter One: The Beginning",
  start: 15,
  end: 10
});

If (!one.isValid()) {
  alert(one.validationError);
}

当您想要保存模型时,Backbone 会调用 validate() 方法,如果模型无效,将触发一个 'invalid' 事件:

one.on("invalid", function(model, error) {
  alert(model.get("title") + " " + error);
});

one.save({
  start: 15,
  end: 10
});

在联系人编辑器中,我们没有进行任何验证。是时候开始进行一些验证了;让我们验证联系人的姓名:

// apps/contacts/models/contact.js
var Contact extends Backbone.Model {
  validate(attrs) {
    if(_.isEmpty(attrs.name)) {
      return {
        attr: 'name',
        message: 'name is required'
      };
    }
  }
}

var contact = new Contact({
// ...
});

记住,validate() 方法可以返回任何内容。Backbone 只会在 validate() 方法不返回任何内容时认为模型是有效的。在这种情况下,会返回一个对象。对象比纯字符串更有用,因为它们可以返回更多可用于改善用户体验的信息。

当发生错误时,将触发一个 'invalid' 事件。编辑表单应显示错误,因此表单将监听模型中的 'invalid' 事件,并管理 DOM 以显示错误消息:

// apps/contacts/contactEditor.js
class ContactForm extends Layout {
  constructor(options) {
    super(options);
    this.template = '#contact-form';
    this.regions = {
      phones: '.phone-list-container',
      emails: '.email-list-container'
    };

    this.listenTo(this.model, 'invalid', this.showError);
  }

// ...

  showError(model, error) {
    this.clearErrors();

    var selector = '#' + error.attr;
    var $msg = $('<span>')
      .addClass('error')
      .addClass('help-block')
      .html(error.message);
    this.$(selector)
      .closest('.form-group')
      .addClass('has-error');
    this.$(selector)
      .after($msg);
  }

  clearErrors() {
    this.$('.has-error').removeClass('has-error');
    this.$('span.error').remove();
  }
}

showError() 方法在输入框下方追加一个 span 消息,这样用户就可以看到哪里出错了。通过错误对象的 attr 属性,我们可以将错误消息放入正确的框中;这就是为什么使用错误对象比使用纯文本消息更好的原因。

注意我们在 showError() 方法中创建了一个 DOM 元素。我在动态创建元素以简化视图中的代码。当然,你也可以直接在模板中创建一个 span 元素,并根据需要显示/隐藏它。

使用 Backbone.Validation 插件进行验证

Backbone.Validation 简化了验证过程,允许我们以声明式的方式编写验证规则,而不是程序化地编写。此外,它还内置了你可以直接使用的验证规则。当使用 Backbone.Validation 时,验证模型的方式会简化,我们将在下面展示。

要开始使用 Backbone.Validation,在包含 Backbone 之后安装插件。

<script src="img/backbone.js"></script>
<script src="img/backbone-validation.js"></script>

现在我们可以使用这个插件;Backbone.Validation 在模型中使用验证属性来指定验证规则:

class Contactextends Backbone.Model {
get validation: {
    name: {
      required: true,
      minLength: 3
    }
  }
}

而不是使用 validate() 方法,你可以在配置对象中编写验证规则,其中对象的键是模型中字段的名称;在这种情况下,我们正在验证 name 字段。requiredminLength 验证规则通过 Backbone.Validation 应用到 name 字段。

现在,Contact 模型有了验证配置,我们需要在 Backbone 模型中重写默认的 validate() 方法来激活 Backbone.Validation 插件。要做到这一点,我们需要在 onRender() 方法中调用 Backbone.Validation.bind() 方法:

class ContactForm extends Layout {
// ...

  onRender() {
    Backbone.Validation.bind(this);
  }

  // …
});

由于我们在模型上重写了 validate() 方法,showError()clearErrors() 现在不再必要。Backbone.Validation 插件提供了钩子来告诉你模型何时有效;我们将使用这些钩子作为快捷方式。目前,save:contact 处理程序应更改为调用 isValid() 方法:

formLayout.on('save:contact', function() {
  if (!contact.isValid(true)) {
    return;
  }
  contact.unset('phones', { silent: true });
  contact.set('phones', phoneCollection.toJSON());
});

注意 isValid() 方法中的 true 参数;这个参数应该用于验证所有模型属性。

Backbone.Validation 检测到一个字段无效时,它将尝试在表单中显示一个错误消息;然而,默认行为是基于表单输入中的 name 属性。你可以更改默认行为,在我们的布局中正确地显示错误:

// app.js
_.extend(Backbone.Validation.callbacks, {
  valid(view, attr) {
    var $el = view.$('#' + attr);
    if ($el.length === 0) {
      $el = view.$('[name~=' + attr + ']');
    }

    // If input is inside an input group, $el is changed to
    // remove error properly
    if ($el.parent().hasClass('input-group')) {
      $el = $el.parent();
    }

    var $group = $el.closest('.form-group');
    $group.removeClass('has-error')
      .addClass('has-success');

    var $helpBlock = $el.next('.help-block');
    if ($helpBlock.length === 0) {
      $helpBlock = $el.children('.help-block');
    }
    $helpBlock.slideUp({
      done: function() {
        $helpBlock.remove();
      }
    });
  },

  invalid(view, attr, error) {
    var $el = view.$('#' + attr);
    if ($el.length === 0) {
      $el = view.$('[name~=' + attr + ']');
    }

    $el.focus();

    var $group = $el.closest('.form-group');
    $group.removeClass('has-success')
      .addClass('has-error');

    // If input is inside an input group $el is changed to
    // place error properly
    if ($el.parent().hasClass('input-group')) {
      $el = $el.parent();
    }

    // If error already exists and its message is different to new
    // error's message then the previous one is replaced,
    // otherwise new error is shown with a slide down animation
    if ($el.next('.help-block').length !== 0) {
      $el.next('.help-block')[0].innerText = error;
    } else if ($el.children('.help-block').length !== 0) {
      $el.children('.help-block')[0].innerText = error;
    } else {
      var $error = $('<div>')
                 .addClass('help-block')
                 .html(error)
                 .hide();

      // Placing error
      if ($el.prop('tagName') === 'div' &&
 !$el.hasClass('input-group')) {
        $el.append($error);
      } else {
        $el.after($error);
      }

      // Showing animation on error message
      $error.slideDown();
    }
  }
});

当发现无效数据时,将调用 invalid() 方法;回调函数将使用视图实例、具有无效数据的字段名称和消息作为参数调用。有了这些信息,我们可以创建一个 span 错误消息,并将 has-error 类添加到包含输入的 control-group

请查阅 Backbone.Validation 文档以了解更多关于其优点和使用方法。

摘要

在本章中,我们学习了如何保持模型和视图同步。一般来说,同步模型和视图是容易的,但如果模型包含嵌套数组,事情可能会变得复杂。你可以使用插件来简化数据绑定;Backbone.Stickit 是一个不错的选择,因为它允许你以声明式的方式创建绑定。

我向你展示了如何使用纯 Backbone 实现双向数据绑定,利用事件系统;在应用中过度使用双向数据绑定并不总是好主意,但在某些情况下它可能很有用。

最后,我们学习了如何使用 Backbone 验证模型,以及如何使用验证 API 在视图中显示错误信息。Backbone.Validation 插件可以帮助你轻松地通过最小努力验证 Backbone 模型,一旦验证回调设置正确。

在下一章中,我们将学习如何模块化我们的联系人应用,使其更易于维护并更有效地管理依赖关系。然后我们将整个应用打包成一个脚本,以便更快地加载。

第四章 模块化代码

随着你项目代码的增长,项目中的脚本数量会越来越多,脚本加载的复杂性也会不断增加。经典的加载 JavaScript 文件的方式是为每个脚本编写一个<script>标签,但你必须按照正确的顺序执行;如果不这样做,你的代码可能会停止工作。这不是中型项目的一个高效方式。

如果你忘记了加载顺序会发生什么?如果你对代码进行了重构并且脚本顺序发生了变化呢?修复它并跟踪所有代码及其依赖项将会很痛苦。

这个问题已经以不同的方式得到了解决。一种方法是通过创建模块语法来创建、加载和明确声明模块的依赖项;这种语法被称为AMD异步模块定义)。AMD 模块定义了一个模块依赖项列表,模块内部的代码只有在依赖项完全加载后才会执行。

依赖项是异步加载的;这意味着你不需要通过<script>标签在 HTML 页面中加载所有脚本。AMD 模块比纯 JavaScript 更好,因为它们明确地定义了依赖项,并且可以自动加载。

尽管 AMD 模块比<script>标签更优越,但在进行单元测试时,与 AMD 模块一起工作可能会很痛苦,因为你需要了解库如何加载模块的细节;当你想要进行单元测试时,你需要隔离待测试的代码片段,但在 RequireJS 中很难做到,即使你做到了,结果也可能存在 bug。

最近,另一个模块加载器和依赖项管理器出现了;Browserify 似乎是目前最受欢迎的。然而,它并不是唯一的;还有许多其他潜在的选择,如 jspm 和 steal.js。

在这本书中,我们将使用 Browserify,因为它很受欢迎,因此你可以在网上找到大量关于它的信息和文档;另一个好理由是,许多项目都是用它构建的,这证明了它的成熟度和生产就绪状态。Browserify 使用与 Node 模块相同的语法来定义模块和依赖项,所以如果你已经了解 Node,你可以直接进入 Browserify 部分。

CommonJS 模块

近年来,Node 在软件行业中的受欢迎程度一直在上升;确实,它正在成为全 JavaScript 技术栈中后端开发的非常受欢迎的选择。如果你不了解 Node,你可以将其视为在服务器上而不是在浏览器中使用的 JavaScript。

Node 使用 CommonJS 模块语法来定义其模块;一个 CommonJS 模块是一个导出一个单一值以供其他模块使用的文件。使用 CommonJS 是有用的,因为它提供了一种管理 JavaScript 模块和依赖项的清晰方式。

为了支持 CommonJS,Node 使用 require() 函数。使用 require(),您可以在不使用 <script> 标签的情况下加载 JavaScript 文件,而是通过调用 require() 并将所需的模块/依赖项的名称传递给它,并将其分配给一个变量。

为了说明 CommonJS 模块的工作原理,让我们编写一个 Node 模块并看看如何使用 require() 函数。以下代码展示了一个简单的模块,它暴露了一个具有 sayHello() 方法的简单对象:

hello = {
  sayHello(name) {
    name = name || 'world';
    console.log('hello', name);
  }
}

module.exports = hello;

此脚本可以放置在名为 hello.js 的文件中,例如。hello 模块可以通过调用 require() 函数从另一个模块中加载,如下面的代码所示:

var hello = require('./hello');
hello.sayHello('world); // prints "hello world"

当我们使用 require() 函数调用脚本时,我们不需要添加 .js 扩展名,Node 会自动为我们添加。请注意,如果您向脚本名称添加扩展名,Node 仍然会添加扩展名,并且您将收到错误,因为 hello.js.js 文件不存在。

这就是您可以为项目定义 CommonJS 模块的方法:我们只需使用 module.exports 导出我们想要暴露给模块外部的变量,然后在需要的地方使用 require() 加载模块。

CommonJS 模块是单例,这意味着每次您加载一个模块时,您都会得到该对象的相同实例。Node 在第一次调用时将缓存返回的值,并在后续调用中重用它。

NPM 和 package.json

使用 Browserify,我们可以创建可以在浏览器中执行的 CommonJS 模块。当您在浏览器中使用 CommonJS 模块时,Browserify 将提供必要的工具来加载模块,包括 require() 函数的定义。

当您使用 Browserify 时,您可以使用 Node 包管理器为您的项目安装和定义依赖项。一个有用的工具是 npm 命令行工具,用于安装和管理项目依赖项。

Node 项目中的 package.json 文件是一个 JSON 文件,用于定义、安装和管理项目依赖项的版本。package.json 文件可以包含许多配置选项;您可以在 Node 网站上查看完整的文档,网址为 docs.npmjs.com/。以下是主要值的列表。

  • Name – 项目名称,不带空格

  • Description – 项目的简短描述

  • Version – 项目的版本号,通常以 0.0.1 开头

  • Dependencies – 项目依赖的库及其版本号的列表

  • devDependencies – 与 dependencies 相同,但这个列表仅用于开发环境——例如,用于放置测试库

  • licence – 项目代码的许可证名称

我们可以从一个非常简单的 package.json 文件开始,该文件仅包含一些基本字段,然后根据需要扩展它:

{
  "name": "backbone-contacts ",
  "version": "0.0.1",
  "description": "Example code for the book Mastering Backbone.js",
  "author": "Abiee Alejandro <abiee.alejandro@gmail.com>",
  "license": "ISC",
  "dependencies": {
  },
  "devDependencies": {
  }
}

如您所见,我们目前还没有任何依赖。我们可以使用 npm 安装我们的第一个依赖项:

$ npm install --save underscore jquery backbone bootstrap

此命令将安装与 backbone 一起工作的基本依赖项;保存标志将自动更新 package.json 文件,添加库的名称及其当前版本:

  "dependencies": {
    "backbone": "¹.2.1",
    "bootstrap": "³.3.5",
    "jquery": "².1.4",
    "underscore": "¹.8.3"
  }

库版本格式遵循 semver 标准;你可以在官方 semver 网站上了解更多关于此格式的信息。

在你的项目中使用 package.json 文件的一个优点是,下次你想安装依赖项时,你不需要记住库及其版本;你只需不带任何参数点击 安装,Node 就会读取 package.json 文件并为你进行安装:

$ npm install

使用 npm 你可以安装开发包,例如 mocha 测试库,但不要使用保存标志,而是使用 save-dev

$ npm install --save-dev mocha

现在你已经知道了如何安装依赖项并将它们保存在 package.json 文件中,我们就可以开始在联系人应用中使用 Browserify 了。

Browserify

使用 Browserify,我们可以在浏览器中直接使用 Node 模块。这意味着你可以利用 npm 包管理器和在前几节中暴露的 Node 模块语法来构建你的项目。然后 Browserify 可以将你的源代码进行一些转换,以便能够在浏览器环境中运行你的代码。

一个非常简单的模块,它暴露了一个带有打印问候消息的方法的对象,可以写成 Node 模块的形式:

// hello.js
module.exports = {
  sayHello: function(name) {
    name = name || 'world';
    console.log('hello', name);
  }
}

这段简单的代码可以从另一个脚本中加载,如下所示:

// main.js
var hello = require('./hello');
hello.sayHello();        // hello world
hello.sayHello('abiee'); // hello abiee

这段代码与 Node 完美兼容。你可以按照以下方式运行它:

$ node main.js

然而,这段代码在浏览器中无法运行,因为 require 函数和模块对象未定义。Browserify 会跟踪你的项目入口代码中的所有依赖项,创建一个包含所有脚本的单一文件:

$ browserify main.js
(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
module.exports = {
  sayHello: function(name) {
    name = name || 'world';
    console.log('hello', name);
  }
}

},{}],2:[function(require,module,exports){
var hello = require('./hello');

hello.sayHello();
hello.sayHello('abiee');

},{"./hello":1}]},{},[2]);

Browserify

图 4.1 使用 Browserify 打包

注意,我们的代码仍然在其中;Browserify 会定义缺失的对象并将所有脚本合并到一个文件中。图 4.1 显示了发生的图形表示。如果你使用像 Backbone 这样的库,那么你的最终脚本将包含输出文件中的所有 Backbone 代码。

要告诉 Browserify 你想要创建一个文件而不是仅仅将结果输出到标准输出,请使用 -o 标志:

$ browserify main.js -o app.js

这将创建一个包含 hello.jsmain.js 文件内容的 app.js 文件。

应用程序依赖

当应用程序在浏览器中加载时,它会按特定顺序加载所有 JavaScript 文件。顺序很重要,因为它代表了依赖链。

<script src="img/jquery-2.1.4.min.js"></script>
<script src="img/bootstrap.min.js"></script>
<script src="img/sweetalert.min.js"></script>
<script src="img/jquery.noty.packaged.min.js"></script>
<script src="img/underscore-min.js"></script>
<script src="img/backbone-min.js"></script>
<script src="img/backbone-validation-min.js"></script>

<script src="img/common.js"></script>
<script src="img/app.js"></script>
<script src="img/router.js"></script>
<script src="img/contact.js"></script>
<script src="img/phone.js"></script>
<script src="img/email.js"></script>
<script src="img/contactCollection.js"></script>
<script src="img/phoneCollection.js"></script>
<script src="img/emailCollection.js"></script>
<script src="img/contactList.js"></script>
<script src="img/contactViewer.js"></script>
<script src="img/contactEditor.js"></script>
<script src="img/app.js"></script>
<script type="text/javascript">App.start();</script>

这是一种标准的脚本加载方式;浏览器负责解析这些脚本标签,从资产服务器获取脚本文件,然后按顺序执行它们。因此,浏览器将首先执行 jQuery,然后是 Bootstrap,然后是 Underscore,依此类推。

如你所知,Backbone 依赖于 Underscore 和 jQuery 来工作;它在 Backbone 视图中使用 jQuery 来处理 DOM 选择,并使用 Underscore 作为工具库。因此,jQuery 和 Underscore 应该在 Backbone 之前加载。

在项目代码中,app.js 依赖于 Backbone,因此它在 Backbone 之后加载。apps/contacts/app.js 模块是应用程序外观。它依赖于子应用程序中的所有其他模块,这就是为什么它最后加载的原因。

图 4.2 以图形方式显示了模块的依赖关系。请注意,这是一个简化,并不是所有的依赖关系都显示出来。

应用程序依赖关系

图 4.2 依赖关系图

在应用程序中使用 Browserify

到目前为止,我们已经了解了 Browserify 是什么以及如何使用它。现在,我们将把这个背景应用到我们的联系人应用程序中,以将所有代码作为 Node 模块加载。

在我们继续之前,请确保您已安装了项目所需的所有依赖项:

{
  "name": "mastering-backbone-04",
  // ...
  "dependencies": {
    "backbone": "¹.2.3",
    "backbone-validation": "⁰.11.5",
    "body-parser": "¹.14.1",
    "bootstrap": "³.3.5",
    "browser-sync": "².9.11",
    "crispy-string": "0.0.2",
    "express": "⁴.13.3",
    "http-proxy": "¹.11.3",
    "jquery": "².1.4",
    "lodash": "³.10.1",
    "morgan": "¹.6.1",
    "sweetalert": "¹.1.3",
    "underscore": "¹.8.3"
  }
}

最容易转换的模块是模型和集合,因为它们没有巨大的依赖关系。

// apps/contacts/models/contact.js
'use strict'

var Backbone = require('backbone');

class Contact extends Backbone.Model {
  // ...
}

module.exports = Contact;

如你所见,模块几乎保持不变。我们只是在文件的末尾添加了 require() 调用和导出语句:

// apps/contacts/contactCollection.js
'use strict';

var Backbone = require('backbone');
var Contact = require('../models/contact');

class ContactCollection extends Backbone.Collection {
  // ...

  get model() {
return Contact;
  }
}

module.exports = ContactCollection;

视图也容易转换。我们只需像对 ContactContactCollection 模块所做的那样添加 require() 调用。在我们继续之前,我们需要对视图进行一个额外的步骤;目前,给定控制器的所有视图都包含在一个单独的脚本中;例如,contactEditor.js 包含 ContactFormContactPreviewPhoneList 等。

由于我们正在模块化项目,将每个视图放入其自己的文件并在需要时调用它是一个好主意。以下展示了这个想法。你有很多理由这样做:为了隔离你的组件进行测试、保持你的文件小、提高维护性以及获得可互换的模块。

在应用程序中使用 Browserify

图 4.3 将视图分割到它们自己的文件中

'use strict';

var Layout = require('../../common').Layout;

class ContactFormLayout extends Layout {
// ...
}

module.exports = ContactFormLayout;

如你所见,将纯 JavaScript 文件转换为 Node 模块非常容易。业务逻辑的代码完全相同。子应用程序控制器依赖于我们在上一步中转换的视图。

'use strict';

var _ = require('underscore');
var Backbone = require('backbone');
var App = require('../../app');
var ContactFormLayout = require('./views/contactFormLayout');
var ContactPreview = require('./views/contactPreview');
var PhoneListView = require('./views/phoneListView');
var EmailListView = require('./views/emailListView');
var ContactForm = require('./views/contactForm');
var PhoneCollection = require('./collections/phoneCollection');
var EmailCollection = require('./collections/emailCollection');

class ContactEditor {
  // ...
}

module.exports = ContactEditor;

应用程序外观依赖于许多子应用程序控制器、模型和集合:

'use strict';

var App = require('../../app');
var ContactList = require('./contactList');
var ContactViewer = require('./contactViewer');
var ContactEditor = require('./contactEditor');
var Contact = require('./models/contact');
var ContactCollection = require('./collections/contactCollection');

function ContactsApp(options) {
  // ...
}

// ...

module.exports = ContactsApp;

路由依赖于子应用程序外观和应用程序基础设施:

'use strict';

var Backbone = require('backbone');
var App = require('../../app');
var ContactsApp = require('./app');

var ContactsRouter = Backbone.Router.extend({
  // ...
});

module.exports = new ContactsRouter();

App 对象负责加载所有子应用程序路由并启动历史模块:

'use strict';

var _ = require('underscore');
var Backbone = require('backbone');
var BackboneValidation = require('backbone-validation');
var swal = require('sweetalert');
var noty = require('noty');
var Region = require('./common').Region;

// Initialize all available routes
require('./apps/contacts/router');

var App = {
start() {
    // The common place where sub-applications will be showed
    App.mainRegion = new Region({el: '#main'});

    // Create a global router to enable sub-applications to
    // redirect to other URLs
    App.router = new DefaultRouter();
    Backbone.history.start();
  },

// ...
};

// ...

module.exports = App;

下一步是通过在 App 对象上调用 start() 方法来启动应用程序;这是从 index.html 文件中完成的:

<script type="text/javascript">App.start();</script>

由于我们正在使用 Browserify 重新打包应用程序,最好在主入口点创建一个新文件:

// main.js
var App = require('./app');

App.start();

一旦我们的应用程序被编写为 Node 模块,我们就可以使用 Browserify 将代码打包成一个单独的脚本:

$ mkdir –p .tmp/js
$ cd app/js
$ browserify main.js -o ../../.tmp/js/app.js

这将创建一个包含所有依赖项的打包文件。为了使用代码的打包版本,我们必须将 index.htm 文件更改为加载它,而不是加载所有单个文件:

<html>
<head>
    // ...
</head>
<body>
// ...
<script src="img/app.js"></script>
</body>
</html>

这应该足够了;然而,应用程序不会启动,因为我们有一个循环依赖问题。

解决循环依赖

有两个相互依赖的模块被称为 循环依赖。在我们的 Contacts 应用程序中,基础设施应用程序依赖于子应用程序路由器,而路由器依赖于应用程序基础设施来加载子应用程序控制器和外观。图 4.4 显示了这是如何表现的。

解决循环依赖

图 4.4 循环依赖

由于循环依赖,无法正确运行应用程序。以下是详细情况。

  • 应用程序模块被执行

  • 应用程序需要 ContactsRouter:

    var ContactsRouter = require('./apps/contacts/router');
    
  • ContactsRouter 需要应用程序模块,但应用程序模块尚未导出:

    var App = require('../../app'); // returns undefined
    
  • ContactsRouterApp 变量接收一个 undefined

  • 应用程序模块继续执行并最终暴露 App 值:

    var App = {
      // ...
    };
    
    module.exports = App;
    
  • ContactsRouter 匹配一个路由,但由于 App 值未定义,它触发了错误:

    startApp() {
      // App = undefined
      return App.startSubApplication(ContactsApp);
    }
    

我们应该以某种方式打破循环。一个简单的方法是在导出 App 模块之后要求它。我们可以在文件顶部从 ContactsRouter 中要求 App 模块,而不是在必要时才这样做:

// apps/contacts/router.js
class ContactsRouter extends Backbone.Router {
  // ...

  startApp() {
    var App = require('../../app');
    var ContactsApp = require('./app');
    return App.startSubApplication(ContactsApp);
  }
}

这是一个简单但有效的方法来打破循环依赖。现在你可以重新打包应用程序并再次运行它。应该可以工作:

$ mkdir –p .tmp/js
$ cd app/js
$ browserify main.js -o ../../.tmp/js/app.js

模块化模板

到目前为止,模板已经在 index.html 文件中声明为脚本标签。虽然这对于小型项目来说是一个好方法,但将所有模板直接放入 HTML 文件中并不是一个好主意。

使用 Browserify,你可以将所有模板文件提取到单独的文件中,具有模块化和更干净的 index.html 文件的优势。模块化模板的另一个好处是,你可以预编译所有模板,从而在用户的浏览器中节省资源。

使用 Browserify,你可以模块化几乎任何模板格式:jade、Handlebars、Underscore 等。它使用图 4.5 中描述的转换过程。如果你使用过其他打包工具,如 webpack,转换类似于预处理器。

模块化模板

图 4.5 转换过程

模板是纯文本;文本被传递到一个函数中,该函数将其编译成一个浏览器 ify 可以处理的 JavaScript 函数,作为一个常规 JavaScript 文件。为了对模板应用必要的转换,你需要安装一个转换插件:

$ npm install --save-dev jstify

转换过程发生在你指示 webpack 在编译时使用 jstify 时:

$ browserify main.js -t [ jstify --engine underscore ] -o ../../.tmp/js/app.js

模板很容易模块化;只需提取脚本标签中的文本并将其放入新文件中:

// apps/contacts/templates/contactListLayout.tpl
<div class="actions-bar-container"></div>
<div class="list-container"></div>
<div class="footer text-muted">
  © 2015\. <a href="#">Mastering Backbone.js</a> by <a href="https://twitter.com/abieealejandro" target="_blank">Abiee Alejandro</a>
</div>

contactListLayout.tpl 现在包含联系列表布局模板的文本。在 ContactListLayout 视图中,你可以将模板作为常规 JavaScript 文件导入,但不要忘记包含 tpl 扩展名:

// apps/contacts/views/contactListLayout.js
'use strict';

var Layout = require('../../../common').Layout;
var template = require('../templates/contactListLayout.tpl');

class ContactListLayout extends Layout {
  constructor(options) {
    super(options);
    this.template = template;
    this.regions = {
      actions: '.actions-bar-container',
      list: '.list-container'
    };
  }

  get className() {
    return 'row page-container';
  }
}

module.exports = ContactListLayout;

当你导入模板时,你会使用一个函数。因为我们的通用视图同时支持 CSS 选择器和预编译模板,所以它应该可以正常工作:

// common.js
render() {
  // Get JSON representation of the model
  var data = this.serializeData();
  var renderedHtml;

  // If template is a function assume that is a compiled
  // template, if not assume that is a CSS selector where
  // the template is defined and is compatible with
  // underscore templates
  if (_.isFunction(this.template)) {
    renderedHtml = this.template(data);
  } else if (_.isString(this.template)) {
    var compiledTemplate = this.compileTemplate();
    renderedHtml = compiledTemplate(data);
  }

  this.$el.html(renderedHtml);

  // Call onRender callback if is available
  if (this.onRender) {
    this.onRender();
  }

  return this;
}

现在你有一个完全模块化的项目,其中每个代码片段都在一个小文件中;这个优势之一是你可以专注于小块代码,从而避免大型文件的开销。

摘要

在本章中,我们学习了 Browserify 是什么以及如何将你的项目组织成 Node 模块,以便以更整洁的方式管理你的代码和依赖。为了使 Contacts 项目与 npm 兼容,我们不得不修改项目的代码;然而,这些更改非常小。

除了 Browserify,还有其他替代方案;require.js 和 AMD 模块定义也是不错的选择。然而,使用 require.js 进行测试可能会非常困难;如果你想要测试独立的模块(单元测试),我不建议你使用 require.js

Webpack 是捆绑和组织你的代码库的另一个流行选择。其主要目的是与前端依赖项一起工作;它可以加载 CommonJS 模块和 AMD 模块。然而,webpack 的配置和管理更为复杂。

Browserify 是捆绑 JavaScript 项目的最受欢迎的选择,比 webpack 更容易配置和维护;使用 Node 用于管理其依赖项的相同工具是有用的,并且它做得很好。

在下一章中,我们将探讨如何在 Backbone 项目中处理文件;通过 RESTful API 处理文件是一个常见问题,因此我们将发现常见的模式和策略。

在第七章,我们将探讨如何使用自动化工具构建应用程序;每次我们更改代码时,我们不必手动运行 Browserify 命令,我们将创建必要的脚本来自动完成这项工作。

第五章。处理文件

当你构建一个 Backbone 应用程序时,你将从一个 RESTful 网络服务中消费资源;然而,大多数 RESTful 服务使用 JSON 格式来编码信息,但 JSON 不适合发送和接收文件。我们如何将文件发送到 RESTful 服务器?

如果你正在开发一个不是 JavaScript 密集型的应用程序,你可以通过 HTML 表单发送文件,但在 单页应用程序SPA)中这不是最好的方法。另一个问题是 Backbone 不提供发送文件的简单机制,因为它与 RESTful 规范不兼容。

但是,Web 应用程序需要处理文件。有一些方法可以处理这个常见问题。例如,你可以在可能包含文件的资源上使用传统的 POST 表单;然而,这并不是一个好的选择。在本章中,你将学习以下内容:

  • 从 Express 服务器处理文件上传

  • 采用策略将文件发送到 RESTful 服务器

  • 上传文件

  • 创建包含文件的资源

我们将首先添加对 Express 服务器上传文件的支持,因为了解服务器如何响应上传请求是很重要的。

Express 服务器

为了演示如何将文件发送到服务器,在本章中我们将使用 Express 的最新版本(撰写本文时可用的最新版本是 Express 4.x)。服务器将负责存储 REST 资源和处理文件上传。请查阅本书的 GitHub 仓库以获取前几章服务器的实现。

目前,当前服务器能够创建、获取、更新和删除联系人资源;我们需要添加一个机制来上传联系人的头像图片。为了简化,应用程序不使用数据库来存储其数据,而是使用散列表在内存中存储所有数据。例如,下面的代码片段演示了如何存储一个联系人:

// Insert a new contact JSON into the contacts array
createContact(req, res) {
var contact = extractContactData(req);

  // Asssign a random id
  contact.id = makeId();
contacts.push(contact);

res.status(201)
.json(contact);
}

将文件附加到资源中

在我们开始在 Express 服务器接收文件之前,我们需要为它设置一个策略。我们仍然想使用 RESTful 服务,所以改变传输数据的格式不是一种选择。

尊重 RESTful 标准(有关文件上传的 REST 设计更多信息,请参阅 bit.ly/1GXqPNY),我们可以在目标资源下附加一个子资源端点来处理上传,这样就不会干扰原始资源。然而,这种方法有一个限制:资源必须先存在,这意味着你不能同时创建一个联系人和其头像照片。

按照这种方法,头像文件上传的端点可以定位在:

http://example.com/api/contacts/10/avatar

将文件附加到资源中

图 5.1 文件上传方案

前面的图显示了服务器处理文件上传的架构;头像端点将处理编码为multipart/form-data的 POST 请求,而不是 JSON,因为这是使用 HTTP 协议上传文件的唯一方式。注意,在端点中包含了联系 ID;这样,一旦文件上传,我们就可以将文件与资源关联起来。尽管端点不接受 JSON 作为输入,但它可以返回 JSON 来通知处理过程:

{
  "success": true,
  "avatar": {
    "file": "something.jpg",
    "url": "http://example.com/avatar/something.jpg"
  }
}

在这个示例结果中,服务器告诉我们可以通过http://example.com/avatar/something.jpg URL 访问头像。我们需要修改联系资源,以包含这个新信息:

{
  "name": "John Doe",
  "email": "john.doe@example.com",
"avatar": {
    "file": "something.jpg",
    "url": "http://example.com/avatar/something.jpg"
  }

}

联系资源现在包括头像信息,以便可以在需要的地方使用它——例如,在联系人列表中。要显示头像图像,你只需要在img标签中包含头像 URL 即可。

服务器也应该能够提供这些文件。在最简单的流程中,你可以将所有头像图像放在一个公共路径中,并将该路径作为常规资源提供服务;这种方法的缺点是,如果有人知道文件名,任何人都可以看到文件。

上传头像照片到联系人

让我们先创建上传头像照片的端点:

// routes.js
var controller = require('./controller');

//...
server.post('/api/contacts/:contactId/avatar', 
controller.uploadAvatar);

表达自身不自动处理文件;它需要一个插件,将原始请求转换为更用户友好的 API。这个插件名为multer;它处理multipart/form-data,将文件保存到临时路径或创建一个缓冲对象,然后提供一个包含元数据信息的 JSON 对象:

// Avatar endpoints
var upload = multer();
server.post('/api/contacts/:contactId/avatar', upload.single('avatar'),
controller.uploadAvatar
);
server.use('/avatar', express.static(__dirname + '/avatar'));

默认配置下,它将所有上传的文件保存到操作系统的临时路径中,在 Unix 系统中是/tmpmulter将在req对象中附加一个files属性,我们可以检查它以检索有关上传文件的信息:

uploadAvatar(req, res, next) {
varcontactId = req.params.contactId;
var filename, fullpath;

  // Ensure that user has sent the file
  if (!_.has(req, 'file')) {
    return res.status(400).json({
      error: 'Please upload a file in the avatar field'
    });
  }

  // File should be in a valid format
var metadata = req.file;
  if (!isValidImage(metadata.mimetype)) {
res.status(400).json({
      error: 'Invalid format, please use jpg, png or gif files'
    });
    return next();
  }

  // Get target contact from database
var contact = _.find(contacts, 'id', contactId);
  if (!contact) {
res.status(404).json({
      error: 'contact not found'
    });
    return next();
  }

  // Ensure that avatar path exists
  if (!fs.existsSync(AVATAR_PATH)) {
fs.mkdirSync(AVATAR_PATH);
  }

  // Ensure unique filename to prevent name colisions
var extension = getExtension(metadata.originalname);
  do {
    filename = generateFilename(25, extension);
fullpath = generateFullPath(filename);
  } while(fs.existsSync(fullpath));

  // Remove previous avatar if any
removeAvatar(contact);

  // Save the file in disk
varwstream = fs.createWriteStream(fullpath);
wstream.write(metadata.buffer);
wstream.end();

  // Update contact by assingn the url of the uploaded file
contact.avatar = {
    file: filename,
url: generateURLForAvatar(filename)
  };

res.json({
    success: true,
    avatar: contact.avatar
  });
}

在第一步中,我们验证用户是否上传了有效的文件,然后从数据库中获取目标用户,如果不存在,则返回 Http 404错误。multer插件将上传的文件存储在内存中,可以在将文件保存到最终路径之前进行处理;例如,我们可能想要生成缩略图文件或处理图像以节省磁盘空间。

我们确保头像路径存在;如果不存在,我们则创建该路径。在接下来的步骤中,我们生成一个要分配给上传文件的文件名,以防止文件名冲突;generateFilename()函数生成该文件名,然后检查它是否已存在;如果存在,则生成另一个文件名,依此类推。

一旦我们为上传的文件找到一个唯一的文件名,我们就将文件从内存缓冲区存储到生成的路径。现在文件在头像路径中,我们可以构建一个 URL,从浏览器中获取图像,并最终将 URL 分配给联系资源中的avatar字段。

显示头像

现在我们可以上传图片,并且联系资源包含了头像的位置信息,我们可以通过将 img 标签指向 Contact 模型中的 avatar.url 属性来在我们的视图中显示头像:

<% if (avatar && avatar.url) { %>
<imgsrc="img/<%= avatar.url %>" alt="Contact photo" />
<% } else { %>
<imgsrc="img/250x250" alt="Contact photo" />
<% } %>

这将显示图片,如果没有则显示默认图片。我们应该修改 Contact 模型以包含默认头像:

// apps/contacts/models/contact.js
'use strict';

var Backbone = require('backbone');

class Contact extends Backbone.Model {
// ...

  get defaults() {
    return {
      name: '',
      phone: '',
      email: '',
      address1: '',
      address2: '',
facebook: '',
      twitter: '',
      google: '',
github: '',
      avatar: null
    };
  }

// ...
}

module.exports = Contact;

如果从服务器未检索到头像图片,则使用空图片。以下截图显示了上传图片时的外观。这足以在需要的地方显示头像图片。显示图片非常简单。在本章的其余部分,我们将看到如何执行上传:

显示头像

图 5.2 显示联系人的头像

从 Backbone 上传图片

为了允许我们从 Backbone 应用程序上传文件,我们应该创建一个输入文件以显示 选择 文件对话框。这可以通过在 ContactEditor 子应用程序中更改 ContactPreview 类来实现,以添加此功能。因此,让我们更改当前模板并添加输入:

<div class="box thumbnail">
<div class="photo">
<% if (avatar && avatar.url) { %>
<imgsrc="img/<%= avatar.url %>" alt="Contact photo" />
<% } else { %>
<imgsrc="img/250x250" alt="Contact photo" />
<% } %>
<input id="avatar" name="avatar" type="file" 
style="display: none" />
</div>
<!-- ... -->
</div>

注意我们创建了一个隐藏的输入文件字段;我们不希望显示输入字段,但希望控件打开 选择文件 对话框。由于输入是隐藏的,当用户点击当前图片时,我们将显示文件选择器:

// apps/contacts/views/contactPreview.js
class ContactPreview extends ModelView {
// ...

  get events() {
    return {
      'click img': 'showSelectFileDialog'
    };
  }

showSelectFileDialog() {
    $('#avatar').trigger('click');
  }

  // ...
}

当用户点击图片时,它会在输入上触发一个点击事件;这将打开 打开文件 对话框,并允许用户从其硬盘驱动器中选择文件。用户选择文件后,浏览器会在文件输入上触发一个 change 事件,我们可以使用该事件来处理选择:

// apps/contacts/views/contactPreview.js
class ContactPreview extends ModelView {
// ...

  get events() {
    return {
      'click img': 'showSelectFileDialog',
'change #avatar': 'fileSelected'
    };
  }

  // ...
}

change 事件将调用 fileSelected() 方法,该方法负责处理所选文件。正如我们在 第一章 中所看到的,Backbone 应用程序的架构 视图不应直接与服务器通信;因此,视图不应进行任何 AJAX 调用。

最好的上传图片位置是在 Contact 模型中,因此视图应该只获取所选文件并将此过程委托给控制器:

// apps/contacts/views/contactPreview.js
class ContactPreview extends ModelView {
  // ...

fileSelected(event) {
event.preventDefault();

var $img = this.$('img');

    // Get a blob instance of the file selected
var $fileInput = this.$('#avatar')[0];
varfileBlob = $fileInput.files[0];

    // Render the image selected in the img tag
varfileReader = new FileReader();
fileReader.onload = event => {
      $img.attr('src', event.target.result);

      // Set the avatar attribute only if the
      // model is new
      if (this.model.isNew()) {
this.model.set({
          avatar: {
url: event.target.result
          }
        });
      }
    };
fileReader.readAsDataURL(fileBlob);

this.trigger('avatar:selected', fileBlob);
  }
}

当选择文件时,我们创建一个 blob 对象,并触发一个带有对象的事件,由控制器进行处理。注意我们使用 HTML 5 API 立即显示所选图片作为头像预览:

// apps/contacts/contactEditor.js
class ContactEditor {
// ...

showEditor(contact) {
    // ...

this.listenTo(contactPreview, 'avatar:selected', blob => {
this.uploadAvatar(contact, blob);
    });
  }
}

uploadAvatar() 方法接受一个文件 blob 作为参数,并将服务器连接委托给 Contact 模型:

// apps/contacts/contactEditor.js
class ContactEditor {
// ...

uploadAvatar(contact, blob) {
    // Tell to others that upload will start
this.trigger('avatar:uploading:start');

contact.uploadAvatar(blob, {
      progress: (length, uploaded, percent) => {
        // Tell to others that upload is in progress
this.trigger('avatar:uploading:progress',
                     length, uploaded, percent);
      },
      success: () => {
        // Tell to others that upload was done successfully
this.trigger('avatar:uploading:done');
      },
      error: err => {
        // Tell to others that upload was error
this.trigger('avatar:uploading:error', err);
      }
    });
  }
}

控制器将触发 'avatar:uploading:*' 事件以反映上传过程的状态。这些事件可以被视图监听,以向用户提供视觉反馈。图 5.3 图形化地显示了控制器和视图之间的通信:

从 Backbone 上传图片

图 5.3 视图和控制器之间的事件通信

联系模型中的 uploadEvent() 方法接受一个 blob 对象作为第一个参数,这是将要上传的文件,以及一个 options 对象,其中包含三个可能被调用的函数,这些函数将在与服务器通信的过程中被调用。

如你所猜,如果服务器接受文件或发生错误,将分别调用 successerror 回调。大文件将被分割并分块上传到服务器;当块在服务器上接收时,将调用 progress() 回调。通过 progress() 处理程序提供的信息,我们可以更新进度条以向用户显示进度:

// apps/contacts/views/contactPreview.js
class ContactPreview extends ModelView {
  constructor(options) {
    super(options);
this.template = template;

this.model.on('change', this.render, this);

    if (options.controller) {
this.listenTo(
options.controller, 'avatar:uploading:start',
this.uploadingAvatarStart, this
      );
this.listenTo(
options.controller, 'avatar:uploading:done',
this.uploadingAvatarDone, this
      );
this.listenTo(
options.controller, 'avatar:uploading:error',
this.uploadingAvatarError, this
      );
    }
  }

uploadingAvatarStart() {
this.originalAvatarMessage = this.$('span.info').html();
this.$('span.notice').html('Uploading avatar...');
  }

uploadingAvatarDone() {
this.$('span.notice').html(this.originalAvatarMessage || '');
  }

uploadingAvatarError() {
this.$('span.notice').html(
'Can\'t upload image, try again later'
);
  }
}

由于事件是由控制器触发的,视图更新显示给用户的消息,因此用户可以看到是否发生错误,或者提供上传消息以显示应用程序正在执行的操作。

我们应该在创建时将控制器实例传递给视图:

class ContactEditor {
// ...

showEditor(contact) {
    // ...
varcontactPreview = new ContactPreview({
      controller: this,
      model: contact
    });
  }
}

使用 AJAX 上传文件

Client 模型接收 blob 对象,构建到 avatar 端点的 URL,并对回调对象进行适当的调用:

// apps/contacts/models/contact.js
class Contact extends Backbone.Model {
  // ...

uploadAvatar(imageBlob, options) {
    // Create a form object to emulate a multipart/form-data
varformData = new FormData();
formData.append('avatar', imageBlob);

varajaxOptions = {
url: '/api/contacts/' + this.get('id') + '/avatar',
      type: 'POST',
      data: formData,
      cache: false,
contentType: false,
processData: false
    };

    options = options || {};

    // Copy options to ajaxOptions
_.extend(ajaxOptions, _.pick(options, 'success', 'error'));

    // Attach a progress handler only if is defined
    if (options.progress) {
ajaxOptions.xhr = function() {
varxhr = $.ajaxSettings.xhr();

        if (xhr.upload) {
          // For handling the progress of the upload
xhr.upload.addEventListener('progress', event => {
            let length = event.total;
            let uploaded = event.loaded;
            let percent = uploaded / length;

options.progress(length, uploaded, percent);
          }, false);
        }

        return xhr;
      };
    }

$.ajax(ajaxOptions);
  }

  // ...
}

看看模型如何从其自身数据构建端点,这样视图就可以与任何服务器连接解耦。由于 multipart/form-data POST 不会被浏览器原生管理,我们应该创建一个表示表单数据结构的 FormData 对象,并添加一个 avatar 字段(服务器期望的字段名)。

$.ajax() 调用中,键属性是 processData,设置为 false;你可以在 jQuery 文档中阅读以下内容:

默认情况下,传递给数据选项的对象(技术上,任何非字符串)将被处理并转换成查询字符串,适合默认的内容类型 "application/x-www-form-urlencoded"。如果你想要发送一个 DOMDocument 或其他未处理的数据,请将此选项设置为 false。

如果你不将此属性设置为 false,或者保留默认值,jQuery 将尝试转换 formData 对象,文件将不会发送。

如果在 options 对象中设置了进度属性,我们将覆盖 jQuery 调用的原始 xhr() 函数以获取 XMLHttpRequest 对象实例;这允许我们在上传文件时监听浏览器触发的 progress 事件。

在创建时上传头像图像

如我们所见,要上传并附加文件到资源,该资源必须已经存在。我们如何创建一个带有附件的文件资源?我们如何创建一个包含头像图像的联系人?

要这样做,我们需要分两步创建资源。在第一步中,我们创建资源本身,然后在第二步中,我们可以上传所有我们想要上传到该资源的文件。是的,这不可能在单个服务器连接中完成,至少在没有对要发送的文件进行编码的情况下:

在创建时上传头像图像

图 5.4 创建联系过程

前面的图示显示了这个过程是如何进行的。注意,模型负责处理这些连接,而控制器则协调通信的顺序和错误处理。正如我们之前看到的,ContactEditor 触发了几个视图可以使用的事件,向用户展示正在发生的事情。

视图可以保持原样;我们只需通过更改 saveContact() 方法的行为来修改 ContactEditor 控制器。然而,我们希望保留用户在做出选择时上传图片的功能。如果联系人模型是新的,此功能将破坏应用程序,因为没有有效的端点来上传头像:

class ContactEditor {
// ...

showEditor(contact) {
    // ...

    // When avatar is selected, we can save it inmediatly if the
    // contact already exists on the server, otherwise just
    // remember the file selected
this.listenTo(contactPreview, 'avatar:selected', blob => {
this.avatarSelected = blob;

      if (!contact.isNew()) {
this.uploadAvatar(contact);
      }
    });
  }
}

当选择头像时,我们不会立即将文件上传到服务器,而是检查联系人是否是新的。如果模型不是新的,我们可以通过调用 uploadAvatar() 方法来执行上传;否则,我们将在 avatarSelected 属性中保留 blob 对象的引用,uploadAvatar() 方法将在被调用时使用它。

saveContact() 方法负责协调前面图示中描述的算法:

// apps/contacts/contactEditor.js
class ContactEditor {
saveContact(contact) {
varphonesData = this.phones.toJSON();
varemailsData = this.emails.toJSON();

contact.set({
      phones: phonesData,
      emails: emailsData
    });

    if (!contact.isValid(true)) {
      return;
    }

varwasNew = contact.isNew();

    // The avatar attribute is read-only
    if (contact.has('avatar')) {
contact.unset('avatar');
    }

    function notifyAndRedirect() {
      // Redirect user to contact list after save
App.notifySuccess('Contact saved');
App.router.navigate('contacts', true);
    }

contact.save(null, {
      success: () =>{
        // If we are not creating an user it's done
        if (!wasNew) {
notifyAndRedirect();
          return;
        }

        // On user creation send the avatar to the server too
this.uploadAvatar(contact, {
          success: notifyAndRedirect
        });
      },
error() {
        // Show error message if something goes wrong
App.notifyError('Something goes wrong');
      }
    });
  }
  // ...
}

在调用联系人模型的 save() 方法之前,有必要保存模型是否是新的;如果我们在此方法之后调用它,isNew() 方法将始终返回 true

如果模型不是新的,那么头像图像的任何更改已经通过 'avatar:selected' 事件处理程序上传,所以我们不需要再次上传图像。但如果图像是新的,那么我们应该通过调用 uploadAvatar() 方法来上传头像;请注意,该方法接受一个 options 对象来注册回调。这是必要的,以便向用户提供反馈;上传完成后,它调用 notifyAndRedirect() 函数来显示通知消息,并返回到联系人列表。

我们需要更改 uploadAvatar() 的实现,以包括前面描述的回调,并立即在它使用 avatarSelected 属性时接收 blob:

// apps/contacts/contactEditor.js
uploadAvatar(contact, options) {
  // Tell to others that upload will start
this.trigger('avatar:uploading:start');

contact.uploadAvatar(this.avatarSelected, {
    progress: (length, uploaded, percent) => {
      // Tell to others that upload is in progress
this.trigger('avatar:uploading:progress',
                   length, uploaded, percent);
      if (options &&_.isFunction(options.success)) {
options.success();
      }
    },
    success: () => {
      // Tell to others that upload was done successfully
this.trigger('avatar:uploading:done');
    },
    error: err => {
      // Tell to others that upload was error
this.trigger('avatar:uploading:error', err);
    }
  });
}

方法基本上是相同的;我们只是添加了 options 回调,并更改了 blob 对象的来源。

编码上传文件

上传文件的另一种方法是将其编码为 base64。当你将二进制文件编码为 base64 时,结果是我们可以用作请求对象属性的字符串。

虽然在资源中创建附加文件的对象或将其用作服务器上的另一个资源可能很有用,但这不是推荐的方法。这种方法有一些限制:

  • 如果后端服务器是节点,线程将锁定,直到服务器解码 base64 字符串。这将导致性能低下的应用程序。

  • 你不能上传大量数据。

  • 如果文件很大,Backbone 应用程序将冻结,直到文件被编码为 base64

如果你上传的数据量非常小,并且没有大量的流量,你可以使用这种技术;否则,我鼓励你避免使用。我们不必上传文件,可以对其进行编码:

class ContactEditor {
  // ...

showEditor(contact) {
      // ...
this.listenTo(contactPreview, 'avatar:selected', blob => {
this.setAvatar(contact, blob);
    });
  }

setAvatar(contact, blob) {
varfileReader = new FileReader();

fileReader.onload = event => {
      let parts = event.target.result.split(',');
contact.set('avatarImage', parts[1]);
    };

fileReader.readAsDataURL(blob);
  }
}

当然,服务器实现应该能够解码avatarImage并将其存储为图像文件。

摘要

在本章中,我们看到了如何将文件上传到服务器;这不是唯一的方法,但这是更广泛和灵活的方法。另一种可能的方法是在浏览器中将图像序列化为base64,然后将输出字符串设置为模型中的属性;当保存十个模型时,编码为base64的文件将成为有效负载的一部分。服务器应该解码字符串并将结果作为文件处理。

我们看到了如何将视图与业务逻辑解耦。视图应该只处理 DOM 事件并触发业务逻辑级别的事件;然后控制器可以处理 blob 对象而不是低级别的 DOM 节点。这种方法帮助我们将上传处理从视图移动到模型,这是理想的做法。

最后,我们处理了创建过程;我们不能同时创建资源并附加文件。我们应该首先创建资源,然后根据需要将所有文件发送到服务器。

在下一章中,你将学习如何直接在浏览器中存储信息。与其使用 RESTful 服务器,不如运行不需要服务器即可运行的独立 Web 应用程序。

第六章:存储浏览器中的数据

Backbone 主要被设计用来与 RESTful API 服务器一起工作;然而,你并不总是想在服务器上存储数据以供离线应用程序使用,或者为了打破应用程序加载,将缓存数据存储在浏览器中。

在用户浏览器中存储数据,我们有两种选择:使用 localStorage 或新的 IndexedDB API。虽然 localStorage 在主流浏览器中得到了广泛的支持,但 IndexedDB 是一个尚未在近期得到支持的全新规范。目前还有一个可用的选项,但已处于弃用状态,那就是 Web SQL。如果你正在开发现代网络应用程序,你应该避免使用 Web SQL。

在本章中,你将学习以下主题:

  • localStorage 基础

  • IndexedDB 基础

  • 使用 localStorage 而不是 RESTful 服务器来存储信息

  • 使用 IndexedDB 而不是 RESTful 服务器来存储信息

localStorage

localStorage 是最简单且支持最广泛的浏览器数据存储。在撰写本书时,它几乎在所有主流浏览器中都得到了支持。如图所示,唯一不支持 localStorage 的浏览器是 Opera Mini:

localStorage

图 6.1 localStorage 的浏览器支持

localStorage 是一个简单的键/值数据库,只能存储文本。在 localStorage 中,你有三个主要方法来访问数据:setItem()getItem()removeItem()。有了这三个函数,你可以很好地管理存储中的数据。

localStorage 的缺点是它没有表或集合,因此所有数据都是混合的;localStorage 的另一个问题是它限制在 5 Mb 的信息量。如果你的存储需求超过这个量,你将需要使用 IndexedDB。

从 localStorage 开始

要在 localStorage 中存储数据,你需要调用 localStorage 全局对象中的 setItem() 方法:

localStorage.setItem('myKey', 'myValue');
localStorage.setItem('name', 'John Doe');

就这样,这些指令会在浏览器中存储信息。我们可以在以下图中探索这些指令的结果:

从 localStorage 开始

图 6.2 Google Chrome 和 localStorage

存储在 localStorage 中的数据是按站点组织的,这意味着你只能访问存储在你站点上的数据。在上面的图中,你可以看到左侧可用的站点(http://localhost:4000)。在右侧,你可以探索我们使用 setItem() 方法为给定站点存储的数据。

要从 localStorage 中检索信息,你必须使用 getItem() 方法:

localStorage.getItem('myKey'); // myValue
localStorage.getItem('name'); // John Doe
localStorage.getItem('notExists'); // null

要从存储中删除一个项目,我们可以使用 removeItem() 方法:

localStorage.removeItem('name');
localStorage.getItem('name'); // null

如前所述,localStorage 只能存储字符串。然而,我们想要存储对象,我们该如何做呢?

varmyObj = {name: 'John Doe', age: 26};
localStorage.setItem('object', myObj);
localStorage.getItem('object'); // [Object object]

哎呀……这不是我们预期的结果。localStorage 在存储对象之前会自动将其转换为字符串。你可以使用JSON.stringify()函数序列化对象,这样 localStorage 接收到的就是一个字符串而不是对象:

varmyObj = {name: 'John Doe', age: 26};
var serialized = JSON.stringify(myObj);

localStorage.setItem('object', serialized);

要获取存储的对象,你可以使用JSON.parse()的逆函数,它将字符串转换为对象:

var data = localStorage.getItem('object');
varobj = JSON.parse(data);

这就是如何在 localStorage 中存储和检索对象的方法。在存储和检索对象时,你需要进行编码和解码。由于 JSON 函数的使用非常频繁,不建议在 localStorage 中存储大对象;每次编码或解码对象时,JavaScript 线程都会阻塞该对象。

Backbone 和 localStorage

要在 localStorage 中存储 Backbone 模型,你可以使用ID属性作为键,将序列化数据作为值。然而,请记住,localStorage 中的所有数据都是混合的,这种策略会导致标识符冲突。

假设你有两个不同的模型(联系人和发票)具有相同的ID;当你将其中一个存储在 localStorage 中时,它会覆盖另一个。

localStorage 的另一个问题是,当你想在从存储中获取项目之前从存储中检索数据时,你需要知道它具有哪个键。然而,在 localStorage 中,我们没有关于当前存储中 ID 的信息,因此,我们需要一种方法来跟踪在给定时间存储中的 ID。

为了处理这些问题,你可以在存储中创建一个已知键作为给定集合可用 ID 的索引。以下是如何工作的示例:

var data = localStorage.get('contacts'); // index name
varavailableIds = data.split(',');
varcontactList = [];

// Get all contacts
for (leti = 0; i<availableIds.length; i++) {
let id = availableIds[i];
let contact = JSON.parse(localStorage.getItem(id));
contactList.push(contact);
}

为了防止具有相同 ID 的模型集合之间的冲突,你可以为集合项生成前缀键,这样你就可以使用像contacts-1这样的键,而不是像1这样的数字键:

var data = localStorage.get('contacts'); // 1, 5, 6
varavailableIds = data.split(',');
varcontactList = [];

// Get all contacts
for (let i = 0; i<availableIds.length; i++) {
let id = 'contacts-' + availableIds[i];
let contact = JSON.parse(localStorage.getItem(id));
contactList.push(contact);
}

在 localStorage 中存储模型

现在你已经知道了如何从 localStorage 存储和检索数据,是时候存储你的模型了。在下面的图中,你可以看到如何将数据存储在本地而不是远程服务器。

默认情况下,当你对一个 Backbone 模型调用save()方法时,它会将操作转换为对 RESTFul 服务器的 HTTP 请求。为了在本地存储数据,你需要更改默认行为,以便使用 localStorage 而不是发起 HTTP 请求;你将在下一节中学习如何做到这一点。

为了使存储层易于维护,你首先需要为 localStorage 创建一个 Backbone 驱动器。驱动器的职责是从 localStorage 存储和检索数据,以便 Backbone 和 localStorage 之间的连接更加简单:

在 localStorage 中存储模型

图 6.3 在 localStorage 中存储模型

在下一节中,我将向你展示如何构建DataStore驱动器,以便在 localStorage 中存储 Backbone 模型。

在 localStorage 中存储 Backbone 模型

现在是时候使用你关于 localStorage 的知识来存储和检索对象了。DataStore 对象负责将模型转换为字符串以便存储在 localStorage 中:

class DataStore {
  constructor(name) {
    this.name = name;

    // Keep track of all ids stored for a particular collection
this.index = this.getIndex();
  }

getIndex() {
var index = localStorage.getItem(this.name);
    return (index &&index.split(',')) || [];
  }
}

DataStore 对象需要一个名称作为集合索引的前缀。第一个用例是创建一个新的条目:

class DataStore {
// ...

  create(model) {
    // Assign an id to new models
    if (!model.id&& model.id !== 0) {
      model.id = generateId();
model.set(model.idAttribute, model.id);
    }

    // Save model in the store with an unique name,
    // e.g. collectionName-modelId
localStorage.setItem(
this.itemName(model.id), this.serialize(model)
    );

    // Keep track of stored id
this.index.push(model.get(model.idAttribute));
this.updateIndex();

    // Return stored model
    return this.find(model);
  }
}

当创建一个新的模型时,它使用 generateId() 函数分配一个新的 ID:

var crispy = require('crispy-string');

const ID_LENGTH = 10;

function generateId() {
 return crispy.base32String(ID_LENGTH);
}

itemName() 函数根据模型 ID 生成一个在 localStorage 中使用的键;serialize() 方法将模型转换为一个准备存储在 localStorage 中的 JSON 字符串。最后,DataStore 中的 index 属性跟踪所有可用的 ID,因此我们应该将模型 ID 推送到索引中。

对于更新方法,我们将覆盖模型的当前值:

class DataStore {
// ...

  update(model) {
    // Overwrite the data stored in the store,
    // actually makes the update
localStorage.setItem(
this.itemName(model.id), this.serialize(model)
    );

    // Keep track of the model id in the collection
varmodelId = model.id.toString();
    if (_.indexOf((this.index, modelId)) >= 0) {
this.index.push(modelId);
this.updateIndex();
    }

    // Return stored model
    return this.find(model);
  }
}

如果你使用 setItem() 方法在 localStorage 中对一个已存在的键进行操作,原来的值会被新的值覆盖,这实际上是一个更新操作。

当你在寻找一个模型时,你需要设置模型的 ID,并在其上调用 fetch() 方法以从服务器检索数据。在我们的 DataStore 中,我们可以将此操作称为 find

class DataStore {
// ...

  find(model) {
    return this.deserialize(
localStorage.getItem(this.itemName(model.id))
    );
  }
}

find() 方法非常简单,它尝试使用 itemName() 方法构建的 ID 从 localStorege 中获取数据;如果找不到模型,它将返回一个 null 值。虽然返回单个模型非常简单,但检索它们的列表是一个更复杂的操作:

class DataStore {
// ...

findAll() {
var result = [];

    // Get all items with the id tracked for the given collection
    for (let i = 0, id, data; i<this.index.length; i++) {
      id = this.index[i];
      data = this.deserialize(localStorage.getItem(
this.itemName(id)
      ));

      if (data) {
result.push(data);
      }
    }

    return result;
  }
}

此方法遍历给定集合的所有可用键;对于列表中的每个项目,它将其从字符串转换为 JSON 对象。所有项目都聚合到一个数组中,并作为结果返回。

要从 DataStore 中删除一个项目,你需要从 localStorage 中删除其值并丢弃与其相关的索引:

class DataStore {
// ...

  destroy(model) {
    // Remove item from the store
localStorage.removeItem(this.itemName(model.id));

    // Rmoeve id from tracked ids
varmodelId = model.id.toString();
    for (let i = 0; i<this.index.length; i++) {
      if (this.index[i] === modelId) {
this.index.splice(i, 1);
      }
    }
this.updateIndex();

    return model;
  }
}

当在 localStorage 中修改模型集合时,我们使用 updateIndex() 方法;它应该存储一个 ID 列表作为字符串:

class DataStore {
// ...

  // Save the ids comma separated for a given collection
updateIndex() {
localStorage.setItem(this.name, this.index.join(','));
  }
}

模型 ID 使用集合名称及其 ID 生成:

class DataStore {
// ...
itemName(id) {
    return this.name + '-' + id;
  }
}

DataStore 类本身可以存储和检索 localStorage 中的模型;然而,它并没有完全集成到 Backbone 中。在下一节中,我们将探讨 Backbone 如何从 RESTful API 存储和检索模型,以及如何更改此行为以使用 DataStore 驱动程序。

Backbone.sync

负责处理 RESTful 服务器与 Backbone 应用程序之间连接的是 Backbone.sync 模块。它将 fetch()save() 操作转换为 HTTP 请求:

  • fetch() 被映射为一个 read 操作。这将使得对具有模型 ID 的模型或集合的 urlRoot 属性执行 GET 请求。

  • save() 被映射为 createupdate 操作,这取决于 isNew() 方法:

    • 如果模型没有 ID(isNew() 方法返回 true),这将映射为 create 操作。执行一个 POST 请求。

    • 如果模型已经有一个 ID(isNew() 方法返回 false),这将映射为 update。执行 PUT 请求。

  • destroy() 被映射为 delete 操作。这将导致对具有模型 ID 的模型或集合的 urlRoot 属性执行 DELETE 操作。

为了更好地理解 Backbone.sync 是如何工作的,考虑以下示例:

// read operation will issue a GET /contacts/1
varjohn= new Contact({id: 1});
john.fetch();

// update operation will issue a PUT /contacts/1
john.set('name', 'Johnson');
john.save();

// delete operation will issue a DELETE /contacts/1
john.destroy();
varjane = new Contact({name: 'Jane'});
// create operation will issue a POST /contacts
jane.save();

如你在 Backbone 文档中所读到的,Backbone.sync 有以下签名:

sync(method, model, [options])

在这里,方法是将要执行的操作(readcreateupdatedelete)。你可以轻松地覆盖这个函数,以便将请求重定向到 localStorage 而不是 RESTful 服务器:

Backbone.sync = function(method, model, options) {
var response;
var store = model.dataStore ||
 (model.collection&&model.collection.dataStore);
var defer = Backbone.$.Deferred();

 if (store) {
 // Use localstorage in the model to execute the query
 switch(method) {
 case 'read':
 response = model.id ?store.find(model) : store.findAll();
 break;

 case 'create':
 response = store.create(model);
 break;

 case 'update':
 response = store.update(model);
 break;

 case 'delete':
 response = store.destroy(model);
 break;
 }
 }

 // Respond as promise and as options callbacks
 if (response) {
defer.resolve(response);
 if (options &&options.success) {
options.success(response);
 }
 } else {
defer.reject('Not found');
 if (options &&options.error) {
options.error(response);
 }
 }

 return defer.promise();
};

虽然 localStorage API 是同步的,但它不需要使用回调或承诺;然而,为了与默认实现兼容,我们需要创建一个 Deferred 对象并返回一个 promise

如果你不知道什么是承诺或 Deferred 对象,请参阅 jQuery 文档以获取更多关于它的信息。承诺如何工作的解释超出了本书的范围。

之前的 Backbone.sync 实现正在寻找模型/集合中的 dataStore 属性。为了正确存储,这些对象应包含该属性。正如你可能猜到的,它应该是我们 DataStore 驱动程序的实例:

// apps/contacts/models/contact.js
class Contact extends Backbone.Model {
  constructor(options) {
    super(options);

this.validation = {
      name: {
        required: true,
minLength: 3
      }
    };

this.dataStore = new DataStore('contacts');
  }
  // ...
}

// apps/contacts/collections/contactCollection.js
class ContactCollection extends Backbone.Collection {
  constructor(options) {
    super(options);
this.dataStore = new DataStore('contacts');
  }

// ...
}

我们之前为 localStorage 所做的实现受到了 Backbone.localStorage 插件的启发。如果你想将所有模型存储在浏览器中,请使用社区支持的插件。

由于 localStorage 的限制,不适合在上面存储头像图片,因为我们只需存储少量记录就会达到限制。

使用 localStorage 作为缓存

数据存储驱动程序对于开发不需要从远程服务器获取和存储数据的小型应用程序很有用。它足以原型化小型 Web 应用程序或存储配置数据在浏览器中。

然而,驱动程序的另一个用途可以是缓存服务器响应以加快应用程序性能:

// cachedSync.js
var _ = require('underscore');
var Backbone = require('backbone');

function getStore(model) {
  return model.dataStore;
}

module.exports =  _.wrap(Backbone.sync, (sync, method, model, options) => {
var store = getStore(model);

  // Try to read from cache store
  if (method === 'read') {
    let cachedModel = getCachedModel(model);

    if (cachedModel) {
      let defer = Backbone.$.Deferred();
defer.resolve(cachedModel);

      if (options &&options.success) {
options.success(cachedModel);
      }

      return defer.promise();
    }
  }

  return sync(method, model, options).then((data) => {
    // When getting a collection data is an array, if is a
    // model is a single object. Ensure that data is always
    // an array
    if (!_.isArray(data)) {
      data = [data];
    }

data.forEach(item => {
      let model = new Backbone.Model(item);
cacheResponse(method, store, model);
    });
  });
});

当应用程序需要读取数据时,它首先尝试从 localStorage 中读取数据。如果没有找到模型,它将使用原始的 Backbone.sync 函数从服务器获取数据。

当服务器响应时,它将响应存储在 localStorage 中供将来使用。为了缓存服务器响应,当模型被删除时,应该存储服务器响应或从缓存中删除模型:

// cachedSync
function cacheResponse(method, store, model) {
  if (method !== 'delete') {
updateCache(store, model);
  } else {
dropCache(store, model);
  }
}

从缓存中删除模型相当简单:

function dropCache(store, model) {
  // Ignore if cache is not supported for the model
  if (store) {
store.destroy(model);
  }
}

在缓存中存储和检索数据更为复杂;你应该有一个缓存过期策略。对于这个项目,我们将缓存响应在 15 分钟后过期,这意味着我们将删除缓存数据然后进行 fetch

// cachedSync.js
// ...

const SECONDS = 1000;
const MINUTES = 60 * SECONDS;
const TTL = 15 * MINUTES;

function cacheExpire(data) {
  if (data &&data.fetchedAt) {
    let now = new Date();
    let fetchedAt = new Date(data.fetchedAt);
    let difference = now.getTime() - fetchedAt.getTime();

    return difference > TTL;
  }

  return false;
}

function getCachedModel(model) {
var store = getStore(model);

  // If model does not support localStorage cache or is a
  // collection
  if (!store&& !model.id) {
    return null;
  }

var data = store.find(model);

  if (cacheExpire(data)) {
dropCache(store, model);
    data = null;
  }

  return data;
}

fetchedAt属性用于显示我们从服务器获取数据的时间。当缓存过期时,它会从缓存中删除模型并返回null以强制服务器fetch

当一个模型被缓存时,它应该在第一次获取时设置fetchedAt属性:

// cachedSync.js
function updateCache(store, model) {
  // Ignore if cache is not supported for the model
  if (store) {
varcachedModel = store.find(model);

    // Use fetchedAt attribute mdoel is already cached
    if (cachedModel&&cachedModel.fetchedAt) {
model.set('fetchedAt', cachedModel.fetchedAt);
    } else {
model.set('fetchedAt', new Date());
    }

store.update(model);
  }
}

最后,我们需要替换原始的 Backbone.sync 函数:

// app.js
varcachedSync = require('./cachedSync');

// ...

Backbone.sync = cachedSync;

IndexedDB

如前几节所示,localStorage 非常简单;然而,它有 5MB 存储容量的限制。另一方面,IndexedDB 没有这个限制;然而,它有一个复杂的 API。IndexedDB 的主要缺点是它不是在所有主要浏览器上完全受支持:

IndexedDB

图 6.4:IndexedDB 的浏览器支持

在撰写本书时,IndexedDB 在 Chrome 和 Firefox 上完全受支持,而 Safari 和 IE 有部分支持。

localStorage 和 IndexedDB 之间的一大区别是 IndexedDB 不是一个键/值存储;IndexedDB 有集合(表)和查询 API。如果你使用过 MongoDB,你将熟悉 IndexedDB 存储数据的方式。

开始使用 IndexedDB

一个 IndexedDB 数据库由一个或多个存储组成。存储就像一个 JSON 容器,它包含一组 JSON。如果你使用过 SQL,那么存储就像一个表。如果你使用过 MongoDB,存储就像一个集合。与 MongoDB 相同,IndexedDB 是无模式的,这意味着你不需要定义记录(JSON)的模式。

无模式的一个后果是集合中的数据不是异构的,你可以在同一个存储中存储不同类型的 JSON 对象。例如,你可以在同一个存储中存储联系人和发票数据。

IndexedDB 比 localStorage 更灵活、更强大;然而,强大的力量伴随着巨大的责任。你将不得不处理存储、游标、索引、事务、迁移和异步 API:

开始使用 IndexedDB

图 6.5:IndexedDB

数据库版本

数据库通常随时间而变化;可能需要一个新的存储或添加一个索引来适应新功能。所有 IndexedDB 数据库都有一个版本号。当你第一次创建一个新的数据库时,它从版本 1 开始。借助每个版本号,你可以根据需要定义存储和索引。

IndexedDB 不允许你创建新的存储或索引,除非你更改了版本号。当检测到新的版本号时,IndexedDB 进入versionchange状态并调用onupgradedneeded()回调,你可以使用它来修改数据库。

每次你更改版本号时,你都有机会在onupgradedneeded()回调中运行数据库迁移。每次你使用 IndexedDB 打开连接时,你都可以指定一个版本号:

indexedDB.open(<database name>, <version number>)

当你第一次打开数据库时,IndexedDB 进入versionchange状态并调用onupgradedneeded()回调。

创建存储

要在 IndexedDB 上创建存储,你需要将数据库置于版本更改状态,你可以通过以下两种方式之一来完成:

  1. 创建一个新的数据库。

  2. 更改数据库的版本号。

在以下示例中,我们正在创建一个名为 library 的新数据库:

var request = indexedDB.open("library");

// In this callback the database is in the versionchange state
request.onupgradeneeded = function() {
  // The database did not previously exist, so that
  // we can create object stores and indexes.
vardb = request.result;
var store = db.createObjectStore("books", {keyPath: "isbn"});

  // Populate with initial data.
store.put({
title: "Quarry Memories",
 author: "Fred",
isbn: 123456});
store.put({
title: "Water Buffaloes",
 author: "Fred",
isbn: 234567});
store.put({
title: "Bedrock Nights",
 author: "Barney",
isbn: 345678});
};

request.onsuccess = function() {
window.db = request.result;
};

当调用open()方法时,它返回一个请求对象,我们可以使用它来注册当数据库成功打开并准备好使用时调用的onsuccess()回调。由于我们正在创建一个新的数据库,所以会调用onupgradeneeded()回调。

数据库处理程序位于request对象的result属性中。你可以使用数据库处理程序的createObjectStore()方法来创建一个新的存储:

createObjectStore(name, options)

createObjectStore()方法的第一个参数是存储的名称,在我们的例子中是 library。options参数应该是一个普通对象,其中可用的字段如下:

选项名称 描述 默认值
autoIncrement 这会自动增加主键属性 false
keyPath 这是对象中用作主键的属性名称 null

在对象存储创建之后,会返回一个存储处理程序,你可以使用它来在最近创建的对象存储中插入新记录。put()方法用于在存储中插入新记录,它接受要存储的 JSON 作为参数:

创建存储

图 6.6:Google Chrome 中的 IndexedDB

如前图所示,对象存储具有我们使用put()方法在onupgradeneeded事件中插入的对象。

删除数据库

你始终可以使用deleteDatabase()方法删除数据库。如果你做错了什么并且想从头开始,只需删除数据库:

indexedDB.deleteDatabase('library');

向对象存储添加元素

你已经看到了如何创建和删除存储。现在,你将看到如何在onupgradeneeded()回调之外连接到数据库并向对象存储添加记录:

vartx = db.transaction("books", "readwrite");
var store = tx.objectStore("books");

store.put({
  title: "Quarry Memories",
  author: "Fred",
isbn: 123456
});
store.put({
  title: "Water Buffaloes",
  author: "Fred",
isbn: 234567
});
store.put({
  title: "Bedrock Nights",
  author: "Barney",
isbn: 345678
});

tx.oncomplete = function() {
console.log('Records added!');
};

注意,我们正在创建一个 IndexedDB 事务。W3C 的 IndexedDB 规范将事务定义为如下:

事务用于与数据库中的数据进行交互。每当数据被读取或写入数据库时,都是通过使用事务来完成的。

事务提供了一些防止应用程序和系统失败的保护。可以使用事务来存储多个数据记录或条件性地修改某些数据记录。事务代表了一组原子且持久的对数据访问和数据修改操作。

indexedDB对象的transaction()方法有两个参数:作用域和模式,如下表所示:

参数 描述 示例
作用域 事务与之交互的存储或存储 'books',['contacts', 'invoices']
模式 这表示将要进行的交互类型 'readonly', 'readwrite'

当事务创建时,您可以通过事务对象的 objectStore() 方法访问存储,该方法返回一个对象存储处理程序,您可以使用它来添加或删除记录。

put() 方法用于将对象插入到存储中;然而,该方法是非同步的,这意味着记录不会像在 localStorage 中那样立即存储。您应该在事务对象中注册一个 oncomplete() 回调函数,当操作完成时将被调用。

执行查询

要查询对象存储中的数据,您需要打开一个 readonly 事务:

vartx = db.transaction("books", "readonly");
var store = tx.objectStore("books");

var request = store.openCursor(IDBKeyRange.only(123456));
request.onsuccess = function() {
var cursor = request.result;
  if (cursor) {
    // Called for each matching record.
console.log(cursor.value);
cursor.continue();
} else {
    // No more matching records, cursor === null
console.log('Done!');
  }
};

查询应通过使用 openCursor() 方法打开游标来完成。openCursor() 方法的第一个参数是一个查询,它应该是一个 IDBKeyRange 对象:

  • only(value): 它查找该值,例如一个 == 操作

  • lower(value): 它查找小于或等于该值的值,例如一个 <= 操作

  • lowerOpen(value): 它查找小于该值的值,例如一个 < 操作

  • upper(value): 它查找大于或等于该值的值,例如一个 >= 操作

  • upperOpen(value): 它查找大于该值的值,例如一个 > 操作

这些是一些可用的查询;请参阅 IndexedDB 规范以获取所有可用查询的完整列表。IndexedDB 使用查询来比较作为参数传递的值与存储中的对象;然而,存储中比较的是哪个属性?答案是 keyPath 中指定的键。在我们的例子中,将使用 isbn 属性。

游标将为每个找到的对象重复调用 onsuccess() 回调,您应该在游标对象上调用 continue() 方法以获取下一个对象。当没有更多对象时,结果将是 null

如果您想根据不同的属性查询对象,您应该在存储中为所需的属性创建索引。使用不同的版本号向对象存储添加新索引:

var request = indexedDB.open("library", 2);

request.onupgradeneeded = function() {
vardb = request.result;
var store = db.createObjectStore("books", {keyPath: "isbn"});
vartitleIndex = store.createIndex("by_title", "title", {
    unique: true
  });
varauthorIndex = store.createIndex("by_author", "author");

  // ...
};

request.onsuccess = function() {
db = request.result;

vartx = db.transaction("books", "readonly");
var store = tx.objectStore("books");
var index = store.index("by_title");

var request = index.get("Bedrock Nights");
request.onsuccess = function() {
    // ...
  };
};

如前例所示,您可以使用索引来查询对象。每次索引找到结果时,都会调用相同的 onsuccess() 方法。

删除存储中的对象

要删除对象,您应该在对象存储中调用 delete() 方法,并传递一个查询参数,用于移除这些对象:

vartx = db.transaction("books", "readwrite");
var store = tx.objectStore("books");

store.delete(123456); // deletes book with isbn == 123456
store.delete(IDBKeyRange.lowerBound(456789)); // deletes books with store <= 456789

Backbone 中的 IndexedDB

由于 IndexedDB API 比 localStorage 更复杂,因此创建一个与 Backbone 相似的 IndexedDB 驱动器会更困难;在本节中,您将使用您所学的 IndexedDB 知识来构建一个 Backbone 驱动器。

驱动器在第一次创建时应打开数据库并初始化存储。

// indexedDB/dataStore.js
'use strict';

var Backbone = require('backbone');

const ID_LENGTH = 10;

var contacts = [
  // ...
];

class DataStore {
constructor() {
this.databaseName = 'contacts';
  }

openDatabase() {
var defer = Backbone.$.Deferred();

    // If a database connection is already active use it,
    // otherwise open a new connection
    if (this.db) {
defer.resolve(this.db);
    } else {
      let request = indexedDB.open(this.databaseName, 1);

request.onupgradeneeded = () => {
        let db = request.result;
this.createStores(db);
      };

request.onsuccess = () => {
        // Cache recently opened connection
this.db = request.result;
defer.resolve(this.db);
      };
    }

    return defer.promise();
  }

createStores(db) {
var store = db.createObjectStore('contacts', {keyPath: 'id'});

    // Create the first records
contacts.forEach(contact => {
store.put(contact);
    });
  }
}

当连接打开时,它创建联系人存储并将第一条记录放入存储中。之后,它将数据库处理程序缓存在 db 属性中以重用连接进行未来的请求。

现在,我们应该创建必要的创建、更新、删除和从存储中读取数据的方法:

// indexedDB/dataStore.js

var crispy = require('crispy-string');

// ...

class DataStore {
  create(model) {
var defer = Backbone.$.Deferred();

    // Assign an id to new models
    if (!model.id&& model.id !== 0) {
      let id = this.generateId();
model.set(model.idAttribute, id);
    }

    // Get the database connection
this.openDatabase()
.then(db =>this.store(db, model))
.then(result =>defer.resolve(result));

    return defer.promise();
  }

generateId() {
    return crispy.base32String(ID_LENGTH);
  }
  // ...
}

当创建记录时,我们应该确保模型有一个 ID。我们可以为没有分配 ID 的模型生成它。store() 方法将记录放入 indexedDB 数据库:

// indexedDB/dataStore.js

var crispy = require('crispy-string');

// ...

class DataStore {
  // ...

store(db, model) {
var defer = Backbone.$.Deferred();

    // Get the name of the object store
varstoreName = model.store;

    // Get the object store handler
vartx = db.transaction(storeName, 'readwrite');
var store = tx.objectStore(storeName);

    // Save the model in the store
varobj = model.toJSON();
store.put(obj);

tx.oncomplete = function() {
defer.resolve(obj);
    };

tx.onerror = function() {
defer.reject(obj);
    };

    return defer.promise();
  }

  // ...
}

store() 方法从 modelstore 属性获取存储的名称,然后为给定的存储名称创建一个 readwrite 事务,以便将记录放在上面。update() 方法使用相同的 store() 方法来保存记录:

// indexedDB/dataStore.js
class DataStore {
  // ...

  update(model) {
var defer = Backbone.$.Deferred();

    // Get the database connection
this.openDatabase()
.then(db =>this.store(db, model))
.then(result =>defer.resolve(result));

    return defer.promise();
  }

  // ...
}

更新方法不会为模型分配 ID,它完全用新的模型数据替换了之前的记录。要删除记录,可以使用对象存储处理器的 delete() 方法:

// indexedDB/dataStore.js
class DataStore {
  // ...

destroy(model) {
var defer = Backbone.$.Deferred();

    // Get the database connection
this.openDatabase().then(function(db) {
      // Get the name of the object store
      let storeName = model.store;

      // Get the store handler
vartx = db.transaction(storeName, 'readwrite');
var store = tx.objectStore(storeName);

      // Delete object from the database
      let obj = model.toJSON();
store.delete(model.id);

tx.oncomplete = function() {
defer.resolve(obj);
      };

tx.onerror = function() {
defer.reject(obj);
      };
    });

    return defer.promise();
  }

  // ...
}

要获取对象存储上存储的所有模型,你需要打开一个游标并将所有项目放入一个数组中,如下所示:

// indexedDB/dataStore.js
class DataStore {
  // ...

findAll(model) {
var defer = Backbone.$.Deferred();

    // Get the database connection
this.openDatabase().then(db => {
      let result = [];

      // Get the name of the object store
      let storeName = model.store;

      // Get the store handler
      let tx = db.transaction(storeName, 'readonly');
      let store = tx.objectStore(storeName);

      // Open the query cursor
      let request = store.openCursor();

      // onsuccesscallback will be called for each record
      // found for the query
request.onsuccess = function() {
        let cursor = request.result;

        // Cursor will be null at the end of the cursor
        if (cursor) {
result.push(cursor.value);

          // Go to the next record
cursor.continue();
        } else {
defer.resolve(result);
        }
      };
    });

    return defer.promise();
  }

  // ...
}

注意这次打开的事务是在 readonly 模式下。可以通过查询模型 ID 获取单个对象:

// indexedDB/dataStore.js
class DataStore {
  // ...

  find(model) {
var defer = Backbone.$.Deferred();

    // Get the database connection
this.openDatabase().then(db => {
      // Get the name of the collection/store
      let storeName = model.store;

      // Get the store handler
      let tx = db.transaction(storeName, 'readonly');
      let store = tx.objectStore(storeName);

      // Open the query cursor
      let request = store.openCursor(IDBKeyRange.only(model.id));

request.onsuccess = function() {
        let cursor = request.result;

        // Cursor will be null if record was not found
        if (cursor) {
defer.resolve(cursor.value);
        } else {
defer.reject();
        }
      };
    });

    return defer.promise();
  }

  // ...
}

与我们处理 localStorage 的方式相同,这个 IndexedDB 驱动程序可以用作覆盖 Backbone.sync 函数:

// app.js
var store = new DataStore();

// ...

Backbone.sync = function(method, model, options) {
var response;
var defer = Backbone.$.Deferred();

  switch(method) {
    case 'read':
      if (model.id) {
        response = store.find(model);
      } else {
        response = store.findAll(model);
      }
      break;

    case 'create':
      response = store.create(model);
      break;

    case 'update':
      response = store.update(model);
      break;

    case 'delete':
      response = store.destroy(model);
      break;
  }

response.then(function(result) {
    if (options &&options.success) {
options.success(result);
defer.resolve(result);
    }
  });

  return defer.promise();
};

然后,模型应该添加 store 属性来指示模型将在哪个对象存储中保存:

class Contact extends Backbone.Model {
  constructor(options) {
// ,,,
this.store = 'contacts';
  }

  // ...
}

class ContactCollection extends Backbone.Collection {
  constructor(options) {
// ...
this.store = 'contacts';
  }

// ...
}

IndexedDB 允许你存储比 localStorage 更多的数据;因此,你也可以用它来存储头像图片。只需确保 avatar 属性被设置,以便始终选择一个图片:

class ContactPreview extends ModelView {
  // ...

fileSelected(event) {
event.preventDefault();

var $img = this.$('img');

    // Get a blob instance of the file selected
var $fileInput = this.$('#avatar')[0];
varfileBlob = $fileInput.files[0];

    // Render the image selected in the img tag
varfileReader = new FileReader();
fileReader.onload = event => {
      $img.attr('src', event.target.result);

this.model.set({
        avatar: {
url: event.target.result
        }
      });
    };
fileReader.readAsDataURL(fileBlob);

this.trigger('avatar:selected', fileBlob);
  }
}

不要尝试上传图片:

class ContactEditor {
// ...

showEditor(contact) {
    // ...

    // When avatar is selected, we can save it inmediatly if the
    // contact already exists on the server, otherwise just
    // remember the file selected
    //this.listenTo(contactPreview, 'avatar:selected', blob => {
    //  this.avatarSelected = blob;

    //  if (!contact.isNew()) {
    //    this.uploadAvatar(contact);
    //  }
    //});
  }
saveContact(contact) {
// ...

    // The avatar attribute is read-only
    //if (contact.has('avatar')) {
    //  contact.unset('avatar');
    //}

// ...
  }

  // ...
}

摘要

你已经学会了两种在浏览器中存储数据并用作 RESTful API 服务器替代的方法。localStorage 方法有一个简单的 API,并且被所有主流浏览器广泛支持;如果你想要支持旧浏览器,这将是你首选的选择;然而,它有一个限制,就是你只能存储五兆字节。

IndexedDB 功能强大;然而,它的 API 比 localStorage 更复杂。在开始使用它之前,你需要学习一些概念。一旦你知道它是如何工作的,你应该异步地编写你的应用程序。

第七章.像专业人士一样构建

几年前,你可以用 PHP 创建一个网站,通过 FTP 上传源文件到服务器,然后上线。在那些日子里,JavaScript 是整个系统的一个紧密部分,用于 UI 任务,如验证表单或小块功能。

现在,网络越来越依赖于 JavaScript,我们正在构建网络应用程序而不是网站,这意味着 JavaScript 不再是应用程序的一个微不足道的部分,它现在是一个核心部分。因此,在部署到生产之前打包我们的 JavaScript 应用程序非常重要。

在本章中,你将学习以下内容:

  • 构建自动处理源文件的流程

  • 压缩应用程序脚本大小

  • 在应用程序加载时减少对服务器的请求数量

  • 压缩图片

  • 优化 CSS 文件

  • 在 HTML 文件中连接所有内容

  • 设置开发环境以自动重新加载应用程序

在撰写本书时,有许多工具可以构建 JavaScript 应用程序;然而,其中两个最受欢迎的是 Grunt 和 Gulp。Grunt 是一个较老的选择,拥有庞大的社区和令人惊叹的插件集合。另一方面,Gulp 每天都在获得更多的关注,几乎拥有 Grunt 存在的大部分最受欢迎的插件。

开发工作流程

当你在开发应用程序时,有些任务非常重复;例如,我们的联系人应用程序使用 Browserify 来管理依赖项。每次你进行更改时,都需要重新打包源代码,这意味着你需要每次都运行browserify命令:

$ npm bundle
$ npm start

每次你进行小更改时都运行这些命令是一个非常繁琐的任务,应该有更好的方法来做这件事:

开发工作流程

图 7.1. 开发工作流程

上图显示了理想的开发生成过程;第一次运行应用程序时,你应该打包源文件,并运行 BrowserSync 网络服务器,然后打开浏览器。之后,对于你在任何源文件中进行的任何更改,应用程序都应该重新打包,然后刷新浏览器以获取新更改。

目前,我们正在手动执行此过程;然而,在下一节中,你将学习如何自动化此任务,让机器为你完成所有这些工作。

什么是任务运行器?

任务运行器是一种计算机程序,它会在你的源代码上运行一系列任务,对文件应用转换。例如,假设你正在用 CoffeeScript 编程语言编写源代码,一个任务可能是将所有源代码编译成 JavaScript,其他任务可以是将所有输出 JavaScript 文件合并成一个文件,第三个任务可以将合并后的文件进行压缩以减小文件大小。

这些任务将由任务运行器自动运行,你只需要编写一个脚本文件来编程需要执行的操作,然后忘记再次运行任何命令行。任务运行器提供触发器,以便在更改文件时启动任务,使其透明完成。

正如你所见,任务运行器可以提高你的生产力,一旦你正确配置了任务运行器,你就可以忘记编译过程的细节。它将允许你自动化所有这些重复且无聊的任务,然后,你可以专注于产品开发。

Grunt 和 Gulp 是最流行的 JavaScript 任务运行器;它们在运行任务时采取不同的方法。在 Grunt 中,任务是顺序运行的:一旦一个任务开始运行,下一个任务不能开始,直到第一个任务完成。在 Gulp 中,任务可以并行运行,如下所示:

什么是任务运行器?

图 7.2 Grunt 和 Gulp 运行任务的不同方法

上图展示了 Grunt 和 Gulp 将如何运行三个任务。任务 B 取决于任务 A 和任务 C 是否已完成。请注意,Grunt 可以通过插件并行运行这些任务。而 Gulp 则从其核心设计上就支持这样做。

Grunt 和 Gulp 之间的另一个区别是,在 Grunt 中,你可以在编写一个大的配置对象时配置任务。而在 Gulp 中,你将编写标准的 JavaScript 函数。一个有趣的观点是,Grunt 和 Gulp 可以在同一个项目中一起工作;然而,为了减少复杂性,最好只选择其中一个。

如果你的项目使用 Grunt,除非有很好的理由,否则不应切换到 Gulp。

Gulp 的工作原理

正如我在本章开头提到的,Gulp 是在撰写本书时最受欢迎的 JavaScript 任务运行器,这也是我们选择它的主要原因。Gulp 和 Grunt 的工作方式类似,它们都使用第三方插件来工作。请记住,Gulp 更像是一个框架,它本身并不做太多。

Gulp 作为协调构建工作流程的粘合剂;它有一些基本功能和一个 API,Gulp 插件可以使用它来完成工作。插件使用编译器和实用程序程序来执行真正的文件处理,例如 CoffeeScript 编译器。插件将这些程序连接到 Gulp 工作流程中:

Gulp 的工作原理

图 7.3 Gulp 插件和库之间的关系

前面的图显示了之前描述的关系,你可以更好地了解 Gulp 如何与其插件连接;注意插件如何将文件处理委托给它们连接的实用程序。

Gulp 由几个命名任务组成,每个任务都可以依赖于其他任务。一个典型的 Gulp 任务在开始时打开一个文件流,并使用已安装的插件对流中的每个文件进行转换。

使用 gulp.src() 方法打开一个流。它启动一个流,你可以将其连接到多个管道以应用必要的转换。当你打开一个流时,你需要指定将在流中使用的目标文件。你将使用 node-glob 格式 选择这些文件:

// get only the index.html file
gulp.src('app/index.html');

// get all the files with .html extension
gulp.src('app/*.html');

// get all the .js files available 1 path depth in 
// the app directory
gulp.src('app/*/*.js');

// get all the .js files in every subdirectory available
gulp.src('app/**/*.js');

指定流中的文件很容易,这与你在命令行中做的类似。下面的图示显示了流和管道是如何连接的。选定的文件被流式传输到 Gulp 插件中,它们进行转换并将输出放回流中,下一个插件可以继续工作,并将结果放回流中:

Gulp 的工作原理

图 7.4 使用 node-blob 选择文件

在管道的末尾,你通常会将结果写入一个已准备好使用的文件。你可以放置你需要的任何数量的 Gulp 任务,每个任务都可以有它需要的任何数量的依赖项。

开始使用 Gulp

首先,全局安装 Gulp 包;这将使你能够访问 gulp 命令:

$ npm install -g gulp

一旦全局安装了 Gulp,你还需要在本地项目中安装它,以便能够访问 Gulp 核心工具:

$ npm install -save-dev gulp

要配置 Gulp 任务,你需要创建一个名为 gulpfile.js 的文件,每次运行 gulp 命令时 Gulp 都会读取此文件。所有 Gulp 任务都有一个名称和一个在任务被调用时执行的功能:

var gulp = require('gulp');

gulp.task('hello', function() {
  console.log('Hello world!');
});

以下简单的 Gulp 任务将在控制台打印 Hello world!:

$ gulp hello
[22:43:15] Using gulpfile ~/path/to/project/gulpfile.js
[22:43:15] Starting 'hello'...
Hello world!
[22:43:15] Finished 'hello' after 118 μs

注意我们是如何调用 Gulp 的,gulp hello,命令中使用的参数是要执行的任务的名称。这是你可以编写的最简单的 Gulp 任务,也是开发有效构建管道的起点。

创建开发工作流程

在本节中,我们将构建一个脚本以帮助我们进行开发过程,并在之后构建一个可用于生产的脚本。首先,你需要安装基本依赖项:

$ npm install --save-dev gulp gulp-load-plugins gulp-util

gulp-load-plugins 非常有用,可以自动加载所有可用的插件,而无需在 gulpfile.js 脚本中手动引入它们;gulp-util 插件提供了如日志消息等实用函数。

使用 Browserify 打包 JavaScript 文件

gulp-browserify 插件目前已被弃用,不应使用。项目作者建议使用 Gulp 开发团队开发的食谱之一。

仓库中描述的食谱需要先安装一些插件:

$ npm install --save-dev jstifywatchify vinyl-source-stream

gulpfile.js 中,我们可以定义 browserify 任务:

var gulp = require('gulp');
var $ = require('gulp-load-plugins')();
var browserify = require('browserify');
var jstify = require('jstify');
var source = require('vinyl-source-stream');

// Bundle files with browserify
gulp.task('browserify', () => {
  // set up the browserify instance on a task basis
  var bundler = browserify({
    entries: 'app/js/main.js',
    debug: true,
    // defining transforms here will avoid crashing your stream
    transform: [jstify]
  });

  return bundler.bundle()
    .on('error', $.util.log)
    .pipe(source('app.js'))
    .pipe(gulp.dest('.tmp/js'));
});

注意我们是如何配置 Browserify 打包的,我们使用 Browserify 的 jstify 转换来编译 underscore 模板。由于 browserify 任务不是一个标准的 Gulp 插件,我们使用 vinyl-source-stream 将文件流式传输到打包器。最后,我们将输出写入 .tmp/js 路径。

现在,你可以使用 Browserify 参数运行 Gulp 来执行任务:

$ gulp browserify
[07:13:18] Using gulpfile ~/path/to/your/project/gulpfile.js
[07:13:18] Starting 'browserify'...
[07:13:19] Finished 'browserify' after 1.13 s

.tmp/js/app.js 文件应该存在并准备好使用。您可以运行项目以验证一切是否正常工作:

$ npm start

源映射

当您运行项目时,浏览器将获得一个名为 app.js 的单个文件,其中包含所有连接的源代码。这对于生产环境来说很好,因为它减少了服务器请求资产的数量。然而,在开发环境中,更有用的是在浏览器中看到单个文件,就像您在源代码中进行调试过程时那样。

您可以确保浏览器显示带有源映射的原始源文件,这样您就可以放置调试断点或简单地检查代码,而无需其他依赖项(如 Backbone 库)的噪音。

要在 browserify 任务中包含源映射,您需要安装一些额外的依赖项:

$ npm install --save-dev vinyl-buffergulp-sourcemaps

然后,修改任务:

// ...
var buffer = require('vinyl-buffer');

// Bundle files with browserify
gulp.task('browserify', () => {
  // set up the browserify instance on a task basis
  var bundler = browserify({
    entries: 'app/js/main.js',
    debug: true,
    // defining transforms here will avoid crashing your stream
    transform: [jstify]
  });

  return bundler.bundle()
    .on('error', $.util.log)
    .pipe(source('app.js'))
 .pipe(buffer())
 .pipe($.sourcemaps.init({loadMaps: true}))
 // Add transformation tasks to the pipeline here.
 .on('error', $.util.log)
 .pipe($.sourcemaps.write('./'))
    .pipe(gulp.dest('.tmp/js'));
});

以下图显示了 Google Chrome 浏览器中的源文件。您可以检查原始文件并放置断点,浏览器将确保在正确的时间停止执行。

对于 Browserify 打包,看到所有原始文件而不是一个巨大的脚本非常有用;然而,这种技术也可以用于编译的编程语言,例如 CoffeeScript,或者您也可以用 ECMAScript 6 编写源代码,然后用 babel 进行转译,然后,带有源映射的原始文件:

源映射

图 7.5 源映射的实际应用

自动重新打包

如果您更改了源文件,那么您将需要再次运行 browserify 任务。您可以通过安装另一个 Browserify 插件来确保 Gulp 和 Browserify 为您完成这项工作。首先,您需要安装另一个 Browserify 插件:

$ npm install --save-dev watchify

watchify 插件会监听源代码中的文件更改,并可用于触发重新打包任务:

//...
var watchify = require('watchify');

// Bundle files with browserify
gulp.task('browserify-2', () => {
  // set up the browserify instance on a task basis
  var bundler = browserify({
    entries: 'app/js/main.js',
    debug: true,
    // defining transforms here will avoid crashing your stream
    transform: [jstify]
  });

 bundler = watchify(bundler);

 var rebundle = function() {
    return bundler.bundle()
      .on('error', $.util.log)
      .pipe(source('app.js'))
      .pipe(buffer())
      .pipe($.sourcemaps.init({loadMaps: true}))
        // Add transformation tasks to the pipeline here.
        .on('error', $.util.log)
      .pipe($.sourcemaps.write('./'))
      .pipe(gulp.dest('.tmp/js'));
 };

 bundler.on('update', rebundle);

 return rebundle();
});

当触发更改时,rebundle() 函数将自动执行,这样您就只需要刷新浏览器。在下一节中,您将看到如何自动化这个过程。

BrowserSync

BrowserSync 是一个用于开发的资产服务器,您应该避免在生产环境中使用它。BrowserSync 是一个运行 HTTP 服务器的 Node 包,当检测到服务文件的更改时,它会自动重新加载浏览器。使用 BrowserSync,您可能会忘记每次更改时手动刷新浏览器。

在开始使用之前,您需要安装此包:

$ npm install --save-dev browser-sync

一旦安装了包,我们就可以创建一个新的 Gulp 任务来运行 BrowserSync:

// ...
var browserSync = require('browser-sync');
var reload = browserSync.reload;

gulp.task('serve', () =>{
  browserSync({
    port: 9000,
    ui: {
      port: 9001
    },
    server: {
      baseDir: ['.tmp', 'app']
    }
  });

  gulp.watch([
    'app/*.html',
    'app/**/*.css',
    '.tmp/**/*.js'
  ]).on('change', reload);
});

在这个 Gulp 任务中,我们将运行 BrowserSync 在 9000 端口,并打开一个额外的 9001 端口以允许我们配置 BrowserSync 的行为。例如,您可以远程调试您的应用程序,这对于移动设备非常有用。

我们配置 BrowserSync 以从 app.tmp 目录中提供文件。如果您从浏览器访问 http://localhost:9000/,则默认提供 app/index.html 文件,并使用 .tmp 目录中的脚本文件。

为了在检测到源文件更改时自动刷新浏览器,我们使用 gulp.watch() 方法,因为它接受一个以 node-blob 格式表示的文件列表,然后我们可以监听 change 事件,通过 BrowserSync 包含的 reload() 函数向浏览器发送刷新信号。

由于服务器任务依赖于 bundle 脚本文件的存在,这个任务应该依赖于我们之前创建的 Browserify 任务。为了向 Gulp 指示任务有依赖关系,我们应该向 gulp.task() 函数添加一个新参数:

// ...
var browserSync = require('browser-sync');
var reload = browserSync.reload;

gulp.task('serve', ['browserify'], () => {
// ...
});

第二个参数是一个字符串列表,表示任务依赖于哪些内容。在前面的代码片段中,Gulp 将确保 browserify 任务先运行并完成,然后再执行 browserify 任务函数。

使用 Express 运行服务器

现在我们已经让资产服务器工作,我们需要使用 nodemon 运行我们的 Express 服务器,这个包与 BrowserSync 非常相似;然而,它不包括浏览器功能。使用 nodemon,你可以运行一个会监视 JavaScript 文件更改的 node 脚本。当检测到更改时,node 脚本将自动重新加载。

您需要首先安装 npm 包:

$ npm install --save-dev gulp-nodemon

然后,我们可以为 nodemon 创建任务:

// ...
var nodemon = require('gulp-nodemon');

gulp.task('express', () => {
  nodemon({
    script: 'server/index.js',
    ignore: ['app']
  });
});

在这个任务中,我们正在通知 nodemon 忽略 app 目录下的更改。这样做的原因是 app 路径已经被 BrowserSync 监视。

现在我们有了服务器,并且资产被自动服务和重新加载,我们可以合并这两个任务以在开发模式下运行项目:

var httpProxy = require('http-proxy');

gulp.task('serve', ['browserify', 'express'], () => {
 var serverProxy = httpProxy.createProxyServer();

  browserSync({
    port: 9000,
    ui: {
      port: 9001
    },
    server: {
      baseDir: ['.tmp', 'app'],
 middleware: [
 function (req, res, next) {
 if (req.url.match(/^\/(api|avatar)\/.*/)) {
 serverProxy.web(req, res, {
 target: 'http://localhost:8000'
 });
 } else {
 next();
 }
 }
 ]
    }
  });

  gulp.watch([
    'app/*.html',
    'app/**/*.css',
    '.tmp/**/*.js'
  ]).on('change', reload);
});

应该安装一个新的依赖项,http-proxy。这个依赖项允许我们将所有 API 请求重定向到 Express 服务器,这样 BrowserSync 就不会尝试服务这些请求:

$ npm install --save-dev http-proxy

这次,我们将 express 任务依赖项添加到 serve 任务中。因为我们现在在不同的端口上运行两个服务器,资产在 9000 端口,API 在 8000 端口,我们在 BrowserSync 中添加了一个中间件来将开始于 /api//avatar/ 的流量重定向到位于 8000 端口的服务器。

现在,当您从命令行运行 serve 任务时,您将获得一个令人惊叹的开发环境。每次前端文件更改时,浏览器都会自动使用新的包重新加载。如果检测到服务器文件中的更改,Express 服务器也会重新加载。

这对您的开发工作流程来说是一个巨大的改进;您将更加高效,并且可以忘记手动重新加载。

创建生产工作流程

在前几节中构建的开发工作流程对项目来说是一个巨大的改进;然而,我们还没有完成。在本节中,你将看到如何优化要在生产环境中运行的项目。

在本节中,你将学习如何最小化你的 JavaScript 和 CSS 文件以混淆源代码并减少浏览器加载资产文件所需的时间。图像也可以被压缩以减少其重量,同时不改变其外观。

Gulp useref

gulp-useref 插件会处理你的 HTML 文件,将你的 JavaScript 和 CSS 资产合并成一个单独的文件。请注意,JavaScript 已经由 Browserify 处理,因此不需要使用 useref 处理 JavaScript 文件;另一方面,CSS 可以在这里处理。

你需要使用 npm 将插件作为开发依赖项安装:

$ npm install --save-dev gulp-useref

然后,为了使用它,你需要创建一个新的任务。让我们称它为 html

// ...

gulp.task('html', function() {
  var assets = $.useref.assets();

  return gulp.src('app/*.html')
    .pipe(assets)
    .pipe(assets.restore())
    .pipe($.useref())
    .pipe(gulp.dest('dist'));
});

gulp.src('app/*.html') 函数抓取所有具有 .html 扩展名的文件。在我们的例子中,只有 index.html 文件存在,因此它是唯一将被处理的文件。useref.assets() 函数将 HTML 文件中找到的所有资产合并到一个流中,assets.restore() 函数将恢复最初选择的 HTML 文件原始流。

当你调用 useref() 函数时,HTML 文件将被解析以替换单个 HTML 标签中的资产文件。例如,如果你有五个 CSS 文件,它将替换 HTML 文件中的这五个链接标签,并在一个指向合并版本的标签中。

你应该指示 useref 任务如何在 HTML 文件中使用特殊标签来合并文件:

<html>
<head>
<!-- ... -->
<!-- build:css(app) css/vendor.css -->
<link rel="stylesheet" href="css/bootstrap.css">
<link rel="stylesheet" href="css/main.css">
<!-- endbuild -->
<!-- ... -->
</head>
<!-- ... -->
</html>

你需要在代码中添加两个 HTML 注释,这些注释对我们来说有特殊的意义。其语法如下:

<!-- build:<type>(alternate search path) <path> -->
... HTML Markup, list of script / link tags.
<!-- endbuild -->

由于我们正在处理 CSS 文件,我们使用 css 作为类型,搜索路径表示 useref 将查找文件的位置。如果我们留这个可选参数为空,那么它将使用根项目路径。最后一个 path 参数表示合并的 CSS 文件将被放置的位置。

如果你运行 Gulp html 任务,你将得到一个在 dist/css/vendor.css 路径下的所有样式合并的文件。输出 HTML 文件将指向此文件而不是开发版本:

<html>
<head>
<!-- ... -->
<link rel="stylesheet" href="css/vendor.css">
<!-- ... -->
</head>
<!-- ... -->
</html>

你可以通过使用 gulp-minify-css 插件来优化输出 CSS 文件。正如你可能已经猜到的,你应该使用 npm 安装此插件:

$ npm install --save-dev gulp-minify-css

然后,你可以在构建过程中使用此插件,如下所示:

// ...
var minifyCss = require('gulp-minify-css');

gulp.task('html', function() {
  var assets = $.useref.assets();

  return gulp.src('app/*.html')
    .pipe(assets)
 .pipe(minifyCss())
    .pipe(assets.restore())
    .pipe($.useref())
    .pipe(gulp.dest('dist'));
});

这将压缩合并后的 CSS 文件。然而,由于 useref 可以处理 CSS 和 JavaScript 文件,如果添加了 JavaScript 构建标签,代码可能会出现错误。为了防止错误,你可以使用 gulp-if 插件:

$ npm install --save-dev gulp-if gulp-uglify

这也将安装 uglify 以处理 JavaScript 文件:

// ...

gulp.task('html', function() {
  var assets = $.useref.assets();

  return gulp.src('app/*.html')
    .pipe(assets)
 .pipe($.if('*.js', uglify()))
 .pipe($.if('*.css', minifyCss()))
    .pipe(assets.restore())
    .pipe($.useref())
    .pipe(gulp.dest('dist'));
});

使用 gulp-if 我们测试流中的文件是否是 CSS 或 JavaScript 文件,然后应用正确的转换。

图片优化

当你在本地机器上开发项目时,资源加载相当快,因为图片和代码都存储在同一台计算机上;然而,当你访问生产环境中的图片时,它们将通过互联网传输到用户的机器。

通过图片优化,我们可以压缩这些图片以减少应用程序从服务器下载的数据量。使用 node,你可以使用 imagemin 包;然而,由于我们使用 Gulp,gulp-imagemin 将完成这项工作。

如我们之前所做的那样,你需要首先安装插件:

$ npm install --save-dev gulp-imagemin

现在插件已经安装,我们可以使用它:

gulp.task('images', function() {
  gulp.src('app/images/*.{jpg,gif,svg,png}')
    .pipe($.imagemin())
    .pipe(gulp.dest('dist/images'));
});

它从 app/images 路径中获取图片,并对每个图片应用 imagemin() 处理。

字体

Bootstrap 的字体位于 node_modules/ 目录下。如果你安装了其他类型的字体,例如 Font Awesome,或者下载了特定的字体;它们应该被复制到 dist/ 目录。你可以创建一个 fonts 任务来完成这个操作,如下所示:

// ...

gulp.task('fonts', function () {
  return gulp.src([
    'app/{,styles/}fonts/**/*',
    'node_modules/bootstrap/dist/fonts/**/*'
  ])
    .pipe($.flatten())
    .pipe(gulp.dest('dist/fonts'));
});

注意,你需要安装 gulp-flatten 插件;此插件将删除任何前缀目录:

$ npm install --save-dev gulp-flatten

打包生产环境的 JavaScript 文件

我们拥有的 browserify 任务对开发很有用,它创建 sourcemaps,输出未压缩。如果你想进入生产环境,你需要移除 sourcemaps 并最小化输出。

对于生产环境,我们将 ECMAScript 6 代码转换为 JavaScript 以支持不支持 ECMAScript 6 的浏览器。Babel 是目前最好的转换器来完成这个转换。

Browserify 的 babelify 插件将应用以下转换:

$ npm install --save-dev babelify

在使用 babelify 插件之前,你需要配置 Babel。在 Babel 6 中,你必须为想要支持的函数安装单个包。对于这个项目,我们支持 ES2015:

$ npm install --save-dev babel-preset-es2015

.babelrc 文件中,你应该配置预设:

// .babelrc
{
  "presets": ["es2015"]
}

一旦你正确配置了 Babel,我们就可以为生产创建 browserify 任务:

// Bundle files with browserify for production
gulp.task('browserify:dist', function () {
  // set up the browserify instance on a task basis
  var bundler = browserify({
    entries: 'app/js/main.js',
    // defining transforms here will avoid crashing your stream
    transform: [babelify, jstify]
  });

  return bundler.bundle()
    .on('error', $.util.log)
    .pipe(source('app.js'))
    .pipe(buffer())
    .pipe($.uglify())
    .pipe(gulp.dest('dist/js'));
});

此任务不会生成 sourcemaps 并优化输出。

整合所有内容

你已经学习了如何优化多种类型的资源:CSS、JavaScript 和图片。现在让我们将这些整合起来,以构建我们的应用程序。serve:dist 任务将所有过程连接成一个管道:

gulp.task('serve:dist', ['browserify:dist', 'images', 'fonts', 'express'], () => {
  var serverProxy = httpProxy.createProxyServer();

  browserSync({
    port: 9000,
    ui: {
      port: 9001
    },
    server: {
      baseDir: 'dist',
      middleware: [
        function (req, res, next) {
          if (req.url.match(/^\/(api|avatar)\/.*/)) {
            serverProxy.web(req, res, {
              target: 'http://localhost:8000'
            });
          } else {
            next();
          }
        }
      ]
    }
  });
});

要测试我们的管道,我们可以在终端中运行 serve:dist 任务:

$ gulp serve:dist
[11:18:04] Using gulpfile ~/Projects/mastering-backbone/ch07/gulpfile.js
[11:18:04] Starting 'browserify:dist'...
[11:18:04] Starting 'images'...
[11:18:04] Finished 'images' after 305 ms
[11:18:04] Starting 'fonts'...
[11:18:04] Starting 'express'...
[11:18:05] Finished 'express' after 141 ms
[11:18:05] gulp-imagemin: Minified 0 images
[11:18:05] [nodemon] 1.8.1
[11:18:05] [nodemon] to restart at any time, enter `rs`
[11:18:05] [nodemon] watching: *.*
[11:18:05] [nodemon] starting `node server/index.js`
Express server is running on port 8000
[11:18:08] Finished 'fonts' after 4.04 s
[11:18:12] Finished 'browserify:dist' after 8.02 s
[11:18:12] Starting 'serve:dist'...
[11:18:12] Finished 'serve:dist' after 40 ms
[11:18:12] [nodemon] restarting due to changes...
[BS] Access URLs:
 --------------------------------------
 Local: http://localhost:9000
 External: http://192.168.100.4:9000
 --------------------------------------
 UI: http://localhost:9001
 UI External: http://192.168.100.4:9001
 --------------------------------------
[BS] Serving files from: dist
[11:18:12] [nodemon] starting `node server/index.js`
Express server is running on port 8000

注意任务是如何由 Gulp 执行的。在所有这些过程之后,浏览器将自动打开,指向 http://localhost:9000 地址,在生产环境中运行应用程序。

摘要

在本章中,我们看到了如何使用工具来构建我们的 Backbone 应用程序。首先,你学习了什么是任务运行器以及 Node 中最流行的选择。然后,我们看到了 Gulp 的工作原理和创建任务的方式。

使用 Gulp,你可以构建一个开发环境并对其进行配置,以便为生产环境中的资源应用优化。Gulp 是基于流的,这意味着你可以从 glob 规范中抓取一串文件,并将这些文件流式传输以应用所需的转换,例如编译、连接、转译等。

任务运行器是惊人的工具,它允许你自动化任务。你不仅可以创建开发和生产工作流程,还可以为几乎所有你不想重复的事情创建任务。例如,创建部署的任务。

在下一章中,我们将看到如何测试 Backbone 应用程序。你将学习如何隔离和模拟依赖关系以方便测试,以及如何进行 Backbone 测试。

第八章。测试 Backbone 应用程序

无论你是一个经验丰富的程序员,还是在某个时间点犯代码错误都是非常正常的。没有人是完美的,软件开发中错误总是时有发生。作为开发者,你的工作是将软件中的缺陷数量降到最低。

错误可能来自不同的来源;一个意外的输入,一个处理不当的错误,第三方插件的更改,内存问题等等。你的代码应该准备好处理这类事情。

在软件行业,一个普遍的规则是始终测试你的代码。当你测试你的应用程序时,最终产品质量更好,因为许多缺陷在用户注意到之前就已经被检测和纠正。

测试不仅仅是为了防止软件中的错误。以下是在进行测试时你可以获得的一些好处:

  • 提高最终产品的质量

  • 让你在应用中充满自信

  • 允许你安全地重构代码片段

  • 保留功能

  • 模拟错误并改进你的错误处理代码

  • 提高你的代码质量,迫使你编写可测试的代码

如果你从未测试过你的软件,现在你有很好的理由开始这样做。Make 测试可能会在开始时减缓你的开发过程;然而,你将在中期看到好处。

在本章中,你将学习以下内容:

  • 哪些工具可用于测试前端应用程序

  • 如何测试 Backbone 应用程序

  • 如何应用应用程序测试的最佳实践

  • 如何自动运行你的测试

测试工具

测试工具可以是库或框架,帮助你编写应用程序的测试并评估结果。在测试工具下,你可以找到以下内容:

  • 测试库:这为你提供了钩子和函数来描述测试

  • 断言库:这为你提供了创建期望的功能

  • 测试运行器:这会发现并运行你的测试

  • 测试覆盖率:这告诉你哪些代码部分被测试了,哪些没有被测试

  • 测试报告:这会以不同的格式生成报告,如 HTML 和 JSON

  • 模拟、存根、伪造工具:这些为你提供了创建具有可预测行为的假对象的方法

  • 模块模拟:这用假模块替换所需的模块,有助于隔离模块

  • 压力工具:这会对应用进行多次请求,以查看它在高需求环境下的行为

  • 浏览器测试:这模拟用户在整个应用程序中输入

解释和展示所有这些工具如何工作超出了本书的范围。在本章中,你将使用测试库、断言库、测试运行器和模拟。

对于 JavaScript,有许多测试库可供你使用;然而,在撰写本书时,有两个更受欢迎:JasmineMocha

Mocha 是一个小型库,允许你编写测试驱动程序,它本身不包含任何断言函数。这意味着你应该将 Mocha 与你选择的断言库集成;一个非常流行的选择是使用 Mocha 和 Chai.js 的组合。

Jasmine 更像是一个框架,它提供了一个与 Mocha 非常相似的 API;然而,它包括了断言函数。因此,使用起来更简单,因为你不需要创建额外的步骤。

对于这本书,我们将使用 Jasmine,因为它是最受欢迎的测试工具,并且更容易开始使用。与 Mocha 一样,你可以将 Jasmine 用作测试运行器并选择不同类型的报告。

Jasmine 入门

要编写测试,你应该创建两样东西:测试套件和规范。规范(简称 spec)是你从代码中测试的功能的一部分;例如,如果你的代码是计算 $100.00 的 5% 税,你预期它应该是 $5。测试套件是一组在主题下分组的预期。在上面的例子中,测试套件可以是“发票总额计算”。

要开始使用 Jasmine,你应该从 npm 安装它,如下所示:

$ npm install --save-dev jasmine

然后,你可以开始编写你的测试。使用 Jasmine,你有两个函数:describe() 用于创建测试套件,it() 用于创建规范:

// specs/mathSpec.js	
describe('Basic mathematicfunctions', () => {
it('should result 4 the sum of 2 + 2', () => {
  });

it('should result 1 the substract of 3 - 2', () => {
  });

it('should result 3 the division of 9 / 3', () => {
  });

it('should throw an error when divide by zero', () => {
  });
});

上述代码定义了一个针对假设的数学函数集的测试套件。注意在 describe() 函数中,你应该写一段文字来告诉人们测试的上下文;而在 it() 函数中,文字应该说明你要测试的内容。

现在,让我们为测试套件构建 math 函数,如下所示:

// app/js/math.js
var math = {
sum(a, b) {
return a + b;
  },

substract(a, b) {
return a - b;
  },

divide(a, b) {
if (b === 0) {
throw new Error('Can not divide by zero');
    }

return a / b;
  }
};

module.exports = math;

math 对象具有通过测试套件所需的功能;然而,要实际测试 math 对象,你需要制定一系列预期。

预期

预期是函数,用于比较函数的输出与预期输出。在下面的例子中,我们使用 22 作为输入调用 sum() 函数。我们预期结果应该是 4

expect(sum(2, 2)).toEqual(4);

toEqual() 预期函数比较函数的输出和预期值是否相等;如果两者相同,测试将通过,否则,测试将失败。下表显示了 Jasmine 中最常用的预期,请查阅文档以获取完整的可用预期函数集:

预期函数 描述 示例
toEqual 值应该完全相等 expect('hello').toEqual('hello')
toMatch 值将与正则表达式匹配 expect('Hello').toMatch(/[Hh]ello/)
toBeTruthy 值应该是真值 expect(true).toBeTruthy(); expect(100).toBeTruthy();
toBeFalsy 值应该是假值 expect(false).toBeFalsy(); expect(null).toBeFalsy();
toThrowError 这验证了被调用的函数会抛出错误 expect(function() {``math.divide(1, 0);``}).toThrowError();

在将所有期望添加到我们已有的示例测试套件之后,代码应该如下所示:

// spec/mathSpec.js
var math = require('../app/js/math');

describe('Basic mathematic functions', () => {
it('should result 4 the sum of 2 + 2', () => {
expect(math.sum(2, 2)).toBe(4);
  });

it('should result 1 the substract of 3 - 2', () => {
expect(math.substract(3, 2)).toBe(1);
  });

it('should result 3 the division of 9 / 3', () => {
expect(math.divide(9, 3)).toBe(3);
  });

it('should throw an error when divide by zero', () => {
expect(() =>math.divide(9, 0)).toThrowError();
  });
});

要运行测试套件,你应该首先配置 Jasmine 测试运行器。为此,你应该创建一个脚本:

// spec/run.js
var Jasmine = require('jasmine');
var jasmine = new Jasmine();

jasmine.loadConfig({
spec_dir: 'spec',
spec_files: [
    '**/*[sS]pec.js'
  ]
});

jasmine.execute();

Jasmine 将在 spec/ 目录下查找测试,它将查找所有以 spec.jsSpec.js 结尾的文件。由于我们的测试文件命名为 mathSpec.js,Jasmine 测试运行器将加载并运行它,如下所示:

$ node spec/run.js
Started
....

4 specs, 0 failures
Finished in 0.008 seconds

你可以看到测试失败时会发生什么;例如,你将求和测试改为 5 而不是 4

$ node spec/run.js
Started
F...

Failures:
1) Basic mathematic functions should result 4 the sum of 2 + 2
 Message:
 Expected 4 to be 5.
 Stack:
 Error: Expected 4 to be 5.
at Object.<anonymous>(/path/to/your/project/spec/mathSpec.js:5:28)

4 specs, 1 failure
Finished in 0.009 seconds

现在,如果你犯了一个错误,Jasmine 会告诉你哪里出了问题。注意 Jasmine 如何通知你错误:

"基本的数学函数应该得到 2 + 2 的和 4"

然后,它告诉你它期望的是 5,但收到了 4。请注意,在 describe()it() 函数中放入的消息非常重要,因为它们将帮助你快速诊断问题。

测试异步代码

当你需要测试异步代码,如 Ajax 调用,你需要额外的一步。当你编写 it() 函数时,你应该传递一个 done 参数,Jasmine 将在那里放置一个回调函数,你应该在测试完成后调用它。

为了说明这一点,让我们模拟一个求两个数的和的异步任务,如下所示:

var math = {
  // ...

asyncSum(a, b, callback) {
    // Will respond after 1.5 seconds.
setTimeout(function() {
callback(a + b);
    }, 1500);
  },

  // ...
};

遵循 JavaScript 标准,syncSum() 函数接收第三个参数,即当求和完成时将被调用的回调函数。在以下示例中,回调函数将在 1,500 毫秒后调用:

math.asyncSum(2, 2, result => {
  // After 1500ms result will be equal to 4
});

要使用此函数编写测试,我们应该将 done 回调函数传递给 it() 函数:

it('sums two numbers asynchronously', done => {
math.asyncSum(2, 2, function(result) {
expect(result).toEqual(4);
done();
  });
});

Karma 测试运行器

Karma 是一个流行的 JavaScript 测试运行器,它与许多其他测试库和框架(如 Jasmine 和 Mocha)一起工作。Jasmine 附带的 Node 测试运行器已经足够好;然而,Karma 为这个方程式增加了超级功能。

使用 Karma,你可以在真实的网络浏览器上运行测试,例如 Google Chrome、Firefox、Opera 等。一旦 Karma 设置并运行,它将负责查找要测试的文件,运行测试,然后给出报告。

在开始使用 Karma 之前,你需要安装 Karma:

$ npm install --save-dev karma karma-jasmine karma-browserify karma-chrome-launcher karma-spec-reporter

然后,你可以使用名为 karma.conf.js 的脚本配置 Karma:

// Karma configuration
// http://karma-runner.github.io/0.12/config/configuration-file.html

module.exports = function(config) {
  'use strict';

config.set({
    // enable / disable watching file and executing tests whenever
    // any file changes
autoWatch: true,

    // base path, that will be used to resolve files and exclude
basePath: '',

    // testing framework to use (jasmine/mocha/qunit/...)
frameworks: ['browserify', 'jasmine'],

    // list of files / patterns to load in the browser
files: [
      'spec/**/*Spec.js'
    ],

    // preprocess matching files before serving them to 
    // the browser available preprocessors:
    // https://npmjs.org/browse/keyword/karma-preprocesso
preprocessors: {
      'spec/**/*Spec.js': ['browserify']
    },

    // Cobfigure how to bundle the test files with Browserify
browserify: {
debug: true,
transform: ['jstify'],
extensions: ['.js', '.tpl']
    },

    // report on console and growl if available
    //
    // More info about growl notifications on
    // http://mattn.github.io/growl-for-linux/
    // http://growl.info/
reporters: ['spec'],

    // list of files / patterns to exclude
exclude: [],

    // web server port
port: 9876,

    // enable / disable colors in the output (reporters and logs)
colors: true,

    // level of logging
    // possible values:
    // LOG_DISABLE || LOG_ERROR || LOG_WARN || 
    // LOG_INFO || LOG_DEBUG
logLevel: config.LOG_INFO,

    // Continuous Integration mode
    // if true, it capture browsers, run tests and exit
singleRun: false,

    // Start these browsers, currently available:
    // - Chrome
    // - ChromeCanary
    // - Firefox
    // - Opera
    // - Safari (only Mac)
    // - PhantomJS
    // - IE (only Windows)
browsers: ['Chrome']
});
};

files 字段告诉 Karma 哪些文件将以 glob 格式进行测试。preprocessors 字段告诉 Karma 是否应该对从 files 字段选择的文件进行预处理,以便在运行测试之前。由于我们使用 Browserify 来管理依赖项,我们应该使用 Browserify 预处理文件以创建测试包。

你可以选择 Karma 如何向你报告测试状态。reporters字段使得这一点成为可能,你可以搜索更多可用的报告器;然而,spec报告器是最常用的之一。

一旦 Karma 配置完成,你可以运行我们用 Karma 配置的测试,而不是使用 Jasmine 测试运行器:

$ ./node_modules/karma/bin/karma start 

你可以使用 Gulp 来自动化 Karma 的运行,毕竟那是它的职责:

// configuration of Gulp

要测试什么以及如何测试 Backbone 应用程序

Backbone 库有不同的组件,每个组件都有自己的意图和职责,这就是为什么你必须以不同的方式测试它们。请记住,你应该只测试你的代码,而不是 Backbone 内置的功能。

在接下来的章节中,你将看到你的 Backbone 应用程序的各个部分以及如何测试它们;我们将从简单的事情开始,然后逐步过渡到更复杂的内容。然后,你将学习如何隔离模块,以便一次只测试一个模块。

测试模型和集合

最基本的测试是确保模型和集合具有正确的属性设置,以防止其属性发生意外更改。在模型的情况下,你可以测试创建新联系人时的默认值,并验证url属性是否正确:

// spec/apps/contacts/models/contactSpec.js
var Contact = require('../../../../app/js/apps/contacts/models/contact');

describe('Contact model', () => {
describe('creating a new contact', () => {
it('has the default values', () => {
var contact = new Contact();

expect(contact.get('name')).toEqual('');
expect(contact.get('phone')).toEqual('');
expect(contact.get('email')).toEqual('');
expect(contact.get('address1')).toEqual('');
expect(contact.get('address2')).toEqual('');
expect(contact.get('avatar')).toEqual(null);
    });
  });

it('has the rigthurl', () => {
var contact = new Contact();
expect(contact.url()).toEqual('/api/contacts');
  });
});

对于集合,你可以验证url是否正确:

// spec/apps/contacts/collections/contactCollectionSpec.js
varContactCollection = require('../../../../app/js/apps/contacts/collections/contactCollection');

describe('Contac collection', () => {
it('has the rigthurlRoot', () => {
var collection = new ContactCollection();
expect(collection.url).toEqual('/api/contacts');
  });
});

测试视图

视图管理数据(如模型或集合)与用户交互(DOM)之间的关系。在视图的情况下,你应该测试以下内容:

  • 渲染:给定一个模型或集合,你应该验证输出 HTML 是否正确

  • 事件:这验证了 DOM 事件是否被正确处理

  • 模型更改:如果模型发生变化,视图应该与之同步

对于这个例子,我们将测试ContactForm视图;这个视图的职责是向用户展示一个表单,然后获取用户输入以更新模型。

在对视图进行测试时,建议使用模拟模型而不是原始的Contact模型。这样做的主要原因是为了隔离ContactView对象,这样如果测试失败,你就会知道错误是孤立在视图中的,并不依赖于Contact模型。

你可以开始测试渲染的 HTML 是否正确,如下所示:

var Backbone = require('backbone');
var ContactForm = require('../../../../app/js/apps/contacts/views/contactForm');

describe('Contact form', () => {
var fakeContact;

beforeEach(() => {
fakeContact = new Backbone.Model({
name: 'John Doe',
facebook: 'https://www.facebook.com/john.doe',
twitter: '@john.doe',
github: 'https://github.com/johndoe',
google: 'https://plus.google.com/johndoe'
    });
  });

it('has the rigth class', () => {
var view = new ContactForm({model: fakeContact});
expect(view.className).toEqual('form-horizontal');
  });

it('renders the rigth HTML', () => {
var view = new ContactForm({model: fakeContact});

view.render();

expect(view.$el.html()).toContain(fakeContact.get('name'));
expect(view.$el.html()).toContain(fakeContact.get('twitter'));
expect(view.$el.html()).toContain(fakeContact.get('github'));
expect(view.$el.html()).toContain(fakeContact.get('google'));
expect(view.$el.html())
.toContain(fakeContact.get('facebook'));
  });
});

注意在测试中,我们是在查看输出 HTML 是否包含特定的文本。你可以使用特定的选择器来代替:

expect(view.$el.find('#name').val())
.toContain(fakeContact.get('name'));

然而,不建议在不稳定的应用程序中这样做,因为设计可能会迅速改变,即使屏幕上有名称,测试也会失败:

测试视图

图 8.1 Jasmine 测试函数

图 8.1 说明了beforeEach()afterEach()it()函数之间的关系。当你定义一个或多个beforeEach()函数在describe()中,那么所有的beforeEach()函数都将始终在it()函数之前执行。这个特性非常有用,因为它可以确保每个测试都有相同的初始条件。

ContactForm对象的示例测试套件中,我们确保fakeContact始终具有相同的属性;如果你在it()函数下更改模型中的某些内容,下一个函数将始终获得一个干净的fakeContact模型进行测试。

ContactForm对象有一个保存按钮,当点击时会触发一个form:save事件;为了测试这个,你可以在 Jasmine 的间谍函数上监听这个事件。间谍函数是一个什么也不做,只是记录何时以及如何被调用的函数。然后,你可以用它来在其中设置期望:

it('triggers a form:save event when save button is cliecked', () => {
var view = new ContactForm({model: fakeContact});
var callback = jasmine.createSpy('callback');

view.on('form:save', callback);
view.render();

// Emulate a user click
view.$el.find('#save').trigger('click');

expect(callback).toHaveBeenCalled();
});

Jasmine 的createSpy()方法创建了一个间谍函数,该函数将被用作form:save事件的处理器。然后,它模拟在保存按钮上触发一个点击事件,并测试callback函数是否被调用。

我们可以更进一步,检查函数是否以模型作为参数被调用:

expect(callback).toHaveBeenCalledWith(mockContact);

现在是测试用户在表单中输入并点击保存按钮的时候;我们期望的是模型随着输入值的改变而改变:

it('updates the model when the save button is clicked', () => {
var view = new ContactForm({model: fakeContact});
var callback = jasmine.createSpy('callback');
varexpectedValues = {
name: 'Jane Doe',
facebook: 'https://www.facebook.com/example',
twitter: '@example',
github: 'https://github.com/example',
google: 'https://plus.google.com/example'
  };

view.on('form:save', callback);
view.render();

  // Change the input fields
  view.$el.find('#name').val(expectedValues.name);
view.$el.find('#facebook').val(expectedValues.facebook);
view.$el.find('#twitter').val(expectedValues.twitter);
view.$el.find('#github').val(expectedValues.github);
view.$el.find('#google').val(expectedValues.google);

  // Emulate a change events on all input fields
view.$el.find('input').trigger('change');

  // Emulate a user click
view.$el.find('#save').trigger('click');

// Get the argument passed to the callback function
var callArgs = callback.calls.argsFor(0);
var model = callArgs[0];

expect(model.get('name')).toEqual(expectedValues.name);
expect(model.get('facebook')).toEqual(expectedValues.facebook);
expect(model.get('twitter')).toEqual(expectedValues.twitter);
expect(model.get('github')).toEqual(expectedValues.github);
expect(model.get('google')).toEqual(expectedValues.google);
});

在这个测试中,我们更改输入字段的值,然后在表单中点击保存按钮。callback间谍函数记录了form:save事件是如何被触发的,并提取传递给它的参数。我们可以使用这个参数来测试模型是否按预期更新。

测试控制器

控制器比测试更复杂,因为它们比模型、集合和视图有更多的依赖。如果你查看这些对象的代码,你会看到它们唯一的依赖是 Backbone 和 Underscore。

你可以用所有依赖来测试控制器,这意味着在测试ContactEditor控制器时,你将测试所有附加到它的视图和模型,因为模块需要这些对象。

这对单元测试来说不是很好,因为你最终会得到集成测试。如果Contact模型有缺陷,那么ContactEditor将失败,即使它没有错误。

你需要将模块从其他模块的混乱中隔离出来。记住,你应该信任你的库,因为它们已经有了自己的测试套件。我们需要一个机制来伪造模块的依赖。

使用依赖注入,你可以覆盖require()函数,而不是加载指向的脚本,以便使用一个假对象。这将保证正在测试的代码是隔离的,并且其行为对单元测试来说是可预测的。

模拟依赖

在 Node 中模拟依赖有两个主要选择:rewireproxyquireify;使用这些库,你可以覆盖模块的原始依赖,以便使用假版本。

使用 Browserify 时,你应该有proxyquireify。使用 npm 安装它,如下所示:

$ npm install --save-dev proxyquirefy

一旦库安装完成,我们需要在 Karma 配置文件中添加适当的配置:

// ...

browserify: {
debug: true,
plugin: ['proxyquireify/plugin'],
transform: ['jstify'],
extensions: ['.js', '.tpl']
},

// ...

你应该在使用之前初始化proxyquireify。因为proxyquireify覆盖了原始的require()函数,所以它应该在使用之前初始化。初始化函数返回一个与原始require()函数类似的功能对象;然而,它具有伪造依赖项的额外功能,如下所示:

var proxyquire = require('proxyquireify')(require);

proxyquire对象可以用来加载模块:

var ContactViewer = proxyquire('./contacts/contactViewer');

当你使用proxyquireify加载模块时,你可以使用第二个参数来覆盖原始依赖项。它是一个对象,其中键是依赖项的名称,值是替换原始依赖项的对象:

var targetFile = '../../app/js/apps/contacts/contactViewer';
var fakes = {
'./views/ContactView': Backbone.View
}
var ContactViewer = proxyquire(targetFile, fakes);

此配置将用空的Backbone.View对象替换ContactView对象,以便在测试ContactViewer对象时,模块不会加载原始的ContactView模块。

伪造对象

一个伪造对象是一个具有与原始对象相同功能的简单对象;然而,它具有可预测的行为,这样你就可以使用伪造对象来隔离正在测试的模块。例如,我们所有的控制器都依赖于App对象来工作;然而,为了测试的目的使用真实的App对象并不是一个好主意。如果App对象出现错误,那么控制器测试将会失败。

如下所示是App对象的伪造:

// spec/fakes/app.js
'use strict';

var fakeRouter = {
navigate: jasmine.createSpy()
};

var FakeApp = {
router:  fakeRouter,

notifySuccess(message) {
this.lastSuccessMessage= message;
  },

notifyError(message) {
this.lastErrorMessage = message;
  },

reset() {
deletethis.lastSuccessMessage;
deletethis.lastErrorMessage;
this.router.navigate = jasmine.createSpy();
  }
};

_.extend(FakeApp, Backbone.Events);

module.exports = FakeApp;

这个简单的对象可以模拟成真实的App对象,正如你所见,这个对象什么也不做;然而,它将在下一节中用于测试ContactEditor控制器。

区域也可以被伪造,以消除原始区域的所有开销:

// spec/fakes/region.js
'use strict';

class FakeRegion {
show(view) {
view.render();
  }
}

module.exports = FakeRegion;

这非常简单,只需渲染传递给它的视图。

测试 ContactEditor

ContactEditor控制器的职责是渲染必要的视图,以便用户可以更新或创建新的联系人。它与许多视图和Contact模型密切相关。

我们将使用proxyquireify来隔离ContactEditor控制器,并且不是使用真实对象,我们将伪造其中大部分。第一个测试是检查子应用程序是否在正确的区域渲染:

// spec/apps/contacts/contactEditor.js
var proxyquery = require('proxyquireify')(require);
var Backbone = require('backbone');

var FakeRegion = require('../../fakes/region');
var fakes = {
'./views/contactPreview': Backbone.View,
'./views/phoneListView': Backbone.View,
'./views/emailListView': Backbone.View,
'./collections/phoneCollection': Backbone.Collection,
'./collections/emailCollection': Backbone.Collection
};

var ContactEditor = proxyquery('../../../app/js/apps/contacts/contactEditor', fakes);

describe('Contact editor', () => {
var fakeContact;
var editor;
var region;

beforeEach(() => {
region = new FakeRegion();
editor = new ContactEditor({region});
fakeContact = new Backbone.Model({
name: 'John Doe',
facebook: 'https://www.facebook.com/john.doe',
twitter: '@john.doe',
github: 'https://github.com/johndoe',
google: 'https://plus.google.com/johndoe'
    });
  });

describe('showing a contact editor', () => {
it('renders the editor in the given region', () => {
spyOn(region, 'show').and.callThrough();
editor.showEditor(fakeContact);
expect(region.show).toHaveBeenCalled();
    });
  });
});

我们几乎伪造了ContactEditor控制器的所有视图,我们不需要真实的视图,因为我们不是在测试输出 HTML,这是视图测试的工作。唯一没有被伪造的视图是FormLayout视图:

// spec/fakes/formLayout.js
'use strict';

var Common = require('../../app/js/common');

class FakeFormLayout extends Common.Layout {
constructor(options) {
super(options);
this.template = '<div class="phone-list-container" />' +
                    '<div class="email-list-container" />';

this.regions = {
phones: '.phone-list-container',
emails: '.email-list-container'
    };
  }
}

module.exports = FakeFormLayout;

然后添加伪造对象,如下所示:

var FakeFormLayout = require('../../fakes/formLayout');

var fakes = {
'./views/contactPreview': Backbone.View,
'./views/phoneListView': Backbone.View,
'./views/emailListView': Backbone.View,
'./views/contactForm': FakeFormLayout,
'./collections/phoneCollection': Backbone.Collection,
'./collections/emailCollection': Backbone.Collection
};

// ...

ContactEditor控制器中,我们正在监听ContactPreview视图的avatar:selected事件,我们应该确保事件被正确处理。然而,我们有一个问题,我们无法访问视图实例。为了使控制器可测试,将视图作为控制器的属性放置是一个常见的做法,如下面的代码所示:

class ContactEditor {
  // ...

showEditor(contact) {
    // Data
var phonesData = contact.get('phones') || [];
var emailsData = contact.get('emails') || [];
this.phones = new PhoneCollection(phonesData);
this.emails = new EmailCollection(emailsData);

    // Create the views
this.layout = new ContactFormLayout({model: contact});
this.phonesView = new PhoneListView({
collection: this.phones
});
this.emailsView = new EmailListView({
collection: this.emails
});
this.contactForm = new ContactForm({model: contact});
this.contactPreview = new ContactPreview({
controller: this,
model: contact
    });

    // Render the views
this.region.show(this.layout);
this.layout.getRegion('form').show(this.contactForm);
this.layout.getRegion('preview').show(this.contactPreview);
this.contactForm.getRegion('phones').show(this.phonesView);
this.contactForm.getRegion('emails').show(this.emailsView);

this.listenTo(this.contactForm, 'form:save',
this.saveContact);
this.listenTo(this.contactForm, 'form:cancel', this.cancel);
this.listenTo(this.contactForm, 'phone:add', this.addPhone);
this.listenTo(this.contactForm, 'email:add', this.addEmail);

this.listenTo(this.phonesView, 'item:phone:deleted', 
(view, phone) => {
this.deletePhone(phone);
    });
this.listenTo(this.emailsView, 'item:email:deleted',
 (view, email) => {
this.deleteEmail(email);
    });

    // When avatar is selected, we can save it inmediatly if the
    // contact already exists on the server, otherwise just
    // remember the file selected
this.listenTo(this.contactPreview, 'avatar:selected',
blob => {
this.avatarSelected = blob;

if (!contact.isNew()) {
this.uploadAvatar(contact);
      }
    });
  }

  // ...
}

通过这个更改,我们可以进行适当的测试,它验证了当contactPreview视图选择一个图像时,avatarSelected属性被设置:

it('binds the avatar:selected event in the contact preview', () => {
var expectedBlob = new Blob(['just text'], {
type: 'text/plain'
});

editor.showEditor(fakeContact);
// Fake the uploadAvatar method to prevent side effects
editor.uploadAvatar = jasmine.createSpy();

editor.contactPreview.trigger('avatar:selected', expectedBlob);
expect(editor.avatarSelected).toEqual(expectedBlob);
});

ContactEditor 控制器的核心功能是在用户点击 保存 按钮时正确保存联系信息,如下所示:

describe('Contact editor', () => {
  // ...
describe('saving a contact', () => {
beforeEach(() => {
jasmine.Ajax.install();

      // Fake the contact url, it is not important here
      fakeContact.url = '/fake/contact';

      // Fake upload avatar, we are not testing this feature
editor.uploadAvatar = function(contact, options) {
options.success();
      };

editor.showEditor(fakeContact);
    });

afterEach(() => {
jasmine.Ajax.uninstall();
FakeApp.reset();
    });
  }
}

在这个测试用例中,控制器将在模型中调用 save() 方法以保存联系信息,Backbone 将向服务器发起 Ajax 调用。在测试时,你不应该建立真实的服务器连接,因为这会使你的测试变慢并容易失败。

使用 jasmine-ajax 插件,你可以伪造 Ajax 调用,从而完全控制测试的行为。你需要先安装该包:

$ npm install --save-devkarma-jasmine-ajax

然后,更新 Karma 的配置以包括插件,如下所示:

frameworks: ['browserify', 'jasmine-ajax', 'jasmine'],

插件覆盖了原始的 XMLHttpRequest 对象,因此,在开始测试之前初始化 Ajax 插件并完成测试后恢复原始对象非常重要。

beforeEach() 函数中,我们将通过调用 jasmine.Ajax.install() 初始化插件,并在 afterEach() 中使用 jasmine.Ajax.uninstall() 恢复原始的 XMLHttpRequest 对象。

当你的应用程序发起 Ajax 调用时,插件将捕获请求,然后你可以检查请求或伪造响应,如下所示:

it('shows a success message when the contact is saved', () => {
editor.saveContact(fakeContact);

jasmine.Ajax.requests.mostRecent().respondWith({
status: '200',
contentType: 'application/json',
responseText: '{}'
  });

expect(FakeApp.lastSuccessMessage).toEqual('Contact saved');
expect(FakeApp.router.navigate)
.toHaveBeenCalledWith('contacts', true);
});

在前面的测试中,我们保存了联系信息并伪造了一个 HTTP 200 响应。当这种情况发生时,应用程序将显示成功消息并将应用程序重定向到联系列表。

如果服务器响应错误,那么应用程序将显示错误消息,而不会将应用程序重定向到联系列表:

it('shows an error message when the contact cant be saved', () => {
editor.saveContact(fakeContact);

jasmine.Ajax.requests.mostRecent().respondWith({
status: '400',
contentType: 'application/json',
responseText: '{}'
  });

expect(FakeApp.lastErrorMessage)
.toEqual('Something goes wrong');
expect(FakeApp.router.navigate)
.not.toHaveBeenCalled();
});

saveContact() 方法还做的一件事是设置联系模型中的 phonesemails 属性。测试将确保这些属性被正确地发送到服务器,如下面的代码所示:

it('saves the model with the phones and emails added', () => {
var expectedPhone = {
description: 'test',
phone: '555 5555'
  };
var expectedEmail = {
description: 'test',
phone: 'john.doe@example.com'
  };

editor.phones = new Backbone.Collection([expectedPhone]);
editor.emails = new Backbone.Collection([expectedEmail]);
editor.saveContact(fakeContact);

var requestText = jasmine.Ajax.requests.mostRecent().params;
var request = JSON.parse(requestText);

expect(request.phones.length).toEqual(1);
expect(request.emails.length).toEqual(1);
expect(request.phones).toContain(expectedPhone);
expect(request.emails).toContain(expectedEmail);
});

我们设置了一个 phonesemails 的列表,然后测试服务器是否接收到正确的请求。

如果联系信息无效,那么控制器将不会向服务器发送任何信息:

it('does not save the contact if the model is not valid', () => {
  // Emulates an invalid model
fakeContact.isValid = function() {
return false;
  };

editor.saveContact(fakeContact);
expect(jasmine.Ajax.requests.count()).toEqual(0);
});

ContactEditor 对象只有在模型是新的情况下才应上传头像图片。如果模型不是新的,那么当用户选择图片时,头像会立即上传:

it('uploads the selected avatar if model is new', () => {
  // Emulates a new model
fakeContact.isNew= function() {
return true;
  };

editor.uploadAvatar = jasmine.createSpy('uploadAvatar');
editor.saveContact(fakeContact);

jasmine.Ajax.requests.mostRecent().respondWith({
status: '200',
contentType: 'application/json',
responseText: '{}'
  });

expect(editor.uploadAvatar).toHaveBeenCalled();
});

it('does not upload the selected avatar if model is not new', () => {
  // Emulates a not new model
fakeContact.isNew= function() {
return false;
  };

editor.uploadAvatar = jasmine.createSpy('uploadAvatar');
editor.saveContact(fakeContact);

jasmine.Ajax.requests.mostRecent().respondWith({
status: '200',
contentType: 'application/json',
responseText: '{}'
  });

expect(editor.uploadAvatar).not.toHaveBeenCalled();
});

测试子应用 Façade

子应用 Façade 的职责是创建模型或收集对象,并创建适当的子应用控制器以渲染获取的数据。为了显示联系编辑器,Façade 应根据其 ID 获取联系信息,然后运行 ContactEditor 子应用:

var proxyquery = require('proxyquireify')(require);

var FakeApp = require('../../fakes/app');
var FakeRegion = require('../../fakes/region');
var FakeContactEditor = require('../../fakes/contactEditor');

var fakes = {
'../../app': FakeApp,
'./contactEditor': FakeContactEditor,
'./contactList': {},
'./contactViewer': {}
};

var ContactsApp = proxyquery('../../../app/js/apps/contacts/app', fakes);

describe('Contacts application facade', () => {
var app;
var region;

function respond(request) {
var fakeResponse = {
name: 'John Doe',
facebook: 'https://www.facebook.com/john.doe',
twitter: '@john.doe',
github: 'https://github.com/johndoe',
google: 'https://plus.google.com/johndoe'
    };

request.respondWith({
status: 200,
contentType: 'application/json',
responseText: JSON.stringify(fakeResponse)
    });
  }

beforeEach(() => {
region = new FakeRegion();
app = new ContactsApp({region});

jasmine.Ajax.install();
  });

afterEach(() => {
jasmine.Ajax.uninstall();
  });

describe('showing contact editor', () => {

  });
});

这个测试套件的设置与控制器非常相似。我们应该伪造 Ajax 调用并创建一个 Façade 对象,该对象将在规格说明中使用。我们的第一个测试将是验证它是否获取了正确的数据:

it('fetches data from the server', () => {
app.showContactEditorById('1');

var request = jasmine.Ajax.requests.mostRecent();
expect(request.url).toEqual('/api/contacts/1');
});

当从服务器获取数据时,Façade 应触发 loading: start

it('triggers a loading:start event', () => {
var callback = jasmine.createSpy('callback');

FakeApp.on('loading:start', callback);
app.showContactEditorById('1');

expect(callback).toHaveBeenCalled();
});

然后,它应在请求完成时停止:

it('triggers a loading:stop event when the contact is loaded', () => {
var callback = jasmine.createSpy('callback');

FakeApp.on('loading:stop', callback);
app.showContactEditorById('1');
respond(jasmine.Ajax.requests.mostRecent());

expect(callback).toHaveBeenCalled();
});

最后,它应显示编辑器:

it('shows the rigth contact', () => {
spyOn(FakeContactEditor.prototype, 'showEditor');
app.showContactEditorById('1');
respond(jasmine.Ajax.requests.mostRecent());

expect(FakeContactEditor.prototype.showEditor)
.toHaveBeenCalled();

var args = FakeContactEditor.prototype
.showEditor.calls.argsFor(0);
var model = args[0];

expect(model.get('id')).toEqual('1');
expect(model.get('name')).toEqual('John Doe');
});

摘要

如果你想要构建具有最少缺陷的健壮应用程序,你应该测试你的代码。即使你非常擅长编码,有时你也可能忘记一个验证或破坏一个依赖关系,直到你的应用程序的最终用户发现错误时你才会知道。

作为一名专业开发者,你应该确保你的代码始终为生产做好准备;成功做到这一点的一种方式是在你的开发工作流程中运行测试。测试应用程序的另一个好处是,你将对你的代码更有信心,这意味着你可以放心地改进你的代码,而不用担心意外破坏某些内容。

在 Backbone 中,测试依赖于你正在测试的对象的责任。模型、视图、控制器和外观都是按照自己的方式测试的。然而,无论对象是什么,Jasmine 都能出色地帮助你制作一个良好的测试库。

在下一章中,你将学习如何将你的 Backbone 应用程序部署到服务器进行生产,以及如何为你的应用程序构建一个生产环境。如果你不想处理服务器配置的内部细节,或者想看看所有部分是如何深入连接的,你可以设置一个 Heroku 实例。我将向你展示如何配置一个 Ubuntu 服务器以便进行部署。

第九章。部署到生产环境

你已经构建了一个伟大的项目:它模块化,有测试,已经自动化以执行常见任务,最后你使用 Gulp 构建了一个生产版本;然而,现在你如何将项目部署到生产服务器?

本章将探讨如何处理你项目的生产版本。在这里,你将看到如何在生产环境中运行你的节点服务器和前端资源。

在生产模式下运行你的项目有许多选择;你可以在裸金属服务器上部署,使用虚拟机,在 DigitalOcean 或 RackSpace 等共享主机上,或者也许只是将其部署到 PaaS平台即服务)服务,如 Heroku。

在下一节中,我们将看到如何部署到 Heroku 实例,这是部署的最简单方法,因为你不必担心服务器细节,你可以在单个配置文件中管理所有配置。

如果你已经有了自己的基础设施,或者只是喜欢与 DigitalOcean 或 RackSpace 虚拟服务器等服务器实例一起工作,我们将向你展示如何在服务器上配置生产环境,在那里你可以访问 shell。

Heroku

Heroku 是一个 PaaS,这意味着你不必担心你部署代码的服务器配置细节,你只需关注你的代码;Heroku 将完成基础设施配置的困难工作。

与使用 shell 安装、配置和调整你的包以在生产模式下运行相比,你只需要编辑一个配置文件,然后使用标准的 git push 命令发布你的更改。

Dynos

Heroku 使用轻量级的 Linux 容器来运行你的项目在 Heroku 平台上。Heroku 将这些容器称为 Dynos。一个 Dyno 可以托管你的代码,并在隔离的 Linux 环境中以单个进程运行它。

如果你没有 Linux 容器(如 Docker)的经验,你可以想象一个容器就像一个没有硬件仿真的小虚拟机;Linux 容器使用与主机机器相同的内核,这意味着你不需要仿真硬件:

Dynos

图 9.1 虚拟化和容器之间的区别

默认情况下,Heroku 将使用 Celadon Cedar 栈来构建 Dynos;Celadon Cedar 栈基于 Ubuntu 发行版。考虑到这一点,你将获得一个类似 Ubuntu 的发行版,你可以在其中运行你用以下语言编写的代码:

  • Ruby on Rails

  • Node.js

  • Java 或 Spring

  • Python 或 Django

  • Clojure

  • Scala 或 Play

  • PHP

  • Go

Dyno 有三种不同的类型,如下所示:

  • Web Dynos:它们用于运行服务器代码并响应 HTTP 请求。

  • 工作 Dynos:它们对于后台作业,如图像处理器非常有用。

  • 一次性 Dynos:它们的作用是为其他两种 Dyno 类型提供维护。

如你所猜,在这本书中,我们只会使用 Node.js 的 Web Dynos 来运行我们的 Contacts app

开始使用 Heroku

为了开始使用 Heroku,首先要做的事情是注册该服务,具体步骤如下:

开始使用 Heroku

图 9.2 Heroku 注册表单

一旦您在平台上注册,您需要在您的宿主机上安装 Heroku Toolbelt;有适用于 Linux、Mac OS X 和 Windows 的版本。安装过程完成后,您可以使用 heroku 命令对服务进行身份验证:

$heroku login
Enter your Heroku credentials.
Email: your.email@example.com
Password (typing will be hidden):
Authentication successful.

在您通过 Heroku 服务进行身份验证后,您可以使用 create 命令开始创建 Dynos:

$ heroku create
Creating enigmatic-anchorage-3587... done, stack is cedar-14
https://enigmatic-anchorage-3587.herokuapp.com/ | https://git.heroku.com/enigmatic-anchorage-3587.git
Git remote heroku added

当您在 Heroku 上创建一个新的 Dyno 时,它会为您的 Dyno 生成一个随机名称。在前面的示例中,名称是 enigmatic-anchorage-3587,您可以通过 enigmatic-anchorage-3587.herokuapp.com 访问您的 Dyno:

开始使用 Heroku

图 9.3 Dyno 默认输出

您可以通过将更改推送到位于 git.heroku.com/enigmatic-anchorage-3587.git 的 Git 服务器来部署您的应用程序。您需要将此地址添加为您的仓库中的远程服务器:

$ git remote add heroku https://git.heroku.com/enigmatic-anchorage-3587.git

如果您现在进行推送,部署将不会工作,这是因为您需要告诉 Heroku 如何运行您的项目;这是通过一个名为 Procfile 的配置文件完成的,您应该将其放在应用程序根目录中:

web: node server/index.js

代码非常简单,运行 server/index.js 脚本。您可以使用 local 命令测试配置是否正常工作;此命令在真正部署之前查找错误或问题非常有用:

$ heroku local
Installing Heroku Toolbelt v4... done
Setting up iojs-v3.2.0... done
Installing core plugins heroku-apps, heroku-fork, heroku-git, heroku-local, heroku-run, heroku-status... done
Downloading forego-0.16.1 to /Users/abiee/.heroku... done
forego | starting web.1 on port 5000
web.1  | Server running

You can access the server using the browser at the http://localhost:8000/ URL. If you don't get any issues, you can make a real deployment by pushing the code at the master branch:
$ git checkout master
$ gulp build
$ git add .
$ git commit "Deployment build"
$ git push heroku master
Counting objects: 63, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (57/57), done.
Writing objects: 100% (63/63), 380.85 KiB | 0 bytes/s, done.
Total 63 (delta 3), reused 0 (delta 0)
remote: Compressing source files... done.
remote: Building source:
remote:
remote: -----> Node.js app detected
remote:
remote: -----> Creating runtime environment
remote:
remote:        NPM_CONFIG_LOGLEVEL=error
remote:        NPM_CONFIG_PRODUCTION=true
remote:        NODE_ENV=production
remote:        NODE_MODULES_CACHE=true
remote:
remote: -----> Installing binaries
remote:        engines.node (package.json):  unspecified
remote:        engines.npm (package.json):   unspecified (use default)
remote:
remote:        Resolving node version (latest stable) via semver.io...
remote:        Downloading and installing node 0.12.7...
remote:        Using default npm version: 2.11.3
remote:
remote: -----> Restoring cache
remote:        Skipping cache (new runtime signature)
remote:
remote: -----> Building dependencies
remote:        Pruning any extraneous modules
remote:        Installing node modules (package.json)
...
remote: -----> Caching build
remote:        Clearing previous node cache
remote:        Saving 1 cacheDirectories (default):
remote:        - node_modules
remote:
remote: -----> Build succeeded!
remote:        ├── backbone@1.2.2
remote:        ├── body-parser@1.13.3
remote:        ├── browser-sync@2.8.2
remote:        ├── express@4.13.3
remote:        ├── http@0.0.0
remote:        ├── http-proxy@1.11.2
remote:        ├── jquery@2.1.4
remote:        ├── lodash@3.10.1
remote:        ├── morgan@1.6.1
remote:        ├── multer@1.0.3
remote:        └── underscore@1.8.3
remote:
remote: -----> Discovering process types
remote:        Procfile declares types -> web
remote:
remote: -----> Compressing... done, 25.4MB
remote: -----> Launching... done, v3
remote:        https://enigmatic-anchorage-3587.herokuapp.com/ deployed to Heroku
remote:
remote: Verifying deploy.... done.
To https://git.heroku.com/enigmatic-anchorage-3587.git

在输出日志中,您可以看到 Heroku 正在做什么:

  • Heroku 会检测项目的类型,以便知道如何构建正确的环境。它可能会检测到这是一个 Node 项目,因为存在 package.json 文件。

  • 知道这是一个 Node 项目后,它可以为运行生产模式下的项目设置一些有用的环境变量。您可以在代码中使用 NODE_ENV 环境变量来使用针对生产环境的特殊配置。

  • 然后,阅读 package.json 文件以查看要安装的节点版本。您可以使用 engines 配置指定要安装的节点版本,如下所示:

    "engines": {
      "node": "⁰.12.21"
    },
    
  • 在 Dyno 中安装正确的节点版本后,Heroku 将安装 package.json 文件中指定的项目依赖项。

  • 然后,它将查找启动的 Dyno 类型的配置,并查看如何在 Procfile 中运行项目。

  • 最后,它将压缩构建并启动项目。

一旦项目在 Heroku 基础设施中运行,您可以在 enigmatic-anchorage-3587.herokuapp.com/ 上看到结果:

开始使用 Heroku

图 9.4 在 Heroku 上部署的应用程序

如你所见,部署到 Heroku 基础设施非常简单,你不必担心服务器细节,如 HTTP 服务器或进程管理,这样你可以专注于应用程序开发,而无需担心基础设施。

如果你在生产中的应用程序遇到任何问题,你可以使用logs命令查看发生了什么:

$ heroku logs –tail

这将显示 Heroku 服务器上的最后一条日志消息。请查阅服务的在线文档以获取更多详细信息;在这里你可以找到有关如何扩展你的应用程序、将 Dyno 实例连接到数据库等信息。

生产环境

如果你有一台裸金属服务器或者想要使用虚拟服务器,例如 DigitalOcean 或 Rackspace,你可以创建自己的生产环境。在本节中,你将了解如何实现这一点。

无论情况如何,因为你在这类服务器上配置生产环境的方式都是相同的。然而,请记住,这里展示的生产环境是为简单的 Web 应用程序设计的。

如果你有一个高流量的应用程序,你可以从这里开始;然而,服务器架构应该有复杂的组织。关于如何扩展你的部署的详细信息超出了本书的范围。

对于服务器,我将使用 Ubuntu 服务器,因为它是最简单且最受欢迎的应用程序部署选择。如果你熟悉其他发行版,如 CentOS,你也可以使用它;然而,说明并不相同。

下面的图显示了生产环境中Node服务器的典型配置:

生产环境

图 9.5 部署图

由于 Node 不是构建为功能齐全且健壮的 Web 服务器,你应该在 Node 之前放置一个 HTTP 服务器来响应用户请求,而不是直接使用 Node。HTTP 服务器将请求转发到 Node 进程,并将 Node 服务器的响应返回给用户。

在前面的图中,你可以看到我们正在使用一个进程管理器,其工作是将 Node 进程保持运行状态;如果 Node 进程由于某种原因崩溃,PM2将负责处理并重启进程。

此外,你可以实时监控应用程序消耗的内存和处理器,手动重启和停止进程,检查日志等。最后,数据库的访问是从Node服务器进行的。

当用户从其浏览器发起请求时,服务器将按以下方式处理请求:

  • 客户端向服务器主机发送请求

  • HTTP 服务器接收请求

  • HTTP 服务器将请求转发到 Node 服务器

  • Node 进程处理其内部请求过程

  • Node 进程向 HTTP 服务器返回答案

  • HTTP 服务器将答案转发给客户端

  • 客户端接收请求

在接下来的章节中,我们将探讨如何安装和配置所有组件,以便运行 Node 应用程序。我们将使用Ubuntu-14.04虚拟机来执行安装过程。如果您有不同的环境,配置内容应该仍然有效;然而,安装说明和配置文件的存放位置可能会有所不同。

HTTP 服务器

HTTP 服务器处理与客户端的连接,并将所有请求转发到 Node 服务器。在某种程度上,它是一种代理。目前,市场上主要有两个广泛用于生产的 HTTP 服务器:Apache 和 Nginx,两者都可以用于托管 Node 应用程序。然而,在这本书中,我们将介绍 Nginx。做出这个决定的主要原因是它的简单性和性能,并且它比 Apache 小。

要安装 Nginx,请使用apt-get

$ sudo apt-get install nginx
[sudo] password for abiee:
Reading package lists... Done
Building dependency tree
Reading state information... Done
The following extra packages will be installed:
 nginx-common nginx-core
Suggested packages:
 fcgiwrap nginx-doc
The following NEW packages will be installed:
 nginx nginx-common nginx-core
0 upgraded, 3 newly installed, 0 to remove and 30 not upgraded.
Need to get 348 kB of archives.
After this operation, 1297 kB of additional disk space will be used.
Do you want to continue? [Y/n] Y

在 Nginx 服务器安装后,Ubuntu 将自动启动服务器;然而,您可以使用服务命令管理服务器守护进程:

#Start the nginx server
$ sudo service nginx start
#Stop the nginx server
$ sudo service nginx stop
#Restart the nginx server
$ sudo service nginx restart

您可以通过将浏览器指向服务器 IP 来检查服务器是否正在运行,如下面的截图所示:

The HTTP Server

图 9.6 Nginx 全新安装

Nginx 配置文件位于/etc/nginx,在这个路径中还有两个子路径,如下所示:

  • sites-available:每个文件都是一个单一主机的配置(子域名)。请注意,这些文件在未放入 sites-enabled 之前不是活动的。

  • sites-enabled:虽然 sites-available 包含一组配置文件,但 sites-enabled 是一组实际激活的网站。

要创建一个新的网站,您需要在 sites-available 路径中创建一个新的配置文件:

$ sudo editor /etc/nginx/sites-available/webapp

配置内容如下所示:

upstream webapp {
  server 127.0.0.1:8000;
}

server {
  listen 80 default_server;

  # Configure logs
  access_log /var/log/nginx/webapp.access.log;
  error_log /var/log/nginx/webapp.error.log;

  # Make site accessible from http://www.example.com/
# server_name localhost;
  server_name www.example.com;

  location / {
    # Proxy headers
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarder-For $proxy_add_x_forwarded_for;
    proxy_set_header Host $http_host;
    proxy_set_header X-NginX-Proxy true;

    # Proxy to Nodejs
    proxy_pass http://webapp;
    proxy_redirect off;
  }
}

Nginx 的上游模块定义了一个或一组服务器,这些服务器可以通过proxy_pass进行引用,这意味着当请求到达 Nginx 时需要击中的目标。服务器配置创建了一个新的虚拟主机,监听对server_name地址的请求。在这种情况下,它正在监听www.example.com

location块中,它描述了如何处理请求;在先前的示例中,它将请求转发到指向127.0.0.1:8000webapp上游。要激活网站,您需要将此文件的 内容链接到sites-enabled路径:

$ sudo ln -s /etc/nginx/sites-available/webapp /etc/nginx/sites-enabled/

可能您需要删除之前启用的默认网站:

$ sudo rm /etc/nginx/sites-enabled/default

然后重新启动 Nginx 服务器以加载新的配置:

$ sudo service nginx restart

如果一切正常,服务器将启动:

The HTTP Server

图 9.7 Nginx 在没有 node.js 工作的情况下运行

上一张图片显示了一个502错误,这是因为 Nginx 服务器指向了具有127.0.0.1:8000地址的proxy_pass设置;然而,在那个套接字上没有任何东西在运行。你需要有一个东西在127.0.0.1:8000套接字上监听请求,因此,你应该在同一主机上运行项目,这样502错误就会消失:

$ npm install --production
$ nodejs app.js

这应该足以让服务器工作。然而,我们不想每次都手动运行app.js脚本,有一个更好的方法可以自动启动 Node 进程。

不要以 root 身份运行

以 root 身份运行服务器进程可能很危险。如果有人发现 node 或你的应用程序代码中的漏洞,那么他们可能会对系统造成严重损害。创建一个仅用于运行应用程序服务器的用户总是一个好主意:

$ sudo useradd -m production

m选项将在/home/production位置创建一个家目录,你可以在这里克隆项目仓库:

$ sudo su - production
$ cd ~
$ git clone https://example.com/path/to/the/project.git

不要以 root 身份运行

图 9.7 运行 node.js 进程后

进程管理

任何时候你在电脑上运行一个程序,它都可能因为许多原因而失败:可能它所依赖的服务器已经关闭,或者更糟糕的是,一个未处理的异常可能会终止正在运行的进程。这对生产应用来说是非常糟糕的,因为你直到注意到服务器不工作之前,你的用户都没有服务器可用。

这就是进程管理器发挥作用的地方,你可以在进程管理器后面运行你的代码,并且它会确保进程始终在运行。如果出现问题并导致程序崩溃,进程管理器将自动重置整个应用程序。

对于 Node 有两个流行的进程管理器:foreverpm2,它们的工作方式类似;然而,pm2似乎更受欢迎,并且提供了比以往更多的实用工具。因此,我们将使用pm2作为栈。

Ubuntu 自带一个名为 Upstart 的集成进程管理器。你可以使用操作系统的进程管理器;然而,pm2专注于 Node 应用,它允许你运行多个进程实例,而不是只运行一个进程。

你可以使用npm工具安装pm2,别忘了将其安装为全局包:

$ npm install -g pm2

安装完成后,你可以使用start命令在pm2后面运行你的进程:

$ pm2 start app.js
[PM2] Starting app in fork_mode (1 instance)
[PM2] Done.
┌──────────┬────┬──────┬───────┬────────┬─────────┬────────┬─────────────┬──────────┐
│ App name │ id │ mode │ pid   │ status │ restart │ uptime │ memory      │ watching │
├──────────┼────┼──────┼───────┼────────┼─────────┼────────┼─────────────┼──────────┤
│ app      │ 0  │ fork │ 16427 │ online │ 0       │ 0s     │ 15.801 MB   │ disabled │
└──────────┴────┴──────┴───────┴────────┴─────────┴────────┴─────────────┴──────────┘
 Use `pm2 show <id|name>` to get more details about an app

在你完成这个步骤后,脚本开始运行,你可以通过日志命令和应用 ID 查看进程的输出:

$ pm2 logs 0
[PM2] Tailing last 20 lines for [0] process

app-0 (out): Server running
app-0 (out): GET / 200 5.911 ms - 8908
app-0 (out): GET /css/vendor.css 200 2.740 ms - 131
app-0 (out): GET /js/app.js 200 3.821 ms - 396191
app-0 (out): GET /api/contacts 200 2.528 ms - 55

[PM2] Streaming realtime logs for [0] process

你可以用名称而不是进程的原始 ID 来命名你的运行进程:

$ pm2 start app.js --name app

你也可以运行多个实例,这样你就可以有两个相同应用正在运行的实例,pm2将在它们之间加载和平衡请求:

$ pm2 start app.js --name app –i 2

运行多个 Node 服务器实例是一个好主意,因为 Node 阻塞正在进行的 I/O 操作。如果你运行多个实例,那么其他进程可以在另一个被阻塞时继续服务传入的请求。

你可以将应用程序参数保存到 JSON 文件中,并使用它而不是在命令行中放置所有选项:

{
  "apps": [{
    "name": "contacts-app",
    "script": "/path/to/the/application/app.js",
    "cwd": "/path/to/the/application",
    "watch": false,
    "instances": 2,
    "error_file": "/path/to/your/home/contacts-app/app-err.log",
    "out_file": "/path/to/your/home/app-out.log",
    "pid_file": "//path/to/your/home/app.pid"
    "env": {
      "NODE_ENV": "production"
    }
  }]
}

使用 JSON 文件,你有这样的优势,你不必记住如何运行应用程序,因为文件包含所有必需的配置,并且相同的环境可以轻松地在不同的主机上重现。

如果你对设置满意,并且一切按预期工作,下一步就是将pm2进程持久化,以便每次服务器重启时都作为守护进程运行;这总是一个好主意,因为如果服务器因某种原因(如维护)重启,你的进程将自动启动。

幸运的是,pm2提供了一个简单的方法,使用startup命令为许多操作系统守护化你的配置,如下所示:

$ pm2 startup -h

 Usage: startup [options] [platform]

 auto resurrect process at startup. [platform] = ubuntu, centos, redhat, gentoo, systemd, darwin, amazon
$ sudo env PATH=$PATH:/usr/local/bin pm2 startup ubuntu -u production
[PM2] Generating system init script in /etc/init.d/pm2-init.sh
[PM2] Making script booting at startup...
[PM2] -ubuntu- Using the command:
 su -c "chmod +x /etc/init.d/pm2-init.sh && update-rc.d pm2-init.sh defaults"
 System start/stop links for /etc/init.d/pm2-init.sh already exist.
[PM2] Done.

第一个命令显示了可用的操作系统。由于startup命令写入/etc/路径,我们需要以 root 用户运行此命令,这就是为什么我们使用 sudo 命令的原因。

要运行守护进程,你需要运行以下命令:

$ service pm2-init.sh start

然而,在运行此命令之前,你需要在守护进程配置中导出你的当前配置,如果你跳过此步骤,服务将不会启动任何进程:

$ pm2 start process.json
$ pm2 save
[PM2] Dumping processes

使用此命令,每次服务器重启或你手动重启服务时,都将使用pm2的当前配置。

这就是你在生产环境中运行你的 Node 应用程序的方法;运行一个真实的 HTTP 服务器,并在其后面运行你的 Node 进程,借助像pm2这样的进程管理器。

摘要

在本章中,我们看到了如何在 Heroku 平台以及裸机或虚拟机服务器上运行 Node 应用程序,这两种部署 Node 应用程序的方法很简单;然而,它们是更复杂部署的基础。例如,你可以在 Docker 容器上进行部署。使用 Docker,你需要知道如何像我们一样在你的新鲜 Linux 安装中安装你的应用程序,然后,像 Heroku 一样管理容器作为进程。

在本章中,我们没有看到很多与 Backbone 相关的内容;然而,如果你有一个由 Node 支持的 Backbone 应用程序,你可能希望将你的代码部署到生产环境中。在本章中,我们看到了如何将分发文件的输出部署到生产服务器。

第十章:认证

大多数网络应用程序都使用某种授权和认证子系统,以允许其用户访问应用程序的私有信息。然而,如果你对如何实现它没有清晰的想法,认证过程可能会变得复杂,因为 Backbone 并没有提供如何实现的提示。

Backbone 是认证无关的,这意味着它不提供用于实现认证策略的对象或工具。优点是 Backbone 不与任何认证机制耦合,缺点是你应该关注它。

由于 Backbone 是基于 REST API 设计的,因此你将不得不处理那种 API 中常见的认证机制。这也是 Backbone 不强制或提供用于认证用户的工具的一个很好的原因。

另一个需要注意的事情是,REST API 应该是无状态的,这意味着它们不会跟踪你之前发出的请求。对你来说,这意味着如果你发出登录请求,你将期望服务器在后续请求中识别你;然而,在无状态服务器上,它不会记住你。

如果你之前没有与 REST 网络服务合作过,这可能会听起来很疯狂;然而,你必须每次向服务器发送请求时都进行认证。这是必要的,并且有许多可行的方法可以实现;你应该查阅 API 文档,以了解认证算法的确切细节。

尽管有众多可用的选项,它们彼此之间非常相似,只是在一些细节上有所变化;然而,本质上,它们的工作方式非常相似。因此,不必担心可供使用的不同认证方式的数量;学习基础知识,然后修改细节即可。

无状态 API 认证

对无状态 API 进行认证意味着你应该在每次向服务器发送请求时进行认证;记住,无状态服务器不会跟踪之前的请求。这意味着每次你向服务器发送请求时,它都会将请求处理为第一个请求。

由于会话信息不会存储在服务器上,你应该将其放在其他地方。对于 Backbone 应用程序来说,存储会话数据的正确位置是浏览器,你可以使用 localStorage 来存储和检索会话数据,并使用 JavaScript 来管理会话。

HTTP 基本认证

对 RESTful API 进行认证的最简单方式是使用 HTTP 基本认证。其背后的思想很简单;你应该在每次发送请求时包含你用户名和密码的编码版本。这可能听起来很危险,因为每次请求都发送用户名和密码,确实如此。因此,强烈建议只在启用了 HTTPS 连接的地方使用基本认证:

HTTP 基本认证

图 10.1 基本认证方案

用户名和密码应在请求的Authentication头下发送。考虑以下场景:

  • 用户:myuser

  • 密码:123456

为了编码Authentication头,用户名和密码应使用冒号:作为分隔符连接。

myuser:123456

然后,该字符串应按如下方式编码为base64

$ echo myuser:123456 | base64
bXl1c2VyOjEyMzQ1Ngo=

生成的字符串应用于对服务器发出的每个请求:

GET /api/contacts
Authorization: Basic bXl1c2VyOjEyMzQ1Ngo=

服务器将为每个请求解码并验证你的身份。请记住,你应该不要在没有 HTTPS 的情况下使用此机制。有人很容易拦截请求头并解码字符串,以发现你的用户名和密码。

OAuth2 认证

OAuth2 协议是为了在服务之间共享资源而设计的,不使用用户名和密码。你可能已经使用过可以使用社交网络账户进行认证的应用程序。这就是 OAuth2 的实际应用。OAuth2 认证是 RFC 6749 中描述的授权框架,如下所示:

OAuth2 认证

图 10.2 OAuth2 抽象流程

在前面的图中,你可以看到使用 OAuth2 算法进行认证的抽象图。你可以识别以下实体,如下所示:

  • 资源所有者是拥有受保护数据的实体。这通常是个人。

  • Web 应用程序是想要访问资源所有者私有数据的应用程序。

  • 授权服务器用于识别和验证资源服务器(受保护数据所在的服务器)的用户。

  • 访问令牌是在资源服务器中用于授权资源访问的数据。访问令牌通常有一个过期时间。

  • 资源服务器是提供受保护数据的主机。

注意,资源服务器和授权服务器可以是同一主机。认证过程如下:

  1. 应用程序请求资源所有者的授权。

  2. 资源所有者授权并颁发授权密钥。

  3. 应用程序使用授权密钥来交换访问令牌。

  4. 授权服务器验证授权密钥和应用程序。

  5. 授权服务器颁发访问令牌并将其返回给应用程序。

  6. 应用程序可以使用访问令牌来访问受保护资源。

颁发的访问令牌通常会有过期时间,以便防止攻击者恶意使用。当令牌过期时,应用程序应重复认证过程。

然而,每次令牌过期时都进行登录并不实际。为了防止这种情况,授权服务器颁发另一个名为刷新令牌的令牌,当当前访问令牌过期时,可以使用它来颁发新的访问令牌。

服务应用程序

当你想访问像 Facebook、Twitter、Google 等服务的私有数据时,你必须首先将该服务中的应用程序注册到该服务上。当你将该服务中的应用程序注册到服务上时,他们将会要求你提供应用程序名称、描述、网站等信息。

当应用程序注册完成后,服务将为你提供一些令牌来识别你的应用程序,这些令牌包括以下两个关键数据:

  • ClientID:这个唯一标识符用于在服务中识别你的应用程序

  • ClientSecret:这用于验证使用给定 ClientID 发出的请求是否合法

如果你为应用程序构建的 REST 服务器只由你访问,你可以手动生成一个ClientIDClientSecret作为应用程序中的常量值。

如果你的 REST 服务器将向任何想要玩弄应用程序数据的人公开公共 API,你应该开发某种应用程序注册(例如用户注册)以便允许其他人注册他们的应用程序。

OAuth2 授权类型

在上一节中,你已经看到了 OAuth2 协议作为认证的抽象模式。RFC 6749 文档规范描述了四种不同的方式来获取访问令牌。

授权代码授权

授权代码授权是最完整的授权流程;其主要用途是从另一个服务器访问用户的私有资源:

授权代码授权

图 10.3 授权代码授权

参考前面的图示。服务器应用程序是一个应用程序服务器(例如 Node.js、Python 等),API 服务器是一个第三方服务器,其中包含私有资源(例如 Facebook、Google 等)。

在授权代码授权场景中,服务器应用程序想要代表用户从 API 服务器获取数据。这是通过服务器应用程序完成的;当用户与 Backbone 应用程序交互时,它会向服务器应用程序发出请求,然后服务器应用程序可以从 API 服务器获取数据,进行一些处理,并将响应返回给 Backbone 应用程序。

Backbone 应用程序永远不会与 API 服务器建立单个连接,这是服务器应用程序的责任,以确保 Backbone 应用程序只能看到单个服务器应用程序。

隐式授权

这是对授权代码授权的简化;隐式授权的使用是针对没有服务器或移动应用程序的纯前端应用程序:

隐式授权

图 10.4 隐式授权

在隐式授权中,App 服务器不存在,因此,Backbone 应用程序应直接与 API 服务器通信。尽管隐式授权很简单,但你应该注意其安全问题。

为了最小化这种风险,你的应用程序应该使用 HTTPS 进行安全保护,并且如果你没有启用此流类型,不要使用这种流类型。另一个相关问题是,此授权类型不会颁发刷新令牌,这意味着当访问令牌过期时,你应该重新登录。

资源所有者密码凭证授权

当 Backbone App 和 API 服务器是同一应用程序时,此授权类型非常有用。换句话说,前端应用程序和后端服务器是由您开发的,这意味着您不是在访问第三方资源。

由于您的应用程序拥有所有资源,您将需要应用程序的用户名和密码来对其进行身份验证:

资源所有者密码凭证授权

图 10.5 资源所有者密码

上述图表与隐式授权图表非常相似;然而,在这种情况下,您不需要使用 ClientID 和 ClientSecret 令牌,这简化了身份验证过程。

当您使用此授权类型时,感觉就像传统的身份验证方式;您应该将您的用户名和密码发送到服务器,然后服务器会告诉您凭证是否有效。如果有效,您将收到一个有效的访问令牌,您可以存储并按需使用。

客户端凭证授权

当您有一个信任的客户端访问服务器资源时,会使用客户端凭证授权。例如,一个商业伙伴。在这种授权类型中,您不是验证用户,而是验证应用程序,因此不需要用户名或密码。

在这个授权中,如果您信任客户端,应该使用 ClientID 和 ClientSecret,将颁发访问令牌。

客户端凭证授权

图 10.6 客户端凭证

摘要

在前面的章节中,您已经看到了如何使用 OAuth2 框架对 REST 服务器进行身份验证;在 OAuth2 中,规范以四种方式描述,使用任何一种取决于应用程序的需求。

然而,所有这些授权类型的目标都是获取一个可以用于下一个服务器请求的访问令牌。一旦您有了访问令牌,与 API 服务器的交互应该对 Backbone App 来说是透明的,令牌应该发送,而无需应用程序其他部分的了解。

实现 HTTP 基本身份验证

让我们在Contacts App中实现基本认证协议。正如您在前面的章节中学到的,您需要为向服务器发出的每个请求添加Authorization头,以便进行身份验证。从服务器端,您需要读取和解析此头。

已经开发了一个有用的npm包来解码Authorization头。使用basic-auth模块,您可以读取请求头并返回一个包含两个字段的对象:namepass,这些字段可以用来验证用户。为了简单起见,我们将使用硬编码的用户名和密码,而不是真实的数据库:

// server/basicAuthMiddleware.js
var basicAuth = require('basic-auth');

var authorizationRequired = function (req, res, next) {
  var credentials = basicAuth(req) || {};

  if (credentials.name === 'john' && credentials.pass === 'doe') {
    return next();
  } else {
    return res.sendStatus(401);
  }
};

module.exports = authorizationRequired;

中间件会检查用户名是否为john且密码是否为doe。如果不是,将向客户端发送 HTTP 401错误。您可以为想要保护的每个资源使用中间件:

var controller = require('./controller');
var authorizationRequired = require('./basicAuthMiddleware');

module.exports = routes = function(server) {
  server.post('/api/contacts',
authorizationRequired, controller.createContact);
  server.get('/api/contacts',
authorizationRequired, controller.showContacts);
  server.get('/api/contacts/:contactId',
authorizationRequired, controller.findContactById);
  server.put('/api/contacts/:contactId',
authorizationRequired, controller.updateContact);
  server.delete('/api/contacts/:contactId',
authorizationRequired, controller.deleteContact);
  server.post('/api/contacts/:contactId/avatar',
authorizationRequired, controller.uploadAvatar);
};

我们在 HTTP 401 响应中包含的 WWW-Authenticate 头部将确保浏览器弹出一个对话框要求你输入用户名和密码。你可以在对话框中使用 john 用户和 doe 密码,然后浏览器将为你构建并发送认证头:

实现 HTTP 基本认证

图 10.7 基本认证登录

为了更好地控制如何请求认证,你可以创建一个 form 视图并为认证目的添加一些路由:

<div class="col-xs-12 col-sm-offset-4 col-sm-4">
<div class="panel">
<div class="panel-body">
<h4>
Login required
</h4>
<p>
Use 'john' as user and 'doe' as password.
</p>
<form>
<div class="form-group">
<label for="username">User</label>
<input type="user" class="form-control" id="username" placeholder="Username">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" class="form-control" id="password" placeholder="Password">
</div>
<p id="message" class="pull-left"></p>
<button type="submit" class="btn btn-primary pull-right">Login</button>
</form>
</div>
</div>
</div>

LoginView 方法应该在用户点击 登录 按钮时处理认证过程:

// apps/login/views/loginView.js
'use strict';

var Common = require('../../../common');
var template = require('../templates/login.tpl');

class LoginView extends Common.ModelView {
  constructor(options) {
    super(options);
    this.template = template;
  }

  get className() {
    return 'row';
  }

  get events() {
    return {
      'click button': 'makeLogin'
    };
  }

  makeLogin(event) {
    event.preventDefault();

    var username = this.$el.find('#username').val();
    var password = this.$el.find('#password').val();

console.log('Will login the user', username,
                'with password', password);
  }
}

module.exports = LoginView; 

应该添加一个新的路由来显示 #/login 表单:

// apps/login/router.js
'use strict';

var Backbone = require('backbone');
var LoginView = require('./views/loginView');

class LoginRouter extends Backbone.Router {
  constructor(options) {
    super(options);

    this.routes = {
      'login': 'showLogin'
    };

    this._bindRoutes();
  }

  showLogin() {
    var App = require('../../app');
    var login = new LoginView();

    App.mainRegion.show(login);
  }
}

module.exports = new LoginRouter();

当应用程序启动时,你需要包含这个新的路由,如下所示:

// app.js
// ...

// Initialize all available routes
require('./apps/contacts/router');
require('./apps/login/router');

// ...

当未认证的用户访问 #/contacts 路由时,Backbone 应用程序应该将他们重定向到登录表单:

Backbone.$.ajaxSetup({
  statusCode: {
    401: () =>{
      window.location.replace('/#login');

    }
  }
});

当服务器响应 HTTP 401 时,意味着用户未认证,此时你可以显示登录窗口。请记住,为了防止浏览器显示其登录对话框,需要移除 WWW-Authenticate 响应头:

function unauthorized(res) {
  // res.set('WWW-Authenticate', 'Basic realm=Authorization Required');
  return res.sendStatus(401);
};

实现 HTTP 基本认证

图 10.8 登录表单

现在我们已经有了登录表单,我们可以将其中的认证代码放入其中。这将分为以下三个步骤进行:

  1. 构建认证字符串。

  2. 测试认证字符串是否有效。

  3. 为未来的请求保存认证字符串。

认证字符串很容易构建,你可以使用 btoa() 函数将字符串转换为 base64,如下所示:

class LoginView extends Common.ModelView {
  // ...

  makeLogin(event) {
    event.preventDefault();

    var username = this.$el.find('#username').val();
    var password = this.$el.find('#password').val();
    var authString = this.buildAuthString(
      username, password
    );

    console.log('Will use', authString);
  }

  buildAuthString(username, password) {
    return btoa(username + ':' + password);
  }
}

然后,你可以使用 authString 来测试是否可以成功获取联系人资源。如果服务器成功响应,则说明用户正在使用正确的凭据:

class LoginView extends Common.ModelView {
  // ...

  makeLogin(event) {
    event.preventDefault();

    var username = this.$el.find('#username').val();
    var password = this.$el.find('#password').val();
    var authString = this.buildAuthString(
      username, password
    );

    Backbone.$.ajax({
      url: '/api/contacts',
      headers: {
        Authorization: 'Basic ' + authString
      },
      success: () => {
        var App = require('../../../app');
        App.router.navigate('contacts', true);
      },
      error: jqxhr => {
        if (jqxhr.status === 401) {
          this.showError('User/Password are not valid');
        } else {
          this.showError('Oops... Unknown error happens');
        }
      }
    });
  }

  buildAuthString(username, password) {
    return btoa(username + ':' + password);
  }

  showError(message) {
    this.$('#message').html(message);
  }
}

如果认证字符串有效,则用户将被重定向到联系人列表;然而,重定向可能不会按预期工作,因为联系人列表中没有发送 Authorization 头部。请记住,你应该为每个请求发送 Authorization 头部。

你需要将 Authentication 字符串保存在 sessionStorage 中,以便在未来的请求中使用。sessionStoragelocalStorage 类似;然而,在 sessionStorage 中,数据将在浏览器关闭时被移除:

class LoginView extends Common.ModelView {
  // ...

  makeLogin(event) {
// ...

    Backbone.$.ajax({
      url: '/api/contacts',
      headers: {
        Authorization: 'Basic ' + authString
      },
      success: () => {
        var App = require('../../../app');
        App.saveAuth('Basic', authSting);
        App.router.navigate('contacts', true);
      },
      error: jqxhr => {
        if (jqxhr.status === 401) {
          this.showError('User/Password are not valid');
        } else {
          this.showError('Oops... Unknown error happens');
        }
      }
    });
  }

// ...
}

App 对象将负责存储令牌:

// app.js
var App = {
  // ...

  // Save an authentication token
  saveAuth(type, token) {
    var authConfig = type + ':' + token;

    sessionStorage.setItem('auth', authConfig);
    this.setAuth(type, token);
  },

  // ...
}

在将令牌保存在 sessionStorage 中后,你应该为每个未来的请求包含 Authorization 头部:

// app.js
var App = {
  // ...

  // Set an authorization token
  setAuth(type, token) {
    var authString = type + ' ' + token;
    this.setupAjax(authString);
  },

  // Set Authorization header for authentication
  setupAjax(authString) {
    var headers = {};

    if (authString) {
      headers = {
        Authorization: authString
      };
    }

    Backbone.$.ajaxSetup({
      statusCode: {
        401: () => {
          App.router.navigate('login', true);
        }
      },
      headers: headers
    });
  }

  // ...
}

当应用程序启动时,它应该检查是否有活跃的会话打开;如果有,则应该使用该会话,如下所示:

// app.js
var App = {
start() {
    // The common place where sub-applications will be showed
    App.mainRegion = new Region({el: '#main'});

    this.initializePlugins();

    // Load authentication data
    this.initializeAuth();

    // Create a global router to enable sub-applications
    // to redirect to
    // other URLs
    App.router = new DefaultRouter();
    Backbone.history.start();
  },

  // ...

  // Load authorization data from sessionStorage
  initializeAuth() {
    var authConfig = sessionStorage.getItem('auth');

    if (!authConfig) {
      return window.location.replace('/#login');
    }

    var splittedAuth = authConfig.split(':');
    var type = splittedAuth[0];
    var token = splittedAuth[1];

    this.setAuth(type, token);
  },

  // ...
}

用户应该能够注销。让我们在 App 路由器中添加一个用户注销的路由:

// app.js

// General routes non sub-application dependant
class DefaultRouter extends Backbone.Router {
  constructor(options) {
    super(options);
    this.routes = {
      '': 'defaultRoute',
      'logout': 'logout'
    };
    this._bindRoutes();
  }

  // Redirect to contacts app by default
  defaultRoute() {
    this.navigate('contacts', true);
  }

  // Drop session data
  logout() {
    App.dropAuth();
    this.navigate('login', true);
  }
}

当从 sessionStorage 中移除 auth 字符串并且不再发送认证头时,会话将被移除:

var App = {
  // ...

  // Remove authorization token
  dropAuth() {
    sessionStorage.removeItem('auth');
    this.setupAjax(null);
  },

  // …
}

这就是您可以使用 HTTP 基本认证协议实现授权的方法。为每个发送到服务器的请求生成并附加一个授权字符串,这是通过 jQuery 的 ajaxSetup() 方法完成的。在下一节中,我们将看到如何实现 OAuth2 协议。

实现 OAuth 认证

正如我们在基本认证中所做的那样,我们将构建 OAuth2 协议的服务端实现。由于 Backbone App 和 Server App 都是由我们构建的,因此最佳授权类型选择是 资源所有者密码凭证授权

与基本认证不同,OAuth2 需要添加一个用于颁发访问和刷新令牌的端点。如 RFC-6749 所述,对此端点发出的请求应包括以下内容:

客户端通过使用 "application/x-www-form-urlencoded" 添加以下参数向令牌端点发起请求:

grant_type:必需。值必须设置为 "password"。

用户名:必需。资源所有者的用户名。

密码:必需。资源所有者的密码。

一个有效的请求将如下所示:

POST /api/oauth/token HTTP/1.1
Host: example.com
Content-Type: application/x-www-form-urlencoded

grant_type=password&username=john&password=doe

然后,服务器将响应一个有效的访问令牌、可选的刷新令牌和令牌类型;它可能包含其他值,如下所示:

HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache

{
    "access_token":"2YotnFZFEjr1zCsicMWpAA",
    "token_type":"example",
    "expires_in":3600,
    "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
    "example_parameter":"example_value"
}

token_type 值告诉客户端所颁发令牌的类型,在我们的情况下,它是 Bearer。我们可以通过创建必要的函数来开始实现颁发授权令牌:

function authorize(data, callback) {
  var grantType = data.grant_type;
  var username = data.username;
  var password = data.password;

  if (grantType !== 'password') {
    return callback({error: 'invalid_grant'});
  }

  if (!username || !password) {
    return callback({error: 'invalid_request'});
  }

  if (username === 'john' && password === 'doe') {
    issueAuthorization(username, callback);
  } else {
    callback({error: 'invalid_grant'});
  }
}

如 RFC 文档中指定,如果未支持授权类型,则我们应该响应一个 invalid_grant 错误;如果请求中缺少参数,则我们应该响应一个 invalid_request 错误。

如果用户名和密码匹配,则我们可以颁发一个授权令牌:

const DEFAULT_EXPIRATION_TIME = 3600; // seconds (1 hour)

// ...

function issueAuthorization(username, callback) {
  var accessToken = generateToken();
  var refreshToken = generateToken();
  var token = {
    access_token: accessToken,
    token_type: 'Bearer',
    expires_in: DEFAULT_EXPIRATION_TIME,
    refresh_token: refreshToken
  };

  saveValidToken(token, username);
  callback(token);
}

生成的令牌只是使用 generateToken() 函数生成的随机字符串,如下所示:

const TOKEN_LENGTH = 20;

// ...

function generateToken() {
return crispy.base32String(TOKEN_LENGTH);
}

这些令牌应该存储在某个地方,以便在未来的请求中进行验证。为了简单起见,在这本书中,我们将令牌存储在内存对象中;然而,您可以使用如 Redis 这样的数据库进行实际项目:

var validTokens = {};
var refreshTokens = {};

// ...

function saveValidToken(token, username) {
  var tokenCopy = _.clone(token);
  tokenCopy.username = username;

  validTokens[token.access_token] = tokenCopy;
  refreshTokens[token.refresh_token] = tokenCopy;

  setTimeout(function() {
    expireToken(tokenCopy.access_token);
  }, DEFAULT_EXPIRATION_TIME * 1000);
}

function expireToken(token) {
  delete validTokens[token];
}

validTokensrefreshTokens 是存储令牌的哈希表。在 TTL生存时间)到期后,validTokens 中的令牌应该被移除,setTimeout() 调用将确保这些项自动移除。

要验证用户是否已认证,我们只需检查令牌是否在 validTokens 哈希表中有效,如下所示:

function authenticate(token, callback) {
  if (_.has(validTokens, token)) {
    callback({valid: true, token: validTokens[token]});
  } else {
    callback({valid: false, token: null});
  }
}

使用本节中描述的函数,我们可以在我们的 Contacts App 项目中实现 OAuth2。让我们添加一个路由来生成访问令牌,并添加一个中间件来保护资源,如下所示:

var controller = require('./controller');
var auth = require('./oauth2Middleware);

module.exports = routes = function(server) {
  server.post('/api/oauth/token', auth.authenticate);
  server.post('/api/contacts', auth.requireAuthorization,
    controller.createContact);
  server.get('/api/contacts', auth.requireAuthorization,
    controller.showContacts);
  server.get('/api/contacts/:contactId',
    auth.requireAuthorization, controller.findContactById);
  server.put('/api/contacts/:contactId',
    auth.requireAuthorization, controller.updateContact);
  server.delete('/api/contacts/:contactId',
    auth.requireAuthorization, controller.deleteContact);
  server.post('/api/contacts/:contactId/avatar',
    auth.requireAuthorization, controller.uploadAvatar);
};

oauth2Middleware 模块提供了 requireAuthorization() 中间件和 authenticate() 认证处理器,如下所述:

module.exports = {
  authenticate(req, res) {
    authorize(req.body || {}, _.bind(res.json, res));
  }
}

为了颁发新的令牌,你需要调用 authorize() 函数,该函数返回一个符合 RFC 文档中指定的有效 OAuth2 响应:

requireAuthorization(req, res, next) {
  var authorization = req.headers.authorization || '';

  if (!authorization) {
    return res.sendStatus(401);
  }

  var splitValues = authorization.split(' ');
  var tokenType = splitValues[0];
  var token = splitValues[1];

  if (!tokenType || tokenType !== 'Bearer' || !token) {
    return res.sendStatus(401);
  }

  authenticate(token, function(response) {
    if (response.valid) {
      next();
    } else {
      return res.sendStatus(401);
    }
  });
}

requireAuthorization() 中间件用于保护我们 OAuth2 协议实现中的资源。该中间件将令牌分为两部分:令牌类型和令牌本身;它验证令牌类型及其在活动访问令牌列表中的存在是否有效。

在 Backbone 应用程序中,我们可以重用为基本认证协议创建的对象;然而,我们需要进行一些小的修改。在 LoginView 对象中,你应该将 url 请求更改为 /api/oauth/token 并将方法更改为 POST,如下所示:

class LoginView extends Common.ModelView {
  // ...

  makeLogin(event) {
    event.preventDefault();

    var username = this.$el.find('#username').val();
    var password = this.$el.find('#password').val();

    Backbone.$.ajax({
      method: 'POST',
      url: '/api/oauth/token',
      data: {
        grant_type: 'password',
        username: username,
        password: password
      },
      success: response => {
        var App = require('../../../app');
        var accessToken = response.access_token;
        var tokenType = response.token_type;

        App.saveAuth(tokenType, accessToken);
        App.router.navigate('contacts', true);
      },
      error: jqxhr => {
        if (jqxhr.status === 401) {
          this.showError('User/Password are not valid');
        } else {
          this.showError('Oops... Unknown error happens');
        }
      }
    });
  }

  buildAuthenticationString(token) {
    return 'Bearer ' + token;
  }

  showError(message) {
    this.$('#message').html(message);
  }
}

摘要

如果你没有清晰的关于 REST 服务器中认证工作原理的视野,Backbone 应用程序中的认证可能会变得复杂。由于 Backbone 对认证是中立的,它不会强迫你使用某种认证机制。作为开发者,创建一个或遵循现有的认证机制是你的责任。

在无状态服务器的支撑下,Backbone 应用程序中,你应该将会话处理代码移动到浏览器中。在本章展示的示例中,我们使用了 sessionStorage 来存储访问令牌;然而,你也可以使用其他存储解决方案,例如 localStorageindexeddb,甚至 cookies。

然后,我们看到了如何在 Contacts 应用程序中将基本认证和 OAuth2 协议的理论与实践实现相结合。该实现对于应用程序的其他部分是透明的,因此,你可以轻松地在实现之间切换。

posted @ 2025-10-26 08:52  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报