Backbone-js-蓝图-全-

Backbone,js 蓝图(全)

原文:zh.annas-archive.org/md5/130af1b9919578afdd01f95dce79856a

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

曾经有一段时间,如果你想要构建一个网络应用程序,你几乎完全得靠自己。你必须从头开始,自己弄清楚所有的事情。然而,现在有很多库和框架你可以使用来构建功能丰富的网络应用程序;Backbone 只是这些库之一。

Backbone 是一个客户端库,它提供了用于管理数据记录的模型、用于处理模型集合的集合以及用于显示数据的视图。严格来说,它不是一个 MVC 库,而是一个相对松散的工具箱,你可以以多种不同的方式使用它。

这本书不是 Backbone 的入门指南。我们不会像阅读友好的文档版本一样,逐个介绍库中的每个功能。相反,我们将从从头到尾构建整个网络应用程序的上下文中查看 Backbone。这意味着 Backbone 的一些功能——一些不为人知的函数或属性——我们根本不会使用。如果你想了解 Backbone 的每一个角落和缝隙,你应该阅读backbonejs.org/上的文档。

这本书有两个目标。第一个是教会你如何使用 Backbone 构建完整的网络应用程序。一个完整的网络应用程序将不仅仅是在浏览器中运行的 JavaScript。还需要考虑 HTML 和 CSS,当然,每个应用程序都需要一个服务器。在这本书的每一章中,我们将从头开始构建不同的应用程序,我们需要创建所有这些组件。当然,这些将是客户端负载较重的应用程序,因此我们将编写的代码中的大部分将使用 Backbone。然而,因为我们正在构建完整的应用程序,你将看到我们的 Backbone 代码将如何与其他代码协同工作。

这本书的另一个目标是教会你如何理解 Backbone 的思考方式。在我们构建应用程序的过程中,我们会尽可能使用多种不同的技术。虽然 Backbone 有许多约定,但其中大多数实际上并不是你的代码正常工作所必需的。通过学习所需的内容,你可以满足这些要求,并根据你的需要编写其余的代码,无论是否遵循约定。

本书涵盖的内容

第一章,构建一个简单的博客,介绍了 Backbone 的每个主要组件以及它们是如何协同工作的。如果你之前没有使用过 Backbone,这将是一个重要的基础;如果你已经使用过,这将是你对每个 Backbone 组件目的的复习。

第二章,构建一个照片分享应用程序,展示了如何构建一个类似于 Instagram 的照片分享网站。在其他方面,你将学习如何自定义 Backbone 模型发送到和从服务器接收的方式。这是因为我们将使用 Backbone 模型来上传文件。

第三章, 构建实时数据仪表板,通过构建一个不断轮询服务器以获取数据集更改的应用程序,将事物提升到下一个层次,从而创建了一个实时应用程序。我们还将探讨更好的代码组织。

第四章, 构建日历,将继续构建具有良好组织代码的应用程序的主题。我们还将学习如何正确分配应用程序功能。

第五章, 构建聊天应用程序,通过使用 Socket.IO 来控制客户端和服务器之间数据传输的方向,走向了不同的道路。此外,我们还将使用 Marionette 框架使我们的工作变得更简单。

第六章, 构建播客应用程序,展示了并非每个 Backbone 应用程序都是客户端代码,一些应用程序将包含大量的服务器代码。我们还将探讨构建一些与 Backbone 一起工作的自定义基础设施。

第七章, 构建游戏,通过一个有趣的项目结束了本书。我们将回顾 Backbone 的所有主要组件,以及构建非 Backbone 页面以创建更完整的 Web 应用程序。当然,我们还需要编写游戏逻辑。

你需要这本书的什么

由于这本书主要关于客户端代码,所以主要工具是文本编辑器和浏览器。然而,你还需要一些其他工具。你必须安装 Node.js(nodejs.org),它包含 npm,Node 包管理器。如果你使用的是 Mac,那么这就足够了。但是,如果你使用的是 Windows,你还需要 Python 2(最好是 2.7.3)和 Express 2013 for Windows Desktop;你需要这些来安装某些章节中使用的bcrypt Node.js 包。

这本书是为谁写的

这本书是为任何想要熟练学习 Backbone 库的人编写的;通过构建七个非常不同的应用程序,你将快速学会 Backbone 的所有细节。希望你也能提高你的编码技能,无论是客户端还是服务器编码。

当然,在我们开始之前,你需要了解一些事情。你应该对 JavaScript 有相当的工作知识。更细致的语言特性将在文本中解释,但你应该大部分时间都能应对自如。此外,我们编写的所有服务器代码都将使用 Node.js,因此你需要熟悉它。如果你理解 Node.js 代码通常是异步的,这就是为什么它使用回调的原因,那么你没问题。你需要熟悉 HTML 和 CSS;虽然它们不会特别突出,但它们仍将扮演一定的角色。

你可能会想知道你是否需要熟悉 Backbone 才能从这本书中受益。如果你理解 Backbone 的基础知识以及其主要组件的一般用途,你可能会感到更加自在。然而,如果你还没有使用过它,请不要担心。第一章节将通过在一个简单的应用程序中使用 Backbone 的各个部分来向你介绍 Backbone 的所有内容。

术语

在这本书中,你会找到许多不同风格的文本,以区分不同类型的信息。以下是一些这些风格的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 处理方式如下所示:“接下来,我们可以在public目录中创建一个名为app.js的文件。”

代码块如下设置:

var Posts = Backbone.Collection.extend({
  model: Post,
  url: "/posts"
});

当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将以粗体显示:

var User = Backbone.Model.extend({
  url: function () {
    return '/user-' + this.get('id') + '.json';
  }
});

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

npm install passport --save

新术语重要词汇以粗体显示。你在屏幕上看到的单词,例如在菜单或对话框中,文本中会这样显示:“输入一个名字并点击加入,名字将出现在列表上方。”

注意

警告或重要注意事项如下所示。

小贴士

小技巧和技巧如下所示。

读者反馈

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

要发送给我们一般反馈,只需发送一封电子邮件到<feedback@packtpub.com>,并在邮件主题中提及书名。

如果你在某个领域有专业知识,并且对撰写或参与书籍感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

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

下载示例代码

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

错误

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

盗版

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

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

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

询问

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

第一章. 构建一个简单的博客

我们将假设您在 Backbone 方面的经验非常有限;实际上,即使您以前从未使用过 Backbone,您也应该能够很好地跟随。在本章中,我们将构建一个非常简单的博客应用程序。作为一个博客,它将具有非常少的功能;将会有供观众阅读和评论的帖子。然而,它将向您介绍 Backbone 库中的每个主要功能,让您熟悉词汇,并了解这些功能通常是如何一起工作的。

到本章结束时,您将知道如何:

  • 使用 Backbone 的模型、集合和视图组件

  • 创建一个 Backbone 路由器来控制用户在屏幕上看到的所有内容

  • 使用 Node.js(以及 Express.js)编程服务器端以创建我们 Backbone 应用的后端

那么让我们开始吧!

设置应用程序

每个应用程序都需要设置,因此我们将从这里开始。为您的项目创建一个文件夹——我将称之为 simpleBlog——然后在其中创建一个名为 package.json 的文件。如果您之前使用过 Node.js,您知道 package.json 文件描述了项目;列出项目主页、仓库和其他链接;并且(对我们来说最重要的是)概述了应用程序的依赖项。

下面是 package.json 文件的样子:

{
  "name": "simple-blog",
  "description": "This is a simple blog.",
  "version": "0.1.0",
  "scripts": {
    "start": "nodemon server.js"
  },
  "dependencies": {
    "express": "3.x.x",
    "ejs"    : "~0.8.4",
    "bourne" : "0.3"
  },
  "devDependencies": {
    "nodemon": "latest"
  }
}

小贴士

下载示例代码

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

这是一个相当基础的 package.json 文件,但它包含了所有重要的部分。namedescriptionversion 属性应该是自解释的。dependencies 对象列出了此项目运行所需的所有 npm 包:键是包的名称,值是版本。由于我们正在构建 ExpressJS 后端,我们需要 express 包。ejs 包用于我们的服务器端模板,而 bourne 是我们的数据库(关于这一点稍后会有更多介绍)。

devDependencies 属性与 dependencies 属性类似,但不同的是,这些包仅对在项目上工作的人是必需的。它们不是使用项目所必需的。例如,构建工具及其组件,如 Grunt,将是开发依赖项。我们想要使用一个名为 nodemon 的包。这个包在构建 Node.js 后端时非常方便:我们可以在编辑器中编辑 server.js 的同时,在后台运行 nodemon server.js 命令。nodemon 包会在我们保存文件更改时重启服务器。唯一的问题是,我们实际上不能在命令行上运行 nodemon server.js 命令,因为我们打算将 nodemon 作为本地包安装,而不是全局进程。这就是我们的 package.json 文件中的 scripts 属性发挥作用的地方:我们可以编写简单的脚本,几乎就像命令行别名一样,为我们启动 nodemon。正如你所看到的,我们创建了一个名为 start 的脚本,它运行 nodemon server.js。在命令行中,我们可以运行 npm start;npm 知道在哪里找到 nodemon 二进制文件,并为我们启动它。

因此,现在我们有了 package.json 文件,我们可以安装我们刚刚列出的依赖项。在命令行中,切换到当前目录到项目目录,并运行以下命令:

npm install

你会看到所有必要的包都将被安装。现在我们准备好开始编写代码了。

从服务器开始

我知道你可能迫不及待地想要开始实际的 Backbone 代码,但对我们来说,从服务器代码开始更合理。记住,好的 Backbone 应用程序将拥有强大的服务器端组件,所以我们不能完全忽略后端。

我们将首先在我们的项目目录中创建一个 server.js 文件。以下是它的开始部分:

var express = require('express');
var path    = require('path');
var Bourne  = require("bourne");

如果你使用过 Node.js,你就知道可以使用 require 函数来加载 Node.js 组件(path)或 npm 包(expressbourne)。现在我们已经在我们的应用程序中有了这些包,我们可以开始如下使用它们:

var app      = express();
var posts    = new Bourne("simpleBlogPosts.json");
var comments = new Bourne("simpleBlogComments.json");

这里的第一个变量是 app。这是我们基本的 Express 应用程序对象,当我们调用 express 函数时就会得到它。我们将在文件中大量使用它。

接下来,我们将创建两个Bourne对象。正如我之前所说的,Bourne是我们将在本书的项目中使用的数据库。这是一个专门为本书编写的简单数据库。为了尽可能简化服务器端,我想使用文档数据库系统,但我想使用无服务器(例如,SQLite)的,这样您就不必同时运行应用程序服务器和数据库服务器。我想出的,Bourne,是一个小包,它从 JSON 文件中读取并写入;该 JSON 文件的路径是我们传递给构造函数的参数。它绝对不适合比小型学习项目更大的任何东西,但应该非常适合这本书。在现实世界中,您可以使用优秀的文档数据库之一。我推荐 MongoDB:它很容易入门,并且有一个非常自然的 API。Bourne 不是 MongoDB 的直接替代品,但它非常相似。您可以在Bourne 的简单文档中查看。

所以,正如您在这里可以看到的,我们需要两个数据库:一个用于我们的博客文章,另一个用于评论(与大多数数据库不同,Bourne 每个数据库只有一个表或集合,因此需要两个)。

下一步是为我们的应用编写一些配置:

app.configure(function(){
  app.use(express.json());
  app.use(express.static(path.join(__dirname, 'public')));
});

这是一个非常简单的 Express 应用配置,但对我们这里的用途来说已经足够了。我们在应用中添加了两层中间件;它们是“小程序”,HTTP 请求在到达我们自定义函数(我们尚未编写)之前将运行通过。我们在这里添加了两层:第一层是express.json(),它解析 Backbone 将发送到服务器的 JSON 请求体;第二层是express.static(),它将从作为参数给出的路径静态服务文件。这允许我们从public文件夹中提供客户端 JavaScript 文件、CSS 文件和图像。

您会注意到这两个中间件组件都被传递给app.use(),这是我们用来选择使用这些组件的方法。

小贴士

您会注意到我们正在使用path.join()方法来创建公共资产文件夹的路径,而不是简单地使用__dirname'public'。这是因为 Microsoft Windows 需要分隔符为反斜杠。path.join()方法将正确处理代码运行的任何操作系统。哦,还有__dirname(开头有两个下划线)只是一个变量,表示此脚本所在的目录路径。

下一步是创建一个路由方法:

app.get('/*', function (req, res) {
  res.render("index.ejs");
});

在 Express 中,我们可以创建一个路由,该路由调用与所需 HTTP 动词(get、post、put 和 delete)相对应的方法。这里,我们调用 app.get() 并向其传递两个参数。第一个是路由;它是域名之后的部分 URL。在我们的例子中,我们使用了一个星号,这是一个通配符;它将匹配以正斜杠开始的任何路由(即所有路由)。这将匹配对应用程序发出的每个 GET 请求。如果 HTTP 请求与路由匹配,那么第二个参数,即一个函数,将被调用。

这个函数接受两个参数;第一个是来自客户端的请求对象,第二个是我们将用来发送响应的响应对象。这些通常被简称为 reqres,但这只是一种约定,你可以随意命名它们。

因此,我们将使用 res.render 方法,该方法将在服务器端渲染模板。现在,我们传递了一个单一参数:模板文件的路径。实际上,这仅仅是路径的一部分,因为 Express 默认假设模板保存在名为 views 的目录中,这是我们将会使用的约定。Express 可以根据文件扩展名猜测要使用的模板包;这就是为什么我们不需要在任何地方选择 EJS 作为模板引擎。如果我们有想要插入到模板中的值,我们将作为第二个参数传递一个 JavaScript 对象。我们稍后会回来做这件事。

最后,我们可以启动我们的应用程序;我将选择使用端口 3000

app.listen(3000);

我们稍后将在 server.js 文件中添加更多内容,但这是我们开始的起点。实际上,在这个阶段,你可以在命令行中运行 npm start 并在浏览器中打开 http://localhost:3000。你会得到一个错误,因为我们还没有创建视图模板文件,但你可以看到我们的服务器正在运行。

创建模板

所有网络应用程序都将有一些类型的模板。大多数 Backbone 应用程序将在前端模板上投入大量精力。然而,我们需要一个单独的服务器端模板,所以让我们构建它。

虽然你可以选择不同的模板引擎,但许多人(以及随后的教程)使用 Jade (jade-lang.com/),这是 Node.js 版本的 Ruby 模板引擎 Haml (haml.info/)。然而,正如你已经知道的,我们正在使用 EJS (github.com/visionmedia/ejs),它与 Ruby 的 ERB 类似。基本上,我们是在 <%= %> 标签内写入带有模板变量的常规 HTML。

如我们之前所见,Express 将在 views 文件夹中寻找一个 index.ejs 文件,所以让我们创建这个文件,并在其中放入以下代码:

<!DOCTYPE html>
<html>
  <head>
    <title> Simple Blog </title>
  </head>
  <body>
    <div id="main"></div>
      <script src="img/jquery.js"></script>
      <script src="img/underscore.js"></script>
      <script src="img/backbone.js"></script>
      <script src="img/app.js"></script>
  </body>
</html>

到目前为止,如果你还在运行服务器(记得命令行中的npm start),你应该能够加载http://localhost:3000而不会出现错误。页面将会是空的,但你应该能够查看源代码并看到我们刚刚编写的 HTML 代码。这是一个好兆头;这意味着我们成功地将东西从服务器发送到了客户端。

添加公共文件夹

由于 Backbone 是一个前端库,我们需要向客户端提供它。我们已经设置了 Express 应用来静态地提供public目录中的文件,并在index.ejs文件中添加了几个脚本标签,但我们还没有创建这些内容。

因此,在你的项目目录中创建一个名为public的目录。现在下载 Underscore 的最新版本(underscorejs.org)、Backbone(backbonejs.org)和 jQuery(jquery.com),并将它们放入这个文件夹。这本书写完之后,这些库的新版本很可能已经发布。由于这些项目的更新可能会改变它们的工作方式,最好坚持以下版本:

  • Backbone: 版本 1.1.2

  • Underscore: 版本 1.6.0

  • jQuery: 版本 2.0.3

我在这里要提到的是,我们包括了 Underscore 和 jQuery,因为 Backbone 依赖于它们。实际上,它主要依赖于 Underscore,但包括 jQuery 确实给了我们一些额外的特性,我们会很高兴拥有。如果你需要支持旧版本的 Internet Explorer,你还将需要包括json2.js库(github.com/douglascrockford/JSON-js),并切换到 jQuery 1 版本(jQuery 2 不支持旧版本的 IE)。

注意

到目前为止,我们将要在本书中构建的每个应用都将使用相同的设置。在本书的下载文件中,你可以通过复制模板文件夹并从那里开始来开始每一章。

开始编写 Backbone 代码

一旦你在public文件夹中有这三个文件,你就可以开始创建app.js文件了。在我们大多数的 Backbone 应用中,大部分的工作将会在这里完成。现在其他所有东西都已经就绪,我们可以开始编写应用特定的代码。

创建模型和集合

在构建 Backbone 应用时,我最先喜欢思考的是这个:我将处理哪些数据?这是我的第一个问题,因为 Backbone 是一个非常数据驱动的库:用户将看到和工作的几乎所有内容都会以某种方式与数据相关。这在我们在创建的简单博客中尤其如此;每个视图要么是用于查看数据(如帖子),要么是用于创建数据(如评论)。你的应用程序将处理的数据的各个部分(如标题、日期和文本)通常会被组合成所谓的模型:我们博客中的帖子、日历应用中的事件或地址簿中的联系人。你明白了。

首先,我们的博客将有一个单一模型:帖子。因此,我们创建了适当的 Backbone 模型和集合类。我们的模型代码片段如下:

var Post = Backbone.Model.extend({});
var Posts = Backbone.Collection.extend({
  model: Post,
  url: "/posts"
});

这五行代码实际上有很多内容。首先,所有主要的 Backbone 组件都是全局变量Backbone的属性。这些组件中的每一个都是一个类。JavaScript 实际上并没有真正的类;基于原型的函数在 JavaScript 中充当类的角色。它们还有一个extend方法,这允许我们创建子类。我们向这个extend方法传递一个对象,该对象内部的所有属性或方法都将成为我们正在创建的新类的一部分,以及我们正在扩展的类的属性和方法。

小贴士

我想在本书的早期就提到,你会在 Backbone 应用之间看到很多类似的代码,这仅仅是惯例。这也是我喜欢 Backbone 的原因之一;有一套强大的惯例可以使用,但你完全可以轻松地跳出这个框。在整个书中,我将尽我所能向你展示不仅常见的惯例,还有如何打破它们。

在这段代码中,我们创建了一个模型类和一个集合类。目前我们实际上根本不需要扩展模型类;一个基本的 Backbone 模型就足够了。然而,对于集合类,我们将添加两个属性。首先,我们需要将这个集合与适当的模型关联起来。我们这样做是因为集合实例基本上就是一个模型实例的华丽数组。第二个属性是url:这是集合在服务器上的位置。这意味着如果我们向/posts发起 GET 请求,我们将得到数据库中所有帖子的 JSON 数组。这也意味着我们可以向/posts发送 POST 请求并将新帖子存储到我们的数据库中。

在这个阶段,现在我们已经在前端有了数据处理类,我想回到server.js文件中创建我们集合所需的路由。所以,在文件中,添加以下代码片段:

app.get("/posts", function (req, res) {
  posts.find(function (results) {
    res.json(results);
  });
});

首先,我要提到,这个app.get调用必须在我们之前的/*路由之上。这是因为 Express 按顺序通过我们的路由发送请求,并在找到匹配项时停止(默认情况下是这样的)。由于/posts将匹配/posts/*,我们需要确保它首先命中/posts路由。

接下来,你会想起我们之前创建的posts数据库实例。在这里,我们只使用回调调用它的find方法,这将传递一个包含数据库中所有记录的数组给回调。然后,我们可以使用响应对象的json方法将这个数组作为 JSON 发送回去(Content-Type头将是application/json)。就这样!

当我们在server.js文件中时,我们为相同的路由添加了 POST 方法:这是浏览器中的帖子数据将进入并保存到我们的数据库的地方。以下是为post()方法提供的代码片段:

app.post("/posts", function (req, res) {
  posts.insert(req.body, function (result) {
    res.json(result);
  });
});

req对象有一个body属性,它代表我们的帖子数据。我们可以直接将其插入到posts数据库中。当 Backbone 以这种方式将模型保存到服务器时,它期望响应是它发送的模型,并添加了一个 ID。我们的数据库会为我们添加 ID,并将更新后的模型传递给回调,所以我们只需要将其作为响应发送给浏览器,就像我们在使用res.json发送所有帖子时的上一个方法一样。

当然,没有表单来向数据库添加帖子这并不是很有用,对吧?我们很快就会构建一个表单来创建新的帖子,但现在我们可以手动将一个帖子添加到simpleBlogPosts.json文件中;这个文件可能还不存在,因为我们还没有写入任何数据,所以你必须创建它。只需确保你创建的文件有正确的名称,即与我们在server.js文件中传递给Bourne构造函数的参数相同的名称。我将在该文件中放入以下代码:

[
  {
    "id": 1,
    "pubDate": "2013-10-20T19:42:46.755Z",
    "title": "Lorem Ipsum",
    "content": "<p>Dolor sit amet . . .</p>"
  }
]

当然,你可以使content字段更长;你明白了这个意思。这是将被发送到我们的Posts集合实例的 JSON 字段,并成为一组Post模型实例(在这种情况下,仅有一个实例)。

进行快速而简单的测试

到目前为止,我们已经编写了足够的代码来测试这些功能。在你的浏览器中转到http://localhost:3000,打开一个 JavaScript 控制台;我更喜欢 Chrome 和开发者工具,但你可以使用任何你想要的。现在尝试以下行:

var posts = new Posts();
posts.length // => 0

我们可以创建一个Posts集合实例;正如你所见,它默认是空的。我们可以通过运行以下行从服务器加载数据:

posts.fetch();

集合实例的fetch方法将向服务器发送一个 GET 请求(实际上,如果你的浏览器工具允许你查看网络请求,你会看到一个到/posts的 GET 请求)。它将合并从服务器接收到的模型与集合中已有的模型。给一点时间来获取响应,然后运行以下行:

posts.length // => 1
var post = posts.get(1);
post.get("title"); // Lorem Ipsum

每个集合实例都有一个 get 方法;我们传递一个 ID,它将返回具有该 ID 的模型实例(注意,这是数据库中的 id 字段,而不是集合中的索引号)。然后,每个模型实例都有一个 get 方法,我们可以用它来获取属性。

编写一些视图

在我们本章创建的简单应用中,我们编写的 Backbone 代码的大部分将位于视图中。我认为可以说视图是 Backbone 应用中最具挑战性的部分,因为几乎可以做任何事情都有很多种方式。

重要的是要理解一个 Backbone.View 实例和满屏的 Web 应用并不是同一回事。浏览器中的一个视图实际上可能是许多 Backbone 视图。我们想要创建的第一个视图是所有帖子的列表;这些将是链接到单个帖子页面的链接。我们可以用两种方式来做这件事:作为一个大视图或者作为多个较小视图的组合。在这个例子中,我们将使用多个视图。下面是如何分解的:每个列表项将由其自己的视图实例生成。然后,列表项周围的包装器将是另一个视图。你可以想象它看起来像这样:

编写一些视图

帖子列表视图类

让我们从子视图开始。我们将这个类命名为 PostListView。视图的命名可能有点棘手。通常,我们会为集合和模型各有一个视图,我们只需在它们的名称末尾添加 View,例如,PostViewPostsView。然而,一个模型或集合将会有多个视图。我们即将编写的视图是用来列出我们的模型。这就是为什么我们称之为 PostListView

var PostListView = Backbone.View.extend({
  tagName: "li",
  template: _.template("<a href='/posts/{{id}}'>{{title}}</a>"),
  render: function () {
    this.el.innerHTML = this.template(this.model.toJSON());
    return this;
  }
});

就像 Backbone.ModelBackbone.Collection 一样,我们通过扩展 Backbone.View 来创建一个视图类。在扩展对象中有三个属性组成了我们的 PostListView。首先看看 template 属性;这个属性持有视图将要渲染的模板。创建模板有很多种方法;在这种情况下,我们使用 Underscore 的 template 函数;我们向 _.template 传递一个字符串,它返回一个函数,我们可以用它来生成正确的 HTML。看看这个模板字符串:它是带有双大括号内变量的常规 HTML。

接下来,让我们看看 render 方法。按照惯例,这是我们调用以实际渲染视图的方法。每个视图实例都有一个名为 el 的属性。这是视图实例的基本元素:这个视图的所有其他元素都放在它里面。默认情况下,这是一个 div 元素,但我们已经将 tagName 属性设置为 li,这意味着我们将得到一个列表项。顺便说一句,还有一个 $el 属性,它是一个包装 el 属性的 jQuery 对象;这只有在我们的应用程序中包含 jQuery 时才有效。

因此,在我们的 render 函数内部,我们需要填充这个元素。在这种情况下,我们将通过分配 innerHTML 属性来完成这个操作。为了获取 HTML 输出,我们使用我们刚刚编写的模板。这是一个函数,所以我们需要调用它,并传递 this.model.toJSON()this.model 部分来自我们实例化这个视图的时候:我们将传递一个模型。每个模型都有一个 toJSON 方法,它返回一个只包含模型属性的原始对象。由于我们的模型将具有 idtitle 属性,将这些传递给我们的模板函数将返回一个字符串,其中包含我们在模板字符串中写入的值。

我们通过返回视图实例来结束 render 函数。再次强调,这只是一个约定。正因为如此,我们可以使用这样的约定:通过 view.render().el 获取这个视图的元素;这将渲染视图并获取 el 属性。当然,没有理由我们不能直接从 render 中返回 this.el

这里还有一件事需要解决,但它与 Underscore 和 Backbone 有关。如果你之前使用过 Underscore 的 template 函数,你知道花括号不是它的正常定界符。我已经从默认的 <%= %> 定界符切换过来,因为那些是我们服务器端模板引擎的定界符。要更改 Underscore 的定界符,只需将以下代码片段添加到我们的 app.js 文件顶部:

_.templateSettings = {
  interpolate: /\{\{(.+?)\}\}/g
};

当然,你意识到我们可以将定界符设置为任何我们想要的,只要正则表达式可以匹配它。我喜欢花括号。

PostsListView

现在我们有了列表项的视图,我们需要包裹这些列表项的父视图:

var PostsListView = Backbone.View.extend({
  template: _.template("<h1>My Blog</h1><ul></ul>"),
  render: function () {
    this.el.innerHTML = this.template();
    var ul = this.$el.find("ul");
    this.collection.forEach(function (post) {
      ul.append(new PostListView({ 
        model: post 
      }).render().el);
    });
    return this;
  }
});

就视图而言,这很简单,但我们可以从中学习一些新东西。首先,你会注意到我们的模板实际上没有使用任何变量,所以我们实际上没有必要使用模板。我们可以直接将那个 HTML 字符串赋值给 this.el.innerHTML;然而,我喜欢做这个小模板舞步,因为将来我可能需要将模板字符串改为包含一些变量。

注意 render 函数的第二行:我们正在查找一个 ul 元素;就是我们刚刚作为根元素 this.el 的子元素创建的那个 ul 元素。然而,我们不是使用 this.el,而是使用 this.$el

接下来,我们正在遍历我们将与这个视图关联的集合中的每个项目(当我们实例化它时)。对于集合中的每个帖子,我们将创建一个新的 PostListView 类。我们传递一个 options 对象,将视图的模型设置为当前帖子。然后,我们渲染视图并返回视图的元素。然后,我们将这个元素附加到我们的 ul 对象上。

我们将以返回视图对象结束。

使用我们的视图

我们几乎准备好在浏览器中实际显示一些内容了。我们的第一步是回到 server.js 文件。我们需要将数据库中的帖子数组发送到我们的 index.ejs 模板。我们通过以下代码片段来完成这个操作:

app.get('/*', function (req, res) {
  posts.find(function (err, results) {
    res.render("index.ejs", { posts: JSON.stringify(results) });
  });
});

正如我们在/posts路由中所做的那样,我们调用posts.find。一旦我们得到结果,我们就像之前一样渲染视图。但这次,我们传递一个包含我们想要在模板内部使用的值的对象。在这种情况下,那就是帖子。我们必须通过JSON.stringify运行结果,因为我们不能向浏览器提供实际的 JavaScript 对象;我们需要对象的字符串表示形式(JSON 形式)。

现在,在views文件夹的index.ejs文件中,我们可以使用这些帖子。在之前创建的其他脚本标签下创建一个新的脚本标签。这次,它将是一个内联脚本:

<script>
  var posts = new Posts(<%- posts %>);
  $("#main").append(new PostsListView({ 
    collection: posts 
  }).render().el);
</script>

第一行创建我们的帖子集合;请注意我们使用模板标签的方式。这就是如何将我们的posts数组插入到模板中。顺便说一句,那里没有打字错误;你可能期望有一个<%=的打开标签,但那个打开标签会转义字符串中的任何可能的字符,这会破坏我们 JSON 代码中的引号。所以我们使用<%-,它不会转义字符。

下一行应该是相当直接的。我们使用 jQuery 找到我们的主元素,并附加一个新PostsListView实例的元素。在options对象中,我们将为此视图设置集合。然后我们渲染它并找到要附加的元素。

现在,确保你的服务器正在运行,然后在浏览器中转到http://localhost:3000。你应该会看到以下截图:

使用我们的视图

你正在使用 Backbone 的三个主要组件——集合、模型和视图——来创建一个迷你应用程序!那太棒了,但我们才刚刚开始。

创建一个路由器

勇敢地点击我们刚刚渲染的链接。你会发现 URL 会改变,页面也会刷新,但内容仍然是相同的。这是因为我们在应用程序的工作方式上做出了一个选择,即我们创建了一个通配符路由,它匹配我们服务器的每个 GET 请求。这意味着//posts/1/not/a/meaningful/link会显示相同的内容。这通常被称为单页Web 应用程序,也就是说,尽可能多的操作是在客户端完成的,JavaScript 负责繁重的工作,而服务器上不使用不同的语言。使用这种应用程序,整个应用程序可以仅使用一个永远不会改变的 URL 来运行。然而,这使得很难保存应用程序的部分。因此,我们想要确保我们的应用程序使用良好的 URL。为此,我们需要创建一个如下所示的 Backbone 路由器:

var PostRouter = Backbone.Router.extend({
  initialize: function (options) {
    this.posts = options.posts;
    this.main  = options.main;
  },
  routes: {
    '': 'index',
    'posts/:id': 'singlePost'
  },
  index: function () {
    var pv = new PostsListView({ collection: this.posts }
    this.main.html(pv.render().el);
  },
  singlePost: function (id) {
    console.log("view post " + id);
  }
});

这是我们的PostRouter的第一个版本。当我们开始时,你应该看到一个熟悉的模式:我们扩展了组件Backbone.Router。下一个重要的部分是initialize方法。我们从未在我们的模型、集合或视图中添加这样的方法,但它们都可以有一个initialize方法。这是我们的路由器的构造函数。按照古老的 Backbone 约定,我们期望得到一个单一的options参数。我们期望这个对象有两个属性:postsmain。这些应该是帖子集合和div#main元素,分别。我们将这些分配为路由器实例的属性。

注意

从技术上讲,initialize函数不是构造函数。它是一个由构造函数调用的函数。要完全替换默认行为,写一个名为constructor的方法,而不是initialize

下一个重要的部分是routes对象。在这个对象中,键是路由,值是当使用这些路由时要调用的路由器方法。所以,相同的页面将从服务器加载,然后客户端路由器将查看确切请求了哪个 URL,并显示正确的内容。

第一条路由是一个空字符串;这是/路由(但最佳实践是在前面不包含斜杠,这样路由器就可以同时与 hash URL 和 pushState API 一起工作)。当我们加载这个路由时,我们将运行路由器的index函数。

这个函数做什么?它看起来很熟悉;它就像我们在index.ejs文件中作为快速测试放入的内容。它创建我们的PostsListView实例并将其放在页面上。注意,我们正在使用我们刚刚创建的this.poststhis.main属性。

我们在这里创建的另一个路由是/posts/:id,它运行singlePost函数。该路由的冒号标签部分将捕获斜杠后面的内容,并将其作为参数传递给路由方法。目前,我们在singlePost方法中做的只是向控制台记录一条消息,但还有更多内容要来。

现在我们已经编写了一个路由器,我们需要开始使用它。你知道index.ejs文件中的内联脚本吗?用以下代码替换其内容:

var postRouter = new PostRouter({
  posts: new Posts(<%- posts %>),
  main: $("#main")
});
Backbone.history.start({pushState: true});

再次,我们正在创建posts集合和主<div>元素的引用。然而,这一次,它们是路由器的属性。实际上,我们不需要对路由器实例做任何事情,只需创建它。但是,我们必须开始历史跟踪:这就是最后一行所做的事情。记住,我们正在使用单页应用,所以我们的 URL 不是服务器上的实际路由。这曾经是通过 URL 中的 hash 来完成的,但现在更好的、更现代的方法是使用pushState API,这是一个浏览器 API,允许你在不实际更改页面内容的情况下更改浏览器地址栏中的 URL。所以,这就是我们在options对象中所做的,我们将pushState设置为true

如果你浏览到http://localhost:3000/,你会看到我们的帖子列表。现在,点击帖子链接,嗯,页面仍然重新加载。然而,在新链接上,你看到没有页面内容,但控制台中有一条日志记录。所以,路由器正在工作,但它没有停止重新加载。当页面重新加载时,路由器看到新的路由并运行正确的方法。

现在的问题是,我们如何防止页面刷新,但仍然改变 URL?为了做到这一点,我们必须阻止我们点击的链接的默认行为。为了做到这一点,我们需要将以下几部分添加到我们的PostListView(在app.js文件中):

events: {
  'click a': 'handleClick'
},
handleClick: function (e) {
  e.preventDefault();
  postRouter.navigate($(e.currentTarget).attr("href"), 
    {trigger: true});
}

events属性在这里很重要,因为它处理在视图的基本元素中发生的任何 DOM 事件。此对象中的键应遵循eventName selector模式。当然,eventName可以是任何 DOM 事件。选择器应该是一个 jQuery 可以匹配的字符串。这个选择器的一部分美在于它只匹配此视图内的元素,所以你通常不需要让它非常具体。在我们的例子中,只需'a'就足够了。

每个events属性的值是在此事件发生时调用的方法的名称。下一步是将此方法作为此视图的另一个属性编写;它接收 jQuery 事件对象作为参数。在handleClick方法内部,我们调用e.preventDefault以阻止默认行为发生。由于这是一个锚点元素,默认行为是切换到链接到的页面。相反,我们在我们的 Backbone 应用程序内部执行此导航:这就是下一行。

我们在这里做的事情并不是一个完全好的主意,但暂时可以工作。我们正在引用postRouter变量,这个变量不是在这个文件中创建的;实际上,它是在这个文件在客户端加载后创建的。我们可以这样做到这一点,因为此函数只有在postRouter变量创建之后才会被调用。然而,在一个更严肃的应用程序中,我们可能希望有更好的代码解耦。然而,对于我们的技术水平来说,这是可以接受的。

我们正在调用路由器的navigate方法。第一个参数是要导航到的路由:我们从这个锚点元素中获取这个值。我们还传递一个options对象,将trigger设置为true。如果我们不触发导航,浏览器地址栏中的 URL 将会改变,但其他什么都不会改变。由于我们正在触发导航,如果存在适当的路由方法,它将被调用。在我们的例子中,有一个singlePost方法,所以你应该在浏览器的 JavaScript 控制台中看到我们的消息被打印出来。

查看帖子

现在我们有了帖子页面的正确 URL,让我们为单个帖子创建一个视图:

var PostView = Backbone.View.extend({
  template: _.template($("#postView").html()),
  events: {
    'click a': 'handleClick'
  },
  render: function () {
    var model = this.model.toJSON();
    model.pubDate = new Date(Date.parse(model.pubDate)).toDateString();
    this.el.innerHTML = this.template(model);
    return this;
  },
  handleClick: function (e) {
    e.preventDefault();
    postRouter.navigate($(e.currentTarget).attr("href"),
      {trigger: true});
    return false;
  }
});

这个视图应该标志着你在 Backbone 学习中的一个重要里程碑:你理解了你在代码中看到的大多数约定。你应该认识到视图的所有属性,以及大多数方法的内容。我想在这里指出,比你可能意识到的有更多的约定在进行。例如,template属性仅在render方法内部引用,所以你可以给它取一个不同的名字,或者像以下代码行所示,将其放在render方法内部:

var template = _.template($("#postView").html());

即使是render方法,也只有在渲染视图时我们才使用它。将其称为render是一种约定,但如果你不这样做,实际上并不会有什么问题。Backbone 内部永远不会调用它。

小贴士

你可能会想知道,如果我们不必这样做,为什么我们还要遵循这些 Backbone 约定。我认为部分原因是因为它们是非常合理的默认值,并且因为这让阅读其他人的 Backbone 代码变得容易得多。然而,这样做的一个很好的理由是,有许多第三方 Backbone 组件依赖于这些约定。当使用它们时,约定变成了必须满足的期望,以便事物能够正常工作。

然而,在这个视图中有一些事情对你来说可能是新的。首先,我们不是将模板文本直接放在一个字符串中传递给_.template,而是将其放在index.ejs文件中,并使用 jQuery 将其拉入。这将是常见的一种做法;这样做很方便,因为大多数应用程序都会有更大的模板,而在 JavaScript 字符串中管理大量的 HTML 是很困难的。所以,在你的index.ejs文件中,与你的“实际”脚本标签相关的地方,放入以下代码:

<script type="text/template" id="postView">
  <a href='/'>All Posts</a>
  <h1>{{title}}</h1>
  <p>{{pubDate}}</p>
  {{content}}
</script>

在你的脚本标签中添加一个type属性是很重要的,这样浏览器就不会尝试将其作为 JavaScript 执行。这个type的具体值并不重要;我使用text/template。我们还给它添加了一个id属性,这样我们就可以从 JavaScript 代码中引用它。然后,在我们的 JavaScript 代码中,我们使用 jQuery 获取元素,然后使用html方法获取其内容。

这个视图的另一个不同之处在于,我们没有直接将this.model.toJSON()传递给render方法。相反,我们将它保存到model变量中,这样我们就可以格式化pubDate属性。当作为 JSON 存储时,日期看起来并不美观。我们使用一些内置的Date方法来修复这个问题,并将其重新分配给模型。然后,我们将更新的model对象传递给render方法。

如果你想知道为什么我们再次使用eventshandleClick来覆盖锚点动作,请注意我们模板中的所有帖子链接;这将在我们的帖子内容上方显示。然而,我希望你能注意到这个模式的缺陷:这将破坏我们帖子内容中可能存在的所有链接,这可能会导致链接跳出我们的博客。这也是为什么,正如我之前所说的,这种视图更改的模式并不那么好;我们将在未来的章节中探讨改进。

现在我们已经创建了视图,我们可以更新我们的路由器中的 singlePost 方法:

singlePost: function (id) {
  var post = this.posts.get(id);
  var pv = new PostView({ model: post });
  this.main.html(pv.render().el);
}

我们不是仅仅将 ID 记录到控制台,而是在我们的 this.posts 集合中找到具有该 ID 的帖子。然后,我们创建一个 PostView 实例,将其作为模型传递给它。最后,我们用帖子视图的渲染内容替换 this.main 元素的内容。

如果你现在进行简单的点击测试,你应该能够访问我们的主页,点击帖子的标题,并看到以下内容:

查看帖子

你应该感到自豪!你刚刚构建了一个完整的 Backbone 应用程序(尽管功能极低,但毕竟是一个应用程序)。

创建新帖子

现在我们能够显示帖子了,让我们创建一个表单来创建新帖子。重要的是要认识到我们只是创建一个表单。没有用户账户和认证,只有一个任何人都可以用来创建新帖子的表单。我们将从模板开始,将其放在 index.ejs 文件中:

<script type="text/template" id="postFormView">
  <a href="/">All Posts</a><br />
  <input type="text" id="postTitle" placeholder="post title" />
  <br />
  <textarea id="postText"></textarea>
  <br />
  <button id="submitPost"> Post </button>
</script>

这是一个非常基本的表单,但足够用了。所以现在,我们需要创建我们的视图;使用以下代码:

var PostFormView = Backbone.View.extend({
  tagName: 'form',
  template: _.template($("#postFormView").html()),
  initialize: function (options) {
    this.posts = options.posts;
  },
  events: {
    'click button': 'createPost'
  },
  render: function () {
    this.el.innerHTML = this.template();
    return this;
  },
  createPost: function (e) {
    var postAttrs = {
      content: $("#postText").val(),
      title: $("#postTitle").val(),
      pubDate: new Date()
    };
    this.posts.create(postAttrs);
    postRouter.navigate("/", { trigger: true });
    return false;
  }
});

它相当大,但你应该能够理解大部分内容。我们首先通过 tagName 属性将视图设置为 <form> 元素。我们在 template 属性中获取我们刚刚创建的模板。在 initialize 方法中,我们接受一个 Posts 集合作为选项并将其分配为一个属性,就像我们在路由器中做的那样。在 events 属性中,我们监听按钮的点击事件。当发生这种情况时,我们调用 createPost 方法。渲染这个视图相当简单。实际上,这里的真正复杂性在于 createPost 方法,但即使那样也很简单。我们创建一个包含我们帖子所有属性的 postAttrs 对象:表单的内容和文本以及我们添加的日期。

在创建这个 postAttrs 对象之后,我们将其传递给 Posts 集合的 create 方法。这实际上是一个便利方法,它会创建 Post 模型实例,将其保存到服务器,并将其添加到集合中。如果我们想“手动”完成这个操作,我们会做类似以下代码的事情:

var post = new Post(commentAttrs);
this.posts.add(post);
post.save();

每个 Backbone 模型构造函数都接受一个对象,这是一个属性哈希。我们可以使用 add 方法将那个模型添加到集合中。然后,每个模型实例都有一个 save 方法,它将模型发送到服务器。

注意

在这种情况下,在保存之前将模型添加到集合中很重要,因为我们的模型类本身不知道要 POST 到服务器的服务器路由。如果我们想能够保存不在集合中的模型实例,我们必须给模型类一个 urlRoot 属性:

urlRoot: "/posts",

最后,我们导航回主页。

下一步是为路由器添加一个新路由。在路由器类的 routes 属性中,添加以下行:

'posts/new': 'newPost'

然后,我们添加 newPost 方法,它非常简单:

newPost: function () {
  var pfv = new PostFormView({ posts: this.posts });
  this.main.html(pfv.render().el);
},

就这样!就像我说的,这并不是在合适的博客中真正进行博客发布的方式,但它展示了我们如何将模型数据发送回服务器。

添加评论

让我们更进一步,添加一些(非常原始的)评论功能。

再次强调,我们应该从考虑数据开始。在这种情况下很明显:我们的基本数据对象,如果你愿意的话,就是评论。然而,我们还需要考虑我们的数据如何与其他应用程序中的数据交互,也就是说,我们拥有的每个帖子都需要能够连接多个评论。Backbone 没有任何关于模型和集合之间关系的约定,所以我们将自己想出一些方法。

我们从模型和集合开始,如下面的代码所示:

var Comment = Backbone.Model.extend({});
var Comments = Backbone.Collection.extend({
  initialize: function (models, options) {
    this.post = options.post;
  },
  url: function () {
    return this.post.url() + "/comments";
  }
});

你还记得 initialize 函数吗?当我们实例化集合时,它将会运行。传统上,它接受两个参数:一个模型数组和一个选项对象。我们期望一个评论集合与单个帖子相关联,并且我们将通过选项获取该帖子。

在我们的 Posts 集合中,url 是一个字符串属性;然而,如果需要更动态的 URL,它也可以是一个返回字符串的函数。这正是我们为 Comments 集合所需要的,因为 URL 依赖于帖子。正如你所看到的,评论集合的服务器位置是帖子的 URL 加上 /comments。因此,对于 ID 为 1 的帖子,它是 /posts/1/comments。对于 ID 为 42 的帖子,它是 /posts/42/comments,依此类推。

注意

模型实例上的 url 方法会检查我们的模型类是否有 urlRoot 属性;如果有,它将使用该属性。否则,它将使用其集合的 url 属性。在任何情况下,它都会将其 id 属性附加到 url 属性上,以获取其自己的唯一 URL。

下一步是将 Comments 集合松散地连接到 Post 模型。我们需要在我们的 Post 模型中添加一个 initialize 方法,如下所示:

var Post = Backbone.Model.extend({
  initialize: function () {
    this.comments = new Comments([], { post: this });
  }
});

我说“松散地”,是因为这里实际上没有帖子与其评论之间的实际关系(除了在 options 对象中设置 post: this 以帮助设置当前 URL 之外);所有这些只是每当创建帖子时创建一个新的 Comments 集合。重要的是要意识到这个 comments 属性与其他模型属性不同。具体来说,它是对象的常规 JavaScript 属性,而不是帖子模型本身的属性。我们无法使用模型的 get 方法来获取它。

提供评论服务

下一步是准备服务器以发送和接收评论。向客户端发送评论实际上相当简单;请参见这里:

app.get("/posts/:id/comments", function (req, res) {
  comments.find(
    { postId: parseInt(req.params.id, 10) },
    function (err, results) {
      res.json(results);
    }
  );
});

就像在 Backbone 路由的路由中一样,我们可以在 Express 路由中使用冒号目标风格的标记来获取变量。然而,这些变量不会以函数参数的形式出现,而是作为请求对象 req.param 的一个子属性来获取。

我们正在使用我们之前创建的comments数据库对象。数据库有一个find方法,它接受一个查询对象作为第一个参数。在这种情况下,我们只想找到所有具有匹配 URL 中id参数的postId属性的评论记录。由于id参数是一个字符串,我们需要使用parseInt将其转换为数字。当我们得到记录时,我们将它们作为 JSON 发送回,就像我们处理帖子一样。

那么关于保存评论呢?这些评论将作为请求体 POST 回服务器,并且它们被 POST 到相同的 URL,你可以在下面的代码中看到:

app.post("/posts/:id/comments", function (req, res) {
  comments.insert(req.body, function (err, result) {
    res.json(result);
  });
});

由于我们正在将请求体解析为 JSON(参见我们添加的中间件),我们可以直接将其插入到我们的数据库中。在我们的回调中,我们取一个result参数并将其作为 JSON 发送回客户端。这是很重要的,因为 Backbone 模型上的id属性应该在服务器上设置。我们的数据库会自动完成这个操作,所以发送回的结果是我们接收到的同一个对象,但有一个新的id属性。这是 Backbone 期望的响应。

评论视图

现在,我们准备创建评论视图。这可以通过许多方式完成,但我们将使用三个视图类来完成。第一个是显示单个评论。第二个是创建新评论的表单。第三个将这两个视图包装起来并添加一些重要的功能。

第一个是最简单的,所以我们从这里开始:

var CommentView = Backbone.View.extend({
  template: _.template($("#commentView").html()),
  render: function () {
    var model = this.model.toJSON();
    model.date = new Date(Date.parse(model.date)).toDateString();
    this.el.innerHTML = this.template(model);
    return this;
  }
});

我们正在格式化日期,就像之前一样,用于帖子。同时,我们再次将模板内容放在一个脚本标签中。这是要放入index.ejs文件的脚本标签:

<script type="text/template" id="commentView">
  <hr />
  <p><strong>{{name}}</strong> said on {{date}}: </p>
  <p>{{text}}</p>
</script>

很直接,不是吗?

接下来是CommentFormView类。这是观众将用来向帖子添加评论的表单。这次我们将从以下代码开始使用模板:

<script type="text/template" id="commentFormView">
  <input type="text" id="cmtName" placeholder="name" /><br />
  <textarea id="cmtText"></textarea><br />
  <button id="submitComment"> Submit </button>
</script>

没有什么特别的:一个用于名称的文本框,一个用于文本的文本区域,以及一个提交按钮。一个非常基本的表单,你同意。现在我们有了类本身:

var CommentFormView = Backbone.View.extend({
  tagName: "form",
  initialize: function (options) {
    this.post = options.post;
  },
  template: _.template($("#commentFormView").html()),
  events: {
    'click button': 'submitComment'
  },
  render: function () {
    this.el.innerHTML = this.template();
    return this;
  },
  submitComment: function (e) {
    var name = this.$("#cmtName").val();
    var text = this.$("#cmtText").val();
    var commentAttrs = {
      postId: this.post.get("id"),
      name: name,
      text: text,
      date: new Date()
    };
    this.post.comments.create(commentAttrs);
    this.el.reset();
  }
});

这个表单视图很长,但与另一个表单非常相似,即创建帖子的表单。tagName属性将视图的基本元素设置为表单。由于这个表单创建的评论需要与帖子相关联,我们在initialize方法中通过options对象设置帖子为一个属性。

注意

在这个视图中,我们不是创建一个post属性,而是可以使用model属性。正如你可能已经注意到的,这是一个特殊命名的属性,当它是options对象的一部分时,会自动分配(因此我们不需要initialize方法)。然而,这个属性通常是显示在这个视图中的模型。由于我们在这里不是使用这个,我更喜欢创建一个自定义属性,这样阅读这段代码的人就不会误解这个视图中的帖子模型的目的。

当然,我们需要捕获提交按钮的click事件。当发生这种情况时,将运行submitComment方法。这个方法的前一部分很简单;我们从文本框和文本区域获取值。然后,我们创建一个具有四个属性的commentAttrs对象:这个评论所属帖子的 ID、评论者的名字、文本以及评论的创建日期和时间(目前)。

在创建这个commentAttrs对象之后,我们将其传递给帖子的评论集合的create方法,就像我们在PostFormView中所做的那样。submitComment方法中的最后一行是一个内置的 DOM 方法,用于重置表单;它清除所有字段。

最后一个视图是CommentsView,它将这两个视图类组合在一起,如下所示:

var CommentsView = Backbone.View.extend({
  initialize: function (options) {
    this.post = options.post;
    this.post.comments.on('add', this.addComment, this);
  },
  addComment: function (comment) {
    this.$el.append(new CommentView({ 
      model: comment 
    }).render().el);
  },
  render: function () {
    this.$el.append("<h2> Comments </h2>");
    this.$el.append(new CommentFormView({ 
      post: this.post 
    }).render().el);
    this.post.comments.fetch();
    return this;
  }
});

就像CommentFormView一样,当它被创建时,这个视图将获得一个Post实例。在render方法中,我们首先将一个标题追加到视图元素,然后渲染并追加我们的评论表单。所有这些都应该看起来相对熟悉,但其余的都是新的。render方法中的倒数第二行调用了帖子的评论集合的fetch方法。这向服务器发送一个 GET 请求,并用从服务器返回的评论填充集合。

现在,回顾一下initialize方法;最后一行是我们第一次看到的 Backbone 的事件能力。当我们执行不同的任务和调用 Backbone 对象的不同的方法时,会触发不同的事件,我们可以监听这些事件并在它们发生时做出反应。在这种情况下,我们正在监听评论集合的add事件。这个事件发生在我们向这个集合添加新模型时。如果你思考一下我们编写的代码,你会看到有两个地方我们向这个集合添加模型:

  • 当在CommentFormViewsubmitComment方法中调用comments.create

  • 当在这个视图的render方法中调用comments.fetch

因此,每当我们将一个模型添加到我们的集合中时,我们希望调用this.addComment方法。请注意,我们在on方法中传递了第三个参数:this。这是我们要调用的函数的上下文。默认情况下,在由on方法调用的函数内部,this将没有值,因此我们希望告诉它使用这个视图实例作为上下文。

addComment方法接受新添加的评论作为参数(集合对象和options对象也传递给响应add事件的函数,但在这里我们不需要它们)。然后我们可以为这个模型创建一个CommentView实例,并将其元素追加到我们的视图元素中。

好吧,现在一切都准备好了。你可以继续尝试,也就是说,加载一个帖子页面并添加一些评论。每次,你应该看到评论出现在表单下方。然后,如果你刷新页面,你做出的评论将再次出现在帖子下方。你可能注意到评论加载有一点延迟。这是因为我们并没有在页面初始加载时加载它们。相反,它们是在 CommentsView 渲染期间加载的。当然,这只是在页面加载后的毫秒级,但你可能会看到短暂的闪烁。你将在屏幕上看到以下内容:

评论查看

摘要

这就带我们结束了第一章。如果你在这之前并没有深入挖掘 Backbone,我希望你现在开始对库的基本知识感到舒适。

在本章中,我们简要地回顾了 Backbone 的所有主要组件。我们看到了模型和集合是如何成为我们的数据记录的家,以及它们如何驱动网络应用。我们创建了一些视图,一些用于显示单个模型实例,一些用于显示集合,还有一些用于显示其他页面组件或包装其他视图。我们创建了一个路由器,并使用它来引导我们网络应用上的几乎所有流量。我们甚至尝到了 Backbone 强大的事件 API 的一点点味道。

除了 Backbone API 的细节之外,我希望你还能掌握一些更大的想法。其中之一是 options 对象,因为几乎每个 Backbone 组件构造函数都将 options 对象作为最后一个参数,许多与服务器交互的函数也是如此。有一些魔法属性名——例如 modelcollection——Backbone 会自动处理,但你也可以传递自己的选项并在类内部处理它们。

本章的另一个重要收获是在编码时如何在传统和选择之间取得平衡。与其他类似的库相比,Backbone 非常轻量级和灵活,并且强制执行的编码模式非常少。好处是 Backbone 强烈支持的少数传统实际上是非常棒的想法,值得遵循。当然,这只是程序员的个人观点,但我发现 Backbone 在遵循传统和自由编码之间的平衡几乎是完美的。当我们下一章构建一个照片分享应用时,我们将了解更多关于这种平衡的内容。

第二章:构建照片分享应用

你已经很好地理解了最基础的 Backbone 特性。我认为你已经准备好提升水平,构建一些更大、更复杂的东西。所以在本章中,我们将构建一个类似 Instagram 的克隆应用;用户将能够创建账户、上传照片、关注其他用户,并在照片上评论。我们将使用上一章中用到的许多特性,但也会探讨一些新的特性。我们将涵盖以下主题:

  • 用户账户如何影响 Backbone 应用

  • 编写自己的模型同步函数

  • 模型的其他用途

  • 通过 AJAX 上传文件

创建用户账户

我们将从上一章第一部分创建的应用模板开始。因此,在本书附带代码的下载中,找到 template 文件夹并复制它。当然,你需要安装必要的 Node.js 包;所以,在终端中运行 npm install

与上一章中我们编写的应用相比,这个应用具有更重要的服务器组件;我们希望能够创建用户账户并允许用户登录和登出。实际上,这正是我们需要开始的。有一个非常棒的 Node.js 包叫做 Passport (passportjs.org/),它使得认证变得简单。我们将首先安装这个库,以及我们将用于加密用户密码的 bcrypt 包。使用以下命令进行操作:

npm install passport --save
npm install passport-local --save
npm install bcrypt --save

--save 标志将把这些包添加到 package.json 文件中。

对于这些设置,有几十行代码,所以我们将它们放在一个单独的文件中。在项目文件夹中创建一个 signin.js 文件。第一步是引入我们需要的库:

var bcrypt = require("bcrypt");
var LocalStrategy = require("passport-local").Strategy;
var salt = bcrypt.genSaltSync(10);

在 Passport 术语中,策略是一种认证方法。我们正在本地进行认证,而不是使用 Twitter 或 Facebook。当我们加密用户的密码时,将使用 salt 变量;在加密中,这是一个好习惯,以确保我们的用户密码安全存储。

接下来,我们将创建我们的 strategy 对象,如下所示:

exports.strategy = function (db) {
  return new LocalStrategy(function (username, password, done) {
    db.findOne({ username: username }, function (err, user) {
      if (!user) {
        done(null, false, { message: "Incorrect username." });
      } else if(!bcrypt.compareSync(password,user.passwordHash)) {
        done(null, false, { message: "Incorrect password." });
      } else {
        done(null, user);
      }
    });
  });
};

首先,我们正在给一个 exports 对象赋值。这是一个可以从文件中导出的 Node.js 模块对象。当我们从 server.js 文件中引入这个文件时,exports 对象的任何属性都将成为 require 调用返回的对象的属性。

现在,关于这里的代码:这可能会让你觉得有些奇怪,但请稍等。我们无法直接创建 strategy 方法,因为我们需要在 strategy 对象内部使用数据库。因此,我们创建了一个函数,该函数将接受数据库并返回一个 strategy 对象。strategy 对象的构造函数接受一个执行认证的函数,该函数接受三个参数:用户名、密码和一个回调函数,我们称之为 done

在函数内部,我们根据我们接收到的参数搜索数据库中的用户。在回调内部,我们首先检查用户是否存在;如果不存在,我们通过传递三个参数来调用done方法。第一个是发生的任何错误:这可以是null,因为没有错误。第二个是false;将是一个用户对象,但我们传递false,因为没有用户。最后一个参数是我们可以向用户显示的消息。

然而,如果我们确实找到了一个用户,我们需要匹配我们给出的密码。当我们开始在数据库中创建用户时,我们将使用bcrypt包将明文密码转换为哈希值,这样我们就不存储明文版本。然后,在这里,我们可以使用bcrypt.compareSync方法来比较结果;它接受我们正在比较的密码和从数据库中获取的用户对象的user.passwordHash属性。最后,如果比较没有失败,我们将通过在done方法中发送用户对象来验证用户。

这一开始可能很多,但开始认证很重要。我们还需要serializedeserialize方法;这些将由 Passport 的会话功能使用,以在页面刷新期间保持用户对象可用。这些方法:

exports.serialize = function (user, done) {
  done(null, user.id);
};

exports.deserialize = function (db) {
  return function (id, done) {
    db.findOne({ id: id }, function (user) {
      done(null, user);
    });
  };
};

serialize方法将只发送用户的 ID;在deserialize方法中,我们使用与strategy对象相同的技巧,因为我们需要在deserialize函数内部使用数据库。我们返回一个函数,它接受 ID,并将用户对象发送到done方法。

模块的一个最后部分;在创建用户账户时,我们需要将明文密码转换为哈希版本。为此,我们将使用bcrypt.hashSync方法:

exports.hashPassword = function (password) {
  return bcrypt.hashSync(password, salt);
};

我们的功能将接受单个参数——明文密码——并将其哈希化。不要忘记将我们创建的salt对象作为hashSync方法的第二个参数传递。

现在,我们已经准备好进入server.js文件并开始那里的工作。我们首先引入 Passport 库和我们的signin.js文件,如下面的代码所示:

var passport = require("passport");
var signin   = require("./signin");

如果你不太熟悉请求本地 Node.js 模块,我们可以直接将相对路径传递给require函数,就像你在这里看到的那样。我们不需要包含.js扩展名。

我们还需要创建我们将需要用于应用程序的数据库实例;我们这样做:

var users = new Bourne("users.json");
var photos = new Bourne("photos.json");
var comments = new Bourne("comments.json");

接下来,我们需要设置我们放在signin.js文件中的护照功能。使用以下代码来完成这个任务:

passport.use(signin.strategy(users));
passport.serializeUser(signin.serialize);
passport.deserializeUser(signin.deserialize(users));

我们将创建策略的函数传递给passport.use。然后,我们设置serializedeserialize函数。注意,strategydeserialize函数接受users数据库作为参数,并返回正确的函数。

下一步是为应用程序准备中间件。在我们的上一个应用程序中,我们不需要很多中间件,因为我们没有在服务器上做很多操作。但这次,我们必须管理用户的会话。所以,这就是我们有的:

app.configure(function () {
  app.use(express.urlencoded());
  app.use(express.json());
  app.use(express.multipart());
  app.use(express.cookieParser());
  app.use(express.session({ secret: 'photo-application' }));
  app.use(passport.initialize());
  app.use(passport.session());
  app.use(express.static('public'));
});

所有额外的中间件组件——我们之前章节中没有使用的——都用于管理用户会话。实际上,Express 的大多数中间件都来自 Connect 库 (github.com/senchalabs/connect);这里可能看起来我们添加了很多中间件组件,但事实是它们被分解成许多小块,这样你可以选择你需要的。你可以在 Connect 网站上了解更多关于每个单独组件的信息 (www.senchalabs.org/connect/),但以下是这个应用程序中我们没有使用过的中间件组件:

  • urlencoded: 此方法解析 x-ww-form-urlencoded 请求体,并将解析后的对象作为 req.body 提供

  • multipart: 此方法解析 multipart/form-data 请求体,并将解析后的对象作为 req.bodyreq.files 提供

  • cookieParser: 此方法解析 cookie 头部,并将数据作为 req.cookies 提供

  • session: 此方法使用给定选项设置会话存储

  • passport.initialize: 此方法设置 Passport

  • passport.session: 此方法使用 Passport 设置持久登录

现在这些组件都已就绪,我们可以开始编写一些路由了。我们将从与用户登录和登出具体相关的路由开始。以下是登录路由:

app.get("/login", function (req, res) {
  res.render("login.ejs");
});

第一个很简单;在 /login 路由中,我们将渲染 login.ejs 模板。关于此文件内容的更多内容很快就会介绍。然而,你可能会猜到那个页面上将有一个表单。用户将输入他们的用户名和密码;当他们提交表单时,数据将回传到这个 URL。因此,我们需要在相同的 URL 上接受 POST 请求。所以,这里是有 post 方法版本:

app.post('/login', passport.authenticate('local', {
  successRedirect: '/',
  failureRedirect: '/login'
}));

你会注意到这个路由有一些不同;我们并没有编写自己的函数。相反,我们调用 passport.authenticate 函数。如你之前所见,我们正在使用本地策略,所以这是第一个参数。之后,我们有一个包含两个属性的对象。它定义了用户根据是否认证将重定向到的路由。显然,如果用户成功登录,他们将被发送到根路由;否则,他们将被送回登录页面。get 方法在以下代码中给出:

app.get("/logout", function (req, res) {
  req.logout();
  res.redirect('/');
});

这个也很简单:要登出,我们只需调用 Passport 添加到请求对象的 logout 方法,然后再次重定向到根路由。

现在,让我们处理 login.ejs 文件。这必须放在 view 文件夹中:

<h1> Sign In </h1>
<form method="post" action="/login">
  <p>Username: <input name='username' type='text' /></p>
  <p>Password: <input name='password' type='password' /></p>
  <button> Login </button>
</form>
<h1> Create Account </h1>
<form method="post" action="/create">
  <p>Username: <input name='username' type='text' /></p>
  <p>Password: <input name='password' type='password' /></p>
  <button> Create </button>
</form>

我们这里有两组表单:一组用于登录,另一组用于创建用户。实际上,它们几乎完全相同,但它们将被提交到不同的路由。我们已经为第一个编写了路由,但我们还没有为新用户创建路由。那么,下一步是什么?

app.post('/create', function (req, res, next) {
  var userAttrs = {
    username: req.body.username,
    passwordHash: signin.hashPassword(req.body.password),
    following: []
  };
  users.findOne({ username: userAttrs.username }, function (existingUser) {
    if (!existingUser) {
      users.insert(userAttrs, function (user) {
        req.login(user, function (err) {
          res.redirect("/");
        });
      });
    } else {
      res.redirect("/");
    }
  });
});

在这个路由的函数中,我们首先创建userAttrs对象。我们将从req.body对象中获取用户名和密码,确保使用hashPassword方法对密码进行散列。我们还将包括一个名为following的空数组;我们将在这个数组中存储他们关注的用户 ID 列表。

接下来,我们将搜索我们的数据库以查看是否有其他用户使用了那个用户名。如果没有,我们可以插入我们刚刚创建的用户属性对象。一旦我们存储了用户,我们可以通过使用 Passport 提供的req.login方法来设置会话。一旦他们登录,我们可以将他们重定向回根路由。

这样基本上就完成了我们的用户账户功能。我应该指出的是,我在生产应用中省略了一些重要的部分;例如,如果用户在登录时输入了错误的用户名或密码,或者尝试使用已存在的用户名创建用户账户,系统将不会显示任何有用的消息。用户只会被重定向回表单。当然,这以及其他重要的账户相关功能(如更改密码)也可以实现;但我们想专注于 Backbone 代码。这正是你在这里的原因,对吧?

正如我们所见,一旦用户登录,系统会将其重定向回根路由。实际上,我们还没有根路由的方法,所以现在让我们创建它,如下面的代码所示:

app.get('/*', function (req, res) {
  if (!req.user) {
    res.redirect("/login");
    return;
  }
  res.render("index.ejs", {
    user: JSON.stringify(safe(req.user))
  });
});

实际上,这不仅仅是根路由;它将收集许多路由。第一步是检查req.user对象,以查看用户是否已登录。还记得我们编写的deserialize方法吗?Passport 将在幕后使用它来确保这个req.user对象正好是我们数据库中的记录。如果没有设置,我们将用户发送到/login路由。否则,事情可以继续。

目前,我们保持得很简单;我们只是渲染index.ejs模板。我们发送到那里的唯一数据是用户。我们已经知道为什么需要将其包裹在JSON.stringify中,但safe函数是什么?这是我们即将编写的:这里的想法是我们不希望将整个用户记录发送回浏览器;我们希望删除一些分类属性,例如passwordHash。以下是safe函数:

function safe(user){
  var toHide = ['passwordHash'], clone = JSON.parse(JSON.stringify(user));

  toHide.forEach(function (prop) {
    delete clone[prop];
  });
  return clone;
}

这非常基础;我们有一个要删除的属性名称数组。我们首先克隆user参数。然后,我们遍历toHide变量,并在克隆上删除这些属性。最后,我们返回安全的user对象。

好吧,服务器端的代码真的开始整合了。我们终于准备好将注意力转向客户端代码。我们将从index.ejs文件开始。

创建我们的应用程序导航

我们已经从模板中得到了这个基本版本。然而,我们需要调整底部的脚本标签。在backbone.js标签之后,但在app.js标签之前,你想要添加以下行:

<script>var USER = <%- user %>;</script>

这是当前登录用户的user对象。我们将在应用程序组件内部需要使用一些其属性,这就是为什么我们需要在app.js文件之前加载它。

说到app.js文件,这是我们下一个要关注的地方。这次,我们将从一个路由器开始:

var AppRouter = Backbone.Router.extend({
  initialize: function (options) {
    this.main = options.main;
    this.navView = new NavView();
  },
  routes: {
    '': 'index'
  },
  index: function () {
    this.main.html(this.navView.render().el);
  }
});

这与我们的前一个应用程序中的路由器非常相似。我们将在应用程序内部使用任何选项——例如 DOM 元素、模型或集合——都将传递给路由器构造函数。正如你所见,我们已经为main元素(用户显示的所有内容的母元素)做好了准备。我们还将创建一个navView属性。你可能已经猜到了,这将显示一些导航;我们的应用程序将包含几个重要的链接,我们希望让用户能够轻松地浏览。我们将在下一个视图中编写这个。

注意

你可能会想知道为什么我们将USER设为一个全局变量而不是路由器的一个属性。毕竟,正如我们上次看到的,那些属性是来自服务器的数据,我们需要在浏览器上的视图中使用,对吧?实际上并没有什么理由不能这样做,但我更喜欢这种方式,因为我们的应用程序并不是真正关于操作用户记录的。虽然我们会有一个User类,但这只是为了方便。用户记录不会在客户端创建或修改。

在我们的routes对象中,我们正在设置我们的索引路由。现在,我们将在那个index方法中只渲染导航,但这是一个好的开始。

让我们编写导航视图。这是我们应用程序中最简单的视图,它是由以下代码创建的:

var NavView = Backbone.View.extend({
  template: _.template($("#navView").html()),
  render: function () {
    this.el.innerHTML = this.template(USER);
    return this;
  }
});

这段代码是相当标准的视图代码。注意我们正在使用USER对象作为这个模板的数据。以下是模板内容,它将放在index.ejs文件中:

<script type="text/template" id="navView">
  <ul>
    <li><a href="/">Home</a></li>
    <li><a href="/users">All Users</a></li>
    <li><a href="/users/{{id}}">My Profile</a></li>
    <li><a href="/upload">Add Photo</a></li>
    <li>Logged in as <strong>{{ username }}</strong></li>
    <li><a href="/logout">Log out</a></li>
  </ul>
  <hr />
</script>

我们已经有了足够的内容来尝试一下。在index.ejs文件中,确保我们通过以下代码创建并启动路由器:

var r = new AppRouter({ 
  main: $("#main")
});
Backbone.history.start({ pushState: true });

启动服务器(npm start)并前往http://localhost:3000。你应该会看到以下截图:

创建我们的应用程序导航

在底部的表单中输入用户名和密码,然后创建一个新的用户账户。当你点击创建按钮时,你将被发送到一个类似于以下屏幕的页面:

创建我们的应用程序导航

太好了!一切都在按计划进行。

当你构建这个应用时,你会遇到一些令人烦恼的事情;每次你做出更改,nodemon都会重新启动服务器,并且保持你登录状态的会话将会消失。每次你都需要重新登录。为了解决这个问题,我在server.js文件的顶部添加了以下代码:

var requser = {
  username: "andrew",
  id: 1
};

然后,在所有使用req.user的地方,使用requser代替。在这个地方进行搜索和替换很容易,并且它将使你在服务器刷新时保持登录状态。我将在接下来的代码片段中继续使用req.user。然而,这个便利的技巧并不完美。当我们到达关注其他用户的功能时,你必须删除这个requser变量,否则事情将不会变得有意义。

上传照片

接下来,让我们解决文件上传问题。这是一个照片分享网站,因此这是最重要的功能之一。让我们先创建一个用于上传表单的视图:

var AddPhotoView = Backbone.View.extend({
  tagName: "form",
  initialize: function (options) {
    this.photos = options.photos;
  },
  template: _.template($("#addPhotoView").html()),
  events: {
    "click button": "uploadFile"
  },
  render: function () {
    this.el.innerHTML = this.template();
    return this;
  },
  uploadFile: function (evt) {
    evt.preventDefault();
    var photo = new Photo({
      file: $("#imageUpload")[0].files[0],
      caption: $("#imageCaption").val()
    });
    this.photos.create(photo, { wait: true });
    this.el.reset();
  }
});

我们从一个initialize函数开始,该函数将一个名为photos的属性分配给我们从options对象中获取的内容。这个photos对象实际上是一个集合,所以你可能想知道为什么我们不在options对象中将其称为collection;正如你所知,Backbone 会自动为我们处理这个分配。我们不这样做的原因是,这样可以使这个视图不是用于显示这个集合;它需要这个集合出于另一个原因(即,为了添加一个Photo模型实例)。你可以把这看作是语义问题,但我希望从 Backbone 惯例中的这一变化能让阅读代码的人停下来寻找原因。

templateeventsrender属性是自解释的。我们正在拉入的模板非常简单:一个接受文件和标题的小表单。这是模板的代码:

<script type="text/template" id="addPhotoView">
  <p>Photo: <input type="file" id="imageUpload" /></p>
  <p>Caption: <input type="text" id="imageCaption" /></p>
  <button> Upload </button>
</script>

当按下那个按钮时,会调用uploadFile方法。在那里,我们将取消表单提交的默认行为,并使用数据创建一个Photo模型实例(即将推出)。caption属性很明显,但file属性稍微复杂一些。我们首先获取文件输入元素,然后获取名为files的数组属性中的第一个项目。这是我们通过 AJAX 上传文件所需的数据。然后,我们通过将其传递给集合的create方法来保存这个对象。你可能对{ wait: true }部分感到好奇。稍后我会解释这一点;当它有意义时,我会解释它。

最后,我们将清除表单,这样他们就可以上传另一张照片(如果他们想的话)。

在我们真正使这个功能工作之前,还有一些其他部分需要构建。最明显的是,我们需要照片模型和照片集合。在上一个应用中,我们的模型相当简单,但这个更复杂;这是模型类的代码:

var Photo = Backbone.Model.extend({
  urlRoot: "/photos",
  sync: function (method, model, options) {
    var opts = {
      url: this.url(),
      success: function (data) {
        if (options.success) {
          options.success(data);
        }
      }
    };

    switch (method) {
      case "create":
        opts.type = "POST";
        opts.data = new FormData();
        opts.data.append("file", model.get('file'));
        opts.data.append("caption", model.get('caption'));
        opts.processData = false;
        opts.contentType = false;
        break;
      default:
        opts.type = "GET";
    }
    return $.ajax(opts);
  }
});

如你所知,urlRoot 对象是这个模型将在服务器上 GET 和 POST 的路由的基础,但这里的大问题是 sync 方法。通常,所有模型和集合都使用 Backbone.sync 方法。这是我们每次从服务器读取或写入一个或多个模型时都会调用的方法。如果我们需要做一些不同的操作,我们可以在模型级别重写这个方法,这正是这里的情况。Backbone 不支持开箱即用的 AJAX 文件上传,因此我们需要编写一个 sync 函数来完成这个任务。

这里的技巧是我们不能仅仅编写一个用于创建新照片记录的文件上传功能。这是因为这个方法是用于读取、更新和删除这个模型实例的方法。正如你所见,sync 方法接受三个参数:第一个是我们即将执行的操作(创建、读取、更新和删除),第二个是 model 实例,第三个是 options 对象。

由于我们将使用 jQuery 来执行 AJAX 调用,我们只需要设置我们自己的 options 对象。这就是我们开始的方式。当然,它需要一个 URL,所以我们调用这个模型类的 url 方法。我们还需要定义一个 success 回调。重要的是这个回调调用 options 对象的 success 方法;这个方法将处理一些幕后的重要操作。无论我们调用什么方法,这些属性都很重要。

然后,我们有一个 switch 语句;这是针对方法的不同之处。在 create 的情况下,我们希望将类型设置为 POST。我们将 data 属性设置为一个新的 FormData 实例;这是我们发送文件数据的方式。我们只是附加了放在模型上的 file 属性;我们也可以附加标题。

我们还需要设置 processDatacontentTypefalse。这样,我们可以确保文件数据以我们期望的方式到达服务器,以便我们可以将其保存到文件中。

我们在这里也设置了一个默认情况,将类型设置为 GET。我们并没有为更新或删除操作准备这个方法,因为这不是我们正在构建的应用程序的一部分。如果我们需要这些功能,我们就必须扩展它。

最后,我们只需要通过使用 $.ajax 并传递我们的 options 对象来执行 AJAX 调用。

我们还需要一个 Photos 集合。目前,我们会保持简单。我们将使用以下代码创建它:

var Photos = Backbone.Collection.extend({
  model: Photo
});

sync 方法允许我们将我们的图片发送到服务器,但我们还没有准备好处理传入数据的路由,所以这是我们下一个优先级:

app.post("/photos", function (req, res) {
  var oldPath = req.files.file.path,
      publicPath = path.join("images", requser.id + "_" + (photos.data.length + 1) + ".jpg"),
      newPath = path.join(__dirname, "public", publicPath);

  fs.rename(oldPath, newPath, function (err) {
    if (!err) {
      photos.insert({
        userId: requser.id,
        path: "/" + publicPath,
        caption: req.body.caption,
        username: requser.username
      }, function (photo) {
        res.send(photo);
      });
    } else {
      res.send(err);
    }
  });
});

正如你所见,我们正在向 /photos 发送数据。由于这个函数将要存储一个需要从浏览器中可查看的图片,我们需要将其放在 public 文件夹中。请创建一个名为 images 的文件夹在 public 文件夹内,这是我们将会放置图片的地方。

我们从几个路径开始。首先,有oldPath;这是请求时文件临时存储的路径。然后是publicPath:这是我们将在浏览器中查看照片的路径;它只是images加上文件名。我们将根据用户的 ID 和数据库中照片的数量给图片一个唯一的名称。第三,是newPath,这是我们将在当前位置相对存储图片的地方。

要在 Node.js 中处理此类文件,我们需要使用文件系统模块,所以请将以下行添加到文件顶部:

var fs = require("fs");

然后,我们可以使用rename方法移动文件。如果一切顺利,没有错误,我们可以在photos数据库中存储这张图片的记录。请注意,我们不是存储file属性,而是只存储path属性。一旦我们将这个对象发送回浏览器,它将替换我们之前有的属性。一旦我们存储了照片,我们将它作为确认操作已完成的消息发送回浏览器。

接下来,回到app.js文件中的客户端代码。我们需要一个路由来访问上传表单。如果你回顾我们的导航视图,你会看到我们想要创建的路由是/upload。你可以在AppRouter中的routes对象中添加以下行:

'upload': 'upload',

然后,让我们通过以下方式创建upload函数:

upload: function () {
  var apv = new AddPhotoView({ photos: this.userPhotos }),
    photosView = new PhotosView({ collection: this.userPhotos });
  this.main.html(this.navView.render().el);
  this.main.append(apv.render().el);
  this.main.append(photosView.render().el);
}

我们实际上在这里做的比你预想的要多一些;我们正在渲染第二个视图:一个PhotosView实例。不过在我们到达那里之前,请注意我们正在使用一个userPhotos属性;我们必须将其添加到路由器中。在AppRouter类的initialize函数中添加以下行:

this.userPhotos = options.userPhotos;

这使我们能够访问我们传递给路由器的任何userPhotos集合。然后,在index.ejs文件中,我们实例化路由器时,这一行将创建该集合:

userPhotos: new Photos()

好的,现在我们创建PhotosView类:

var PhotosView = Backbone.View.extend({
  tagName: 'ul',
  template: _.template($("#photosView").html()),
  initialize: function () {
    this.collection.on("add", this.addPhoto, this);
  },
  render: function () {
    this.collection.forEach(this.addPhoto, this);
      return this;
  },
  addPhoto: function (photo) {
    this.$el.append(this.template(photo.toJSON()));
  }
});

这是PhotosView类。请注意,我们将tagName属性设置为ul;然后,在render函数内部,我们只是遍历集合并调用addPhoto函数,该函数渲染模板并将结果放入列表中。这次,我们不是使用template函数来渲染整个视图,而是使用它来渲染集合中的每个模型。此外,请注意,在initialize函数中,我们正在监听每当新照片被添加到集合时,我们可以将它们添加到列表中。现在是时候回忆一下我们在创建create调用时添加的{ wait: true }选项。当我们告诉 Backbone 这样等待时,它不会在模型从服务器收到回复之前触发这个add事件。在这种情况下,这是很重要的,因为我们否则将没有我们图像的公开路径。这个类的最后一部分是模板;当然,以下代码应该放在index.ejs文件中:

<script type="text/template" id="photosView">
  <a href="/photo/{{id}}"><img src="img/{{path}}" /></a>
</script>

现在应该都准备好了!你可以访问http://localhost:3000/upload,选择一个文件,输入一个标题,然后点击上传按钮。文件将被上传,你会在表单下方看到它。恭喜!你刚刚上传了你的第一张照片。以下截图显示了照片可能的样子:

上传照片

注意

在构建这个应用程序时,我使用了来自unsplash.com的照片;这是一个免费高分辨率照片的绝佳来源。

从服务器发送照片到客户端

在我们开始处理另一个特定页面之前,我们需要一个从服务器获取照片的路由。这些照片需要放入Photos集合中,但如果你稍微思考一下,你会意识到我们可能会得到几组不同的照片。例如,我们可以获取一个用户的全部照片,或者获取当前用户关注的用户的全部照片。所以,深呼吸,这里是那个路由的代码:

app.get(/\/photos(\/)?([\w\/]+)?/, function (req, res) {
  var getting = req.params[1],
      match;

  if (getting) {
    if (!isNaN(parseInt(getting, 10))) {
      photos.findOne({ id: parseInt(getting, 10) },
        function (photo) { res.json(photo); });
    } else {
      match = getting.match(/user\/(\d+)?/);
      if (match) {
        photos.find({ userId: parseInt(match[1], 10) }, 
          function (photos) { res.json(photos); });
      } else if (getting === "following") {
        var allPhotos = [];
        req.user.following.forEach(function (f) {
          photos.find({ userId: f }, function (photos) {
            allPhotos = allPhotos.concat(photos);
          });
        });
        res.json(allPhotos);
      } else {
        res.json({});
      }
    }
  } else {
    res.json({});
  }
});

是的,这是一个难题。让我们从路由开始;我们使用一个正则表达式来匹配我们想要捕获的路由,而不是字符串。这个正则表达式几乎匹配以/photos开头的任何内容。我们感兴趣的以下模式是:

  • /photos/11:ID 为 11 的照片

  • /photos/following:登录用户关注的所有用户的照片

  • /photos/user/2:ID 为 2 的用户的照片

路由的捕获组被放入req.params中,所以req.params[1]是第二个捕获组。我们将它放入getting变量中,然后我们必须进一步检查。假设它存在,我们首先检查它是否是一个数字(通过解析它并通过isNaN传递它)。如果是数字,这是最简单的情况,我们找到具有该 ID 的照片并将其发送回去。

如果它不是一个数字,我们将getting变量与另一个正则表达式进行匹配,以查看它是否匹配user/ID。如果是,我们将返回所有匹配userId的照片。

最后,如果getting变量是字符串following,我们将遍历当前用户的following数组,并从每个这些用户那里获取照片,将他们的照片推入allPhotos数组,然后我们将其返回。

在任何时刻,如果我们遇到我们没有预料到的模式,我们只需返回一个空的 JSON 对象。

创建个人资料页面

现在我们有了这个路由可用,我们可以做更多的事情。比如个人资料页面?如果你再次查看导航视图,你会看到我们创建了一个我的个人资料链接,它带我们到/users/1(或你的 ID 号码)。当然,这意味着我们可以用它来做的不仅仅是我们的个人资料页面。如果我们使代码足够通用,它将适用于任何用户。

首先,我们需要一种从服务器获取用户数据的方式(记住,这可能不是登录用户的个人资料)。我们将通过以下代码使用一个模型来完成这项工作:

var User = Backbone.Model.extend({
  url: function () {
    return '/user-' + this.get('id') + '.json';
  }
});

URL 与我们通常的做法不同,但它展示了 Backbone 的灵活性;我们可以使 URL 看起来像指向 JSON 文件的路径。当然,如果我们需要向这个 URL 发送数据以保存用户(尤其是因为模型通常在保存之前没有 ID),这就不会那么好了。然而,既然我知道我们不需要这样做,我们可以玩这个,并这样去做。正如你可能想象的那样,服务器端代码非常简单,如下面的代码所示:

app.get("/user-:id.json", function (req, res) {
  users.findOne({ id : parseInt(req.params.id, 10) }, 
    function (user) {
      res.json(safe(user));
    });
});

现在,我们可以获取用户了,让我们通过以下代码将个人资料页面路由添加到路由器中:

'users/:id': 'showUser',

现在,将以下方法添加到路由器中:

showUser: function (id) {
  var thiz = this,
      user,
      photos;

  id = parseInt(id, 10);

  function render() {
    var userView = new UserView({ 
      model: user.toJSON(), 
      collection: photos 
    });
    thiz.main.html(thiz.navView.render().el);
    thiz.main.append(userView.render().el);
  }

  if (id === USER.id) {
    user = new User(USER);
    photos = this.userPhotos;
    render();
  } else {
    user = new User({ id: id });
    photos = new Photos({ url: "/photos/user/" + id });
    user.fetch().then(function () {
      photos.fetch().then(render);
    });
  }
},

再次,让我们做一些不同的事情。这里的情况是:如果用户正在查看自己的页面,就没有必要再次从服务器获取他们的用户和照片数据;我们可以使用浏览器中已有的数据。为了查看用户是否正在查看自己的个人资料,我们比较路由中的 ID(作为参数获取)和USER对象上的 ID。如果用户正在查看另一个用户的个人资料,我们创建一个用户模型和照片集合,只包含足够的数据:模型只需要id,集合只需要url。然后,我们可以让他们两者都从服务器获取所需的其他数据。在这两种情况下,fetch方法返回一个 jQuery 延迟对象。如果你不熟悉 JavaScript 中的延迟或承诺,可以将它们视为等待数据准备的一种方式;我们调用延迟的then方法,传递一个在数据准备就绪时调用的函数。我们将在后面的章节中更多地使用承诺。

等等,我们能否直接将 URL 传递给一个集合对象?通常不行。我们需要在我们的Photos集合类中添加一个initialize方法,如下所示:

initialize: function (options) {
  if (options && options.url) {
    this.url = options.url;
  }
}

很聪明,对吧?这样,我们可以使用我们想要的任何 URL。这就是为什么我们为这个类创建了 URL 灵活的后端。

在这两种情况下,我们随后调用我们的内部render方法。关于这个函数有一些需要注意的地方。尽管它位于我们的路由方法内部,但它仍然会在全局命名空间中运行;这就是为什么我们创建thiz变量,以便在render函数中使用。当然,在一种情况下我们按程序调用它,在另一种情况下我们作为回调调用它,但将完成相同的事情;我们将渲染UserView实例。以下是该类的代码:

var UserView = Backbone.View.extend({
  template: _.template($("#userView").html()),
  render: function () {
    this.el.innerHTML = this.template(this.model.toJSON());
    var ul = this.$("ul");
    this.collection.forEach(function (photo) {
      ul.append(new PhotoView({ 
        model: photo 
      }).render().el);
    });
    return this;
  }
});

非常简单;它只是一个用户名和你的照片列表。我们甚至可以重用我们之前制作的PhotoView类来显示单个照片。注意我们使用this.$方法;它允许我们在元素内部搜索并创建这些元素的 jQuery 对象,但它将搜索限制在这个视图实例的el属性内部。最后,这是模板:

<script type="text/template" id="userView">
  <h1>{{username}}</h1>
  <ul></ul>
</script>

如果你尝试运行它,你可能会注意到一个问题;在查看当前用户的个人资料时,将不会显示任何照片。这是因为我们正在使用路由器的userPhotos属性,它只是一个空的集合。当我们加载页面时,我们也应该加载用户的照片数据。这并不难做。首先,回到你的server.js文件中,在/*路由函数中,将res.render调用替换为以下代码:

photos.find({ userId: req.user.id }, function (err, photos) {
  res.render("index.ejs", {
    user: JSON.stringify(safe(req.user)),
    userPhotos: JSON.stringify(photos)
  });
});

然后,在index.ejs文件中,使用你的模板定界符将其放置在路由实例化的位置:

userPhotos: new Photos(<%- userPhotos %>)

现在,你应该能在你的个人资料页面上看到你自己的照片,因为我们是从服务器加载照片。

创建个人照片页面

我们现在已经使用了两次PhotoView类;它创建了一个链接,你可能还记得,为每个照片创建一个单独的页面。让我们创建这个页面。这次,我们从路由开始。首先,将此路由添加到routes属性:

'photo/:id': 'showPhoto',

然后,这里有一个showPhoto方法与之相关联:

showPhoto: function (id) {
  var thiz = this,
    photo = new Photo({ id : parseInt(id, 10) });

  photo.fetch().then(function () {
    var comments = new Comments({ photo: photo }),
    var photoView = new PhotoPageView({ 
      model: photo, 
      collection: comments 
    });

    comments.fetch().then(function () {
      thiz.main.html(thiz.navView.render().el);
      thiz.main.append(photoView.render().el);
    });
  });
},

正如我们处理showUser函数一样,我们通过创建一个带有idPhoto实例来获取照片数据,然后调用fetch方法。然而,我们还在这个照片的基础上创建了一个Comments集合。然后,我们创建一个PhotoPageView实例,它既有模型(照片)又有集合(评论)。一旦我们从服务器获取了评论,我们就渲染它。所以这里是视图:

var PhotoPageView = Backbone.View.extend({
  template: _.template($("#photoPageView").html()),
  initialize: function () {
    this.collection.on("add", this.showComment, this);
  },
  events: {
    'click button': 'addComment'
  },
  render: function () {
    this.el.innerHTML = this.template(this.model.toJSON());
    this.collection.forEach(this.showComment.bind(this));
    return this;
  }
});

如你所能猜到的,这并不是全部。在initialize函数中,我们设置了一个事件,每当集合中添加了新的评论时,就会调用showComment方法。这也是我们在render方法中使用的方法,用于显示已存在的每个评论。然后,我们有一个事件:一个按钮点击事件,触发addComment方法。在我们到达这些方法之前,你可能还想看看按钮以及模板的其余部分;以下是模板的代码,你应该将其添加到index.ejs文件中:

<script type="text/template" id="photoPageView">
  <img src="img/{{path}}" />
  <p> {{caption}} <small> by {{username}}</small></p>
  <div>
    <textarea id="commentText"></textarea><br />
    <button> Comment </button>
  </div>
  <ul></ul>
</script>

那么,我们现在就添加这些方法,好吗?我们先处理较长的那个。以下是为addComment函数编写的代码:

addComment: function () {
  var textarea = this.$("#commentText"),
      text = textarea.val(),
      comment = {
        text: text,
        photoId: this.model.get("id"),
        username: USER.username
      };
  textarea.val("");
  this.collection.create(comment);
},

这与我们的AddPhotoView类中的addPhoto方法非常相似。我们创建一个包含文本框中的文本、我们评论的相片的 ID 和评论者用户名的属性对象。然后,我们通过集合的create方法将它们发送到服务器。

当我们这样做时,我们的add事件将被触发,并且会调用showComment方法。以下是该方法:

showComment: function (comment) {
  var commentView = new CommentView({ model: comment });
  this.$("ul").append(commentView.render().el);
}

再次强调,魔法在其他地方。你想要看到的是以下代码中显示的CommentView实例:

var CommentView = Backbone.View.extend({
  tagName: "li",
  template: _.template($("#commentView").html()),
  render: function () {
    this.el.innerHTML = this.template(this.model.toJSON());
    return this;
  }
});

它非常简单;甚至它的模板也很简单。它的模板代码如下:

<script type="text/template" id="commentView">
  <p><strong>{{username}}</strong> said</p>
  <p>{{text}}</p>
</script>

在所有这些过程中,我们太兴奋了,完全忽略了一个重要的因素:我们还没有Comment模型或Comments集合。不用担心。我们通过以下代码创建这些:

var Comment = Backbone.Model.extend();
var Comments = Backbone.Collection.extend({
  model: Comment,
  initialize: function (options) {
    this.photo = options.photo;
  },
  url: function () {
    return this.photo.url() + '/comments';
  }
});

注意Comments集合的url函数。它接受分配为属性的相实例的url,并在末尾追加/comments。因此,在我们的server.js文件中,我们需要为这个路由创建 GET 和 POST 方法,如下面的代码所示:

app.get('/photos/:id/comments', function (req, res) {
  comments.find({ photoId: parseInt(req.params.id, 10) }, 
    function (comments) {
      res.json(comments);
    });
});

GET 路由将返回所有具有photoId属性与 URL 参数匹配的照片。以下是 POST 路由:

app.post('/photos/:id/comments', function (req, res) {
  var comment = {
    text: req.body.text,
    photoId: req.body.photoId,
    username: req.body.username
  };
  comments.insert(comment, function (data) {
    res.json(data);
  });
});

此路由将获取属性,创建一个对象,将其存储在comments数据库中,并以 JSON 格式返回保存的版本。

评论功能到此结束!你应该能够访问单个照片页面,输入评论,点击按钮,然后看到你的评论出现在下方。更好的是,当你刷新页面时,评论仍然会保留。下面的截图显示了它可能的样子:

创建单个照片页面

关注用户

这引出了我们应用程序中最复杂的功能:关注其他用户。我们希望用户能够选择他们想要关注的用户,并且这些用户的照片能够显示在主页上。

我们将从/users路由开始。将以下行添加到路由器的routes对象中:

'users': 'showUsers',

现在,让我们创建showUsers函数:

showUsers: function () {
  var users = new Users(),
      thiz  = this;
  this.main.html(this.navView.render().el);
  users.fetch().then(function () {
    thiz.main.append(new UserListView({ 
      collection: users 
    }).render().el);
  });
},

我们还没有Users集合类;这是下一步。然而,您可以看到我们将在这里获取所有用户,然后渲染一个UserListView实例。

Users集合非常直接,如下面的代码所示:

var Users = Backbone.Collection.extend({
  model: User,
  url: '/users.json'
});

此外,我们还需要在服务器端设置相应的部分,如下面的代码所示:

app.get("/users.json", function (req, res) {
  users.find(function (users) {
    res.json(users.map(safe));
  });
});

现在,我们可以查看UserListView实例。实际上,它也是我们的包装视图之一,只是用来组合一系列单个模型视图,如下面的代码所示:

var UserListView = Backbone.View.extend({
  tagName: "ul",
  render: function () {
    this.collection.forEach(function (model) {
      this.$el.append((new UserListItemView({ 
        model: model 
      })).render().el);
    }, this);
    return this;
  }
});

如您从这段代码中可以看到的,我们真正感兴趣的是UserListItemView实例。这可能是您今天(或至少在这一章中)看到的最大视图。我们将逐部分分析,如下面的代码所示:

var UserListItemView = Backbone.View.extend({
  tagName: "li",
  template: _.template('<a href="/users/{{id}}">{{username}}</a>'),
  events: {
    'click .follow': 'follow',
    'click .unfollow': 'unfollow'
  },
  render: function () {
    this.el.innerHTML = this.template(this.model.toJSON());
    if (USER.username === this.model.get("username")) {
      this.$el.append(" (me)");
    } else {
      this.update();
    }
    return this;
  }
});

这只是一个开始。正如您从事件中可以看到的,我们将有关注和取消关注按钮,它们将触发相应的方法。render函数首先渲染模板,我们将将其内联,因为它很小。

渲染之后还会发生更多有趣的事情。首先,我们检查我们正在为创建列表项的用户是否是当前登录用户;如果是,我们将在其末尾添加文本(me)。否则,我们将调用update方法。

update方法实际上非常基础。它的目标是检查当前用户是否正在关注我们为列表项创建的用户。如果他们已经关注了该用户,我们将添加一个取消关注按钮;否则,我们将使用关注按钮。当点击这些按钮之一时,此方法也会被调用,因此我们将在适当的时候移除按钮:

update: function () {
  if (USER.following.indexOf(this.model.get("id")) === -1) {
    this.$("#unfollow").remove();
    this.$el.append("<button id='follow'> Follow </button>");
  } else {
    this.$("#follow").remove();
    this.$el.append("<button id='unfollow'> Unfollow </button>");
  }
}

实际上很简单。如果视图的用户 ID 在当前用户的 following 数组中,我们就移除一个 取消关注 按钮,并添加一个 关注 按钮。否则,我们移除 关注 按钮,并添加一个 取消关注 按钮。在这个时候,我们可以加载 /users 页面,我们将有一个带有关注按钮的用户列表。然而,点击它们不会做任何事情。我们需要编写那些 followunfollow 函数(在 UserListItemView 类中)。

这两个函数几乎完全相同,如下面的代码所示:

follow: function (evt) {
  var thiz = this,
    f = new Follow({ userId: thiz.model.id });
  f.save().then(function (user) {
    USER.following = user.following;
    thiz.update();
  });
},
unfollow: function (evt) {
  var thiz = this,
    f = new Follow({ id: thiz.model.id });
  f.destroy().then(function (user) {
    USER.following = user.following;
    thiz.update();
  });
},

在这两种情况下,我们创建一个新的 Follow 模型实例。当目标是保存 following 数组时,我们设置 userId 属性;当目标是删除 following 数组时,我们设置 id 属性。在 follow 函数的情况下,我们保存该模型;在服务器端,这将把用户添加到当前用户的 following 数组中。在 unfollow 函数的情况下,我们删除模型;在服务器端,这将从 following 数组中移除用户。同样,在这两种情况下,调用服务器的那个方法将返回一个延迟对象。我们将传递 then 方法,这是一个函数,它将使用来自服务器的 following 数组重置 USER 对象上的 following 数组。在这两种情况下,我们随后将调用 update 方法来修正按钮。

最后一步是创建 Follow 模型。实际上,我们在这里不需要使用 Backbone 模型;我们只需要两个 AJAX 请求。然而,我们可以通过使用模型类来巧妙地完成所有艰苦的工作。以下是为 Follow 模型编写的代码:

var Follow = Backbone.Model.extend({
  urlRoot: '/follow'
});

实际上就是这样。我们可以以两种方式使用这个类。如果我们给一个 Follow 实例一个 userId 属性,我们就可以调用 save 方法将其 POST 到 /follow。或者,如果我们创建一个带有 id 属性的 Follow 实例,我们可以调用 destroy 方法发送一个 DELETE 请求到 /follow/id

在服务器端,事情要复杂一些。首先,让我们看看 POST 路由:

app.post("/follow", function (req, res) {
  var id = parseInt(req.body.userId, 10);
  if (req.user.following.indexOf(id) === -1) {
    req.user.following.push(id);
    users.update({ id: req.user.id }, req.user, function (err, users) {
      res.json(safe(users[0]));
    });
  } else {
    res.json(safe(req.user));
  }
});

我们首先找出 ID 是否在用户的 following 列表中。如果不是,我们将将其推入数组,并更新数据库中的用户记录。然后,我们将发送回更新的用户记录。即使用户已经关注了所选用户,我们也会发送用户数据回。

DELETE 路由与 POST 路由类似:

app.delete("/follow/:id", function (req, res) {
  var id = parseInt(req.params.id, 10),
    index = req.user.following.indexOf(id);
  if (index !== -1) {
    req.user.following.splice(index, 1);
    users.update({ id: req.user.id }, req.user, function (err, users) {
      res.json(safe(users[0]));
    });
  } else {
    res.json(safe(req.user));
  }
});

如果当前用户正在关注此用户,我们将使用 JavaScript 的 splice 方法从数组中移除项目(此方法会修改数组,所以我们不会将其重新分配给 req.user.following)。然后,我们将更新数据库中的用户记录,并将更新的用户作为 JSON 发送回(我们发送 users[0] 因为 update 函数给 callback 函数一个数组,但在这个情况下,该数组应该只有一个记录)。

现在一切就绪,我们的 /users 路由将会有工作的关注/取消关注按钮。我们可以关注几个其他用户。

显示关注用户的照片

我们该如何处理关注的用户?我们想在主页上显示关注用户的照片。首先,在 server.js 文件中,我们需要能够获取当前用户关注的所有用户的照片。我们将为此编写一个单独的函数:

function followingPhotos(user, callback) {
  var allPhotos = [];
  user.following.forEach(function (f) {
    photos.find({ userId: f }, function (err, photos) {
      allPhotos = allPhotos.concat(photos);
    });
  });
  callback(allPhotos);
}

这看起来熟悉吗?它与我们在照片获取路由中的一些代码几乎完全相同,你知道的,那个正则表达式路由。由于我们将此代码放入了一个函数中,因此你可以替换该函数中相应的行,使其看起来如下所示:

} else if (getting === "following") {
 followingPhotos(req.user, function (allPhotos) {
 res.json(allPhotos);
 });
} else {

服务器端的最后一步是将这些关注者的照片发送到客户端,我们将在主页上显示它们。让我们用我们刚刚编写的函数包裹之前的 res.render 调用:

followingPhotos(req.user, function (followingPhotos) {
  photos.find({ userId: req.user.id }, function (photos) {
    res.render("index.ejs", {
      user: JSON.stringify(safe(req.user)),
      userPhotos: JSON.stringify(photos),
 followingPhotos: JSON.stringify(followingPhotos)
    });
  });
});

现在,在 index.ejs 文件中,我们可以将 followingPhotos 添加到我们的路由器选项对象中:

followingPhotos: new Photos(<%- followingPhotos %>)

我们必须在 AppRouter 类中使用该属性,所以请将以下行添加到 initialize 方法中:

this.followingPhotos = options.followingPhotos;

最后一步是将此代码用于路由器的 index 方法;现在该方法的整体结构应如下所示:

index: function () {
 var photosView = new PhotosView({ 
 collection: this.followingPhotos 
 });
  this.main.html(this.navView.render().el);
 this.main.append(photosView.render().el);
},

现在,如果你访问主页,你将能够看到你关注的用户的照片!太棒了!

摘要

在本章中,我们已经覆盖了很多内容,所以在继续之前,让我们回顾一下我们的步骤。

我希望你们从本章中带走的最重要的事情之一是数据是如何从服务器发送到客户端的。在这里,我们使用了两种不同的方法:首先,我们使用服务器端模板将它们放入 HTML 响应中,并作为响应的一部分发送。其次,我们使用客户端的 fetch 命令,为这些数据创建一个完全独立的 HTTP 请求。第一种方法的优势是,单个“数据块”没有自己的 HTTP 头;而且,由于它们是初始请求的一部分,用户在使用应用程序时永远不会等待数据。第二种方法的优势是我们永远不会从服务器加载比所需更多的数据;当我们需要时,请求它很容易。这对于像这样的应用程序尤为重要,因为单个用户可能随着时间的推移发布数百张照片,人们可能会关注很多用户;你不想一开始就加载所有数据。我们在这里混合使用,这样你就可以了解两种方法是如何工作的。

然而,你应该注意,这种混合使用实际上导致我们在大多数情况下加载了比必要更多的数据。这是因为我们在不使用这些数据的情况下(例如,在用户的列表页面上)也会加载当前用户的照片和他们关注的照片。我们实际上编写了所有的骨干代码,以便我们可以覆盖默认的锚点标签行为,并使用路由器的navigate方法导航整个应用程序;不需要页面刷新。这可能是一个很好的练习:尝试实现使用骨干导航而不是刷新页面的功能。如果你卡住了,请回顾上一章的代码。

除了数据加载技术之外,我们发现骨干模型和集合类实际上非常灵活,可以以“非传统”的方式使用。我希望你发现,当你从骨干中去除魔法,并确切了解它在做什么时,你可以更有效地使用它。这些想法在我们下一章构建实时数据仪表板时将非常有用。

第三章:构建实时数据仪表板

这将是一个有趣的章节。到目前为止,我们已经创建了两个相对简单的应用程序。在两种情况下,我们主要是在浏览器中创建和读取数据。虽然这些都是浏览器端的内容,但它相当静态。这次,我们将做一些更有趣的事情;我们将构建一个跟踪事件的表格。然而,有趣的是,我们将构建一个表格,它将根据其他打开我们应用程序的浏览器中的更改自动更新。

以下是一些章节的剧透:

  • 我们将探讨通过多个文件进行更好的代码组织

  • 我们将编写代码来更新和删除模型实例

  • 我们将构建一个轮询服务器以保持其集合更新的应用程序

我们将再次从项目模板开始;然而,在我们的最后两个项目中,我们需要做一些修改。你可能已经注意到我们的 app.js 文件变得有点长;这使得在组件之间导航变得困难,并且总的来说,使我们的代码保持整洁和可管理变得困难。所以在这个项目中,我们将把 Backbone 代码拆分成多个文件。我们将把模型和集合放在 models.js 中,视图放在 views.js 中,路由器放在 router.js 中。你可以继续在 public 文件夹中创建这些文件(它们现在将是空的);同时,删除 app.js。然后,在 index.ejs 文件中,我们需要将 app.js 的脚本标签替换为那些新文件的脚本标签,如下所示:

<script src="img/models.js"></script>
<script src="img/views.js"></script>
<script src="img/router.js"></script>

规划我们的应用程序

在前面的章节中,我们立即开始编写代码。然而,在现实世界中,你不会从一开始就有我来告诉你该写什么。你需要自己规划你的应用程序。所以让我们花点时间来做这件事。

我们想要构建一个应用程序,可以显示过去和即将发生的活动列表。一个活动将有一个标题、一个描述和一个发生日期。这种类型的数据可以很好地在表格中显示。我们需要能够创建新事件,但我们还希望能够编辑和删除现有事件。我们还希望定期轮询服务器以获取事件集合的更改,以便所有连接的客户端都能保持最新。

由于这是一个相当基础的应用,所以这就足够了。现在我们已经明确了它需要做什么,我们可以开始从 Backbone 组件的角度来思考。显然,我们需要一个 Event 模型和 Events 集合。表格将是一个 EventsView 实例,每一行将是一个 EventView 实例。我们需要一个用于创建新事件的表单,比如 CreateEventView 类,还需要另一个用于编辑现有事件的表单,比如 EditEventView 类。我们不需要一个用于删除事件的完整视图;我们只需要一个按钮,可能是在 EventView 类中。

关于路由呢?整个表格可以在主页路由/上显示。创建表单可以在/create上,而编辑表单可以在/edit/<id>上。

就这些了!这并不真正代表一个实际应用的规划过程,但它应该让你意识到构建应用不仅仅是编写代码。你可以遵循一些智能流程来简化这个过程。如果你是相对编程新手,你应该了解一下敏捷开发或测试驱动开发等主题。当你刚开始时,这些想法可能会让你觉得项目比必要的花费更多时间,但请相信我,它们会让构建和维护大型项目变得更加简单。

设置预编译模板

让我们先谈谈我们用于视图类的模板。在前一章中,我们把模板源文本直接放在index.ejs文件中的脚本标签内。这次我们要做一些不同的事情。我们将预编译我们的模板。想想模板的时间线;它从脚本标签中的文本开始。我们一直在获取这个文本,并将其传递给_.template函数,该函数将文本编译成一个template函数,并将其返回给我们。然后,我们将数据传递给这个函数,并得到带有数据插值的 HTML。所有这些都必须在我们能够向用户显示任何内容之前完成。

我们想要做的是从这个过程中省略几个步骤。我们希望将template函数发送到浏览器,而不是发送模板文本并让浏览器编译它。为了做到这一点,我们需要将模板作为我们开发过程的一部分进行编译。

做这件事最简单的方法是使用 Grunt,一个方便的构建工具。首先,我们需要安装它,使用以下命令:

npm install grunt --save-dev
npm install grunt-contrib-jst --save-dev

我们在这里不会深入学习 Grunt。如果你不熟悉这个库,网上有很多很好的资源可以学习。从主页gruntjs.com/开始。

我们使用 npm 安装 Grunt 和JavaScript 模板JST)Grunt 插件。JST 将为我们进行编译。

接下来,我们需要一个Gruntfile.js文件,该文件将配置这个插件。将Gruntfile.js放在我们的项目目录的根目录下。在这个文件中,从以下代码开始:

module.exports = function (grunt) {
  grunt.initConfig({});

  grunt.loadNpmTasks('grunt-contrib-jst');
  grunt.registerTask('default', ['jst']);
};

你可能还记得,在前一章中,我们使用了exports对象从我们的signin.js模块导出函数。我们也可以完全覆盖那个exports对象;然而,当我们这样做的时候,我们必须使用它的全名,即module.exports。我们将一个函数分配给这个对象,该函数接受一个grunt对象作为参数。然后,在这个函数内部,我们为我们的项目配置 Grunt。

我们首先调用 initConfig 方法,它配置所有插件。之后,我们使用以下代码将插件注册到 Grunt 中;最后,我们可以注册一个任务。我们创建了一个默认任务,当我们在命令行上调用 grunt 时将运行这个任务。我们只是简单地告诉它运行 jst 任务。

现在,让我们回到之前的 initConfig 方法调用,使用以下代码:

grunt.initConfig({
  jst: {
    templates: {
      options:  {
        templateSettings: {
          interpolate : /\{\{(.+?)\}\}/g
        },
        processName: function (filename) {
          return filename.split('/')[1].split('.')[0];
        }
      },
      files: {
        "public/templates.js": ["templates/*.html"]
      }
    }
  }
});

我们从一个 jst 属性开始,因为那是我们配置的任务的名称。在里面,我们创建一个目标,这是一个为我们任务和想要执行任务(带有这些选项)的文件集的选项。我们称这个目标为 templates。第一个选项是 templateSettings 对象,我们在前几章中已经使用过;它允许我们使用花括号分隔符语法。我们设置的第二个选项是一个函数,它将为模板命名。我们的模板将是 templates 文件夹中的 HTML 文件,所以默认情况下它们的名称将是它们的文件路径;比如 templates/event.htmlprocessName 函数将把它转换成 event。这个名字是我们从视图代码中引用它们的方式。

files 中,我们选择要处理的文件。在这里,我们说的是所有匹配字符串 templates/*.html 的模板文件将被编译成 public/templates.js

这可能看起来设置起来有点多,但现在我们只需要在命令行上运行 grunt 来获取预编译的模板。我们将在创建视图时测试这一点。

创建模型

现在我们已经设置了模板创建过程,让我们开始项目代码。像之前一样,我们从模型开始。这些将放在 models.js 中:

var Event = Backbone.Model.extend({});
var Events = Backbone.Collection.extend({
  model: Event,
  url: '/events'
});

目前就这样吧。我们稍后会回来做一些有趣的变化。

server.js 文件中,我们将为 Events 类中刚刚定义的路由创建路由函数。在此之前,我们需要数据库。我们创建它的方法如下:

var db  = new Bourne("db/events.json");

这次,我将数据库 JSON 文件放在了一个单独的文件夹中;如果你要这样做,请确保你创建了 db 文件夹。

但现在,数据库已经就位,我们可以创建 GET 路由。这个路由将简单地发送我们数据库中的所有记录回浏览器:

app.get("/events", function (req, res) {
  db.find(function (err, events) {
    res.json(events);
  });
});

POST 路由是新事件对象数据发送的地方。我们将属性收集到一个对象中并插入它;我们的回调函数只需将更新后的记录发送回浏览器。下面是这个样子:

app.post("/events", function (req, res) {
  var attrs = {
    title: req.body.title,
    details: req.body.details,
    date: req.body.date,
    createdOn: new Date()
  };

  db.insert(attrs, function (err, event) {
    res.json(event);
  });
});

再多一个服务器方法,那就是根路由:

app.get('/*', function (req, res) {
  db.find(function (err, events) {
    res.render("index.ejs", { 
      events: JSON.stringify(events) 
    });
  });
});

这个路由与我们在前几章中的 get-all 路由非常相似。它将渲染我们的 index.ejs 模板,并将所有事件记录发送到浏览器。

创建控件

让我们从一些控件开始。正如我们之前决定的,我们需要能够打开一个表单来创建新事件,所以让我们在页面顶部的控制栏上放一个按钮。

我们可以从模板开始。如果你还没有开始,请在项目的根目录下创建一个名为templates的目录。在里面,创建一个名为controls.html的文件,并将以下代码放入其中:

<li><a href="/create"> Create Event </a></li>

我们只有一个控制项,实际上它不需要是一个模板,但这给了我们以后轻松扩展它的能力。我们实际上可以通过在命令行上运行grunt来测试预编译。当你这样做的时候,你应该会收到一条消息说文件"public/templates.js"已创建。太好了!如果你想查看该文件的内容,可以这样做:

this["JST"] = this["JST"] || {};

this["JST"]["controls"] = function(obj) {
obj || (obj = {});
var __t, __p = '', __e = _.escape;
with (obj) {
__p += '<li><a href="/create"> Create Event </a></li>';

}
return __p
};

它相当混乱,但对我们来说足够了。重要的是要注意,我们现在可以通过JST.controls引用这个模板函数。这里的最后一步是将这个脚本包含在我们的index.ejs文件中,就在views.js中的脚本标签之上,如下所示:

<script src="img/templates.js"></script>

说到views.js,我们已经准备好打开它并从这个模板开始处理视图。我们将称之为ControlsView

var ControlsView = Backbone.View.extend({
  tagName: "ul",
  className: "nav nav-pills",
  template: JST.controls,
  initialize: function (options) {
    this.nav = options.nav;
  },
  events: {
    'click a[href="/create"]': 'create'
  },
  render: function () {
    this.el.innerHTML = this.template();
    return this;
  },
  create: function (evt) {
    evt.preventDefault();
    this.nav("create", { trigger: true });
  }
});

由于我们的模板是一组列表项(好吧,一个单独的列表项),所以使用<ul>元素来表示这个视图是有意义的。然后,注意我们是通过JST.controlstemplates.js文件中获取模板的;方便,不是吗?render函数非常基础。它只是渲染我们的模板;我们甚至不需要传递任何数据。更有趣的是,我们正在监听创建事件链接上的点击事件。当发生这种情况时,我们将阻止默认行为,即从服务器请求/create路由,而我们将将其发送到我们的 Backbone 路由器。

我们在第一章 构建一个简单的博客 中做了这件事,但我们采取了不同的方法。当时,我们在视图中使用了实际的路由对象;我们只是期望它作为一个全局变量可用。那是个坏主意。这次,我们在两个方面进行了改进。首先,当我们创建这个视图时,我们期望在options对象中接收到路由作为一个属性。你可以在initialize函数中看到,我们正在将this.navoptions.nav赋值。第二个改进是,这实际上并不是整个路由对象;它只是路由的navigate方法。这样,我们可以给选定的视图赋予改变路由的能力,同时它们仍然不能干扰路由的其他部分。

注意

这是一个名为依赖注入的软件设计模式。基本上,路由器的navigate方法是我们注入到ControlsView类中的一个依赖项。这允许我们将无关的代码分开,这可以使将来更新此代码的过程更加简单。例如,如果我们需要更改应用程序中路由的方式,我们只需要将新的或更新的依赖项注入到这个类中,并且希望在这个类中不进行太多更改。依赖注入是遵循依赖倒置原则的一种方式,这是五个 SOLID 设计原则之一。要了解更多信息,请从维基百科页面en.wikipedia.org/wiki/SOLID开始阅读。

最后,你可能想知道className属性。正如你可能怀疑的那样,这个属性设置了我们的元素的class属性。但这些类是从哪里来的呢?嗯,在这个应用程序中,我们将使用 Twitter 的 Bootstrap 库,这些类创建了一个基本的导航/控制栏。

包含 Bootstrap

当然,为了使这可行,我们必须将这个库添加到我们的项目中。您可以访问getbootstrap.com,点击下载 Bootstrap。这里有很多文件,但我们不需要全部。在我们的项目public目录中,创建一个名为css的文件夹,并将bootstrap.min.css文件复制到该文件夹中。我们还需要bootstrap.min.js,我们将将其放在public文件夹中。Bootstrap 还附带 GLYPHICONS 字体(glyphicons.com/),因此您需要在public目录中创建一个fonts文件夹,并将字体文件从 Bootstrap 的font目录复制过来。将这些组件放在一起,我们就可以在index.ejs文件的头部添加一个到stylesheet标签的链接:

<link rel="stylesheet"  href="/css/bootstrap.min.css" />

然后,在底部,我们链接到 Bootstrap 的 JavaScript 部分:

<script src="img/bootstrap.min.js"></script>

注意

如果你不太熟悉GLYPHICONS,它是一组你可以用于你的 Web 应用程序中的图标的符号。通常,你必须购买许可证,但其中一些是免费提供给 Bootstrap 用户的。

启动路由器

现在,为了渲染我们的控件,我们需要开始构建我们的路由器。我们已经创建了一个router.js文件,所以让我们打开它,如下所示:

var AppRouter = Backbone.Router.extend({
  initialize: function (options) {
    this.main = options.main;
    this.events = options.events;
    this.nav = this.navigate.bind(this);
  },
  routes: {
    '': 'index'
  },
  index: function () {
    var cv = new ControlsView({
      nav: this.nav
    });
    this.main.html(cv.render().el);
  }
});

从我们的initialize函数中,我们可以看到我们期望从我们的options对象中获取主元素和Events集合作为属性。我们还在创建一个nav属性;这是我们在ControlsView中看到的nav方法。重要的是要意识到我们不能只是发送this.navigate;我们需要确保这个函数绑定到路由器对象上,我们通过它的bind方法来实现。当我们以这种方式绑定一个函数时,我们正在创建一个函数的副本,其this(在函数副本内部)的值是我们传递给bind方法的参数对象;所以无论我们在哪里调用存储在this.nav中的函数,this的值都将保持一致。要了解更多关于 JavaScript 中this的信息,JavaScript Garden 是一个很好的资源,可在bonsaiden.github.io/JavaScript-Garden/#function.this找到。

我们现在的索引路由非常简单。我们只是在渲染我们的控件。这是一个开始!现在,在index.ejs中,我们可以按照以下方式实例化路由器:

var r = new AppRouter({
  main: $("#main"),
  events: new Events(<%- events %>)
});
Backbone.history.start({ pushState: true });

现在,我们可以启动服务器(npm start)并加载页面。这应该看起来像以下截图所示:

启动路由

既然我们现在有一个按钮,让它工作起来是有意义的。目前,当我们点击按钮时,我们的路由会变为/create;但除此之外没有其他变化,因为我们还没有创建那个路由。所以按照以下方式将这个路由添加到我们的路由器的路由对象中:

'create': 'create'

然后将following函数也添加到路由器中:

create: function () {
  var cv = new CreateEventView({
    collection: this.events,
    nav: this.nav
  });
  this.main.prepend(cv.render().el);
}

我们还没有创建CreateEventView视图类,但你可以看到我们将传递我们的活动集合和nav方法。我们将渲染它并将其附加到主元素上。

注意

你可能还记得,在前一章中,当我们没有渲染时,我们没有将集合属性命名为collection,这样其他阅读我们代码的开发者就不会混淆将集合赋予视图的目的。然而,在这种情况下,我们将其命名为collection,因为events属性已经被 Backbone 用于分配 DOM 事件。

构建 CreateEventView 类

因此,让我们创建CreateEventView视图类。现在,让我们来点变化;既然我们在页面上有 Bootstrap,为什么不使用它的模态组件来显示我们的表单呢?

要做到这一点,我们首先创建我们的模板。创建templates/createEvent.html并将以下内容放入该文件中:

<div class="modal-dialog">
<div class="modal-content">
  <div class="modal-header">
    <button class="close">&times;</button>
    <h4 class="modal-title"> Create New Event </h4>
  </div>
  <div class="modal-body">
    <form>
      <label>Title</label>
      <input type="text" class="form-control" id="title" />
      <label>Details</label>
      <textarea id="details"  class="form-control"></textarea>
      <label>Date</label>
      <input type="datetime-local" class="form-control" id="date" />    
    </form>
  </div>
  <div class="modal-footer">
    <a href="#" class="create btn btn-primary"> Create Event </a>
  </div>
</div>
</div>

这里有大量的 HTML 代码,但你能看到中间那里的表单,对吧?实际上,Bootstrap 模型需要另一个包装<div>,但视图类会提供这个。这是视图的第一个部分:

var CreateEventView = Backbone.View.extend({
  className: "modal fade",
  template: JST.createEvent,
  initialize: function (options) {
    this.nav = options.nav;
  },
  render: function (model) {
    this.el.innerHTML = this.template();
    return this;
  }
});

这是我们开始的地方,这是一个非常基本的视图。这里最重要的要注意的是className属性;这些类样式化模态窗口。然而,在这个时候,如果我们编译我们的模板并点击我们的按钮,我们的模态窗口不会出现。这是怎么回事?如果你在浏览器的开发者工具中检查页面,你会看到视图的 HTML 被添加到页面中,但它不可见。你可以在以下屏幕截图中看到这一点:

构建 CreateEventView 类

问题是我们需要使用 Bootstrap 的 jQuery 模态插件,这是我们之前加载的(bootstrap.min.js)。我们可以使用这个插件来显示和隐藏模态。要显示表单,只需将以下代码行添加到我们的render方法中,在调用template函数之后即可:

this.$el.modal("show"); 

我们获取“jQuery 化”的元素并调用modal方法,传递显示模态窗口的命令。

下一步是向这个类中添加几个事件。有两个按钮需要考虑:创建事件按钮(用于创建新的Event模型)和x按钮(用于关闭模态窗口,不创建新模型):

events: {
  "click .close": "close",
  "click .create": "create"
},

close方法将非常简单。请看以下代码:

close: function (evt) {
  evt.preventDefault();
  this.$el.modal("hide");
},

我们阻止按钮的默认操作,然后隐藏模态窗口。

那么,接下来是编写create方法?我们使用以下代码来编写它:

create: function (evt) {
  evt.preventDefault();
  var e = {
    title: this.$("#title").val(),
    details: this.$("#details").val(),
    date: this.$("#date").val()
  };
  this.$el.modal("hide");
  this.collection.create(e, { wait: true });
  return false;
}

我们收集我们新事件对象的所有属性,并使用集合的create方法将数据发送到服务器。我们传递wait选项,因为我们很快将会有视图监听新事件的创建。这样,视图不会在事件对象成功保存到服务器之前被通知。

这个谜题还剩下一部分;当点击任意一个按钮时,模态窗口关闭,但它的 DOM 元素仍然存在。为了消除构成视图的元素,我们需要调用视图的remove方法。这个方法会消除元素并移除与这些元素连接的所有事件处理器。那么我们究竟应该在什么时候调用这个remove方法呢?嗯,当我们隐藏模态窗口时,它将淡出;我们需要在之后移除视图。方便的是,我们使用的 jQuery 插件在不同的点发出事件。我们可以监听hidden.bs.modal事件,该事件将在模态窗口的淡出序列完成后触发。

因此,在视图的initialize方法中,我们将使用以下代码监听该事件:

this.$el.on("hidden.bs.modal", this.hide.bind(this));

当它触发时,我们将在我们的视图中调用hide方法。这个方法看起来是这样的:

hide: function () {
  this.remove();
  this.nav('/');
},

我们调用视图的remove函数来消除 DOM 和事件;然后,我们使用nav方法将我们的用户送回主页。

现在,让我们暂停一下,思考一下用户可能会如何浏览我们的应用程序。他们可以从主页开始,点击创建活动按钮,这将带他们到/create路由。然而,它不会重新加载页面;它只是淡入模态框。当他们关闭模态窗口(无论是通过提交表单还是关闭表单)时,他们将被带回到主页路由;表单将淡出,控制栏仍然存在。然而,用户也可能直接访问/create。他们将获得表单,它将正常工作;然而,当他们关闭模态时,他们将被带回到主页路由,但页面将是空的。这是因为当我们移动回该路由时,我们没有触发路由器的index方法(没有{trigger: true})。为什么不触发那个方法呢?

我们没有触发它,因为这里有一个更好的方法;即使用户直接访问/create,我们也希望渲染控件(以及最终将出现在那里的表格)。这意味着在create路由方法中,我们需要检查是否已经调用了index。在我们的情况下,我们将通过检查导航的存在来做到这一点。在create方法中,在添加CreateEventView实例之前添加以下内容:

if ($("ul.nav").length === 0) {
  this.index();
}

现在,如果用户直接访问/create路由,如果它尚未渲染,路由器的index方法将被调用。

创建活动表

到目前为止,我们已经成功创建新的活动记录并将它们存储在我们的数据库中。下一步是显示活动表。我们将从EventsView类开始。

实际上,我们将从这个视图的模板开始。在templates/events.html中,我们将创建theadtbody元素,如下所示:

<thead>
  <tr>
    <th data-field="id">ID</th>
    <th data-field="title">Title</th>
    <th data-field="details">Details</th>
    <th data-field="date">Date</th>
    <th data-field="createdOn">Created On</th>
    <th> Actions </th>
  </tr>
</thead>
<tbody></tbody>

如您所见,我们的表格将显示我们的活动拥有的五个字段。我们还有一个用于操作的第六列:编辑和删除操作。我们为每个表格标题元素添加了一个数据属性,其名称与Event记录的属性名称匹配。我们将在稍后使用这些属性进行排序。您可以在命令行上运行grunt来编译这个模板函数。

现在,关于EventsView,我们可以使用以下代码来编译它:

var EventsView = Backbone.View.extend({
  tagName: "table",
  className: "table",
  template: JST.events,
  initialize: function (options) {
    this.nav = options.nav;
  },
  render: function () {
    this.el.innerHTML = this.template();
    this.renderRows();
    return this;
  },
  renderRows: function () {
    this.collection.forEach(this.addRow, this);
  },
  addRow: function (event) {
    this.$("tbody").append(new EventView({
      model: event,
      nav: this.nav
    }).render().el);
  }
});

我们首先将这个视图的元素制作成表格;我们还添加了 table 类以获取 Bootstrap 的表格样式。在 initialize 方法中,我们可以看到我们正在从 options 对象中获取 nav 方法,以便我们可以更改路由。在 render 中,我们渲染模板然后调用 renderRowsrenderRows 方法遍历我们 Events 集合中的每个项目并调用 addRow。你可能想知道为什么我们不直接在这个 render 方法中放置单行代码;这是因为我们稍后还需要它。那个 addRow 方法将接受一个事件对象作为属性,并渲染一个 EventView 实例,将其放置在模板中我们放置的 tbody 元素中。我们将回到这个视图来添加和调整东西,但现在让我们转到 EventView 类。

如我们之前所做的那样,我们将从模板开始。以下代码片段应该是 templates/event.html 的内容:

<td>{{id}}</td>
<td>{{title}}</td>
<td>{{details}}</td>
<td>{{date}}</td>
<td>{{createdOn}}</td>
<td>
  <button class="edit btn btn-inverse">
    <span class="glyphicon glyphicon-edit glyphicon-white"></span>
  </button>
  <button class="delete btn btn-danger">
    <span class="glyphicon glyphicon-trash"></span>
  </button>
</td>

模板的第一个部分很简单。我们只是在 <td> 元素中放置 Event 对象的属性。在最后一个 <td> 元素中,我们有两个按钮;我们使用 Bootstrap 的按钮和 Glyphicon 类来获取正确的样式。这些将是编辑和删除按钮。

以下代码片段是 EventView 类的开始:

var EventView = Backbone.View.extend({
  tagName: "tr",
  template: JST.event,
  initialize: function (options) {
    this.nav = options.nav;
  }
});

这与其他我们之前看到的观点类似。然而,render 方法将会稍微复杂一些。在此之前,我们需要添加另一个第三方库:Moment (momentjs.com/)。这个库是一个快速格式化日期的强大工具。从网站上下载脚本并将其添加到 index.ejs 文件中,在 views.js 文件之上任何位置,如下所示:

<script src="img/moment.min.js"></script>

在设置好这些之后,我们可以在 EventView 类中添加一个 render 方法,如下所示:

render: function () {
  var attrs = this.model.toJSON(),
      date = moment(attrs.date),
      diff = date.unix() - moment().unix();

  attrs.date = date.calendar();
  attrs.createdOn = moment(attrs.createdOn).fromNow();
  this.el.innerHTML = this.template(attrs);

  if (diff < 0) {
    this.el.className = "error";
  } else if (diff < 172800) { // next 2 days
    this.el.className = "warning";
  } else if (diff < 604800) { // next 7 days
    this.el.className = "info";
  }

  return this;
},

这无疑是迄今为止我们见过的最复杂的 render 方法。我们首先定义几个变量。首先获取我们模型的属性。然后,我们创建一个 moment 对象,并将我们模型的 date 属性传递给它。一个 moment 对象封装了一个日期,并为我们提供了访问几个有用的日期相关方法。最后,我们使用 Moment 的 unix 方法(返回自 Unix 纪元以来的时间,以秒为单位)来获取这个事件发生的时间和现在之间的时间差。

接下来,我们使用我们刚刚创建的 date 对象来覆盖属性对象中的默认日期值,使其更易于阅读。我们使用 Moment 的 calendar 方法来提供一个日期字符串,例如 星期一晚上 6:30(或对于更远的日期,例如 10/30/2014)。然后,我们将 createdOn 属性替换为不同的日期字符串。使用 Moment 的 fromNow 方法,我们得到一个字符串,例如 6 小时前。然后,我们将更新后的属性对象传递给 this.template 函数进行渲染。

渲染后,我们进行最后的调整。Bootstrap 有几个方便的类用于给表格行着色,所以我们将根据事件的时间来不同地着色一行。如果diff的值小于 0(这意味着事件在表格渲染之前开始),我们将添加danger类,结果会变成红色的一行。如果事件在接下来的两天内发生(diff < 172800),我们将使用warning(黄色的一行)。如果事件在下下周内(diff < 604800),success类会给我们一个绿色的一行。

让我们回到路由器,让EventsViewEventView类发挥作用。以下是新的index方法:

index: function () {
  var cv = new ControlsView({
    nav: this.nav
  }),
  av = new EventsView({
    collection: this.events,
    nav: this.nav
  });
  this.main.html(cv.render().el);
  this.main.append(av.render().el);
},

这样一来,我们可以重新加载主页并看到表格。如果你添加一些事件,你应该会看到以下截图所示的内容:

创建事件表

目前情况看起来相当不错,你也会同意这一点。然而,在我们完成这个应用程序之前,还有很多事情要做。让我们先从让那个删除按钮真正删除一条记录开始。

删除记录

由于按钮已经就位,我们只需将其连接起来。在EventView中,让我们添加如下的事件监听器:

events: {
  "click .delete" : "destroy"
},

你知道接下来是什么。我们需要在EventView类中创建destroy方法。可以像以下这样做:

destroy: function (evt) {
  evt.preventDefault();
  this.model.destroy();
  this.remove();
},
remove: function () {
  this.$el.fadeOut(Backbone.View.prototype.remove.bind(this));
  return false;
}

destroy方法将调用模型的destroy方法,然后调用这个视图的remove方法。通常情况下,这就足够了,但我们还想添加更多。我们希望表格行淡出,然后移除 DOM 元素。因此,我们正在覆盖默认的 Backbone View remove方法。我们将使用 jQuery 来淡出元素。jQuery 的fadeOut方法接受一个回调,一个在淡出完成后将被调用的函数。我们可以从Backbone.View.prototype对象中获取通常的 Backbone View remove方法。当然,我们必须在正确的视图实例上调用它,通过将方法绑定到当前视图,this

我们之前没有在视图的destroy方法中调用 Backbone 模型的destroy方法,就像我们现在这样做。这个方法向服务器发送一个 DELETE 请求,到/events/<id>路由。我们需要在我们的server.js文件中创建一个方法,如下所示:

app.delete("/events/:id", function (req, res) {
  db.delete({ id: parseInt(req.params.id, 10) }, function () {
    res.json({});
  });
});

这相当基础;我们的数据库有一个delete方法,所以我们调用它,传递一个包含从路由中获取的id的查询对象。我们返回的内容无关紧要,所以我们将返回一个空对象。

有了这段代码,你现在可以点击任何事件表格行中的删除按钮,该行将淡出。刷新页面,你会发现它已经永久消失。

编辑事件记录

下一步是允许用户编辑他们的事件记录。连接我们的编辑按钮将很简单。首先,我们在EventViewevents对象中监听点击,如下所示:

"click .edit": "edit"

其次,我们导航到该事件的编辑路由:

edit: function (evt) {
  evt.preventDefault();
  this.nav("/edit/" + this.model.get("id"), { trigger: true });
}

我们希望我们的编辑路由表现得就像创建路由一样。如果用户点击编辑按钮,一个模态框会淡入,允许编辑事件记录。但他们也应该能够直接访问编辑路由,表格将在模态框下加载。这意味着我们的路由器的edit方法应该非常类似于其create方法。

首先,我们将路由添加到路由器的route对象中,如下所示:

'edit/:id': 'edit'

然后,edit方法本身使用以下代码:

edit: function (id) {
  var ev = new EditEventView({
    model: this.events.get(parseInt(id, 10)),
    nav: this.nav
  });

  if ($("ul.nav").length === 0) {
    this.index();
  }

  this.main.prepend(ev.render().el);
}

我们还没有创建EditEventView,但如果你回顾一下create方法,你会看到这两个方法是多么相似。这就需要我们进行一些重构,就像我们在以下代码片段中所做的那样:

create: function () {
  var cv = new CreateEventView({
    collection: this.events,
    nav: this.nav
  });
  this.modal(cv);
},
edit: function (id) {
  var ev = new EditEventView({
    model: this.events.get(parseInt(id, 10)),
    nav: this.nav
  });
  this.modal(ev);
},
modal: function (view) {
  if ($("ul.nav").length === 0) {
    this.index();
  }
  this.main.prepend(view.render().el);
}

我们已经将公共代码提取到一个modal方法中。然后,在createedit操作中,我们将要渲染的视图传递给该方法。

接下来,我们需要创建EditEventView类。如果你稍作思考,你会意识到,由于我们希望它表现得像CreateEventView类一样,如果能尽可能多地重用该视图的代码就太好了。实际上,create视图和edit视图之间的主要区别在于,在edit视图中,记录的当前值已经存在于表单输入元素中。我们还将希望表单标题和按钮文本得到适当的更改。

我们可以从templates下的createEvent.html文件开始。我们将准备它以接收我们需要传递给它的值,如下所示:

<div class="modal-dialog">
<div class="modal-content">
  <div class="modal-header">
    <button class="close">&times;</button>
    <h4 class="modal-title"> {{ heading }} </h4>
  </div>
  <div class="modal-body">
    <form>
      <label>Title</label>
      <input type="text" class="form-control" id="title" value="{{title}}" />
      <label>Details</label>
      <textarea id="details"  class="form-control">{{details}}</textarea>
      <label>Date</label>
      <input type="datetime-local" class="form-control" id="date" value="{{date}}" />    
    </form>
  </div>
  <div class="modal-footer">
    <a href="#" class="modify btn btn-primary"> {{btnText}} </a>
  </div>
</div>
</div>

注意,我们不仅期望在表单输入元素中有值,还期望有标题和按钮文本。而且,由于这个模板将由CreateEventViewEditEventView共同使用,让我们将其重命名为templates/modifyEvent.html

不要忘记重新编译模板(使用命令行中的grunt)。

由于我们希望EditEventView类具有的大部分行为与我们为CreateEventView类创建的行为相同,让我们尽可能多地将其提取到ModifyEventView类中。以下是我们得出的代码:

var ModifyEventView = Backbone.View.extend({
  className: "modal fade",
  template: JST.modifyEvent,
  events: {
    "click .close": "close",
    "click .modify": "modify"
  },
  initialize: function (options) {
    this.nav = options.nav;
    this.$el.on("hidden.bs.modal", this.hide.bind(this));
  },
  hide: function () {
    this.remove();
    this.nav('/');
  },
  close: function (evt) {
    evt.preventDefault();
    this.$el.modal("hide");
  },
  render: function (model) {
    var data = this.model.toJSON();
    data.heading = this.heading;
    data.btnText = this.btnText;
    this.el.innerHTML = this.template(data);
    this.$el.modal("show");  
    return this;
  },
  modify: function (evt) {
    evt.preventDefault();
    var a = {
      title: this.$("#title").val(),
      details: this.$("#details").val(),
      date: this.$("#date").val()
    };
    this.$el.modal("hide");
    this.save(a);
    return false;
  }
});

CreateEventView类相比,这个类有几个关键的区别。首先,注意在render方法中,我们正在将headingbtnText添加到我们放入模板的数据中。我们稍后会了解到这些值从何而来。另一件事是,在modify方法中,我们调用this.save而不是this.collection.create。这是创建记录和更新记录之间的一大区别;我们保存它们的方式。因此,我们需要为每个创建一个save方法,说明如何确切地执行保存操作。

现在,如果这个视图类作为父类或超类,那么子视图呢?嗯,EditEventView非常简单,如下所示:

var EditEventView = ModifyEventView.extend({
  heading: "Edit Event",
  btnText: "Update",
  save: function (e) {
    this.model.save(e);
  }
});

首先,注意我们是如何创建这个视图的:ModifyEventView.extend。Backbone 的类创建功能允许我们以扩展Backbone.View相同的方式扩展我们自己的视图。当然,我们可以访问所有方法和属性,以及我们添加的任何内容。这就是我们添加headingbtnText的地方,这些是我们render方法使用的。这也是save方法出现的地方。在这个视图中,我们只是使用模型的save方法将更新后的属性发送回服务器。我们将在稍后创建一个服务器方法来完成这个任务。但首先,我们需要使用以下代码更新我们的CreateEventView以使用ModifyEventView

var CreateEventView = ModifyEventView.extend({
  heading: "Create New Event",
  btnText: "Create",
  initialize: function (options) {
    ModifyEventView.prototype.initialize.call(this, options);
    this.model = new Event();
  },
  save: function (e) {
    this.collection.create(e, { wait: true });
  }
});

除了我们熟悉的headingbtnTextsave部分之外,我们还重写了initialize方法。我们在那里确实调用了父类的initialize方法,但还有更多。如果你稍微思考一下我们的模板,你就会明白为什么。我们的模板期望接收属性来填充表单输入,但CreateEventView类没有模型可以提供;它的任务是创建模型!所以我们将创建一个具有空白属性的可用Event对象,这样实际上就不会填充任何值,但我们不会从模板函数中收到任何错误。

然而,你可能意识到一个空白的Event对象实际上没有任何属性。我们需要做的是添加默认值,这样Event对象将具有空属性以传递给模板。在models.js文件中,插入以下代码:

var Event = Backbone.Model.extend({
  defaults: {
    title: "",
    details: "",
    date: ""
  }
});

这是我们的更新后的模型类。非常简单,但它解决了我们的视图问题。

不要忘记,我们在EditEventView类中调用this.model.save。这将通过向/events/<id>发送 PUT 请求将更新后的属性发送到服务器。在server.js中,这是我们处理这些 PUT 请求的方式:

app.put("/events/:id", function (req, res) {
  var e = {
    title: req.body.title,
    details: req.body.details,
    date: req.body.date
  };

  db.update({ id: parseInt(req.params.id, 10) }, e, 
    function (err, e) {
      res.json(e);
    });
});

我们将把属性汇总成一个对象,并将其传递给数据库的update方法。为了找到要更新的正确记录,我们将传递一个包含记录 ID 的查询对象。然后,我们将以 JSON 格式将更新后的记录返回给浏览器以完成事务。

让它变得实时

到目前为止,我们有一个相当不错的应用程序。我们可以创建显示在我们表格中的事件。我们还可以更新和删除这些事件。然而,如果多个人使用相同的事件表,我们可能希望定期轮询服务器以获取数据集的更改。这样,有人可以保持页面打开,就像某种仪表板一样,它将始终保持最新。

这个功能听起来可能很难实现,但实际上比你想象的要容易得多。第一步是前往model.js文件,并扩展我们的Events集合,通过向其中添加以下方法:

initialize: function (models, options) {
  this.wait = (options && options.wait) || 10000; 
},
refresh: function () {
  this.fetch();
  setTimeout(this.refresh.bind(this), this.wait);
}

refresh 方法在这里很重要。主要地,我们调用集合的 fetch 方法。这将从服务器获取模型集并将其设置为集合的模型。然而,它以智能的方式完成。如果有任何新模型,它将触发一个 add 事件;如果有任何更新的模型,它将触发一个 change 事件;如果有任何模型被删除,它将触发一个 remove 事件。然后,它将保持任何未更改的模型不变。然后,我们设置一个超时,在几秒钟后再次调用此方法。

我们还添加了一个 initialize 方法,允许选择在获取之间等待多少秒。如果 options 对象有一个 wait 属性,我们将使用它。否则,它是 10 秒。

现在,在 EventsView 类的 initialize 方法中,我们只需像这样调用集合的 refresh 方法:

this.collection.refresh();

获取更新就这么简单。现在,我们需要监听事件并做正确的事情。

从更新中添加到集合中的任何新记录都会在集合上触发一个 add 事件。因此,在 EventsView 中,我们应该监听这个事件。我们还需要将其添加到其 initialize 方法中。所以,这里是整个 initialize 方法,包括这两个更新:

initialize: function (options) {
  this.nav = options.nav;
  this.listenTo(this.collection, 'add', this.addRow);
  this.collection.refresh();
},

之前,我们使用了 on 方法来监听事件。然而,listenTo 是一个替代形式。它基本上做的是同样的事情,但它允许监听器——在这个例子中是视图——跟踪它正在监听的事件。这样,如果我们删除视图对象,remove 方法可以断开这些事件并节省浏览器内存。在这里,我们告诉我们的视图监听集合上的 add 事件;当这发生时,我们调用 addRow。正如我们所知,这将向我们的表格添加一个 EventView 类。

这样就处理了通过 AJAX 更新的添加。removechange 事件将在事件模型上调用。这意味着我们在 EventView 中监听变化。在其 initialize 方法中,我们将监听这些事件。以下是整个新的 EventView initialize 方法:

initialize: function (options) {
  this.nav = options.nav;
  this.listenTo(this.model, "remove", this.remove);
  this.listenTo(this.model, "change", this.render);
},

我们已经创建了 renderremove 方法,所以我们只需要这些。

就这样!现在,你可以在多个浏览器窗口中打开 http://localhost:3000。在其中一个浏览器窗口中添加一个事件;你应在不到 10 秒内看到它在另一个窗口中显示出来。你可以编辑或删除一个事件,你将在另一个窗口中看到变化。很酷,对吧?而且这只需要极少的代码。

排序事件

我们要为我们的应用程序添加一个新功能。我们有一个事件表,为什么不添加按我们点击的任何字段排序行的功能呢?

首先,我们需要对集合中的模型进行排序。你已经知道,当我们创建一个集合对象时,我们传递一个模型对象的数组。我们可以在创建集合时通过向 Events 类添加一个比较器来让集合对这些模型进行排序。在 models.js 中,向 Events 集合类添加以下行:

comparator: 'date',

添加此行将按 date 字段对收集中的模型进行排序。这将按最初添加到收集中和随后添加的任何模型进行排序。然而,它不会在其中一个模型被更改后重新排序模型。这很重要,因为我们希望在编辑事件记录时,如果需要,我们的表格行可以重新排序。我们可以相当容易地实现这一点。然而,当我们编辑模型时,它将发出 change 事件,该事件会冒泡到收集中。我们可以在 Events 收集的 initialize 方法中监听它,如下所示:

this.listenTo(this, 'change', this.sort);

这就像在收集中监听 change 事件一样简单;当发生这种情况时,我们手动在收集中调用 sort 方法。

然而,在收集过程中还有更多的事情要做。默认的 sort 方法只会按一个方向排序。我们希望能够点击一个标题两次并得到反向排序。因此,我们必须自己编写一个 reverse 方法。在 Events 收集中插入以下代码:

reverse: function (options) {
  this.sort({ silent: true });
  this.models = this.models.reverse();
  this.trigger('sort', this, options);
}

首先,我们调用 sort;我们传递 silent 选项,以便不会触发 sort 事件。然后,我们获取内部的 models 属性;这是在收集中持有模型实例的数组。我们调用原生的数组方法 reverse 来反转数组中模型的顺序。我们将这个反转后的数组重新赋值给 models 属性。最后,我们触发 sort 事件;这是通常由 sort 方法触发的事件,但我们已经静音了它,以便我们可以反转数组。我们将收集体对象和传递给 reverse 的任何选项作为 sort 事件的参数。

如果我们的收集体已排序,当我们通过 EventsView 类遍历它以将记录添加到表格中时,它们将以正确的顺序添加。但我们要能够点击表格标题并按点击的标题对行进行排序。因此,下一步是在我们的 EventsView 类中监听点击事件。向该类添加以下代码:

events: {
  'click th[data-field]': 'sort'
},

在这里,我们监听对任何具有我们放入模板中的 data-field 属性的表格标题的点击。因此,让我们在 EventsView 中按如下方式编写这个 sort 方法:

sort: function (evt) {
  var target = evt.currentTarget,
      c = this.collection;

  c.comparator = target.getAttribute("data-field");

  if (target.getAttribute("data-direction") === "asc") {
    c.reverse();
    this.fixSortIcons(target, "desc");
  } else {
    c.sort();
    this.fixSortIcons(target, "asc");
  }
}

我们首先获取被点击的元素(target);我们还为收集体创建了一个更短的变量,只是因为我们在这个方法中经常使用它。接下来,我们在收集中设置新的比较器。然后,我们需要确定我们正在尝试按哪个方向排序。我们通过表格标题上的另一个属性 data-direction 来做这件事。如果属性是 asc,我们将执行反向排序。否则,如果它是 desc,我们将执行常规排序。

但是,这个属性是从哪里来的?我们没有在模板中放入它。注意我们调用的fixSortIcon方法。这个方法做两件事。首先,正如我们所期望的,它将在元素上设置data-direction属性。但我们还在设置一个图标;一个指示排序方向的箭头。以下是那个方法:

fixSortIcons: function (target, dir) {
  var icon = 'glyphicon glyphicon-arrow-' + (dir === 'asc' ? 'down' : 'up');
  this.$("th i").remove();
  target.setAttribute("data-direction", dir);
  $("<i>").addClass(icon).appendTo(target);
},

这个方法接受两个参数:目标元素(用户点击的表格标题)和方向(ascdesc)。首先,我们获取 Glyphicons 箭头图标的名称:对于desc是向上,对于asc是向下。为了显示这个图标,我们需要一个<i>元素。但是,对于除了第一次排序之外的所有排序,将有一个来自前一次排序的<i>元素。下一步是移除这些元素。然后,我们在目标元素上设置属性。最后一步是创建<i>元素,添加适当的类,并将其追加到目标元素。

因此,现在,当我们点击表头时,集合将按该属性排序。但是,我们实际上如何重新排列表格行?如您所知,当集合排序时,将发出一个sort事件。让我们在EventsViewinitialize方法中捕获它,如下所示:

this.listenTo(this.collection, 'sort', this.renderRows);

如我们之前所见,renderRows函数将为集合中的每条记录添加一行。我们已经在表中有了行,所以我们必须弄清楚如何处理这些行。最简单的方法就是简单地清空<tbody>元素,并创建大量的新EventView实例。然而,这对内存管理来说并不好。我们应该使用它们的remove方法正确地移除视图,取消事件监听器,然后我们可以创建新的EventViews类。我们将采取另一种方法;然而,我们将重新排列我们已创建的视图。这意味着我们需要保留对EventView实例的引用。在EventsViewinitialize方法中,让我们创建一个children属性来跟踪这些视图。这可以通过以下简短的代码片段完成:

this.children = {};

然后,我们需要更改addRow方法,使其利用这个属性。它现在应该看起来像这样:

addRow: function (event) {
  if (!this.children[event.id]) {
    this.children[event.id] = new EventView({
      model: event,
      nav: this.nav
    }).render();
  }

  this.$("tbody").append(this.children[event.id].el);
},

现在,这个方法将首先检查我们的children对象中是否有具有此事件 ID 的属性。如果没有,我们将创建一个新的EventView对象,渲染它,并将其存储在children对象中。无论它是新创建的还是旧的,我们都会将其追加到<tbody>中。这种方法的优点是,即使它已经在<tbody>中,这也会将其移动到末尾。在遍历集合后,表格行将得到适当的排序。

所有的东西都已经到位!现在,前往浏览器,对排序功能进行一次全面测试。以下屏幕截图显示了实际操作的情况:

排序事件

现在,您可以在以下屏幕截图中看到按 ID 列排序的情况:

排序事件

摘要

我们在本章中涵盖了大量的内容。之前,我们只是在服务器上创建和读取模型。现在,我们知道了如何更新和删除服务器上的模型。这对于 Backbone 应用程序来说是基础性的内容。你构建的许多应用程序都将使用所有四个 CRUD 操作:创建、读取、更新和删除。

另一个需要记住的重要事情是我们通过从服务器获取数据来更新集合的方式。你不会在每一个应用程序中都这样做——定期轮询服务器,但我们在每个情况下都会监听相同的事件。实际上,这里有一个常见的 Backbone 习惯;在模型上监听 change 事件,并重新渲染显示该模型的视图。通常,你只需要调用 render 方法即可。由于视图的主要元素已经在 DOM 中,因此不需要重新附加它;当调用 render 时,它将更新。

此外,本章中一个值得注意的事情是我们创建了一个 view 类,然后通过两个子视图类来扩展它。不要忘记,你的模型、集合和视图都有 extend 方法,就像它们的 Backbone 父类一样。当你发现自己正在创建两个或更多极其相似组件时,你可以利用这个方法。

最后,请记住,丢弃视图不仅仅是删除其 DOM 元素。正确地调用视图的 remove 方法是很重要的。然而,正如我们所看到的,这不仅仅是那样。在视图的 initialize 方法中监听事件时,使用 this.listenTo 比使用 this.model.onthis.collection.on 更好。这样,当移除视图时,视图可以取消绑定这些事件。

在创建了这个 events 仪表板之后,你可能会认为它看起来像一张整洁的日历。然而,对于日历,我们可以做得更好。我们将在下一章中构建它。

第四章:构建日历

我们将在本章中构建一个日历。你可能会认为这是我们上一章构建的,但这个将有所不同;它将类似于一个非常简化的 Google Calendar。我们将能够一次查看一个月或一天,并计划跨越一定小时数的事件。

在本章中,我们将讨论以下想法:

  • 更好的应用程序组件组织,整个应用程序只有一个全局变量

  • 将模型功能放在模型方法中

  • 使用可丢弃的模型来封装我们不需要在服务器上存储的重要信息

  • 在多个视图中显示单个模型实例

你可以从项目模板开始,就像我们之前做的那样。然而,我们将使用与上一章相同的预编译模板,并且我们还将把我们的代码分成models.jsviews.jsrouter.js。你也可以选择复制上一个项目并清除自定义代码,这样你将会有我们上次创建的Gruntfile.js文件。

规划我们的应用程序

再次,我们将从规划我们的应用程序开始。我们的主要模型将是Event模型。它与我们在上一章中创建的模型同名,但略有不同。这个模型将包含标题、日期、开始时间和结束时间。我们将允许一天内有多个事件,但事件不能重叠(因为我们不能同时参加两个事件)。然后,我们还将创建一个Calendar集合类来存储我们的事件。

我们的应用程序将有两个屏幕。第一个将是一个月视图,以标准、表格、墙式日历风格。然后,点击该视图中的某一天将切换到日视图,这将显示该天的每小时事件分解。这也将是我们创建新事件的屏幕。

创建模型和集合

让我们从Event模型类开始,它将放在public文件夹的models.js文件中。将我们的代码分成多个文件是组织的好第一步,但我们还可以更进一步。之前,我们的每个类都通过它自己的全局变量进行引用。你可能知道这不是一个特别明智的技术。如果我们使用其他库、框架或插件,我们不希望两个组件使用相同的变量名并搞乱工作。在这个应用程序中,我们将把所有的类安全地放在一个单独的全局对象中。因此,我们以以下代码开始models.js文件:

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

第一行可能看起来有点棘手;为什么不直接使用 var App = {}; 呢?嗯,我在这里使用的技巧允许我们不必担心文件在浏览器中加载的顺序。这一行首先检查 window.App 是否存在。如果存在,它将其分配给自己(基本上,它什么都不做)。如果不存在,我们可以确信这是我们的第一个加载的文件,因此我们将其创建为一个空对象。只要我们以这种方式开始所有自定义 JavaScript 文件,这个技巧就会起作用。

下一个行创建了一个 Models 属性。我们的模型和集合类将是这个对象上的属性。

现在,我们准备创建我们的 Event 模型类,如下所示:

App.Models.Event = Backbone.Model.extend({});

我们将从一个基本模型开始,但我们会回来添加很多功能。如我之前提到的,将模型功能放在模型方法中是本章讨论的最重要行动点之一。

我们还有一个集合类,其代码如下:

App.Models.Calendar = Backbone.Collection.extend({
  model: App.Models.Event,
  url: "/events",
});

这里没有什么新或独特的地方。请注意,我们确实需要通过其全名作为属性来引用我们的模型类,因为它不再是全局变量了。

实际上,我们还需要创建一个额外的模型;然而,这个模型不是我们要在服务器上存储实例或允许我们的用户了解的模型;它只是一个我们将内部使用的类,以使我们的视图代码更简单。记住,我们正在制作一个日历;这意味着我们需要关于我们显示的每个月的大量信息:月份的名称、天数以及该月开始的星期几,仅举几例。因此,我们将创建一个 Month 模型类,并使用它来跟踪所有这些数据。以下是为 Month 模型类编写的代码:

App.Models.Month = Backbone.Model.extend({
  defaults: {
    year : moment().year(),
    month: moment().month()
  },
  initialize: function (options) {
    var m = this.moment();
    this.set('name', m.format('MMMM'));
    this.set('days', m.daysInMonth());
    this.set('weeks', Math.ceil((this.get('days') + m.day()) / 7));
  },
  moment: function () {
    return moment([this.get('year'), this.get('month')]);
  }
});

注意

我想明确指出,我们在这里不需要使用 Backbone 模型类。一个简单的 JavaScript 构造函数,在原型上添加一些方法,就足够了。然而,由于我们正在使用 Backbone,我们将创建一个模型类,这样我们就可以看到如何使用具有某种可丢弃数据包装器的模型。

我们包括了 defaults 属性,并不是因为我们期望需要 defaults,而是一种简单的方式来记录我们期望传递给 Month 构造函数的 options 对象应具有哪些属性。当我们创建 Month 实例时,我们需要给它一个年份和月份,两者都是数字。正如您可能预料的那样,我们在这个应用程序中大量使用 Moment 库,因为我们将进行大量的日期计算。记住,Moment 库使用零索引值作为月份数字,所以,一月是 0,十二月是 11。

initialize方法中,我们首先调用moment方法,您可以在类的底部看到这个方法。这个方法简单地返回一个新的moment对象。moment构造函数可以接受一个包含时间值(年、月、日、小时等)的数组。我们只需要,所以我们只传递这两个值。其余的值将默认为最早的可能值,因此这个moment对象将代表我们月份的第一天午夜。这很完美。

因此,回到initialize方法中,我们调用moment方法。然后,我们设置一些其他属性,这些属性是我们Month对象所需要的:月份的字符串名称、月份中的天数和月份中的周数。最后一个属性在我们渲染月份表格时将非常重要;我们需要知道需要多少表格行。我们可以通过将月份中的天数加上moment对象中的日期值来找到月份的周数。这将是一个表示月份第一天是星期几的数值。方便的是,这也是我们需要在月份开始时填充的前一个月的天数。然后,我们将这个数字除以 7,并向上取整。

在这些类设置好之后,我们就可以开始启动路由器了,所以请从public目录打开router.js文件。就像在models.js文件中一样,我们将从以下行开始:

window.App = window.App || {};

然后,我们将编写路由器类,它最初将渲染用户的页面:

App.Router = Backbone.Router.extend({
  initialize: function (options) {
    this.main = options.main;
    this.calendar = options.calendar;
    App.Router.navigate = this.navigate.bind(this);
  },
  routes: {
    '': 'month',
    ':year/:month': 'month'
  },
  month: function (year, month) {
    var c = this.clean(year, month);

    this.main.html(new App.Views.Month({
      collection: this.calendar,
      model: new App.Models.Month({ year: c[0], month: c[1] })
    }).render().el);
  }
});

就像我们之前的应用程序一样,我们的路由器构造函数期望接收一个主元素,我们的视图将在其中渲染,以及一个我们称之为calendar的集合对象。我们在initialize方法中将这些设置为局部变量。我们还创建了一个路由器navigate方法的绑定副本,以便我们的视图可以更改路由。这次,我们通过将其作为Router类的类属性来实现这一点。

接下来,我们有我们的路由。这次的情况有些不同,因为两个路由都会调用同一个方法:month。第二个路由是有道理的;任何符合/year/month模式的路由都会显示那个月份。然而,我们希望根路由显示当前月份;这就是为什么它调用相同的方法。

然后,month方法接受年和月参数,并将它们传递给一个clean函数以确保它们是可用的。这将返回一个包含年和月值的数组,我们可以使用它。那么,没有这些参数的根路由怎么办?clean方法将处理这个问题。之后,我们可以在主元素中放入一个新的App.Views.Month视图。这个视图将接受两个属性:calendar集合和一个Month模型。我们创建一个Month实例,传递给它从清理数组中获取的年和月。

clean方法相当简单:

clean: function (year, month, day) {
  var now = moment();
  year  = parseInt(year, 10)             || now.year();
  month = (parseInt(month, 10) - 1) % 12 || now.month();
  day   = parseInt(day, 10)              || now.day();
  return [year, month, day];
}

这个函数接受三个参数:yearmonthday。它们将是字符串,因为它们就是这样从路由中来的。每个都会被解析为整数,但可能其中一个不会解析为数字。如果是这样,我们将从 moment 对象中获取当前的年、月或日。然后,我们将返回一个包含所需数字的数组。这些内置默认值意味着根路由将获取当前的年月。它还有一个有趣的副作用;路由 /what/4 将显示当前年份的四月。

因此,有了路由器,我们可以转到 views 文件夹中的 index.ejs 文件。你首先想要确保所有脚本都已就绪。别忘了获取 Moment 库,就像我们在上一章中做的那样,并添加 models.jsviews.jsrouter.js。最后,让我们实例化路由器,如下面的代码所示:

<script>
  var r = new App.Router({
    main: $("#main"),
    calendar: new App.Models.Calendar([])
  });

  Backbone.history.start({ pushState: true });
</script>

注意当我们创建我们的日历时,我们放入了一个空数组。通常,这是我们从服务器获取模型的地方,但这次我们将有所不同。让我们在这里硬编码一些示例数据。这样,我们就可以现在专注于前端代码。我们很快就会得到后端的东西。所以,在这个数组中,添加一些模型,如下面的代码行所示:

{ "title": "event one", "date": "2014-01-06", "startTime": "10:00", "endTime": "12:00", "id": 1 },
{ "title": "event two", "date": "2014-01-08", "startTime": "00:00", "endTime": "24:00", "id": 2 },
{ "title": "event three", "date": "2014-01-09", "startTime": "18:00", "endTime": "21:00", "id": 3 }

当你阅读这段内容时,你可能想要更改日期为当前日期。这是因为日历默认会显示当前月份,这样你就可以看到这些事件了。

index.ejs 文件中还有一件事要做;如果我们不做一些样式设计,我们真的无法得到一个好看的日历,所以我们将这样做——在文件的头部添加以下行:

<link rel="stylesheet" href="/style.css" />

我们稍后会添加这个样式表文件。现在,我们准备好创建我们的视图了。

创建月份视图

月份视图将以表格的形式显示月份,就像墙上的日历一样。事件将在相应日期的单元格中显示。这需要几个嵌套视图,所以让我们从 Month 视图开始。这是我们的开始方式:

App.Views.Month = Backbone.View.extend({
  template: JST.month,
  render: function () {
    this.el.innerHTML = this.template(this.model.toJSON());
    var weeks = this.model.get('weeks');

    for (var i = 0; i < weeks; i++) {
      this.$("tbody").append(new App.Views.WeekRow({
        week  : i,
        model : this.model,
        collection: this.collection
      }).render().el);
    }
    return this;
  }
});

我们将为这个类提供一个 JST.month 模板和一个 render 方法。在我们讨论 render 方法之前,让我们先看看模板文件。

注意

注意我们并没有像以前那样将视图命名为 MonthViewWeekRowView。相反,它们只是 MonthWeekRow。我们这样做是因为我们无论如何都要用 App.Views.MonthApp.Views.MonthTable 来引用它们,所以没有必要两次都说 View

正如你所看到的,以下代码将放在 template 文件夹中的 month.html 文件里:

<h1>
  <span class="prev"> &larr; Previous Month </span> 
  {{name}} {{year}}
  <span class="next"> Next Month &rarr; </span>
</h1>
<table class='month'>
  <thead>
    <tr>
      <th>Sunday</th>
      <th>Monday</th>
      <th>Tuesday</th>
      <th>Wednesday</th>
      <th>Thursday</th>
      <th>Friday</th>
      <th>Saturday</th>
    </tr>
  </thead>
  <tbody>
  </tbody>
</table>

在顶部有一个标题,它将显示我们正在显示的月份的名称和年份。还将有按钮可以切换到下一个月和上一个月。下面,将有一个 <table> 元素,用于显示月份。别忘了运行 grunt 来编译模板。

现在,回顾一下 render 方法。我们首先渲染模板,传递给它来自 Month 模型的数据。然后,我们获取 month 模型的 weeks 属性;这告诉我们表格需要多少行(每周一行)。最后,我们循环这么多次,每次循环都将一个新的 WeekRow 视图添加到 <tbody> 元素中。一个 WeekRow 实例需要三个属性:周数(第一周为 0,第二周为 1,依此类推)、month 模型和 calendar 集合。

对于这种视图的最后一步是让我们的下个月和上个月按钮正常工作。将以下事件属性添加到 Month 视图中:

events: {
  'click .prev': 'prev',
  'click .next': 'next'
},

这些事件监听器需要 prevnext 方法才能工作,所以让我们也把这个类中的这些方法添加上:

prev: function () {
  var route = this.model.moment()
    .subtract(1, 'month').format('YYYY/MM');
  App.Router.navigate(route, { trigger: true });
},
next: function () {
  var route = this.model.moment()
    .add(1, 'month').format('YYYY/MM');
  App.Router.navigate(route, { trigger: true });
}

当点击 <span> 元素时,我们将分别调用 nextprev 方法。这两个方法通过向 month 模型的 moment 实例添加或减去一个月来获取下一个月或上一个月。然后,我们按需格式化它并触发路由更改。

构建周行

我们即将创建的 WeekRow 视图比我们之前所做的一切都要复杂。在我们查看代码之前,花一分钟时间思考一下表格中的周行。这里有三种情况。一个月的第一周可能需要在第一天之前有一些空白单元格,中间的周会有七天,而最后一周将只有剩余的天数。这将在 render 方法中需要一点额外的代码。以下是这个类的代码:

App.Views.WeekRow = Backbone.View.extend({
  tagName: 'tr',
  initialize: function (options) {
    if (options) {
      this.week = options.week;
    }
  },
  render: function () {
    var month = this.model;

    if (this.week === 0) {
      var firstDay = month.moment().day();
      for (var i = 0; i < firstDay; i++) {
        this.$el.append("<td>");
      }
    }

    month.weekDates(this.week).forEach(function (date) {
      date = month.moment().date(date);
      this.$el.append(new App.Views.DayCell({
        model: date,
        collection: this.collection.onDate(date)
      }).render().el);
    }, this);

    return this;
  }
});

每个 WeekRow 视图的元素是 <tr>。在 initialize 方法中,我们获取 week 选项;正如你所知,modelcollection 属性会自动获取。在 render 方法中,我们首先创建一个 month 变量,仅作为一个快捷方式来引用这个模型。接下来,我们寻找我们的第一个特殊情况:第一周。如果我们正在创建第一周的行,我们首先需要找到这个月是星期几开始。我们可以用 month.moment().day() 来做这个。day 方法返回一周中天数的零基于索引。这正是我们需要的,因为如果这个月从星期日开始,我们会得到一个 0,这就是我们需要空白单元格的数量,依此类推。

因此,firstDay 变量是我们需要的空白单元格的数量。然后我们循环,添加我们需要的那么多空 <td> 元素。

下一步是为 WeekRow 视图添加正确数量的 DayCell 视图。这听起来很简单;但实际上有点棘手,有两个原因。首先,因为第一周可能不会有七天,我们得弄清楚它应该有多少天。第二个原因是我们需要做一些数学计算来得到那个单元格的日期数字。为了使视图代码更简单,我们将在 App.Models.Month 类中创建一个方法。weekDates 方法将接受一个周数并返回一个包含该周日期的数组。在 Month 类的 models.js 文件中,添加以下方法:

weekDates: function (num) {
  var days  = 7,
      dates = [],
      start = this.moment().day();

  if (num === 0) {
    days -= start;
    start = 0;
  }

  var date = num*7 + 1 - start, 
      end  = date + days;

  for (; date < end; date++) {
    if (date > this.get('days')) continue;
    dates.push(date);
  }
  return dates;
},

我们首先创建几个变量;一周中的天数,一个返回的日期数组,以及这个月开始的那一周是星期几。然后,如果我们正在处理第一周,我们从start中减去day,因为第一周没有七天。然后,我们将start设置为0,以供以后使用。

接下来,我们进行一些数学运算以获取date,这是本周的第一个日期。我们将周数乘以 7,然后加 1,这样它就不是零索引了。最后,我们减去start以纠正不是从星期日开始的那一周。最后,我们创建一个end变量,我们将使用它来停止循环。

然后,我们从date循环到end,并将递增的date推入dates数组。重要的是我们将date与这个月应有的天数进行比较,如果date大于这个值,则不要将其推入数组。

最后,我们返回dates数组。

现在,如果你回顾一下WeekRow视图的render方法,事情应该会更有意义。我们获取那一周的日期数组,并使用原生的forEach方法遍历它。对于每个date变量,我们创建一个实际的moment对象。我们获取月份的moment对象,并通过调用date方法来修改它,该方法在对象上设置日期(月份中的天数)。然后,我们将那个date变量和日历集合的一部分传递给一个新的DayCell视图,我们渲染并附加到元素上。

注意,我说的是“日历集合的一部分”;我们正在调用onDate方法,该方法返回一个只包含我们传递给方法的日期的事件的新Calendar集合实例。这个onDate方法位于Calendar集合的models.js文件中。然而,在我们到达那里之前,我们需要创建另一个方法;这是Event类的一个方法:

start: function () {
  return moment(this.get('date') + " " + this.get('startTime'));
},

Event类的start方法返回事件开始时间的新moment实例。正如你所见,我们通过连接事件的日期和开始时间,然后将结果字符串传递给moment函数来获取这个。

我们将在onDate方法中使用这种方法,如下所示:

onDate: function (date) {
  return new App.Models.Calendar(this.filter(function (model) {
    return model.start().isSame(date, 'day');
  }));
}

这将调用集合的filter方法,并且它只返回与onDate方法传入的日期相同的模型。然后,filter返回的数组被传递给一个新的Calendar实例。

注意

注意,我们不需要事件开始时间来完成这个目的,只需要事件发生的日期就足够了。然而,我们将在其他地方使用start方法。

构建日单元格

事情正在顺利进行!我们现在准备好使用在WeekRow视图中使用的DayCell视图类。让我们从templates文件夹中的dayCell.html文件中的模板开始。以下是这个模板的代码:

<span class="date">{{num}}</span>
<ul>
  <% titles.forEach(function (title) { %>
    <li>{{ title }}</li>
  <% }); %>
</ul>

在这个模板中,我们做了一些新的尝试。我们加入了一些逻辑。之前,我们只使用双大括号来界定值以进行插值。然而,我们可以使用<%%>界定符来运行我们想要的任何 JavaScript 文件。当然,对于大量代码来说这样做并不聪明,但我们只是用它来遍历一个数组。由于每个单元格代表日历上的一天,每个单元格可能有多个事件。我们将传递一个包含那些事件标题的数组给这个模板。然后,在模板内部,我们将遍历这些标题并为每个标题添加一个列表项。

那么视图类呢?它如下所示:

App.Views.DayCell = Backbone.View.extend({
  tagName: 'td',
  template: JST.dayCell,
  events: {
    'click': 'switchToDayView'
  },
  render: function () {
    this.el.innerHTML = this.template({ 
      num: this.model.date(),
      titles: this.collection.pluck('title') 
    });
    return this;
  },
  switchToDayView: function () {
    App.Router.navigate(this.model.format('YYYY/MM/DD'), {
      trigger: true 
    });
  }
});

每个实例都将是一个<td>元素。在渲染时,我们将传递日期数字,这是我们通过传递给模型作为模型的moment实例获得的。我们还将使用集合的pluck方法从集合中的每个实例获取一个属性;在这里,我们从每个Event实例中提取title属性。

此外,请注意events对象。我们在根元素上监听点击事件。当发生这种情况时,我们将使用App.Router.navigate来进入单个日视图。我们通过格式化moment实例来获取路由。

信不信由你,我们现在已经准备好在浏览器中看到一些东西了。编译你的模板,启动你的服务器,并在浏览器中加载http://localhost:3000/。你应该会看到以下截图类似的内容:

构建日单元格

这是可以的,但并不那么漂亮。然而,我们可以修复这个问题。记得我们在index.ejs文件中放入的style.css文件的链接吗?现在在public目录中创建这个文件。

我们将从以下代码开始:

body {
  font-family: sans-serif;
  margin: 0;
}

这将为整个页面设置字体和边距。然后,我们继续到视图特定的样式:

.prev, .next {
  font-size: 60%;
}
h1 {
  text-align: center;
  margin: 0;
}

这是Month视图的头部。它将缩小下一个和上一个按钮,并将标题居中在屏幕上。

为了给我们的表格添加边框,我们将添加以下代码:

table { 
  border-collapse: collapse;
}

td {
  border: 1px solid #ccc;
}

这些是为任何表格的;所以这种样式将在单个日页面上使用,在那里我们将有另一个表格。但是,我们需要为月份表格做几件特别的事情,如下所示:

table.month {
  table-layout: fixed;
  width: 1000px;
  height: 600px;
  margin: auto;
}

你会记得我们给月份的<table>元素添加了month类。我们在这里利用了这一点。如果你不熟悉table-layout属性,它基本上确保了所有列的宽度相同。

接下来,我们想要样式化单个单元格。这是如何做到的:

table.month td {
  position: relative;
  vertical-align: top;
}
table.month td .date {
  font-weight: bold;
  position: absolute;
  font-size: 100px;
  bottom: -23px;
  right: -4px;
  color: #ececec;
  z-index: -1;
}

我们必须将<td>元素相对定位,以便我们可以将具有date类的<span>元素绝对定位在其内部,以达到效果。这是一个老技巧,它将允许我们将<span>元素绝对定位在其父元素(一个<td>元素)中,而不是整个页面上。其余的只是为了外观。

用户将点击这些 <td> 元素来进入单个日历页面,所以当用户悬停在单元格上时,我们是否给用户一点反馈?

table.month td:hover {
  cursor: pointer;
}
table.month td:hover .date {
  color: #ccc;
}

最后一件事情是每个单元格将有的无序列表的事件标题。以下是它们的样式:

td ul {
  list-style-type: none;
  padding: 0;
  margin: 0;
  font-size: 80%;
  height: 100%;
  overflow: scroll;
}

td li {
  padding: 3px 10px;
  margin: 2px 0;
  background: rgba(223, 240, 216, 0.5);
  border: 1px solid rgb(223, 240, 216);
}

注意到 <ul> 元素有 overflow: scroll。这样,如果某一天有很多事件,它根本不会创建额外的表格行;它只会使行滚动。

在所有这些样式设置完成后,您可以刷新页面,查看以下截图所示的内容:

构建日历单元格

不是更好吗?

创建单个日历屏幕

目前,当我们点击表格中的单元格时,我们的路由会改变,但屏幕上没有任何变化。这不是因为我们没有通过我们的路由交换触发更改;我们确实触发了。我们只是还没有在我们的路由器中创建那个方法。所以,那就是我们的下一个目的地。

router.js 文件中,向 Router 类的 routes 属性添加以下行:

':year/:month/:day': 'day'

然后,我们需要在那里调用的 day 方法:

day: function (year, month, day) {
  var date = moment(this.clean(year, month, day)); 
  this.main.html(new App.Views.Day({
    date: date,
    collection: this.calendar
  }).render().el);
},

此方法渲染 App.Views.Day 视图,这是单个页面的顶级视图。它需要一个表示显示日期的 Moment 对象和事件集合。我们通过将清理属性传递给 moment 方法来获取日期的 moment 对象。

注意

您可能期望我们通过我们的 onDate 方法将此集合限制为仅包含用户查看的特定日期的事件。然而,我们传递了整个集合,因为我们想要向这个 Calendar 实例添加新的事件实例。这是因为我们的月份视图使用 this.calendar 集合,我们想要确保在日历视图中添加的任何事件都会立即显示在月份视图中,无需刷新页面。

App.Views.Day 视图是一个包装视图。它包含三个主要视图:

  • DayTable:这提供了按小时分解的日历

  • Details:这提供了对用户当前悬停的任何事件的更详细查看

  • CreateEvent:这提供了一个用于创建新事件的表单

我们将使用 CSS 在垂直方向上分割屏幕。在左侧,我们将有 DayTable 视图;在右侧,我们将有 Details 视图和 CreateEvent 视图。Day 视图类的任务是放置这三个视图。

我们将从模板开始,通过在 templates 文件夹中的 day.html 文件中添加以下代码:

<h1> {{ date }} </h1>
<p class='back'>&larr; Back to Month View </p>
<div class="splitView">
</div>

我们将在页面顶部显示日期,并提供一个链接返回到月份视图。然后,我们有一个具有 splitView 类的 <div> 元素。

我们中断代码,为您提供以下针对该 splitView 类的 CSS 代码。将此代码放入 style.css 文件中:

.splitView > * {
  width: 45%;
  margin: 2%;
  float: left;
}

现在,让我们开始 Day 视图类的编写:

App.Views.Day = Backbone.View.extend({
  template: JST.day,
  initialize: function (options) {
    this.date = options.date;
  },
  events: {
    'click .back' : 'backToMonth'
  },
  render: function () {
    this.el.innerHTML = this.template({ 
      date: this.date.format("MMMM D, YYYY") 
    });
    this.$('.splitView').append(new App.Views.DayTable({
      date: this.date,
      collection: this.collection
    }).render().el);
    return this;
  },
  backToMonth: function () {
    App.Router.navigate(this.date.format('/YYYY/MM'), { 
      trigger: true 
    });
  }
});

还有更多内容,但我们将从这里开始。我们设置模板。在 initialize 方法中,我们获取 date 属性。然后,我们绑定一个事件。当点击后退按钮时,我们将调用 backToMonth 方法,这将改变路由回到月份屏幕,就像我们切换到日屏幕一样。

然后,在 render 方法内部,我们将解决方案的一部分组合起来。我们获取 <div class='splitView'> 元素,并附加一个新的 DayTable 视图实例。这个视图接受本页的日期和事件集合。

这个 DayTable 视图可能是我们在本书到目前为止创建的最独特的视图。正如你所期待的那样,它将是一个 HTML 表格,其中每一行代表一天中的一个小时。左列将是时间,右列将显示在该小时发生的事件的标题(如果有的话)。棘手的部分是,大多数事件可能跨越多个小时,因此我们需要确定事件的开始和结束位置。

首先,这个视图的模板是什么样子?将以下代码存储在 templates 文件夹中的 dayTable.html 文件中:

<thead>
  <tr>
    <th> Time </th>
    <th> Event </th>
  </tr>
</thead>
<tbody>
</tbody>

就像我们的其他基于表格的视图一样,模板是表格的核心。你可以看到两个列:时间和事件。

我们将分部分介绍这个视图类。DayTable 视图的代码如下:

App.Views.DayTable = Backbone.View.extend({
  tagName: 'table',
  className: 'day',
  template: JST.dayTable,
  events: {
    'mouseover tr.highlight td.event': 'hover',
    'mouseout  tr.highlight td.event': 'hover'
  },
  initialize: function (options) {
    this.date = options.date;
    this.listenTo(this.collection, 'add', this.addEvent)
    this.listenTo(this.collection, 'destroy', this.destroyEvent)
    this.hours = {};
  }
});

这个视图的元素将是一个具有 day 类的表格。我们在该视图中监听两个事件;任何包含事件的表格行都将具有 highlight 类,而第二列中的每个表格单元格都将具有 event 类。当用户将鼠标移至包含事件标题的单元格上或移出时,我们将调用 hover 方法来突出显示该事件。

initialize 方法中,我们将获取 date 选项,然后监听我们的集合中模型被添加或销毁的情况。了解何时发生这些操作非常重要,这样我们就可以将它们添加或从表格中删除。我们将编写 addEventdestroyEvent 方法来完成这项工作。

最后,我们创建了一个 hours 对象,我们将使用它来跟踪 Hour 视图,每个视图将是我们表格中的一行。我们在上一章中使用了这种技术,因此我们可以轻松地对表格中的行进行排序。这次,我们这样做是因为当我们想要在一天中添加或删除事件时,我们实际上并不想添加或删除 Hour 视图;我们只想添加或删除该视图中的事件标题。你很快就会看到这是如何工作的。

在这些部分就绪后,我们可以继续到 render 方法:

render: function () {
  this.el.innerHTML = this.template();

  for (var i = 0; i < 24; i++) {
    var time = moment(i, "H").format('h:mm A');
    this.hours[time] = new App.Views.Hour({ time: time });
    this.$('tbody').append(this.hours[time].render().el);
  }
  this.collection.onDate(this.date).forEach(this.addEvent, this);
  return this;
},

这应该很容易理解。我们首先渲染模板。然后,我们循环 24 次;每次,我们创建一个 App.Views.Hour 视图实例,将其存储在 this.hours 属性中供以后使用,并将其附加到 <tbody> 元素。我们可以通过创建一个带有递增变量的 moment 对象来获取时间文本;由于 i 不是一个可理解的日期格式,我们需要传递 "H" 作为第二个参数,这样它就知道这只是一个小时。然后,我们将其格式化为一个漂亮的时间字符串。我们使用这个时间字符串作为属性名,在将视图实例存储在 this.hours 中时使用。此时(如果我们有一个 Hour 视图类),我们将有一个完整的表格,每天的小时都有一个行。然而,所有行都是空的;我们还没有渲染任何事件。这就是为什么我们接下来过滤 collection 以获取这一天的活动,并对它们进行循环,为每个活动调用 addEvent 方法。

正如我之前提到的,难点在于一个 Event 模型实例可能需要跨越几个 Hour 视图实例。为了编写 addEvent 方法,我们首先将一个 hours 方法添加到 Event 模型类中。

将以下代码添加到 models.js 文件中的 App.Models.Event 类:

hours: function () {
  var hours = [],
      start = this.start(),
      end   = this.end();

  while (start.isBefore(end)) {
    hours.push(start.format('h:mm A'));
    start.add(1, 'hour');
  }
  return hours;
}

我们首先创建一个目前为空的 hours 数组,我们最终将返回它。然后,我们获取模型的 startend 时间。我们已经创建了 start 方法,但我们需要创建 end 方法。它比 start 方法稍微复杂一些。将以下代码添加到我们正在工作的同一个类中:

end: function () {
  var endTime = moment(this.get('date') + " " +this.get('endTime'));
  if (this.get('endTime') === '00:00') {
    endTime.add(1, 'day');
  }
  return endTime;
},

start 方法中,我们通过连接 dateendTime 创建 moment 对象。然而,有一个特殊情况;如果事件在午夜结束,技术上是在第二天结束。但是,我们的 moment 对象将指向事件日期的午夜,这是当天的小时。所以,如果结束时间是午夜,我们将向 moment 对象添加一天。然后,我们返回。

让我们回到 hours 方法。在获取 startend 时间后,我们可以循环,当 start 时间在 end 时间之前时。我们将一个时间字符串推入 hours 数组;注意我们是以我们在表格中做的方式格式化它的。然后,我们将一个小时添加到 start 对象。最终,start 将与 end 相同,循环将停止。然后,我们将返回那个 hours 数组。

使用此方法,如果我们有一个从下午 1:00 到下午 4:00 举行的活动,我们将得到以下数组:

['1:00 PM', '2:00 PM', '3:00 PM']

你可能认为我们还想在那里包含下午 4:00,但我们不需要。这是因为每个 Hour 视图实例代表一个完整的小时;所以,带有标签 1:00 PM 的小时指的是从下午 1:00 到下午 2:00 的小时。

使用这种方法后,我们可以回到 App.Views.DayTable 并编写 addEvent 方法。记住,我们为需要在表中显示的每个事件调用此方法。以下是为 addEvent 方法编写的代码:

addEvent: function (evt) {
  evt.hours().forEach(function (hour) {
    this.hours[hour].displayEvent(evt);
  }, this);
},
destroyEvent: function (evt) {
  evt.hours().forEach(function (hour) {
    this.hours[hour].removeEvent();
  }, this);
},

addEventdestroyEvent 方法非常相似,所以我们一起来看它们。在两种情况下,我们都会获取给定事件的时数组,然后使用原生数组的 forEach 方法遍历它。对于每个小时,我们从 this.hours 获取视图。在 addEvent 方法中,我们调用视图的 displayEvent 方法,并将事件传递给该方法。在 destroyEvent 方法中,我们只是调用视图的 removeEvent 方法;没有必要传递事件。

在我们到达 Hour 视图类之前,让我们写下这个类的最后一个方法:hover。这个方法在我们将鼠标移到或移出我们表格中的事件标题上时被调用。以下是 hover 方法的代码:

hover: function (e) {
  var id = parseInt(e.currentTarget.getAttribute('data-id'), 10),evt = this.collection.get(id);

  evt.hours().forEach(function (hour) {
    this.hours[hour].hover();
  }, this);

  this.collection.trigger("hover", evt);
}

由于此方法是由 DOM 事件触发的,我们将获得一个 DOM 事件对象作为我们的参数(实际上,由于我们使用 jQuery,它将是一个 jQuery 包装的 DOM 事件对象)。在这个方法中的首要任务是确定我们悬停的行是哪个 Event 模型实例的一部分。我们可以通过获取 Event 实例的 ID 来做到这一点。该 DOM 事件对象的 currentTarget 属性将是触发事件的元素;稍后,当我们渲染它时,我们将给它我们在这里得到的 data-id 属性。由于我们在 <td class='event'> 元素上监听鼠标事件,所以 currentTarget 属性将是这个。

一旦我们知道 ID 是什么,我们就可以调用集合的 get 方法来找到具有该 ID 的模型。一旦我们得到该事件模型,我们可以使用 hours 获取该事件的时数。然后我们遍历这些时数,找到显示此事件的 Hour 视图实例,并调用它们的 hover 方法。最后,我们将在我们的集合上触发一个 hover 事件,并将事件模型作为参数传递。这是件新鲜事;到目前为止,我们只在我们的模型和集合上监听内置事件(如 adddestroy)。然而,我们也可以使用 trigger 方法来创建我们自己的事件。我们可以命名我们的事件为任何我们想要的;我们称这个事件为 hover。在其他地方,我们将监听此事件,并在事件发生时执行操作。

你可能会认为我们应该在 Hour 视图中监听这些鼠标事件,因为那将是受影响的视图。然而,在这种情况下,这不会起作用,因为我们需要在单个视图被悬停时更改多个 Hour 视图。

我们终于准备好创建 Hour 视图了。它的模板非常简单。将以下行放入 templates 文件夹中的 hour.html 文件:

<td class='time'> {{ time }}</td>
<td class='event'></td>

模板只期望时间;我们将从 JavaScript 文件中填写(如果需要)事件名称。

现在,在我们的 views.js 文件中,添加以下代码:

App.Views.Hour = Backbone.View.extend({
  tagName: 'tr',
  template: JST.hour,
  initialize: function (options) {
    this.time = options.time;
  },
  render: function () {
    this.el.innerHTML = this.template({ time: this.time });
    return this;
  },
  displayEvent: function (model) {
    this.$el.addClass("highlight");
    this.$('.event').attr('data-id', model.get('id'));
    this.$(".event").text(model.get('title'));
  },
  removeEvent: function () {
    this.$el.removeClass('highlight');
    this.$('.event').removeAttr('data-id');
    this.$('.event').text('');
  },
  hover: function () {
    this.$el.toggleClass('hover');
  }
});

如我们所知,这个视图将是一个<tr>元素。在initialize方法中,我们获取time属性。在这个情况下,render方法非常简单,因为大部分动作都在displayEventremoveEvent方法中发生。正如我们所见,App.Views.DayTable视图类中的addEvent方法将调用这个displayEvent方法,并传递那个小时发生的event模型。在displayEvent方法中,我们将highlight类添加到那个小时,添加data-id属性,并将标题文本放入具有event类的<td>元素中。在删除事件时,我们做相反的操作;移除highlight类和data-id属性,并将文本设置为空。

最后,是hover方法。这个方法只是切换<tr>元素上的hover类。现在,在我们检查浏览器中的效果之前,让我们添加一些样式,将其添加到style.css文件中:

table.day tr.highlight td.event {
  background: rgb(217, 237, 247);
  color: rgb(53, 103, 132);
}
table.day tr.highlight.hover td.event {
  background: rgb(252, 248, 227);
  color: rgb(53, 103, 132);
}
table.day td {
  padding: 4px 0;
  width: 100px;
  text-align: center;
}
table.day td.event {
  width: 500px;
}

这并没有什么特别的;它只是为表格添加了一些颜色和间距。有了所有这些,我们现在可以加载我们的日视图。你应该能看到以下截图所示的内容:

创建单个日屏幕

看起来还不错,不是吗?如果你悬停在任一彩色单元格上,你应该看到它们都切换到黄色背景。

那是我们分割视图的左侧部分。现在,是时候创建右侧部分了。你应该记得,右侧将包含details视图和创建表单。让我们从details视图开始。

再次,我们从模板开始:templates文件夹中的details.html。其代码如下:

<h2>{{ title }}</h2>
<% if (start) { %>
<p> {{ start }} - {{ end }} ({{ duration }}) <p>
<p><button> Delete Event </button>
<% } %>

我们在模板中再次使用了一点点逻辑。如果start值不是一个空字符串,我们将渲染两个段落。我们将显示事件的startend时间,以及持续时间。最后,我们将有一个删除事件按钮,这将允许我们删除事件。

我们使用这段逻辑的原因是因为当页面首次加载时,用户不会悬停在任何事件上。在这种情况下,我们将显示默认说明。

views.js文件中,我们将创建以下视图类:

App.Views.Details = Backbone.View.extend({
  template: JST.details,
  events: {
    'click button': 'delete'
  },
  initialize: function () {
    this.data = {
      title: "Hover over an event to see details",
      start: '',
      end: '',
      duration: ''
    };
    this.render();
  },
  render: function () {
    this.el.innerHTML = this.template(this.data);
    return this;
  },
  changeModel: function (model) {
    this.model = model;
    var s = this.model.start(),
        e = this.model.end();
    this.data = {
      title: model.get('title'),
      start: s.format('h:mm A'),
      end: e.format('h:mm A'),
      duration: e.diff(s, 'hour') + ' hours'
    }
    return this.render();
  },
  delete: function () {
    this.model.destroy();
  }
});

我们这次在initialize方法中会做一些不同的事情。首先,我们将为视图创建一些默认填充数据,称为this.data,在用户第一次悬停在小时之前显示。然后,我们立即在initialize方法中调用render方法。这不是你经常看到的一种模式,但真的没有不这样做的原因。在render方法中,我们处理这些数据并渲染模板。这个类中的重要方法是changeModel方法。它接受一个模型作为属性,并从该模型重新创建data属性。我们将startend时间放入变量中,这样我们就不必调用这些方法两次。然后,我们通过再次调用render方法来重新渲染视图。

你可能会想知道为什么我们在changeModel方法中分配this.model。这是因为我们将在delete方法中使用它。我们需要获取当前显示的模型的引用,以便在点击删除按钮时将其销毁(你可以在events属性中看到我们正在连接到delete方法)。当然,为了使模型的销毁工作,我们需要编写一个服务器方法;我们很快就会做到这一点。

但首先,我们想要渲染这个视图。为此,回到App.Views.Day视图类中的render方法。到目前为止,这个方法只创建了一个DayTable视图(屏幕的左侧)。向该方法添加以下代码:

var div = this.$('div').append('<div>')

this.details = new App.Views.Details();
div.append(this.details.el);

首先,我们在分割视图的右侧创建一个<div>元素。然后,我们创建一个Details视图实例并将其附加到那个div元素上。注意,由于我们在内部调用了render方法,所以我们在这里不需要调用它。此外,我们保留了对Details视图实例的引用,将其作为this.details。这是因为我们需要在showDetails方法中使用它,我们将在Day视图类中添加一个新的方法。showDetails方法的代码如下:

showDetails: function (model) {
  this.details.changeModel(model);
}

这只是调用了Details视图上的changeModel方法。但这个方法是在哪里被调用的呢?记得我们手动触发的那次hover事件,当用户将鼠标移到行上时?回到Day视图类的initialize方法,因为我们将用这一行代码来监听那个事件:

this.listenTo(this.collection, 'hover', this.showDetails);

太好了!所有这些都准备好了,你可以通过悬停在事件上测试它;details视图应该看起来像下面截图所示:

创建单个日视图

我们只剩下一个视图:CreateEvent视图。我们将从createEvent.html模板开始。以下是它的代码:

<p><input type="text" id="eventTitle" /></p>
<p><input type="time" id="eventStartTime" /></p>
<p><input type="time" id="eventEndTime" /></p>
<p><button> Create Event </button></p>
<p class="error"></p>

如你所见,这是一个表单的内部结构;视图元素将是<form>元素本身。

这里是类开始的代码:

App.Views.CreateEvent = Backbone.View.extend({
  tagName: 'form',
  template: JST.createEvent,
  initialize: function (options) {
    this.date = options.date;
  },
  events: {
    'click button': 'createEvent'
  },
  render: function () {
    this.el.innerHTML = this.template();
    return this;
  }
});

到现在为止,你已经理解了所有这些;即使是render函数也很简单。在initialize函数中,我们接受一个date选项,因为我们需要知道我们将在哪一天创建事件。有趣的部分开始于createEvent方法。你可以看到我们在表单中的按钮上监听点击事件,并在事件发生时调用createEvent方法。

这里是那个方法:

createEvent: function (evt) {
  evt.preventDefault();

  var model = new App.Models.Event({
    collection: this.collection.onDate(this.date),
    title: this.$("#eventTitle").val(),
    date: this.date,
    startTime: this.$("#eventStartTime").val(),
    endTime: this.$("#eventEndTime").val()
  });

  if (model.isValid()) {
    this.collection.create(model, { wait: true });
    this.el.reset();
    this.$(".error").text('');
  } else {
    this.$(".error").text(model.validationError);
  }

  return false;
}

这是一个大项目,我知道。我们首先阻止默认的表单提交。然后,我们使用表单的数据和构造函数中的date来创建一个新的模型实例。创建这样的模型时,它不会立即保存到服务器。我们要么在模型上调用save方法,要么将其传递给集合的create方法。

你可能想知道为什么我们把collection对象做成这个模型实例的属性。这实际上是一种小窍门。解释的第一部分来自于方法的第二部分。你可以看到我们正在调用模型的isValid方法。Backbone 有在模型上执行验证的能力。如果我们的属性不符合给定的模式,我们可以阻止它们保存。我们在这里非常明确地这样做,通过调用这个方法。如果模型有效,我们将通过将其传递给集合的create方法将模型保存到服务器(我们传递{wait: true}是因为我们的DayTable视图正在监听集合的增加,以便将其添加到表格中;这样,它将在我们确定它已被保存之后才会被添加)。然后,我们清除表单元素,并从错误段落中删除任何错误。如果模型没有验证,这个错误就会发生。而不是保存,我们会在那个段落中显示模型的validationError属性。

目前,我们正在调用模型的isValid方法,但我们还没有创建任何验证规则。Backbone 的验证功能是基础版的。在我们的Event模型类中,我们将创建一个名为validate的方法。每次我们尝试保存模型时,这个方法都会被调用。作为参数,validate方法将接收一个包含模型属性的对象。在方法内部,我们编写我们想要的任何代码。如果一切检查无误,我们不返回任何内容。然而,如果有问题,我们可以返回一个错误消息(它可以是简单的字符串或更复杂的东西)。然后,错误消息将被分配给model.validationError

所以,让我们编写验证方法:

validate: function (attrs) {
  if (attrs.collection) {
    var takenHours = _.flatten(attrs.collection.invoke('hours'));

    var hours = this.hours().map(function (x) {
      return takenHours.indexOf(x);
    }).filter(function (x) {
      return x > -1;
    }).length;

    this.unset('collection');

    if (hours > 0) {
      return "You already have an event at that time.";
    }
  }
}

这是为什么我们把collection对象包含在我们的模型属性中的解释的第二部分。我们需要验证的是startend时间。如果我们正在尝试创建的事件与日历中已经存在的事件冲突,我们不允许创建新的事件。然而,在模型的validate方法内部,我们无法访问集合。因此,我们通过使用onDate方法将其限制为这个日期的事件,将其作为我们正在验证的模型的一个属性传递。当然,这是一个小窍门;但它有效。

我们首先确保我们的属性中包含一个collection属性。如果我们已经有了,第一项任务是找出一天中已经被占用的时段。我们可以通过调用我们创建的hours方法来单独找到每个事件的时段。我们可以使用集合的invoke方法来调用其所有模型上的该方法;它将返回一个结果数组。由于每个结果都是一个数组,所以我们有一个数组的数组。然后,我们可以使用 Underscore 的flatten方法将其转换为一维数组。结果是包含所有已被占用的时段的数组。

接下来,我们进行一些函数式编程。我们首先调用this.hours来获取这个事件发生的时段数组。然后,我们将它映射到takenHours.indexOf(x)的值。这将遍历这个事件的小时,并获取它们在takenHours数组中的索引。这里的关键点是,如果一个小时不在takenHours中,它将返回-1。接下来,我们使用数组的filter进行过滤,只保留大于-1的值。最后,我们获取结果数组的length值。按照这个逻辑,hours变量将是takenHoursthis.hours数组之间重叠的值的数量。

然后,我们将使用unset方法移除collection属性,因为我们不再需要它。

最后,如果重叠的小时数大于 0,我们将返回一个错误;你已经在那个时间有了事件。有了这个方法,你可以回顾createEvent方法,并确切地了解我们在做什么。

CreateEvent类的最后一步是将它显示在屏幕上。回到App.Views.Day类的render方法,并添加以下代码:

div.append(new App.Views.CreateEvent({
  date: this.date.format('YYYY-MM-DD'),
  collection: this.collection
}).render().el);

我们将其放入我们为分割视图右侧创建的div元素中。按照要求,我们给它提供date字符串和collection对象,然后进行渲染。现在,我们的页面应该看起来像以下截图所示:

创建单个日历屏幕

编写服务器代码

对于这个应用程序,服务器代码非常简单。首先,我们需要使用数据库中的事件模型渲染index.ejs模板。所以,确保我们的 GET 请求通配符看起来像以下代码:

app.get('/*', function (req, res) {
  db.find(function (err, events) {
    res.render("index.ejs", { events: JSON.stringify(events) });
  });
});

现在,在views文件夹中的index.ejs文件中,在创建路由的代码中,移除我们放入的虚拟记录,并用模板数据替换它,如下所示:

calendar: new App.Models.Calendar(<%- events %>)

回到server.js文件,当我们创建一个新的Event模型时,需要 POST 请求发送到的路由。其代码如下:

app.post('/events', function (req, res) {
  var b = req.body;
  db.insert({
    title: b.title,
    date: b.date,
    startTime: b.startTime,
    endTime: b.endTime
  }, function (err, evt) {
    res.json(evt);
  -});
});

我们获取请求体,然后根据其属性创建我们的记录。一旦我们保存了记录,我们就会将其发送回服务器。

最后,我们需要在销毁模型时调用的路由。这是一个 DELETE 请求,其外观如下:

app.delete('/events/:id', function (req, res) {
  var id = parseInt(req.params.id, 10);

  db.delete({ id: id }, function () {
    res.json({});
  });
});

我们获取记录的 ID,找到相关的行,并返回一个空响应。这就是服务器的全部内容。有了这段代码,你就可以尝试使用了。前往一个单独的日历页面并添加一些事件。你应该会得到以下截图所示的内容:

编写服务器代码

你可以在这里看到所有组件的运行情况;小时表、悬停效果和详情视图。你甚至可以看到当我们尝试创建一个与其他事件重叠的事件时出现的错误信息。

最后还有一步;实际上是一个小细节。如果你回到月份视图,你会注意到每一天单元格中的事件并不是按照时间顺序出现的。相反,它们是按照我们创建它们的顺序出现的。如果它们能按照发生顺序出现那就更好了,而这非常简单。在 App.Models.Calendar 类(在 models.js 文件中),我们可以写一个 comparator 方法来保持顺序:

comparator: function (a, b) {
  return a.start().isAfter(b.start());
},

我们可以简单地返回 moment 对象的 isAfter 方法的结果,以确定哪个应该先出现;Backbone 会处理其余的部分。

摘要

在本章中,我们做了许多新颖有趣的事情。最困难的部分是获取悬停效果。这需要我们找到表示单个模型实例的所有视图。在 Backbone 应用程序中,大多数时候你只会有一个视图来表示一个模型实例。然而,正如你所看到的,虽然这是规范,但这绝对不是唯一可能的方式。

Backbone 的另一个巧妙用法是我们创建的 Month 类。我们实际上只是用它作为一个方便的包装器;我们没有理由不能写一个简单的函数来返回一个对象字面量。然而,我们这样做的方式展示了 Backbone 的灵活性。

本章最后一个,但可能是最重要的想法是将适当的逻辑移动到模型类中,而不是将其放在视图类中。这个想法的例子包括 App.Models.Calendar 类的 onDate 方法或 App.Models.Event 类的 hours 方法。这是模型-视图-控制器模式的一个大思想。当然,Backbone 并不是严格遵循 MVC,但许多原则仍然适用。尽可能让你的模型类更丰富,让你的视图和路由器更精简。这并不意味着将视图或路由逻辑放入模型中。这意味着任何不是专门关于视图或路由的逻辑可能都应该在一个模型类中。关于这个话题,网上有很多优秀的 MVC 资料可供参考;你可以从 dev.tutsplus.com/tutorials/mvc-for-noobs--net-10488 开始。在下一章中,当我们创建一个实时双向聊天应用程序时,我们将把事情提升到一个全新的水平。

第五章:构建聊天应用

到目前为止,我们构建的所有应用都使用了普通的 Backbone。这并不是说我们没有使用辅助库,而是我们还没有使用任何扩展 Backbone 自身的库。然而,这样的库确实存在;Backbone GitHub 维基上有一个完整的列表(github.com/jashkenas/backbone/wiki/Extensions%2C-Plugins%2C-Resources)。在本章中,我们将使用这些库之一来简化构建这个应用的过程。

我们将关注以下想法:

  • 使用第三方库使大型应用更容易处理

  • 在服务器和客户端之间进行实时通信

概述应用

在我们开始之前,让我们明确我们要构建的内容。它将是一个在线聊天应用;用户将访问网站,选择一个昵称,选择一个房间,然后与其他房间成员聊天。这里不会有真实的用户账户;你可以通过简单地提供一个名字加入,有点像更简单的 IRC 版本。如果其他人正在使用那个名字,你必须选择另一个。用户还可以创建新的房间。

在本章中,我们将使用一些新的工具:Socket.IO 和 Marionette。Socket.IO (socket.io) 是一个实时通信库,它允许客户端快速且容易地与服务器通信。将其视为客户端和服务器之间的发布和订阅系统(类似于 Backbone 的 triggerlistenTo 方法);你可以在维基百科上了解更多关于这种设计模式的信息(en.wikipedia.org/wiki/Publish_and_subscribe)。我们将使用这个库来使我们的聊天应用实时功能更容易编写。

然而,Marionette (marionettejs.com) 则更有趣。它将自己定位为 Backbone.js 的复合应用库,旨在简化大型 JavaScript 应用的构建。这个想法是;正如你可能从所有前面的章节中注意到的,我们在 Backbone 应用中编写的很多代码在每个应用中都是重复的。例如,我们既有模型视图也有集合视图。通常,集合视图会遍历集合中的模型,并为每个模型渲染一个视图,将它们放入一个容器元素中。由于这是一个常见的模式,Marionette 为我们封装了所有这些,并允许我们只写几行代码就能完成所有这些。然而,Marionette 还提供了其他一些工具,使管理大型应用变得更容易。在本章中,我们将探讨其中的一些。

设置应用

我们必须从一点服务器端代码开始这个应用程序。我们将使用 Express 作为我们的主要服务器;然而,我们还想使用 Socket.IO,因此我们必须设置它。将模板复制以启动新项目。然后,在项目目录中,继续安装所有我们的包,然后使用 npm 安装 Socket.IO,如下所示:

npm install
npm install socket.io --save

现在,为了使 Express 和 Socket.IO 顺利协作,我们需要在 server.js 文件中做一些不同的操作。首先,我们需要引入 Node.js 的 http 库和 socket.io。以下是方法:

var http = require('http');
var socketio = require('socket.io');

然后,我们必须将我们的 Express 应用程序(app 对象)封装在 Node.js 服务器对象中,如下所示:

var server = http.createServer(app);

现在我们有了服务器。要使用 Socket.IO 使一切正常工作,最后一步是创建一个监听我们服务器的 Socket.IO 实例。我们这样做:

var io = socketio.listen(server);

目前在 server.js 文件中,你将看到调用 app.listen 函数的代码。然而,由于我们现在正在将 Express 应用封装在 Node.js 服务器对象中,我们需要在那个对象上调用 listen。因此,请移除 app.listen 调用,并用以下代码替换:

server.listen(3000);

到目前为止,你应该能够启动服务器(npm start),然后访问 http://localhost:3000 并看到我们的空白页面。

准备我们的模板

下一步将带我们进入 views/index.ejs 文件。Marionette 当然是一个客户端库,但 Socket.IO 也有一个客户端组件;因此,我们需要为它们两个都添加脚本标签。将这些标签放在 backbone.js 标签的下方:

<script src="img/backbone.marionette.js"></script>
<script src="img/socket.io.js"></script>

即使我们没有在 socket.io.js 中放置任何内容,Socket.IO 在后端也会将正确的文件发送到那个路由。然而,我们确实需要下载 Marionette。如果你访问 Marionette 下载页面(marionettejs.com/#download),你会看到有几个版本可供选择。Marionette 库使用两个主要组件:Backbone.WreqrBackbone.BabySitter(这两个组件都是由制作 Marionette 的同一群好人构建的)。你可以单独下载 Wreqr、BabySitter 和 Marionette,或者将它们捆绑在一起。确保你下载捆绑版本并将其放置在 public 目录中。

此外,我们将把我们的应用程序拆分成许多更小的部分,并将它们放在各自的文件中,类似于我们在前两章中所做的那样。对于我们创建的每个文件,你都会想要在 views/index.ejs 文件中为其添加一个脚本标签。在这种情况下,顺序很重要,我们将会看到为什么顺序很重要以及如何正确排序它们。

关于 Socket.IO 的一些话

Socket.IO 使得在服务器和客户端之间发送和接收数据变得非常容易。正如我们所见,这是 Backbone 最重要的一部分;将我们的模型发送到服务器和从服务器接收。相对而言,用 Socket.IO 替换 Backbone.sync 函数(如我们在第二章 Building a Photo-sharing Application 中所述)会更容易一些。例如,我们可能会做如下所示的事情:

var SOCKET = io.connect('http://localhost:3000');

Backbone.sync = function (method, model, options) {
  var success = function (data) {
    if (options.success) options.success(data, null, null);
    model.trigger('sync', model, data, options);
  };

  var data;
  if (method === 'create' || method === 'update') {
    data = model.toJSON();
  } else {
    data = { id: model.get('id') };
  }
  socket.emit(method, data, success);
};

如果你之前没有使用过 Socket.IO,这段代码现在可能不太容易理解;但请在本章末尾再次查看,它应该会变得清晰。虽然我们可以使用 Socket.IO 以这种方式编写 Backbone 应用程序,但这里我们不会这样做。为了获得我们聊天应用程序的实时特性,我们不能使用与服务器通信的常规 Backbone 方法,如 savecreate;我们需要自己动手。Backbone 的一个优点是,这将顺利工作;如果我们决定使用 Backbone 来构建一个实际上并不在其 正常 使用范围内的应用程序,Backbone 不会产生额外的摩擦。然而,你应该知道你可以使用 Socket.IO 来同步一个常规 Backbone 应用程序。

创建模块

本章的大部分代码都将放入模块中,Marionette 将为我们提供这些模块。但我们需要从一些应用程序准备代码开始。之前,我们看到了如何将我们应用程序的所有组件放入一个单独的全局变量中。Marionette 通过给我们一个 Application 类来更进一步,它不仅仅是一个我们可以挂载我们自己的类的对象。正如你将看到的,它提供了许多其他有趣的功能。

因此,我们首先从常规的 app.js 文件开始。以下是我们将首先放入该文件的代码:

_.templateSettings = {
  interpolate: /\{\{(.+?)\}\}/g
};

var App = new Backbone.Marionette.Application();
App.on('initialize:after', function () {
  Backbone.history.start({ pushState: true });
});

我们已经熟悉了 Underscore 的模板设置,所以其他行是你应该关注的。第一行是我们为应用程序创建单个全局变量的方式。Marionette 给我们的所有特殊类和组件都通过 Backbone.Marionette 命名空间提供,在这里,我们将创建一个 Backbone.Marionette.Application 的实例。

Marionette 应用程序对象的工作方式是,我们将最终使用 App.start() 启动应用程序。当我们这样做时,我们添加到应用程序中的任何初始化器(使用 App.addInitializer 方法)都将被执行。我们还没有添加任何初始化器,但稍后我们会这样做。

在代码的最后部分,我们正在监听initialize:after事件。Marionette 在应用程序生命周期的许多点上触发许多不同的事件,这是其中之一。正如你可能猜到的,这个事件是在我们设置的初始化器全部运行之后触发的。一旦应用程序初始化完成,我们可以通过启动 Backbone 的历史机制来启动路由器,就像我们之前做的那样。

现在我们已经有一个基本的应用程序对象,我们可以创建模块。一般来说,在任何编程语言或库中,模块是将相关代码组合成一个单元的方式;内部细节被隐藏起来,只有我们选择的片段可以从模块外部访问。这正是 Marionette 使用它们的方式。

我们的第一个模块将会非常简单;它是 Socket 模块。该文件将是public/socket.js。以下是这个文件的代码:

App.module('Socket', function (Socket) {
  Socket.io = io.connect('http://localhost:3000');
});

这是 Marionette 创建模块的方式。我们调用App.module方法;它接受两个参数。第一个是模块的名称。通过这个名称,模块将作为我们App对象的一个属性提供。由于我们在这里将其命名为Socket,我们将在其他地方通过App.Socket访问这个模块。

第二个参数是一个函数;当然,在这个函数中我们创建模块。你可能会期望从这个函数返回的任何对象都将成为我们的模块,但实际上并非如此。相反,App.module函数将传递一个参数给我们的函数;我们将称之为Socket。这个对象将成为我们的属性。我们将其作为属性设置的所有内容都将从App对象中访问。因此,在我们所有的其他模块中,我们可以调用App.Socket.io属性。然而,我们刚刚创建的这个属性究竟是什么呢?

我们添加到index.ejs文件中的脚本将给我们一个全局的io对象,我们可以与之交互。我们通过调用connect方法并传递我们想要连接的 URL 来创建我们的连接。由于我们正在本地服务器上运行端口 3000,这是我们连接的路径;如果你要在公共应用中使用它,你将想要在那里放置你应用的公共 URL。因此,这是我们连接对象,正如我们刚才看到的,我们将能够从其他模块访问它。

创建用户

接下来,我们将创建用户。与我们的某些先前应用不同,这些用户并不是可以登录的用户账户。相反,一个用户只是当前正在使用我们的聊天应用的人;他们需要提供的只是昵称。因此,用户集合实际上只是一个当前使用的昵称列表。

因此,创建一个public/user.js文件,并从以下代码开始:

App.module('User', function (User) { 
  var UserModel = Backbone.Model.extend({});

  User.Collection = Backbone.Collection.extend({
    model: UserModel,
    initialize: function () {
      var thiz = this;
      App.Socket.io.on('user:join', function (user) {
        thiz.add(user);
      });

      App.Socket.io.on('user:leave', function (user) {
        thiz.findWhere(user).destroy();
      });
    }
  });
});

这就是我们开始的方式。首先,我们创建一个基本的 UserModel 类(我们不能简单地将其命名为 User,因为这会覆盖我们的模块变量)。然后,我们创建一个集合类。正如我们之前所做的那样,我们给它一个模型类。在集合的 initialize 函数中,事情开始变得有趣。记住,我们在这里不是使用正常的通道与服务器通信,因此,我们需要设置一种方式来发现其他用户何时加入或离开网站。我们将在服务器上使用 Socket.IO 来发射一个 user:join 事件,每当有用户加入网站时;该事件将发送新用户的数据到客户端,它是一个具有名称属性的对象,例如 { name: 'Andrew' }。我们可以使用 App.Socket.io.on 来监听这个事件;这个方法接受我们正在监听的事件的名称和一个函数,每次事件发生时都会运行这个函数。正如你所看到的,每次有用户加入,我们都会将该用户添加到集合中。

我们还需要知道何时有用户离开。我们将监听 user:leave 事件;当发生这种情况时,我们将使用集合的 findWhere 方法来找到那个 UserModel 实例,然后销毁它,从集合中移除。Backbone 集合的 findWhere 方法将返回第一个与我们传递给它的属性哈希匹配的模型。由于我们将在服务器端确保每个名称都是唯一的,我们可以确信我们正在销毁正确的用户。

最后要指出的一点是,我们将把 UserModel 类保留在模块内部,但我们将通过将其放在 User 对象上来使 Collection 类公开。这是因为我们永远不会直接使用模型类(只通过集合),因此我们可以将其隐藏。没有必要让这个模块外部的代码访问比我们需要的更多这个模块的功能。

现在我们已经创建了模型和集合类,让我们为它们创建视图。这些视图也放在 User 模块中。视图看起来是这样的:

var ItemView = Backbone.Marionette.ItemView.extend({
  tagName: 'li', 
  template: '#user'
});

User.CollectionView = Backbone.Marionette.CollectionView.extend({
  tagName: 'ul',
  itemView: ItemView
});

在这里,我们使用了 Marionette 给我们的两个方便的视图类:Backbone.Marionette.ItemViewBackbone.Marionette.CollectionView。我们通常创建特定的视图来渲染单个模型或集合,这些类为我们封装了常见的代码。首先,我们创建一个 ItemView 类。我们只需要给它提供 tagNametemplate 属性。这两个属性我们通常都会使用;然而,你会发现 template 属性有些不同。我们不是通过 jQuery 获取模板文本并使用 Underscore 将其转换为模板函数,我们只需要将模板设置为选择器字符串。在这里,我们将将其设置为 #user。当然,我们将把这个模板放在 index.ejs 文件中,以下是一些代码行:

<script type='text/template' id='user'>
  {{ name }}x
</script>

简单的模板确实。然而,它展示了 Marionette 提供的扩展如何使复杂的应用程序变得更简单。

User.CollectionView甚至更简单。我们不必给它一个tagName,但我们可以,而且由于我们的ItemView实例是列表项,所以将CollectionView的元素做成列表是有意义的。然后,我们只需要说明itemView是什么。在我们的例子中,这是我们刚刚创建的ItemView类。Marionette.CollectionView的工作方式是它会遍历集合,为每个项目创建一个itemView,并将其附加到集合的元素上。

因此,这是我们第一个模块。我们将在这个应用程序中创建更多模块,但User模块是一个典型的 Marionette 模块的好例子。

我们在这里创建了三个文件(app.jssocket.jsusers.js),所以让我们将它们添加到index.ejs文件中。确保app.js排在第一位。我们将使用以下代码添加这三个文件:

<script src="img/app.js"></script>
<script src="img/socket.js"></script>
<script src='/users.js'></script>

构建布局

下一步是布局。这不是我们在之前的应用程序中做过的事情,但 Marionette 给了我们这个功能。这个功能允许我们组织和操作屏幕上同时显示的许多视图。在一个大型应用程序中,这可能会变得复杂,Marionette 有两个类使这变得更简单:RegionLayout。一个区域基本上是屏幕上的一个区域,一个我们可以用来轻松显示和隐藏视图或布局的对象。布局基本上是一组区域。

我们将为我们的布局类创建一个Layout模块。以下是我们整个public/layout.js文件的内容:

App.module('Layout', function (Layout, App) {
  Layout.Layout = Backbone.Marionette.Layout.extend({
    template: '#appLayout',
    regions: {
      users: '#users',
      rooms: '#rooms',
      conversation: '#conversation',
      controls: '#controls' 
    }
  });

  Layout.MainRegion = Backbone.Marionette.Region.extend({
    el: '#main'
  });
});

第一个类是我们应用程序的布局。把它想象成一个视图类,但没有模型或集合来显示。相反,它给我们提供了访问几个区域的方法。就像ItemView一样,template属性是一个模板的选择器。模板如下:

<script type='text/template' id='appLayout'>
  <div id='users'></div>
  <div id='conversation'></div>
  <div id='rooms'></div>
  <div id='controls'></div>
</script>

如您所见,我们有四个主要区域,这些是区域。我们有一个用户列表,一个房间列表,实际的聊天对话,以及一个用户将登录并输入消息的控制区域。在我们的Layout类中,我们有一个regions属性,它定义了我们的布局区域。每个都是一个选择器,指向我们的模板中的四个<div>元素中的每一个。当我们创建这个Layout类的实例时,我们将能够单独控制这些区域的内容。

之后是MainRegion类,这是一个Marionette Region。这次,我们不再设置tagName,而是设置el属性。当我们这样做时,该类将使用现有的 DOM 元素而不是创建一个新的。这只是一个我们将在其中渲染布局的区域。实际上,这是我们下一步;让我们的App对象意识到这个主要区域。在app.js中,我们需要添加之前讨论过的addInitialize方法的调用。这可以通过以下方式完成:

App.addInitializer(function () {
  App.addRegions({
    main: App.Layout.MainRegion
  });
});

我们的App对象有一个addRegions方法,它接受一个对象作为参数。属性名称是区域的名称,值是我们使用的区域类。在这里,我们将创建一个单独的区域main,使用我们的MainRegion类。请注意,由于我们在layout.js中将main分配为Layout的属性,我们可以通过App.Layout.MainRegion来访问它。

启动路由器

毫无疑问,本章应用程序中最复杂的部分是路由器,并且对于更高级的应用程序,这通常也是情况。因此,Marionette 推荐的模式是将 Backbone 路由器的功能分成两部分。第一部分仍然被称为路由器;它的任务是根据当前路由决定应该做什么。然后,有一个控制器实际执行路由器决定的操作。Marionette 有Marionette.AppRouter类用于路由功能。有趣的是,Marionette 没有提供控制器框架。所需的所有东西只是一个具有正确方法的基本对象。我们将创建一个构造函数,并将所有方法放在原型上。所以,让我们在public中创建router.js并开始吧。

由于 Marionette 建议将大多数传统的 Backbone 路由器的工作转移到控制器上,因此路由器本身非常简单。以下是它是如何启动的:

App.module('Router', function (Router) {
  var Router = Backbone.Marionette.AppRouter.extend({
    initialize: function () {
      App.layout = new App.Layout.Layout();
      App.main.show(App.layout);
    },
    appRoutes: {
      '': 'index'
    }
  });
});

我们将这个包裹在Router模块中。然后,我们使用 Marionette 的AppRouter类;正如许多其他 Backbone 类一样,我们创建一个initialize函数,该函数将在我们创建路由器实例时运行。这就是我们渲染布局的地方。我们创建Layout类的新实例,并将其传递给main区域的show方法。正如您将在控制器中看到的,这是 Marionette 渲染布局和视图的方式。我们从不自己调用render方法。相反,我们将布局或视图实例传递给区域的show方法。

此外,请注意,我们将布局实例作为我们的App模块的一个属性:App.layout。这就是我们如何在控制器内部通过App.layout.usersApp.layout.controls来访问我们的四个区域。由于这些是区域,它们将具有show方法,我们可以向其中传递我们想要渲染的视图。

最后,而不是routes属性,我们的AppRouter将有一个appRoutes属性。这就像正常路由器的routes方法一样工作,只不过我们调用的方法将在控制器上而不是在路由器本身上。我们将从一个简单的index路由开始。

现在,让我们从控制器开始。这也位于我们创建的Router模块内部。控制器可以像这样启动:

function Controller () {
  this.users = new App.User.Collection();
}
Controller.prototype.index = function () {
  App.layout.users.show(new App.User.CollectionView({
    collection: this.users
  }));
};

还有更多内容即将到来,但这是我们目前可以用我们已编写的代码做到的。在构造函数中,我们将创建一个users属性。这是将管理我们用户列表的集合。由于我们的路由器将寻找一个名为index的方法,我们将将其添加到Controller函数的prototype中。这个方法简单地创建一个App.User.CollectionView实例,并在我们布局的users区域中渲染它。

在我们加载页面之前,我们需要实例化路由器。在Router模块的底部,添加以下代码:

App.addInitializer(function () {
  var r = new Router({
    controller: new Controller()    
  });
});

在这里,我们实例化我们的路由器,传递一个新Controller对象作为选项对象中的一个属性。路由器将使用此对象作为我们应用程序的控制器。

要使一些我们可以实际运行的代码,最后一步是向index.ejs文件中添加几行代码。这可以按照以下方式完成:

<script src='/layout.js'></script>
<script src='/router.js'></script>
<script>
  App.start();
</script>

我们添加我们的布局和路由模块,然后,在底部,我们启动应用程序。记住,即使我们稍后添加其他脚本标签,router.js脚本应该是最后一个加载的,因为它几乎引用了所有其他文件。

现在,你可以运行npm start来启动服务器并加载浏览器中的http://localhost:3000。在这个时候,你将看不到页面上有任何东西;然而,打开开发者工具,你会看到事情开始成形。我们可以在以下屏幕截图中看到这一点:

启动路由器

你可以看到我们的布局已经被渲染,并且我们的User.CollectionView实例的<ul>元素是存在的。尽管我们没有渲染任何内容,但这是一个重要的步骤。我们编写了很多看似分散和无关的代码,但它们都汇集在一起,为我们应用程序的谦逊开端做出了贡献。现在,我们的基础设施已经工作,我们可以开始考虑具体的功能。

让用户加入乐趣

我们的第一个重要功能将是允许用户选择一个屏幕名并加入聊天室。我们需要一个带有表单的视图,用户可以在其中提交他们的名字。然而,作为这部分的一部分,我们需要一种方式来询问服务器这个名字是否已经被占用。

对于所有这些,我们回到User模块,并向User.CollectionView添加一个方法,使用以下代码:

addUser: function (name, callback, context) {
  App.Socket.io.emit('join', name, function (joined) {
    if (joined) App.name = name;
    callback.call(context joined);
  });
}

这个方法接受用户想要使用的name以及一个callback函数。在方法内部,我们使用另一个 Socket.IO 方法:emit。这是我们在本类initialize方法中较早看到的App.Socket.io.on方法的反面。on方法用于监听事件,而emit实际上使事件发生。emit方法至少需要一个参数;我们正在触发的事件的名称。然后我们可以传递尽可能多的后续参数;这些是与事件相关联的数据。如果服务器正在监听此事件,它将接收到这些参数。我们传递用户的名字和一个函数。名字是有意义的;如果服务器要告诉我们这个名字是否已被使用,我们需要发送这个名字。然而,函数是有一点不同的。我们在服务器端接收这个函数,但当我们从服务器调用这个函数(记住!)时,它将在浏览器这里执行。这不仅非常酷,而且非常有用。在服务器上,我们将传递一个布尔值给这个函数;如果用户可以使用这个名字并且已被添加到当前用户列表中,则为true;如果名字已被使用,则为false

如果用户成功加入了聊天室,我们将设置他们的屏幕名字作为我们App对象的属性,这样我们就可以在其他地方访问它。然后,我们将调用传递给addUser方法的callback函数,并将joined值传递给它。context参数实际上是一个很好的细节。我不是很喜欢每次进入回调函数时都要将this的值放入变量中,所以当我有这个选择时,我会创建接受上下文作为最后一个参数的函数。这样,我就可以在函数内部按需使用this

在设置好这些之后,让我们转到server.js文件。我们还没有编写任何 Socket.IO 特定的代码,但现在我们将开始编写。首先,将以下内容添加到server.js中:

var users = {};
io.sockets.on('connection', function (socket) {
});

我们从一个users对象开始;目前它是空的,但随着用户的加入,它将被使用。由于我们并没有创建实际的用户账户,因此这个记录不需要持久化;一个普通对象就足够了。

之前,我们创建了io对象。这个对象有一个sockets对象,上面有一个on方法,我们可以使用它来监听来自浏览器的连接。如你所见,我们正在监听connection事件。当建立新的连接时,这里的回调函数将被执行。新的 socket(与浏览器的连接)是这个函数的参数。

在这个回调函数内部,我们将首先监听User.CollectionView类的addUser方法所发出的join事件。将以下内容添加到那个回调函数中:

socket.on('join', function (name, response) {
  if (userExists(name)) {
    response(false); 
  } else {
    response(true);
    users[socket.id] = { name: name };
    io.sockets.emit('user:join', { name: name });
  }
});

记住,当我们发出join事件时,我们发送了名字和一个函数。你可以在服务器上看到这些参数,作为当此事件发生时将被调用的函数的参数。在这个函数中,我们使用一个名为userExists的函数来检查用户是否存在,而这个函数我们还没有编写。如果用户已经存在,我们将调用那个response函数(记住,这是在客户端执行的)并传递false(因为用户不能使用那个名字加入)。然而,如果用户当前不存在,我们将响应true。然后,我们将用户添加到users对象中。我们可以使用唯一的socket.id作为键。最后,我们将发出user:join事件,传递一个基本用户对象作为与该事件关联的数据。所有当前连接的客户端(包括发送加入事件的客户端)都将接收到此事件。记住,在我们的User.Collection类的initialize方法中,我们正在监听这个事件。这就是客户端如何了解新用户加入聊天室的方式。

你可能会想知道为什么我们不能直接查看集合中的用户来检查一个名字是否已被使用,而不是每次有新用户尝试加入时都询问服务器。毕竟,如果集合是当前连接用户的列表,它应该知道名字是否已被使用。但问题是,在某些我们尚未涉及的场景中,用户会在服务器有机会将当前用户列表发送到集合之前尝试加入。

这是我们必须添加的。当一个新的套接字连接时,我们需要发送当前连接用户的列表给它。这可以按以下方式完成:

Object.keys(users).forEach(function (id) {
  socket.emit('user:join', users[id]);
});

Object.keys方法接受一个对象并返回其键的数组。我们可以遍历我们的users对象中的所有用户,并为每个用户发出user:join事件。这个事件与我们之前发出的user:join事件有一个重要的区别。在join事件监听器中,我们使用io.sockets.emit,它将事件发送到所有套接字。这里,我们使用socket.emit。这样,只有那个套接字会接收到这些事件。

在放置好这段代码后,我们就准备好编写允许我们的用户加入聊天室的视图了。我们将这段代码放入我们的User模块中:

User.LogInView = Backbone.Marionette.ItemView.extend({
  tagName: 'form',
  template: '#form',
  model: new Backbone.Model({ 
    placeholder: 'name',
    button: 'Join' 
  }),
  events: {
    'click button': 'addUser'
  },
  ui: {
    'input': 'input'
  },
  addUser: function (e) {
    e.preventDefault();
    var name = this.ui.input.val();
    this.collection.addUser(name, function (joined) {
      if (joined) {
        this.trigger('user-added');
      } else {
        this.ui.input.val('');
      }
    }, this);
    return false;
  }
});

在这里,我们创建了一个Marionette.ItemView类,这样我们就不必自己编写render方法。在创建这个类的实例时,我们直接在这里将model放入类定义中(这在普通的 Backbone 视图中也是可能的;这并不特别针对 Marionette)。我们这样做是因为模板的原因。通常,显示表单的视图没有模型,但这个视图有模型,因为我们想为多个视图使用这个模板。我们将使用 ID 为form的模板。模板如下:

<script type='text/template' id='form'>
  <input type='text' placeholder='{{placeholder}}' />
  <button> {{button}} </button>
</script>

这非常基础。它只有一个输入元素和一个按钮。占位文本和按钮文本需要来自模型,这就是为什么我们向这个类定义添加了一个具有正确属性的简单 Backbone 模型。

events 属性并不新颖或特殊。当我们点击按钮时,我们将调用这个类的 addUser 方法。另一方面,ui 属性是 Marionette 视图特有的;我们经常需要在视图方法中引用视图的特定元素,而 ui 属性是访问它们的快捷方式。键是我们引用元素的名称,值是元素的选择器。在这种情况下,我们找到输入元素并将其命名为 input。你可以在 addUser 方法中看到它的使用。我们不需要用 this.$("input") 搜索输入元素,我们可以直接引用 this.ui.input;它甚至是一个 jQuery 对象。

addUser 函数中,我们首先阻止表单的默认提交。然后,我们获取用户在文本框中输入的任何名称,并将其发送到集合的 addUser 方法。在我们的回调函数中,如果用户成功加入了聊天室,我们将在该视图中触发 user-added 事件。这是 Backbone 在 Socket.IO 中发出事件的等效操作(这不是 Marionette 特有的;你同样可以在纯 Backbone 应用程序中触发和监听事件)。稍后,我们将监听此事件。如果用户未能成功加入,我们将清除输入元素,以便他们可以尝试一个新的名称。

现在,回到 router.js 文件中的 Controller.prototype.index 方法。我们需要渲染一个 LogInView 实例,如下所示:

var loginView = new App.User.LogInView({
  collection: this.users
});	
App.layout.controls.show(loginView);

注意,这就是 addUser 方法中的集合来源。有了这段代码,事情开始变得有趣。如果你打开 http://localhost:3000,你会看到一个文本框和一个按钮。输入一个名称并点击 加入;然后名称将出现在上面的列表中。现在,魔法开始了。在另一个浏览器标签中打开网站。你会看到第一个名称已经在列表中。继续添加另一个;它也会出现在列表中。现在,回到第一个窗口。你会看到它也接收到了第二个名称。这不是很神奇吗!这有两个原因。首先,Socket.IO 确保每个新用户都被添加到每个连接浏览器的用户集合中。然后,Marionette 的 CollectionView 将立即渲染添加到集合中的新模型,这就是为什么它出现在列表中,而无需我们进行任何手动渲染或监视集合的变化。

然而,这里有一个小问题。如果你关闭第二个窗口并回到第一个窗口,你会发现两个名称仍然在列表中。当用户关闭网站时,我们需要从集合中移除一个名称。

这是在 server.js 中完成的。当一个 socket 从服务器断开连接时,我们得到一个断开连接的事件;所以让我们监听这个事件(在 connection 事件回调中)。我们可以这样做:

socket.on('disconnect', function () {
  if (users[socket.id]) {
    io.sockets.emit('user:leave', users[socket.id]);
    delete users[socket.id]; 
  }
});

当此套接字断开连接时,我们检查用户的对象以查看是否为该 Socket ID 有条目。记住,如果用户从未尝试加入聊天室(也许他们加载了页面然后关闭了它),他们将不会有条目;这就是我们检查的原因。如果他们确实有,我们将向所有套接字发出 user:leave 事件,然后从我们的用户哈希中删除该条目。

现在,我们知道我们的用户集合正在监听 user:leave 事件,当它发生时,用户将从集合中移除。相应地,Marionette 将更新 User.CollectionView。现在,如果你再次在浏览器中进行快速测试,你会看到当你关闭第二个浏览器窗口时,第二个名字将从第一个窗口中消失。聪明,不是吗?

在离开用户模块之前,让我们添加一个新功能。稍后,我们将为我们的应用程序编写一些 CSS;所以让我们在列表中突出显示用户的自己的名字。在 User 模块中的 ItemView 类中,让我们添加一个名为 onRender 的方法。此方法将在视图渲染后被调用。下面是这个方法的示例:

onRender: function () {
  if (this.model.get('name') === App.name) {
    this.el.className = "highlight";
  }
}

这非常快速且简单。如果我们为渲染此视图所使用的模型与浏览器中的用户名相同,则将类 highlight 添加到该元素。

加入房间

一旦用户选择了他们的屏幕名,下一步就是选择一个房间。这比选择名字要复杂一些,因为他们可以从现有房间的列表中选择,或者他们可以通过输入一个新名字来创建一个新的房间。如果用户输入了现有房间的名字,他们将进入现有房间,因为我们显然不能有多个同名房间。所有这一切中棘手的部分是,虽然我们称之为房间,但它们实际上更像标签。它们只存在于聊天消息的属性上;它们并没有独立存储。当用户创建一个新房间时,直到他们在该房间中写下第一条消息之前,该房间实际上没有记录。如果他们创建了一个房间然后关闭了页面,该房间就不存在了。所有这一切都会使跟踪房间变得有些棘手,但我们喜欢接受挑战,对吧?

public 中打开一个新的文件,rooms.js。就像我们的 user.js 文件一样,这将有一个模型、集合、模型视图、集合视图和表单视图。以下代码显示了我们是怎样从这个文件开始的:

App.module('Room', function (Room) {
  var RoomModel = Backbone.Model.extend({
    url: function () {
      return '/room/' + this.get('name');
    }
  });
});

我们将模块命名为 Room,并从 RoomModel 开始。此模型有一个单独的方法;它返回房间的 URL。计划是最终允许用户通过将其包含在 URL 中直接进入他们选择的房间。这使得特定房间更容易被收藏。当然,他们仍然需要在实际看到房间之前输入他们的屏幕名,但将省略“选择一个房间”的步骤。我们将使用此方法获取给定房间模型的路由。接下来,我们编写集合,如下所示:

Room.Collection = Backbone.Collection.extend({
  model: RoomModel,
  initialize: function () {
    App.Socket.io.on('room:new', this.getRoom.bind(this));
  },
  getRoom: function(room) {
    return this.findWhere({ name: room }) || this.add({ name: room });
  }
});

就像在我们的User.Collection类中一样,这里的initialize方法监听一个事件。在这种情况下,是room:new事件。当这个事件发生时,我们将调用这个类的getRoom方法。这个方法可能看起来不像你预期的样子。在这个上下文中,它的目的是如果房间尚未存在于集合中,则将其添加到集合中。你可能期望它看起来像以下代码:

addRoom: function (room) {
  if (!this.findWhere({ name: room }).length) {
    this.add({ name: room });
  }
}

然而,稍后我们还需要一个方法,该方法接受一个房间名称,要么返回该名称的现有房间,要么创建一个新的房间,这正是getRoom方法所做的事情。实际上,getRoom中的逻辑与这个示例addRoom方法完全相同。如果房间不存在,就添加它。所以我们的getRoom方法是一举两得。

让我们暂时转到server.js。当一个新套接字连接时,我们需要将现有房间的列表发送到这个房间集合。在运行新套接字连接时调用的函数内部,添加以下代码:

db.find(function (err, records) {
  var rooms = {};
  records.forEach(function (record) { rooms[record.room] = 0; });
  Object.keys(rooms).forEach(function (room) {
    socket.emit('room:new', room);
  })
});

在前面的代码中,我们正在查找我们数据库中的所有记录;这些记录是聊天消息。我们需要做的是将这些消息数组转换为它们所在的房间列表。尽管我们还没有任何消息,但每一条消息都将有一个room属性。我们遍历每个模型,并将一个属性添加到一个可丢弃的rooms对象中。由于一个对象不能有多个同名属性,结果将是一个对象,其键是现有房间的唯一列表。然后,我们可以使用Object.keys获取仅包含这些键的数组;最后,我们将遍历这个数组,并为每个房间发出room:new事件。正如我们刚才看到的,Room.Collection实例将在浏览器端捕获这些事件并填充列表。

现在我们有了我们的模型和集合,我们可以按照以下方式制作它们各自的视图:

var RoomView = Backbone.Marionette.ItemView.extend({
  tagName: 'li',
  template: '#room',
  events: {
    'click a': 'chooseRoom'
  },
  chooseRoom: function (e) {
    e.preventDefault();
    Backbone.history.navigate(this.model.url(), { trigger: true });
  }
});

Room.CollectionView = Backbone.Marionette.CollectionView.extend({
  tagName: 'ul',
  itemView: RoomView
});

在这个例子中,RoomView类是项目视图。它将是一个列表项元素;模板的 ID 是room。以下是该模板:

<script type='text/template' id='room'>
  <a href='/room/{{ name }}'>{{ name }}</a>
</script>

如您所见,房间列表将是链接;然后在视图中,我们将监听这些锚点元素之一上的点击。当发生这种情况时,我们将阻止页面的默认重新加载,并使用 Backbone 导航到房间的 URL。这次,我们不是从路由器中拉取方法,而是使用Backbone.history.navigate方法。在我们查看的所有更改路由的方法中,这显然是最好的一个(当然,了解其他方法也很好)。

Room.CollectionView类非常基础。我们只是将包装元素设置为列表,并指向项目视图。

这就是我们显示现有房间列表所需的所有内容。然而,如果用户想要创建一个新的房间,我们需要一个视图来处理这个需求。所以,以下是那个视图:

Room.CreateRoomView = Backbone.Marionette.ItemView.extend({
  tagName: 'form',
  template: '#form',
  model: new Backbone.Model({ 
    placeholder: 'room name', 
    button: 'Join' 
  }),
  events: {
    'click button': 'createRoom'
  },
  ui: {
    'input': 'input'
  },
  createRoom: function (e) {
    e.preventDefault();
    var name = this.ui.input.val().toLowerCase()
          .replace('/ /g, '_').replace(/\W/g, ''),
        room = this.collection.getRoom(name);
    Backbone.history.navigate(room.url(), { trigger: true });
    return false;
  }
});

Room.CreateRoomView类将使用我们在Login View中使用的相同表单模板,所以整个类看起来相当相似。我们在这里添加模型,这样我们就可以设置模板的占位文本和按钮文本。当按钮被点击时,我们将调用createRoom方法。这个方法将阻止默认的表单提交,然后从输入元素中获取文本。由于我们的房间名称将用于 URL 中,我们需要先替换所有空格为下划线,然后删除所有其他非单词字符。然后,我们将房间名称传递给集合的getRoom函数。正如我们所知,这将返回一个房间(要么是新建的,要么是具有该名称的现有房间)。然后,我们将导航到该房间的 URL。

现在我们有了这些类,我们可以使用它们。首先,在index.ejs中添加Room模块:

<script src='/rooms.js'></script>

然后,在router.js中的Controller函数中,添加以下代码行。它将是我们的应用程序用来跟踪房间的集合对象:

this.rooms = new App.Room.Collection();

现在,转到我们控制器的index函数;我们已经编写了其中的一部分,但这里是整个新改进的版本:

Controller.prototype.index = function () {
  App.layout.users.show(new App.User.CollectionView({ 
    collection: this.users 
  }));
  App.layout.rooms.show(new App.Room.CollectionView({ 
    collection: this.rooms 
  }));

  var loginView = new App.User.LogInView({
    collection: this.users
  });
  App.layout.controls.show(loginView);
  loginView.on('user-added', function () {
    App.layout.controls.show(new App.Room.CreateRoomView({ 
      collection: this.rooms 
    }));
  }, this);
};

如前所述,我们渲染用户列表和登录表单。然而,我们还在布局的rooms区域中渲染我们的新房间集合。然后,我们在登录表单上监听user-added事件。记住,当用户成功加入网站时,该事件将被触发。当发生这种情况时,我们将在controls区域渲染不同的视图;创建新房间的视图。我们不能忘记给这个视图提供集合,以便它可以添加新房间。Backbone 的on方法将上下文变量作为第三个参数,因此我们可以在回调函数中使用this

现在,如果你测试我们的应用程序,你会看到在你输入屏幕名称后,表单将改变并要求输入房间名称,如下面的截图所示:

加入房间加入房间

当然,没有可供选择的房间名称列表,因为我们还没有存储任何消息,但如果你查看 DOM,你会看到等待的空<ul>元素。输入一个房间名称并点击按钮,应该发生两件事。首先,房间名称应该出现在屏幕上的列表中。其次,URL 将更改为房间路由。

这个 URL 更改意味着我们需要向我们的Router类中添加一个路由。在appRoutes属性中,添加以下代码行:

'room/:room': 'room'

这意味着我们需要在我们的控制器原型上创建一个room方法。在我们编写这个方法之前,考虑一下这个问题;如果选择房间将用户发送到房间路由,那么用户也可能直接访问这个路由。如果他们这样做,房间将被选择,但用户没有选择屏幕名。这意味着这个方法将必须检查屏幕名的存在,如果没有提供屏幕名,我们将在显示房间之前获取一个。

首先,因为这个路由可能会直接加载(而不是通过 Backbone 通过房间链接),我们需要渲染用户列表和房间列表。由于我们将在我们最终创建的所有路由中首先做这件事,让我们将其移动到一个辅助函数中:

Controller.prototype.showUsersAndRooms = function () {
  App.layout.users.show(new App.User.CollectionView({
    collection: this.users 
  }));
  App.layout.rooms.show(new App.Room.CollectionView({
    collection: this.rooms 
  }));
};

控制器原型上的showUsersAndRooms方法在正确的区域渲染这些视图。

让我们再写一个辅助函数。正如我们之前所想的那样,如果用户还没有选择一个屏幕名,我们需要显示我们在index路由中显示的相同视图:logInView。所以让我们写一个showLogin函数:

Controller.prototype.showLogin = function () {
  var loginView = new App.User.LogInView({
    collection: this.users
  });
  App.layout.controls.show(loginView);
  return loginView;
};

我们将创建loginView,在controls区域显示它,然后返回视图。我们返回它是因为调用这个辅助函数的路由函数可能希望监听那个user-added事件。有了这两个辅助函数,我们真的可以清理index函数,如下所示:

Controller.prototype.index = function () {
  this.showUsersAndRooms();
  this.showLogin().on('user-added', function () {
    App.layout.controls.show(new App.Room.CreateRoomView({ 
      collection: this.rooms 
    }));
  }, this);
};

然而,创建这些辅助函数的原因是它们在我们需要创建的房间路由函数中也将非常有用:

Controller.prototype.room = function (room) {
  this.showUsersAndRooms();
  App.room = this.rooms.getRoom(room);
  if (!App.name) {
    this.showLogin().on('user-added', function () { 
      // render chat room conversation
    });
  } else {
    // render chat room conversation
  }
};

我们首先渲染用户和房间列表。然后,我们在全局App对象上设置一个属性,用于显示用户选择的房间。然后,我们检查App.name是否已设置。如果用户是从index路由(或通过点击列表中的链接切换房间)来的,App.name将会被设置。如果没有设置,我们将显示登录表单。如果设置了名字,或者设置名字之后(由我们监听的user-added事件确定),我们需要渲染聊天室对话。为了做到这一点,我们需要创建Chat模块。

构建聊天模块

要创建Chat模块,我们将在public目录下创建一个chat.js文件。再次,我们将从模型和集合类开始:

App.module('Chat', function (Chat) {
  var Message = Backbone.Model.extend({});

  Chat.Collection = Backbone.Collection.extend({
    model: Message,
    initialize: function (models, options) {
      var thiz = this;
      App.Socket.io.emit('room:join', options.room, this.add.bind(this)); 

      App.Socket.io.on('message:new', function (data) {
        if (data.room === options.room) {
          thiz.add(data);
        }
      });
    }
  });
});

Message模型非常简单,但Chat.Collection类要有趣一些。首先,请注意,这个函数接受两个参数:modelsoptions。我们实际上并不期望收到任何模型,但这是 Backbone 约定,集合接收这两个参数。因此,我们将遵循这个约定。我们期望options对象包含这些消息所在的房间名称。一旦我们有了这个名称,我们就可以发出带有两个参数的room:join事件:房间的名称和一个回调函数。这个函数是这个集合的add方法。我们期望服务器调用回调函数,并传递当前房间中所有消息的列表。然后,对于在集合创建后创建的所有消息,服务器将发出message:new事件。我们将在这里捕获这个message:new事件,如果新消息的房间与这个Chat.Collection实例对应的房间相同,我们将将其添加到集合中。

此外,我们还会添加项目和集合视图,就像我们之前做的那样。以下是它是如何工作的:

var MessageView = Backbone.Marionette.ItemView.extend({
  tagName: 'li',
  template: '#message'
});

Chat.CollectionView = Backbone.Marionette.CollectionView.extend({
  tagName: 'ul',
  itemView: MessageView,
  onRender: function () {
    setTimeout(this.render.bind(this), 60000);
  }
});

MessageView很简单:一个用于渲染消息模板的列表项元素。以下是该模板:

<script type='text/template' id='message'>
  <strong> {{ user }} </strong>: 
  {{ text }}
  <span> {{ moment(date).fromNow() }} </span>
</script>

每条消息都将包含用户名、消息文本以及消息创建的日期和时间。请注意,我们没有直接显示日期值。相反,我们使用 Moment 库将日期转换为类似于10 分钟前的字符串。就像我们在之前的应用程序中所做的那样,我们可以继续下载 Moment(momentjs.com)并将适当的脚本标签添加到index.ejs中。

CollectionView以一种有趣的方式使用了onRender函数。在视图渲染后,这个函数将设置一个超时,以便在 60 秒后再次调用render方法。这样做是为了确保我们的消息视图中的时间前时间戳会更新。

此模块的最后一个视图是Chat.CreateMessageView视图:

Chat.CreateMessageView = Backbone.Marionette.ItemView.extend({
  tagName: 'form',
  template: '#form',
  model: new Backbone.Model({ 
    placeholder: 'message', 
    button: 'Post' 
  }),
  events: {
    'click button': 'addMessage'
  },
  ui: {
    'input': 'input'
  },
  addMessage: function (e) {
    e.preventDefault();
    App.Socket.io.emit('message:new', { 
      user: App.name,
      text: this.ui.input.val(), 
      room: App.room.get('name'),
      date: new Date()
    });
    this.ui.input.val('').focus();
    return false;
  }
});

这与我们之前的两个表单视图非常相似。我们有一个用于设置占位符和按钮文本的模型。然后,当我们点击按钮时,我们运行addMessage方法。这个方法将阻止表单提交,并向服务器发出message:new事件。作为数据,我们获取用户的姓名、输入元素中的文本、用户当前所在的房间名称以及当前日期和时间。所有这些数据都发送到服务器。然后,我们清除输入元素,并使其聚焦于下一条消息。

现在我们已经完成了chat.js,将其添加到index.ejs文件中。

那么服务器上会发生什么?嗯,这就是我们监听message:new事件的地方:

socket.on('message:new', function (data) {
  db.insert(data, function (msg) {
    io.sockets.emit('message:new', msg);

    db.find({ room:data.room }, function (msgs) {
      if (msgs.length === 1) {
        io.sockets.emit('room:new', data.room);
      }
    });
  });
});

当这种情况发生时,我们将数据插入数据库。一旦成功保存,我们将向所有连接的客户端发出message:new事件。那些查看此消息所在房间的用户将几乎立即看到它。我们还搜索数据库中相同房间的记录。如果一个用户已经开始了一个新的聊天室,那么将只有一个带有该房间名称的消息(我们刚刚保存的那个)。然而,这也意味着所有其他客户端的Room.Collection对象中还没有这个房间。因此,我们将向他们所有人发送一个带有房间名称的room:new事件。

回到控制器

在创建Chat模块后,我们可以回到控制器,我们想要为所选房间渲染聊天。让我们为这个功能创建一个辅助函数:

Controller.prototype.showChat = function () {
  App.layout.controls.show(new App.Chat.CreateMessageView());
  App.layout.conversation.show(new App.Chat.CollectionView({
    collection: new App.Chat.Collection([], { 
      room: App.room.get('name') 
    })
  }));
};

controls区域,我们放置Chat.CreateMessageView。然后,在conversation区域,我们渲染一个Chat.CollectionView实例。现在,在我们的Controller.prototype.room方法中,我们可以调用这个showChat方法:

Controller.prototype.room = function (room) {
  this.showUsersAndRooms();
  App.room = this.rooms.getRoom(room);
  if (!App.name) {
    this.showLogin().on('user-added', this.showChat.bind(this));
  } else {
    this.showChat();
  }
};

现在,这个路由已经完成。一旦我们有一个名字,我们将显示聊天消息。

添加一些其他路由

目前,我们有两个路由。然而,我们还想添加一些。接下来,我们将添加一个/user/:name路由,这样用户就可以跳过登录步骤。例如,我可以直接访问http://localhost:3000/user/Andrew,我就不需要登录;我可以选择一个房间。虽然这可能不实用或不现实,但我认为这是一个有趣的功能,而且很容易添加。

在路由器类中,添加以下路由:

'user/:user': 'user'

现在,让我们将这个方法在控制器中编写如下:

Controller.prototype.user = function (user) {
  this.showUsersAndRooms();

  this.users.addUser(user, function (joined) {
    if (joined) {
      App.layout.controls.show(new App.Room.CreateRoomView({
        collection: this.rooms 
      }));
    } else {
      Backbone.history.navigate('', { trigger: true });
    }
  }, this);
};

首先,我们将调用我们的showUsersAndRooms辅助方法来显示用户和房间的列表。然后,我们将调用用户集合的addUser方法。记住,这个方法将决定用户是否可以使用他们选择的屏幕名。由于屏幕名是 URL 的一部分,我们将其作为函数的参数获取。在回调函数中,如果用户成功加入,我们将显示CreateRoomView,他们可以在其中开始一个新的房间(或者他们也可以点击房间列表中的一个房间)。否则,我们将将他们重定向到根路由,他们可以在那里选择一个未使用的屏幕名。

因此,我们使来我们应用程序的人可以选择他们的屏幕名或直接从 URL 中选择房间。为什么我们不更进一步,允许用户同时做这两件事?我们可以使两种方式都起作用:

/room/Pets/name/Andrew
/name/Andrew/room/Pets

在路由器中,将以下代码行添加到appRoutes属性中:

'room/:room/user/:user': 'room_user',
'user/:user/room/:room': 'user_room'

我们将从room_user方法开始:

Controller.prototype.room_user = function (room, user) {
  this.showUsersAndRooms();
  App.room = this.rooms.getRoom(room);

  this.users.addUser(user, function (joined) {
    if (joined) {
      this.showChat(room);
    } else {
      Backbone.history.navigate(App.room.url(), { trigger: true });
    }
  }, this);
};

我们首先再次调用showUsersAndRooms。然后,我们通过 URL 中给出的名称获取房间模型。最后,我们尝试登录用户。如果他们成功加入,我们将显示他们选择的聊天室。如果他们需要选择另一个屏幕名,我们将将他们重定向到该房间的 URL。

在此基础上,user_room方法只是简单地交换了参数的顺序:

Controller.prototype.user_room = function (user, room) {
  this.room_user(room, user);
};

有了这些,我们就已经放置了所有需要的功能!我们的应用几乎完成了。现在它只需要一点装饰。

编写 CSS

由于样式化不是本书的主要目的,所以我们将其放在了最后;如果你不感兴趣,可以跳到章节概述。

虽然我们的应用现在运行得非常好,但它确实对眼睛不太友好。让我们来修复这个问题。首先,我们将从index.ejs文件中的<head>元素链接到一个样式表,如下所示:

<link rel="stylesheet"  href="/style.css" />

现在,在public目录下创建一个名为style.css的文件。我们将从一些通用的样式开始:

body {
  font-family: sans-serif;  
  padding: 0;
  margin: 0;
}

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

这实际上只是一个微小的重置;我们在本应用中使用了多个<ul>元素,所以这一点很重要。

接下来的几行代码主要是为了样式化用户列表:

#users, #rooms {
  float: left;
  width: 13%;
  margin: 1%;
  font-size: 80%;
}

#users li {
  padding: 5px;
  border-bottom: 1px solid #ccc;
}
.highlight {
  font-weight: bold;
  background: #ececec;
}

用户和房间列表将分别位于左侧和右侧的侧边栏。我们将设置它们的宽度和边距,稍微缩小字体大小,然后将它们浮动到左侧。然后我们对用户列表项进行一些基本的样式化。你会记得我们为登录用户在用户视图中添加了highlight类;我们在这里定义这个类。

然后,我们用这些行来样式化房间列表:

#rooms li {
  padding: 0;
  border-bottom: 1px solid #ccc;
}
#rooms li a {
  text-decoration: none;
  color: #000;
  display: block;
  padding: 5px;
}
#rooms li a:hover {
  background: #ececec;
}

然后,我们为用户列表提供一些样式;由于列表项内部有锚点元素,所以它稍微复杂一些。当然,我们添加了一些基本的悬停样式。

接下来,我们为对话本身添加样式:

#conversation {
  float: left;
  width:68%;
  margin: 1%;
  margin-bottom: 60px;
}

#conversation li {
  padding: 10px;
  border-bottom: 1px solid #ececec;
}
#conversation li span {
  color: #ccc;
  font-size: 75%;
  float: right;
}

conversation区域位于中间,在两个侧边栏之间,因此它也很重要,需要将其浮动到左侧。我们样式化的<span>元素是显示消息日期和时间的地方,所以我们稍微缩小了文本,并将其移动到右边。

最后,我们为控件添加样式:

#controls {
  background: #ececec;
  padding: 10px;
  position: fixed;
  bottom: 0;
  width: 100%;
}

#controls input {
  border: 1px solid #ccc;
  padding: 5px;
  width: 300px;
}

#controls button {
  border: 1px solid #ccc;
  background: #efefef;
  padding: 5px 15px;
}

CSS 样式的最后一部分是control区域。这是所有表单将被显示的地方。在这里我们做了一些不同的事情。我们使用了一个固定位置来将其附着到屏幕的底部。现在,对话可以无限延长,但消息表单始终可见。

现在,代码已经完成。以下是在使用中的最终应用的截图:

编写 CSS

概述

我希望你觉得这一章很有趣。我们在这里探讨的最大想法是使用 Marionette 而不是普通的 Backbone 来构建我们的应用。正如你所看到的,当使用为大型应用设计的框架时,会有更多你可能称之为脚手架代码的东西。基于应用对象,使用模块,分割路由器和控制器,这些都使得应用有更多的可移动部分。这里的关键是,我们构建的应用实际上并不能称为大型应用,所以你可能认为使用 Marionette 并不真的那么有意义。然而,如果你参与过任何大型项目,你就会知道,涉及的代码越多,你就越会欣赏 Marionette 这样的框架为你提供的结构。那个额外的边界层当然不是必需的,但我认为你会发现它可能非常有帮助,并且随着项目的增长和变化,它可以帮助保持大型项目的管理。

本章的另一个重要概念是 Socket.IO。在这个应用中,我们完全忽略了 Backbone 为我们提供的内置同步通道,但正如我之前提到的,这并不是唯一的方法。现在是一个很好的时机去回顾使用 Socket.IO 实现Backbone.sync的方式,甚至可以构建一个小型应用来测试它。从我们使用 Socket.IO 的方式中可以吸取的更大的一点是,Backbone 只是一个工具,并没有一种正确的方式来使用它。不要忽视 Backbone 社区的传统和建议,但也不必害怕根据你的意愿调整它,看看会发生什么。在下一章中,当我们创建一个播客订阅应用时,我们会做更多类似的事情。

第六章。构建播客应用

在本章中,会有一个有趣的转折。我们迄今为止构建的所有应用在客户端代码方面都很重,但在服务器端却很轻。事实是,你将要构建的 Web 应用程序并不总是这样的。通常,你既需要在后端也需要在客户端进行大量的工作;而我们在这里将要构建的应用程序就是这样。

因此,在本章中,我们将关注以下想法:

  • 构建既注重服务器端又注重客户端的应用程序

  • 复制一些 Marionette 的功能,而不使用 Marionette

  • 在存储之前解析和简化数据文件

我们在建造什么?

在本章中,我们将构建一个播客收听应用。如您所知,播客源与常规博客的 RSS 源非常相似。主要区别在于字段;因此,尽管我们正在构建一个基本的播客 捕获器,但其中很多可以用于构建常规的 RSS 阅读器。所以,我们将拥有以下内容:人们可以为我们的应用创建账户,然后订阅播客源。我们将加载所有现有剧集,用户可以在我们的应用中收听它们,并查看节目笔记和链接。每次用户打开应用程序时,他们订阅的每个播客都会检查是否有新剧集。他们可以收听剧集,或者只是标记为已收听。

现在让我们看看完成的项目:

我们在建造什么?

也许听起来或看起来并不多,但还有很多工作要做,让我们开始吧。

构建用户账户

我们将从用户账户开始。您会记得,在 第二章,构建照片分享应用 中,当我们构建照片分享应用时,我们创建了一个 signin.js 文件;我们在这里也会用到它。我们可以通过以下步骤来设置:

  1. 将模板目录复制以创建一个新的项目,然后将 signin.js 文件复制到新目录中。您需要在 server.js 文件的顶部添加以下行:

    var signin   = require("./signin");
    
  2. 现在,如您所回忆的那样,这需要安装一些额外的 Node.js 包。请在终端中使用以下命令安装 passportpassport-localbcrypt

    npm install bcrypt passport passport-local --save
    
  3. bcryptpassport-local 包在 sigin.js 文件中使用,但在 server.js 文件中我们需要引入 passport;我们还将创建 users 数据库,如下所示:

    var passport = require("passport");
    var users    = new Bourne("users.json");
    
  4. 然后,我们需要确保我们的 express 应用程序已配置好以支持这一点。这是我们之前在照片分享应用中看到的完整的 configure 块:

    app.configure(function () {
      app.use(express.urlencoded());
      app.use(express.json());
      app.use(express.multipart());
      app.use(express.cookieParser());
      app.use(express.session({ secret: 'podcast-app' }));
      app.use(passport.initialize());
      app.use(passport.session());
      app.use(express.static('public'));
    });
    
  5. 接下来,我们配置 passport 以使用我们在 signin.js 文件中的方法:

    passport.use(signin.strategy(users));
    passport.serializeUser(signin.serialize);
    passport.deserializeUser(signin.deserialize(users));
    
  6. 我们需要创建登录、登出和创建用户账户的路线。如果用户正在获取/login路由,我们将渲染login.ejs(即将推出)文件。一旦他们输入用户名和密码,结果将通过 POST 请求保存到/login路由,其中将进行身份验证。然后,要在/logout登出,我们将调用passport添加到请求对象中的logout方法,并重定向回根路由。因此,以下是这些路由:

    app.get("/login", function (req, res) {
      res.render("login.ejs");
    });
    
    app.post('/login', passport.authenticate('local', {
      successRedirect: '/',
      failureRedirect: '/login'
    }));
    
    app.get("/logout", function (req, res) {
      req.logout();
      res.redirect('/');
    });
    
  7. 与用户账户相关的最后一个路由是/create路由;这是用于创建新账户的路由。代码很多,但非常基础。我们创建一个包含用户名和散列密码的属性对象。然后,我们检查用户是否存在。如果存在,我们将返回根路由。否则,我们将创建用户账户并重定向到根路由,区别在于我们现在已登录。以下是为/create路由的代码:

    app.post('/create', function (req, res, next) {
      var userAttrs = {
        username: req.body.username,
        passwordHash: signin.hashPassword(req.body.password)
      };
      users.findOne({ username: userAttrs.username }, 
        function (existingUser) {
          if (!existingUser) {
            users.insert(userAttrs, function (err, user) {
              req.login(user, function (err) {
                res.redirect("/");
              });
            });
          } else {
            res.redirect("/");
          }
        });
    });
    
  8. 此部分的最后一击是login.ejs文件,位于views目录中。正如您将看到的,我们将再次使用 Twitter Bootstrap 的所有额外类和包装元素。然而,这次,我们将不使用默认版本。您可以去 Bootswatch(bootswatch.com)找到基于 Bootstrap 的其他主题;所有相同的类,但不同的样式。这样,您可以从 Bootswatch 中选择您喜欢的任何主题,并为您的应用程序获得不同的外观,但您不需要更改任何 HTML 代码。我将选择 Simplex 主题(bootswatch.com/simplex),但如果您更喜欢,您可以选择不同的一个。下载 CSS 文件并将其添加到public目录中。正如以下模板所示,我们还将有自己的样式表,即public目录中的style.css,进行一些自定义。我们稍后会添加到这个文件中。

    这是login.ejs文件中应该包含的内容:

    <!DOCTYPE html>
    <html>
    <head>
      <title></title>
        <link rel="stylesheet"  href="/bootstrap.min.css" />
        <link rel="stylesheet"  href="/style.css" />
    </head>
    <body>
    <div class='container'>
    <div class='row'>
      <h1> Sign In </h1>
      <form method="post" action="/login">
        <div class='form-group'>
          <label>Username</label>
          <input name='username' type='text' class='form-control' />
        </div>
        <div class='form-group'>
          <label>Password</label>
          <input name='password' type='password' class='form-control' />
        </div>
        <button class='btn btn-primary'> Login </button>
      </form>
    
      <h1> Create Account </h1>
      <form method="post" action="/create">
        <div class='form-group'>
          <label>Username</label>
          <input name='username' type='text' class='form-control' />
        </div>
        <div class='form-group'>
          <label>Password</label>
          <input name='password' type='password' class='form-control' />
        </div>
        <button class='btn btn-primary'> Create </button>
      </form>
    </div>
    </div>
    </body>
    </html>
    
  9. 下一步是根/通配符路由。如果用户已登录,我们将渲染index.ejs文件;否则,我们必须重定向到/login。这是根路由的一个很好的初始版本;如果req.user值未设置,我们将重定向到登录页面。否则,我们将渲染索引模板。以下是此路由的代码:

    app.get('/*', function (req, res) {
      if (!req.user) {
        res.redirect("/login");
        return;
      }
      res.render('index.ejs', { 
        username: req.user.username
      });
    });
    

订阅和存储播客

与我们之前的应用程序相比,这个应用程序在需要存储的数据方面略有不同。以前,我们总是只存储从用户那里获得的数据。这次,用户将只给我们一个 URL——播客源路径——我们必须从那里获取所有数据。然后,稍后,我们需要检查相同的源以获取更新。这需要我们做更多的工作。

你可能会想我们如何获取这些播客数据。当然,我们只能从两个地方获取这些数据:客户端和服务器。两者都是可能的;然而,如果我们选择在服务器端获取这些数据,事情将会更加顺利。原因如下:在客户端准备数据需要我们首先获取源(这比简单的请求要复杂一些,因为它是跨域请求);然后,我们必须解析它以获取所需的播客和剧集数据,然后再将数据发送回服务器进行存储。这可能需要相当长的时间,尤其是如果播客有很多剧集的话。如果用户在处理过程中关闭了应用程序,所有或部分数据将会丢失,事情可能会变得一团糟。在服务器端做所有这些工作会更好,因为即使用户关闭了浏览器标签,处理也可以继续。所以,我们将重点关注数据处理。

现在,获取播客数据将涉及相当多的代码,因此我们将创建一个专门的 Node.js 模块来处理播客。所以,在项目目录中创建一个 podcasts.js 文件,然后我们开始吧。

首先,我们将在本模块中使用另外两个 Node.js 包:

因此,通过执行以下命令来安装这两个包:

npm install q xml2js --save

注意

如果你之前没有使用过承诺(promises),你可以这样理解:在 JavaScript 中,你通常会在函数调用时传递一个回调函数,以便在数据准备就绪后运行该函数;承诺是一个封装预期数据的对象。你可以传递这个承诺对象,并对其添加多个回调函数,所有这些回调函数将在数据就绪时运行。你甚至可以在数据就绪后添加回调函数(当然,这些会立即运行)。对于承诺的详细介绍和解释,我推荐你阅读 Matt Greer 的优秀文章 JavaScript Promises ... In Wicked Detail (mattgreer.org/articles/promises-in-wicked-detail/)。它将解释它们的优点以及如何使用它们。

podcasts.js 文件中,我们需要引入以下库:

var http        = require('http');
var Bourne      = require('bourne');
var Q           = require('q');
var parseString = require('xml2js').parseString;
var pcdb = new Bourne('podcasts.json');
var epdb = new Bourne('episodes.json');

我们需要 Node.js 的原生 http 库,以便我们可以请求播客源文件。此外,我们还将创建两个 Bourne 数据库:一个用于播客,另一个用于剧集。我们甚至不需要从 server.js 文件中访问这些数据库。

以下是我们将要编写的第一个获取实际源文件的函数:

function get (url) {
  var deferred = Q.defer();
  var req = http.get(url, function (res) {
    var xml = '';
    res.on('data', function (chunk) {
      xml += chunk;
    });
    res.on('end', function () {
      deferred.resolve(xml);
    });
  });
  return deferred.promise;
};

该方法接收一个 URL 并将其传递给 http.get 方法。我们提供给该方法的回调函数会接收到一个响应对象。我们可以在该对象上监听 data 事件,并将数据连接成一个字符串,我们将其命名为 xml。然后,当请求完成时(由 end 事件表示),我们使用 XML 字符串解析方法中创建的 deferred 对象。方法结束时,我们返回 deferred 对象的 promise 对象。现在,我们可以像以下代码所示使用这个方法:

get('http://podcast.com/feed.xml').then(function (xml) {
  // use the xml
});

我们返回的 promise 对象有一个 then 方法。我们传递给 deferred 对象的 resolve 方法的值将被作为参数传递给我们在请求完成后传递给 then 方法的函数。因此,这就是我们获取播客的 XML 数据的方式。现在,我们需要将其转换为 JSON 并获取我们想要的值。parse 函数看起来是这样的:

function parse(xml) {
  var deferred = Q.defer();
  parseString(xml, function (err, result) {
    var rss = result.rss.channel[0];
    var episodes = rss.item.map(function (item) {
      return {
        title:       item.title[0],
        duration:    item['itunes:duration'][0],
        audio:       item.enclosure[0].$.url,
        link:        item.link[0],
        description: item['content:encoded'][0], 
        pubDate:     item.pubDate[0],
        listened:    false
      };
    });

    var info = {
      title: rss.title[0],
      link:  rss.link[0],
      image: rss['itunes:image'][0].$.href,
      lastUpdated: +new Date()
    };

    deferred.resolve({ info: info, episodes: episodes });
  });
  return deferred.promise;
}

parse 函数接收 XML 输入。我们将 XML 输入传递给 xml2jsparseString 函数以将其转换为 JSON。然后,我们可以开始从结果中提取我们想要的数据。不幸的是,xml2js 并没有给我们一个很干净的 JSON 结构来工作;几乎每个值都是一个数组,但大多数只有一个值。这就是为什么我们在每种情况下都获取数组的第一元素。当元素有属性而不是子元素时,xml2js 使用一个名为 $ 的属性。一旦我们获取了关于播客的一般信息和每个剧集的数据,我们将它们放入一个对象中,该对象将用于解决另一个承诺。

现在我们已经有了这两个方法,我们可以创建一个 Podcast 构造函数,作为一个方便的包装器来管理单个播客。这个构造函数需要以两种方式工作,以便在 server.js 文件中最为有用。如果我们传递给它一个 URL,它将假设我们正在创建一个新的播客记录,并将获取并存储数据。然而,如果我们传递给它一个数字,它将假设这个数字是已存储播客的 ID,并从数据库中获取该播客。由于存储和获取这些数据将是异步操作,我们将使用承诺来等待合适的行动时机。

因此,Podcast 构造函数是一个相当大的函数;我们将逐部分进行解析。我们将从以下代码开始:

function Podcast(feed, userId) {
  var self      = this;
  var info      = Q.defer();
  var episodes  = Q.defer();
  this.info     = info.promise;
  this.episodes = episodes.promise;
  this.ready    = Q.all([this.info, this.episodes]);
}

feed参数将是之前讨论过的 URL 或 ID。userId参数将是订阅此播客的用户的 ID。然后,我们将创建两个延迟对象,分别称为infoepisodes。我们将它们的承诺作为我们将使用此函数创建的对象的属性,以便在它们准备好时使用。我们还将创建一个ready属性;这是一个承诺对象,当我们将它传递到数组中的所有承诺都解决时,它将解决。这为在infoepisodes承诺都准备好时做某事提供了一个方便的方法。您可以在以下代码中看到这一点,这是Podcast函数的下一部分:

if (typeof feed === 'string') {
  get(feed).then(parse).then(function (data) {
    data.info.userId = userId;
    data.info.feed = feed;

    pcdb.insert(data.info, function (err, data) {
      info.resolve(data);
    });

    self.info.then(function (record) {
      data.episodes.forEach(function (e) {
        e.podcastId = record.id;
      });

      epdb.insertAll(data.episodes, function (err, records) {
        episodes.resolve(records);            
      });
    });
  });
}

如果feed参数的类型是字符串,我们知道我们正在创建一个新的播客记录。我们将获取并解析该源,使用我们之前创建的方法。然后,我们将源 URL 和userId参数添加到我们获取的数据的info属性中。现在这个info属性已经准备好存储在数据库中。我们将它在pcdb播客数据库中存储。在回调中,我们将解决info延迟对象,因为info属性现在已经存储(这意味着我们的播客记录在我们的数据库中有一个 ID)。

承诺的一个美妙之处在于我们可以对它们有多个then调用。因此,尽管我们创建了this.info承诺用于播客对象外部,我们也可以在内部等待其解决。这是下一步。当info承诺解决时,我们需要存储这些剧集。您可以看到为什么等待播客记录存储很重要;我们需要将播客的 ID 作为podcastID属性添加到每个剧集对象中。

完成这些操作后,我们可以将所有记录插入到episodes数据库中,然后使用它们来解决episodes承诺。

如果feed参数不是一个字符串,我们这样做:

else {
  pcdb.findOne({ id: feed }, function (err, record) {
    info.resolve(record);
  });

  epdb.find({ podcastId: feed }, function (err, records) {
    episodes.resolve(records);
  });
}

如果feed参数不是一个字符串,那么我们之前已经创建了此播客记录,我们需要找到它。我们首先通过该 ID 找到播客,并解决info承诺。然后,我们找到所有具有该podcastID属性的剧集,并使用它们来解决episodes承诺。信不信由你,这就是我们为Podcast构造函数需要做的全部。

接下来,我们需要能够检查源以获取新剧集。为此,我们需要一个update方法。这个方法有点长且复杂,实际上并没有做太多复杂的事情。以下是外部结构:

Podcast.prototype.update = function () {
  var deferred = Q.defer();
  this.ready.spread(function (info, oldEpisodes) {
    function resolve () {
      epdb.find({ podcastId: info.id }, function (err, records) {
        deferred.resolve(records);                       
      });
    }

    var now = +new Date();
    if (now - info.lastUpdated > 86400000) {
      // update the podcast
    } else {
      resolve();
    }
  });
  return deferred.promise;
};

我们等待 this.ready 的承诺被解决;如您所回忆的,这意味着我们正在等待 infoepisodes 都被解决。这个承诺有一个 spread 方法,它将把这些承诺解决后的值展开,使得每个值都作为一个单独的参数被接收。如您所见,这些是 infooldEpisodes 参数。然后,我们创建一个 resolve 函数,我们将在这个方法内部的好几个地方使用它。这个函数将简单地找到这个播客的所有剧集,并用它们来解决延迟。因此,update 方法的承诺将返回这个播客的所有剧集,而不仅仅是新的剧集。

现在,我们将在用户每次加载应用程序时调用这个 update 方法。然而,大多数播客大约每周更新一次,所以每次加载页面时检查新剧集是没有必要的。因此,我们将每天检查一次。当我们订阅一个播客时,我们使用一元加运算符(开始处的单个加号,它是将 Date 对象转换为时间戳的快捷方式)将 lastUpdated 属性设置为当前日期和时间作为 Unix 时间戳。在这里,我们获取当前时间戳减去以获取差异。如果差异超过 86,400,000(这是一天中的毫秒数),这意味着我们过去一天没有更新这个播客,所以我们将进行更新。否则,我们将调用 resolve,它将只使用当前的剧集。

那么,如果我们想进行更新呢?以下代码将替换 // update the podcast 注释:

get(info.feed).then(parse).then(function (data) {
  if (data.episodes.length > oldEpisodes.length) {
    var oldTitles = oldEpisodes.map(function (e) { 
      return e.title; 
    }),
    newEpisodes = data.episodes.filter(function (e) { 
      return oldTitles.indexOf(e.title) === -1; 
    });

    epdb.insertAll(newEpisodes, resolve);
  } else {
    resolve();
  }
  pcdb.update({ id: info.id }, { lastUpdated: now });
});

如您所见,我们首先获取并解析 XML 提要。然后,我们检查检索到的剧集列表是否大于当前剧集列表。如果是这样,我们知道我们有新的剧集需要存储。下一步是确定这些剧集是什么。我们首先从当前存储的剧集获取标题,并将它们放入 oldTitles。下一步是找到所有标题不在该数组中的剧集;我们只是使用数组的 filter 方法。然后,我们可以将所有剩余的剧集插入到剧集数据库中,并调用 resolve 方法。如果没有新的剧集,我们仍然会调用 resolve 方法。最后一步是更新播客记录上的 lastUpdated 属性。

对于 Podcast 类,我们需要的就这些了。然而,由于我们预计用户会订阅多个播客,让我们创建一个简单的 Podcasts 类来包含这种行为:

function Podcasts (id) {
  this.id = id;
}

Podcasts.prototype.all = function () {
  var d = Q.defer();
  pcdb.find({ userId: this.id }, function (err, records) {
    d.resolve(records);
  });
  return d.promise;
};

Podcasts.prototype.get = function (feed) {
  return new Podcast(feed, this.id);
};

Podcasts.prototype.updateEpisode = function (id, update, cb) {
  epdb.update({ id: id }, update, cb);
};

module.exports = Podcasts;

当我们创建一个Podcasts实例时,我们将传递用户的 ID。然后,all方法将返回一个包含该用户所有播客的 promise,而get方法将返回单个播客实例。updateEpisode方法是一个快速更新单个剧集的方法;我们只会使用这个来标记剧集已收听。最后,在真正的 Node.js 模块形式中,我们通过导出Podcasts类来结束。这就是我们从server.js文件中需要访问的所有内容。

说到server.js文件,让我们暂时回到那里。首先,使用以下代码行引入你的podcasts.js文件:

var Podcasts = require('./podcasts');

然后,在通配符路由中,我们想要获取当前用户的播客。这是该路由的完成版本:

app.get('/*', function (req, res) {
  if (!req.user) {
    res.redirect("/login");
    return;
  }
  req.user.podcasts = new Podcasts(req.user.id);
  req.user.podcasts.all().then(function (records) {
    res.render('index.ejs', { 
      podcasts: JSON.stringify(records),
      username: req.user.username
    });
  });
});

如果用户已登录,我们可以在用户对象上创建一个podcasts属性。这是一个新的Podcasts对象,它接收用户 ID 作为参数。然后,我们获取用户的播客并将这些记录发送到index.ejs文件,包括我们之前发送的用户名。

准备index.ejs

我们已经创建了login.ejs模板,它将在用户登录之前显示。一旦用户登录,我们将渲染index.ejs文件。这是我们开始的地方:

<!DOCTYPE html>
<html>
<head>
  <title> PodcastApp </title>
  <link rel="stylesheet"  href="/bootstrap.min.css" />
  <link rel="stylesheet"  href="/style.css" />
</head>
<body>
<div class='container-fluid' id='main'>
  <div class='row'>
    <div id='podcasts' class='col-md-3'></div> 
    <div id='episodes' class='col-md-3'></div> 
    <div id='episode' class='col-md-6'></div> 
  </div>
</div>
<script src="img/jquery.js"></script>
<script src="img/underscore.js"></script>
<script src="img/backbone.js"></script>
<script src="img/bootstrap.min.js"></script>

<script src="img/models.js"></script>
<script src="img/views.js"></script>
<script src="img/router.js"></script>
</body>
</html>

就像我们在之前的应用程序中所做的那样,我们将把所有内容都放在<div id='#main'>元素内。这次,然而,我们将给它一个 Bootstrap 类:container-fluid。非常神奇的是,仅仅通过应用正确的 Bootstrap 类,我们的应用程序就变得相当响应;我们不需要做任何额外的工作。这次,我们在主要的<div>元素中开始添加一些内容。我们的应用程序将有三个列:第一个将列出播客,第二个将列出所选播客的剧集,第三个将显示单个剧集的详细信息。

在底部,我们将引入所有的脚本标签;除了默认的(jQuery、Underscore 和 Backbone)之外,我们还有 Bootstrap 的 JavaScript 组件。这对于我们稍后添加的导航是必要的。然后,我们有我们自己的三个文件:models.jsviews.jsrouter.js

创建我们的模型和集合

我们将从models.js文件开始。这里我们将展示两种类型的数据:播客和剧集。因此,我们将为每种类型创建一个模型和集合。让我们从剧集开始:

var Episode = Backbone.Model.extend({
  urlRoot: '/episode',
  listen: function () {
    this.save({ listened: true });
  }
});
var Episodes = Backbone.Collection.extend({
  model: Episode,
  initialize: function (models, options) {
    this.podcast = options.podcast;
  },
  url: function () {
    return this.podcast.url() + '/episodes';
  },
  comparator: function (a, b) {
    return +new Date(b.get('pubDate')) - +new Date(a.get('pubDate'));
  }
});

我们的模型类被命名为Episode;我们给它一个根 URL 和一个listen方法。listen方法将通过将listened属性设置为 true 并将更新保存到服务器来标记剧集已收听。你可能还记得,当我们订阅播客时,我们默认将每个剧集的listened设置为false

然后,集合类被命名为 Episodes。一个集合的剧集需要与一个播客相关联,因此我们将从传递给 initialize 方法的 options 对象中获取那个 podcast 实例。注意,我们在集合上设置了一个 url 方法。通常,你会在模型类或集合类上设置一个 url 方法,但不会同时设置两个。然而,我们在这里需要两个不同的 URL。集合 URL 将用于获取播客的所有剧集。模型 URL 将在我们标记剧集为已听时使用。集合类的最后一部分是 comparator。我们希望我们的剧集以正确的顺序显示,最新剧集位于列表顶部,因此我们将使用发布日期作为比较。通常,我们会从值 B 减去值 A,但通过反转这个顺序,我们可以将最新剧集放在顶部。

播客类甚至更简单,如下面的代码所示:

var Podcast = Backbone.Model.extend({
  episodes: function () {
    return this.episodes || (this.episodes = new Episodes([], { podcast: this }));
  }
});

var Podcasts = Backbone.Collection.extend({
  model: Podcast,
  url: '/podcasts',
});

Podcast 模型类的 episodes 方法相当有趣。正如我们之前看到的,每个 Podcast 实例都将有一个相关的 Episodes 集合。这个方法将返回那个集合。我们在这一行方法中所做的是,如果存在,则返回 this.episodes 属性。如果不存在,我们将创建它、分配它并返回它,所有这些都在一行中完成。

构建导航

现在,我们准备开始构建我们的用户界面;我们可以通过执行以下步骤来完成:

  1. 打开公共目录下的 views.js 文件。我们将从一些辅助代码开始。你对第一部分很熟悉,但 tmpl 函数是新的。它只是一个小的辅助函数,我们将用它来获取我们的模板。我们将使用这个方法为几乎每一个视图。以下是代码:

    _.templateSettings = {
      interpolate: /\{\{(.+?)\}\}/g
    };
    
    function tmpl(selector) {
      return _.template($(selector).html());
    }
    
  2. 很有趣,我们不会为第一个视图使用 tmpl 函数;第一个视图是导航视图。我们不是创建一个 template 属性并选择一个 tagName 属性,而是设置 el 属性。我们使这个属性成为页面上已存在元素的选择器,这个元素将成为这个视图的元素。当我们点击 添加播客 链接时,我们希望显示一个表单。要显示这个表单,我们将导航到 /podcasts/new 路由。这是整个类的代码:

    var NavView = Backbone.View.extend({
      el: '#navbar',
      events: {
        'click #addPodcast': 'addPodcast'
      },
      addPodcast: function (e) {
        e.preventDefault();
        Backbone.history.navigate('/podcasts/new', 
          { trigger: true });
        return false;
      }
    });
    
  3. 现在,我们需要创建具有 ID navbar 的元素,因为这个视图期望它。其中很多只是为了 Bootstrap,但你可以看到我们有 添加播客登出 链接。其代码如下:

    <nav id='navbar' class="navbar navbar-inverse navbar-fixed-top" role="navigation">
      <div class="container-fluid">
        <div class="navbar-header">
          <button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#navbar-tools">
            <span class="sr-only">Toggle navigation</span>
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
          </button>
          <a class="navbar-brand" href="#">PodcastApp</a>
        </div>
    
        <div class="collapse navbar-collapse" id="navbar-tools">
          <ul class="nav navbar-nav">
            <li><a id='addPodcast' href="#">Add Podcast</a></li>
            <li><p class="navbar-text">Logged in as <%= username %></p></li>
            <li><a href='/logout'>Log Out</a></li>
          </ul>
        </div>
      </div>
    </nav>
    

由于这已经在页面上,我们不需要在任何地方插入它;我们只需在类中实例化路由器。我们很快就会这样做,但截图是提前看看我们这样做后的样子:

构建导航

再说一件事:这个导航栏将固定在屏幕顶部,所以我们需要将其他所有内容向下推一点,这样在用户滚动之前,内容就不会被隐藏在其后面。这非常简单;从 public 目录打开你的 style.css 文件,并添加以下代码行:

body { padding-top: 60px; }

显示播客

下一步将是显示用户订阅的播客列表。我们从 PodcastListView 类开始,它将显示集合。以下是该类:

var PodcastListView = Backbone.View.extend({
  className: 'list-group',
  initialize: function (options) {
    this.current = options.current || null;
    this.listenTo(this.collection, 'add', this.render);
  },
  render: function () {
    if (this.collection.length === 0) {
      this.el.innerHTML = "<a class='list-group-item'>No Podcasts</a>";
      return this;
    }
    this.el.innerHTML = '';
    this.collection.forEach(this.renderItem, this);
    return this;
  },
  renderItem: function (model) {
    model.set({ current: this.current === model.get('id') });
    var v = new PodcastListItemView({ model: model });
    this.el.appendChild(v.render().el);
  }
});

对于 Bootstrap,我们将 list-group 类添加到视图的元素中。在 initialize 方法中,我们将检查 options 对象中的 current 值。如果用户点击列表中的某个播客以显示剧集,我们将想要突出显示该播客,因此 current 将是所选播客的 ID(如果已选择)。然后,我们还将监听显示的集合中的新添加项。如果添加了新项,我们将再次调用 render 方法。render 方法会查找几种不同的场景。如果集合为空(最初将是这种情况),我们将只显示 没有播客。否则,我们将清除元素并使用 renderItem 方法渲染每个模型。renderItem 方法在每个模型上设置一个 current 属性;如果这个模型是当前的,它将是 true;否则,它将是 false。然后,我们将创建一个新的 PodcastListItemView 实例,渲染它并将其附加到元素上。现在,我们准备好这个视图;这是它的代码:

var PodcastListItemView = Backbone.View.extend({
  tagName: 'a',
  className: 'list-group-item',
  template: tmpl('#podcastItem'),
  initialize: function () {
    this.model.episodes().on('count', this.displayCount, this);   
  },
  events: {
    'click': 'displayEpisodes'
  },
  render: function () {
    this.el.innerHTML = this.template(this.model.toJSON());
    this.el.href = this.model.url();
    this.$el.addClass( this.model.get('current') ? 'active': '');
    this.displayCount();
    return this;
  }
});

这个视图的元素是一个带有 list-group-item 类的锚标签。我们获取 podcastItem 模板,它相当简单。将以下代码添加到 index.ejs 文件中:

<script type='text/template' id='podcastItem'>
  {{ title }} <span class='badge'></span>
</script>

在这个 initialize 方法中,我们将获取这个播客模型的剧集集合并监听 count 事件;当它发生时,我们将调用 displayCount 方法。但在我们编写该方法之前,我们将渲染视图。首先,我们将渲染模板。然后,我们将设置此元素的 href 属性(记住,它是一个锚点);这将是为播客实例的 URL。如果是当前播客,我们将向元素添加活动类。最后,我们将调用 displayCount 方法。以下是该方法:

displayCount: function (evt) {
  var eps = this.model.episodes();
  eps.fetch().done(function () {
    var count = eps.pluck('listened')
      .filter(function (u) { return !u; }).length;
    this.$('.badge').text(count);
  }.bind(this));
}

在这个方法中,我们获取播客的剧集集合并从服务器获取数据。当数据到达时,我们从每个剧集模型中提取 listened 属性的值;这将是一个布尔值数组。然后,我们过滤掉所有 true 值,这样我们只剩下 false 值。结果数组的长度是尚未收听的播客数量。然后,我们将那个数字放入模板的徽章元素中。

最后一点;如果你之前没有见过 .bind(this) 的技巧,这个技巧只是保持函数内部 this 的值与函数外部相同。

最后,看看events属性。当这个视图的元素被点击时,我们将重定向到模型的 URL,如下所示:

displayEpisodes: function (evt) {
  evt.preventDefault();
  Backbone.history.navigate(this.model.url(), { trigger: true });
  return false;
}

创建布局

在这些视图就绪后,我们几乎准备好开始路由器了。打开public目录中的router.js文件。现在,在上一章中,我们使用了 Marionette,它为我们提供了区域和布局来管理我们的视图去哪里。我们现在没有它们,但既然它们非常有用,为什么不自己创建它们呢?我们可以用以下代码创建它:

function Region(selector) {
  this.el = $(selector); 
}
Region.prototype.show = function (views) {
  if (!_.isArray(views)) { views = [views]; }
  this.el.empty();
  views.forEach(function (view) {
    this.el.append(view.render().el); 
  }.bind(this));
};

当我们创建一个区域时,我们将传递一个选择器。然后,show方法将接受一个或多个视图。如果我们只传递一个视图,我们将将其包裹在一个数组中。然后,我们将循环并将每个视图附加到元素上。请注意,我们在这里调用render方法并获取视图的元素,所以我们只需要将视图实例传递给此方法。

如果复制区域很容易,创建自己的布局将变得轻而易举;我们将使用以下代码来创建我们的布局:

var layout = {
  podcasts: new Region('#podcasts'),
  episodes: new Region('#episodes'),
  episode:  new Region('#episode')
};

开始路由器

现在,我们准备好开始路由器了。以下是我们router.js文件的Router代码;我们可以从这里开始:

var Router = Backbone.Router.extend({
  routes: {
    '': 'index'
  },
  initialize: function (options) {
    this.podcasts = options.podcasts;
    this.nav = new NavView();
  },
  index: function () {
    layout.podcasts.show(new PodcastListView({ 
    collection: this.podcasts 
  }));
  }
});

当路由器创建时,我们将接受一个podcasts集合。我们还将创建我们的NavView实例;记住,因为这个元素已经在页面上,所以我们不需要附加它们。我们准备好使用index方法接受根路由;当发生这种情况时,我们将使用我们的layout.podcasts区域来显示一个PodcastListView实例。

要使用这个路由器,让我们向index.ejs文件添加另一个脚本标签:

<script>
  var r = new Router({
    podcasts: new Podcasts(<%- podcasts %>)
  });
  Backbone.history.start({ pushState: true });
</script>

订阅新播客

我们已经有了显示播客所需的所有功能;现在,让我们创建一个用于订阅新播客的表单。正如我们之前确定的,在我们的Podcast模块中,我们只需要从用户那里获取播客源 URL。因此,让我们创建我们的NewPodcastView类。首先,这是这个视图的模板:

<script type='text/template' id='newPodcast'>
  <form class='form-inline'>
    <div class="form-group">
      <input type="text" placeholder="feed url" class="form-control">
    </div>
    <button class='btn btn-primary'> Add </button>
  </form>
</script>

如您所见,这是一个简单的表单,包含一个文本输入和一个按钮。有了这个,我们现在可以编写实际的视图:

var NewPodcastView = Backbone.View.extend({
  className: 'list-group-item',
  template: tmpl('#newPodcast'),
  events: {
    'click button': 'addPodcast'
  },
  render: function () {
    this.el.innerHTML = this.template();
    return this;
  },
  addPodcast: function (e) {
    e.preventDefault();
    var feed = this.$el.find('input').val();
    this.$el.addClass('loading').text('Loading Podcast . . . ');
    this.collection.create({ feed: feed }, { 
      wait: true,
      success: this.remove.bind(this)
    });
    Backbone.history.navigate('/');
    return false;
  }
});

我们将给元素添加一个list-group-item类并获取模板。渲染非常简单,我们正在监听按钮的点击。当发生这种情况时,我们将从字段获取源并替换表单为文本加载中。然后,我们在与这个视图关联的播客集合中创建一个新的播客。我们需要的唯一属性是源。现在,记住我们的PodcastListView类将监听添加到这个集合中的新模型。然而,我们需要它等待数据已存储在服务器上,以便它有一个 ID 和可以计数的剧集。因此,我们将添加wait选项到这个创建调用中。此外,在请求成功完成后,我们将调用这个视图的remove方法来从 UI 中移除它。最后,我们将导航回主页路由(不会触发它,请注意,因为没有必要;我们只是移除了表单)。

当服务器正在获取数据时,我们在视图的元素上添加 loading 类。打开 public 目录中的 style.css 文件,并添加以下样式:

@keyframes pulse {
  0% {
    background: #fff;
    color: #000;
  }
  100% {
    background: #b81e0d;
    color: #fff;
  }
}

.loading {
  animation: pulse 1s ease-in-out infinite alternate;
}

我们在这里使用了一些 CSS3;当服务器正在工作时,元素将从红色脉冲到白色。请注意,我正在使用标准的 CSS3 语法。然而,在撰写本书时,一些浏览器仍然需要专有前缀,因此您必须为要支持的浏览器添加代码。当然,有许多工具可以帮助您完成这项工作;Compass (compass-style.org/) 是一个很好的起点。

现在,我们将更新路由器以添加播客的路由。首先,使用以下代码行将路由添加到路由对象中:

'podcasts/new': 'newPodcast'

然后,我们需要编写 newPodcast 方法:

newPodcast: function () {
  var pv = new PodcastListView({ collection: this.podcasts });
  layout.podcasts.show(pv);
  pv.$el.append(new NewPodcastView({ 
    collection: this.podcasts 
  }).render().el);
}

这很简单;就像我们在 index 方法中所做的那样,我们将创建并渲染一个新的 PodcastListView 实例。然而,我们保留对视图的引用,并将我们的表单附加到它上;这样,表单就会像列表中的另一个条目一样显示。

订阅播客的最后一步是服务器代码。我们需要管理当用户保存新的源时发生的 POST 请求。在 server.js 文件中,添加以下代码:

app.post('/podcasts', function (req, res) {
  var podcast = req.user.podcasts.get(req.body.feed);
  podcast.info.then(res.json.bind(res));
});

我们可以使用用户的 podcasts 对象通过源获取新的播客。我们等待 info promise 准备就绪,并将数据发送回客户端。现在,我们可以成功订阅播客。试试看;启动应用程序,创建用户账户,并订阅几个播客。结果应该类似于以下截图:

订阅新的播客

显示剧集列表

现在我们能够订阅播客了,让我们来显示播客列表。我们有 Episode 模型和 Episodes 集合。我们将从集合视图 EpisodesView 开始:

var EpisodesView = Backbone.View.extend({
  className: 'list-group',
  initialize: function (options) {
    this.region = options.region; 
  },
  render: function () {
    this.collection.forEach(function (model) {
      var v = new EpisodeListItemView({ 
        model: model,
        layout: this.region
      });
      this.el.appendChild(v.render().el);
    }, this);
    return this;
  }
});

再次强调,这个元素将具有类 list-group。你会在 initialize 方法中注意到,我们期望 regionoptions 对象属性之一。请记住这一点,我们稍后会用到它。

render 方法中,我们遍历集合并显示一个 EpisodeListItemView 实例。请注意,我们传递了 region;那里我们需要它。让我们接下来创建这个类:

var EpisodeListItemView = Backbone.View.extend({
  className: 'list-group-item',
  events: {
    'click': 'displayEpisode'
  },
  initialize: function (options) {
    this.layout = options.layout;
    this.listenTo(this.model, 'change:listened', this.markAsListened);
  },
  render: function () {
    this.el.innerText = this.model.get('title');
    if (!this.model.get('listened')) {
      this.$el.addClass('list-group-item-danger');
    }
    return this;
  },
  markAsListened: function () {
    this.$el.removeClass('list-group-item-danger');
  },
  displayEpisode: function (evt) {
    evt.preventDefault();
    this.layout.show(new EpisodeView({ model: this.model }));
    return false;
  }
});

正如我们在之前的列表项视图中所做的那样,我们将给这个元素赋予类 list-group-item。这里没有模板;我们只需将这个模型的标题设置为元素的文本。然后,如果这个剧集尚未收听,我们将添加一个类来突出显示它,标记为已收听。在 initialize 方法中,请注意我们正在监听 listened 属性的变化。当这个变化发生时,我们将调用 markAsListened 方法,这将移除该类,使视图不再突出显示。

最后一个方法是displayEpisode,它将调用区域的show方法,传递一个EpisodeView实例作为这个视图显示的模型。这不仅仅是一个标题,因为我们在这里展示的;它将是整个模型。这就是我们传递区域的原因。由于我们没有改变 URL,我们必须在这里改变页面的内容。所以,这就是我们做的。

列表中的剧集还有一个部分:它上面的工具栏。只有一个工具:标记所有为已听,这是一个简单的按钮。它的代码如下:

var EpisodesToolsView = Backbone.View.extend({
  className: 'btn-tools btn-group',
  events: {
    'click #mark': 'mark'
  },
  render: function () {
    this.el.innerHTML = "<button id= 'mark' class="btn btn-default">Mark As Listened</button>";
    return this;
  },
  mark: function (evt) {
    this.collection.forEach(function (model) {
      model.listen();
    });
    this.collection.trigger('count');
  }
});

再次,我们从className属性开始;render方法非常简单。在events属性中,我们等待点击#mark按钮。当发生这种情况时,我们调用mark函数,它将遍历集合并将它们全部标记为已听。然后,我们在集合上触发count事件;我们在PodcastListItemView类中监听这个事件的发生,我们将更新播客计数。

注意,我们使用的类之一是btn-tools类。这是我们自己的创造之一,非常简单;它只是让工具栏底部有更多的空间:

.btn-tools {
  margin-bottom: 20px;
}

这个过程的最后一步是为标记剧集为已听的服务器组件。这是要添加到server.js文件的路线:

app.put('/episode/:id', function (req, res) {
  req.user.podcasts.updateEpisode(parseInt(req.params.id, 10),
    req.body, function (err, data) {
      res.json(data);  
    });
});

现在,我们准备好显示我们的剧集列表。在router.js文件的 router 中,添加以下路由:

'podcasts/:id': 'podcast'

现在来看那个podcast方法,这是它的代码:

podcast: function (id) {
  layout.podcasts.show(new PodcastListView({ 
    collection: this.podcasts, 
    current: parseInt(id, 10) 
  }));
  var podcast = this.podcasts.get(id);
  var episodes = podcast.episodes();
  episodes.fetch();
  layout.episodes.show([
    new EpisodesToolsView({
      model: podcast,
      collection: episodes
    }),
    new EpisodesView({
      collection: episodes,
      layout: layout.episode 
    })
  ]);
}

我们首先渲染播客列表,因为有可能这个页面会直接加载。注意,这次我们设置了current选项,以便它在列表中突出显示。然后,我们从服务器获取该播客的剧集。接下来,在episodes区域,我们显示EpisodesToolsViewEpisodesView视图。

从服务器获取剧集,通过episodes.fetch(),我们需要另一个服务器路由,如下面的代码所示:

app.get('/podcasts/:id/episodes', function (req, res) {
  var podcast = req.user.podcasts.get(parseInt(req.params.id, 10));
  podcast.update().then(res.json.bind(res));
});

我们将获取Podcast对象,然后调用update方法来检查是否有新剧集。当返回时,我们将它们作为 JSON 发送到客户端。

在这里设置好之后,我们现在可以查看剧集列表,如下面的截图所示:

显示剧集列表

现在,剩下要做的就是显示单个播客剧集。

显示剧集

单个剧集将在EpisodeView类中显示。让我们从模板开始:

<script type='text/template' id='episodeView'>
  <div class='btn-group btn-tools'>
    <button id='markOne' class="btn btn-default">Mark As Listened</button>
  </div>
  <div class="panel panel-default">
    <div class="panel-body">
      <h1>{{title}}</h1>
      <p>
        <strong>Duration</strong>: {{duration}}
        <strong>Date</strong>: {{pubDate}} 
      </p> 
      <audio controls='true' src="img/{{audio}}"></audio>
      {{description}}
    </div>
  </div>
</script>

我们从顶部的工具开始:一个标记为已听按钮。然后,我们显示剧集的详细信息:标题、时长和日期。接下来是audio元素;这使得用户能够在我们的应用程序中直接收听播客变得非常容易。在我们的例子中,我们只有一个音频源;然而,当使用audio元素时,你通常会想添加多个不同格式的源(MP3、OGG 等),以实现最大的浏览器和操作系统覆盖。在audio元素下方,我们将显示描述,这将是对该剧集的节目笔记。以下是类名:

var EpisodeView = Backbone.View.extend({
  template: tmpl('#episodeView'),
  events: {
    'click #markOne': 'listen' 
  },
  render: function () {
    this.el.innerHTML = this.template(this.model.toJSON()); 
    this.$('audio')[0].addEventListener('play', this.listen.bind(this), false);
    return this;
  },
  listen: function (evt) {
    this.model.listen();
    this.model.collection.trigger('count');
  }
});

这个视图的大部分是标准的视图代码;我们获取模板,并用模型数据渲染模板。我们还有一个listen方法,当用户点击标记为已听按钮时会被调用。唯一的区别是我们不能使用events属性来监听audio元素上的play事件,因为audio元素事件与 Backbone 的工作方式有关。因此,我们获取元素并使用addEventListener方法来监听该事件。

这是最后一部分。现在,你应该能够查看和播放播客的剧集。它看起来就像以下截图所示:

显示剧集

摘要

这就结束了本章的内容。在本章中,我们做的大部分事情你可能已经从之前的实践中熟悉了,但也有一些要点你不应该忽视。主要方面是强大的服务器组件。很容易忘记 Backbone 应用程序后面总是有服务器代码,而且通常这些代码会远不止渲染一个主模板和传递 JSON 数据的一堆路由。通常会有大量的逻辑、数据处理和其他细节需要在服务器上处理。正如我们所看到的,通常可以在客户端或服务器上执行这些逻辑——我们可以在任何一个位置捕获 RSS 源并处理它。当你构建自己的应用程序时,关于在哪里执行过程做出良好的决策是很重要的。在客户端执行某事通常要快得多(无需等待请求/响应),但在服务器上你可能拥有更多的能力和权限,所以时间延迟可能可以忽略不计。每个情况的决定都会不同,而且通常不会有唯一的正确选择。

我们做的另一件有趣的事情是重新创建了一些 Marionette 的行为。这再次提醒我们,Backbone 只是 JavaScript,没有理由你不能编写自己的代码来让它更容易使用。没有必要做任何花哨的事情;正如我们所看到的,像我们的区域和布局这样简单的东西真的可以清理你的路由器。

只剩下最后一章了,我们将有一些乐趣并构建一个游戏。

第七章. 构建游戏

我们已经来到了这本书的最后一章,如果我可以暂时以第一人称来谈谈,这是我最喜欢构建的应用程序。每个人都喜欢游戏,如果你喜欢文字游戏,你也会喜欢这个。我们迄今为止编写的应用程序大多数都是单视图应用程序;用户看到的唯一屏幕是执行应用程序主要动作的视图。然而,完整的 Web 应用程序通常还有其他不是应用程序主要目的的视图,但有助于完善应用程序。在这个应用程序中,我们有一个或两个这样的视图。

因此,以下是本章我们将涵盖的一些主题:

  • 复习所有 Backbone 组件的主要用途

  • 添加非 Backbone 页面以完善应用程序

  • 构建一个使用用户未提供的数据的应用程序

  • 编写(简单)的游戏逻辑

我们正在构建什么?

再次,我们将从描述我们计划构建的内容开始。这将是一款文字游戏,模仿我非常喜欢的一款非常简单的 iPhone 游戏,名为 7 Little Words (www.7littlewords.com/)。每一局游戏(或者可以说是回合,如果你愿意这样称呼的话)包含七个单词,这些单词被分成两个、三个或四个字母的部分。你的任务是根据你给出的简短定义重新组装这些单词。为了使问题更清晰,我并没有与这款 iPhone 游戏有任何关联,我只是喜欢玩它!

然而,我们将比那个游戏做得更深入,通过为单词分配不同的分数值,并且还计时我们的用户。这样,玩家可以比较分数和时间,使比赛更具竞争性。

下面是完成后的应用程序游戏视图的截图。在底部,你可以看到用户将选择组合成单词的标记。中间有一个文本框,显示用户已经组装的单词。然后,他们点击 猜测 按钮来查看单词是否与上面的定义之一匹配:

我们正在构建什么?

用户账户

我们将像上一章一样开始;通过向我们的基本应用程序添加用户账户。我们不会再次详细讲解整个过程;你可以从上一章的应用程序中复制它。我们只需要做一个小改动。在 app.post('/create') 路由中,我们创建一个 userAttrs 对象并将其存储在数据库中。这个应用程序的用户将有三项特定于应用程序的值需要存储:

  • score:这是他们的最高分

  • time:这是他们的最低时间

  • games:这是一个包含他们所玩游戏的数组

下面是创建 userAttrs 对象的代码:

var userAttrs = {
  username: req.body.username,
  passwordHash: signin.hashPassword(req.body.password),
  score: 0,
  time: 3600,
  games: []
};

在此基础上,加上我们之前创建的所有其他用户账户创建代码,我们就有了一个应用程序的框架,可以开始定制。

模板

在之前的应用中,我们的服务器端模板相当基础。我们只有单个index.ejs文件,也许还有一个login.ejs文件。然而,在一个大型应用中,你可能会拥有多个不同的服务器端模板。当这种情况发生时,你希望尽可能减少代码重复。你如何进行取决于你使用的服务器端模板系统。由于我们使用的是ejs(github.com/visionmedia/ejs),我们将通过包含来实现这一点。所以,在我们的项目views目录中创建一个名为header.ejs的文件。以下是文件中的内容:

<!DOCTYPE html>
<html>
<head>
  <title> Tokenr </title>
  <link rel="stylesheet"  href="/style.css" />
</head>
<body>

基本且符合预期,对吧?现在,我们还在views目录中添加了一个footer.ejs文件,它将关闭这些标签:

</body>
</html>

或者,你也可以只是记住在创建每个使用header.ejs包含文件的模板时,将这些两行添加到模板的底部(或者,如果你对 HTML5 的宽松性很熟悉,也可以完全省略它们),但我喜欢同时拥有header.ejsfooter.ejs文件带来的对称性。例如,我们位于views目录中的login.ejs文件,它包含了登录和注册表单:

<% include header %>
<div id="main">
  <form method="post" action="/login">
    <h1> Sign In </h1>
    <p><input type='text' name='username' /></p>
    <p><input type='password' name='password' /></p>
    <p><button>Log In</button></p>
  </form>
  <form method="post" action="/create">
    <h1> Sign Up </h1>
    <p><input type='text' name='username' /></p>
    <p><input type='password' name='password' /></p>
    <p><button>Create Account</button></p>
  </form>
</div>
<% include footer %>

你明白我说的对称性吗?我们可以在views目录中的index.ejs文件中使用相同的技巧,它将开始如下:

<% include header %>
<div id="main"></div>
<script src="img/jquery.js"></script>
<script src="img/underscore.js"></script>
<script src="img/backbone.js"></script>
<script src="img/models.js"></script>
<script src="img/views.js"></script>
<script src="img/router.js"></script>
<% include footer %>

如你所见,我们将再次将模型、视图和路由器拆分到单独的文件中。这在当前应用中尤为重要,因为模型将使用一些复杂的代码。所以现在我们在views目录中有一个index.ejs文件,我们可以渲染索引路由。在server.js文件中,这段代码应该是你的最终路由:

app.get('/*', function (req, res) {
  if (!req.user) {
    res.redirect("/login");
    return;
  }
  res.render("index.ejs");
});

注意,我们没有向索引模板传递任何值;这个应用不需要这样的东西。这听起来可能有些奇怪。既然它是一个更高级的应用,难道你不期望它一开始就需要更多的数据吗?如果你的应用需要从服务器传输大量数据到浏览器,那么一次传输所有数据可能不是最佳选择;这可能会严重影响你的加载时间。更好的技术是在需要时加载数据,这正是我们将要做的。此外,可能在你确切知道需要哪些数据之前,你需要让用户做出决定;这也是延迟加载数据的另一个原因,在我们的案例中也是如此。

创建游戏数据

说到加载数据,下一步是创建我们游戏的数据——用户将要拼写的单词。实际上,这是本书中唯一一个从数据开始的应用,而不是仅仅处理用户提供给应用的数据。实际的原始数据将存储在我们项目的根目录下的words.json文件中。这个游戏成为好游戏的关键之一是有大量的单词可供选择。以下是文件开始的部分:

[{"id":1,"level":3,"word":"anguine","definition":"snakelike"},
{"id":2, "level":1,"word":"cardinal","definition":"of fundamental importance"},
{"id":3, "level":3,"word":"detersion","definition":"act of cleansing"},
{"id":4, "level":3,"word":"exiguous","definition":"meager"},
{"id":5, "level":2,"word":"fraternise","definition":"associate with"},

当然,每个单词都有一个 ID。然后,重要的属性是单词和定义。定义是用户将看到的,而单词是他们需要拼凑的。级别是一个介于 1 到 3 之间的数字,其中级别 1 的单词是最简单的,级别 3 的单词是最难的。你可以编写自己的列表,或者从 GitHub 下载这个列表(gist.github.com/andrew8088/9627996)。

注意

在开发这个应用程序的过程中,一个想法是使用字典 API(如dictionaryapi.com)从更大的数据库中随机选择单词。然而,这实际上并不实用,因为我们需要一个简短的、类似于填字游戏的定义,而标准的字典定义根本不够。此外,大多数 API 都没有选择随机单词的方法。

一旦我们有了单词列表,我们需要创建实际的数据库。将以下内容添加到server.js文件的顶部:

var _ = require('./public/underscore');
var words = new Bourne('words.json');

我们在这里也需要 Underscore 库;你很快就会看到我们为什么需要它。我们需要与客户端相同的文件。这不会适用于每个文件;碰巧在撰写这本书的时候(1.6.0 版本),Underscore 的最新版本被编写为可以在客户端和服务器上运行。

用户每玩一次游戏都会有八个单词;这意味着我们需要从数据库中随机抽取八个单词,但所有单词的难度级别相同。为此,我们将在服务器文件中添加一个getWords函数:

function getWords(level, cb) {
  words.find({ level: level }, function (err, records) {
    cb(null, _.shuffle(records).slice(0, 8));
  });
}

这个函数将接受一个级别数字和一个回调函数。然后,我们将获取数据库中该级别的所有单词。接下来,我们将使用 Underscore 的shuffle方法对记录数组进行洗牌。洗牌后,我们将从数组中取出前八个项目并传递给回调。

注意

应该指出,这可能不是从大多数数据库中获取八个随机单词的最佳方式。由于我为小型数据集编写了 Bourne 数据库系统,并且它将所有记录都保存在内存中,所以我们在这里所做的应该很快。然而,根据你使用的数据库系统,可能还有更好的方法。

现在我们有了获取单词的方法,我们需要为它创建一个路由:

app.get('/game/:level', function (req, res) {
  var level = parseInt(req.params.level, 10);
  getWords(level, function (err, words) {
    res.json(words);
  });
});

游戏的级别是 URL 的一部分。我们将它转换为数字,然后调用我们的getWords函数。一旦我们有了单词,我们就可以将它们作为 JSON 发送回浏览器。

编写模型

由于这个应用程序的性质,我们将比通常有更多的模型。两个明显的模型是Word模型及其集合Game。这些几乎不需要解释。然而,请记住,我们将单词分成部分,我们将称之为标记。为此,我们将有一个Token模型和Tokens集合。这些实际上是简单的部分:

var Token = Backbone.Model.extend({});
var Tokens = Backbone.Collection.extend({
  model: Token
});

由于这些只是被切碎的单词的壳,它们不需要太多。所有的主要逻辑都将包含在WordGame类中。让我们从Word类开始:

var Word = Backbone.Model.extend({
  initialize: function () {
    this.set('points', this.get('word').length + this.get('level'));
  },
  parts: function () {
    return Word.split(this.get('word'));
  }
});

每个Word实例都需要分配一个点值。这并不复杂;只需加上单词的长度和难度级别。稍后,将这个值乘以基于时间的另一个值。另一种方法调用Word.split函数,传递给它的单词。这就是代码变得稍微复杂的地方。

然而,在我们开始分割单词之前,请注意split方法是一个静态或类级别方法。这在我们之前看到的 Backbone 中并不常见;但 Backbone 使得添加静态方法变得非常简单。到目前为止,我们只向Backbone.Model.extend方法传递了一个参数;一个包含实例级别属性和方法的对象。然而,这个方法可以接受第二个对象,包含类属性和方法:

Backbone.Model.extend({
  // instance properties
},
{
  // class properties
});

这不仅适用于模型;它还适用于集合、视图,甚至路由器。所以,给前面的Word模型添加一个类属性对象;我们将在下一节中使用它。

分割单词

将单词随机分割成标记并不是像你想的那么简单。我们希望随机进行,这样每次玩游戏时,单词的分割方式可能都不同。我们希望将每个单词分割成两个、三个或四个字母的标记。你可能认为,那么我们可以随机选择这些数字中的一个。然而,我们并不真的想要所有三种尺寸的数量相等;我们希望有两个字母标记更少。这需要加权随机选择,因此我们首先必须编写一个函数来实现这一点。表示我们的加权选项的方式是通过如下数组:

[[2, 0.2], [3, 0.4], [4,0.4]]

这个数组中的每个数组都有两个元素。第一个是我们想要使用的值;这可以是一个字符串、一个对象或任何东西。第二个值是这个值被选中的概率。正如你可以从这个数组中看到的那样,值 2 将有 20%的概率被选中,值 3 和 4 将各有 40%的概率被选中。所以,这是一个接受该数组作为参数的函数。记住要将这个函数放在Word模型的类属性对象中:

weightedRandomGenerator: function(items) {
  var total = items.reduce(function (prev, cur) { 
    return prev + cur[1]; 
  }, 0),sum = 0,list = [];
  for (var i = 0; i < items.length; i++) {
    sum = (sum*100 + items[i][1]*100) / 100;
    list.push(sum);
  }
  return function () {
    var random = Math.random() * total;
    for (var i = 0; i < list.length; i++) {
      if (random <= list[i]) {
        return items[i][0];
      }
    }
  }
}

第一步是计算数组中的百分比值。在我们的例子中,这些值加起来是 1,但它们不必是;如果它们加起来是某个其他值,这仍然有效。我们通过在数组上调用原生的reduce方法来实现这一点,将所有第二个元素相加。下一步是创建一个新数组,随着加权值的累加,我们将创建一个数组,例如:

[0.2, 0.6, 1]

因此,我们创建了一个sum变量和一个名为list的数组。然后,我们遍历项目,将值添加到sum变量中,然后将该sum变量推入list数组中。我们现在有了所需的数组。最后,我们将返回一个函数。该函数将首先获取一个介于 0 和总数之间的随机数。然后,我们将遍历列表,检查每个项目是否小于或等于随机数。一旦我们找到匹配项,我们将使用相同的索引号从原始项目参数中返回值。这就是我们的加权随机生成器的全部内容。现在,我们准备在分割单词成标记的函数中使用它。这是split函数:

function split(word) {
  word = word.split('');
  var tokens = [];

  var rand234 = Word.weightedRandomGenerator([[2, 0.2], [3, 0.4], [4,0.4]]),
    rand23  = Word.weightedRandomGenerator([[2, 0.5], [3, 0.5]]),
    rand24  = Word.weightedRandomGenerator([[2, 0.5], [4, 0.5]]);

  var w, length;
  while (word.length > 0) {
    w = word.length;
    if      (w  >  5) length = rand234();
    else if (w === 5) length = rand23();
    else if (w === 4) length = rand24();
    else              length = w;

    tokens.push(word.splice(0, length).join(''));
  }
  return tokens;
}

这个函数接受一个单词并将其分割成标记。首先,我们将字符串分割成一个数组,然后创建一个数组来保存标记。接下来,我们创建三个随机生成器,我们将在不同的点需要它们。然后,我们有一个while循环,用于当单词长度大于零时。如果单词长度大于五个字符,我们将使用返回 2、3 或 4 的生成器。如果单词是五个字符长,我们将使用返回 2 或 3 的生成器。如果它是四个字符长,我们将使用返回 2 或 4 的生成器。最后的else语句将用于单词长度小于四个字符的情况;我们将使用单词的长度。

所有这些确保单词将被分割成两个、三个或四个字符的标记;这也确保我们永远不会得到一个字母的标记,通过移除除了一个字母之外的所有字母。while循环的最后一步是使用单词数组的splice方法。这个方法将改变原始数组,将这些字母从数组中取出并返回(这就是单词长度在while循环条件中变化的原因)。一旦我们将单词分割成标记,我们返回标记数组。这是在Word类的parts方法中使用的函数。

这个集合的类是Game。这将从非常简单开始:

var Game = Backbone.Collection.extend({
  model: Word,
  initialize: function (models, options) {
    this.guessedCorrectly = [];
    this.seconds = -1;
    this.score = 0;
    this.level = 1;
  },
  getWords: function () {
    return Backbone
      .ajax("/game/" + this.level)
      .then(this.reset.bind(this));
  },
  tokens: function () {
    var tokens = _.flatten(this.invoke('parts'));
    return new Tokens(tokens.map(function (token) {
      return { text: token };
    }));
  }
});

这实际上只是一个开始。这些集合实例中之一将处理更多内容,但我们终将到达那里。我们首先为这个集合设置模型类,然后创建initialize方法。集合对象将负责跟踪时间和分数,因此我们给它一个secondsscore属性。由于我们的游戏将有等级,所以我们还有一个level属性。然后,我们有getWords方法。

正如我们所知,我们并不是在页面初始加载时发送一组单词,因此这是完成这一任务的方法。它会对我们创建的用于发送单词的路由发起一个 AJAX 请求。Backbone.ajax方法实际上是对jQuery.ajax方法的封装。它返回一个承诺,我们在第六章中学习了承诺的概念,即构建播客应用程序,在这里。我们调用它的then方法,传递给它集合的reset方法。这个方法会用作为参数传递的模型数组替换集合中的任何模型。then方法将返回承诺对象,因此我们返回它。这样,我们就可以在单词加载后执行操作。

最后,请注意tokens方法;在这里,我们调用集合的invoke方法。这个方法接受另一个方法的名称,并在集合中的每个模型上调用它。这将返回一个值数组;在这种情况下,值将是一个标记数组,即分割后的单词。一个数组数组没有用,所以我们将使用 Underscore 的flatten方法将嵌套数组展平成一个标记的单个数组。然后,我们返回一个映射到对象数组的Tokens集合实例。

编写标记视图

现在我们已经将模型大致准备好,我们准备开始编写实际的视图。让我们从一个简单的东西开始:标记。我们从TokensView类开始:

var TokensView = Backbone.View.extend({
  render: function () {
    this.collection.tokens()
      .shuffle().forEach(this.addToken, this);
    return this;
  },
  addToken: function (token) {
    this.el.appendChild(new TokenView({ 
      model: token 
    }).render().el);
  }
});

编写这个类非常简单。我们从游戏中获取标记集合,调用内置的shuffle方法来打乱标记,然后使用addToken方法将它们每个都渲染出来。这个方法渲染一个TokenView实例并将其附加到元素上。所以下一个步骤就是——TokenView类:

var TokenView = Backbone.View.extend({
  className: 'token',
  events: {
    'click': 'choose'
  },
  render: function () {
    this.model.view = this;
    this.el.innerHTML = this.model.get('text');
    return this;
  },
  choose: function () {
    Backbone.trigger('token', this.model);
    this.hide();
  },
  hide: function () {
    this.$el.addClass('hidden');
  },
  show: function () {
    this.$el.removeClass('hidden');
  }
});

每个TokenView实例都将有一个类:tokenrender方法相当基础——它只是将标记的文本放入元素中。然而,请注意方法的第 1 行;我们正在给模型添加一个指向这个视图的视图属性。这是我们之前没有做过的事情;我们从未给模型添加指向渲染它的视图的链接。这并不总是被认为是一件好事;通常更好的做法是保持模型和视图之间的清晰分离。然而,有时这可以是一件好事,正如我们将在本例中看到的那样。无论如何,这样做非常简单。当这个元素被点击时,将调用choose方法。这个方法使用模型作为参数触发token事件。我们之前已经触发过事件,但这是第一次我们使用Backbone.trigger方法。我们可以使用这个方法在所有代码中全局触发和监听事件。在触发事件后,我们将隐藏视图。我们这里也有hideshow视图。这些在元素上添加或删除一个类来分别隐藏或显示标记。

通常,这会是启动路由器的点,这样我们就可以渲染我们的视图并对其进行样式化。然而,这次我们将走不同的路线。在构建更复杂的应用程序时,你可能会对正在处理的工作——在我们的例子中是视图——充满热情,并且你不想改变思维模式。在这种情况下,我会在index.ejs文件中放置一个script标签来测试我们刚刚创建的视图:

<script>
  var game = new Game();
  game.getWords().then(function () {
    $('#main').append(new TokensView({ 
      collection: game 
    }).render().el);
  }.bind(this));
</script>

它快速而简单;我们创建一个Game对象,获取一组单词,然后将一个新的TokensView实例添加到页面上。你应该会看到类似这样的结果:

编写标记视图

如果你点击单个标记并打开你的开发者工具,你会看到它们获得了hidden类,就像我们编码的那样。当然,现在还没有其他事情发生,这是因为我们没有为'token'事件添加监听器。这是一个好的开始,并且足够开始样式化。所以,打开public目录下的style.css文件。让我们从这里开始:

@import url("//fonts.googleapis.com/css?family=Lato:300,400,700");
body {
  margin: 0;
  padding: 0;
  font-family: lato, helvetica-neue, sans-serif;
  background: #2B3E50;
  font-weight:300;
  color: #ebebeb;
}
#main {
  width: 540px;
  padding: 0 5%;
  margin: auto;
}

我们从引入一个 Google 字体开始。在www.google.com/fonts上有几个可供选择;我们选择了 Lato。在<body>元素上,我们将设置字体、字体颜色和背景。然后,我们将在主<div>元素上设置宽度,这是我们应用程序大部分内容所在的位置。接下来,我们将对我们的锚点元素添加一些样式:

a {
  text-decoration: none;
  font-weight: 700;
  color: #ebebeb;
}
#main a:hover {
  text-decoration: underline;
}

所有链接都会有一些样式——没有下划线,一些加粗,以及新的颜色——但只有主元素中的锚点会得到悬停样式。这是因为我们很快将创建一个导航栏(在主元素外部),我们不希望当悬停时链接被下划线。现在,我们已经准备好为标记添加样式,我们使用以下代码来完成:

.token {
  font-size: 150%;
  font-weight: 700;
  margin: 5px;
  padding:7px 0;
  display: inline-block;
  background:#F0AD4E;
  color: #474747;
  width: 100px;
  text-align:center;
}
.token:nth-child(5n+1) {
  margin-left: 0;
}
.token:nth-child(5n) {
  margin-right:0;
}
.token:hover {
  background: #DF691A;
  cursor: pointer;
  color: #ececec;
}
.hidden {
  visibility: hidden; 
}

我们将标记样式化为基本的橙色块。我们将它们均匀地间隔开;我们使用nth-child 选择器来移除其他块的外边缘的边距。我们为块添加一个悬停效果。最后,我们添加了hidden类。现在,刷新页面应该会得到类似这样的结果:

编写标记视图

看起来不错,不是吗?现在,我们准备好进行下一个视图,即显示线索的视图,也就是定义。

线索视图

单词的线索——即定义——需要出现在标记上方。CluesView类相当简单:

var CluesView = Backbone.View.extend({
  tagName: 'table',
  render: function () {
    this.collection.forEach(function (word) {
      this.el.appendChild(new ClueView({ 
        model: word 
      }).render().el);
    }, this);
    return this;
  }
});

线索将显示在表格中。在render方法中,我们将遍历集合,为每个Word模型渲染一个ClueView类。ClueView类是所有动作发生的地方。以下是ClueView类的代码:

var ClueView = Backbone.View.extend({
  tagName: 'tr',
  template: _.template($('#clue').html()),
  initialize: function () {
    Backbone.on('correct', this.correct, this);
  },
  render: function () {
    this.el.innerHTML = this.template(this.model.toJSON());
    return this;
  },
  correct: function (word) {
    if (this.model.get('word') === word.get('word')) {
      this.$el.addClass('correct');
      this.$('.word')
        .removeClass('clue')
        .text(word.get('word'));
    }
  }
});

这个视图将使用表格行元素,这是我们第一个使用模板的视图。为了渲染模板,我们只需将模型的 JSON 版本传递给 template 函数。在 initialize 方法中,我们监听 correct 事件的发生。这是当玩家正确猜出一个单词时将被触发的事件。这是另一个全局事件,我们将在另一个位置触发。当它发生时,我们将调用 correct 方法。这个方法将作为参数接收正确单词的 Word 模型。尽管只有一个单词被猜出,但所有的 ClueView 实例都将监听 correct 事件。所以,第一步将是比较单词并找到正确的 ClueView 实例。如果这个视图的模型匹配,我们将添加 correct 类。然后,我们将从模板的一部分移除 clue 类并添加单词。

说到模板,将以下内容添加到 views 目录中的 index.ejs 文件:

<script type='text/template' id='clue'>
  <td>{{ definition }}</td>
  <td class='word clue'>{{ word.length }} letters</td>
</script>

如你所知,这将放在我们的表格行元素内部。第一个 <td> 元素将包含定义。第二个将首先显示单词中的字母数量,作为另一个小线索。正如我们所见,当他们正确猜出单词时,线索将被单词本身替换。在我们检查浏览器中的效果之前,让我们在 public 目录中的 style.css 文件中添加一些样式:

table {
  width: 100%;
}
td:nth-of-type(1) {
  width:75%;
}
.clue {
  font-size:75%;
}
.word {
  float: right;
}
.correct {
  color: #5CB85C;
  font-weight: 700;
}

我们将整理 table 元素以及每行的第一个 <td> 单元格。当第二个 <td> 单元格包含字母数量时,clue 类将稍微减小字体大小。然后,当单词被正确猜出时,我们将移除该类,并将 correct 添加到整个 <tr> 元素上,使其着色并加粗。

现在,回到 index.ejs 文件,你可以以同样的快速且简单的方式渲染这个视图。将 getWords 回调函数内的内容替换为以下内容:

$('#main')
  .append(new CluesView({ collection: game }).render().el)
  .append(new TokensView({ collection: game }).render().el);

然后,刷新页面。它应该看起来像这样:

线索视图

我们还看不到正确单词的样式,因为我们还无法猜测单词。这带我们到了下一步:猜测视图。

创建猜测视图

这将是我们的应用中最长的视图,因为它要做的事情最多。让我们从这个模板开始:

<script type='text/template' id='guess'>
  <div class='btn text'></div>
  <div id='guessBtn' class='btn'> Guess </div>
</script>

看起来很简单。第一个 <div> 元素是玩家点击标记时标记文本将出现的地方。第二个 <div> 元素将是一个按钮;当玩家点击它时,他们的猜测将被“提交”。如果猜测是其中的一个单词,它将出现在正确的定义旁边。否则,标记将重新出现,与其他标记一起。这是 GuessView 类的代码:

var GuessView = Backbone.View.extend({
  className: 'guess',
  template: $('#guess').html(),
  events: {
    'click #guessBtn': 'guess'
  },
  initialize: function () {
    Backbone.on('token', this.add, this);
    this.currentTokens = [];
  },
  render: function () {
    this.el.innerHTML = this.template;
    this.guessText = this.$('.text');
    return this;
  },
  add: function (token) {
    this.currentTokens.push(token);
    this.guessText.append(token.get('text'));
  }
});

这是开始;我们将给这个元素添加一个名为 guess 的类,并获取我们刚刚创建的先前的模板。在 initialize 方法中,我们将监听 token 事件。记住,当点击其中一个标记时,这个事件将在全局范围内触发。在这里,我们捕获这个事件并运行我们的 add 方法。initialize 方法中发生的另一件事是创建一个 currentTokens 属性。这将跟踪用户在实际猜测之前选择的标记。在 render 方法中,我们将获取模板(在这种情况下,它只是一个字符串,因为在这个视图中没有模板数据),然后创建一个指向我们用作文本字段的 <div> 元素的属性。这个属性在 add 方法中使用;该方法将 Token 模型作为参数。我们将缓存标记在 currentTokens 数组中,并将文本追加到元素中。

在我们继续之前,让我们给它添加样式。你知道该去哪里——public 目录下的 style.css 文件:

.guess {
  overflow: hidden;
  margin: 20px 0 5px;
  border: 5px solid #D4514D;
}
.btn {
  background: #D4514D;
  width: 30%;
  cursor: pointer;
  line-height: 50px;
  height: 50px;
  font-size:200%;
  text-align: center;
  float:left;
}
.btn:hover {
  background: #C04946;
}
.btn.text {
  background: #5BC0DE;
  width:70%;
}

两个内部的 <div> 元素都有 btn 类;我们将它们浮动到左边并应用高度、宽度和着色。然后,对于同时具有 btntext 类的元素,我们将调整背景颜色和宽度。当实际的按钮 <div> 被悬停时,我们将稍微改变背景颜色,因为按钮应该这样做。

现在,让我们渲染这个视图。回到 index.ejs 文件,再次更改 getWords 回调:

$('#main')
  .append(new CluesView({ collection: game }).render().el)
  .append(new GuessView({ collection: game }).render().el)
  .append(new TokensView({ collection: game }).render().el);

在浏览器中打开此页面并点击几个标记。你应该会看到类似这样的内容:

创建猜测视图

在玩弄这个之后,你应该会看到我们需要对这个视图进行两个更改。一个明显的大问题是我们的 Guess 按钮没有任何作用。更小、与设计相关的问题是,我们的 GuessView 类的 <div> 元素的红色边框在按钮被悬停时不会改变颜色。由于它们是相同的颜色,这将是一个很好的触感。然而,当子元素被悬停时,我们无法使用 CSS 来更改父元素的属性。别担心,JavaScript 就在这里来拯救这个情况。将这些两个事件添加到 GuessView 类的事件属性中:

'mouseover #guessBtn': 'color',
'mouseout #guessBtn': 'color'

当按钮接收到 mouseovermouseout 事件时,我们将调用 color 方法。这个方法非常简单;它所做的只是切换那个父元素的 border 类:

color: function () {
  this.$el.toggleClass('border');
},

当然,这意味着我们不得不在我们的 CSS 文件中添加一个 border 类:

.border {
  border-color: #C04946;
}

现在,让我们关注更重要的问题;允许玩家进行实际的猜测。我们已经有了准备好的 GuessView 类,用于点击 Guess 按钮。当这发生时,我们调用 guess 方法:

guess: function (evt) {
  var results = this.collection.guess(this.guessText.text());
  if (results.word) {
    Backbone.trigger('correct', results.word);
  } else {
    this.currentTokens.forEach(function (token) {
      token.view.show();
    });
  }
  this.currentTokens = [];
  this.guessText.text('');
  if (results.complete) 
    Backbone.trigger('completed', this.collection);
}

第一步是检查这个单词是否在集合中。我们通过调用集合的guess方法来完成这个操作。我们还没有写这个,但它将返回一个具有两个属性的对象。第一个是word属性。如果猜测是集合中的单词,则此属性将是Word模型本身;否则,它将是undefined。如果有Word模型,我们将触发correct事件,传递Word模型。记住,ClueView实例正在监听此事件。如果results.wordundefined,这意味着标记没有拼出集合中的任何一个单词,需要替换。因此,我们将遍历标记并调用我们在渲染这些视图时给它们的view属性的show方法。在两种情况下,我们都会清空currentTokens属性并清除guessText属性的文本。最后一步是检查results对象上的complete属性。如果是true,则意味着玩家刚刚完成了最后一个单词并完成了游戏。如果游戏结束,我们将触发一个completed事件,并将游戏对象作为参数传递。

最后一步是编写Game集合的guess方法。回到public目录下的models.js文件,将此方法添加到Game类中:

guess: function (word) {
  var results = {
    word: this.findWhere({ word: word }),
    complete: false
  };
  if (results.word) {
    results.word.set('correct', true);
    var score = results.word.get('points');
    var mult = 10 - parseInt(this.seconds / 15);
    if (mult <= 0) mult = 1;
    this.score += score * mult;
    results.complete = this.where({
      correct:true
    }).length === this.length;
  }
  return results;
}

我们首先创建一个带有wordcomplete属性的results对象。默认情况下,complete属性将是false;对于word属性,我们将搜索集合以找到与传递给此方法的文本匹配的单词模型。如果找不到单词,findWhere方法将返回undefined。然而,如果找到一个Word模型,我们将给该模型一个临时属性。我们将correct设置为true。由于玩家刚刚正确猜了一个单词,下一步是更新分数。我们创建一个score变量;它从Word模型上的基本points属性开始。然后,我们需要计算乘数。如我们之前看到的,Game实例将有一个seconds属性;很快,我们将看到它是如何增加的。

现在,我们将秒数除以 15,用parseInt进行四舍五入,然后从 10 中减去。然后,如果结果是小于或等于0的数字,我们将mult重置为1。这样,前 15 秒内任何正确的猜测都将获得 10 倍乘数,第二 15 秒内的任何猜测将获得 9 倍乘数,依此类推。2 分 30 秒后,乘数将是 1。然后,我们将score属性增加单词score乘以乘数。最后,我们将correct属性的数量与集合中的总单词数进行比较。如果这些相等,results.complete将是true,因为所有单词都已被正确猜测。最后,我们将返回results对象。

现在,有了这个,我们可以刷新页面并实际玩游戏。试试看!它应该看起来像这样:

创建猜测视图

非常令人印象深刻,不是吗?我们几乎可以玩我们的游戏了。然而,还有很多细节需要处理。当我们在这里玩游戏时,我们实际上还没有开始记分。所以这是下一步。

构建信息视图

下一个类是我们将要称为InfoView的类。这将包括时间计数器和当前分数。我们从模板开始。将以下内容添加到views目录中的index.ejs文件:我们正在创建两个<span>元素:一个用于时间,另一个用于分数。以下是模板的代码:

<script type='text/template' id='info'>
  <span class='timer'> 00:00 </span>
  <span class='points'> 0 points </span>
</script>

现在,在我们编写视图类之前,我们需要向我们的Game集合类添加几个更多的方法。我们之前写的guess方法跟踪玩家的分数。我们还想让Game实例跟踪时间。计数器将位于游戏实例内部,但InfoView类实际上需要显示时间。这就是start方法:

start: function (callback) {
  this.callback = callback;
  this.loop();
},
loop: function () {
  this.seconds++;
  this.callback(this.time());
  this.timeout = setTimeout(this.loop.bind(this), 1000);
},

start方法是InfoView类将使用的。它接受一个callback函数作为参数,并将其分配为实例的属性。然后,它调用loop方法。这个方法增加seconds计数,然后调用callback函数,传递time方法的结果(这是下一个)。然后,我们为这个方法设置一个超时,以便一秒后再次调用它;我们必须将loop绑定到this,这样每次调用时this的值都将保持不变。time方法只是返回seconds计数作为一个漂亮的时间戳:

time: function () {
  var hrs = parseInt(this.seconds / 3600),
    min = parseInt((this.seconds % 3600) / 60),
    sec = (this.seconds % 3600) % 60;

  if (min < 10) min = "0" + min;
  if (sec < 10) sec = "0" + sec;
  var time = min + ":" + sec;

  if (hrs === 0) return time;

  if (hrs < 10) hrs = "0" + hrs;
  return hrs + ":" + time;
},

这非常基础。我们可以使用除法和取模运算符,以及parseInt函数,来创建一个时间字符串。所以,当seconds计数为 42 时,字符串将是"00:42";73 将是"01:13"。如果时间超过一小时(这不太可能,但有可能),我们将小时计数添加到前面。这个字符串是传递给回调函数的值。

现在,让我们看看实际的类。我们将给元素分配info类,并获取模板。这是InfoView类的代码:

var InfoView = Backbone.View.extend({
  className: 'info',
  template: $('#info').html(),
  initialize: function () {
    this.listenTo(Backbone, 'correct', this.updateScore);
    this.collection.listenTo(Backbone, 'completed', this.collection.stop);
  },
  render: function () {
    this.el.innerHTML = this.template;
    this.time = this.$('.timer');
    this.score = this.$('.score');
    this.collection.start(this.time.text.bind(this.time));
    return this;
  },
  updateScore: function () {
    this.score.text(this.collection.score + ' points');
  }
});

render方法首先使用模板字符串,然后为计时器元素和分数元素创建两个属性。然后,我们调用我们刚刚编写的collection.start方法。记住,这个方法接受一个将接收时间字符串的回调函数,所以,我们可以直接传递绑定到我们的this.time元素的 jQuery text方法。现在,这将为我们计时。

在我们查看initialize方法之前,让我们给这个添加一点样式。将以下内容添加到public目录中的style.css文件:

.info {
  font-size:60px;
  margin: 20px 0;
}
.info span {
  margin-right:40px;
}

这不是什么大问题;我们只是增加了字体大小并添加了一些边距。现在,将一个InfoView实例添加到我们一直在使用的快速测试中:

$('#main')
  .append(new InfoView({ collection: game }).render().el)
  .append(new CluesView({ collection: game }).render().el)
  .append(new GuessView({ collection: game }).render().el)
  .append(new TokensView({ collection: game }).render().el);

在浏览器中加载这个,你应该会看到类似这样的内容:

构建信息视图

现在,在initialize方法中,我们监听两个应用程序级别的事件。当玩家猜对一个单词并触发correct事件时,我们将调用updateScore方法。正如你所见,这将通过在集合对象上更新的score属性来设置分数元素的文本。我们正在监听的另一个事件是completed事件,当游戏结束时将被触发。当发生这种情况时,我们在集合对象上调用stop方法。这个方法有两个任务要做。首先,它必须停止计时器,其次,它必须将游戏记录到服务器上。这是要添加到Game类的最后一个方法:

stop: function () {
  clearTimeout(this.timeout);
  Backbone.ajax({
    url: '/game',
    method: 'POST', 
    data: {
      time: this.seconds,
      score: this.score,
      date: new Date().toJSON()
    }
  });
}

当我们在loop方法中创建超时时,我们将它分配给this.timeout属性。在这个stop方法中,我们可以清除超时以停止计时器。然后,我们将当前游戏数据存储到服务器上。我们不会像 Backbone 那样做——通过创建一个模型并使用其实例将数据发送到服务器——我们只是使用Backbone.ajax方法将此数据 POST 到服务器。如果你更愿意使用模型,这非常简单。首先,在你的models.js文件中创建模型类:

var GameInfo = Backbone.Model.extend({
  urlRoot: '/game'
});

然后,将Backbone.ajax调用替换为GameInfo实例:

(new GameInfo({
  time: this.seconds,
  score: this.score,
  date: new Date()
}).save();

我们实际上不会这样做,因为我们没有在任何其他地方使用GameInfo类。然而,这些方法的美丽之处在于,两种情况下的服务器端代码是相同的。再次打开server.js文件,并添加这个路由器:

app.post('/game', function (req, res) {
  if (!req.user) return res.redirect('/login');
  var game = {
    time : parseInt(req.body.time, 10),
    score: parseInt(req.body.score, 10),
    date : req.body.date
  };
  req.user.games.push(game);

  if (game.score > req.user.score) req.user.score = game.score;
  if (game.time  < req.user.time ) req.user.time  = game.time;

  users.update({ id: req.user.id }, req.user, function (err, user) {
    res.json(game);
  });
});

首先,我们检查用户是否已登录;我们这样做是因为我们需要这个方法中的req.user对象,我们不想得到错误。如果用户已登录,我们将组合一个游戏对象,包含从浏览器发送的时间、分数和日期。然后,我们将该游戏对象推入用户的游戏数组中。你可能还记得,用户对象有自己的分数和时间属性;这些是为他们的最高分和最低时间。如果这个游戏的分数或时间比用户的最好成绩好,我们将更新他们的最佳成绩。最后,我们将更新记录存储到数据库中。当然,最后一步是将游戏作为 JSON 返回,但我们实际上不会在浏览器上使用它。

将我们的视图包裹在GameView类中

到目前为止,为了让用户玩游戏,我们正在渲染四个视图。让我们将这些视图组合成一个单一的视图:GameView类。这是一个相当简短的视图,但它将清理我们路由器中的代码。以下是GameView类的代码:

var GameView = Backbone.View.extend({
  render: function () {
    var attrs  = { collection: this.collection },
      info   = new InfoView(attrs),
      clues  = new CluesView(attrs),
      guess  = new GuessView(attrs),
      tokens = new TokensView(attrs);

    this.$el.append(info.render().el)
      .append(clues.render().el)
      .append(guess.render().el)
      .append(tokens.render().el);

    return this;
  }
});

render方法中,我们将创建四个视图,并将它们附加到元素上。我们可以非常容易地测试这个视图——只需将创建四个视图的callback函数代码替换为这一行:

$('#main').append(new GameView({ collection: game }).render().el);

浏览器中看起来不会有任何不同,这正是我们想要的。

启动路由器

我们终于准备好开始构建路由器了。你可能还记得 views 目录中的 index.ejs 文件,我们引入了一个 router.js 脚本,因此,在 public 目录中创建一个 router.js 文件。让我们从以下内容开始:

var Router = Backbone.Router.extend({
  initialize: function (options) {
    this.main = options.main;
  },
  routes: {
    'play': 'play',
    'play/:level': 'play'
  },
  play: function (level) {
    var game = new Game();
    if (level) game.level = level;
    game.getWords().then(function () {
      this.main.append(new GameView({ 
        collection: game 
      }).render().el);
    }.bind(this));
  }
});

就像我们之前的应用程序一样,initialize 方法将接受一个 options 对象,这将设置应用程序的主要元素。在 routes 对象中,你会看到我们创建了两个路由。要玩游戏,我们可以访问 /play 或者,比如说 /play/2:这两个路由都会调用 play 方法。这个方法创建一个 Game 集合对象;如果通过路由路径选择了某个等级,我们会设置它;否则,我们将保持默认等级(等级 1)。然后,一旦我们有了这些单词,我们就可以获取单词并创建 GameView 实例。

下一步是去掉我们的快速且简单的测试,并用我们的路由器来替换它。在 views 目录的 index.ejs 文件中,最终的脚本标签(内联脚本)应该看起来像这样:

var r = new Router({
  main: $("#main")
});
Backbone.history.start({ pushState: true });

现在,你可以回到浏览器中尝试 /play/play/3 这两条路由。你应该能够像以前一样玩这个游戏。

创建主页视图

当用户第一次来到我们的网站时,我们不想立即显示游戏视图。大多数网络应用程序都会有一些主页视图,或者欢迎视图,解释应用程序的目的。我们可以将其作为一个服务器端模板,但我们将使用 Backbone 视图。以下是 HomeView 类的代码:

var HomeView = Backbone.View.extend({
  template: $('#levels').html(),
  events: {
    'click a' : 'chooseLevel'
  },
  render: function () {
    this.el.innerHTML = this.template;
    return this;
  },
  chooseLevel: function (evt) {
    evt.preventDefault();
    this.remove();
    Backbone.history.navigate(evt.currentTarget.pathname, 
      { trigger: true });
    return false;
  }
});

我们从 index.ejs 文件中获取具有 home 属性的模板。render 方法很简单。events 对象监听锚元素的点击,并调用 chooseLevel 方法。我们之前已经见过这样的方法;它只是阻止默认操作——页面刷新——并使用 Backbone.history 来更改视图。最后,这是这个视图的模板:

<script type='text/template' id='home'>
  <h1>Pick a Level:</h1>
  <h2><a href='/play/1'> Level 1 </a></h2>
  <h2><a href='/play/2'> Level 2 </a></h2>
  <h2><a href='/play/3'> Level 3 </a></h2>
  <h1>Or, check out the <a href='/scoreboard'>scoreboard</a></h1>
</script>

让我们稍微样式化一下 <h1> 元素。你知道在哪里——在 style.css 文件中:

h1 {
  font-weight:300;
  font-size:39px;
}

现在,我们需要在路由器中使用这个视图。打开 public 目录中的 router.js 文件,并添加 index 路由:

'': 'index'

然后,我们将添加 index 函数:

index: function () {
  this.main.html(new HomeView().render().el);
},

现在,你应该能够访问根路由并看到这个:

创建主页视图

构建分数板

我们已经为这个应用程序构建了主要视图。然而,每个完整的网络应用程序都将有几个视图,这些视图并不特定于应用程序的主要目的,但有助于完善其有用性。在我们的应用程序中,这将是一个分数板视图;一个玩家可以看到彼此的最佳时间和分数的地方。让我们这次从服务器端开始,在 server.js 文件中。在通配符路由之前添加这个路由:

app.get('/scoreboard', function (req, res) {
  users.find(function (err, userRecords) {
    userRecords.forEach(function (user) {
      user.totalScore = 0;
      user.games.forEach(function (game) {
        user.totalScore += game.score;
      });
    });
    userRecords.sort(function (a,b) { 
      return b.score - a.score
    });

    res.render("scoreboard.ejs", { users: userRecords });
  });
});

我们首先获取数据库中的所有用户。然后,我们遍历每个用户,为每个用户添加一个totalScore属性。我们遍历每个用户的games数组,并为每个游戏累加得分,创建totalScore属性。注意,我们实际上没有在数据库中更改任何东西;我们只是在本地创建了一个临时属性。然后,我们排序userRecords数组;默认情况下,数组的sort方法将按字母顺序排序,所以我们在这里传递一个函数来按得分从高到低排序用户。然后,我们将渲染views目录中的scoreboard.ejs模板,并传递userRecords对象。

下面是scoreboard.ejs模板的代码:

<% include header %>
<div id="main">
  <h1> Scoreboard </h1>
  <table class="users">
  <thead>
    <tr>
      <th>Name</th>
      <th>Total Score</th>
      <th>Best Game</th>
      <th>Best Time</th>
    </tr>
  </thead>
  <tbody>
  <% users.forEach(function(user){ %>
    <tr>
      <td><%=: user.username | capitalize %></td>
      <td><%=  user.totalScore %></td>
      <td><%= user.score %></td>
      <td><%=: user.time | time %></td>
    </tr>
  <% }); %>
  </tbody>
  </table>
</div>
<% include footer %>

就像我们的其他全页模板一样,我们将使用页眉和页脚包含文件来打开和关闭。然后,我们将创建主要元素。这个元素内部有一个表格元素。我们从一个<thead>元素开始,包含四个列标题:玩家的名字、总分、最佳游戏得分和最佳时间。然后,在<tbody>元素内部,我们遍历user数组,并为每个用户添加一行。在这里,我们使用了 EJS 的一个特性:过滤器。例如,我们打印user.username属性,但通过capitalize过滤器过滤它,所以第一个字母将会是大写的。然后,user.time属性是一个秒数,所以我们通过time过滤器过滤它,以显示一个对人类友好的字符串。然而,这不是一个内置的过滤器,所以我们将不得不自己编写它。

server.js文件中,我们首先需要 Express 在幕后使用的ejs库:

var ejs = require('ejs');

然后,我们必须编写过滤器函数。实际上,我们只需复制并调整Game类中的时间方法即可:

ejs.filters.time = function(seconds) {
  var hrs = parseInt(seconds / 3600),
    min = parseInt((seconds % 3600) / 60),
    sec = (seconds % 3600) % 60;

  if (min < 10) min = "0" + min;
  if (sec < 10) sec = "0" + sec;
  var time = min + ":" + sec;

  if (hrs === 0) return time;

  if (hrs < 10) hrs = "0" + hrs;
  return hrs + ":" + time;
};

计分板的最后一步是为用户的表格添加一些样式。再次回到public目录中的style.css文件:

table.users {
  border-collapse: collapse;
}

.users tbody tr {
  background: #4E5D6C;
}

.users tbody tr td {
  padding: 10px;
}

.users th,
.users td {
  width: 25%;
  text-align:center;
}

它并不复杂,但能完成工作。我们将添加一些填充并着色背景,然后完成!这是最终产品:

构建计分板

编写导航

我们应用程序的下一部分将整合所有内容;它是导航栏。在之前的程序中,导航是其自己的 Backbone 视图,但这次不是这样。相反,我们将创建一个专门用于导航的新服务器端模板。我们将能够像使用页眉和页脚模板一样使用它。所以,在views目录中创建一个nav.ejs文件,并将以下代码放入其中:

<nav>
  <ul>
    <li><a href="/">Tokenr</a></li>
    <li><a href="/"> Play </a></li>
    <li><a href="/scoreboard"> Scoreboard </a></li>
  </ul>
</nav>

这是一个基本的列表;在我们的应用程序中不需要做太多的导航。但当然,我们需要添加一些样式。这是public目录中style.css文件的最后一次添加:

nav {
  margin:0;
  background-color: #4E5D6C;
  overflow: hidden;
  font-size:19px;
}
ul {
  list-style-type:none;
  margin:0;
  padding: 0;
}
nav li {
  display: inline-block;
}
nav li a {
  display: inline-block;
  padding: 10px;
}
nav li a:hover {
  background-color: #485563;
}
nav li:nth-of-type(1) a {
  color: #D4514D;
}

这种样式在页面顶部创建了一个漂亮的导航栏,每个链接都有漂亮的悬停效果。最后一部分给第一个项目添加了颜色,使其看起来像标志。现在,将其作为包含项添加到index.ejsscoreboard.ejs文件的头包含项下,如下所示:

<% include nav %>

就这些!下面是它的样子:

编写导航

添加新单词

让我们给我们的应用程序添加一个新功能;将新单词添加到单词列表中的能力。我们不会允许任何用户这样做,只有管理员。我们如何将用户变成管理员呢?嗯,我们将作弊。直接打开users.json文件,并为我们选择的用户对象添加一个"admin":true属性。然后,我们将打开server.js文件;首先是/new的 GET 路由:

app.get('/new', function (req, res) {
  if (req.user && req.user.admin) {
    res.render('new.ejs');
  } else {
    res.redirect('/');
  }
});

如果有用户登录,并且该用户是管理员,那么我们将渲染新单词模板。否则,我们将重定向到根路由。在views目录中创建new.ejs文件,并写入以下内容:

<% include header %>
<% include nav %>
<div id='main'>
  <form method="post" action="/new">
    <h1> Add a Word </h1>
    <p>Word:</p>
    <p><input type='text' name='word' /></p>
    <p>Definition:</p>
    <p><input type='text' name='definition' /></p>
    <p>Level:
      1 <input type='radio' name='level' value='1' />
      2 <input type='radio' name='level' value='2' />
      3 <input type='radio' name='level' value='3' />
    </p>
    <p><button>Add</button></p>
  </form>
</div>
<% include footer %>

当表单提交时,我们将发布到/new路由。我们有一个用于单词及其定义的输入元素(我们在这里可以使用文本区域,但输入元素将鼓励简短的定义)。然后,我们有一组单选按钮用于选择难度级别。由于这将发布到相同的路由,我们需要在服务器端创建一个 POST 路由来捕获新单词:

app.post('/new', function (req, res) {
  if (req.user && req.user.admin) {
    var w = {
      word: req.body.word.toLowerCase(),
      definition: req.body.definition,
      level: parseInt(req.body.level, 10)
    };
    words.find({ word: w.word }, function (err, ws) {
      if (ws.length === 0) {
        words.insert(w);
      }
    });
    res.redirect('/new');
  } else {
    res.redirect('/');
  }
});

如果有管理员用户登录,我们将创建一个单词对象w。然后,我们将检查单词数据库以查看单词是否已存在;如果不存在,我们将插入它。最后,我们将返回到表单,以便管理员可以插入另一个单词(如果他们想的话)。

最后,让我们将此路径添加到导航中,但仅当管理员登录时。在views目录的nav.ejs文件中,将其作为最后一个列表项添加:

<% if (admin) { %>
  <li><a href="/new"> Add Word </a></li>
<% } %>

然后,在调用res.render函数的所有模板中,我们使用nav.ejs(这是new.ejsscoreboard.ejsindex.ejs),将admin值添加到传递给模板的值中。例如:

res.render("index.ejs", { admin: req.user && req.user.admin });

如果用户登录并且他们是管理员,则admin将为true。否则,它将为false

摘要

这就带我们结束了上一章的结尾。在本章中,我们首先探讨的大想法是在初始页面加载时不加载任何应用程序数据。如果你的应用程序使用大量数据,这通常是一个好主意。这不仅缩短了初始页面加载时间,而且还可以防止你加载用户不需要的数据(例如,如果用户从未使用应用程序的特定功能,该功能所需的数据永远不会加载)。

本章需要记住的另一件事是,Backbone 应用程序可能不仅仅是 Backbone 页面。我们的得分板页面就是这样一个很好的例子。通过 Backbone 创建这个页面并不困难——只需创建一个User模型、一个Users集合和几个视图即可——但由于用户记录除了登录之外在客户端实际上没有任何相关性,所以我们选择了从服务器端进行更简单的实现方式。你的 Web 应用程序可能还有其他不需要数据的页面:比如联系页面、常见问题解答页面等。不要忘记这些细节!

本章所涵盖的大部分内容是对 Backbone 主要组件(模型、集合、视图和路由器)的初级使用方法的回顾。就像任何事物一样,完全理解某物的工作方式的美妙之处在于你可以自由地以任何你选择的方式去调整它。在这本书的整个过程中,我们探讨了在 Backbone 中几乎做任何事情的不同方法。如果你只能从中带走一样东西,让它成为这一点;这只是 JavaScript,而且有无数其他未提及的方式来创建你自己的模式。可以说,编程就像自我表达一样重要,一个熟练的程序员不会害怕去实验。这里就举一个例子。如果一个视图类的initialize方法以调用render方法结束会怎样?享受你的 Backbone 应用程序吧!

posted @ 2025-09-24 13:53  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报