Node-蓝图-全-
Node 蓝图(全)
原文:
zh.annas-archive.org/md5/bc5cffc75d56fb2c71d8af21a49d7f59
译者:飞龙
前言
如你所知,我们领域中的大事都是由社区推动的。Node.js 是一种已经变得非常流行的技术。它的生态系统设计得很好,并带来了我们所需要的灵活性。随着移动开发的兴起,JavaScript 现在占据了技术栈的一大块。在服务器端使用 JavaScript 的能力非常有趣。了解 Node.js 的工作原理以及何时何地使用它是有益的,但更重要的是看到一些示例。这本书将向你展示这种奇妙的技术是如何处理实际用例的。
本书涵盖内容
第一章,常见的编程范式,介绍了 Node.js 是一种由 JavaScript 驱动的技术,我们可以在 Node.js 中应用在 JavaScript 中已知的常见设计模式。
第二章,使用 Node.js 和 Express 开发基本网站,讨论了 ExpressJS 是市场上最好的框架之一。ExpressJS 被包含在内,是因为它在 Node.js 世界中的基本重要性。在本章的结尾,你将能够使用内置的 Express 模块创建应用程序,并添加你自己的模块。
第三章,使用 Node.js 和 AngularJS 编写博客应用,教你如何使用 Node.js 与前端框架如 AngularJS 一起使用。本章的示例实际上是一个与真实数据库一起工作的动态应用。
第四章,使用 Socket.IO 开发聊天应用,解释说如今,每个大型网络应用都使用实时数据。向用户展示即时结果是重要的。本章涵盖了创建一个简单的实时聊天应用。同样的概念可以用来创建一个自动更新的 HTML 组件。
第五章,使用 Backbone.js 创建待办事项应用,说明了 Backbone.js 是最早在应用前端引入数据绑定的框架之一。本章将向你展示这个库是如何工作的。待办事项应用是一个简单的示例,但完美地展示了框架的强大功能。
第六章,将 Node.js 用作命令行工具,涵盖了创建一个简单的 CLI 程序。有许多用 Node.js 编写的命令行工具,能够创建自己的工具是非常令人满意的。本书的这一部分将展示一个简单的应用程序,该程序可以抓取目录中的所有图片并将它们上传到 Flickr。
第七章, 使用 Ember.js 展示社交动态,描述了一个 Ember.js 示例,该示例将读取 Twitter 动态并显示最新帖子。这实际上是每个开发者的常见任务,因为许多应用程序都需要可视化社交活动。
第八章, 使用 Grunt 和 Gulp 开发 Web 应用工作流程,展示了在将应用程序交付给用户之前有许多事情要做,例如连接、压缩、模板化等。Grunt 是此类任务的既定标准。所描述的模块优化并加快了您的流程。本章展示了简单的应用程序设置,包括管理 JavaScript、CSS、HTML 和缓存清单。
第九章, 使用 Node.js 自动化测试,表明测试对当今的每个应用程序都至关重要。Node.js 有一些真正出色的模块可以用于此。如果您是测试驱动开发的粉丝,那么这一章就是为您准备的。
第十章, 编写灵活和模块化的 CSS,介绍了两个最受欢迎的 CSS 预处理器都是用 Node.js 编写的。这一章就像是对它们的简要介绍,当然,也描述了如何为简单的网页进行样式设计。
第十一章, 编写 REST API,指出 Node.js 是一种快速工作的技术,它是构建 REST API 的完美候选者。您将学习如何创建一个简单的 API 来存储和检索书籍数据,即在线图书馆。
第十二章, 使用 Node.js 开发桌面应用程序,表明 Node.js 不仅仅是一种网络技术——您还可以用它来创建桌面应用程序。了解您可以使用 HTML、CSS 和 JavaScript 来创建桌面程序是非常有趣的。创建一个简单的文件浏览器可能不是一项具有挑战性的任务,但它将为您提供足够的知识来构建您自己的应用程序。
阅读本书所需的条件
您需要安装 Node.js,一个浏览器,以及您喜欢的代码编辑器。这就是您将使用的所有工具。还有很多额外的模块可以使用,但 Node.js 自带一个出色的包管理器,可以处理安装过程。
本书面向的对象
本书面向中级开发者。它教您如何使用流行的 Node.js 库和框架。因此,需要良好的 JavaScript 知识。
习惯用法
在本书中,您将找到许多不同风格的文本,以区分不同类型的信息。以下是一些这些风格的示例,以及它们含义的解释。
文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称会以以下方式显示:“我们在第一行初始化的http
模块是运行网络服务器所需的。”
代码块会以以下方式设置:
var http = require('http');
var getTime = function() {
var d = new Date();
return d.getHours() + ':' + d.getMinutes() + ':' +
d.getSeconds() + ':' + d.getMilliseconds();
}
任何命令行输入或输出会以以下方式书写:
express --css less myapp
新术语和重要词汇会以粗体显示。你在屏幕上看到的,例如在菜单或对话框中的文字,会以这样的形式出现:“点击带有文本OK, I'LL AUTHORIZE IT的蓝色按钮。”
注意
警告或重要注意事项会以这样的框显示。
小贴士
小贴士和技巧会像这样显示。
读者反馈
读者反馈总是受欢迎的。告诉我们你对这本书的看法——你喜欢什么或可能不喜欢什么。读者反馈对我们开发你真正能从中获得最大价值的标题非常重要。
要发送给我们一般性的反馈,只需发送一封电子邮件到<feedback@packtpub.com>
,并在邮件主题中提及书名。
如果你在一个领域有专业知识,并且你对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在你已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助你从你的购买中获得最大价值。
下载示例代码
你可以从你购买的所有 Packt 图书的账户中下载示例代码文件。www.packtpub.com
。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support
并注册,以便直接将文件通过电子邮件发送给你。
下载本书的颜色图像
我们还为你提供了一个包含本书中使用的截图/图表颜色图像的 PDF 文件。这些颜色图像将帮助你更好地理解输出的变化。你可以从以下链接下载此文件:www.packtpub.com/sites/default/files/downloads/7338OS_ColoredImages.pdf
错误更正
尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——我们非常感谢您能向我们报告。通过这样做,您可以避免其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata
,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分下的现有勘误列表中。您可以通过选择您的标题从 www.packtpub.com/support
查看任何现有勘误。
盗版
在互联网上盗版版权材料是一个跨所有媒体持续存在的问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何我们作品的非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 <copyright@packtpub.com>
联系我们,并提供涉嫌盗版材料的链接。
我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。
问题
如果您在本书的任何方面遇到问题,可以通过 <questions@packtpub.com>
联系我们,我们将尽力解决。
第一章. 常见编程范式
Node.js 是一种由 JavaScript 驱动的技术。这种语言已经发展了超过 15 年,最初用于 Netscape。多年来,他们发现了有趣和有用的设计模式,这些模式将有助于我们在本书中。所有这些知识现在都可供 Node.js 程序员使用。当然,由于我们在不同的环境中运行代码,所以存在一些差异,但我们仍然能够应用所有这些良好的实践、技术和范式。我总是说,为您的应用程序建立一个良好的基础是很重要的。无论您的应用程序有多大,它都应该依赖于灵活且经过良好测试的代码。本章包含经过验证的解决方案,保证您有一个良好的起点。了解设计模式并不一定使您成为更好的开发者,因为在某些情况下,严格应用原则可能不起作用。您实际上得到的是想法,这些想法将帮助您跳出思维定式。有时,编程就是管理复杂性。我们都会遇到问题,而编写良好应用程序的关键是找到最佳合适的解决方案。我们了解的范式越多,我们的工作就越容易,因为我们有现成的、可以应用的概念。这就是为什么本书从介绍最常见的编程范式开始。
Node.js 基础知识
Node.js 是一种单线程技术。这意味着每个请求都只在一个线程中处理。在其他语言中,例如 Java,Web 服务器为每个请求实例化一个新的线程。然而,Node.js 旨在使用异步处理,有一种理论认为在单个线程中这样做可能会带来良好的性能。单线程应用程序的问题在于阻塞 I/O 操作;例如,当我们需要从硬盘读取文件以响应用户时。一旦新的请求到达我们的服务器,我们就打开文件并开始读取。问题发生在当另一个请求生成,而应用程序仍在处理第一个请求时。让我们通过以下示例来阐明这个问题:
var http = require('http');
var getTime = function() {
var d = new Date();
return d.getHours() + ':' + d.getMinutes() + ':' +
d.getSeconds() + ':' + d.getMilliseconds();
}
var respond = function(res, str) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(str + '\n');
console.log(str + ' ' + getTime());
}
var handleRequest = function (req, res) {
console.log('new request: ' + req.url + ' - ' + getTime());
if(req.url == '/immediately') {
respond(res, 'A');
} else {
var now = new Date().getTime();
while(new Date().getTime() < now + 5000) {
// synchronous reading of the file
}
respond(res, 'B');
}
}
http.createServer(handleRequest).listen(9000, '127.0.0.1');
小贴士
下载示例代码
您可以从www.packtpub.com
的账户下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support
并注册,以便将文件直接通过电子邮件发送给您。
我们在第一行初始化的http
模块是运行 Web 服务器所需的。getTime
函数返回当前时间作为字符串,respond
函数向客户端的浏览器发送一个简单的文本,并报告收到的请求已被处理。最有趣的功能是handleRequest
,它是我们逻辑的入口点。为了模拟读取大文件,我们将创建一个持续 5 秒的while
循环。一旦我们运行服务器,我们就可以向http://localhost:9000
发送 HTTP 请求。为了演示单线程行为,我们将同时发送两个请求。这些请求如下:
-
一个请求将被发送到
http://localhost:9000
,服务器将执行一个需要 5 秒的同步操作 -
另一个请求将被发送到
http://localhost:9000/immediately
,服务器应该立即响应
以下截图是服务器打印出的输出,在 ping 了两个 URL 之后:
如我们所见,第一个请求在16:58:30:434
到达,其响应在16:58:35:440
发送,即 5 秒后。然而,问题是第二个请求在第一个请求完成后被注册。这是因为属于 Node.js 的线程正忙于处理while
循环。
当然,Node.js 有针对阻塞 I/O 操作的解决方案。它们被转换为接受回调的异步函数。一旦操作完成,Node.js 就会触发回调,通知任务已完成。这种方法的一个巨大好处是,在等待 I/O 结果的同时,服务器可以处理另一个请求。处理外部事件并将它们转换为回调调用的实体被称为event
循环。event
循环充当一个非常好的管理者,并将任务委托给各种工作者。它从不阻塞,只是等待事情发生;例如,文件成功写入的通知。
现在,我们不再同步读取文件,而是将我们的简短示例转换为使用异步代码。修改后的示例看起来如下代码:
var handleRequest = function (req, res) {
console.log('new request: ' + req.url + ' - ' + getTime());
if(req.url == '/immediately') {
respond(res, 'A');
} else {
setTimeout(function() {
// reading the file
respond(res, 'B');
}, 5000);
}
}
将while
循环替换为setTimeout
调用。这种更改的结果在服务器输出中非常明显,如下面的截图所示:
第一个请求仍然在 5 秒后收到响应。然而,第二个请求立即被处理。
在模块中组织你的代码逻辑
如果我们编写了大量的代码,迟早我们会开始意识到我们的逻辑应该被分割成不同的模块。在大多数语言中,这是通过类、包或其他语言特定的语法来实现的。然而,在 JavaScript 中,我们并没有原生的类。一切都是对象,在实践中,对象继承其他对象。在 JavaScript 中实现面向对象编程有几种方法。你可以使用原型继承、对象字面量或玩转函数调用。幸运的是,Node.js 有一个标准化的模块定义方式。这是通过实现 CommonJS 来实现的,这是一个指定 JavaScript 生态系统项目的项目。
所以,你有一些逻辑,并且你希望通过提供有用的 API 方法来封装它。如果你达到了那个时刻,你肯定是在正确的方向上。这真的很重要,也许它是当今编程中最具挑战性的方面之一。将我们的应用程序分割成不同的部分并将函数委托给它们的能力并不总是容易的任务。很多时候,这被低估了,但它却是良好架构的关键。如果一个模块包含大量的依赖项、操作不同的数据存储或具有多个职责,那么我们就是在做错事。这样的代码无法被测试,并且难以维护。即使我们注意到了这两件事,扩展代码并继续与之工作仍然很困难。这就是为什么为不同的功能定义不同的模块是很好的。在 Node.js 的上下文中,这是通过 exports
关键字来实现的,它是 module.exports
的引用。
构建汽车构造应用
让我们用一个简单的例子来阐明这个过程。假设我们正在构建一个构建汽车的应用程序。我们需要一个主模块(car
)和几个其他模块,这些模块负责汽车的不同部分(wheels
、windows
、doors
等)。让我们从定义一个代表汽车轮子的模块开始,以下代码:
// wheels.js
var typeOfTires;
exports.init = function(type) {
typeOfTires = type;
}
exports.info = function() {
console.log("The car uses " + typeOfTires + " tires.");
}
上述代码可能是 wheels.js
的内容。它包含两个方法。第一个方法 init
应该首先被调用,接受一个设置,即轮子轮胎的类型。第二个方法简单地输出一些信息。在我们的主文件 car.js
中,我们必须获取轮子的实例并使用提供的 API 方法。这可以按照以下方式完成:
// cars.js
var wheels = require("./wheels.js");
wheels.init("winter");
wheels.info();
当你使用 node car.js
运行应用时,你会得到以下输出:
The car uses winter tires.
因此,你希望暴露给外部世界的所有内容都应该附加到 export
对象上。请注意,typeOfTires
是模块的局部变量。它仅在 wheels.js
中可用,而在 car.js
中不可用。将对象或函数直接应用于 exports
对象也是常见的做法,以下代码示例展示了这一点:
// engine.js
var Class = function() {
// ...
}
Class.prototype = {
forward: function() {
console.log("The car is moving forward.");
},
backward: function() {
console.log("The car is moving backward.");
}
}
module.exports = Class;
在 JavaScript 中,一切都是对象,并且这个对象有一个prototype
属性。它就像一个存储可用变量和方法的地方。prototype
属性在 JavaScript 的继承中得到了广泛的使用,因为它提供了一种传递逻辑的机制。
我们还将澄清module.exports
和exports
之间的区别。正如你所见,在wheels.js
中,我们直接将两个函数init
和info
赋值给了exports
全局对象。实际上,这个对象是module.exports
的引用,并且任何附加到它的函数或变量都可以对外部世界可用。然而,如果我们直接将一个新的对象或函数赋值给export
对象,我们不应该期望在文件导入后能够访问它。这应该使用module.exports
来完成。以下代码将作为一个例子:
// file.js
module.exports.a = 10;
exports.b = 20;
// app.js
var file = require('./file');
console.log(file.a, file.b);
假设app.js
和file.js
两个文件都在同一个目录下。如果我们运行node app.js
,我们将得到10 20
作为结果。然而,考虑如果我们把file.js
的代码改为以下代码会发生什么:
module.exports = { a: 10 };
exports.b = 20;
在这种情况下,我们会得到10 undefined
作为结果。这是因为module.exports
被分配了一个新的对象,而exports
仍然指向旧的。
使用汽车的引擎
假设engine.js
中的模块控制着汽车。它有使汽车前进和后退的方法。它有一点不同,因为逻辑是在一个单独的类中定义的,并且这个类直接作为module.exports
的值传递。此外,因为我们正在导出一个函数,而不是一个对象,所以我们的实例应该使用new
关键字来创建。我们将在以下代码中看到如何使用new
关键字来使汽车引擎工作:
var Engine = require("./engine.js");
var e = new Engine();
e.forward();
使用 JavaScript 函数作为构造函数和直接调用它们之间存在显著差异。当我们以构造函数的方式调用函数时,我们会得到一个具有自己原型的新的对象。如果我们遗漏了new
关键字,最终得到的值就是函数调用的结果。
Node.js 缓存了require
方法返回的模块。这样做是为了防止阻塞event
循环并提高性能。这是一个同步操作,如果没有缓存,Node.js 将不得不重复做同样的工作。还应该知道,我们可以仅使用文件夹名称来调用该方法,但目录内应该有一个package.json
或index.js
文件。所有这些机制都在 Node.js 的官方文档nodejs.org/
中得到了很好的描述。这里需要注意的是,环境鼓励模块化编程。我们需要的只是将原生实现集成到系统中,我们不需要使用提供模块化的第三方解决方案。
就像客户端代码一样,每个 Node.js 模块都可以被扩展。再次强调,由于我们正在用纯 JavaScript 编写代码,我们可以使用众所周知的继承方法。例如,看看以下代码:
var Class = function() { }
Class.prototype = new require('./engine.js')();
Class.prototype.constructor = Class;
Node.js 甚至为此提供了一个辅助方法。假设我们想要扩展我们的engine.js
类,并添加 API 方法来控制汽车向左和向右移动。我们可以用以下代码片段来实现:
// control.js
var util = require("util");
var Engine = require("./engine.js");
var Class = function() { }
util.inherits(Class, Engine);
Class.prototype.left = function() {
console.log("The car is moving to left.");
};
Class.prototype.right = function() {
console.log("The car is moving to right.");
}
module.exports = Class;
第一行获取 Node.js 原生utils
模块的引用。它包含许多有用的函数。第四行是魔法发生的地方。通过调用inherits
方法,我们实际上为我们的Class
对象设置了一个新的原型。请记住,每个新方法都应该使用已经应用的原型。这就是为什么left
和right
方法在继承之后定义。最后,我们的汽车将能够向四个方向移动,如下面的代码片段所示:
var Control = require("./control.js");
var c = new Control();
c.forward();
c.right();
理解模块间通信
我们已经找到了如何将我们的代码逻辑放入模块中的方法。现在,我们需要知道如何使它们相互通信。人们经常将 Node.js 描述为一个事件驱动系统。它也被称作非阻塞的,因为我们之前在章节中看到,它可以在上一个请求完全完成之前接受新的请求。这非常高效且具有高度的可扩展性。事件非常强大,是通知其他模块正在发生什么的良好手段。它们带来了封装,这在模块化编程中非常重要。让我们给之前讨论的汽车示例添加一些事件。假设我们有空调,我们需要知道它何时启动。这种逻辑的实现包括两个部分。第一个是空调模块。它应该派发一个表示动作开始的事件。第二个部分是其他监听该事件的代码。我们将创建一个名为air.js
的新文件,其中包含负责空调的逻辑,如下所示:
// air.js
var util = require("util");
var EventEmitter = require('events').EventEmitter;
var Class = function() { }
util.inherits(Class, EventEmitter);
Class.prototype.start = function() {
this.emit("started");
};
module.exports = Class;
我们的这个类扩展了名为EventEmitter
的 Node.js 模块。它包含emit
或on
等方法,这些方法帮助我们建立基于事件的通信。定义了一个自定义方法:start
。它简单地派发一个表示空调已开启的事件。以下代码展示了我们如何附加一个监听器:
// car.js
var AirConditioning = require("./air.js");
var air = new AirConditioning();
air.on("started", function() {
console.log("Air conditioning started");
});
air.start();
创建了一个 AirConditioning
类的新实例。我们附加了一个事件监听器并调用了 start
方法。处理程序被调用,并将消息打印到控制台。这个例子很简单,但展示了两个模块如何通信。这是一个非常强大的方法,因为它提供了封装。模块知道自己的职责,并且对系统其他部分的操作不感兴趣。它只是完成自己的工作并派发通知(事件)。例如,在前面的代码中,AirConditioning
类不知道我们在它启动时会输出一条消息。它只知道应该派发一个特定的事件。
非常常见的情况是在事件发射期间发送数据。这非常简单。我们只需在事件名称旁边传递另一个参数即可。以下是如何发送 status
属性的示例:
Class.prototype.start = function() {
this.emit("started", { status: "cold" });
};
附加到事件的对象包含有关空调模块的一些信息。相同的对象将在事件监听器中可用。以下代码展示了如何获取之前提到的 status
变量的值:
air.on("started", function(data) {
console.log("Status: " + data.status);
});
存在一个设计模式可以说明上述过程。它被称为 观察者。在该模式的上下文中,我们的空调模块被称为 主题,而汽车模块被称为观察者。主题向其观察者广播消息或事件,通知他们有变化发生。
如果我们需要移除一个监听器,Node.js 提供了一个名为 removeListener
的方法。我们甚至可以使用 setMaxListeners
允许特定数量的观察者。总的来说,事件是连接你的逻辑部分的最佳方式之一。主要好处是你可以隔离模块,但仍然与你的应用程序的其他部分保持高度通信。
异步编程
如我们之前所学的,在非阻塞环境中,例如 Node.js,大多数过程都是异步的。一个请求到达我们的代码,我们的服务器开始处理它,但与此同时继续接受新的请求。例如,以下是一个简单的文件读取操作:
fs.readFile('page.html', function (err, content) {
if (err) throw err;
console.log(content);
});
readFile
方法接受两个参数。第一个参数是我们想要读取的文件的路径,第二个参数是在操作完成时将被调用的函数。即使读取失败,回调也会被触发。此外,由于所有操作都可以通过异步方式完成,我们可能会遇到一个非常长的回调链。这有一个术语,称为回调地狱。为了阐明这个问题,我们将扩展前面的示例并执行一些文件内容操作。在下面的代码中,我们嵌套了几个异步操作:
fs.readFile('page.html', function (err, content) {
if(err) throw err;
getData(function(data) {
applyDataToTheTemplate(content, data, function(resultedHTML) {
renderPage(resultedHTML, function() {
showPage(function() {
// finally, we are done
});
});
});
});
});
如您所见,我们的代码看起来很糟糕。它难以阅读和跟踪。有十几种工具可以帮助我们避免这种情况。然而,我们可以自己解决这个问题。首先要做的是发现问题。如果我们有四个或五个以上的嵌套回调,那么我们绝对应该重构我们的代码。有一种非常简单的方法,通常很有帮助,可以使代码扁平化。前面的代码可以转换为一个更友好、更易读的格式。例如,请看以下代码:
var onFileRead = function(content) {
getData(function(data) {
applyDataToTheTemplate(content, data, dataApplied);
});
}
var dataApplied = function(resultedHTML) {
renderPage(resultedHTML, function() {
showPage(weAreDone);
});
}
var weAreDone = function() {
// finally, we are done
}
fs.readFile('page.html', function (err, content) {
if (err) throw err;
onFileRead(content);
});
大多数回调函数都是单独定义的。由于函数具有描述性的名称,因此可以清楚地了解正在发生的事情。然而,在更复杂的情况下,这种技术可能不起作用,因为你需要定义很多方法。如果是这样的话,那么将函数组合在外部模块中会更好。前面的例子可以转换为一个接受文件名和回调函数的模块。该模块如下所示:
var renderTemplate = require("./renderTemplate.js");
renderTemplate('page.html', function() {
// we are done
});
你仍然有一个回调,但它看起来像辅助方法被隐藏了,只有主要功能是可见的。
处理异步代码的另一种流行方法是 promises 模式。我们已经讨论了 JavaScript 中的事件,而 promises 与它们类似。我们仍然在等待某个事件发生并传递一个回调。我们可以说,promises 代表一个目前不可用但将来会可用的值。promises 的语法使异步代码看起来像是同步的。让我们看看一个例子,其中有一个简单的模块加载 Twitter 推文。例子如下:
var TwitterFeed = require('TwitterFeed');
TwitterFeed.on('loaded', function(err, data) {
if(err) {
// ...
} else {
// ...
}
});
TwitterFeed.getData();
我们为 loaded
事件附加了一个监听器,并调用了 getData
方法,该方法连接到 Twitter 并获取信息。以下代码是如果 TwitterFeed
类支持 promises 的相同示例:
var TwitterFeed = require('TwitterFeed');
var promise = TwitterFeed.getData();
promise.then(function(data) {
// ...
}, function(err) {
// ...
});
promise
对象代表我们的数据。第一个函数,它被发送到 then
方法,当 promise
对象成功时被调用。请注意,回调是在调用 getData
方法之后注册的。这意味着我们不是严格绑定到获取数据的过程。我们不关心动作何时发生。我们只关心它何时完成以及它的结果是什么。我们可以从基于事件的实现中看到一些差异。如下所示:
-
有一个单独的函数用于错误处理。
-
getData
方法可以在调用then
方法之前调用。然而,对于事件来说,这种情况是不可能的。我们需要在运行逻辑之前附加监听器。否则,如果我们的任务是同步的,事件可能会在我们附加监听器之前被分发。 -
promise 方法只能成功或失败一次,而一个特定的事件可能会被触发多次,并且其处理程序可以被多次调用。
当我们将承诺链在一起时,它们变得非常有用。为了阐明这一点,我们将使用相同的例子,并使用以下代码将推文保存到数据库中:
var TwitterFeed = require('TwitterFeed');
var Database = require('Database');
var promise = TwitterFeed.getData();
promise.then(function(data) {
var promise = Database.save(data);
return promise;
}).then(function() {
// the data is saved
// into the database
}).catch(function(err) {
// ...
});
因此,如果我们的成功回调返回一个新的承诺,我们可以再次使用 then
。此外,我们还可以只设置一个错误处理器。如果某些承诺被拒绝,最后的 catch
方法会被触发。
每个承诺都有四种状态,我们应该在这里提及它们,因为这是一个广泛使用的术语。一个承诺可能处于以下任何一种状态:
-
实现(Fulfilled):当与承诺相关的操作成功时,承诺处于实现状态
-
拒绝(Rejected):当与承诺相关的操作失败时,承诺处于拒绝状态
-
挂起(Pending):如果承诺尚未被实现或拒绝,它就处于挂起状态
-
已解决(Settled):当承诺被实现或拒绝时,承诺处于已解决状态
JavaScript 的异步特性使得我们的编码变得非常有趣。然而,它有时也可能导致很多问题。以下是对讨论的思路的总结,以处理这些问题:
-
尽量使用更多函数而不是闭包
-
通过移除闭包并定义顶层函数来避免金字塔状代码
-
使用事件
-
使用承诺
探索中间件架构
Node.js 框架基于中间件架构。这是因为这种架构带来了模块化。添加或删除系统功能非常容易,而不会破坏应用程序,因为不同的模块之间不相互依赖。想象一下,我们有几个模块都存储在一个数组中,我们的应用程序逐个使用它们。我们正在控制整个过程,也就是说,只有当我们想要时,执行才会继续。这个概念在以下图中得到了演示:
Connect (github.com/senchalabs/connect
) 是最早实现这种模式的框架之一。在 Node.js 的上下文中,中间件是一个接受请求、响应和下一个回调函数的函数。前两个参数代表中间件的输入和输出。最后一个参数是一种将流程传递给列表中下一个中间件的方式。以下是一个简短的例子:
var connect = require('connect'),
http = require('http');
var app = connect()
.use(function(req, res, next) {
console.log("That's my first middleware");
next();
})
.use(function(req, res, next) {
console.log("That's my second middleware");
next();
})
.use(function(req, res, next) {
console.log("end");
res.end("hello world");
});
http.createServer(app).listen(3000);
data property.
.use(function(req, res, next) {
req.data = { value: "middleware"};
next();
})
.use(function(req, res, next) {
console.log(req.data.value);
})
请求和响应对象在每一个函数中都是相同的。因此,中间件共享相同的范围。同时,它们是完全独立的。这种模式提供了一个非常灵活的开发环境。我们可以组合由不同开发者编写的执行不同任务的模块。
组合与继承
在上一节中,我们学习了如何创建模块,如何使它们相互通信,以及如何使用它们。让我们谈谈如何设计模块。构建一个优秀应用程序的方法有数十种。也有一些关于这个主题的杰出书籍,但我们将关注两种最常用的技术:组合和继承。理解这两者之间的区别非常重要。它们都有优点和缺点。在大多数情况下,它们的用法取决于当前项目。
上一节中的car
类是组合的完美例子。car
对象的功能是由其他小对象构建的。因此,主模块实际上是将任务委托给其他类。例如,汽车的车轮或空调是由外部定义的模块控制的:
var wheels = require("./wheels.js")();
var control = require("./control.js")();
var airConditioning = require("./air.js")();
module.export = {
run: function() {
wheels.init();
control.forward();
airConditioning.start();
}
}
对于外界来说,汽车只有一个方法:run
。然而,实际上我们执行了三种不同的操作,它们定义在其他模块中。通常,组合比继承更受欢迎,因为在使用这种方法时,我们可以轻松地添加我们想要的任何数量的模块。而且,我们不仅可以包含模块,还可以包含其他组合。
另一方面是继承。以下代码是继承的典型示例:
var util = require("util");
var EventEmitter = require('events').EventEmitter;
var Class = function() { }
util.inherits(Class, EventEmitter);
这段代码意味着我们的类需要是一个事件发射器,因此它简单地从另一个类继承该功能。当然,在这种情况下,我们仍然可以使用组合并创建EventEmitter
类的实例,定义如on
和dispatch
等方法,并将实际工作委托出去。然而,在这种情况下使用继承会更好。
事实真相介于两者之间——组合和继承应该协同工作。它们确实是伟大的工具,但每个都有其合适的位置。这不仅仅是黑白分明,有时很难找到正确的方向。有三种方法可以向我们的对象添加行为。如下所示:
-
直接将功能写入对象
-
从已经具有所需行为的类继承功能
-
创建一个执行工作的对象本地实例
第二种与继承相关,最后一种实际上是组合。通过使用组合,我们添加了更多的抽象层,这本身并不是坏事,但它可能导致不必要的复杂性。
管理依赖
依赖管理是复杂软件中最大的问题之一。我们经常围绕第三方库或为其他项目编写的自定义模块构建我们的应用程序。我们这样做是因为我们不希望每次都重新发明轮子。
在本章的前几节中,我们使用了 require
全局函数。这就是 Node.js 将依赖项添加到当前模块的方式。一个 JavaScript 文件中编写的功能被包含在另一个文件中。好事是导入文件中的逻辑存在于自己的作用域中,并且只有公开导出的函数和变量对宿主可见。通过这种行为,我们能够将我们的逻辑模块分离成 Node.js 包。有一个工具可以控制这样的包。它被称为 Node 包管理器 (npm),它作为一个命令行工具提供。Node.js 的流行主要归功于其包管理器的存在。每个开发者都可以发布自己的包并与社区分享。良好的版本控制帮助我们绑定我们的应用程序到特定版本的依赖项,这意味着我们可以使用依赖于其他模块的模块。使这一切工作的主要规则是在我们的项目中添加一个 package.json
文件。我们将使用以下代码添加此文件:
{
"name": "my-awesome-module",
"version": "0.1.10",
"dependencies": {
"optimist": "0.6.1",
"colors": "0.6.2"
}
}
文件内容应该是有效的 JSON 格式,并且至少包含 name
和 version
字段。name
属性应该是唯一的,并且不应该有其他具有相同名称的模块。dependencies
属性包含我们所依赖的所有模块和版本。对于同一个文件,我们可以添加很多其他属性。例如,关于作者的信息、包的描述、项目的许可证,甚至是关键词。一旦模块在注册表中注册,我们就可以将其用作依赖项。我们只需将其添加到我们的 package.json
文件中,然后运行 npm install
,我们就能将其用作依赖项。由于 Node.js 采用了模块模式,我们不需要像依赖注入容器或服务定位器这样的工具。
让我们为之前章节中使用的汽车示例编写一个 package.json
文件,如下所示:
{
"name": "my-awesome-car",
"version": "0.0.1",
"dependencies": {
"wheels": "2.0.1",
"control": "0.1.2",
"air": "0.2.4"
}
}
摘要
在本章中,我们了解了 Node.js 中最常用的编程范式。我们学习了 Node.js 如何处理并行请求。我们了解了如何编写模块并使它们具有通信能力。我们看到了异步代码的问题及其最流行的解决方案。在本章的结尾,我们讨论了如何构建我们的应用程序。有了所有这些作为基础,我们可以开始思考更好的程序。编写软件并不是一件容易的任务,需要强大的知识和经验。经验通常是在多年的编码之后获得的;然而,知识是我们可以立即获得的东西。Node.js 是一种年轻的技术;尽管如此,我们能够应用来自客户端 JavaScript 以及其他语言的范式和概念。
在下一章中,我们将看到如何使用 Node.js 中最受欢迎的框架之一,即 Express.js,并构建一个简单的网站。
第二章. 使用 Node.js 和 Express 开发基本网站
在前一章中,我们学习了常见的编程范式以及它们如何应用于 Node.js。在这一章中,我们将继续介绍 Express 框架。它是可用的最受欢迎的框架之一,无疑是一个开创性的框架。Express 仍然被广泛使用,许多开发者将其作为起点。
熟悉 Express
Express (expressjs.com/
) 是一个 Node.js 的网络应用程序框架。它建立在 Connect (www.senchalabs.org/connect/
) 之上,这意味着它实现了中间件架构。在前一章中,当我们探索 Node.js 时,我们发现了这种设计决策的好处:框架充当插件系统。因此,我们可以说,由于其架构,Express 不仅适合简单应用,也适合复杂应用,因为我们可能只使用一些流行的中间件类型,或者添加很多功能,同时仍然保持应用程序模块化。
通常,大多数 Node.js 项目执行两个功能:运行一个监听特定端口的服务器,并处理传入的请求。Express 是这两个功能的包装器。以下是在服务器上运行的简单代码:
var http = require('http');
http.createServer(function (req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello World\n');
}).listen(1337, '127.0.0.1');
console.log('Server running at http://127.0.0.1:1337/');
这是一个从 Node.js 官方文档中提取的示例。如图所示,我们使用原生模块 http
在端口 1337
上运行服务器。还有一个请求处理函数,它简单地将 Hello world
字符串发送到浏览器。现在,让我们使用以下代码实现相同的事情,但使用 Express 框架:
var express = require('express');
var app = express();
app.get("/", function(req, res, next) {
res.send("Hello world");
}).listen(1337);
console.log('Server running at http://127.0.0.1:1337/');
这基本上是同一件事。然而,我们不需要指定响应头或在字符串末尾添加新行,因为框架会为我们处理这些。此外,我们有一系列可用的中间件,这将帮助我们轻松处理请求。Express 就像是一个工具箱。我们有很多工具来做那些无聊的事情,让我们能够专注于应用程序的逻辑和内容。这正是 Express 的构建目的:通过提供现成的功能来为开发者节省时间。
安装 Express
安装 Express 有两种方法。我们将从简单的方法开始,然后继续到更高级的技术。简单的方法会生成一个模板,我们可以用它直接开始编写业务逻辑。在某些情况下,这可以为我们节省时间。从另一个角度来看,如果我们正在开发一个自定义应用程序,我们需要使用自定义设置。我们还可以使用高级技术获得的样板代码;然而,它可能不适合我们。
使用 package.json
Express 就像其他模块一样。它在包注册表中有自己的位置。如果我们想使用它,我们需要在package.json
文件中添加这个框架。Node.js 生态系统建立在 Node 包管理器之上。它使用 JSON 文件来查找我们需要的内容,并将其安装到当前目录中。因此,我们的package.json
文件的内容如下所示:
{
"name": "projectname",
"description": "description",
"version": "0.0.1",
"dependencies": {
"express": "3.x"
}
}
这些是我们必须添加的必填字段。更准确地说,我们必须说必填字段是name
和version
。然而,总是给我们的模块添加描述是个好主意,尤其是如果我们想在注册表中发布我们的工作,那里的信息非常重要。否则,其他开发者将不知道我们的库在做什么。当然,还有许多其他字段,如贡献者、关键词或开发依赖项,但我们将坚持有限选项,以便我们可以专注于 Express。
一旦我们将package.json
文件放置在项目的文件夹中,我们必须在控制台中调用npm install
。这样做,包管理器将创建一个node_modules
文件夹,并将 Express 及其依赖项存储在那里。命令执行结束后,我们将看到如下截图:
第一行显示了安装的版本,接下来的行实际上是 Express 所依赖的模块。现在,我们已经准备好使用 Express 了。如果我们输入require('express')
,Node.js 将开始在本地node_modules
目录中寻找这个库。由于我们没有使用绝对路径,这是正常的行为。如果我们没有运行npm install
命令,我们将收到Error: Cannot find module 'express'
的提示。
使用命令行工具
有一个名为express-generator
的命令行工具。一旦我们运行npm install -g express-generator
,我们就会安装并像在终端中的其他命令一样使用它。
如果你在一个项目中使用这个框架,你会注意到有些事情是重复的。我们甚至可以从一个应用程序复制粘贴到另一个应用程序,这是完全正常的。我们甚至可能最终拥有自己的样板代码,并且总是可以从那里开始。Express 的命令行版本做的是同样的事情。它接受少量参数,并根据这些参数创建一个用于的骨架。在某些情况下,这可能会非常方便,并且肯定会节省一些时间。让我们看看可用的参数:
-
-h, --help
: 这表示输出使用信息。 -
-V, --version
: 这将显示 Express 的版本。 -
-e, --ejs
: 这个参数添加了 EJS 模板引擎支持。通常,我们需要一个库来处理我们的模板。编写纯 HTML 并不实用。默认引擎设置为 JADE。 -
-H, --hogan
: 这个参数启用了 Hogan 模板引擎(另一个模板引擎)。 -
-c, --css
:如果我们想使用 CSS 预处理器,这个选项允许我们使用 LESS(简称 Leaner CSS)或 Stylus。默认是纯 CSS。 -
-f, --force
:这个选项强制 Express 在非空目录上运行。
让我们尝试使用 LESS 作为 CSS 预处理器来生成一个 Express 应用程序骨架。我们使用以下命令行:
express --css less myapp
创建了一个新的 myapp
文件夹,其结构如图所示:
我们仍然需要安装依赖项,因此需要执行 cd myapp && npm install
。现在我们将跳过对生成的目录的解释,并转到创建的 app.js
文件。它从初始化模块依赖项开始,如下所示:
var express = require('express');
var path = require('path');
var favicon = require('static-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
var routes = require('./routes/index');
var users = require('./routes/users');
var app = express();
我们的框架是 express
,而 path
是一个原生 Node.js 模块。中间件包括 favicon
、logger
、cookieParser
和 bodyParser
。routes
和 users
是自定义模块,放置在项目的本地文件夹中。同样,在 模型-视图-控制器 (MVC) 模式下,这些是应用程序的控制器。紧接着,创建了一个 app
变量;这代表了 Express 库。我们使用这个变量来配置我们的应用程序。脚本继续通过设置一些键值对。接下来的代码片段定义了视图的路径和默认模板引擎:
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');
该框架使用 set
和 get
方法来定义内部属性。实际上,我们可以使用这些方法来定义我们自己的变量。如果值是布尔型,我们可以用 enable
和 disable
替换 set
和 get
。例如,请看以下代码:
app.set('color', 'red');
app.get('color'); // red
app.enable('isAvailable');
以下代码向框架中添加了中间件。我们可以如下看到代码:
app.use(favicon());
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded());
app.use(cookieParser());
app.use(require('less-middleware')({ src: path.join(__dirname, 'public') }));
app.use(express.static(path.join(__dirname, 'public')));
第一个中间件充当我们应用程序的 favicon。第二个负责控制台输出。如果我们移除它,我们将无法获取服务器接收到的请求信息。以下是由 logger
产生的一个简单输出:
GET / 200 554ms - 170b
GET /stylesheets/style.css 200 18ms - 110b
json
和 urlencoded
中间件与随请求发送的数据相关。我们需要它们,因为它们将信息转换为易于使用的格式。还有一个用于 cookie 的中间件。它填充请求对象,这样我们就可以稍后访问所需的数据。生成的应用程序使用 LESS 作为 CSS 预处理器,我们需要通过设置包含 .less
文件的目录来配置它。我们将在第十章,编写灵活和模块化的 CSS 中讨论 LESS,我们将详细说明这一点。最终,我们定义了我们的静态资源,这些资源应由服务器提供。这些只是一些简单的行,但我们已经配置了整个应用程序。我们可以移除或替换一些模块,而其他模块将继续工作。文件中的下一代码将两个定义的路由映射到两个不同的处理器,如下所示:
app.use('/', routes);
app.use('/users', users);
如果用户尝试打开一个缺失的页面,Express 仍然会通过将其转发到错误处理器来处理请求,如下所示:
app.use(function(req, res, next) {
var err = new Error('Not Found');
err.status = 404;
next(err);
});
框架建议两种错误处理方式:一种用于开发环境,另一种用于生产服务器。区别在于第二种方式隐藏了错误的堆栈跟踪,这应该只对应用程序的开发者可见。正如我们可以在以下代码中看到的那样,我们正在检查env
属性的值,并不同地处理错误:
// development error handler
if (app.get('env') === 'development') {
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: err
});
});
}
// production error handler
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: {}
});
});
最后,app.js
文件导出创建的 Express 实例,如下所示:
module.exports = app;
要运行应用程序,我们需要执行node ./bin/www
。代码需要app.js
并启动服务器,默认情况下服务器监听端口3000
。
#!/usr/bin/env node
var debug = require('debug')('my-application');
var app = require('../app');
app.set('port', process.env.PORT || 3000);
var server = app.listen(app.get('port'), function() {
debug('Express server listening on port ' + server.address().port);
});
process.env
声明提供了对当前开发环境中定义的变量的访问。如果没有PORT
设置,Express 将使用 3000 作为值。所需的debug
模块使用类似的方法来确定是否需要向控制台显示消息。
管理路由
我们应用程序的输入是路由。用户访问我们的页面时会在特定的 URL,我们必须将这个 URL 映射到特定的逻辑。在 Express 的上下文中,这可以很容易地完成,如下所示:
var controller = function(req, res, next) {
res.send("response");
}
app.get('/example/url', controller);
我们甚至可以控制 HTTP 的方法,也就是说,我们能够捕获 POST、PUT 或 DELETE 请求。如果我们想保留地址路径但应用不同的逻辑,这非常有用。例如,请看以下代码:
var getUsers = function(req, res, next) {
// ...
}
var createUser = function(req, res, next) {
// ...
}
app.get('/users', getUsers);
app.post('/users', createUser);
app.all('/', serverHomePage);
Express 中的路由有一些有趣的特点。我们不仅可以传递一个处理器,还可以传递多个处理器。这意味着我们可以创建一个与一个 URL 对应的函数链。例如,如果我们需要知道用户是否已登录,有一个模块可以做到这一点。我们可以添加另一个方法来验证当前用户并将变量附加到请求对象上,如下所示:
var isUserLogged = function(req, res, next) {
req.userLogged = Validator.isCurrentUserLogged();
next();
}
var getUser = function(req, res, next) {
if(req.userLogged) {
res.send("You are logged in. Hello!");
} else {
res.send("Please log in first.");
}
}
app.get('/user', isUserLogged, getUser);
Validator
类是一个检查当前用户会话的类。想法很简单:我们添加另一个处理器,它充当额外的中间件。在执行必要的操作后,我们调用next
函数,它将流程传递给下一个处理器getUser
。因为对于所有中间件,请求和响应对象都是相同的,所以我们有权访问userLogged
变量。这正是 Express 真正灵活的原因。有很多很棒的功能可用,但它们是可选的。在本章的结尾,我们将创建一个简单的网站,实现相同的逻辑。
处理动态 URL 和 HTML 表单
Express 框架也支持动态 URL。假设我们为系统中的每个用户都有一个单独的页面。这些页面的地址如下所示:
/user/45/profile
在这里,45
是我们数据库中用户的唯一编号。当然,使用一个路由处理器来处理这种功能是正常的。我们真的不能为每个用户定义不同的函数。这个问题可以通过使用以下语法来解决:
var getUser = function(req, res, next) {
res.send("Show user with id = " + req.params.id);
}
app.get('/user/:id/profile', getUser);
路由实际上就像一个包含变量的正则表达式。稍后,这个变量可以在 req.params
对象中访问。我们可以有多个变量。以下是一个稍微复杂一点的例子:
var getUser = function(req, res, next) {
var userId = req.params.id;
var actionToPerform = req.params.action;
res.send("User (" + userId + "): " + actionToPerform)
}
app.get('/user/:id/profile/:action', getUser);
如果我们打开 http://localhost:3000/user/451/profile/edit
,我们会看到 User (451): edit
作为响应。这就是我们如何得到一个看起来很好,SEO 友好的 URL 的方法。
当然,有时我们需要通过 GET 或 POST 参数传递数据。我们可能有一个像 http://localhost:3000/user?action=edit
这样的请求。为了轻松解析它,我们需要使用本地的 url
模块,它有几个辅助函数来解析 URL:
var getUser = function(req, res, next) {
var url = require('url');
var url_parts = url.parse(req.url, true);
var query = url_parts.query;
res.send("User: " + query.action);
}
app.get('/user', getUser);
一旦模块解析了给定的 URL,我们的 GET 参数就存储在 .query
对象中。POST 变量略有不同。我们需要一个新的中间件来处理它。幸运的是,Express 提供了一个,如下所示:
app.use(express.bodyParser());
var getUser = function(req, res, next) {
res.send("User: " + req.body.action);
}
app.post('/user', getUser);
express.bodyParser()
中间件将 POST 数据填充到 req.body
对象中。当然,我们必须将 HTTP 方法从 .get
改为 .post
或 .all
。
如果我们想在 Express 中读取 cookies,我们可以使用 cookieParser
中间件。与 body parser 类似,它也应该被安装并添加到 package.json
文件中。以下示例设置了中间件并演示了其用法:
var cookieParser = require('cookie-parser');
app.use(cookieParser('optional secret string'));
app.get('/', function(req, res, next){
var prop = req.cookies.propName
});
返回响应
我们的服务器接受请求,做一些处理,最后将响应发送到客户端的浏览器。这可以是 HTML、JSON、XML 或二进制数据等。众所周知,默认情况下,Express 中的每个中间件都接受两个对象,request
和 response
。response
对象有我们可以用来向客户端发送答案的方法。每个响应都应该有一个适当的内容类型或长度。Express 通过提供设置 HTTP 头和向浏览器发送内容的功能来简化这个过程。在大多数情况下,我们将使用 .send
方法,如下所示:
res.send("simple text");
当我们传递一个字符串时,框架将 Content-Type
头设置为 text/html
。了解如果我们传递一个对象或数组,内容类型将是 application/json
是很有帮助的。如果我们开发 API,响应状态码可能对我们来说很重要。使用 Express,我们能够像以下代码片段那样设置它:
res.send(404, 'Sorry, we cannot find that!');
甚至可以响应来自我们硬盘上的文件。如果我们不使用框架,我们需要读取文件,设置正确的 HTTP 头,并发送内容。然而,Express 提供了 .sendfile
方法,它将这些操作封装如下:
res.sendfile(__dirname + "/images/photo.jpg");
再次强调,内容类型是自动设置的;这次它是基于文件扩展名来确定的。
当构建具有用户界面的网站或应用程序时,我们通常需要提供 HTML。当然,我们可以手动在 JavaScript 中编写它,但使用模板引擎是良好的实践。这意味着我们将所有内容保存在外部文件中,引擎从那里读取标记。它用一些数据填充它们,最后提供准备好显示的内容。在 Express 中,整个过程总结为一个方法,.render
。然而,为了正常工作,我们必须指导框架使用哪个模板引擎。我们已经在本章的开始讨论了这一点。以下两行代码设置了我们的视图路径和模板引擎:
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');
假设我们有一个以下模板(/views/index.jade
):
h1= title
p Welcome to #{title}
Express 提供了一个提供模板的方法。它接受模板的路径、要应用的数据和一个回调。要渲染前面的模板,我们应该使用以下代码:
res.render("index", {title: "Page title here"});
生成的 HTML 看起来如下:
<h1>Page title here</h1><p>Welcome to Page title here</p>
如果我们传递一个第三个参数,function
,我们将能够访问生成的 HTML。然而,它不会作为响应发送到浏览器。
示例-日志系统
我们已经看到了 Express 的主要功能。现在让我们构建一些真实的东西。接下来的几页展示了一个简单的网站,用户只有在登录后才能阅读。让我们开始并设置应用程序。我们将使用 Express 的命令行工具。它应该使用npm install -g express-generator
来安装。我们为示例创建一个新的文件夹,通过终端导航到它,并执行express --css less site
。将创建一个新的目录,site
。如果我们去那里并运行npm install
,Express 将下载所有必需的依赖项。正如我们之前看到的,默认情况下,我们有两条路由和两个控制器。为了简化示例,我们将只使用第一个:app.use('/', routes)
。让我们将views/index.jade
文件的内容更改为以下 HTML 代码:
doctype html
html
head
title= title
link(rel='stylesheet', href='/stylesheets/style.css')
body
h1= title
hr
p That's a simple application using Express.
现在,如果我们运行node ./bin/www
并打开http://127.0.0.1:3000
,我们将看到页面。Jade 使用缩进来解析我们的模板。因此,我们不应该混合制表符和空格。否则,我们会得到一个错误。
接下来,我们需要保护我们的内容。我们检查当前用户是否创建了一个会话;如果没有,则显示登录表单。这是创建新中间件的最佳时机。
要在 Express 中使用会话,安装一个额外的模块:express-session
。我们需要打开我们的package.json
文件并添加以下代码行:
"express-session": "~1.0.0"
一旦我们这样做,快速运行npm install
就会将模块带到我们的应用程序中。我们唯一要做的就是使用它。以下代码将进入app.js
:
var session = require('express-session');
app.use(session({ secret: 'app', cookie: { maxAge: 60000 }}));
var verifyUser = function(req, res, next) {
if(req.session.loggedIn) {
next();
} else {
res.send("show login form");
}
}
app.use('/', verifyUser, routes);
注意,我们更改了原始的 app.use('/', routes)
行。初始化并添加到 Express 的 session
中间件。在页面渲染之前调用 verifyUser
函数。它使用 req.session
对象,并检查是否定义了 loggedIn
变量,并且其值是否为 true
。如果我们再次运行脚本,我们会看到对于每个请求都会显示“显示登录表单”文本。这是因为没有代码以我们想要的方式设置会话。我们需要一个表单,用户可以在其中输入他们的用户名和密码。我们将处理表单的结果,如果凭证正确,则将 loggedIn
变量设置为 true
。让我们创建一个新的 Jade
模板,/views/login.jade
:
doctype html
html
head
title= title
link(rel='stylesheet', href='/stylesheets/style.css')
body
h1= title
hr
form(method='post')
label Username:
br
input(type='text', name='username')
br
label Password:
br
input(type='password', name='password')
br
input(type='submit')
而不是只发送带有 res.send("show login form");
的文本,我们应该渲染新的模板,如下所示:
res.render("login", {title: "Please log in."});
我们选择 POST 作为表单的方法。因此,我们需要添加中间件,以便用用户的数据填充 req.body
对象,如下所示:
app.use(bodyParser());
按如下方式处理提交的用户名和密码:
var verifyUser = function(req, res, next) {
if(req.session.loggedIn) {
next();
} else {
var username = "admin", password = "admin";
if(req.body.username === username &&
req.body.password === password) {
req.session.loggedIn = true;
res.redirect('/');
} else {
res.render("login", {title: "Please log in."});
}
}
}
有效的凭证设置为 admin/admin
。在实际应用中,我们可能需要访问数据库或从其他地方获取此信息。将用户名和密码放在代码中并不是一个好主意;然而,对于我们的这个小实验来说,这是可以的。之前的代码检查传递的数据是否与预定义的值匹配。如果一切正确,它将设置会话,之后用户将被转发到主页。
登录后,你应该能够注销。让我们在索引页面的内容之后添加一个链接(views/index.jade
):
a(href='/logout') logout
当用户点击此链接时,他们将被转发到新页面。我们只需要为新的路由创建一个处理器,删除会话,并将他们转发到登录表单所在的索引页面。以下是我们注销处理器的样子:
// in app.js
var logout = function(req, res, next) {
req.session.loggedIn = false;
res.redirect('/');
}
app.all('/logout', logout);
将 loggedIn
设置为 false
就足以使会话无效。重定向将用户发送到他们来自的相同内容页面。然而,这次内容被隐藏,登录表单弹出。
摘要
在本章中,我们学习了最广泛使用的 Node.js 框架之一,Express。我们讨论了其基础、如何设置它以及其主要特性。我们在上一章中提到的中间件架构是库的基础,并赋予我们编写复杂但同时又灵活的应用程序的能力。我们使用的示例很简单。我们需要一个有效的会话来提供页面访问。然而,它说明了 body parser 中间件的使用和注册新路由的过程。我们还更新了 Jade
模板,并在浏览器中看到了结果。
下一章将展示 Node.js 如何与 Google 为客户端 JavaScript 应用程序制作的流行框架 AngularJS 协作。
第三章:使用 Node.js 和 AngularJS 编写博客应用程序
在本章中,我们将使用 Node.js 和 AngularJS 构建一个博客应用程序。我们的系统将支持添加、编辑和删除文章,因此将有一个控制面板。MongoDB 或 MySQL 数据库将处理信息的存储,Express 框架将用作网站的基础。它将向最终用户交付 JavaScript、CSS 和 HTML,并提供一个访问数据库的 API。我们将使用 AngularJS 构建用户界面并控制管理页面上的客户端逻辑。
本章将涵盖以下主题:
-
AngularJS 基础知识
-
选择和初始化数据库
-
使用 AngularJS 实现应用程序的客户端部分
探索 AngularJS
AngularJS 是由 Google 开发的一个开源的客户端 JavaScript 框架。它功能丰富,文档齐全。它几乎已经成为单页应用程序开发的标准框架。AngularJS 的官方网站 angularjs.org
提供了结构良好的文档。由于该框架被广泛使用,因此有很多文章和视频教程形式的材料。作为一个 JavaScript 库,它与 Node.js 协作得很好。在本章中,我们将构建一个带有控制面板的简单博客。
在我们开始开发应用程序之前,让我们首先了解一下这个框架。AngularJS 让我们对页面上的数据有了非常好的控制。我们不必考虑从 DOM 中选择元素并填充它们的值。幸运的是,由于可用的数据绑定,我们可以在 JavaScript 部分更新数据,并在 HTML 部分看到变化。反之亦然。一旦我们在 HTML 部分做了更改,我们就会在 JavaScript 部分得到新的值。该框架有一个强大的依赖注入器。有一些预定义的类用于执行 AJAX 请求和管理路由。
您还可以阅读由 Peter Bacon Darwin 和 Pawel Kozlowski 编著,由 Packt Publishing 出版的《Mastering Web Development with AngularJS》。
引导 AngularJS 应用程序
要引导一个 AngularJS 应用程序,我们需要将 ng-app
属性添加到我们的某些 HTML 标签中。选择正确的一个非常重要。如果某个地方有 ng-app
,这意味着所有子节点都将由框架处理。通常的做法是将该属性放在 <html>
标签上。在下面的代码中,我们有一个包含 ng-app 的简单 HTML 页面:
<html ng-app>
<head>
<script src="img/angular.min.js"></script>
</head>
<body>
...
</body>
</html>
非常常见的是,我们会将一个值应用到属性上。这将是一个模块名称。我们会在开发我们的博客应用的控制面板时这样做。有自由将ng-app
放置在我们想要的位置意味着我们可以决定我们的标记的哪一部分将由 AngularJS 控制。这是好的,因为如果我们有一个巨大的 HTML 文件,我们真的不想花费资源解析整个文档。当然,我们可能需要手动引导我们的逻辑,当我们页面上有多个 AngularJS 应用时,这是必要的。
使用指令和控制器
在 AngularJS 中,我们可以实现模型-视图-控制器(Model-View-Controller)模式。控制器作为数据(模型)和用户界面(视图)之间的粘合剂。在框架的上下文中,控制器只是一个简单的函数。例如,以下 HTML 代码说明了控制器只是一个简单的函数:
<html ng-app>
<head>
<script src="img/angular.min.js"></script>
<script src="img/HeaderController.js"></script>
</head>
<body>
<header ng-controller="HeaderController">
<h1>{{title}}</h1>
</header>
</body>
</html>
在页面的<head>
中,我们添加了库的精简版本和HeaderController.js
;一个将包含我们控制器代码的文件。我们还在 HTML 标记中设置了ng-controller
属性。控制器的定义如下:
function HeaderController($scope) {
$scope.title = "Hello world";
}
每个控制器都有自己的影响区域。这个区域被称为作用域。在我们的例子中,HeaderController
定义了{{title}}
变量。AngularJS 有一个出色的依赖注入系统。幸运的是,由于这个机制,$scope
参数会自动初始化并传递给我们的函数。ng-controller
属性被称为指令,即一个对 AngularJS 有意义的属性。我们可以使用很多指令。这可能是框架的强项之一。我们可以在模板中直接实现复杂逻辑,例如数据绑定、过滤或模块化。
数据绑定
数据绑定是一个在模型更改后自动更新视图的过程。正如我们之前提到的,我们可以在应用程序的 JavaScript 部分更改一个变量,HTML 部分将自动更新。我们不需要创建 DOM 元素的引用或附加事件监听器。一切由框架处理。让我们继续并详细说明之前的例子,如下:
<header ng-controller="HeaderController">
<h1>{{title}}</h1>
<a href="#" ng-click="updateTitle()">change title</a>
</header>
添加了一个链接,并包含ng-click
指令。updateTitle
函数是在控制器中定义的函数,如下代码片段所示:
function HeaderController($scope) {
$scope.title = "Hello world";
$scope.updateTitle = function() {
$scope.title = "That's a new title.";
}
}
我们不关心 DOM 元素和{{title}}
变量在哪里。我们只需更改$scope
的一个属性,一切就会正常工作。当然,当然会有这样的情况,我们会遇到<input>
字段,并希望绑定它们的值。如果是这种情况,那么可以使用ng-model
指令。我们可以如下看到:
<header ng-controller="HeaderController">
<h1>{{title}}</h1>
<a href="#" ng-click="updateTitle()">change title</a>
<input type="text" ng-model="title" />
</header>
输入字段中的数据绑定到相同的title
变量。这次,我们不需要编辑控制器。AngularJS 自动更改h1
标签的内容。
使用模块封装逻辑
有控制器真是太好了。然而,将所有内容都放在全局定义的函数中并不是一个好的实践。这就是为什么使用模块系统是好的。以下代码展示了如何定义一个模块:
angular.module('HeaderModule', []);
第一个参数是模块的名称,第二个参数是一个包含模块依赖项的数组。依赖项指的是其他模块、服务或我们可以在模块内部使用的自定义项。它也应该设置为ng-app
指令的值。到目前为止的代码可以转换为以下代码片段:
angular.module('HeaderModule', [])
.controller('HeaderController', function($scope) {
$scope.title = "Hello world";
$scope.updateTitle = function() {
$scope.title = "That's a new title.";
}
});
因此,第一行定义了一个模块。我们可以链式调用模块的不同方法,其中之一就是controller
方法。按照这种方法,即把我们的代码放在模块内部,我们将封装逻辑。这是一个良好架构的标志。当然,使用模块,我们可以访问不同的功能,例如过滤器、自定义指令和自定义服务。
使用过滤器准备数据
当我们想要在向用户展示之前准备数据时,过滤器非常有用。比如说,如果我们需要提到标题,一旦它的长度超过 20 个字符,就将其转换为大写:
angular.module('HeaderModule', [])
.filter('customuppercase', function() {
return function(input) {
if(input.length > 20) {
return input.toUpperCase();
} else {
return input;
}
};
})
.controller('HeaderController', function($scope) {
$scope.title = "Hello world";
$scope.updateTitle = function() {
$scope.title = "That's a new title.";
}
});
这就是自定义过滤器customuppercase
的定义。它接收输入并执行简单的检查。它返回的,就是用户最终看到的内容。以下是如何在 HTML 中使用这个过滤器的示例:
<h1>{{title | customuppercase}}</h1>
当然,我们可以为每个变量添加多个过滤器。有一些预定义的过滤器可以限制长度,例如 JavaScript 到 JSON 的转换或日期格式化。
依赖注入
依赖管理有时可能非常困难。我们可能需要将一切分成不同的模块/组件。它们有很好的 API 编写,并且有很好的文档。然而,很快我们可能会意识到我们需要创建很多对象。依赖注入通过提供我们需要的,即时解决问题。我们已经在实际操作中看到了这一点。传递给我们的控制器的$scope
参数实际上是由 AngularJS 的injector
创建的。要获取某个依赖项,我们需要在某个地方定义它,并让框架知道它。我们这样做如下:
angular.module('HeaderModule', [])
.factory("Data", function() {
return {
getTitle: function() {
return "A better title.";
}
}
})
.controller('HeaderController', function($scope, Data) {
$scope.title = Data.getTitle();
$scope.updateTitle = function() {
$scope.title = "That's a new title.";
}
});
Module
类有一个名为factory
的方法。它注册了一个新的服务,这个服务可以稍后作为依赖项使用。该函数返回一个只有一个方法的对象,即getTitle
。当然,服务的名称应该与控制器参数的名称匹配。否则,AngularJS 将无法找到依赖项的来源。
在 AngularJS 上下文中的模型
在众所周知的模型-视图-控制器(Model-View-Controller)模式中,模型是存储应用程序数据的部分。AngularJS 没有定义模型的特定工作流程。$scope
变量可以被视为一个模型。我们将在当前作用域附加的属性中保存数据。稍后,我们可以使用ng-model
指令并将属性绑定到 DOM 元素。我们已经在前面的章节中看到了它是如何工作的。框架可能不会提供模型的传统形式,但它是这样设计的,以便我们可以编写自己的实现。AngularJS 与纯 JavaScript 对象一起工作的事实,使得这项任务变得容易实现。
关于 AngularJS 的结语
AngularJS 是领先的框架之一,不仅因为它是由谷歌制作的,而且因为它非常灵活。我们可以只使用它的一小部分,或者使用其庞大的功能集合构建一个坚实的架构。
选择和初始化数据库
要构建一个博客应用程序,我们需要一个数据库来存储已发布的文章。在大多数情况下,数据库的选择取决于当前项目。有一些因素,如性能和可扩展性,我们应该记住。为了更好地查看可能的解决方案,我们将查看两个最受欢迎的数据库:MongoDB和MySQL。第一个是一个 NoSQL 类型的数据库。根据维基百科上关于 NoSQL 数据库的条目(en.wikipedia.org/wiki/NoSQL
):
"NoSQL 或非仅 SQL 数据库提供了一种以不同于关系数据库中使用的表格关系的方式对数据进行存储和检索的机制。"
换句话说,它比 SQL 数据库更简单,并且通常以键值类型存储信息。通常,此类解决方案用于处理和存储大量数据。当需要灵活的模式或想要使用 JSON 时,这也是一个非常流行的方法。它实际上取决于我们正在构建的系统类型。在某些情况下,MySQL 可能是一个更好的选择,而在其他情况下,MongoDB。在我们的示例博客中,我们将使用两者。
为了做到这一点,我们需要一个连接到数据库服务器并接受查询的层。为了使事情更有趣,我们将创建一个只有一个 API 的模块,但可以在两种数据库模型之间切换。
使用 MongoDB 的 NoSQL
让我们从 MongoDB 开始。在我们开始存储信息之前,我们需要一个运行的 MongoDB 服务器。可以从数据库的官方网站下载www.mongodb.org/downloads
。
我们不会手动处理与数据库的通信。有一个专门为 Node.js 开发的驱动程序。它被称为mongodb
,我们应该将其包含在我们的package.json
文件中。通过npm install
成功安装后,驱动程序将可用于我们的脚本。我们可以如下检查:
"dependencies": {
"mongodb": "1.3.20"
}
我们将坚持使用 Model-View-Controller 架构,并在名为Articles
的模型中进行数据库相关操作。我们可以如下查看:
var crypto = require("crypto"),
type = "mongodb",
client = require('mongodb').MongoClient,
mongodb_host = "127.0.0.1",
mongodb_port = "27017",
collection;
module.exports = function() {
if(type == "mongodb") {
return {
add: function(data, callback) { ... },
update: function(data, callback) { ... },
get: function(callback) { ... },
remove: function(id, callback) { ... }
}
} else {
return {
add: function(data, callback) { ... },
update: function(data, callback) { ... },
get: function(callback) { ... },
remove: function(id, callback) { ... }
}
}
}
它从定义一些 MongoDB 连接的依赖项和设置开始。第一行需要crypto
模块。我们将使用它为每篇文章生成唯一的 ID。type
变量定义了当前访问的是哪个数据库。第三行初始化 MongoDB 驱动程序。我们将使用它与数据库服务器进行通信。之后,我们设置连接的主机和端口,最后是一个全局的collection
变量,它将保持对文章集合的引用。在 MongoDB 中,集合类似于 MySQL 中的表。下一个逻辑步骤是建立数据库连接并执行所需的操作,如下所示:
connection = 'mongodb://';
connection += mongodb_host + ':' + mongodb_port;
connection += '/blog-application';
client.connect(connection, function(err, database) {
if(err) {
throw new Error("Can't connect");
} else {
console.log("Connection to MongoDB server successful.");
collection = database.collection('articles');
}
});
我们传递主机和端口,驱动程序将完成其他所有工作。当然,处理错误(如果有)并抛出异常是一个好习惯。在我们的情况下,这尤其必要,因为没有数据库中的信息,前端就没有东西可以显示。该模块的其余部分包含添加、编辑、检索和删除记录的方法:
return {
add: function(data, callback) {
var date = new Date();
data.id = crypto.randomBytes(20).toString('hex');
data.date = date.getFullYear() + "-" + date.getMonth() + "-" + date.getDate();
collection.insert(data, {}, callback || function() {});
},
update: function(data, callback) {
collection.update(
{ID: data.id},
data,
{},
callback || function(){ }
);
},
get: function(callback) {
collection.find({}).toArray(callback);
},
remove: function(id, callback) {
collection.findAndModify(
{ID: id},
[],
{},
{remove: true},
callback
);
}
}
add
和update
方法接受data
参数。这是一个简单的 JavaScript 对象。例如,请看以下代码:
{
title: "Blog post title",
text: "Article's text here ..."
}
记录通过自动生成的唯一id
进行标识。update
方法需要它来找出要编辑哪个记录。所有方法都有回调。这很重要,因为这个模块旨在作为一个黑盒使用,也就是说,我们应该能够创建其实例,操作数据,并在最后继续应用程序的其他逻辑。
使用 MySQL
我们将使用 MySQL 类型的数据库。我们将在已经工作的Articles.js
模型中添加几行代码。想法是有一个类支持两个数据库,就像两个不同的选项。最后,我们应该能够通过简单地更改变量的值从其中一个切换到另一个。类似于 MongoDB,我们需要首先安装数据库才能使用它。官方下载页面是www.mysql.com/downloads
。
MySQL 需要另一个 Node.js 模块。它应该再次添加到package.json
文件中。我们可以如下查看该模块:
"dependencies": {
"mongodb": "1.3.20",
"mysql": "2.0.0"
}
类似于 MongoDB 解决方案,我们首先需要连接到服务器。为此,我们需要知道主机、用户名和密码字段的值。并且因为数据是有组织地存储在数据库中的,所以需要一个数据库名称。在 MySQL 中,我们将数据放入不同的数据库中。因此,以下代码定义了所需的变量:
var mysql = require('mysql'),
mysql_host = "127.0.0.1",
mysql_user = "root",
mysql_password = "",
mysql_database = "blog_application",
connection;
之前的例子中密码字段留空,但我们应该设置我们系统的正确值。MySQL 数据库要求我们在开始保存数据之前定义一个表及其字段。因此,以下代码是本章中使用的表的简短转储:
CREATE TABLE IF NOT EXISTS `articles` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`title` longtext NOT NULL,
`text` longtext NOT NULL,
`date` varchar(100) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 ;
一旦我们设置了数据库及其表,我们就可以继续进行数据库连接,如下所示:
connection = mysql.createConnection({
host: mysql_host,
user: mysql_user,
password: mysql_password
});
connection.connect(function(err) {
if(err) {
throw new Error("Can't connect to MySQL.");
} else {
connection.query("USE " + mysql_database, function(err, rows, fields) {
if(err) {
throw new Error("Missing database.");
} else {
console.log("Successfully selected database.");
}
})
}
});
驱动程序提供了一个连接到服务器并执行查询的方法。第一个执行的查询选择数据库。如果一切正常,你应该在你的控制台中看到成功选择数据库的输出。一半的工作已经完成。我们现在需要做的是复制第一个 MongoDB 实现中返回的方法。我们需要这样做,因为当我们切换到 MySQL 使用时,使用该类的代码将不会工作。而我们所说的复制是指它们应该有相同的名称,并且应该接受相同的参数。
如果我们一切都做得正确,最终我们的应用程序将支持两种类型的数据库。我们只需要更改type
变量的值:
return {
add: function(data, callback) {
var date = new Date();
var query = "";
query += "INSERT INTO articles (title, text, date) VALUES (";
query += connection.escape(data.title) + ", ";
query += connection.escape(data.text) + ", ";
query += "'" + date.getFullYear() + "-" + date.getMonth() + "-" + date.getDate() + "'";
query += ")";
connection.query(query, callback);
},
update: function(data, callback) {
var query = "UPDATE articles SET ";
query += "title=" + connection.escape(data.title) + ", ";
query += "text=" + connection.escape(data.text) + " ";
query += "WHERE id='" + data.id + "'";
connection.query(query, callback);
},
get: function(callback) {
var query = "SELECT * FROM articles ORDER BY id DESC";
connection.query(query, function(err, rows, fields) {
if(err) {
throw new Error("Error getting.");
} else {
callback(rows);
}
});
},
remove: function(id, callback) {
var query = "DELETE FROM articles WHERE id='" + id + "'";
connection.query(query, callback);
}
}
代码比第一个 MongoDB 变体生成的代码要长一些。这是因为我们需要从传递的数据中构建 MySQL 查询。请记住,我们必须转义传递给模块的信息。这就是为什么我们使用connection.escape()
。有了这些代码行,我们的模型就完成了。现在我们可以添加、编辑、删除或获取数据。让我们继续展示文章的部分给用户。
使用 Angular 开发客户端
假设数据库中已有一些数据,我们准备将其展示给用户。到目前为止,我们只开发了模型,即负责信息访问的类。在本书的上一章中,我们学习了 Express。为了简化过程,我们在这里再次使用它。我们需要首先更新package.json
文件,并将其包含在框架中,如下所示:
"dependencies": {
"express": "3.4.6",
"jade": "0.35.0",
"mongodb": "1.3.20",
"mysql": "2.0.0"
}
我们还添加了Jade,因为我们打算将其用作模板语言。现在使用纯 HTML 编写标记并不是很高效。通过使用模板引擎,我们可以将数据和 HTML 标记分开,这使得我们的应用程序结构更加清晰。Jade 的语法与 HTML 有些类似。我们可以编写不需要关闭的标签:
body
p(class="paragraph", data-id="12").
Sample text here
footer
a(href="#").
my site
上述代码片段被转换成以下代码片段:
<body>
<p data-id="12" class="paragraph">Sample text here</p>
<footer><a href="#">my site</a></footer>
</body>
Jade 依赖于内容中的缩进来区分标签。
让我们从以下截图所示的项目结构开始:
我们将已经编写的类Articles.js
放在了models
目录中。公共目录将包含 CSS 样式,以及所有必要的客户端 JavaScript:AngularJS 库、AngularJS 路由模块和我们的自定义代码。
我们将跳过一些关于以下代码的解释,因为我们已经在上一章中介绍过了。我们的index.js
文件看起来如下所示:
var express = require('express');
var app = express();
var articles = require("./models/Articles")();
app.set('views', __dirname + '/views');
app.set('view engine', 'jade');
app.use(express.static(__dirname + '/public'));
app.use(function(req, res, next) {
req.articles = articles;
next();
});
app.get('/api/get', require("./controllers/api/get"));
app.get('/', require("./controllers/index"));
app.listen(3000);
console.log('Listening on port 3000');
在开始时,我们引入了 Express 框架和我们的模型。也许在控制器内部初始化模型会更好,但在这个例子中并不必要。紧接着,我们为 Express 设置了一些基本选项并定义了我们自己的中间件。它只有一个任务要做,那就是将模型附加到请求对象上。我们这样做是因为请求对象会被传递给所有的路由处理器。在我们的例子中,这些处理器实际上是控制器。所以,Articles.js
通过 req.articles
属性在所有地方都可以访问。在脚本末尾,我们放置了两个路由。第二个路由捕获来自用户的常规请求。第一个路由 /api/get
则更有趣。我们想在 AngularJS 之上构建我们的前端。因此,存储在数据库中的数据不应进入 Node.js 部分,而应在客户端使用 Google 的框架。为了实现这一点,我们将创建用于获取、添加、编辑和删除记录的路由/控制器。所有这些都将由 AngularJS 执行的 HTTP 请求来控制。换句话说,我们需要一个 API。
在我们开始使用 Angular 之前,让我们看看 /controllers/api/get.js
控制器:
module.exports = function(req, res, next) {
req.articles.get(function(rows) {
res.send(rows);
});
}
主要工作由我们的模型完成,响应由 Express 处理。这很好,因为我们传递了一个 JavaScript 对象(实际上 rows
是一个对象数组),框架会自动设置响应头。为了测试结果,我们可以用 node index.js
运行应用程序,并打开 http://localhost:3000/api/get
。如果没有记录在数据库中,我们将得到一个空数组。如果有,存储的文章将被返回。所以,这就是我们应该从 AngularJS 控制器内部击中的 URL,以获取信息。
/controller/index.js
控制器的代码也只有几行。我们可以看到以下代码:
module.exports = function(req, res, next) {
res.render("list", { app: "" });
}
它简单地渲染了存储在 list.jade
文件中的列表视图。该文件应保存在 /views
目录中。但在我们查看其代码之前,我们将检查另一个文件,该文件作为所有页面的基础。Jade 有一个叫做块的不错特性。我们可以定义不同的部分并将它们组合成一个模板。以下是我们的 layout.jade
文件:
doctype html
html(ng-app="#{app}")
head
title Blog
link(rel='stylesheet', href='/style.css')
script(src='/angular.min.js')
script(src='/angular-route.min.js')
body
block content
这个模板只传递了一个变量,即 #{app}
。我们稍后会需要它来初始化管理模块。angular.min.js
和 angular-route.min.js
文件应从官方 AngularJS 网站下载,并放置在 /public
目录中。页面主体包含一个名为 content
的块占位符,稍后我们将用文章列表填充它。以下是 list.jade
文件:
extends layout
block content
.container(ng-controller="BlogCtrl")
section.articles
article(ng-repeat="article in articles")
h2
{{article.title}}
br
small published on {{article.date}}
p {{article.text}}
script(src='/blog.js')
开头两行将两个模板合并到一个页面中。Express 框架将 Jade 模板转换为 HTML,并服务于用户的浏览器。从那里,客户端 JavaScript 接管控制。我们使用ng-controller
指令表示div
元素将由名为BlogCtrl
的 AngularJS 控制器控制。相同的类应该有一个变量articles
,其中包含来自数据库的信息.
ng-repeat
遍历数组并向用户显示内容.
blog.js
类包含控制器的代码:
function BlogCtrl($scope, $http) {
$scope.articles = [
{ title: "", text: "Loading ..."}
];
$http({method: 'GET', url: '/api/get'})
.success(function(data, status, headers, config) {
$scope.articles = data;
})
.error(function(data, status, headers, config) {
console.error("Error getting articles.");
});
}
控制器有两个依赖项。第一个依赖项,
$scope,
指向当前视图。我们在那里分配的任何属性都作为变量在我们的 HTML 标记中可用。最初,我们只添加了一个元素,它没有标题,但有文本。这显示出来是为了表明我们仍在从数据库中加载文章。第二个依赖项,$http
,提供了一个 API,用于执行 HTTP 请求。因此,我们只需查询/api/get
,获取数据,并将其传递给$scope
依赖项。其余的工作由 AngularJS 及其神奇的双向数据绑定来完成。为了使应用程序更有趣,我们将添加一个搜索字段,如下所示:
// views/list.jade
header
.search
input(type="text", placeholder="type a filter here", ng-model="filterText")
h1 Blog
hr
ng-model
指令将输入字段的值绑定到我们$scope
依赖项内的一个变量。然而,这次我们不需要编辑我们的控制器,只需将相同的变量作为过滤器应用到ng-repeat
:
article(ng-repeat="article in articles | filter:filterText")
因此,显示的文章将根据用户的输入进行筛选。这两个简单的添加,但页面上确实有一些真正有价值的东西。AngularJS 的过滤器可以非常强大。
实现控制面板
控制面板是我们管理博客文章的地方。在继续处理用户界面之前,在后台需要做一些事情。具体如下:
app.set("username", "admin");
app.set("password", "pass");
app.use(express.cookieParser('blog-application'));
app.use(express.session());
应将前面的代码行添加到/index.js
中。我们的管理应该受到保护,所以前两行定义了我们的凭证。我们使用 Express 作为数据存储,简单地创建键值对。稍后,如果我们需要用户名,我们可以使用app.get("username")
来获取它。接下来的两行启用了会话支持。我们需要它是因为登录过程。
我们添加了一个中间件,它将文章附加到request
对象上。我们将对当前用户的身份状态做同样处理,如下所示:
app.use(function(req, res, next) {
if((
req.session &&
req.session.admin === true
) || (
req.body &&
req.body.username === app.get("username") &&
req.body.password === app.get("password")
)) {
req.logged = true;
req.session.admin = true;
};
next();
});
我们的if
语句有点长,但它告诉我们用户是否已登录。第一部分检查是否创建了会话,第二部分检查用户是否提交了包含正确用户名和密码的表单。如果这些表达式为true
,则我们将变量logged
附加到request
对象上,并创建一个在后续请求中有效的会话。
在主应用程序的文件中,我们只需要一个东西。一些将处理控制面板操作的路由。在以下代码中,我们定义了它们以及所需的路由处理程序:
var protect = function(req, res, next) {
if(req.logged) {
next();
} else {
res.send(401, 'No Access.');
}
}
app.post('/api/add', protect, require("./controllers/api/add"));
app.post('/api/edit', protect, require("./controllers/api/edit"));
app.post('/api/delete', protect , require("./controllers/api/delete"));
app.all('/admin', require("./controllers/admin"));
以/api
开头的三个路由将使用Articles.js
模型来添加、编辑和从数据库中删除文章。这些操作应该是受保护的。我们将添加一个中间件函数来处理这个问题。如果req.logged
变量不可用,它将简单地响应一个401 - Unauthorized
状态码。最后一个路由/admin
有一点不同,因为它显示了一个登录表单。以下是为创建新文章创建的控制器:
module.exports = function(req, res, next) {
req.articles.add(req.body, function() {
res.send({success: true});
});
}
我们将大部分逻辑转移到前端,所以这里也只有几行。这里有趣的是,我们直接将req.body
传递给模型。它实际上包含了用户提交的数据。以下代码展示了req.articles.add
方法在 MongoDB 实现中的样子:
add: function(data, callback) {
data.ID = crypto.randomBytes(20).toString('hex');
collection.insert(data, {}, callback || function() {});
}
MySQL 实现如下:
add: function(data, callback) {
var date = new Date();
var query = "";
query += "INSERT INTO articles (title, text, date) VALUES (";
query += connection.escape(data.title) + ", ";
query += connection.escape(data.text) + ", ";
query += "'" + date.getFullYear() + "-" + date.getMonth() + "-" + date.getDate() + "'";
query += ")";
connection.query(query, callback);
}
在这两种情况下,我们都需要在传递的数据对象中包含title
和text
。幸运的是,由于 Express 的bodyParser
中间件,这就是我们在req.body
对象中拥有的。我们可以直接将其转发到模型。其他路由处理程序几乎相同:
// api/edit.js
module.exports = function(req, res, next) {
req.articles.update(req.body, function() {
res.send({success: true});
});
}
我们改变的是Articles.js
类的处理方法。它不是add
而是update
。同样的技术也应用于路由中删除文章的操作。我们可以这样看到:
// api/delete.js
module.exports = function(req, res, next) {
req.articles.remove(req.body.id, function() {
res.send({success: true});
});
}
我们需要删除的不是请求的整个主体,而是记录的唯一 ID。每个 API 方法都会发送{success: true}
作为响应。当我们处理 API 请求时,我们应该始终返回一个响应。即使出了问题。
在 Node.js 部分,我们最后要讨论的是负责管理面板用户界面的控制器,即.controllers/admin.js
文件:
module.exports = function(req, res, next) {
if(req.logged) {
res.render("admin", { app: "admin" });
} else {
res.render("login", { app: "" });
}
}
有两个模板被渲染:/views/admin.jade
和/views/login.jade
。根据我们在/index.js
中设置的变量,脚本决定显示哪一个。如果用户未登录,则将登录表单发送到浏览器,如下所示:
extends layout
block content
.container
header
h1 Administration
hr
section.articles
article
form(method="post", action="/admin")
span Username:
br
input(type="text", name="username")
br
span Password:
br
input(type="password", name="password")
br
br
input(type="submit", value="login")
这里没有 AngularJS 代码。我们只有那个古老的 HTML 表单,它通过 POST 将数据提交到相同的 URL——/admin
。如果用户名和密码正确,.logged
变量将被设置为true
,控制器将渲染其他模板:
extends layout
block content
.container
header
h1 Administration
hr
a(href="/") Public
span |
a(href="#/") List
span |
a(href="#/add") Add
section(ng-view)
script(src='/admin.js')
控制面板需要几个视图来处理所有操作。AngularJS 有一个出色的路由模块,它与标签型 URL 一起工作,即类似于/admin#/add
的 URL。同一个模块需要一个占位符来处理不同的部分。在我们的案例中,这是一个section
标签。ng-view
属性告诉框架这是一个为该逻辑准备的元素。在模板的末尾,我们添加了一个外部文件,它包含了控制面板所需的全部客户端 JavaScript 代码。
虽然应用程序的客户端部分只需要加载文章,但控制面板需要更多的功能。使用 AngularJS 的模块化系统是很好的。我们需要路由和视图发生变化,因此需要ngRoute
模块作为依赖项。此模块未添加到主angular.min.js
构建中。它放在angular-route.min.js
文件中。以下代码显示了我们的模块是如何开始的:
var admin = angular.module('admin', ['ngRoute']);
admin.config(['$routeProvider',
function($routeProvider) {
$routeProvider
.when('/', {})
.when('/add', {})
.when('/edit/:id', {})
.when('/delete/:id', {})
.otherwise({
redirectTo: '/'
});
}
]);
我们通过将 URL 映射到特定路由来配置路由器。目前,路由只是空对象,但我们将很快解决这个问题。每个控制器都需要向应用程序的 Node.js 部分发出 HTTP 请求。如果我们有一个这样的服务并在整个代码中使用它,那将很好。以下是一个示例:
admin.factory('API', function($http) {
var request = function(method, url) {
return function(callback, data) {
$http({method: method, url: url, data: data})
.success(callback)
.error(function(data, status, headers, config) {
console.error("Error requesting '" + url + "'.");
});
}
}
return {
get: request('GET', '/api/get'),
add: request('POST', '/api/add'),
edit: request('POST', '/api/edit'),
remove: request('POST', '/api/delete')
}
});
AngularJS 最好的事情之一是它与纯 JavaScript 对象一起工作。没有不必要的抽象,也没有扩展或继承特殊类。我们正在使用.factory
方法创建一个简单的 JavaScript 对象。它有四个可以调用的方法:get
、add
、edit
和remove
。每个方法都调用在辅助方法request
中定义的函数。该服务只有一个依赖项,即$http
。我们已经知道这个模块;它很好地处理 HTTP 请求。我们将查询的 URL 与我们在 Node.js 部分定义的相同。
现在,让我们创建一个控制器,该控制器将显示数据库中当前存储的文章。首先,我们应该用以下对象替换空的路由对象.when('/', {})
:
.when('/', {
controller: 'ListCtrl',
template: '\
<article ng-repeat="article in articles">\
<hr />\
<strong>{{article.title}}</strong><br />\
(<a href="#/edit/{{article.id}}">edit</a>)\
(<a href="#/delete/{{article.id}}">remove</a>)\
</article>\
'
})
对象必须包含一个控制器和一个模板。模板不过是几行 HTML 标记。它看起来有点像客户端用来显示文章的模板。区别在于用于编辑和删除的链接。JavaScript 不允许在字符串定义中使用换行符。行尾的反斜杠可以防止语法错误,这些错误最终会被浏览器抛出。以下是为控制器编写的代码。它再次在模块中定义:
admin.controller('ListCtrl', function($scope, API) {
API.get(function(articles) {
$scope.articles = articles;
});
});
这里是 AngularJS 依赖注入的美丽之处。我们自定义的服务API
会自动初始化并传递给控制器。.get
方法从数据库中获取文章。稍后,我们将信息发送到当前的$scope
依赖项,双向数据绑定完成剩余的工作。文章显示在页面上。
使用 AngularJS 的工作非常简单,以至于我们可以将控制器组合在一起,在同一个地方添加和编辑。让我们将路由对象存储在一个外部变量中,如下所示:
var AddEditRoute = {
controller: 'AddEditCtrl',
template: '\
<hr />\
<article>\
<form>\
<span>Title</spna><br />\
<input type="text" ng-model="article.title"/><br />\
<span>Text</spna><br />\
<textarea rows="7" ng-model="article.text"></textarea>\
<br /><br />\
<button ng-click="save()">save</button>\
</form>\
</article>\
'
};
然后,将其分配给两个路由,如下所示:
.when('/add', AddEditRoute)
.when('/edit/:id', AddEditRoute)
模板只是一个带有必要字段和按钮的表单,该按钮调用控制器中的save
方法。注意,我们将输入字段和文本区域绑定到了$scope
依赖项内部的变量上。这很有用,因为我们不需要访问 DOM 来获取值。我们可以这样看到:
admin.controller(
'AddEditCtrl',
function($scope, API, $location, $routeParams) {
var editMode = $routeParams.id ? true : false;
if(editMode) {
API.get(function(articles) {
articles.forEach(function(article) {
if(article.id == $routeParams.id) {
$scope.article = article;
}
});
});
}
$scope.save = function() {
APIeditMode ? 'edit' : 'add' {
$location.path('/');
}, $scope.article);
}
})
控制器接收四个依赖项。我们已经了解了$scope
和API
。当我们要更改当前路由,或者说,将用户重定向到另一个视图时,使用$location
依赖项。$routeParams
依赖项用于从 URL 中获取参数。在我们的例子中,/edit/:id
是一个包含变量的路由。在代码内部,id
在$routeParams.id
中可用。文章的添加和编辑使用相同的表单。因此,通过简单的检查,我们知道用户当前正在做什么。如果用户处于编辑模式,那么我们将根据提供的id
获取文章并填写表单。否则,字段为空,将创建新的记录。
删除文章可以通过类似的方法完成,即添加一个路由对象并定义一个新的控制器。我们可以这样看待删除操作:
.when('/delete/:id', {
controller: 'RemoveCtrl',
template: ' '
})
在这种情况下,我们不需要模板。一旦文章从数据库中删除,我们将用户重定向到列表页面。我们必须调用 API 的remove
方法。以下是RemoveCtrl
控制器的外观:
admin.controller(
'RemoveCtrl',
function($scope, $location, $routeParams, API) {
API.remove(function() {
$location.path('/');
}, $routeParams);
}
);
之前的代码描述了与上一个控制器相同的依赖项。这次,我们只是简单地将$routeParams
依赖项转发给 API。因为它是一个纯 JavaScript 对象,所以一切按预期工作。
摘要
在本章中,我们通过使用 Node.js 编写应用程序的后端,构建了一个简单的博客。我们编写的数据库通信模块可以与 MongoDB 或 MySQL 数据库协同工作,并存储文章。博客的客户端部分和控制面板是用 AngularJS 开发的。然后,我们使用内置的 HTTP 和路由机制定义了一个自定义服务。
Node.js 与 AngularJS 配合得很好,主要是因为两者都是用 JavaScript 编写的。我们发现 AngularJS 是为了支持开发者而构建的。它消除了所有那些无聊的任务,例如 DOM 元素引用、附加事件监听器等。它是现代客户端编码堆栈的一个很好的选择。
在下一章中,我们将看到如何使用 Socket.IO 编程实时聊天,它是涵盖 WebSockets 通信的流行解决方案之一。
第四章:使用 Socket.IO 开发聊天功能
正如我们在上一章中学到的,Node.js 与前端框架如 AngularJS 协作得非常好。我们可以从浏览器传输数据到 Node.js,反之亦然,这真是太棒了。如果我们可以实时做到这一点,那就更好了。如今,实时通信几乎被集成到每个 Web 产品中。它为用户提供了良好的体验,并为应用的所有者带来了很多好处。通常,当我们谈论实时 Web 组件时,我们指的是WebSockets。WebSocket 是一种协议,允许我们在浏览器和服务器之间建立双向(双向)对话。这开辟了一个全新的世界,并赋予我们实施快速和健壮应用的能力。Node.js支持 WebSockets,我们将看到如何使用 WebSockets 构建实时聊天。该应用将使用 Socket.IO。这是一个建立在 WebSockets 之上的库,提供机制来覆盖如果它们不可用时的相同功能。我们将有一个输入字段,并且每个打开页面的用户都将能够向所有其他可用的用户发送消息。
在本章中,我们将学习如何设置 Socket.IO,以及如何在浏览器中使用它并启动一个 Node.js 服务器,使实时聊天成为可能。
探索 WebSockets 和 Socket.IO
假设我们想要构建一个聊天功能。我们首先应该做的是开发显示屏幕上消息的部分。在典型场景中,我们希望这些消息能够快速送达,也就是说,在发送后几乎立即送达。然而,如果我们不使用套接字从服务器接收数据,我们需要发起一个 HTTP 请求。此外,服务器应该保留信息,直到我们请求它这样做。想象一下,如果我们有 10 个用户,并且他们中的每一个都开始发送数据,会发生什么。
我们需要维护一个用户会话,以便识别用户的请求。如果我们使用套接字,这些问题很容易解决。一旦套接字打开,我们就有一个长连接通道,可以来回发送消息。这意味着你可以在不请求的情况下开始接收信息。这种架构类似于一个巨大的桥梁网络。桥梁始终开放,如果我们需要去某个地方,我们可以自由地去。网络中心有一个中心节点,将每一侧连接起来。在 Web 的背景下,中心节点是我们的服务器。每次我们需要接触到网络上的某些用户时,我们只需通过套接字发送一条消息。服务器接收它并将其转发给正确的人。这是实现实时通信最有效的方法之一。它节省了时间和资源。
就像大多数酷技术一样,我们不需要从头开始编写底层的东西,例如握手请求等。有两种类型的开发者:那些非常努力工作并将复杂事物抽象成更简单的 API 和工具的开发者,以及那些知道如何使用它们的人。第二组的开发者可以利用像 Socket.IO 这样的库。本章将广泛讨论 Socket.IO 模块。它作为 WebSocket 之上的抽象,并在很大程度上简化了过程。
在我们继续之前,Socket.IO 实际上不仅仅是 WebSockets 之上的一个层。在实践中,它做了更多的事情,如网站 socket.io/
上所述:
"Socket.IO 旨在使实时应用在每一个浏览器和移动设备上成为可能,模糊了不同传输机制之间的差异。它是无压力的实时 100% JavaScript。"
我们通常会遇到一些常见的协议情况,例如心跳、超时和断开连接支持。所有这些事件都不是 WebSocket API 本地支持的。幸运的是,Socket.IO 正在这里解决这些问题。该库还消除了某些跨浏览器问题,并确保您的应用在所有地方都能工作。
理解基本应用结构
在上一章中,我们使用了 Express 和 Jade 来编写应用资产的交付(HTML、CSS 和 JavaScript)。在这里,我们将坚持使用纯 JavaScript 代码,并避免使用额外的依赖项。我们需要添加到我们的 package.json
文件中的唯一东西是 Socket.IO:
{
"name": "projectname",
"description": "description",
"version": "0.0.1",
"dependencies": {
"socket.io": "latest"
}
}
在我们的项目文件夹中调用 npm install
之后,Socket.IO 被放置在一个新创建的 node_modules
目录中。让我们创建两个新的目录。以下截图显示了应用文件结构应该看起来像什么:
文件结构
应用将读取 styles.css
文件并将内容交付给浏览器。同样的事情也会发生在 /html/page.html
上,这是包含项目 HTML 标记的文件。Node.js 代码在 /index.js
。
运行服务器
在我们开始使用 Socket.IO 之前,让我们首先编写一个简单的 Node.js 服务器代码,该代码响应聊天页面。我们可以看到以下服务器代码:
var http = require('http'),
fs = require('fs'),
port = 3000,
html = fs.readFileSync(__dirname + '/html/page.html', {encoding: 'utf8'}),
css = fs.readFileSync(__dirname + '/css/styles.css', {encoding: 'utf8'});
var app = http.createServer(function (req, res) {
if(req.url === '/styles.css') {
res.writeHead(200, {'Content-Type': 'text/css'});
res.end(css);
} else {
res.writeHead(200, {'Content-Type': 'text/html'});
res.end(html);
}
}).listen(port, '127.0.0.1');
之前的代码应该放在 /index.js
中。脚本从定义几个全局变量开始。http
模块用于创建服务器,而 fs
模块用于从磁盘读取 CSS 和 HTML 文件。html
和 css
变量包含将被发送到浏览器的实际代码。在我们的案例中,这些数据是静态的。这就是为什么我们只读取一次文件,即在脚本运行时。我们通过使用 fs.readFileSync
而不是 fs.readFile
来同步执行此操作。紧接着,我们的服务器被初始化并运行。req.url
变量包含当前请求的文件。根据其值,我们以适当的内容响应它。一旦服务器运行,HTML 和 CSS 代码保持不变。如果我们更改了内容,我们需要停止并重新启动脚本。这是因为我们在启动服务器之前读取了文件的内容。如果没有更改 /css/styles.css
或 /html/page.html
,这可以被认为是一种良好的实践。在服务器的处理程序中插入 fs.readFileSync
操作会使我们的聊天稍微慢一些,因为我们将从磁盘读取数据,每次请求都会这样做。
添加 Socket.IO
实现聊天功能需要在两个地方编写代码:服务器端和客户端。我们将通过扩展之前的代码继续 Node.js 部分,如下所示:
var io = require('socket.io').listen(app);
io.sockets.on('connection', function (socket) {
socket.emit('welcome', { message: 'Welcome!' });
socket.on('send', function (data) {
io.sockets.emit('receive', data);
});
});
http.createServer
方法返回一个新的网络服务器对象。我们必须将此对象传递给 Socket.IO。一旦一切准备就绪,我们就可以访问这个奇妙且简单的 API。我们可以监听传入的事件并向连接到服务器的用户发送消息。io.sockets
属性指向系统中创建的所有套接字,而作为 connection
处理程序参数传递的 socket
对象,仅代表一个单独的用户。
例如,在之前的代码中,我们正在监听 connection
事件,即新用户连接到服务器。当发生这种情况时,服务器向该用户发送一条个人消息,内容为 欢迎!
接下来可能发生的事情是我们从用户那里收到一种新的消息类型,我们的脚本应该将此信息分发到所有可用的套接字。这正是 io.sockets.emit
所做的。请记住,emit
方法可以接收我们自己的自定义事件名称和数据。并不需要严格遵循这里使用的格式。
编写聊天客户端
完成编写服务器端代码后,我们现在可以继续编写前端代码,即编写与聊天服务器通信所需的必要 HTML 和 JavaScript。
准备 HTML 标记
到目前为止的开发工作完成后,我们的聊天功能将如下截图所示:
我们有一个容器,用作接收消息的持有者。有两个输入框。第一个用于用户的名称,第二个接受我们必须发送的消息。每个用户都会为其文本应用一个随机颜色。没有按钮将数据发送到服务器;我们可以通过按 Enter 键来完成。让我们继续阅读保存在 /html/page.html
中的 HTML 标记,如下所示:
<!doctype html>
<html>
<head>
<link rel="stylesheet" type="text/css" href="styles.css">
</head>
<body>
<section>
<div id="chat"></div>
<input type="text" id="name" placeholder="your name" />
<input type="text" id="input" disabled="disabled" />
</section>
<script src="img/socket.io.js"></script>
<script>
window.onload = function() {
var Chat = (function() {
// ...
})();
}
</script>
</body>
</html>
CSS 样式被添加到页面顶部和脚本底部。代表之前代码中提到的控制器的只有三个元素。逻辑的启动被放置在 window.onload
处理器中。我们这样做只是为了确保所有资源都已完全加载。请注意,将接受消息的输入字段默认是禁用的。一旦建立了套接字连接,我们将启用它。还有最后一件事需要明确——/socket.io/socket.io.js
文件所在的位置/来源。它不是从外部来源下载并保存在项目目录中;它是通过 Socket.IO 交付到该位置的。这也是在后台将 web server
对象传递给 Socket.IO 的原因之一。
编写聊天逻辑
HTML 标记本身是无用的。我们开发过程中的下一步将是编写与后端通信的 JavaScript 代码。我们需要捕获用户的输入并将其发送到服务器。屏幕上显示的消息将以不同的颜色呈现。我们将首先定义两个辅助方法,如下所示:
var addEventListener = function(obj, evt, fnc) {
if (obj.addEventListener) { // W3C model
obj.addEventListener(evt, fnc, false);
return true;
} else if (obj.attachEvent) { // Microsoft model
return obj.attachEvent('on' + evt, fnc);
}
}
var getRandomColor = function() {
var letters = '0123456789ABCDEF'.split('');
var color = '#';
for (var i = 0; i < 6; i++ ) {
color += letters[Math.round(Math.random() * 15)];
}
return color;
}
第一个,addEventListener
函数,将为 DOM
元素添加事件监听器。为了让我们的聊天在 Internet Explorer 中工作,我们需要使用 attachEvent
而不是 addEventListener
。第二个,getRandomColor
函数,每次都会提供不同的颜色。我们将使用这个来区分不同用户的消息。
我们客户端逻辑的起点是定义几个变量:
var socket = io.connect('http://localhost:3000'),
chat = document.querySelector("#chat"),
input = document.querySelector("#input"),
name = document.querySelector("#name"),
color = getRandomColor();
我们将使用 socket
变量与服务器通信。接下来的三个变量是之前使用的 DOM
元素的快捷方式。建议创建这样的快捷方式,因为始终使用 document.getElementById
或 document.querySelector
引用元素可能会导致性能问题。
聊天做两件事:向 Node.js 部分发送消息并从那里接收消息。让我们将所有内容封装到两个简单的函数中,如下所示:
var send = function(message) {
var username = name.value === '' ? '' : '<strong>' + name.value + ': </strong>';
socket.emit('send', {
message: '<span style="color:' + color + '">' +
username + message + '</span>'
});
}
var display = function(message) {
chat.innerHTML = chat.innerHTML + message + '<br />';
chat.scrollTop = chat.scrollHeight;
}
在这里,我们通过socket.emit
方法发送消息,并将文本包裹在带颜色的span
元素中。当然,如果用户在name
输入字段中输入了某些内容,我们会使用该值并将其与其他数据一起发送。display
函数相当简单。它只是更改chat
元素的innerHTML
属性。有趣的是第二行。如果我们稍微使用一下聊天功能,我们会注意到div
会很快被填满,而我们实际上看到的是只有第一条消息。通过将scrollTop
属性设置为scrollHeight
,我们确保容器始终向下滚动。
我们小型应用程序的下一步是处理用户的输入。这可以通过以下代码完成:
addEventListener(input, "keydown", function(e) {
if(e.keyCode === 13) {
send(input.value);
input.value = "";
}
});
目前对我们来说唯一有趣的是Enter键。它的键码是 13。如果按下该键,字段中的值就会被发送到服务器。我们正在清空输入字段,以便用户可以输入新的消息。
我们最后应该编写接收消息的代码:
socket.on('welcome', function (data) {
display(data.message);
input.removeAttribute("disabled");
input.focus();
}).on('receive', function(data) {
display(data.message);
});
我们正在监听两种类型的事件。它们是welcome
和receive
。当连接建立时,会发送welcome
事件。receive
事件是一个传入事件,当一些用户发送消息(包括我们自己)时发生。我们可能会问为什么我们需要将我们的消息发送到服务器并在之后接收它。直接将文本放置在容器上不是更容易吗?这个问题的答案是,我们需要数据的一致性,也就是说,我们应该向所有用户提供完全相同的消息,并且顺序完全相同。这只能由应用程序中的一块保证,那就是服务器。
通过这个最后的代码片段,我们已经完成了聊天功能的构建。在本章的最后部分,我们将改进用户间通信。
实现用户间通信
我们的聊天现在可以正常工作,但如果我们可以向特定用户发送消息那就更好了。这样的功能需要在前后端都进行更改。让我们首先更改 Node.js 脚本。
修改服务器端代码
到目前为止,我们的系统中的用户都是匿名的。我们只是将接收到的消息传递给所有可用的套接字。然而,要实现用户间对话,我们需要为每个用户设置唯一的 ID。同时,我们必须保留所有创建的套接字的引用,以便我们可以向它们发送消息。这可以通过以下方式完成:
var crypto = require('crypto');
var users = [];
我们可以利用 Node.js 中默认可用的crypto
模块生成随机唯一的 ID,如下所示:
var id = crypto.randomBytes(20).toString('hex');
我们还应该通知聊天中的人关于可用用户的信息。否则,他们无法选择合适的用户进行聊天。通知如下:
var sendUsers = function() {
io.sockets.emit('users', users.map(function(user) {
return { id: user.id, name: user.username };
}));
}
实际上,用户的名字是与消息一起传递的。它是消息的一部分,后端根本不使用它。然而,在新场景中,我们需要它与 ID 一起。之前的代码将users
数组发送到浏览器,但在那之前,它过滤了它并只传递 ID 和名字。正如我们将在以下代码中看到的,我们为每个元素都有一个socket
属性。以下是更新的connection
处理程序:
io.sockets.on('connection', function (socket) {
var id = crypto.randomBytes(20).toString('hex');
users.push({ socket: socket, id: id, name: null });
socket.emit('welcome', { message: 'Welcome!', id: id });
sendUsers();
socket.on('send', function (data) {
if(data.username !== '') {
setUsername(id, data.username);
}
if(data.toUser !== '') {
users.forEach(function(user) {
if(user.id === data.toUser || user.id === data.fromUser) {
user.socket.emit('receive', data);
}
})
} else {
io.sockets.emit('receive', data);
}
});
});
因此,服务器接收到了一个新的用户连接。我们生成一个新的 ID,并在users
数组中创建一个新的元素。我们保留 socket、ID 和用户的姓名。之后,我们发出古老的welcome
消息,但这次我们发送了 ID。现在,前端可以识别自己进入系统,因为users
变量已更新,我们应该通过sendUsers
函数通知其他人。我们开始监听send
消息,一旦它到来,我们就使用setUsername
方法更新数组中的用户名,如下所示:
var setUsername = function(id, name) {
users.forEach(function(user) {
if(user.id === id) {
user.username = name;
sendUsers();
}
});
}
接下来的几行检查是否存在toUser
属性。如果存在,我们知道它包含了一些其他用户的 ID。因此,我们只需找到用户 ID 并将消息传递给确切的 socket。如果没有toUser
属性,那么数据将通过io.sockets.emit('receive', data)
发送给所有人。与toUser
一起,前端还应发送fromUser
。这是因为通常发送文本的人直到服务器将其发送回来之前,看不到自己的消息在屏幕上。我们将使用fromUser
来实现这一点。
修改聊天的前端
我们必须做的第一件事是在屏幕上显示可用的用户,这样我们就可以选择其中一个与他们聊天。就在输入字段下方,我们将添加一个下拉菜单,如下所示:
<select id="users">
<option value="">all</option>
</select>
我们需要定义几个新的变量。一个指向select
元素的新的快捷方式,当前列表中选中的用户,以及一个将保存当前用户 ID 的变量。这可以通过以下方式完成:
var users = document.querySelector("#users"),
selectedUser = null,
id = null;
send
方法有所改变。我们可以这样看到:
var send = function(message) {
var username = name.value == '' ? '' : '<strong>' + name.value + ': </strong>';
socket.emit('send', {
message: '<span style="color:' + color + '">' + username + message + '</span>',
username: name.value,
toUser: users.value,
fromUser: id
});
}
差别在于我们正在将用户的名字发送到一个单独的属性中,即用户的 ID 和我们想要与之聊天的用户的 ID。如果没有这样的用户,那么值就是一个空字符串。display
方法可以保持不变。我们还需要一个事件监听器来监听下拉菜单的变化。我们将如下添加它:
addEventListener(users, "change", function(e) {
selectedUser = users.value;
});
大部分工作都是在 socket 对象的监听器中完成的:
socket.on('welcome', function (data) {
id = data.id;
display(data.message);
input.removeAttribute("disabled");
input.focus();
}).on('receive', function(data) {
display(data.message);
}).on('users', function(data) {
var html = '<option value="">all</option>';
for(var i=0; i<data.length; i++) {
var user = data[i];
if(id != user.id) {
var username = user.name ? user.name : 'user' + (i+1);
var selected = user.id === selectedUser ? ' selected="selected"': '';
html += '<option value="' + user.id + '"' + selected + '>' + username + '</option>';
}
}
users.innerHTML = html;
});
首先,我们接收到了欢迎
信息。它包含了 ID,因此我们将它存储在我们的局部变量中。我们显示了欢迎信息,启用了输入,并将焦点移至那里。这里没有变化。新的变化在于最后的消息监听器。这是我们将数据填充到下拉菜单的地方。我们编写一个 HTML 字符串,并将其设置为innerHTML
属性的值。这里有两个检查。第一个检查防止当前用户在select
元素中显示。第二个条件会自动从列表中选择一个用户。这实际上非常重要,因为用户的消息可能会发送多次,而菜单应该保持其选择状态。
摘要
在本章中,我们学习了如何使用 Socket.IO 创建实时聊天。它是一个优秀的 Node.js 模块,简化了 WebSocket 的工作。这是一种今天广泛使用的技术,也是未来应用的一部分。
在下一章中,我们将学习如何使用 BackboneJS 创建一个简单的待办事项应用。同样,我们将借助 Node.js 来管理数据。
第五章:使用 Backbone.js 创建待办事项应用程序
在前面的章节中,我们学习了如何使用 Socket.IO 创建实时聊天。我们使用 AngularJS 制作了一个博客应用程序,并使用 Express 创建了一个简单的网站。本章专门介绍另一个流行的框架——Backbone.js。Backbone.js 是最早获得广泛认可的 JavaScript 框架之一。它有处理数据的模型、控制逻辑和用户界面的视图,以及处理浏览器地址变化的自带路由器。该框架与 jQuery 非常兼容,这使得它对几乎每一位 JavaScript 开发者都具有吸引力。在本章中,我们将构建一个简单的应用程序来存储短任务。最后,我们将能够创建、编辑、删除任务,并将它们标记为完成。
在本章中,我们将涵盖以下主题:
-
Backbone.js 的基础知识
-
编写管理待办事项列表的 Node.js 代码
-
使用 Backbone.js 编写前端代码
探索 Backbone.js 框架
在开始示例应用程序之前,我们应该检查框架的主要功能。有时候,了解底层发生了什么是有好处的。所以,让我们深入探讨。
识别框架依赖项
我们现在使用的绝大多数软件都是建立在其他库或工具之上的。通常,它们被称为依赖项。Backbone.js 只有一个硬依赖项——那就是 Underscore.js,这是一个充满实用函数的库。例如,有 forEach
、map
或 union
等数组函数。我们可以扩展一个对象并检索其键或值。所有这些都是在某些时候需要的功能,但它们在内置的 JavaScript 对象中缺失。因此,我们应该在我们的页面上包含这个库。否则,Backbone.js 将会因为缺少功能而抛出错误。
Backbone.js 与 jQuery 非常兼容。它会检查库是否可用,并立即开始使用它。这是一个很好的合作,因为我们可以用各种 jQuery 方法加快我们的工作速度。它不是必须的依赖项,框架在没有它的情况下仍然可以工作,但它简化了 DOM 操作。
扩展功能
框架有几个独立的组件,我们将使用它们。所以,我们的想法是创建新的类,这些类继承基本实现的功能。这些组件有 extend
方法,它接受一个对象——我们的自定义逻辑。最后,我们的属性将覆盖原始代码。以下是我们将创建的新视图类:
var ListView = Backbone.View.extend({
render: function() {
// ...
}
});
var list = new ListView();
没有强制性的模块。我们的应用程序没有严格定义的中心入口点。一切都在我们的掌控之中,这是好事。所有部分都如此解耦,这使得 Backbone.js 很容易使用。
理解 Backbone.js 作为事件驱动框架
通过事件驱动,我们指的是应用程序流程由事件决定,也就是说,框架中的每个类/对象都会派发消息,通知其他组件关于某些动作。换句话说,我们创建的每个对象都可以接受监听器并触发事件。这使得我们的应用程序非常灵活和易于沟通。这种方法鼓励模块化编程,并真正有助于构建坚实的架构。Backbone.Events
模块是一个提供这种功能的模块。以下示例代码解释了如何扩展Backbone.Events
模块:
var object = {};
_.extend(object, Backbone.Events);
object.on("event", function(msg) {
console.log(msg);
});
object.trigger("event", "an event");
Underscore.js 的extend
方法将传递的对象合并成一个。在我们的情况下,我们将生成一个实现了观察者模式的对象。这导致我们得出结论,Backbone.js 产生的每个视图、模型或集合都具有on
和trigger
方法。
使用模型
模型是每个 Backbone.js 项目的核心部分。它的主要功能是保存我们的数据。模型保存、验证并同步数据与服务器。与此相关,模型可以通知外部世界模块内部发生的事件。以下示例代码解释了如何扩展Backbone.Model
模块:
var User = Backbone.Model.extend({
defaults: {
name: '',
password: '',
isAdmin: false
}
});
var user = new User({
name: 'John',
password: '1234'
});
console.log(user.get('name'));
模型中的信息被保存在哈希表中。这里有属性和值。我们拥有set
和get
方法来访问数据。一旦有东西被改变,模型就会触发一个事件。你可能想知道为什么我们需要将数据封装成一个类。一开始,Backbone.Model
看起来像是一个不必要的抽象。然而,很快你就会意识到这个概念真的很强大。首先,我们可以将尽可能多的视图附加到同一个模型上,这里的附加意味着监听一个change
事件。我们可以更新模型并改变用户界面。第二件事是,我们可以将模型连接到服务器端 API,并通过 Ajax 请求立即同步信息。我们将在后面的示例应用程序中这样做。
使用集合
非常常见的情况是我们需要将模型存储在数组中。集合就是为了这种情况而设计的。Backbone.Collection
模块具有add
、remove
和forEach
等方法,用于与存储的项目交互。它还可以从外部源获取多个模型,这就是它主要被用于的地方。当然,集合需要知道模型的数据类型。以下示例代码解释了如何扩展Backbone.Collection
模块:
var User = Backbone.Model.extend({
defaults: {
name: '',
password: '',
isAdmin: false
}
});
var Accounts = Backbone.Collection.extend({
model: User
});
var accounts = new Accounts();
accounts.add({name: 'John'});
accounts.add({name: 'Steve'});
accounts.add({name: 'David'});
accounts.forEach(function(model) {
console.log(model.get('name'));
});
示例显示了相同的User
模型类,但这个类被放置在一个集合中。我们可以轻松地添加新用户并检索他们的名字。类似于Backbone.Model
模块,每个集合都可以通过 HTTP 请求与外部服务器同步我们的数据。
实现视图
Backbone.js 中的视图负责用户界面及其业务逻辑,即与通常的 模型-视图-控制器(MVC)模式相比,在这里,视图和控制器合并在一个地方。再次强调,我们必须扩展一个基类。有趣的是,一个 DOM 元素会自动为我们创建。我们可以控制其类型、类或 ID,并且它始终存在。这非常方便,因为我们可以在幕后动态构建我们的界面,并且只需将其添加到页面一次,从而避免浏览器多次重排和重绘。这可以提高我们应用程序的性能。
Backbone.js 视图存在一种流行的错误实现。我自己在理解了如何正确工作之前犯了很多错误。想法是将视图的 render
方法绑定到模型的变化上。通过这样做,界面将自动更新。保持类短小也很重要。有时,我们可能会得到一个非常长的视图,它控制了我们页面的大部分内容。一种良好的实践是将部分划分为更小的块。这只是为了维护和测试而变得更容易。以下示例代码解释了我们可以如何扩展 Backbone.View
模块:
var LabelView = Backbone.View.extend({
tagName: 'span'
});
var label = new LabelView();
console.log(label.el);
tagName
属性决定了生成的 DOM 元素的类型。只操作创建的元素是一种良好的实践。将其附加到另一个视图或 DOM 树中的某个位置并不是一个好主意。这应该在类外部完成。在需要附加事件监听器,例如 click
时,有一些棘手的部分我们必须注意。然而,框架为这种情况提供了解决方案。我们将在本章后面看到它。
使用路由器
到目前为止,我们学习了关于模型、集合和视图的内容。还有另一件广泛使用的事情,尤其是在我们需要构建像我们这样的单页应用程序时——那就是路由器。这是一个将函数映射到特定 URL 的模块。它支持新的历史 API,因此它可以处理像 /page/action/32
这样的地址。HTML5 历史 API 是一种通过脚本操作浏览器历史的标准化的方式。如果浏览器不支持此 API,则它将使用良好的旧片段版本,即 #page/action/32
。
以下示例代码解释了我们可以如何扩展 Backbone.Router
模块:
var Workspace = Backbone.Router.extend({
routes: {
"help": "help",
"search/:query": "search",
"search/:query/p:page": "search"
},
help: function() {
// ...
},
search: function(query, page) {
// ...
}
});
我们只需要定义我们的路由,模块就会负责其余部分。记住,我们可能使用动态 URL,即包含动态部分的 URL,就像前面代码中的 search
路由一样。
路由器本身与另一个名为 Backbone.history
的模块协作。这是一个监听浏览器触发的 hashchange
事件或 pushState
事件的类。因此,一旦初始化了路由,我们应该运行 Backbone.history.start()
来触发匹配的路由处理程序。我们将在编写应用程序的客户端部分时看到这一点。
与后端通信
正如我们提到的,Backbone.js 提供了与服务器端数据的自动同步。当然,这需要我们这边的一些努力,而且这些努力更像是我们需要在应用程序的后端部分完成的事情。客户端 JavaScript 会发送 CRUD(创建、读取、更新和删除)HTTP 请求,服务器将处理它们。每个模型和集合都应该设置一个 url
属性(或方法),我们将信息发送到这个地址。只有一个 URL,所以不同的操作使用不同的请求方法——GET
、POST
、PUT
和 DELETE
。在我们的例子中,关键的时刻是将 Backbone.js 的对象连接到 Node.js 服务器。一旦完成这个步骤,我们就能直接从浏览器中轻松地管理待办事项列表。
编写应用程序的后端
后端是 Node.js 部分,它将负责数据传输并提供必要的 HTML、CSS 和 JavaScript 功能。为了在每一章中学习新知识,我们将使用不同的方法来完成常见任务。当然,有些事情我们每次都需要做,例如,运行监听特定端口的服务器。JavaScript 是一种非常有趣的语言,在大多数情况下,我们可以用完全不同的方式解决相同的问题。在前面的章节中,我们使用了 Express 向用户发送资源。此外,还有一些例子,我们直接通过文件系统 API 读取文件来完成这项工作。然而,这次,我们将结合两种方法的思想,也就是说,我们将使用的代码将从硬盘读取资源,我们将处理动态路径。
运行 Node.js 服务器
我们将在一个空目录中启动项目。一开始,我们需要一个空的 index.js
文件,它将托管 Node.js 服务器。让我们在 index.js
文件中放入以下内容:
var http = require('http'),
fs = require('fs'),
files = {},
debug = true,
port = 3000;
var respond = function(file, res) {
var contentType;
switch(file.ext) {
case "css": contentType = "text/css"; break;
case "html": contentType = "text/html"; break;
case "js": contentType = "application/javascript"; break;
case "ico": contentType = "image/ico"; break;
default: contentType = "text/plain";
}
res.writeHead(200, {'Content-Type': contentType});
res.end(file.content);
}
var serveAssets = function(req, res) {
var file = req.url === '/' ? 'html/page.html' : req.url;
if(!files[file] || debug) {
try {
files[file] = {
content: fs.readFileSync(__dirname + "/" + file),
ext: file.split(".").pop().toLowerCase()
}
} catch(err) {
res.writeHead(404, {'Content-Type': 'plain/text'});
res.end('Missing resource: ' + file);
return;
}
}
respond(files[file], res);
}
var app = http.createServer(function (req, res) {
serveAssets(req, res);
}).listen(port, '127.0.0.1');
console.log("Listening on 127.0.0.1:" + port);
脚本从定义一些全局变量开始。使用 http
模块运行 Node.js 服务器,使用 fs
访问文件。files
对象充当已请求文件的缓存。从硬盘读取文件可能是一个非常昂贵的操作,所以真的没有必要在每次请求中都这样做。尽可能缓存内容是一个好的实践。当我们在开发应用程序时,debug
变量设置为 true
。这实际上关闭了我们的缓存机制,因为否则,每次我们更改一些 HTML、CSS 或 JavaScript 文件时,都需要重新启动服务器。有一个简短的 respond
方法,它接受以下格式的对象:
{
content: '...',
ext: '...'
}
content
属性是实际文件的文件内容,而 ext
属性表示文件的扩展名。同样的方法也需要 response
对象,以便它能向浏览器发送信息。根据文件的类型,我们设置适当的 Content-Type
头部。这很重要,因为我们如果跳过这一步,浏览器可能无法正确处理资源。接下来,serveAssets
方法获取当前请求的路径,并尝试从系统中读取实际文件。它还会检查文件是否不在缓存中,或者我们是否处于调试模式。如果文件缺失,它将向浏览器发送 404 错误页面。最后几行代码简单地运行服务器,并将 request
和 response
对象传递给 serveAssets
。有了这段代码,我们就能通过匹配实际目录路径的 URL 请求文件。
管理待办列表
我们已经设置了服务器,现在我们可以继续编写业务逻辑,即管理待办列表的任务的逻辑。让我们在文件顶部定义以下两个新变量:
var todos = [],
ids = 0;
todos
数组将保存我们的任务。每个任务都将是一个简单的 JavaScript 对象,如下面的代码所示:
{
id: <number>,
text: <string>,
done: <true | false>
}
每次我们需要添加一个新的待办活动时,我们将增加 ids
变量。因此,数组中的每个对象都将附加一个唯一的 ID。当然,通常我们不会依赖单个数字来识别不同的任务,但 ids
变量将适用于我们的小型实验。以下是将新元素添加到 todos
数组的函数:
var addToDo = function(data) {
data.id = ++ids;
todos.push(data);
return data;
}
我们应该有另外两个方法用于删除和编辑待办列表。它们如下所示:
var deleteToDo = function(id) {
var arr = [];
for(var i=0; i<todos.length; i++) {
if(todos[i].id !== parseInt(id)) {
arr.push(todos[i]);
}
}
todos = arr;
return id;
}
var editToDo = function(id, data) {
for(var i=0; i<todos.length; i++) {
if(todos[i].id === parseInt(id)) {
todos[i].text = data.text;
todos[i].done = data.done;
return todos[i];
}
}
}
deleteToDo
函数遍历元素,跳过与传递的 ID 匹配的元素。editToDo
函数几乎相同,只是它更新存储对象的属性。
我们有管理数据的方法;现在,我们必须编写使用它们的部分。一般来说,我们的服务器有两个角色。第一个是向浏览器提供常规的 HTML、CSS 和 JavaScript 功能。另一个是作为 REST 服务,即接受 CRUD 类型的请求并对其做出响应。Backbone.js 将发送 JSON 对象,并期望以相同的格式接收资源。因此,我们有 respond
函数,以下代码定义了 respondJSON
函数,该函数将数据发送到浏览器:
var respondJSON = function(json, res) {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify(json));
}
我们服务器的入口点是 http.createServer
方法的处理器。这就是我们需要划分应用程序流程的地方,如下面的代码所示:
var app = http.createServer(function (req, res) {
if(req.url.indexOf('/api') === 0) {
serveToDos(req, res);
} else {
serveAssets(req, res);
}
}).listen(port, '127.0.0.1');
我们将检查当前 URL 是否以 /api
开头。如果不是,那么我们提供资源。否则,请求被视为 CRUD 操作,如下面的代码所示:
var serveToDos = function(req, res) {
if(req.url.indexOf('/api/all') === 0) {
respondJSON(todos, res);
} else if(req.url.indexOf('/api/todo') === 0) {
if(req.method == 'POST') {
processPOSTRequest(req, function(data) {
respondJSON(addToDo(data), res);
});
} else if(req.method == 'DELETE') {
deleteToDo(req.url.split("/").pop());
respondJSON(todos, res);
} else if(req.method == 'PUT') {
processPOSTRequest(req, function(data) {
respondJSON(editToDo(req.url.split("/").pop(), data), res);
});
}
} else {
respondJSON({error: 'Missing method'}, res);
}
}
有两个路径控制着一切。/api/all
路径响应包含所有可用待办事项列表的 JSON 代码。下一个/api/todo
路径负责创建、编辑和删除任务。实际使用的地址是http://localhost:3000/api/todo/4
,其中末尾的数字是todos
数组中元素的 ID。这就是为什么我们需要req.url.split("/").pop()
,它从 URL 中提取数字。还有一个额外的函数叫做processPOSTRequest
。它是一个辅助函数,用于获取通过POST
或PUT
方法发送的数据。在 Express 中,相同的功能由bodyParser
中间件提供。processPOSTRequest
函数的代码如下:
var processPOSTRequest = function(req, callback) {
var body = '';
req.on('data', function (data) {
body += data;
});
req.on('end', function () {
callback(JSON.parse(body));
});
}
最后,也许填充todos
数组一些任务是个好主意。添加以下方法只是为了在我们构建前端时有一些内容可以显示:
addToDo({text: "Learn JavaScript", done: false});
addToDo({text: "Learn Node.js", done: false});
addToDo({text: "Learn BackboneJS", done: false});
编写前端
在本节中,我们将开发客户端逻辑——将在用户的浏览器中运行的代码。这包括由 Node.js 部分提供的待办事项列表的列出和管理。
查看应用程序的基础
在我们开始编码之前,让我们看一下文件结构。以下图显示了我们的项目应该如何看起来:
index.js
文件包含我们已编写的 Node.js 代码。.css
和.html
目录包含页面的样式和 HTML 标记。在.js
文件夹中,我们将放置 Backbone.js 的集合、模型和视图。此外,还有框架的依赖项和主应用程序的app.js
文件。让我们从page.html
文件开始:
<!doctype html>
<html>
<head>
<link rel="stylesheet" type="text/css" href="css/styles.css">
</head>
<body>
<div id="menu">
<a href="#new">Add new ToDo</a>
<a href="#">Show all ToDos</a>
</div>
<div id="content"></div>
<script src="img/jquery-1.10.2.min.js"></script>
<script src="img/underscore-min.js"></script>
<script src="img/backbone.js"></script>
<script src="img/app.js"></script>
<script src="img/ToDo.js"></script>
<script src="img/ToDos.js"></script>
<script src="img/list.js"></script>
<script src="img/add.js"></script>
<script src="img/edit.js"></script>
<script>
window.onload = app.init;
</script>
</body>
</html>
样式被添加到页面的head
标签中。脚本被放置在末尾,就在关闭body
标签之前。我们这样做是因为 JavaScript 文件通常会阻止页面的渲染。将它们添加到页面顶部意味着浏览器将无法获取必要的样式和 HTML 标记,并且不会向用户显示任何内容。
我们有一个带有两个按钮的菜单。第一个按钮将显示一个表单,用户可以在其中添加新的待办事项列表。第二个按钮显示主页,即包含所有任务的列表。内容div
元素将是承载容器,我们将在这里渲染 Backbone.js 的视图。应用程序的引导过程是在app
对象的init
方法中完成的,如下所示:
var app = (function() {
var init = function() { }
return {
models: {},
collections: {},
views: {},
init: init
}
})();
我们将使用Revealing Module模式。app
对象拥有其自己的私有作用域。它的公共 API 包括模型、集合和视图的命名空间。最后是init
方法。使用命名空间是一个好的实践。它们封装了我们的应用程序并防止了冲突。
我们首先想做的事情是显示当前可用的任务。让我们提前写一些东西。很明显,我们将把用户界面放在内容div
元素中。所以,缓存对该元素的引用是一个好主意,因为我们将会多次使用它。我们可以定义一个变量并将 jQuery 对象分配给它,如下所示:
var content;
var init = function() {
content = $("#content");
}
接下来,我们需要一个视图类来列出数据。然而,视图本身不应该向后端发送请求。这是模型的工作——/js/models/ToDo.js
;其代码如下:
app.models.ToDo = Backbone.Model.extend({
defaults: {
text: '',
done: false
},
url: function() {
return '/api/todo/' + this.get("id");
}
});
我们正在使用在/js/app.js
中创建的命名空间。Backbone.js 提供了defaults
属性,我们可以用它来定义初始值。在这里,url
方法非常重要。没有它,框架无法向服务器发送请求。后端管理待办事项的逻辑需要一个 ID。这就是为什么我们需要动态构建 URL 的原因。
当然,我们可能会有很多任务,所以我们需要一个/js/collections/ToDos.js
集合,其代码如下:
app.collections.ToDos = Backbone.Collection.extend({
model: app.models.ToDo,
url: '/api/all'
});
我们直接将 URL 设置为字符串。集合也应该知道其中存储了什么类型的模型,我们传递了模型的类。记住,我们实际上在这里扩展了类。在下面的代码中,我们将创建集合类的实例并调用fetch
方法,该方法从 Node.js 部分获取存储的待办事项列表:
var content,
todos;
var init = function() {
content = $("#content");
todos = new app.collections.ToDos();
todos.fetch({ success: function() {
}});
}
没有数据,我们的应用程序就没有用。我们将使用success
回调,并在信息到达后渲染列表视图。
在我们继续/js/views/list.js
文件的代码之前,我们将澄清关于 Backbone.js 视图的一些事情。我们在本章开头提到,有一个 DOM 元素是自动为我们创建的。它作为视图的.el
属性可用。我们将可能执行一些常见任务。第一个任务是绑定 DOM 事件到视图类内部的函数。这可以通过将值应用到events
属性来实现,如下面的代码所示:
events: {
'click #delete': 'deleteToDo',
'click #edit': 'editToDo',
'click #change-status': 'changeStatus'
}
我们从事件的类型开始,然后是一个元素选择器。值是视图的一个函数。这种事件处理技术的一个大优点是,处理程序中的this
关键字指向正确的位置,即视图。我们可能需要调用delegateEvents
来重新分配监听器。当我们更新视图的 DOM 元素的 HTML 代码时,这很有必要。
关于 Backbone.js 视图的另一个有趣之处是 render
方法。我们通常在那里更新 .el
对象的内容。我们可以使用任何我们喜欢的代码,但避免放置 HTML 标签是良好的实践。这是大多数开发者使用模板引擎的地方。在我们的例子中,我们将使用 Underscore.js 模板。它接受一个字符串和一个包含数据的对象。由于我们不想在视图中放置 HTML 字符串,我们将它添加到 page.html
文件中。标记将被放置在脚本标签内,这样就不会弄乱其余的有效 HTML 代码。好消息是,我们仍然可以通过简单地查询标签来获取它。例如,以下是在 /js/views/list.js
中使用的模板:
<script type="text/template" id="tpl-list-item">
<li data-index="<%= index %>" class="<%= done %>">
<span><%= index+1 %>. <%= text %></span>
<a href="#edit/<%= index %>" id="edit">edit</a>
<a href="javascript:void(0);" id="change-status"><%= statusLabel %></a>
<a href="javascript:void(0);" id="delete">delete</a>
</li>
</script>
有数据占位符用于项目的索引、文本和状态。我们将在渲染过程中用实际值替换它们。
列出待办活动
让我们继续列表视图的代码。以下是将显示当前添加的待办活动的代码:
app.views.list = Backbone.View.extend({
events: {
'click #delete': 'deleteToDo',
'click #change-status': 'changeStatus'
},
getIndex: function(e) {
return parseInt(e.target.parentNode.getAttribute("data-index"));
},
deleteToDo: function(e) {
this.model.at(this.getIndex(e)).destroy();
this.render();
},
changeStatus: function(e) {
var self = this;
var model = this.model.at(this.getIndex(e));
model.save({ done: !model.get("done") }, {
wait: true,
success: function() {
self.render()
}
});
},
render: function() {
var html = '<ul class="list">',
self = this;
this.model.each(function(todo, index) {
var template = _.template($("#tpl-list-item").html());
html += template({
text: todo.get("text"),
index: index,
done: todo.get("done") ? "done" : "not-done",
statusLabel: todo.get("done") ? "mark as not done" : "mark as done"
});
});
html += '</ul>';
this.$el.html(html);
this.delegateEvents();
return this;
}
});
我们在正确的命名空间中定义了视图类。我们将传递待办活动的集合作为模型,因此 this.model
语句将使我们能够访问所有任务。在 render
方法中,我们遍历每个模型并构建一个无序列表,该列表位于末尾并附加到 DOM 元素上。我们使用 $el
而不是 el
,因为我们的项目包含了 jQuery,Backbone.js 会自动开始与它一起工作。请注意,我们根据任务的状态发送不同的 done
和 statusLabel
值。如果我们检查前面的模板,我们会看到 done
实际上是一个 CSS 类。应用不同的类将允许我们区分列表中的项目。我们不应该忘记在最后运行 delegateEvents
方法。我们正在更新 $el
的子元素,因此每个附加的事件监听器都会被移除。
在课程开始时,我们定义了两个事件。第一个事件是从系统中删除一个待办活动。Backbone.js 有一个用于此类情况的 destroy 方法。然而,为了到达集合中的确切模型,我们需要它的索引(ID)。如果我们检查 HTML 模板,我们会看到每个 li
标签都有一个 data-index
属性,它包含我们需要的确切内容。这就是 getIndex
辅助函数的作用——它获取该属性的值。同样,changeStatus
更新待办列表的 done
字段。每次修改后,我们都调用 render
方法。这对用户来说非常重要,因为他们必须看到变化已经完成。
现在,让我们稍微修改一下 app.js
文件并渲染视图,如下面的代码所示:
var content,
todos;
var showList = function() {
content.empty().append(list.render().$el);
}
var init = function() {
content = $("#content");
todos = new app.collections.ToDos();
list = new app.views.list({model: todos});
todos.fetch({ success: function() {
showList();
}});
}
有一个新方法 showList
,它触发视图的渲染并将它的 DOM 元素附加到内容 div
元素上。现在,如果我们通过在控制台中键入 node ./index.js
来运行应用程序,我们将看到我们添加的三个待办活动在屏幕上显示。
添加、删除和编辑待办事项列表
下一个逻辑步骤是开发添加、编辑和删除任务的代码。因此,我们需要两个新的页面,额外的逻辑来显示两个新的视图,以及几行用于删除任务的代码。我们还需要一个处理新内容的路由器。为了简化过程,让我们直接看看最终的 /js/app.js
文件看起来是什么样子:
var app = (function() {
var todos, content, list, add, edit, router;
var showList = function() {
content.empty().append(list.render().$el);
}
var showNewToDoForm = function() {
content.empty().append(add.$el);
add.delegateEvents();
}
var showEditToDoForm = function(data) {
content.empty().append(edit.render(data).$el);
}
var home = function() {
router.navigate("", {trigger: true});
}
var RouterClass = Backbone.Router.extend({
routes: {
"new": "newToDo",
"edit/:index": "editToDo",
"": "list"
},
list: showList,
newToDo: showNewToDoForm,
editToDo: function(index) {
showEditToDoForm({ index: index });
}
});
var init = function() {
todos = new app.collections.ToDos();
list = new app.views.list({model: todos});
edit = (new app.views.edit({model: todos}));
add = (new app.views.add({model: todos})).render();
content = $("#content");
todos.fetch({ success: function() {
router = new RouterClass();
Backbone.history.start();
}});
add.on("saved", home);
edit.on("edited", home);
}
return {
models: {},
collections: {},
views: {},
init: init
}
})();
我们在顶部添加了一些新的变量。add
和 edit
变量代表了两个新的视图。有两个新的函数会改变内容 div
元素。请注意,我们并没有调用 add
视图的 render
方法。这是因为其中没有动态内容,也就是说没有必要重复渲染它。它只是一个提交数据的表单。showEditToDoForm
函数几乎与 showList
函数相同,只是我们期望一个额外的参数——data
。这应该是一个格式为 {index: <number>}
的对象。一旦我们有了待办事项列表的索引,我们就可以轻松地获取其字段。我们需要这些字段,因为我们必须填写用于编辑的表单。
接下来,home
方法简单地使用路由器的 navigate
方法,并将用户返回到 list
视图。脚本中的下一件事是路由器的定义。描述的路径调用我们刚刚经过的函数。这是 URL 地址到 JavaScript 函数的映射。
在 init
方法中有很多新的内容,让我们仔细看看。两个新的视图,add
和 edit
,被初始化了,并且它们再次接受集合的待办活动。我们还将开始监听两个事件。当添加新的待办活动时,视图会触发 saved
事件,当一些任务被更新时,会触发 edited
事件。
添加新任务的视图如下:
app.views.add = Backbone.View.extend({
events: {
"click button": "save"
},
save: function() {
var textarea = this.$el.find("textarea");
var value = textarea.val();
if(value != "") {
var self = this;
this.model.create({ text: value }, {
wait: true,
success: function() {
textarea.val("");
self.trigger("saved");
}
});
} else {
alert("Please, type something.");
}
},
render: function() {
var template = _.template($("#tpl-todo").html());
this.$el.html(template());
this.delegateEvents();
return this;
}
});
有用户输入的验证。如果在 textarea
元素中输入了文本,我们调用集合的 create
方法初始化一个新的模型。它还会向服务器发送一个 POST
请求。一旦操作完成,我们清空文本框并触发 saved
事件,以便 /js/app.js
中的代码可以将用户转发到主页。添加和编辑视图需要单独的模板。以下是该模板的代码:
<script type="text/template" id="tpl-todo">
<div class="form">
<textarea></textarea>
<button>save</button>
</div>
</script>
/js/views/edit.js
文件中的代码几乎相同,如下所示:
app.views.edit = Backbone.View.extend({
events: {
'click button': 'save'
},
save: function() {
var textarea = this.$el.find('textarea');
var value = textarea.val();
if(value != '') {
var self = this;
this.selectedModel.save({text: value}, {
wait: true,
success: function() {
self.trigger('edited');
}
});
} else {
alert('Please, type something.');
}
},
render: function(data) {
this.selectedModel = this.model.at(data.index);
var template = _.template($('#tpl-todo').html());
this.$el.html(template());
this.$el.find('textarea').val(this.selectedModel.get('text'));
this.delegateEvents();
return this;
}
});
不同之处在于它在 textarea
元素中放入一个值,并调用已编辑模型的 save
方法,而不是整个集合的 create
函数。
摘要
在本章中,我们学习了如何使用 Backbone.js。我们使用模型、集合、路由器和几个视图来实现一个简单的待办事项应用。幸运的是,由于框架的事件驱动特性,我们将所有内容绑定在一起。Node.js 在这个小型项目中扮演了一个有趣且重要的角色。它处理来自客户端 JavaScript 的请求,并充当 REST 服务。
下一章将专门介绍命令行编程。我们将学习如何从命令行使用 Node.js,并开发一个脚本,用于将我们的照片上传到 Flickr。
第六章:将 Node.js 用作命令行工具
在前面的章节中,我们学习了如何使用 Node.js 与客户端框架一起使用,例如 AngularJS 和 Backbone.js。每次,我们都从命令行运行后端。Node.js 不仅适合于 Web 应用程序,也适合于开发命令行工具。对文件系统的访问、各种内置模块以及庞大的社区使 Node.js 成为这类程序的有吸引力的环境。
在本章中,我们将详细介绍开发用于在 Flickr 上上传图片的命令行工具的过程。到本章结束时,我们将创建一个程序,该程序可以在特定目录中查找图片并将它们上传到互联网门户。
探索所需的模块
我们将使用几个模块来简化我们的工作,具体如下:
-
fs
: 这为我们提供了对文件系统的访问,并且是 Node.js 模块的一个内置特性。 -
optimist
: 这是一个模块,用于解析传递给我们的 Node.js 脚本的参数。 -
readline
: 这允许按行读取流(例如process.stdin
)。我们将在应用程序运行时使用它来获取用户输入。该模块默认添加到 Node.js 中。 -
glob
: 这个模块读取一个目录,并返回所有匹配预定义特定模式的现有文件。 -
open
: 在某个时候,我们需要在用户的默认浏览器中打开一个页面。Node.js 在不同的操作系统上运行,这些操作系统有不同的命令来打开默认浏览器。此模块通过提供一个 API 来帮助我们。 -
flapi
: 这是用于与 Flickr 服务通信的 Flickr API 包装器。
根据前面的列表,我们可以编写并使用以下 package
.json
文件:
{
"name": "FlickrUploader",
"description": "Command line tool",
"version": "0.0.1",
"dependencies": {
"flapi": "*",
"open": "*",
"optimist": "*",
"glob": "*"
},
"main": "index.js",
"bin": {
"flickruploader": "./index.js"
}
}
我们脚本的入口点是 index.js
文件。因此,我们将它设置为 main
属性的值。还有一个我们尚未使用的特性——bin
属性。这是二进制脚本名称和 Node.js 脚本路径的键/值映射。换句话说,当我们的模块在 Node.js 包管理器的注册中发布并随后安装时,我们的控制台将自动拥有 flickruploader
命令。在安装过程中,npm
命令检查我们是否向 bin
属性传递了某些内容。如果是,那么它将创建我们脚本的 symlink
。在 index.js
文件的顶部添加 #!/usr/bin/env node
也是非常重要的。这样系统就会知道脚本应该用 Node.js 处理。最后,如果我们输入命令并按 Enter 键,我们的脚本就会运行。
规划应用程序
我们可以将命令行工具分为两部分:第一部分读取一个目录并返回其中的所有文件,第二部分将图片发送到 Flickr。将这两个功能形成不同的模块是个好主意。以下图表显示了我们的项目将如何呈现:
images
目录将被用作测试文件夹,也就是说,我们的脚本将在该目录中执行其任务。当然,如果我们想的话,可以有一个其他的。之前提到的两个模块保存在 lib
目录中。因此,我们首先需要获取文件 (Files.js
),然后将其 (Flickr.js
) 上传到门户。这两个操作是异步的,所以这两个模块都应该接受 回调函数。以下为 index.js
文件的内容:
var flickr = require('./lib/Flickr');
var files = require('./lib/Files');
var flickrOptions = {};
files(function(images) {
flickr(flickrOptions, images, function() {
console.log("All the images uploaded.");
process.exit(1);
})
});
Files
模块将检查指定的文件夹,并扫描其中的子文件夹和图片。所有图片文件都将作为传递的回调函数的参数返回。这些图片将被发送到 Flickr
模块。除了文件外,我们还将传递一些访问 Flickr 服务的必要设置。最终,一旦一切顺利,我们将调用 process.exit(1)
来终止程序并将用户返回到终端。
从文件夹中获取图片
Files.js
文件以所需模块的定义开始:
var fs = require('fs');
var argv = require('optimist').argv;
var readline = require('readline');
var glob = require('glob');
紧接着,我们需要定义两个变量。currentDirectory
变量存储当前工作目录的路径,而 rl
是 readline
模块的一个实例。
var currentDirectory = process.cwd() + '/';
var rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
createInterface
函数接受一个对象。两个必需的字段是 input
和 output
。input
字段将指向传入的可读流,而 output
指向可写流。在我们的例子中,用户将直接在终端/控制台中输入数据,因此我们将传递 process.stdin
。
在本章的开头,我们提到了 optimist
模块。我们将使用它从命令行获取参数。在我们的例子中,这将是我们用于解析的目录。总是提供一种替代方法来应用设置是很好的,也就是说,除了询问用户外,还应接受命令行参数。每个 Node.js 脚本都有一个全局对象 process
,它有一个 argv
属性。这个属性是从终端传递的参数的数组。optimist
模块简化了解析并提供了一个有效的 API 来访问这些参数。
让我们在 rl
变量的定义之后立即添加以下代码:
module.exports = function(callback) {
if(argv.s) {
readDirectory(currentDirectory + argv.s, callback);
} else {
getPath(function(path) {
readDirectory(path, callback);
});
}
};
argv.s will be equal to images. So, we should check whether such a parameter is passed, and if yes, we continue with searching the image files. If not, ask the user via the readline module, the getPath function, as in the following code:
var getPath = function(callback) {
rl.question('Please type a path to directory: ', function(answer) {
callback(currentDirectory + answer);
});
}
询问方法的回调返回用户输入的文本。我们只需将其传递给 readDirectory
函数,如下所示:
var readDirectory = function(path, callback) {
if(fs.existsSync(path)) {
glob(path + "/**/*.+(jpg|jpeg|gif|png)", function(err, files){
if(err) {
throw new Error('Can\'t read the directory.');
}
console.log("Found images:");
files.forEach(function(file) {
console.log(file.replace(/\//g, '\\').replace(process.cwd(), ''));
});
rl.question('Are you sure (y/n)? ', function(answer) {
if(answer == 'y') {
callback(files);
}
rl.close();
});
});
} else {
getPath(function(path) {
readDirectory(path, callback);
});
}
}
当然,我们应该检查路径是否有效。为此,我们将使用 fs.existsSync
方法。如果目录存在,我们将获取符合以下模式的文件:
/**/*.+(jpg|jpeg|gif|png)
这意味着解析目录及其所有子目录,并搜索以 jpg
、jpeg
、gif
或 png
结尾的文件。在这种情况下,glob
模块非常有帮助。
在将文件发送回 index.js
之前,我们需要显示它们并请求用户确认。这同样是通过在开头包含的 readline
模块完成的。使用 rl.close()
方法是很重要的。此方法释放了对输入和输出流的控制。
授权 Flickr 协议
我们将使用flapi
模块与 Flickr 进行通信。它提供了访问 API 方法的功能。大多数大型公司都会实施一定程度的授权。换句话说,我们不能只是发出请求并上传/检索数据。我们需要在请求中签名访问令牌或在过程中提供凭证。Flickr 使用OAuth(1.0 规范),这是一种此类操作的行业标准。OAuth 是一个开放标准,用于授权,并定义了客户端访问服务器资源的方法。让我们查看以下图表,看看 Flickr 的 OAuth 机制是如何工作的:
几乎整个流程都被flapi
模块封装。我们应该记住的是,我们需要一个密钥和密钥来检索访问令牌。相同的令牌将在上传图片时使用。
获取应用程序的密钥和密钥
要创建我们自己的应用程序的密钥和密钥,我们首先必须拥有一个有效的 Flickr 账户。接下来,登录并导航到www.flickr.com/services/apps/create/apply/
。在此页面上,点击申请非商业密钥,这是蓝色的按钮。
我们正在构建一个非商业应用程序;然而,如果您计划将密钥用于商业目的,请选择右侧的第二个选项。之后,您将看到一个带有少量字段的表单。填写它们并点击以下截图所示的提交按钮:
将显示的下一屏包含我们的密钥和密钥。它应该看起来像以下截图:
向 Flickr.js 模块写入
一旦我们获取了密钥和密钥值,我们就可以继续并开始编写我们的lib/Flickr.js
模块。以下是该文件的初始代码:
var open = require('open');
var http = require('http');
var url = require('url');
var Flapi = require('flapi');
var flapiClient;
var filesToOpen;
var done;
var options;
module.exports = function(opts, files, callback) {
options = opts;
filesToOpen = files;
done = callback;
createFlapiClient();
}
所需的依赖项位于上一段代码的开头。我们提到了open
模块;在这里,http
用于运行 Node.js HTTP 服务器,而url
用于解析传入请求的参数。该模块导出一个接受三个参数的函数。第一个参数包含 Flickr 的 API 设置,如密钥和密钥。第二个参数是需要上传的文件数组。最后,我们接受一个callback
函数,该函数将在上传完成后被调用。我们将所有内容保存在几个全局变量中,并调用createFlapiClient
,这将初始化flapi
对象。在我们看到createFlapiClient
中确切发生的事情之前,让我们编辑index.js
并传递所需选项,如下所示:
var flickr = require('./lib/Flickr');
var files = require('./lib/Files');
var flickrOptions = {
oauth_consumer_key: "ebce9c7a68eb009f8db5bcc41d139320",
oauth_consumer_secret: "a9277a76c947c0b3",
// oauth_token: '',
// oauth_token_secret: '',
perms: 'write'
};
我们将flickrOptions
留空,但现在是我们填充它的时候了。将密钥设置为oauth_consumer_key
的值,将密钥设置为oauth_consumer_secret
的值。默认情况下,令牌oauth_token
和oauth_token_secret
被注释掉,但一旦我们执行初始授权,我们将设置它们的值。最后,还有一个权限属性,应该设置为write
,因为我们将会上传照片。
当在Flickr.js
中配置了正确的选项时,我们可以创建我们的flapi
客户端并开始查询 Flickr 的服务器,如下面的代码所示:
var createFlapiClient = function(){
flapiClient = new Flapi(options);
if(!options.oauth_token) {
flapiClient.authApp('http://127.0.0.1:3000', function(oauthResults){
runServer(function() {
open(flapiClient.getUserAuthURL());
})
});
} else {
uploadPhotos();
}
};
我们传递设置,目前是oauth_consumer_key
、oauth_consumer_secret
和perms
。请注意,oauth_token
是undefined
,我们需要授权我们的应用程序。这发生在浏览器中。Flickr 定义的机制要求打开一个特定的 URL 并传递一个回调地址,用户在获得权限后将被重定向到该地址。我们正在开发一个命令行工具,所以我们实际上无法提供该地址,因为我们的脚本在终端中。因此,我们运行自己的 HTTP 服务器,该服务器将接受来自 Flickr 的请求。当然,这个服务器将只在我们机器上和脚本执行期间可用。但那应该足够了,因为我们只需要在第一次运行时使用它。如果一切顺利,我们将获得oauth_token
和oauth_token_secret
值,如下面的代码所示。我们将它们设置在flickrOptions
中,并且下次不会运行 HTTP 服务器。当服务器启动时,我们在用户的默认浏览器中打开一个新页面,传递由flapiClient.getUserAuthURL
返回的正确 URL。
runServer
函数背后的代码如下:
var runServer = function(callback) {
http.createServer(function (req, res) {
res.writeHead(200, {'Content-Type': 'text/html'});
var urlParts = url.parse(req.url, true);
var query = urlParts.query;
if(query.oauth_token) {
flapiClient.getUserAccessToken(query.oauth_verifier, function(result) {
options.oauth_token = result.oauth_token;
options.oauth_token_secret = result.oauth_token_secret;
var message = '';
for(var prop in result) {
message += prop + ' = ' + result[prop] + '<br />';
}
res.end(message);
uploadPhotos();
});
} else {
res.end('Missing oauth_token parameter.');
}
}).listen(3000, '127.0.0.1');
console.log('Server running at http://127.0.0.1:3000/');
callback();
}
服务器监听在端口 3000 上,并且只有一个处理器。我们正在等待的请求包含GET参数oauth_verifier
。我们将通过使用url
模块及其parse
方法来获取它。同样重要的是,我们需要将true
作为第二个参数发送,以便 Node.js 解析请求的查询字符串。通过将oauth_verifier
传递给客户端的getUserAccessToken
方法flapi
,我们将获取所需的令牌和密钥。在最后,会调用一个名为uploadPhotos
的函数,但现在我们将保持其主体为空。这将在本章的下一部分中填充。
运行我们的应用程序工具
现在,让我们运行我们的工具。在您的终端中输入node ./index.js
,您将看到以下截图所示的内容:
我们测试的目录是images
,所以我们输入这个字符串并点击Enter。Files.js
中的代码将扫描目录中的图片,并要求我们确认,如下面的截图所示:
输入y并按Enter键。将显示一条消息,表明服务器正在运行,并且在我们的默认浏览器中打开一个新页面。它将要求我们授予应用程序执行几个操作的权限,如下面的截图所示:
点击带有文本OK, I'LL AUTHORIZE IT的蓝色按钮。此时有两个动作正在进行。浏览器发送一个带有oauth_verifier
参数的请求到我们的 Node.js 服务器。我们使用该值,将其传递给getUserAccessToken
方法,并获取所需的oauth_token
和oauth_token_secret
值。同时,浏览器收到响应,我们看到如下类似的截图:
我们将从第二行和第三行获取信息,并将其放入在index.js
文件中初始化的flickrOptions
对象中。通过这样做,我们将避免下次使用 Node.js 服务器执行的步骤。脚本将能够直接上传照片,而无需请求令牌和密钥。
上传图片
我们将要编写的最后一个函数是Flickr.js
模块的uploadPhotos
方法。它将使用全局filesToOpen
数组,逐个上传文件。由于操作是异步的,我们将持续执行该函数,直到数组为空。我们可以看到如下代码:
var uploadPhotos = function() {
if(filesToOpen.length === 0) {
done();
} else {
var file = filesToOpen.shift();
console.log("Uploading " + file.replace(/\//g, '\\').replace(process.cwd(), ''));
flapiClient.api({
method: 'upload',
params: { photo : file },
accessToken : {
oauth_token: options.oauth_token,
oauth_token_secret: options.oauth_token_secret
},
next: function(data){
uploadPhotos();
}
});
}
}
done
回调将应用程序流程返回到index.js
,在那里脚本终止。整个过程的结果将如下截图所示:
摘要
在本章中,我们学习了如何将 Node.js 用作命令行工具。我们成功从终端获取了参数,搜索目录中的图片文件,并将它们上传到 Flickr。大多数原始操作,如文件系统访问或 Flickr OAuth 实现,都委托给了不同的模块,我们将它们作为依赖项添加到项目中。每天都有越来越多的工具出现,使 Node.js 成为一个吸引人的开发环境,不仅适用于基于 Web 的应用程序,也适用于命令行脚本。
在下一章中,我们将学习如何将 Node.js 和 Ember.js 一起使用。我们将获取 Twitter 社交动态并将其显示在浏览器上。
第七章. 使用 Ember.js 显示社交动态
在上一章中,我们学习了如何创建一个命令行工具,用于将照片上传到 Flickr。在这一章中,我们将与最受欢迎的社交网络之一:Twitter 进行通信。我们将创建一个应用,根据用户名获取最新的推文并在屏幕上显示。Node.js 将负责与 Twitter API 的通信,而 Ember.js 将负责用户界面。以下是本章我们将涵盖的一些主题的简要列表:
-
Ember.js 框架简介
-
与 Twitter 的 API 通信
-
将 Node.js 与 Ember.js 连接以获取推文
准备应用
我们在前面几章中已经讨论了应用。对于这个应用,我们需要一个 Node.js 服务器,它将提供必要的 HTML、CSS 和 JavaScript 代码。以下是我们从它开始的 package.json
文件:
{
"name": "TwitterFeedShower",
"description": "Show Twitter feed",
"version": "0.0.1",
"dependencies": {
"twit": "*"
},
"main": "index.js"
}
只有一个依赖项,那就是将连接到 Twitter 的模块。在 package.json
文件所在的同一文件夹中运行 npm install
后,该模块将出现在新创建的 node_modules
目录中。
下一步是创建 HTML、CSS 和 JavaScript 的文件夹,并将必要的文件放入这些文件夹中。此外,创建包含我们的 Node.js 服务器代码的 index.js
主文件。最后,我们的项目目录应该看起来像以下图示:
项目的 CSS 样式将存放在 css/styles.css
中。模板将放置在 html/page
html 文件中,自定义 JavaScript 代码将编写在 js/scripts.js
中。其他的 .js
文件是 Ember.js 本身及其两个依赖:jQuery 和 Handlebars。
运行服务器并交付资源
在 第五章,使用 Backbone.js 创建待办事项应用中,我们使用 Backbone.js 创建了一个应用,并使用了两个辅助函数:serveAssets
和 respond
。这些函数的目的是读取我们的 HTML、CSS 和 JavaScript 文件,并将它们作为响应发送到浏览器。我们在这里将再次使用它们。
让我们先定义全局变量,如下所示:
var http = require('http'),
fs = require('fs'),
port = 3000,
files = [],
debug = true;
http
模块提供了创建和运行 Node.js 服务器的方法,而fs
模块负责从文件系统中读取文件。我们将监听端口 3000,files
变量将缓存读取文件的文件内容。当debug
设置为true
时,资产将在每次请求时读取。如果它是false
,其内容只会在第一次请求时获取,但未来的每个响应都将包含相同的代码。我们这样做是因为在我们开发应用程序时,我们不希望停止并运行我们的服务器只是为了看到 HTML 脚本的更改。每次请求时读取文件确保我们看到的都是最新版本。然而,当我们在生产环境中运行应用程序时,这被认为是一种不良做法。
让我们继续,使用以下代码运行服务器:
var app = http.createServer(function (req, res) {
if(req.url.indexOf("/tweets/") === 0) {
// ... getting tweets
} else {
serveAssets(req, res);
}
}).listen(port, '127.0.0.1');
console.log("Server listening on port " + port);
我们传递给http.createServer
的回调函数接受两个参数:request
和response
对象。我们的 Node.js 应用程序部分将负责两件事。第一件事是提供必要的 HTML、CSS 和 JavaScript,第二件事是从 Twitter 获取推文。因此,我们检查 URL 是否以/tweets
开头,如果是,我们将以不同的方式处理请求。否则,将调用serveAssets
,如下所示:
var serveAssets = function(req, res) {
var file = req.url === '/' ? 'html/page.html' : req.url;
if(!files[file] || debug) {
try {
files[file] = {
content: fs.readFileSync(__dirname + "/" + file),
ext: file.split(".").pop().toLowerCase()
}
} catch(err) {
res.writeHead(404, {'Content-Type': 'plain/text'});
res.end('Missing resource: ' + file);
return;
}
}
respond(files[file], res);
}
在这个函数中,我们正在获取请求的文件路径,并将从文件系统中读取文件。除了文件内容外,我们还将获取其扩展名,这是正确设置响应头所必需的。这一操作在respond
方法中完成,如下所示:
var respond = function(file, res) {
var contentType;
switch(file.ext) {
case "css": contentType = "text/css"; break;
case "html": contentType = "text/html"; break;
case "js": contentType = "application/javascript"; break;
case "ico": contentType = "image/ico"; break;
default: contentType = "text/plain";
}
res.writeHead(200, {'Content-Type': contentType});
res.end(file.content);
}
这很重要,因为我们如果不提供Content-Type
,浏览器可能无法正确解释响应。
关于资产服务的所有内容就到这里了。让我们继续,从 Twitter 获取信息。
根据用户名获取推文
在我们编写从 Twitter API 请求数据的代码之前,我们需要注册一个新的 Twitter 应用程序。首先,我们应该打开dev.twitter.com
并使用我们的 Twitter用户名和密码登录。之后,我们需要加载dev.twitter.com/apps/new
并填写表格。它应该看起来像下面的截图:
我们可以留空回调 URL字段。网站字段可以填写我们的个人或公司网站的地址。我们应该接受表格下面的条款和条件,并点击创建 Twitter 应用程序。我们接下来将看到的页面应该类似于下面的截图:
我们需要的信息位于第三个标签页:API 密钥。一旦点击它,Twitter 将显示API 密钥和API 密钥字段,如下面的截图所示:
此外,我们还将通过点击创建我的访问令牌按钮生成访问令牌和访问令牌密钥。通常,数据不会立即显示。因此,我们应该稍等片刻,并在必要时刷新页面。生成的文档应类似于以下截图:
我们将复制访问令牌和访问令牌密钥的值。将此类敏感信息从应用程序代码中移除是一个好习惯,因为我们的程序可能会从一个地方转移到另一个地方。将数据放置在外部配置文件中通常可以完成这项工作。
一旦我们有了这四个字符串,我们就能与 Twitter 的 API 进行通信。以下变量位于我们的 index.js
文件顶部:
var Twit = require('twit');
var T = new Twit({
consumer_key: '...',
consumer_secret: '...',
access_token: '...',
access_token_secret: '...'
});
var numOfTweets = 10;
T
变量实际上是一个 Twitter 客户端,我们将用它来请求数据。我们在服务器上留了一个查询 Twitter API 的位置。现在,让我们将必要的代码放入 index.js
文件中,如下所示:
var app = http.createServer(function (req, res) {
if(req.url.indexOf("/tweets/") === 0) {
var handle = req.url.replace("/tweets/", "");
T.get("statuses/user_timeline", { screen_name: handle, count: numOfTweets }, function(err, reply) {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify(reply));
});
} else {
serveAssets(req, res);
}
}).listen(port, '127.0.0.1');
我们需要执行的请求是 http://localhost:3000/tweets/KrasimirTsonev
。URL 的最后一部分是用户的 Twitter 名称。因此,if
语句变为 true
,因为地址以 /tweets/
开头。我们提取用户名到一个名为 handle
的变量中。之后,这个变量被发送到 Twitter API 的 statuses/user_timeline
资源。请求的结果直接通过字符串化的 JSON 发送到浏览器。
在总结的要点中,我们项目的 Node.js 部分提供了所有 HTML、CSS 和 JavaScript 代码。除此之外,它接受一个 Twitter 名称,并返回用户的最新推文。
发现 Ember.js
Ember.js 是当今最受欢迎的客户端 JavaScript 框架之一。它拥有庞大的社区,其功能得到了很好的文档记录。Ember.js 因其架构而聚集了越来越多的粉丝。该库使用模型-视图-控制器设计模式,这使得它易于理解,因为该模式几乎在所有编程语言中都被广泛使用。它还与 REST API 协作良好(我们将在第十一章第十一章。编写 REST API 中构建此类 API),并消除了编写样板代码的任务。
了解 Ember.js 的依赖项
Ember.js 框架有以下两个依赖项:
-
jQuery
-
Handlebars
第一个是目前 Web 上最常用的 JavaScript 工具。它提供了选择和操作 DOM
元素的方法,以及许多辅助函数,如 forEach
或 map
,这些可以帮助我们更快地工作。该库还通过提供一个单一的 API 解决了一些 跨浏览器 问题。例如,如果我们想要向一个元素附加事件监听器,我们需要在 Internet Explorer 中使用 attachEvent
,而在其他浏览器中使用 addEventListener
。jQuery 提供了一个简单的 .on
方法,它封装了这个功能。它会检查当前浏览器并调用正确的函数。除此之外,我们还可以使用 .get
或 .post
函数,这些函数执行 AJAX 请求。
Handlebars 是一个模板引擎库。它通过添加表达式和自定义标签扩展了 HTML 语法。它与我们在 第二章 中使用的另一个模板语言 Jade 类似,使用 Node.js 和 Express 开发基本网站。不同之处在于这次我们将使用应用程序客户端部分的模板。例如:
<script type="text/x-handlebars" data-template-name="say-hello">
<div class="content">{{name}}</div>
</script>
这是一个 Handlebar 使用的模板定义。它定义在一个 <script>
标签中,因为浏览器会忽略其中的内容,并且它不会被渲染为 DOM
树的一部分。其中有一个表达式:{{name}}
。通常,模板会填充信息,并将这些标记部分替换为实际数据。Handlebar 所做的是获取 script
标签的值。然后,它将解析它。找到的表达式将被执行,并将结果返回给开发者。
理解 Ember.js
在我们继续编写我们的小型应用程序的实际代码之前,我们将学习 Ember.js 中最重要的组件。
探索 Ember.js 中的类和对象
和每个框架一样,Ember.js 有预定义的对象和类,这些都在我们的掌控之中。在大多数情况下,我们会扩展它们,并只编写应用程序的一部分自定义逻辑。所有可用的类都在 Ember
命名空间下。这意味着每次我们想要使用框架的某个部分时,我们都需要通过 Ember.
语法。例如,在以下代码中展示的类扩展:
App.Person = Ember.Object.extend({
firstname: '',
lastname: '',
hi: function() {
var name = this.get("firstname") + " " + this.get("lastname");
alert("Hello, my name is " + name);
}
});
var person = App.Person.create();
person.set("firstname", "John");
person.set("lastname", "Black");
person.hi();
我们定义了一个名为 Person
的类。它有两个属性和一个仅显示屏幕上消息的功能。就在那之后,我们创建了该类的实例并调用了该方法。在 Ember.js 中,类的属性通过 .get
和 .set
方法访问。在先前的例子中,我们仍然可以使用 this.firstname
而不是 this.get("firstname")
,但这并不完全正确。在 .set
和 .get
方法中,Ember.js 进行了一些必要的计算,以实现数据绑定和计算属性等特性。如果我们直接访问变量,库可能没有机会完成其工作。
计算属性
根据定义,计算属性是属性,它们通过执行一个函数来获取其值。让我们继续使用之前的例子。而不是每次都连接 firstname
和 lastname
,我们将创建一个计算属性 name
,它将返回所需的字符串。我们可以在以下代码中看到这一点:
App.Person = Ember.Object.extend({
firstname: '',
lastname: '',
hi: function() {
alert("Hello, my name is " + this.get("name"));
},
name: function() {
return this.get("firstname") + " " + this.get("lastname");
}.property("firstname", "lastname")
});
var person = App.Person.create();
person.set("firstname", "John");
person.set("lastname", "Black");
person.hi();
我们仍然会使用 .get
方法访问一个属性,但这次它的值是由一个函数计算得出的。如果我们需要在显示之前格式化数据,这可能会非常有帮助。了解我们可以使用计算属性来设置值是很好的。默认情况下,它们是只读的,但我们可以将它们转换为接受和处理数据,如下所示:
name: function(key, value) {
if (arguments.length > 1) {
var nameParts = value.split(/\s+/);
this.set('firstname', nameParts[0]);
this.set('lastname', nameParts[1]);
}
return this.get("firstname") + " " + this.get("lastname");
}.property("firstname", "lastname")
路由器
路由过程更像是其他客户端框架的扩展。然而,在 Ember.js 中,所有内容都是围绕它们构建的。路由器是一个类,它将页面的 URL 转换为一系列嵌套模板。这些模板中的每一个都与一个提供数据的模型相连接。
App = Ember.Application.create();
App.Router.map(function() {
this.resource('post', { path: '/post/:post_id' }, function() {
this.route('edit', { path: '/edit' });
this.resource('comments', function() {
this.route('new');
});
});
});
edit route. That's because the name of the path is the same as the route name.
我们可以将路由器视为我们逻辑的起点。每个路由和资源都有自己的类和控制器与之关联。好消息是,我们实际上并不需要定义它们,因为框架会为我们完成这项工作。我们经常需要通过设置一些属性来修改它们的实现;然而,通常情况下,我们可以自由地保留默认建议的版本。一旦我们开始使用 Ember.js,我们会发现有很多类是自动创建的。有时,跟踪它们可能有点困难。有一个名为 Ember Inspector 的 Google Chrome 扩展程序。它实际上是开发者工具面板中的一个新标签页。检查器可以显示我们的应用程序中正在发生的事情。例如,之前的代码产生了以下结果:
如我们所见,有几个路由和控制器可用。应用程序有一个默认路由,以及主 帖子 资源的路由。这个扩展程序非常有用,因为它显示了类的确切名称。Ember.js 有严格的命名约定,我们应该能够自己找出这些名称,但这个扩展程序仍然很方便。
如果我们想在评论区的控制器中添加一些逻辑,那么我们应该使用以下代码:
App.CommentsController = Ember.ObjectController.extend({
// ...
});
我们应该记住,我们实际上正在修改类的定义。框架会自动创建它的实例。
视图和模板
我们已经提到 Ember.js 使用 Handlebars 来实现模板功能。一个简单的模板定义看起来像以下代码:
<script type="text/x-handlebars" data-template-name="post/index">
<section>
<h1>{{title}}</h1>
<p>{{text}}</p>
</section>
</script>
这是一个脚本标签以及 HTML 标记。每个模板都有一个与其关联的view
类。通常,开发者不会扩展view
类。它在需要大量处理用户事件或创建自定义组件的情况下使用。在底层,view
类将原始浏览器事件转换为在应用程序上下文中具有意义的事件。例如,我们可能有以下模板:
<script type="text/x-handlebars" data-template-name="say-hello">
Hello, <b>{{view.name}}</b>
</script>
其对应的视图实例如下所示:
var view = Ember.View.create({
templateName: "say-hello",
name: "user",
click: function(evt) {
alert("Clicked.");
}
});
view.append();
我们正在处理文本的点击事件。通过使用.append
方法,视图被添加到<body>
元素中,但还有.appendTo
方法,可以将我们的自定义 HTML 添加到所需的任何DOM
元素中。
模型
Ember.js 中的每个路由都有一个关联的模型,它是一个存储持久状态的对象。我们在路由的类中设置我们的模型。有一个名为model
的钩子,它应该返回我们的数据。通常,我们将以异步方式获取应用程序的数据。对于此类情况,我们可以返回一个 JavaScript 承诺。
App.PostRoute = Ember.Route.extend({
model: function() {
return Ember.$.getJSON("/posts.json");
}
});
与特定路由链接的模板根据模型渲染其 HTML。因此,我们可以使用表示.model
方法结果的属性的表达式。例如,请看以下代码:
<script type="text/x-handlebars" data-template-name="post/index">
<section>
<h1>{{title}}</h1>
<p>{{text}}</p>
</section>
</script>
App.PostIndexRoute = Ember.Route.extend({
model: function() {
return {
title: "Title of the post",
text: "Text of the post"
}
}
})
控制器
在 Ember.js 的上下文中,控制器是装饰你的模型的显示逻辑的类。理想情况下,它们将存储不需要存储在数据库中的数据。只有在信息需要显示时才需要。与模型一样,框架为每个路由定义了一个不同的控制器类。假设我们正在开发一个在线书店。我们可以有一个类似于以下代码的路由:
App.Router.map(function() {
this.route("books");
});
我们只有一个路由,但定义了三个控制器。我们可以通过使用 Google Chrome 的扩展程序看到它们。查看以下屏幕截图:
在BooksRoute
类中,我们将定义我们的模型,并在BooksController
中创建计算属性以更好地显示书籍。控制器也是处理来自浏览器的事件的地方。最初,这些事件由视图捕获,但如果未定义View
或没有事件处理程序,则将事件传递给控制器。
这些是每个 Ember.js 应用程序最重要的组件。现在,让我们继续构建我们的小型项目——一个用于从 Twitter 获取消息的单页应用程序。
编写 Ember.js
项目的客户端包含两个屏幕。第一个屏幕显示一个输入字段和一个按钮,用户应在其中输入 Twitter 用户名。第二个屏幕显示推文。我们可以在以下屏幕截图中看到这一点:
图像的左侧显示第一页,右侧显示用户的推文。
定义模板
html/page.html
文件是我们的主文件,是应用程序的基础,也将是用户看到的第一个页面。它包含以下代码:
<!doctype html>
<html>
<head>
<title>Get Twitter Feed</title>
<link rel="stylesheet" type="text/css" href="css/styles.css">
</head>
<body>
<script src="img/jquery-1.10.2.js"></script>
<script src="img/handlebars-1.1.2.js"></script>
<script src="img/ember-1.3.1.js"></script>
<script src="img/scripts.js"></script>
</body>
</html>
这是我们开始的基本 HTML 标记。Ember.js 的依赖项包括 js/scripts.js
文件,该文件将包含我们的自定义逻辑。我们将在之后定义的模板将被放置在 <body>
标签内。以下模板是第一个。它是应用程序的主模板:
<script type="text/x-handlebars" data-template-name="social-feed">
<div class="wrapper">
<h1>Social feed</h1>
<section>
{{outlet}}
</section>
</div>
</script>
我们只有一个表达式:{{outlet}}
。这是一个 Ember.js 特定的表达式,它告诉框架我们希望子视图在哪里渲染。注意模板的名称:social-feed
。在定义路由时,我们将使用相同的名称。
我们将用于第一个屏幕(包含输入字段)的 HTML 代码如下:
<script type="text/x-handlebars" data-template-name="social-feed/index">
{{input
type="text"
value=handle
placeholder="type a Twitter handle"
}}
<a href="javascript:void(0);" class="get-tweets-button" {{action getTweets}}>Get Tweets</a>
</script>
模板的名称是 social-feed/index
。通过 /index
,我们表示这是名为 social-feed
的路由的默认模板。{{input}}
标签是 Ember.js 辅助函数,稍后将被转换为 <input>
元素。type
和 placeholder
属性与常规 HTML 中的含义相同。然而,这里的 value
扮演着另一个角色。注意 value
没有被双引号包围。这是因为 handle
关键字实际上是路由控制器的属性,并且我们有两个向数据绑定。还使用了另一个表达式:{{action}}
,它接受一个方法名,这个方法也是控制器的一部分。它将响应用户的点击事件。
我们将要定义的最新模板是显示推文的模板。我们可以如下看到该模板:
<script type="text/x-handlebars" data-template-name="social-feed/tweets">
<h3>Tweets of {{{formattedHandle}}}:</h3>
<hr />
<ul>
{{#each}}
<li>{{formatTweet text}}</li>
{{/each}}
</ul>
{{#link-to 'social-feed.index'}}back{{/link-to}}
</script>
{{{formattedHandle}}}
辅助函数将被替换为链接到用户的 Twitter 个人资料。因为有三个括号,所以 formatedHandle
的值将包含在 HTML 中。如果我们只使用双括号,handlebars 将显示数据作为字符串,而不是作为 HTML 标记。使用了 {{#each}}
辅助函数。这就是我们将遍历获取的推文并显示其内容的方式。最后,我们将使用 {{#link-to}}
辅助函数生成链接到第一个屏幕。
定义路由
通常,Ember.js 应用程序以创建全局命名空间开始,然后定义路由。js/scripts.js
以以下代码开始:
App = Ember.Application.create();
App.Router.map(function() {
this.resource('social-feed', { path: '/' }, function() {
this.route("tweets", { path: '/tweets/:handle' });
});
});
创建了一个资源和一个路由。该路由对包含动态段的路由做出响应。让我们检查 Ember.js Chrome 扩展中的控制器和模板的名称。以下截图显示了创建的确切类:
Ember.js 默认定义了几个路由:application
、loading
和 error
。第一个是主项目路由。如果我们在两个路由之间有异步转换,可以使用 LoadingRoute
和 ErrorRoute
。如果我们从外部资源加载模型数据并想以某种方式指示这个过程,这些子状态非常有用。
处理用户输入并移动到第二个屏幕
我们需要为 social-feed/index
模板定义一个控制器。如果屏幕上的按钮被点击,它将把用户转移到第二个屏幕。除此之外,我们还将获取输入元素中输入的 Twitter 句柄。我们定义控制器如下:
App.SocialFeedIndexController = Ember.Controller.extend({
handle: '',
actions: {
getTweets: function() {
if(this.get('handle') !== '') {
window.location.href = "#/tweets/" + this.get('handle');
this.set('handle', '');
} else {
alert("Please type a Twitter handle.");
}
}
}
});
注意,我们正在清除 handle
属性的值——this.set('handle', '')
。我们这样做是因为用户稍后将返回该视图并希望输入新的用户名。作为补充,我们可以扩展负责该模板的视图,并在模板添加到 DOM 树后,将浏览器的焦点带到该字段。
App.SocialFeedIndexView = Ember.View.extend({
didInsertElement: function() {
this.$('input').focus();
}
});
显示推文
我们有一个响应 JSON 格式推文列表的 URL 地址。存在相应的控制器和路由类,这些类默认由 Ember.js 定义。然而,我们需要设置一个模型并从浏览器的地址中获取句柄,因此我们将创建自己的类。这可以从以下内容中看到:
App.SocialFeedTweetsRoute = Ember.Route.extend({
model: function(params) {
this.set('handle', params.handle);
return Ember.$.getJSON('/tweets/' + params.handle);
},
setupController: function(controller, model) {
controller.set("model", model);
controller.set("handle", this.get('handle'));
}
});
App.SocialFeedTweetsController = Ember.ArrayController.extend({
handle: '',
formattedHandle: function() {
return "<a href='http://twitter.com/" + this.handle + "'>@" + this.handle + '</a>';
}.property('handle')
});
URL 的动态部分通过 params
参数传递到路由的 model
函数中。我们将获取字符串并将其设置为类的属性。稍后,当我们设置控制器时,我们能够将它与模型一起传递。setupController
函数是一个钩子,它在路由初始化期间运行。正如我们在本章开头所说,控制器的主要作用是装饰模型。我们的控制器只做了一件事——定义一个计算属性,在 <a>
标签中打印用户的 Twitter 句柄。控制器还扩展了 Ember.ArrayController
,它提供了一种发布对象集合的方法。
如果我们回顾几页并查看 social-feed/tweets
模板,我们会看到我们可以使用以下代码来显示推文:
{{#each}}
<li>{{formatTweet text}}</li>
{{/each}}
通常,我们只会使用 {{text}}
而不是 {{formatTweet text}}
。我们所做的是使用自定义定义的辅助函数,它将推文文本格式化。我们需要这样做,因为推文可能包含 URL,我们希望将它们转换为有效的 HTML 链接。我们可以在控制器中这样做并定义另一个计算属性,但我们将它作为 Handlebars 辅助函数来做。我们可以如下看到:
Ember.Handlebars.registerBoundHelper('formatTweet', function(value) {
var exp = /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/ig;
return new Handlebars.SafeString(value.replace(exp, "<a href='$1'>$1</a>"));
});
我们使用正则表达式将 URL 转换为 <a>
标签。
随着最新代码行的完成,我们的 js/script.js
文件已经完成,我们可以使用该应用程序来获取任何 Twitter 用户的最新推文。
摘要
在本章中,我们学习了如何使用 Node.js 与 Ember.js。我们成功创建了一个完全工作的应用程序,该应用程序显示了 Twitter 上发布的消息。外部模块完成了基本工作,这再次证明 Node.js 生态系统确实非常灵活,并提供了我们开发一流网络应用程序所需的一切。现代客户端框架,如 Ember.js、AngularJS 或 Backbone.js,预计将接收 JSON,而 Node.js 能够提供它。
在下一章中,我们将了解如何使用 Node.js 来优化我们的项目任务并提升我们的编码性能。
第八章:使用 Grunt 和 Gulp 开发 Web 应用工作流程
在前面的几章中,我们学习了如何使用 Node.js 与最流行的客户端 JavaScript 框架,如 AngularJS 和 Ember.js 一起使用。我们学习了如何运行一个功能齐全的 Web 服务器和构建命令行工具。
在本章中,我们将探索任务运行器的世界。Grunt 和 Gulp 是两个广泛使用的模块,它们拥有一个坚实的插件集合。
介绍任务运行器
应用程序在本质上通常是复杂的。越来越多的逻辑被放入浏览器中,并且用许多行 JavaScript 代码编写。新的 CSS3 特性和原生浏览器动画性能的改进导致大量的 CSS 代码。当然,最后我们仍然希望将事物分开。确保所有内容都放置在不同的文件夹和文件中。否则,我们的代码将难以维护。我们可能需要生成 manifest.json
,使用预处理器,或者简单地从一处复制文件到另一处。幸运的是,有一些工具可以使我们的生活变得更简单。任务运行器接受指令并执行某些操作。它使我们能够设置监视器并监视文件的变化。这对于我们有一个复杂的设置和许多方面要处理的情况非常有帮助。
目前,有两个流行的 Node.js 任务运行器:Grunt 和 Gulp。它们之所以被广泛使用,是因为为它们编写的特定插件;模块本身没有很多功能;然而,如果我们将它们与外部插件结合使用,它们就变成了我们的好朋友。甚至像 Twitter 或 Adobe 这样的公司也在详细阐述它们。
探索 Grunt
Grunt 是一个 Node.js 模块,这意味着它是通过 Node.js 软件包管理器安装的。要开始使用,我们需要安装 Grunt 的命令行工具。
npm install -g grunt-cli
Gruntfile.js file:
module.exports = function(grunt) {
grunt.initConfig({
concat:{
}
});
grunt.registerTask('default', ['concat']);
}
package.json file should look like:
{
"name": "GruntjsTest",
"version": "0.0.1",
"description": "GruntjsTest",
"dependencies": {},
"devDependencies": {
"grunt-contrib-concat": "0.3.0"
}
}
运行 npm install
后,我们将能够通过调用 grunt.loadNpmTasks
(grunt-contrib-concat
) 来请求插件。还有一个 grunt.loadTasks
方法用于自定义任务。现在,让我们继续并运行我们的第一个 Grunt 脚本。
连接文件
连接操作是最常见的操作之一。它与 CSS 样式相同。拥有许多文件意味着更多的服务器请求,这可能会降低应用程序的性能。grunt-contrib-concat
插件就是为了帮助解决这个问题。它接受源文件的 glob
模式和目标路径。它会遍历所有文件夹,找到匹配模式的文件,并将它们合并。让我们为我们的小型实验准备一个文件夹。
build/scripts.js
文件将由 Grunt 生成。因此,我们不需要创建它。向 src
文件夹中的文件添加一些内容。我们的 Gruntfile.js
文件应该包含以下代码:
module.exports = function(grunt) {
grunt.initConfig({
concat: {
javascript: {
src: 'src/**/*.js',
dest: 'build/scripts.js'
}
}
});
grunt.loadNpmTasks('grunt-contrib-concat');
grunt.registerTask('default', ['concat']);
}
concat
任务包含一个 javascript
对象,该对象包含合并的配置。源值实际上是一个 glob
模式,它匹配 src
文件夹及其子文件夹中的所有 JavaScript 文件。我们在 第六章,使用 Node.js 作为命令行工具中使用了 glob
模块。使用前面的代码,我们可以在我们的终端中运行 grunt
命令。我们将得到以下截图所示的结果:
scripts.js
文件应该生成在 build
目录中,并包含 src
文件夹中的所有文件。
非常常见的情况是我们最终会调试编译后的文件。这主要是因为它是我们在浏览器中使用的文件,所有内容都保存在一起,所以我们实际上看不到错误是从哪里开始的。在这种情况下,在每份文件的内容之前添加一些文本是很好的。这将使我们能够看到代码的原始目的地。Gruntfile.js
文件的新内容如下:
module.exports = function(grunt) {
grunt.initConfig({
concat: {
javascript: {
options: {
process: function(src, filepath) {
return '// Source: ' + filepath + '\n' + src;
}
},
src: 'src/**/*.js',
dest: 'build/scripts.js'
}
}
});
grunt.loadNpmTasks('grunt-contrib-concat');
grunt.registerTask('default', ['concat']);
}
因此,我们传递一个自定义的 process
函数。它接受文件的文本内容和其路径。它应该返回我们想要合并的代码。在我们的例子中,我们只是在顶部添加了一个简短的注释。
压缩你的代码
压缩是一个使我们的代码变得更小的过程。它使用智能算法来替换我们的变量和函数的名称。它还删除了不必要的空格和制表符。这对于优化来说非常重要,因为它通常可以将文件大小减少一半。Grunt 的插件 grunt-contrib-uglify
提供了这项功能。让我们使用上一页的示例代码,并按如下方式修改我们的 Gruntfile.js
文件:
module.exports = function(grunt) {
grunt.initConfig({
concat: {
javascript: {
options: {
process: function(src, filepath) {
return '// Source: ' + filepath + '\n' + src;
}
},
src: 'src/**/*.js',
dest: 'build/scripts.js'
}
},
uglify: {
javascript: {
files: {
'build/scripts.min.js': '<%= concat.javascript.dest %>'
}
}
}
});
grunt.loadNpmTasks('grunt-contrib-concat');
grunt.loadNpmTasks('grunt-contrib-uglify');
grunt.registerTask('default', ['concat', 'uglify']);
}
在前面的代码中,我们执行以下重要任务:
-
我们将
grunt-contrib-uglify
添加到我们的package.json
文件中 -
我们运行
npm install
以获取node_modules
目录中的模块 -
最后,我们定义了最小化的选项
在前面的代码中,我们设置了一个名为 uglify
的新任务。它的属性 files
包含我们想要执行的转换的哈希。键是目标路径,值是源文件。在我们的例子中,源文件是另一个任务的输出,这样我们就可以直接使用 <% %>
分隔符。我们能够设置确切的路径,但使用分隔符做这件事要灵活得多。这是因为我们可能会得到一个非常长的 Grunt 文件,而且总是保持代码可维护性是很好的。如果我们只有一个地方有目的地,我们就能够纠正它,而无需在其他地方重复相同的更改。
注意,我们定义的任务相互依赖,即它们应该按特定顺序运行。否则,我们将收到意外的结果。就像我们的例子中,concat
任务在 uglify
之前执行。这是因为第二个任务需要第一个任务的结果。
监视文件变化
Grunt 在为我们做一些事情方面真的很出色。然而,如果我们每次更改一些文件时都必须运行它,那就有点烦人了。让我们看看上一节的情况。我们有一堆 JavaScript 脚本,想要将它们合并到一个文件中。如果我们使用编译版本,那么每次我们修改源文件时都必须运行连接操作。在这种情况下,最好的做法是设置一个监视器——一个监视我们的文件系统并触发特定任务的作业。一个名为grunt-contrib-watch
的插件为我们做了这件事。将其添加到我们的package.json
文件中,然后再次运行npm install
以本地安装它。我们的文件只需要在配置中有一个条目。以下代码显示了新的监视属性:
module.exports = function(grunt) {
grunt.initConfig({
concat: {
javascript: {
options: {
process: function(src, filepath) {
return '// Source: ' + filepath + '\n' + src;
}
},
src: 'src/**/*.js',
dest: 'build/scripts.js'
}
},
uglify: {
javascript: {
files: {
'build/scripts.min.js': '<%= concat.javascript.dest %>'
}
}
},
watch: {
javascript: {
files: ['<%= concat.javascript.src %>'],
tasks: ['concat:javascript', 'uglify']
}
}
});
grunt.loadNpmTasks('grunt-contrib-concat');
grunt.loadNpmTasks('grunt-contrib-uglify');
grunt.loadNpmTasks('grunt-contrib-watch');
grunt.registerTask('default', ['concat', 'uglify', 'watch']);
}
在concat
和uglify
之后添加了一个watch
任务。请注意,该插件需要两个选项。第一个选项是files
,它包含我们想要监控的文件,第二个选项是tasks
,它定义了将要运行的过程。我们还在执行concat
任务的特定部分。目前,我们只有一个要连接的文件,但如果我们处理一个大型项目,我们可能有几种类型的文件,甚至可能有不同的 JavaScript 源。因此,始终指定我们的定义是很好的,特别是对于监控的glob
模式。我们真的不希望运行不必要的任务。例如,如果某些 CSS 文件已更改,我们通常不会连接 JavaScript。
如果我们使用前面代码中显示的设置并运行 Grunt,我们将看到以下截图所示的输出:
有很好的日志记录显示了确切发生了什么。所有任务都运行了,src\A.js
文件已更改。立即,concat
和uglify
插件被启动。
忽略文件
有时,我们将有一些不应该在整个过程中占用一部分的文件,例如,拥有一个 CSS 文件不应该与其他文件连接。Grunt 为这种情况提供了解决方案。比如说,我们想要跳过src/lib/D.js
中的 JavaScript。我们应该更新我们的GruntFile.js
文件并更改任务的src
属性:
concat: {
javascript: {
options: {
process: function(src, filepath) {
return '// Source: ' + filepath + '\n' + src;
}
},
src: ['src/**/*.js', '!src/lib/D.js'],
dest: 'build/scripts.js'
}
}
我们需要做的只是使用一个数组而不是一个单独的字符串。值前面的感叹号告诉 Grunt 我们想要忽略这个文件。
创建我们自己的任务
Grunt 拥有大量的插件,我们可能会找到我们想要的东西。然而,有些情况下我们需要为我们的项目定制一些东西。在这种情况下,我们需要一个自定义任务。比如说,我们需要保存编译后的 JavaScript 文件的大小。我们应该访问build/scripts.js
,检查其大小,并将其写入硬盘上的一个文件。
我们首先需要一个新的目录来存放我们的任务,如下面的截图所示:
注意custom
文件夹和jssize.js
文件。它的名称可能不会与新任务的名称匹配,但将它们保持同步是一个好的实践。在编写实际执行工作的代码之前,我们将更改我们的配置以触发任务。到目前为止,我们使用了grunt.loadNpmTasks
来指示在处理过程中将使用的模块。然而,我们的脚本不是 Node.js 的包管理的一部分,我们必须使用grunt.loadTasks
。该方法接受包含我们的文件的文件夹的路径,如下面的代码行所示:
grunt.loadNpmTasks('grunt-contrib-concat');
grunt.loadNpmTasks('grunt-contrib-uglify');
grunt.loadNpmTasks('grunt-contrib-watch');
grunt.loadTasks('custom');
custom
目录中的所有文件都将被检索并注册为有效的、可用的插件。现在我们可以将我们的jssize
任务添加到默认任务列表中,以便它与其他任务一起运行,如下所示:
grunt.registerTask('default', ['concat', 'uglify', 'jssize', 'watch']);
最后,我们将在传递给grunt.initConfig
函数的对象中添加一个新的条目,如下所示:
jssize: {
javascript: {
check: 'build/scripts.js',
dest: 'build/size.log'
}
}
由于这是我们自己的任务,我们可以传递我们认为必要的任何内容。在我们的情况下,这是我们想要获取大小的文件以及我们将保存结果的路径。
Grunt 任务实际上是一个通过接受 Grunt 的 API 对象导出函数的 Node.js 模块。以下是custom/jssize.js
文件的内容:
var fs = require('fs');
module.exports = function(grunt) {
grunt.registerMultiTask('jssize', 'Checks the JavaScript file size', function() {
var fileToCheck = this.data.check;
var destination = this.data.dest;
var stat = fs.statSync(fileToCheck);
var result = 'Filesize of ' + fileToCheck + ': ';
result += stat.size + 'bytes';
grunt.file.write(destination, result);
});
};
关键时刻是grunt.registerMultiTask
方法。第一个参数是任务的名称。这非常重要,因为在Gruntfile.js
文件中也使用了相同的字符串。紧接着,我们传递一个描述和匿名函数。该函数的主体包含完成任务的真正逻辑。我们定义的配置在this.data
对象中可用。文件大小检查是通过grunt.file
API 完成的。
生成缓存清单文件
我们发现了如何创建我们自己的 Grunt 任务。让我们写一些有趣的东西。让我们为项目生成一个缓存清单文件。
缓存清单文件是我们用来指示我们的 Web 应用程序静态资源的声明性文件。这可能是我们的 CSS 文件、图像、HTML 模板、视频文件或保持一致的东西。这是一个巨大的优化技巧,因为浏览器将不会从网络而是从用户的设备加载这些资源。如果我们需要更新已缓存的文件,我们应该更改清单。
目前,我们只有 JavaScript 文件。让我们添加一些图像和一个 CSS 文件。进行必要的更改,使我们的项目文件夹看起来如下所示:
styles.css
的内容并不重要。img
文件夹中的图像也不重要。我们只需要不同的文件来测试。接下来,我们必须将我们的任务添加到Gruntfile.js
中。我们将使用generate-manifest
作为名称,如下面的代码片段所示:
'generate-manifest': {
manifest: {
dest: 'cache.manifest',
files: [
'build/*.js',
'css/styles.css',
'img/*.*'
]
}
}
grunt.registerTask('default', ['concat', 'uglify', 'jssize', 'generate-manifest', 'watch']);
注意,我们正在传递几个 glob
模式;这些是我们想要添加的文件。在配置中描述每一个单独的文件会花费太多时间,我们可能会忘记一些东西。Grunt 有一个非常有效的 API 方法,grunt.file.expand
,它接受 glob
模式并返回匹配的文件。我们剩余的任务是组合清单文件的内容并将其保存到磁盘上。我们将注册新的任务并填充 content
变量,该变量稍后将被写入文件,如下所示:
module.exports = function(grunt) {
grunt.registerMultiTask('generate-manifest', 'Generate manifest file', function() {
var content = '',
self = this,
d = new Date();
content += 'CACHE MANIFEST\n';
content += '# created on: ' + d.toString() + '\n';
content += '# id: ' + Math.floor((Math.random()*1000000000)+1) + '\n';
var files = grunt.file.expand(this.data.files);
for(var i=0; i<files.length; i++) {
content += '/' + files[i] + '\n';
}
grunt.file.write(this.data.dest, content, {});
});
};
在自定义任务中依赖 Grunt API 是一项良好的实践。因为它保持了应用程序的一致性,因为我们只依赖于一个模块——Grunt。在前面的代码中,我们使用了 grunt.file.expand
,这是我们之前在代码中讨论过的,以及 grunt.file.write
,它将清单的内容保存到磁盘。
为了提供一个可工作的清单,缓存文件应该以 CACHE MANIFEST
开头。这就是我们为什么在开头添加它的原因。同时,在生成日期上包含也是一项良好的实践。随机生成的 id
简化了应用程序开发的过程。
如前所述,浏览器将提供文件的缓存版本,直到缓存清单文件被更改。每次设置不同的 id
都会迫使浏览器获取文件的最新版本。然而,在生产环境中,这应该被移除。要使用缓存清单文件,请在我们的 HTML 页面中添加一个特殊属性,如下所示:
<html manifest="cache.appcache">
如果一切顺利,我们应该看到如下截图所示的结果:
因此,缓存清单的内容如下:
CACHE MANIFEST
# created on: Fri Feb 14 2014 23:40:46 GMT+0200 (FLE Standard Time)
# id: 585038007
/build/scripts.js
/build/scripts.min.js
/css/styles.css
/img/A.png
/img/B.png
/img/C.png
记录我们的代码
我们知道代码应该有文档。但很多时候,这会花费太多时间且很枯燥。有一些好的实践我们可以使用。其中之一是将注释写入代码,并使用这些注释生成文档。遵循这种方法,我们应该使我们的代码更容易被同事理解。Grunt 插件 grunt-contrib-yuidoc
将帮助我们创建 .doc
文件。将其添加到我们的 package.json
并运行 npm install
。再次,我们只需要更新我们的 Gruntfile.js
文件。
yuidoc: {
compile: {
name: 'Project',
description: 'Description',
options: {
paths: 'src/',
outdir: 'docs/'
}
}
}
...
grunt.registerTask('default', ['concat', 'uglify', 'jssize', 'generate-manifest', 'yuidoc', 'watch']);
有一个 paths
属性显示源代码,以及一个 outdir
属性显示文档将被保存的位置。如果我们运行 Grunt 并导航到我们的目录,我们会看到没有任何列表。这是因为我们没有在代码中添加任何注释。打开 src/A.js
并放置以下代码:
/**
* This is the description for my class.
*
* @class A
*/
var A = {
/**
* My method description. Like other pieces of your comment blocks,
* this can span multiple lines.
*
* @method method
*/
method: function() {
}
};
重新启动任务后,我们将在文档中看到 A 类,如下面的截图所示:
发现 Gulp
Gulp 是一个已经相当流行的构建系统。它与 Grunt 几乎是相同的概念。我们能够创建为我们做些事情的任务。当然,有很多插件。实际上,大多数主要的 Grunt 插件在 Gulp 中都有等效的插件。然而,也有一些差异,以下将提到这些差异。
-
存在一个配置文件,但它被称为
gulpfile.js
-
Gulp 使用流来处理文件,这意味着它不会创建任何临时文件或文件夹。这可能会导致任务运行器的性能更好。
-
Gulp 遵循
code-over-configuration
原则,也就是说,当我们设置 Gulp 任务时,过程更像是在编码而不是在编写配置。这使得 Gulp 对开发者来说更加友好。
安装 Gulp 和获取插件
与 Grunt 一样,Gulp 可在 Node.js 的包管理器中找到。
npm install -g gulp
上述命令行将全局设置任务运行器。一旦安装完成,我们就可以运行 gulp
命令。当然,我们应该在包含 gulpfile.js
文件的目录中执行此操作。
Gulp 的插件也是 Node.js 模块。例如,gulp-concat
与 grunt-contrib-concat
相同,而 gulp-uglify
是 grunt-contrib-uglify
的替代品。在 package.json
文件中描述它们是一个好习惯。没有像 grunt.loadNpmTasks
这样的函数。我们可以直接引入模块。
使用 Gulp 进行连接和压缩
让我们使用我们已有的代码。src
文件夹中有一系列 JavaScript 文件,我们希望将它们连接起来。任务运行器还应生成一个压缩版本并监视文件的变化。我们需要几个模块,以下是我们的 package.json
文件看起来像:
{
"name": "GulpTest",
"version": "0.0.1",
"description": "GulpTest",
"dependencies": {},
"devDependencies": {
"gulp": "3.5.2",
"gulp-concat": "2.1.7",
"gulp-uglify": "0.2.0",
"gulp-rename": "1.0.0"
}
}
需要 gulp
命令是因为我们需要访问 Gulp 的 API。gulp-concat
插件将连接文件,而 gulp-uglify
将压缩结果。使用 gulp-rename
插件是因为我们必须提供两个文件——一个适合阅读的文件和一个压缩的文件,即 build/scripts.js
和 build/scripts.min.js
。
以下代码是 gulpfile.js
文件的内容:
var gulp = require('gulp');
var concat = require('gulp-concat');
var uglify = require('gulp-uglify');
var rename = require('gulp-rename');
gulp.task('js', function() {
gulp.src('./src/**/*.js')
.pipe(concat('scripts.js'))
.pipe(gulp.dest('./build/'))
.pipe(rename({suffix: '.min'}))
.pipe(uglify())
.pipe(gulp.dest('./build/'))
});
gulp.task('watchers', function() {
gulp.watch('src/**/*.js', ['js']);
});
gulp.task('default', ['js', 'watchers']);
使用 Grunt 时,我们需要对任务运行器和其配置结构有一些更深入的了解。而使用 Gulp,情况则略有不同。我们通常使用 Node.js 模块以及它们公共 API 的使用。脚本从插件定义和 gulp
对象的定义开始。一个任务是通过使用 gulp.task
方法定义的。第一个参数是任务的名称,第二个参数是一个函数。此外,我们也可以传递一个表示其他任务的字符串数组来代替函数。
类似地,就像在 Grunt 中一样,我们有一个default
入口。这次,我们将任务分为两部分:JavaScript 操作和监视器。几乎每个 Gulp 任务都以gulp.src
开始,以gulp.dest
结束。第一个方法接受glob
模式,显示需要转换的文件。gulp.dest
插件将结果保存到期望的位置。它们之间的所有动作实际上都是接收和输出流的模块。在我们的例子中,js
任务从src
目录及其子文件夹中获取所有文件,将它们连接起来,并将结果保存到build
文件夹。我们继续通过重命名文件、压缩它,并将它保存在同一位置。在项目文件夹中运行gulp
后,我们的终端输出应该如下所示:
当然,我们应该在构建目录中查看scripts.js
和scripts.min.js
文件。
创建自己的 Gulp 插件
Gulp 插件的开发几乎与创建 Grunt 插件相同。我们需要一个新的 Node.js 模块,具有适当的 API。区别在于我们接收一个流,然后应该输出这个流。这可能会有一点难以编码,因为我们需要理解流是如何工作的。幸运的是,有一个辅助包可以简化这个过程。我们将使用through2
——Node.js 流 API 的一个小型包装器。因此,我们的package.json
文件随着以下内容略有增长:
{
"name": "GulpTest",
"version": "0.0.1",
"description": "GulpTest",
"dependencies": {},
"devDependencies": {
"gulp": "3.5.2",
"gulp-concat": "2.1.7",
"gulp-uglify": "0.2.0",
"gulp-rename": "1.0.0",
"through2": "0.4.1"
}
}
让我们创建相同的jssize
任务。它只需要做一项工作:测量连接文件的文件大小。我们可以重新创建custom
目录,并在其中放置一个空的jssize.js
文件。我们的 Gulp 文件还需要进行快速修正。在顶部,我们按照以下方式引入新创建的模块:
var jssize = require('./custom/jssize');
我们必须将第一个gulp.dest('./build/')
命令的输出通过管道传输到jssize
插件。以下片段显示了完成的任务:
gulp.task('js', function() {
gulp.src('./src/**/*.js')
.pipe(concat('scripts.js'))
.pipe(gulp.dest('./build/'))
.pipe(jssize())
.pipe(rename({suffix: '.min'}))
.pipe(uglify())
.pipe(gulp.dest('./build/'));
});
现在,让我们看看以下代码中我们的插件看起来如何:
var through2 = require('through2');
var path = require('path');
var fs = require("fs");
module.exports = function () {
function transform (file, enc, next) {
var stat = fs.statSync(file.path);
var result = 'Filesize of ' + path.basename(file.path) + ': ';
result += stat.size + 'bytes';
fs.writeFileSync(__dirname + '/../build/size.log', result);
this.push(file);
next();
}
return through2.obj(transform);
};
through2.obj
对象返回一个用于 Gulp 管道的流。处理流就像处理块一样。换句话说,我们不会一次性收到整个文件,而是反复收到文件的部分,直到我们得到全部数据。through2
对象简化了过程,并直接给我们提供了对整个文件的访问。因此,transform
方法接受文件、其编码以及一个我们需要在完成工作后调用的函数。否则,链将被停止,下一个插件将无法完成其任务。生成size.log
文件的实际代码与 Grunt 版本中使用的相同。
摘要
在本章中,我们学习了如何使用任务运行器。这些工具通过简化常见任务使我们的生活变得更轻松。作为网页开发者,我们可能希望将生产代码连接和压缩,而像 Grunt 和 Gulp 这样的模块可以很好地处理这些琐碎的操作。广泛的插件和强大的 Node.js 社区鼓励使用任务运行器,并彻底改变了我们的工作流程。
在下一章中,我们将深入探讨测试驱动开发,并了解 Node.js 如何处理这些过程。
第九章:使用 Node.js 自动化测试
在上一章中,我们学习了如何使用 Grunt 和 Gulp 自动化我们的开发过程。这两个 Node.js 模块拥有大量的插件,我们几乎可以在任何情况下使用它们。在本章中,我们将讨论测试的重要性以及如何将其集成到我们的工作流程中。以下是我们将要涵盖的主题列表:
-
流行测试方法
-
Jasmine 框架
-
Mocha 框架
-
使用 PhantomJS 和 DalekJS 进行测试
理解编写测试的重要性
在开发软件时,我们编写的代码可以放入浏览器中运行,作为桌面程序运行,或者作为 Node.js 脚本启动。在这些所有情况下,我们都期望得到特定的结果。每一行代码都有其重要性,我们需要知道最终产品是否能够完成工作。通常,我们会调试我们的应用程序,也就是说,我们编写程序的一部分并运行它。通过监控输出或其行为,我们评估一切是否正常,或者是否存在问题。然而,这种方法很耗时,尤其是如果项目很大。对应用程序的每个功能进行迭代需要花费大量的时间和金钱。自动测试在这种情况下很有帮助。从架构的角度来看,测试非常重要。这是因为当系统复杂,模块之间存在众多关系时,添加新功能或引入重大更改就变得困难。
我们实际上无法保证修改后一切都会像以前一样工作。因此,与其依赖手动测试,不如创建可以为我们完成这项工作的脚本。编写测试有以下几个主要好处:
-
这证明了我们的软件是稳定的,并且按预期工作。
-
这可以节省大量时间,因为我们不必反复进行手动测试。
-
代码编写得不好,有很多依赖关系,很难进行测试。在这些大多数情况下编写测试会导致更好的代码。
-
如果我们有一个稳固的测试套件,我们就可以在不用担心损坏任何东西的情况下扩展系统。
-
如果测试覆盖了应用程序的所有功能,那么它们可以用作应用程序的文档。
选择测试方法
存在少数流行的编写测试的方法。让我们看看它们是什么,以及它们之间的区别。
测试驱动开发
测试驱动开发(TDD)是一个依赖于重复短周期开发过程的过程。换句话说,我们在编写实现的同时编写测试。周期越短,越好。以下图表显示了 TDD 流程:
在我们编写实际为我们完成工作的代码之前,我们需要准备一个测试。当然,在第一次运行后,测试将失败,因为没有实现任何内容。因此,我们需要确保测试通过所有循环。一旦发生这种情况,我们可能会花一些时间重构到目前为止所做的工作,并继续进行下一个方法、类或功能。请注意,一切围绕着测试旋转,这真的是一件好事,因为这是我们定义代码应该做什么的地方。有了这个基础,我们可以避免交付不必要的代码。我们还可以确保实现符合要求。
行为驱动开发
行为驱动开发(BDD)与 TDD 类似。事实上,如果项目很小,我们实际上无法发现它们之间的区别。这种方法的理念是更多地关注规范和应用程序的过程,而不是实际的代码。例如,如果我们用 TDD 测试一个在 Twitter 上发布消息的模块,我们可能会问以下问题:
-
消息是否为空?
-
消息长度是否小于 140 个符号?
-
Ajax 请求是否正确执行?
-
返回的 JSON 是否包含某些字段?
然而,使用 BDD,我们只问以下问题:
- 消息是否已发送到 Twitter?
这两个过程是相互关联的,正如我们所说的,有时它们之间根本没有任何区别。我们应该记住的是,BDD 关注代码正在做什么,而 TDD 关注代码是如何做的。
测试分类
你可能需要编写几种不同的测试,这些测试通过提供输入并期望特定输出来评估我们的系统。然而,它们也会在不同的部分进行这种评估。了解它们的名称是很有帮助的,如下列所示:
-
单元测试:单元测试对应用程序的单个部分进行检查;它关注一个单元。我们经常在编写此类测试时遇到困难,因为我们无法将代码拆分成单元;这通常是一个坏信号。如果没有明确定义的模块,我们就无法进行此类测试。将逻辑分配到不同的单元不仅有助于测试,还有助于程序的总体稳定性。让我们用以下图表来说明这个问题:
让我们假设我们有一个电子商务网站,该网站向我们的用户销售产品。在上面的图表中,登录、订购和注销等流程由一个类处理,该类在
App.js
文件中定义。是的,它工作。我们可能达到目标并成功完成循环,但这绝对不是可单元测试的,因为没有单元。如果我们将责任分割到不同的类中,会更好,如下面的图表所示:我们继续使用
App.js
,它仍然控制一切。然而,整个流程的不同部分被分配到三个类中:Router
、Users
和Payments
。现在,我们能够编写单元测试。 -
集成测试:集成测试为多个单元或组件输出结果。如果我们看前面的例子,集成测试将模拟整个订购产品的过程,即登录、购买和登出。通常,集成测试会使用系统的几个模块,并确保这些模块能正确协同工作。
-
功能测试:功能测试与集成测试密切相关,并关注系统中的特定功能。它可能涉及多个模块或组件。
-
系统测试:系统测试在不同的环境中测试我们的程序。在 Node.js 的上下文中,这可能是在不同的操作系统上运行我们的脚本并监控输出时。有时会有差异,如果我们想全球分布我们的工作,我们需要确保我们的程序与最流行的系统兼容。
-
压力或性能测试:这些测试评估我们的应用程序超出定义的规范,并显示我们的代码对高流量或复杂查询的反应。当决定程序的架构或选择框架时,它们非常有帮助。
有一些其他类型的测试,但前面提到的测试方法是最受欢迎的。没有关于要编写哪些测试的严格政策。当然,有良好的实践,但我们应该关注的是编写可测试的代码。没有任何事情比一个完全被测试覆盖的应用程序更好。
由于测试是开发过程中的一个非常重要的部分,有一些框架专门针对编写测试。通常,当我们使用框架时,我们需要以下两个工具:
-
测试运行器:这是框架的一部分,用于运行我们的测试并显示它们是否通过或失败。
-
断言:这些方法用于实际的检查,也就是说,如果我们需要检查一个变量是否为
true
,我们可以写expect(active).toBe(true)
而不是仅仅if(active === true)
。这对读者更好,也能防止一些奇怪的情况;例如,如果我们想检查一个变量是否已定义,下面代码中的if
语句返回true
,因为status
变量有一个值,而这个值是null
。实际上,我们是在询问status
变量是否已初始化,如果我们以这种方式留下测试,我们将得到错误的结果。这就是为什么我们需要一个具有适当测试方法的断言库。以下代码是显示status
变量实际上已定义且其类型为object
的示例:var status = null; if(typeof status != "undefined") { console.log("status is defined"); } else { console.log("status is not defined"); }
使用 Jasmine
Jasmine 是一个用于测试 JavaScript 代码的框架。它作为 Node.js 模块和库提供,我们可以在浏览器中使用它。它自带断言方法。
安装 Jasmine
我们将使用框架的 Node.js 版本。它是一个模块,因此可以通过 Node.js 包管理器npm
进行安装,如下面的代码行所示:
npm install jasmine-node -g
上述命令将在全局设置 Jasmine,因此我们可以在我们选择的每个目录中运行jasmine-node
。测试可以组织到放在一个文件夹或子文件夹中的不同文件中。唯一的要求是文件名以spec.js
结尾,例如testing-payments.spec.js
或testing-authorization.spec.js
。
定义测试模块
在我们编写实际测试之前,让我们定义我们想要构建的应用程序。假设我们需要一个 Node.js 模块,它可以读取文件并找到其中的特定单词。以下是我们开始的基本文件结构:
测试应用程序的代码将放在tests/test.spec.js
中,逻辑的实现将在app.js
中,我们将从中读取的文件将是file.txt
。让我们打开file.txt
文件并在其中添加以下文本:
The quick brown fox jumps over the lazy dog.
这是一个用于测试打字机键的短语。它包含英语字母表中的所有字母,非常适合我们的小型项目。
遵循测试驱动开发的概念
任务很简单,我们可能只需要大约 20 行代码就能解决。当然,我们可以将所有代码封装在一个函数中并执行所有操作。缺点是如果出现问题,我们无法检测问题发生的位置。这就是为什么我们将逻辑分成两部分,并以下述方式分别测试它们:
-
读取文件内容
-
在文件内容中搜索特定单词
正如我们在本章开头所解释的,我们将先编写测试,然后观察它失败,接着编写app.js
的代码。
测试文件读取过程
编写测试,就像任何其他任务一样,可能会具有挑战性。有时,我们无法确定要测试什么以及要排除什么。存在一条未明说的规则,建议用户避免开发其他开发者已测试的功能——在我们的例子中,我们不需要测试文件是否成功读取。如果我们这样做,它将看起来像我们在测试 Node.js 的文件系统 API,这是不必要的。
每个用 Jasmine 编写的测试都以describe
子句开始。将以下代码添加到tests/test.spec.js
中:
describe("Testing the reading of the file's content.", function() {
// ...
});
我们正在添加有意义的信息,告诉我们将要测试什么。it
的第二个参数再次是一个函数。不同之处在于它接受一个参数,即另一个函数。我们在完成检查后需要调用它。许多 JavaScript 脚本都是异步的,而done
回调帮助我们处理此类操作。
上述代码块包括app.js
模块并验证结果。expect
方法接受一个断言的主题,接下来的链式方法执行实际的检查。
我们已经有了测试,所以我们可以执行它。运行jasmine-node ./tests
,你将看到以下结果:
测试用例通过了。app.js
文件是空的,但即使如此,Node.js 也不会失败。app
变量的值实际上是一个空对象。让我们继续,并尝试想象我们需要的方法。在以下代码中,我们添加了一个额外的代码块来测试模块的read
API 方法:
describe("Testing the reading of the file's content.", function() {
it("should create an instance of app.js", function(done) {
var app = require("../app.js");
expect(app).toBeDefined();
done();
});
it("should read the file", function(done) {
var app = require("../app.js");
var content = app.read("./file.txt");
expect(content).toBe("The quick brown fox jumps over the lazy dog.");
done();
});
});
第一个it
运行良好,但第二个引发了一个错误。这是因为app.js
中没有内容。我们没有在那里定义read
方法。错误显示在下述屏幕截图:
注意,我们可以清楚地看到出了什么问题。如果有人出于某种原因删除或重命名了使用的方法,这个测试将失败。即使函数存在,我们也期望看到特定的结果来验证模块的工作。
现在,我们必须开始编写应用程序的实际代码。我们应该使测试通过。将以下代码放在app.js
中:
module.exports = {
read: function(filePath) {
}
}
如果我们运行测试,它将失败,但原因不同,这是因为read
方法中没有逻辑。以下屏幕截图是控制台的结果:
这次read
方法被定义了,但它没有返回任何内容,expect(content).toBe("The quick brown fox jumps over the lazy dog.")
失败了。让我们使用 Node.js 文件 API 读取file.txt
并返回其内容:
var fs = require('fs');
module.exports = {
read: function(filePath) {
return fs.readFileSync(filePath).toString();
}
}
现在,测试的颜色是绿色,这表明模块有我们使用的方法,并且该方法返回了我们期望的结果,如以下屏幕截图所示:
在文件内容中查找字符串
通过使用相同的方法,我们将实现应用程序的第二部分:在文件中查找单词。以下是我们将开始的新的describe
代码块:
describe("Testing if the file contains certain words", function() {
it("should contains 'brown'", function(done) {
var app = require("../app.js");
var found = app.check("brown", "The quick brown fox jumps over the lazy dog.");
expect(found).toBe(true);
done();
});
});
我们需要一个接受两个参数的check
方法。第一个是我们想要查找的单词,第二个是包含它的字符串。请注意,我们没有使用read
方法。我们的想法是单独测试该函数,并确保它正常工作。这是一个非常重要的步骤,因为它使我们的check
方法通用。它不受匹配文件内部文本的想法的限制;然而,它确实匹配字符串内部的文本。如果我们不使用测试驱动的工作流程,我们可能会得到一个同时执行这两个操作的功能:读取文件和扫描其内容。然而,在我们的情况下,我们可以使用从数据库或通过 HTTP 请求获取的文本的相同模块。而且,如果我们发现我们的模块没有找到特定的单词,我们将知道问题出在check
函数上,因为它被单独作为单元测试。
以下是新方法的代码:
var fs = require('fs');
module.exports = {
read: function(filePath) {
return fs.readFileSync(filePath).toString();
},
check: function(word, content) {
return content.indexOf(word) >= 0 ? true : false;
}
}
如下截图所示,测试现在通过了三个断言:
编写集成测试
我们迄今为止编写的测试是单元测试,即它们测试了我们应用程序的两个单元。现在,让我们添加一个集成测试。同样,我们需要一个失败的测试来使用该模块。因此,我们开始于以下代码:
describe("Testing the whole module", function() {
it("read the file and search for 'lazy'", function(done) {
var app = require("../app.js");
app.read("./file.txt")
expect(app.check("lazy")).toBe(true);
done();
});
});
注意,我们并没有将文件内容保存在一个临时变量中,也没有将其传递给 check
方法。实际上,我们对文件的实际内容不感兴趣。我们只关心它是否包含特定的字符串。因此,我们的模块应该处理这种情况并保留其中的文本。前面的测试失败,并显示以下消息:
以下是我们使 app.js
按照预期工作的所需更改:
var fs = require('fs');
module.exports = {
fileContent: '',
read: function(filePath) {
var content = fs.readFileSync(filePath).toString();
this.fileContent = content;
return content;
},
check: function(word, content) {
content = content || this.fileContent;
return content.indexOf(word) >= 0 ? true : false;
}
}
我们将简单地将在一个名为 fileContent
的局部变量中存储文本。请注意,我们正在谨慎地做出更改并保留 read
方法的返回逻辑。这是必需的,因为有一个测试需要这个功能。这显示了 TDD 的另一个好处。我们确保在包含我们的修改之前,代码以原始形式工作。在复杂系统或应用程序中,这一点非常重要,没有测试,这将非常难以实现。最终结果又是一个带有绿色消息的截图:
使用 Mocha 进行测试
Mocha 是一个比 Jasmine 更先进的测试框架。它更可配置,支持 TDD 或 BDD 测试,并且甚至有几种类型的报告器。它也非常受欢迎,适用于在浏览器中进行客户端使用,这使得它成为我们测试的一个很好的候选者。
安装
与 Jasmine 类似,我们需要 Node.js 的包管理器来安装 Mocha。通过运行以下命令,框架将被全局设置:
npm install -g mocha
安装完成后,我们可以运行 mocha ./tests
。默认情况下,该工具会搜索 JavaScript 文件并尝试运行它们。这里,让我们使用与 Jasmine 相同的示例并通过 Mocha 运行它。实际上,它使用相同的 describe
和 it
块语法。然而,它没有自己的断言库。实际上,有一个名为 assert
的内置 Node.js 模块用于此类目的。还有其他开发者开发的库,例如 should.js
、chai
或 expect.js
。
它们在某些方面有所不同,但执行相同的任务:检查实际值和预期值,如果它们不匹配则抛出错误。之后,框架捕获错误并显示结果。
使用 Mocha 翻译我们的示例
一切都相同,只是将 expect
模块调用替换为 assert.equal
。我们使用了 assert.fail
来通知框架存在问题。以下是一些其他的 describe
块:
describe("Testing if the file contains certain words", function() {
it("should contains 'brown'", function(done) {
var app = require("../app.js");
var found = app.check("brown", "The quick brown fox jumps over the lazy dog.");
assert.equal(found, true);
done();
});
});
describe("Testing the whole module", function() {
it("read the file and search for 'lazy'", function(done) {
var app = require("../app.js");
app.read("./file.txt")
assert.equal(app.check("lazy"), true);
done();
});
});
随着最新的更改,测试应该通过,我们应该看到以下截图:
选择报告器
当我们谈到报告器时,Mocha 非常灵活。报告器是框架中显示结果的那个部分。我们有几十种选项可以选择。为了设置报告器的类型,我们应该在命令行中使用-R
选项,例如,与 Jasmine 的报告器最接近的是dot
类型,如下面的截图所示:
为了查看有关通过或失败的测试的更详细的信息,我们可以使用spec
报告器,如下面的截图所示:
也有一个看起来像着陆飞机(landing
类型)的报告器,如下面的截图所示:
使用无头浏览器进行测试
到目前为止,我们学习了如何测试我们的代码。我们可以编写一个模块、类或库,如果它有一个 API,我们就可以测试它。然而,如果我们需要测试用户界面,这会变得有点复杂。像 Jasmine 和 Mocha 这样的框架可以运行我们编写的代码,但不能访问页面、点击按钮或发送表单;至少,不能单独完成。对于这种测试,我们需要使用无头浏览器。无头浏览器是一个没有用户界面的网络浏览器。有一种方法可以程序化地控制它并执行诸如访问 DOM 元素、点击链接和填写表单等操作。我们能够做与使用真实浏览器相同的事情。这为我们提供了一个真正不错的工具来测试用户界面。在接下来的几页中,我们将看到如何使用无头浏览器。
编写我们的测试主题
为了探索这种测试的可能性,我们需要一个简单的网站。让我们创建两个页面。第一个将包含一个输入字段和一个按钮。当第一个页面上的按钮被点击时,将访问第二个页面。页面的h1
标签标题将根据字段中写入的文本而改变。创建一个新的目录,并在app.js
文件中插入以下代码:
var http = require('http');
var url = require('url');
var port = 3000;
var pageA = '\
<h1>First page</h1>\
<form>\
<input type="text" name="title" />\
<input type="submit" />\
</form>\
';
var pageB = '\
<h1>{title}</h1>\
<a href="/">back</a>\
';
http.createServer(function (req, res) {
var urlParts = url.parse(req.url, true);
var query = urlParts.query;
res.writeHead(200, {'Content-Type': 'text/html'});
if(query.title) {
res.end(pageB.replace('{title}', query.title));
} else {
res.end(pageA);
}
}).listen(port, '127.0.0.1');
console.log('Server running at http://127.0.0.1:' + port);
我们只需要两个 Node.js 原生模块来启动我们的服务器。http
模块运行服务器,而url
模块从 URL 获取GET
参数。页面的标记存储在简单的变量中。在 HTTP 请求的处理程序中有一个检查,如果pageA
上的表单被提交,则服务pageB
。如果我们用node app.js
运行服务器,我们将看到页面看起来如何,如下面的截图所示:
注意,在文本字段中输入的文本被设置为第二页的标题。我们还有一个返回按钮可以用来返回主页。我们有一个主题要运行我们的测试。我们将定义我们需要验证的操作如下:
-
页面是否正确渲染?我们应该检查
pageA
的标签是否实际上在页面上。 -
我们应该在文本字段中添加一些字符串并提交表单。
-
新加载的页面的标题应该与我们输入的文本相匹配。
-
我们应该能够点击 后退 按钮并返回主页。
使用 PhantomJS 进行测试
我们知道我们的应用程序应该如何工作,所以让我们编写测试。我们将使用的无头浏览器是 PhantomJS。访问 phantomjs.org
并下载适合您操作系统的包。就像我们对 Node.js 所做的那样,我们将编写我们的测试在 JavaScript 文件中,并在命令行中运行它。假设我们的文件结构如下所示:
请记住,PhantomJS 不是一个 Node.js 模块。我们为 PhantomJS 编写的 JavaScript 代码并不完全等同于有效的 Node.js 代码。我们不能直接使用原生模块,例如 assert
。此外,它没有集成测试运行器或测试框架。它是一个基于 Webkit 的浏览器,但可以通过命令行或代码进行控制。它看起来像是二进制的,一旦安装,我们就能在我们的终端中运行 phantom ./tests/phantom.js
命令。测试代码将打开 http://127.0.0.1:3000
并与那里的页面进行交互。当然,JavaScript 社区开发了工具,可以将测试框架如 Jasmine 或 Mocha 与 PhantomJS 结合使用,但我们在本章中不会使用它们。我们将编写自己的小型实用工具——这就是 framework.js
文件的作用。
开发微型测试框架
最终结果应该是一个简单的函数,可以直接使用,例如 Jasmine 中的 describe
或 it
。它还应该有一个类似于断言库的东西,这样我们就不必使用常规的 if-else
语句或手动报告失败的测试。在下面的代码中,我们可以看到正确的实现:
var test = function(description, callback) {
console.log(description);
callback(function(subject) {
return {
toBe: function(value) {
if(subject !== value) {
console.log("! Expect '" + subject + "' to be '" + value + "'.")
}
},
toBeDefined: function() {
if(typeof subject === 'undefined') {
console.log("! Expect '" + subject + "' to be defined")
}
}
}
});
}
该函数接受描述和函数。第一个参数只是打印到控制台,这表明我们将要测试什么。紧接着,我们调用传递的 callback
函数,并使用另一个函数作为参数,该函数充当断言库的角色。它接受测试的主题,并对其执行两个方法:toBe
和 toBeDefined
。以下是一个简单的用法:
test("make a simple test", function(expect) {
var variable = { property: 'value' };
expect(true).toBe(true);
expect(1).toBe(0);
expect(variable.property).toBeDefined()
expect(variable.missing).toBeDefined()
});
如果我们运行前面的代码,结果将如以下截图所示:
理解 PhantomJS 的工作原理
PhantomJS 接受用 JavaScript 编写的指令。我们可以将它们保存到文件中,并通过使用 phantom
命令在命令行中执行。让我们看看下面的代码片段:
var page = require('webpage').create();
var url = 'http://127.0.0.1:3000';
page.onConsoleMessage = function(msg) {
// ...
};
page.onLoadFinished = function(status) {
// ...
};
page.open(url);
page
变量是对 PhantomJS API 的访问。有一个 open
方法,它加载一个新的页面。我们主要对来自无头浏览器的两个事件感兴趣。第一个事件是 onConsoleMessage
,当加载的页面使用 console
命令时触发,例如 console.log
或 console.error
。第二个事件 onLoadFinished
也相当重要。我们有一个在页面加载时被调用的函数。这就是我们应该放置测试的地方。除了监听事件外,我们还将使用以下两个其他方法:
-
injectJs
:此方法需要我们硬盘上文件的路径。传递的文件被包含在页面中。我们还可以使用includeJs
,它执行相同的功能,但它从外部源加载文件。 -
Evaluate
:此方法接受一个在当前加载的页面上下文中执行的功能。这很重要,因为我们需要检查某些元素是否在 DOM 树中。我们需要通过填写文本字段和点击按钮来与之交互。
编写实际测试
在我们开始使用 PhantomJS 之前,我们需要用 node ./app.js
运行我们的应用程序。这样做,我们正在运行一个监听特定端口的服务器。PhantomJS 将向该服务器发送请求。现在,让我们按照以下方式开始填写 tests/phantom.js
文件:
var page = require('webpage').create();
var url = 'http://127.0.0.1:3000';
page.onConsoleMessage = function(msg) {
console.log("\t" + msg);
};
page.onLoadFinished = function(status) {
console.log("phantom: load finished");
page.injectJs('./framework.js');
phantom.exit();
};
page.open(url);
正如我们已经讨论过的,我们能够创建一个 page
变量并打开特定的 URL。在我们的例子中,我们使用测试应用程序的地址。onConsoleMessage
监听器只是将消息打印到我们的终端。当页面加载时,我们注入我们的微单元测试框架。这意味着我们能够在页面上下文中调用 test
函数。如果我们用 phantom ./tests/phantom.js
运行脚本,我们将得到以下结果:
前面的截图显示了确切应该发生的事情。浏览器访问页面并触发 onLoadFinished
。调用 phantom.exit()
很重要;否则,PhantomJS 的进程将保持活跃。
framework.js
文件被注入到页面中,我们可以编写第一个测试,即检查标题是否包含第一页,填写测试字段,并提交表单:
page.onLoadFinished = function(status) {
console.log("phantom: load finished");
page.injectJs('./framework.js');
page.evaluate(function() {
test("should open the first page", function(expect) {
expect(document).toBeDefined();
expect(document.querySelector('h1').innerHTML).toBe('First page');
document.querySelector('input[type="text"]').value = 'Phantom test';
document.querySelector('form').submit();
});
});
phantom.exit();
};
由 evaluate
方法执行的功能是在页面上下文中运行的,因此它能够访问通常的文档对象。我们能够使用 getElementById
、querySelector
或 submit
方法。现在获得的脚本结果如下截图所示:
现在变得有趣了。确实,表单已经提交,但我们立即调用了 phantom.exit()
,这终止了我们的脚本。如果我们去掉它,浏览器将保持活跃状态,并且 onLoadFinished
事件将再次触发,因为新页面已成功加载。然而,脚本失败,因为下一页上没有文本框或 form
元素。我们需要评估另一个函数。以下是一个可能的解决方案:
var steps = [
function() {
test("should open the first page", function(expect) {
expect(document).toBeDefined();
expect(document.querySelector('h1').innerHTML).toBe('First page');
document.querySelector('input[type="text"]').value = 'Phantom test';
document.querySelector('form').submit();
});
},
function() {
test("should land on the second page", function(expect) {
expect(document).toBeDefined();
expect(document.querySelector('h1').innerHTML).toBe('Phantom test');
var link = document.querySelector('a');
var event = document.createEvent('MouseEvents');
event.initMouseEvent('click', true, true, window, 1, 0, 0);
link.dispatchEvent(event);
});
},
function() {
test("should return to the home page", function(expect) {
expect(document.querySelector('h1').innerHTML).toBe('First page');
});
}
];
page.onLoadFinished = function(status) {
console.log("phantom: load finished");
page.injectJs('./framework.js');
page.evaluate(steps.shift());
if(steps.length == 0) {
console.log("phantom: browser terminated");
phantom.exit();
}
};
steps
数组是一个全局变量,它包含了一系列需要评估的函数。在每次 onLoadFinished
事件发生时,我们会获取这些函数中的一个,直到 steps
数组为空。这就是我们调用 phantom.exit()
的地方,如下面的截图所示:
PhantomJS 打开主页。它在文本框中输入 Phantom 测试 并提交表单。然后,在下一页,它检查标题是否包含有效值,当你点击 返回链接 按钮时,它会再次加载上一页。
使用 DalekJS 进行测试
到目前为止,我们学习了如何测试我们的 JavaScript 代码。之后,我们发现了如何使用 Phantom.js 编写用户界面测试。所有这些都非常有用,但如果我们能运行一个真实的浏览器并控制它,那就更好了。使用 DalekJS 就可以实现这一点。这是一个非常棒的 Node.js 模块,它附带了一个命令行界面工具和针对主要浏览器(如 Google Chrome、Firefox 和 Internet Explorer)的子模块。
让我们看看一切是如何工作的,并使用以下命令安装 DalekJS 的命令行工具:
npm install -g dalek-cli
package.json file looks:
{
"name": "project",
"description": "description",
"version": "0.0.1",
"devDependencies": {
"dalekjs": "*",
"dalek-browser-chrome": "*"
}
}
一个快速的 npm install
命令将创建一个包含所有依赖项的 node_modules
目录。DalekJS 在 dalekjs.com
上有详细的文档。它指出我们可以加载页面、填写表单并点击不同的 DOM 元素。它还附带了自己的测试 API,因此我们不必担心这一点。我们必须编写的测试实际上非常简短。以下 tests/dalek.js
的内容:
var url = 'http://127.0.0.1:3000';
var title = 'DalekJS test';
module.exports = {
'should interact with the application': function (test) {
test
.open(url)
.assert.text('h1', 'First page', 'The title is "First page"')
.type('input[type="text"]', title)
.submit('form')
.assert.text('h1', title, 'The title is "' + title + '"')
.click('a')
.assert.text('h1', 'First page', 'We are again on the home page')
.done()
}
};
再次,我们将向 http://127.0.0.1:3000
发送请求,并期望在页面上看到某些元素。我们还会在文本框中输入一些文本(使用 type
方法)并提交表单(使用 submit
方法)。要运行测试,我们需要输入以下命令:
dalek .\tests\dalek.js -b chrome
如果我们省略 -b
参数,DalekJS 将使用 Phantom.js。这是库的默认浏览器类型。当在终端中启动前面的命令时,将打开一个新的 Google Chrome 浏览器实例。它执行我们在测试中定义的内容,然后关闭浏览器。为了使示例工作,我们需要通过执行 node ./app.js
来运行应用程序。结果将报告到控制台,如下面的截图所示:
我们甚至可以截取当前浏览器的截图。这只需要简单地调用 screenshot
API 方法,如下面的代码片段所示:
test
.open(url)
.assert.text('h1', 'First page', 'The title is "First page"')
.type('input[type="text"]', title)
.submit('form')
.assert.text('h1', title, 'The title is "' + title + '"')
.screenshot('./screen.jpg')
.click('a')
.assert.text('h1', 'First page', 'We are again on the home page')
.done()
在前面的代码中,我们正在截取第二页的屏幕截图,即表单提交后加载的页面。
摘要
在本章中,我们看到了测试的重要性。幸运的是,Node.js 生态系统中提供了许多优秀的工具。例如,Jasmine 和 Mocha 这样的框架使我们的工作更加轻松。像 Phantom.js 这样的工具通过自动化测试并将我们的代码置于浏览器环境中,节省了大量时间。使用 DalekJS,我们甚至可以直接在 Firefox、Google Chrome 或 Internet Explorer 中运行测试。
在下一章中,我们将了解如何编写灵活和模块化的 CSS。Node.js 为编写大量 CSS 的前端开发者提供了几个优秀的模块。
第十章:编写灵活和模块化的 CSS
在上一章中,我们学习了在 Node.js 下最流行的测试工具。我们看到了编写测试的重要性,并了解了 TDD 和 BDD。本章将介绍CSS(层叠样式表)和预处理器的作用。网络建立在三种语言的基础之上——HTML、CSS 和 JavaScript。作为现代技术的一部分,Node.js 提供了非常有帮助的工具来编写 CSS;在本章中,我们将探讨这些工具以及它们如何改进我们的样式表。本章将涵盖以下主题:
-
编写模块化 CSS 的流行技术
-
Less 预处理器
-
Stylus 预处理器
-
Sass 预处理器
-
AbsurdJS 预处理器
编写模块化 CSS
在过去的几年里,CSS(层叠样式表)发生了很大的变化。开发者曾经使用 CSS2 作为声明性语言来装饰页面。今天的 CSS3 给我们带来了更多的能力。如今,CSS 被广泛用于在页面上实现设计理念,动画化元素,甚至应用逻辑,如隐藏和显示内容块。大量的 CSS 代码需要更好的架构、文件结构和合适的 CSS 选择器。让我们探索一些可能有助于此的概念。
BEM(块、元素、修饰符)
BEM(bem.info/method/definitions
)是由 Yandex 在 2007 年引入的一种命名约定。它成为了一种流行的前端应用开发概念。实际上,它不仅适用于 CSS,也适用于任何其他语言,因为它只有很少的规则却能很好地工作。
假设我们有以下 HTML 标记:
<header class="site-header">
<div class="logo"></div>
<div class="navigation"></div>
</header>
我们可以立即想到的即时 CSS 如下:
.site-header { ... }
.logo { ... }
.navigation { ... }
然而,这可能不会真正奏效,因为我们可能在页面的侧边栏中还有另一个标志。当然,我们可以使用后代选择器,如.site-header { ... }
和.logo { ... }
,但这些选择器带来了新的问题。将选择器连接成树状结构并不是一个好的实践,因为我们不能从中提取一部分并用于其他地方。BEM 通过定义我们可以遵循的规则来解决此问题。在 BEM 的上下文中,一个块是一个独立的实体。它可以是一个简单的块,也可以是一个复合块(包含其他块)。在先前的例子中,<header>
标签先于 CSS 块。元素放置在块内,并且它们是上下文相关的,也就是说,只有当它们放置在所属的块内时才有意义。块中的.logo
和.navigation
选择器是元素。还有一种类型的选择器称为修饰符。为了更好地理解它们,我们将使用一个例子。假设圣诞节即将到来,我们需要制作一个假日版本的标志。同时,我们需要保留旧样式,因为几个月后我们需要将其恢复到之前的版本。这就是修饰符的作用。我们将它们应用于已存在的元素,以设置新的外观或样式。对于按钮也是如此,它有正常、按下或禁用状态。为了区分不同类型的选择器,BEM 引入了以下语法:
.site-header { ... } /* block */
.site-header__logo { ... } /* element */
.site-header__logo--xmas { ... } /* modifier */
.site-header__navigation { ... } /* element */
元素名称添加双下划线,修饰符添加双破折号。
使用面向对象的 CSS 方法
面向对象的 CSS(OOCSS)(github.com/stubbornella/oocss/wiki
)是另一个有助于我们编写更好 CSS 的概念。它最初由 Nicole Sullivan 提出,并定义了以下两个原则。
分离结构和皮肤
考虑以下 CSS:
.header {
background: #BADA55;
color: #000;
width: 960px;
margin: 0 auto;
}
.footer {
background: #BADA55;
text-align: center;
color: #000;
padding-top: 20px;
}
有一些样式描述了元素的视觉和皮肤外观。重复是将其提取到单独定义中的良好理由。继续前面的代码如下:
.colors-skin {
background: #BADA55;
color: #000;
}
.header {
width: 960px;
margin: 0 auto;
}
.footer {
text-align: center;
padding-top: 20px;
}
很好,我们可以使用相同的.colors-skin
类应用于其他元素,甚至更好的是,我们只需在该特定类中稍作修改,就可以更改整个页面的主题。
分离容器和内容
想法是每个元素都应该有它的样式应用于它所处的任何上下文中。以下代码作为例子:
.header .login-form {
margin-top: 20px;
background: #FF0033;
}
在某个时候,我们可能需要在网站的页脚中放置相同的表单。我们应用的20px
值和#FF0033
颜色将会丢失,因为表单不再位于页眉中。因此,避免这样的选择器将帮助我们防止此类情况发生。当然,我们不可能对每个元素都遵循这个原则,但总体来说,这是一个非常好的实践。
CSS 的可伸缩和模块化架构
乔纳森·斯努克介绍了一种名为可伸缩和模块化 CSS 架构(SMACSS)的另一种有趣的方法(smacss.com/
)。他的想法是将应用程序的样式分类到不同的类别中,如下所示:
-
基本选择器:如用于清除浮动或基本字体大小等的基本选择器
-
布局:定义页面网格的 CSS 样式
-
模块:这些类似于 BEM 块,即形成有意义块的一组元素
-
状态:定义元素状态的 CSS 样式,例如,按下、展开、可见、隐藏等
-
主题:主题规则类似于状态规则,它们描述了模块或布局可能的外观
以这种方式构建样式表很好地组织了选择器。我们可以为不同类别创建不同的目录或文件,最终我们将拥有一切都设置得当。
原子设计
原子设计(bradfrostweb.com/blog/post/atomic-web-design
),由布拉德·弗罗斯特提出的一个概念,是一种简单但非常强大的方法。我们知道物质的基本单位是原子。将这个概念应用到 CSS 中,我们可以将原子定义为简单的 HTML 标签:
<label>Search the site</label>
原子包含一些基本样式,如颜色、字体大小或行高。稍后,我们可以将原子组合成分子。以下示例显示了如何将form
标签由几个原子组成:
<form>
<label>Search the site</label>
<input type="text" placeholder="enter keyword" />
<input type="submit" value="search" />
</form>
正确地样式化和组合小块块带来了灵活性。如果我们遵循这个概念,我们可以反复使用相同的原子,或者将任何分子放在不同的上下文中。布拉德·弗罗斯特并没有止步于此。他继续说,分子可以合并成生物体,生物体可以合并成模板。例如,登录表单和主菜单分子定义了一个生物体头部。
本节中提到的所有概念并不适合每个项目。然而,它们都有一些有价值的东西可以应用。我们应尽量避免严格遵循它们,而是获取最适合我们当前应用的规则。
探索 CSS 预处理器
预处理器是接受代码并编译的工具。在我们的环境中,这些工具输出 CSS。使用预处理器有几个显著的优点。
-
连接:将所有内容写入一个单独的
.css
文件不再是可行的选择。我们都需要逻辑地分割我们的样式,这通常是通过创建多个不同的文件来实现的。CSS 有一个从另一个文件导入文件的机制——@import
指令。然而,使用它,我们迫使浏览器向服务器创建另一个 HTTP 请求,这可能会降低我们应用程序的性能。CSS 预处理器通常通过替换@import
的功能并简单地连接所有使用的文件来只输出一个文件。 -
扩展(Extending):我们不喜欢反复写同样的事情,而在纯 CSS 编码中,这种情况经常发生。好消息是预处理器提供了一个解决这个问题的功能。它被称为混入。我们可以将其视为一个执行并应用其中定义的所有样式的函数。我们将在本章后面看到它在实际中的应用。
-
配置(Configuration):通常,我们需要在 CSS 文件中重复颜色、宽度和字体大小。通过使用 CSS 预处理器,我们可以将这些值放入变量中,并在一个地方定义它们。切换到新的颜色方案或字体排印可以非常快地完成。
在大多数预处理器中使用的语法与正常 CSS 类似。这使得开发者可以几乎立即开始使用它们。让我们来看看可用的 CSS 预处理器。
使用 Less
Less 是一个基于 Node.js 的 CSS 预处理器。它作为 Node.js 模块分发,可以使用以下命令行安装:
npm install -g less
安装成功后,我们应该能够在终端中调用lessc
命令。在某个地方创建一个新的styles.less
文件,并将以下代码放入其中:
body {
width: 100%;
height: 100%;
}
如果我们运行lessc ./styles.less
,我们将看到作为结果的相同 CSS。Less 采取的方法是使用与正常 CSS 中使用的语法相近的语法。因此,在实践中,几乎所有的现有 CSS 代码都可以由 Less 编译,这非常方便,因为我们可以在不做任何准备的情况下开始使用它。
定义变量
Less 中的变量定义方式与我们编写 CSS 属性的方式相同。我们只需在属性名前加上@
符号,如下面的代码片段所示:
@textColor: #990;
body {
width: 100%;
height: 100%;
color: @textColor;
}
使用混入
混入(Mixins)在我们想要将特定样式从一个地方转移到另一个地方,甚至多个地方时非常有用。比如说,如果我们有一些需要为页面上的不同元素设置的固定边框,我们就会使用以下代码片段:
.my-border() {
border-top: solid 1px #000;
border-left: dotted 1px #999;
}
.login-box {
.my-border();
}
.sidebar {
.my-border();
}
我们可以省略.my-border
的括号,但这样结果文件中将会出现相同的类。现在的代码编译如下:
.login-box {
border-top: solid 1px #000;
border-left: dotted 1px #999;
}
.sidebar {
border-top: solid 1px #000;
border-left: dotted 1px #999;
}
混入可以接受参数,这使得它们成为 Less 中最重要的功能之一。
.my-border(@size: 2px) {
border-top: solid @size #000;
border-left: dotted @size #999;
}
.login-box {
.my-border(4px);
}
.sidebar {
.my-border();
}
在示例中,边框的大小作为参数传递。它还有一个默认值,为两像素。编译后的结果如下:
.login-box {
border-top: solid 4px #000000;
border-left: dotted 4px #999999;
}
.sidebar {
border-top: solid 2px #000000;
border-left: dotted 2px #999999;
}
将样式结构化为嵌套定义
当我们使用后代选择器时,经常会遇到一个非常长的样式定义。这很烦人,因为我们必须输入更多,而且难以阅读。CSS 预处理器通过允许我们编写嵌套样式来解决此问题。以下代码显示了如何嵌套选择器:
.content {
margin-top: 10px;
p {
font-size: 24px;
line-height: 30px;
a {
text-decoration: none;
}
small {
color: #999;
font-size: 20px;
}
}
}
.footer {
p {
font-size: 20px;
}
}
这要容易理解得多,也更容易遵循。我们也不必担心冲突。例如,.content
元素中的段落将具有 24 像素的字体大小,并且不会与页脚的样式混合。这是因为最后,我们正确地生成了选择器:
.content {
margin-top: 10px;
}
.content p {
font-size: 24px;
line-height: 30px;
}
.content p a {
text-decoration: none;
}
.content p small {
color: #999;
font-size: 20px;
}
.footer p {
font-size: 20px;
}
Less 有十几个其他功能,如数学计算、函数定义、条件混入,甚至循环。我们可以就这个主题写一本全新的书。所有功能的完整列表可以在 lesscss.org/
上看到,这是 Less 的官方网站,包含其文档。
使用 Sass
另有一个流行的 CSS 预处理器叫做 Sass。实际上它不是基于 Node.js,而是基于 Ruby。因此,我们首先需要安装 Ruby。你还可以在官方下载页面找到有关如何安装 Ruby 的详细信息:www.ruby-lang.org/en/downloads
。一旦我们正确地设置了它,我们需要运行以下命令来获取 Sass:
gem install sass
执行后,我们将安装一个命令行工具,即 sass
,我们可以用它来运行 .sass
或 .scss
文件。.sass
文件中使用的语法看起来与 Stylus 中使用的语法相似(我们将在 使用 Stylus 部分学习这一点),而 .scss
文件中使用的语法与 Less 变体相似。起初,Less 和 Sass 看起来非常相似。Sass 在变量前使用 $
符号,而 Less 使用 @
符号。Sass 具有与 Less 相同的功能——条件语句、嵌套、混入、扩展。以下是一个简短的示例代码:
$brandColor: #993f99;
@mixin paragraph-border($size, $side: '-top') {
@if $size > 2px {
border#{$side}: dotted $size #999;
} @else {
border#{$side}: solid $size #999;
}
}
body {
font-size: 20px;
p {
color: $brandColor;
@include paragraph-border(3px, '-left')
}
}
上述代码生成以下 CSS 代码:
body {
font-size: 20px;
}
body p {
color: #993f99;
border-top: dotted 3px #999;
}
有两个关键字:@mixin
和 @include
。第一个定义混入,第二个在混入使用时是必需的。
使用 Stylus
Stylus 是另一个流行的 CSS 预处理器,由 Node.js 编写。与 Less 类似,Stylus 也接受常规 CSS 语法。然而,它引入了另一种编写方式——没有花括号、冒号和分号。以下是一个简短的示例代码:
body {
font: 12px Helvetica, Arial, sans-serif;
}
a.button {
-webkit-border-radius: 5px;
-moz-border-radius: 5px;
border-radius: 5px;
}
在 Stylus 中,生成的 CSS 代码可能看起来像以下代码片段:
body
font 12px Helvetica, Arial, sans-serif
a.button
-webkit-border-radius 5px
-moz-border-radius 5px
border-radius 5px
该语言使用缩进来识别定义。Stylus 作为 Node.js 模块分发,可以使用 npm install -g stylus
命令行安装。一旦过程完成,我们可以使用以下命令进行编译:
stylus ./styles.styl
这是命令行,其中 styles.styl
包含必要的 CSS。结果,我们将在同一目录下得到 styles.css
文件。
Stylus 比 Less 稍微复杂一些。它仍然支持相同的功能,但具有更多的逻辑运算符。让我们看看一个演示其大多数功能的示例:
brandColor = #FF993D
borderSettings = { size: 3px, side: '-top' }
paragraph-border(size, side = '')
if size > 2px
border{side}: dotted size #999
else
border{side}: solid size #999
body
font-size: 20px
p
color: brandColor
paragraph-border(borderSettings.size, borderSettings.side)
第一行定义了一个名为 brandColor
的变量。稍后,这个变量被用来设置段落的颜色。Stylus 支持将哈希对象作为变量的值。这真的很不错,因为我们可以定义一组选项。在前面的例子中,borderSettings
包含段落边框的大小和位置。paragraph-border
混入接受两个参数。第二个参数不是必需的,有一个默认值。有一个 if
-else
语句定义了应用的边框类型。类似于 Less,我们有嵌套选择器的能力。段落的样式嵌套在 body
选择器内部。编译后,生成的 CSS 如下所示:
body {
font-size: 20px;
}
body p {
color: #ff993d;
border-top: dotted 3px #999;
}
使用 AbsurdJS
AbsurdJS 是 Node.js 中可用的另一个 CSS 预处理器,它采取了略微不同的方向。它不是发明新的语法,而是使用已经存在的语言——JavaScript。因此,变量、混入或逻辑运算符等特性自然出现,无需任何额外努力。
与其他预处理器类似,AbsurdJS 通过 Node.js 的包管理器分发。以下命令行将库安装到您的机器上:
npm install -g absurd
CSS 样式写在 .js
文件中。实际上,该库接受 .css
、.json
和 .yaml
文件,并成功处理它们,但在这本书中,我们将坚持使用 JavaScript 格式,因为它是最有趣的。每个传递给 AbsurdJS 的文件都以以下代码开始:
module.exports = function(api) {
// ...
}
导出的函数接受模块的 API。所有操作都通过 API 对象进行。因为一切都在 JavaScript 中,CSS 样式以 JSON 格式表示。以下是一个示例代码:
module.exports = function(api) {
api.add({
body: {
fontSize: '20px',
margin: '0 12px'
}
})
}
代码被编译成以下 CSS:
body {
font-size: 20px;
margin: 0 12px;
}
AbsurdJS 可以作为一个命令行工具使用。要处理包含前面代码片段的 styles.js
文件,我们应该执行以下代码:
absurd -s ./styles.js -o ./styles.css
-s
标志来自源代码,-o
来自输出。该模块既可以用在代码中,也可以将 AbsurdJS 集成到每个 Node.js 应用程序中。我们只需在我们的 package.json
文件中添加库,并按以下代码所示引入它:
var absurd = require('absurd')();
absurd.add({
body: {
fontSize: '20px',
marginTop: '10px'
}
}).compile(function(err, css) {
// ...
});
实际上,对于 Less 预处理器,同样适用。它也可以在 Node.js 脚本中使用。
在讨论 Sass 和 Stylus 时,我们使用了一个例子:几行代码为页面的 paragraph
标签添加边框。以下代码阐述了如何使用 AbsurdJS 实现这一点:
module.exports = function(api) {
var brandColor = '#993f99';
var paragraphBorder = function(size, side) {
var side = side ? side : '-top';
var result = {};
result['border' + side] = (size > 2 ? 'dotted ' : 'solid ') + size + 'px #999';
return result;
}
api.add({
body: {
fontSize: '20px',
p: [
{ color: brandColor },
paragraphBorder(3, '-left')
]
}
});
}
这一切都是关于构建 JavaScript 对象并将它们传递给 add
方法。仍然有嵌套、定义变量和使用混入(paragraphBorder
)。
设计简单的登录表单样式
我们现在将编写一个简单登录表单的 CSS 样式。HTML 标记非常简单。它有两个标签、两个输入字段和两个按钮,如下代码所示:
<form method="post" id="login">
<label>Your username</label>
<input type="text" name="u" />
<label>Your password</label>
<input type="password" name="p" />
<input type="submit" value="login" />
<input type="button" value="forgot" />
</form>
我们最终想要达到的结果如下截图所示:
作为预处理器,我们将使用 AbsurdJS 并以 JavaScript 格式编写我们的样式。让我们创建一个空的 style.js
文件并输入以下代码:
module.exports = function(api) {
var textColor = '#9E9E9E';
var textColorLight = api.lighten('#9E9E9E', 50);
var textColorDark = api.darken('#9E9E9E', 50);
var brandColor = '#8DB7CD';
var brandColorLight = api.lighten('#8DB7CD', 50);
var brandColorDark = api.darken('#8DB7CD', 30);
var warning = '#F00';
}
我们定义了页面的设置。在我们的例子中,它们只是颜色,但可以是任何其他东西,例如字体大小、边距或行间距。api.lighten
和 api.darken
函数用于生成颜色的变体。它们通过使它们变亮或变暗来改变传递的值,具体取决于百分比。
我们已经设置了配置,我们可以继续以下基本 CSS:
api.add({
body: {
width: '100%', height: '100%',
margin: 0, padding: 0,
color: textColor,
fontFamily: 'Arial'
}
});
这些样式应用于我们页面上的 body
标签。如果我们现在打开页面,我们将看到以下结果:
这是因为我们还没有处理表单。让我们继续并使用以下代码定义其基本规则:
api.add({
body: {
width: '100%', height: '100%',
margin: 0, padding: 0,
color: textColor,
fontFamily: 'Arial',
'#login': [
{
width: '400px',
margin: '0 auto',
padding: '30px 0 0 30px',
label: {
display: 'block',
margin: '0 0 10px 0',
color: textColorDark
}
}
]
}
});
#login
选择器匹配表单。我们将它定位在页面中间,并设置顶部和底部的填充。我们还使 label
标签成为一个块元素。现在示例看起来好多了,如下面的截图所示:
如果我们检查从其开始的 HTML 标记,我们会看到其余的元素都是 input
标签,即两个字段和两个按钮。让我们创建一个函数(混合),用于生成这些元素的 CSS:
var input = function(selector, addons) {
var result = {};
result[selector] = {
'-wm-border-radius': '4px',
'-wm-box-sizing': 'border-box',
marginBottom: '20px',
border: 'solid 3px ' + brandColor,
width: '100%',
padding: '8px',
'&:focus': {
outline: 0,
background: textColorLight
}
}
if(addons) {
for(var prop in addons) {
result[selector][prop] = addons[prop];
}
}
return result;
}
input
方法接受一个选择器和对象。因为我们将使用该函数来样式字段和按钮,我们需要一个机制来添加自定义规则。如果定义了 addons
对象,它将包含需要设置的额外样式。有两个属性可能看起来很奇怪:-wm-border-radius
和 -wm-box-sizing
。在开始时,-wm-
属性将浏览器前缀添加到 CSS 的末尾。例如,-wm-box-sizing: border-box
产生以下输出:
box-sizing: border-box;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
&:focus
属性也是一个特殊属性。井号代表编写样式的选择器。在函数的末尾,我们添加了自定义 CSS。现在,让我们看看用例:
'#login': [
{
width: '400px',
margin: '0 auto',
padding: '30px 0 0 30px',
label: {
display: 'block',
margin: '0 0 10px 0',
color: textColorDark
}
},
input('input[type="text"]'),
input('input[type="password"]', {
marginBottom: '40px'
}),
input('input[type="submit"]', {
gradient: brandColorLight + '/' + brandColor,
width: '80px'
}),
input('input[type="button"]', {
gradient: brandColorLight + '/' + brandColor,
width: '80px',
transparent: 0.6,
'&:hover': {
transparent: 1
}
})
]
对于输入字段,我们仅使用选择器调用输入方法。然而,对于按钮,我们需要更多的样式,并且它们作为 JavaScript 对象传递。AbsurdJS 内置了混合,允许我们生成跨浏览器的 CSS,例如 gradient
和 transparent
属性。执行 gradient
属性的结果如下:
/* gradient: brandColorLight + '/' + brandColor */
background: -webkit-linear-gradient(0deg, #d4ffff 0%, #8DB7CD 100%);
background: -moz-linear-gradient(0deg, #d4ffff 0%, #8DB7CD 100%);
background: -ms-linear-gradient(0deg, #d4ffff 0%, #8DB7CD 100%);
background: -o-linear-gradient(0deg, #d4ffff 0%, #8DB7CD 100%);
background: linear-gradient(0deg, #d4ffff 0%, #8DB7CD 100%);
-ms-filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FF8DB7CD',endColorstr='#FFD4FFFF',GradientType=0);
此外,执行 transparent
属性的结果如下:
/* transparent: 0.6 */
filter: alpha(opacity=60);
-ms-filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=60);
opacity: 0.6;
-moz-opacity: 0.6;
-khtml-opacity: 0.6;
使用混合比我们自己编写所有这些内容要容易得多。一旦我们添加了 input
调用,我们就完成了。AbsurdJS 产生了期望的结果。
摘要
CSS 是并且将始终是网络的重要组成部分。使其简单、结构良好且具有灵活的标记,有助于构建良好的架构。在本章中,我们学习了编写模块化 CSS 的最流行概念。同时,我们还探讨了 CSS 预处理领域的最新趋势、可用工具及其特性。
Node.js 运行速度快,经常被用作 REST API。在下一章中,我们将看到如何编写 REST API 以及这一方向的最佳实践。
第十一章。编写 REST API
在上一章中,我们学习了如何优化我们的 CSS 编写。我们了解了最流行的架构概念,并检查了可用的 CSS 预处理器。本章是关于使用 Node.js 构建 REST API。我们将:
-
运行 Web 服务器
-
实现路由机制
-
处理传入的请求
-
发送适当的响应
发现 REST 和 API
REST代表表示性状态转移,它是 Web 的一种架构原则。在大多数情况下,我们在服务器上有资源需要创建、检索、更新或删除。REST API 提供了执行所有这些操作的方法。每个资源都有自己的 URI,根据请求方法,会发生不同的操作。例如,假设我们需要管理我们的社交网络中的用户。要检索特定用户的信息,我们将向/user/23
地址执行GET
请求,其中数字23
是用户的 ID。要更新数据,我们将向同一 URL 发送PUT
请求,要删除记录,我们将发送DELETE
请求。POST
请求保留用于创建新资源。换句话说,服务器上的资源管理是通过使用GET
、POST
、PUT
和DELETE
方法向精心选择的地址发送 HTTP 请求来实现的,这些方法通常被称为HTTP 动词。许多公司采用这种架构,因为它简单、通过 HTTP 协议工作,并且具有高度的可扩展性。当然,还有不同的方法,如 SOAP 或 CORBA,但我们有更多的规则要遵循,并且机器之间的通信通常很复杂。
根据维基百科,应用程序编程接口(API)指定了某些软件组件应该如何相互交互。API 通常是我们的程序对外可见的部分。
在本章中,我们将构建一个。这是一个简单的在线图书库的 API。资源是书籍,它们将通过 REST API 进行访问。
开发在线图书馆——REST API
REST API 的开发与其他 Node.js 应用程序的开发相同。我们需要制定计划,并逐一仔细实现不同的组件。
定义 API 部分
在开始一个新项目之前有一个计划总是好的。所以,让我们定义 API 服务器的主要部分如下:
-
路由器:我们知道 Node.js 从端口开始监听并接受 HTTP 请求。因此,我们需要一个类来处理它们并将请求传递到正确的逻辑。
-
处理器:这是我们将放置逻辑的地方。它将处理请求并准备响应。
-
响应器:我们还需要一个类,它将结果发送到浏览器。API 通常需要以不同的格式响应。例如,XML 和 JSON。
编写基础
Node.js 经常被用来构建 REST API。此外,因为它是一个常见任务,所以我们有几种可能的方法。甚至有现成的模块,如rest.js
或restify
。然而,我们将从头开始构建我们的 REST API,因为它将更有趣、更具挑战性。我们将首先运行一个 Node.js 服务器。让我们创建一个空目录,并将以下代码放入index.js
文件中:
var http = require('http');
var router = function(req, res) {
res.end('API response');
}
http.createServer(router).listen('9000', '127.0.0.1');
console.log('API listening');
如果我们使用node ./index.js
运行脚本,我们就能打开http://127.0.0.1:9000
并在屏幕上看到API 响应。所有传入的请求都会通过一个函数。这就是我们的路由器所在的位置。
实现 API 路由器
在几乎每个基于 Web 的 Node.js 应用程序中,路由器扮演着主要角色之一。这是因为它是程序的入口点。这是 URL 映射到逻辑和请求处理的地方。对于 REST API 的路由器应该稍微复杂一些,因为它不仅应该处理常见的GET
和POST
请求,还应该处理PUT
和DELETE
请求。除了我们的index.js
,我们还需要另一个名为router.js
的文件。因此,将以下代码添加到router.js
文件中:
var routes = [];
module.exports = {
register: function(method, route, handler) {
routes.push({ method: method, route: route, handler: handler });
},
process: function(req, res, next) {
// ...
}
}
模块导出一个包含两个方法的对象。第一个方法(register
)将记录存储在routes
变量中。第二个方法(process
)将被用作index.js
中createServer
方法的处理器。以下代码演示了如何使用我们的路由器:
var http = require('http');
var router = require('./router');
http.createServer(router.process).listen('9000', '127.0.0.1');
console.log('API listening');
register
方法的第一参数将是 HTTP 动词作为字符串:GET
、POST
、PUT
或DELETE
。route
参数将是一个正则表达式,最后一个参数,如果表达式与当前 URL 匹配,将调用一个函数。
process
方法将执行几件事情。它将对当前请求运行定义的正则表达式。它还将做几件其他事情,如下所述:
-
从 URL 中获取
GET
参数 -
获取与请求一起传递的
POST
/PUT
参数 -
支持动态 URL
所述的所有这些事情都可以在router
变量外部实现,但因为是常见任务,我们可能会在几个地方用到它们,所以我们将它们添加到以下代码中。以下代码是路由器process
方法的完整代码:
process: function(req, res, next) {
var urlInfo = url.parse(req.url, true);
var info = {
get: urlInfo.query,
post: {},
path: urlInfo.pathname,
method: req.method
}
for(var i=0; i<routes.length; i++) {
var r = routes[i];
var match = info.path.match(r.route);
if((info.method === r.method || '' === r.method) && match) {
info.match = match;
if(info.method === 'POST' || info.method === 'PUT') {
processRequest(req, function(body) {
info.post = body;
r.handler(req, res, info);
});
} else {
r.handler(req, res, info);
}
return;
}
}
res.end('');
}
有一个info
对象持有我们讨论过的数据。我们遍历了所有路由,并尝试找到一个具有方法和正则表达式匹配的路由。我们还检查了请求方法是否为POST
或PUT
,并获取了发送的信息。最后,如果没有匹配的路由,我们发送一个空字符串。为了使前面的代码正常工作,我们需要定义两个变量和一个函数,这些都在以下代码中完成:
var url = require('url');
var qs = require('querystring');
var processRequest = function(req, callback) {
var body = '';
req.on('data', function (data) {
body += data;
});
req.on('end', function () {
callback(qs.parse(body));
});
}
实体url
和querystring
是 Node.js 的本地模块。processRequest
变量是必需的,因为 Node.js 以不同的方式处理POST
/PUT
参数。
通过使用前面的代码,我们能够添加路由并检查它们是否正常工作。例如,查看index.js
文件中的以下代码:
router.register('GET', /\/books(.+)?/, function(req, res, info) {
console.log(info);
res.end('Getting all the books')
});
在这里,我们使用node ./index.js
启动服务器并向http://127.0.0.1:9000/books
发送请求。屏幕上显示的结果是文本获取所有书籍
,如下所示截图:
你也会在我们的终端中看到以下输出:
没有发送数据,所以get
和post
属性是空的。现在,让我们使用以下路由:
router.register('POST', /\/book(.+)?/, function(req, res, info) {
console.log(info);
res.end('New book created')
});
我们应该确保我们的 API 正确地接受POST
和GET
请求;我们可以通过使用此路由来实现。如果我们向http://127.0.0.1:9000/book?notification=no
发送包含数据name=Node.js blueprints&author=Krasimir Tsonev
的POST
请求,我们将得到以下结果:
我们的路由器还有一件事要做。它处理动态 URL。我们所说的“动态”是指像/book/523/edit
这样的 URL,其中523
是书籍的唯一 ID,它可以不同,我们想要在一个特定的处理器中处理所有这类请求,如下所示:
router.register('GET', /\/book\/(.+)\/(.+)?/, function(req, res, info) {
console.log(info);
res.end('Getting specific book')
});
这里的关键点是正则表达式。有两个捕获括号。第一个代表书籍的 ID,第二个代表我们想要执行的操作,例如edit
或delete
。127.0.0.1:9000/book/523/edit
的响应如下所示截图:
如我们所见,523
和edit
是match
属性的一部分,我们可以轻松地获取它们。我们可以通过添加一些额外的辅助方法来改进我们的路由器。为每种不同类型的请求提供方法是一个好的实践。以下代码显示了这些方法的样子:
get: function(route, handler) {
this.register('GET', route, handler);
},
post: function(route, handler) {
this.register('POST', route, handler);
},
put: function(route, handler) {
this.register('PUT', route, handler);
},
del: function(route, handler) {
this.register('DELETE', route, handler);
},
all: function(route, handler) {
this.register('', route, handler);
}
我们现在可以写router.get(/\/book\/(.+)\/(.+)?/...
,而不是router.register('GET', /\/book\/(.+)\/(.+)?/...
,这稍微好一些。如果我们需要处理特定的 URL 但不关心request
方法,可以使用all
函数。在 Express 框架中,我们也有get
、post
、put
、delete
和all
方法。
编写响应器
在编写我们的小型 REST API 库的逻辑之前,我们需要一个合适的响应器,即一个我们将用来将结果发送到浏览器的类。当我们谈论一个作为 API 工作的服务器时,有一些非常重要的事情需要我们注意。除了数据,我们必须发送一个适当的 HTTP 状态码。例如,如果一切正常,则为200
,如果资源缺失,则为404
。
我们的响应器将被保存在与index.js
和router.js
相同的目录中的responder.js
文件中。模块以以下代码开始:
module.exports = function(res) {
return {
c: 200,
code: function(c) {
this.c = c;
return this;
},
send: function(content) {
res.end(content.toString('utf8'));
this.c = 200;
return this;
}
}
}
该模块需要响应对象以便将结果发送到浏览器。code
方法设置状态码。我们可以获取最新使用的路由并将其转换为以下代码:
var responder = require('./responder');
router.get(/\/book\/(.+)\/(.+)?/, function(req, res, info) {
console.log(info);
responder(res).code(200).send('Getting specific book');
});
在本章的开头,我们提到 API 应该能够以不同的格式响应。我们必须在响应器中添加一些方法来实现这一点:
json: function(o) {
res.writeHead(this.c, {'Content-Type': 'application/json; charset=utf-8'});
return this.send(JSON.stringify(o));
},
html: function(content) {
res.writeHead(this.c, {'Content-Type': 'text/html; charset=utf-8'});
return this.send(content);
},
css: function(content) {
res.writeHead(this.c, {'Content-Type': 'text/css; charset=utf-8'});
return this.send(content);
},
js: function(content) {
res.writeHead(this.c, {'Content-Type': 'application/javascript; charset=utf-8'});
return this.send(content);
},
text: function(content) {
res.writeHead(this.c, {'Content-Type': 'text/plain; charset=utf-8'});
return this.send(content);
}
通过添加这些函数,我们实际上能够提供 JSON、HTML、CSS、JavaScript 和纯文本。类向浏览器发送一个包含状态码、Content-Type
和 charset
的头信息。响应器的所有方法都返回类本身,因此我们可以将它们链接起来。
与数据库一起工作
在 第三章,使用 Node.js 和 AngularJS 编写博客应用程序中,我们使用了 MongoDB 和 MySQL。我们学习了如何从这些数据库中读取、写入、编辑和删除记录。本章我们也使用 MongoDB。我们将数据存储在名为 books
的集合中。为了使用数据库驱动程序,我们需要创建一个 package.json
文件,并在其中放入以下内容:
{
"name": "projectname",
"description": "description",
"version": "0.0.1",
"dependencies": {
"mongodb": "1.3.20"
"request": "2.34.0"
}
}
运行 npm install
后,我们将能够通过使用 node_modules
目录中安装的驱动程序连接到 MongoDB 服务器。我们需要与数据库交互的代码与 第三章,使用 Node.js 和 AngularJS 编写博客应用程序中使用的代码相同,如下所示:
var crypto = require("crypto"),
client = require('mongodb').MongoClient,
mongodb_host = "127.0.0.1",
mongodb_port = "27017",
collection;
var connection = 'mongodb://';
connection += mongodb_host + ':' + mongodb_port;
connection += '/library';
client.connect(connection, function(err, database) {
if(err) {
throw new Error("Can't connect.");
} else {
console.log("Connection to MongoDB server successful.");
collection = database.collection('books');
}
});
将使用 crypto
模块为新创建的记录生成一个唯一的 ID。已经初始化了一个 MongoDB 客户端。它连接到服务器,并将 collection
变量指向 books
集合。这就是我们所需要的。现在我们可以管理我们书籍的记录了。
创建新记录
将新书添加到数据库应通过 POST
请求进行。以下代码是处理此任务的路由:
router.post(/\/book/, function(req, res, info) {
var book = info.post;
book.ID = crypto.randomBytes(20).toString('hex');
if(typeof book.name == 'undefined') {
responder(res).code(400).json({error: 'Missing name.'});
} else if(typeof book.author == 'undefined') {
responder(res).code(400).json({error: 'Missing author.'});
} else {
collection.insert(book, {}, function() {
responder(res).code(201.json({message: 'Record created successful.'});
});
}
});
添加新书的 URL 是 /book
。它可以通过 POST
方法访问。预期的参数是 name
和 author
。请注意,如果这些中的任何一个缺失,我们将设置状态码为 400
。400
表示 Bad request
。如果用户忘记传递它们,我们应该通知他们具体出了什么问题。在设计 API 时,这非常重要。使用我们服务的开发者应该知道为什么他们没有得到适当的响应。通常,设计良好的 API 可以在没有文档的情况下使用。这是因为它们的方法提供了足够的信息。
这本书的数据是以 JSON 格式编写的,浏览器接收到的答案也是以 JSON 格式发送的。以下截图是数据库中保存的记录预览:
编辑记录
为了实现编辑,我们将使用 PUT
方法。我们还需要定义一个动态路由。以下代码创建了路由和适当的处理程序:
router.put(/\/book\/(.+)?/, function(req, res, info) {
var book = info.post;
if(typeof book.name === 'undefined') {
responder(res).code(400).json({error: 'Missing name.'});
} else if(typeof book.author === 'undefined') {
responder(res).code(400).json({error: 'Missing author.'});
} else {
var ID = info.match[1];
collection.find({ID: ID}).toArray(function(err, records) {
if(records && records.length > 0) {
book.ID = ID;
collection.update({ID: ID}, book, {}, function() {
responder(res).code(200).json({message: 'Record updated successful.'});
});
} else {
responder(res).code(400).json({error: 'Missing record.'});
}
});
}
});
除了对缺失的name
和author
进行检查外,我们还需要确保在 URL 中使用的 ID 存在于我们的数据库中。如果不存在的,应该发送适当的错误信息。
删除记录
记录的删除与编辑非常相似。我们还需要一个动态路由。当我们有书籍的 ID 时,我们可以检查它是否真的存在,如果存在,则简单地从数据库中删除它。查看以下实现,它执行了我们刚才描述的操作:
router.del(/\/book\/(.+)?/, function(req, res, info) {
var ID = info.match[1];
collection.find({ID: ID}).toArray(function(err, records) {
if(records && records.length > 0) {
collection.findAndModify({ID: ID}, [], {}, {remove: true}, function() {
responder(res).code(200).json({message: 'Record removed successfully.'});
});
} else {
responder(res).code(400).json({error: 'Missing record.'});
}
});
});
显示所有书籍
这可能是最简单的 API 方法,我们必须要实现。这里有一个对数据库的查询,结果直接传递给响应者。以下代码定义了一个名为books
的路由,用于从数据库中获取所有记录:
router.get(/\/books/, function(req, res, info) {
collection.find({}).toArray(function(err, records) {
if(!err) {
responder(res).code(200).json(records);
} else {
responder(res).code(200).json([]);
}
});
});
添加默认路由
我们应该有一个默认路由,即当用户输入错误的 URL 或仅访问 API 的根地址时将被发送的页面。为了捕获所有类型的请求,我们使用路由器的all
方法:
router.all('', function(req, res, info) {
var html = '';
html += 'Available methods:<br />';
html += '<ul>';
html += '<li>GET /books</li>';
html += '<li>POST /book</li>';
html += '<li>PUT /book/[id]</li>';
html += '<li>DELETE /book/[id]</li>';
html += '</ul>';
responder(res).code(200).html(html);
});
我们构建了一个简单的 HTML 标记并发送给用户。该路由的正则表达式只是一个空字符串,它可以匹配任何内容。我们还在使用.all
函数,它可以处理任何类型的请求。请注意,我们需要在所有其他路由之后添加此路由;否则,如果它位于开头,所有请求都将流向那里。
测试 API
为了确保一切正常工作,我们将编写一些测试,涵盖上一节中提到的所有方法。在第九章《使用 Node.js 自动化测试》中,我们学习了 Jasmine 和 Mocha 测试框架。以下测试套件使用 Jasmine。我们还需要一个额外的模块来执行 HTTP 请求。该模块称为request
,我们可以使用npm install request
或将其添加到我们的package.json
文件中。以下是与测试 API 相关的步骤和代码:
-
让我们先测试创建一个新的数据库记录:
var request = require('request'); var endpoint = 'http://127.0.0.1:9000/'; var bookID = ''; describe("Testing API", function() { it("should create a new book record", function(done) { request.post({ url: endpoint + '/book', form: { name: 'Test Book', author: 'Test Author' } }, function (e, r, body) { expect(body).toBeDefined(); expect(JSON.parse(body).message).toBeDefined(); expect(JSON.parse(body).message).toBe('Record created successfully.'); done(); }); }); });
我们使用模块的
.post
方法。所需数据附加到form
属性。我们还期望接收到包含特定消息的 JSON 对象。 -
要获取数据库中的所有书籍,我们需要向
http://127.0.0.1:9000/books
发起请求:it("should get all the books", function(done) { request.get({ url: endpoint + '/books' }, function (e, r, body) { var books = JSON.parse(body); expect(body).toBeDefined(); expect(books.length > 0).toBeDefined(); bookID = books[0].ID; expect(bookID).toBeDefined(); done(); }); });
-
编辑和删除操作与
POST
和GET
请求相似,只是我们传递了一个 ID。而且,我们是从上一个测试中获取集合中所有记录的地方得到的:it("should edit", function(done) { request.put({ url: endpoint + '/book/' + bookID, form: { name: 'New name', author: 'New author' } }, function (e, r, body) { expect(body).toBeDefined(); expect(JSON.parse(body).message).toBeDefined(); expect(JSON.parse(body).message).toBe('Record updated successfully.'); done(); }); }); it("should delete a book", function(done) { request.del({ url: endpoint + '/book/' + bookID }, function (e, r, body) { expect(body).toBeDefined(); expect(JSON.parse(body).message).toBeDefined(); expect(JSON.parse(body).message).toBe('Record removed successfully.'); done(); }); });
摘要
在本章中,我们构建了一个 REST API 来存储有关书籍的信息。Node.js 处理此类任务很好,因为它有易于工作的原生模块。我们成功覆盖了GET
、POST
、PUT
和DELETE
请求,创建了一个管理简单在线图书馆的接口。
在本书的下一章和最后一章中,我们将构建一个桌面应用程序。我们将学习如何使用 Node.js 不仅限于 Web 项目,还可以用于桌面程序。到下一章结束时,我们应该有一个用 Node.js 编写的可工作的文件浏览器。
第十二章. 使用 Node.js 开发桌面应用程序
在上一章中,我们实现了一个 REST API 并构建了一个处理各种请求的服务器。本书的大部分章节都介绍了网络技术,这些技术是在浏览器中通过 HTTP 协议工作的应用程序。有趣的是,Node.js 可以用来生成桌面程序,我们不需要学习新的语言或使用新的工具。我们可以继续使用 HTML、CSS 和 JavaScript。这是一个很大的好处,因为这些技术易于学习和开发。Node.js 也非常快:当我们处理大量编写的模块时,我们可以节省很多时间,因为我们不需要处理琐碎的问题。在本章中,我们将编写一个文件浏览器。我们的应用程序将执行以下操作:
-
以桌面程序运行
-
从我们的硬盘读取文件并在屏幕上显示它们
-
显示图片
使用 node-webkit
有几种工具可用于编写桌面应用程序。我们将使用 node-webkit(github.com/rogerwang/node-webkit
)。它是一个基于 Chromium 和 Node.js 的应用程序运行时。它作为二进制程序分发,我们运行它来查看代码的结果。它适用于所有主要的操作系统——Linux、Windows 和 Mac。因此,在开发过程中,我们将使用nw
可执行文件,这与使用node
可执行文件运行 Node.js 脚本相同。nw
文件可以从 GitHub 上该工具的官方仓库下载。
每个用 node-webkit 编写的桌面应用程序至少必须包含两个文件:package.json
和主 HTML 文件。类似于我们迄今为止编写的模块,package.json
文件包含我们应用程序的配置。以下是一个简单的示例:
{
"name": "nw-demo",
"main": "index.html"
}
为main
属性设置一个值是很重要的。它应该指向文件浏览器的主 HTML 文件。路径相对于package.json
文件的位置。index.html
的内容可能如下所示:
<!DOCTYPE html>
<html>
<head>
<title>Hello World!</title>
</head>
<body>
<h1>Hello World!</h1>
We are using node.js <script>document.write(process.version)</script>.
</body>
</html>
这只是一个普通的 HTML 页面,除了放置在script
标签之间的代码。document.write
方法在所有现代浏览器中都是可用的。然而,process
是 Node.js 的全局对象。这个例子很简单,但我们能看出 node-webkit 的力量。在实践中,我们可以将客户端 JavaScript 与服务器端 JavaScript 混合,后者在我们的机器环境中运行。我们可以在 Node.js 环境中编码,同时仍然可以访问页面的 DOM。
运行应用有以下两种方式:
-
我们可以导航到包含文件的目录并运行
nw ./
-
我们可以将这两个文件压缩成
myapp.zip
,例如,将存档重命名为myapp.nw
,然后运行nw myapp.nw
一旦我们完成编程,我们可以将其与 node-webkit 可执行文件一起打包。对于最终用户来说,这意味着不需要安装额外的软件或单独下载 node-webkit。这使得分发变得更容易。有一些规则我们作为开发者应该遵循,例如,在 Windows 操作系统下发送少量的 .dll
文件(和许可文件)。然而,了解我们可以打包项目并在其他机器上运行它而不安装依赖项是很好的。
完成此操作的步骤取决于操作系统,并在官方文档中定义良好(github.com/rogerwang/node-webkit
)。如前所述,node-webkit 基于 Chromium。通常,当我们编写客户端 JavaScript 或 CSS 时,我们会遇到很多问题,因为浏览器之间存在差异。然而,在这里我们只有一个浏览器,不需要考虑复杂的解决方案。我们只需编写在 Webkit 下运行的代码即可。我们还可以使用与 Google Chrome 中几乎相同的开发者工具面板。启动我们的应用程序后,我们将看到以下窗口——即由 node-webkit 生成的窗口:
在右上角有一个小按钮,它为我们提供了访问 元素、网络、源代码、时间轴、配置文件、资源、审核 和 控制台 面板的权限。当我们点击该按钮时,我们会看到一个类似于以下截图的窗口:
拥有相同的工具简化了调试和测试过程。正如我们在本章开头所指出的,我们不必学习新的语言或使用不同的技术。我们可以坚持使用常见的 HTML、CSS 和 JavaScript。
编写应用程序的基础
在开始实际实现我们的文件浏览器之前,我们必须准备 HTML 布局、JavaScript 部分的基座和 package.json
文件。
编写 package.json 文件
package.json
文件应放置在项目的根目录下。它是一个包含类似以下代码内容的文件:
{
"name": "FileBrowser",
"main": "index.html",
"window": {
"toolbar": true,
"width": 1024,
"height": 800
}
}
我们已经讨论了 name
和 main
属性。window
对象是针对桌面环境的特定设置;它告诉 node-webkit 主应用程序窗口应该如何显示。在前面代码中,我们只设置了三个属性。width
和 height
属性定义了窗口大小,toolbar
隐藏或显示最上面的面板,使我们的程序看起来像浏览器。通常我们不需要它,在开发周期结束时,我们将 toolbar
设置为 false
。还有一些其他选项我们可以应用,例如 title
或 icon
。我们甚至可以隐藏关闭、最大化、最小化按钮。
准备 HTML 布局
我们开始准备布局的 HTML 代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>FileBrowser</title>
<link rel="stylesheet" href="css/styles.css">
<link rel="stylesheet" href="css/font-awesome-4.0.3/css/font-awesome.min.css">
<script src="img/scripts.js"></script>
</head>
<body>
<section class="tree-area">
<div class="current-location"></div>
<div class="tree"></div>
</section>
<section class="file-info"></section>
</body>
</html>
有两个 CSS 文件。第一个是styles.css
,它包含为我们的应用程序编写的样式,第二个使用来自font-awesome
的酷字体图标,这些图标由字体表示而不是图像。这个资源的确切内容不包括在本章中,但你可以从本书提供的附加材料中找到。
此外,一个scripts.js
文件将托管文件浏览器的 JavaScript 逻辑。
应用程序有两个部分:
-
树:这是我们展示当前目录的名称及其内容(文件和文件夹)的地方
-
文件信息:如果选中了一个文件,这个区域将显示一些其特征和复制、移动和删除的按钮
如果我们使用前面的代码运行 node-webkit,结果将如下所示:
设计 JavaScript 基础
让我们打开scripts.js
文件,看看如何构建 JavaScript 代码。在文件的开头,我们定义了所需的 Node.js 模块和一个全局变量root
:
var fs = require('fs');
var path = require('path');
var root = path.normalize(process.cwd());
我们使用fs
模块进行所有文件系统相关的操作。path
模块包含用于处理文件路径的实用方法。例如,操作系统之间有一些差异,例如,在 Windows 中,路径使用反斜杠编写,而在 Linux 中,它使用正斜杠。path.normalize
方法通过根据操作系统纠正字符串到正确的格式来处理这个问题。
我们将要读取的第一个文件夹是应用程序启动的目录。因此,我们使用process.cwd()
来获取当前工作目录。
在全局范围内工作不是一个好的做法,因此我们将创建一个名为Tree
的 JavaScript 类,如下所示:
var Tree = function() {
var api = {},
el,
currentLocationArea,
treeArea,
fileArea
api.cwd = root;
api.csf = null;
api.init = function(selector) {
el = document.querySelector(selector);
currentLocationArea = el.querySelector('.current-location');
treeArea = el.querySelector('.tree');
fileArea = document.querySelector('.file-info');
return api;
}
return api;
}
前面代码中的定义使用了揭示模块模式,这是一个很好的模式来封装 JavaScript 逻辑。api
对象是类的公共接口,并在最后返回。变量el
、currentLocationArea
、treeArea
和fileArea
是私有变量,代表页面上的 DOM 元素。它们在init
方法中初始化。缓存对 DOM 的查询是一个好的做法。通过将元素的引用存储在局部变量中,我们避免了额外的querySelector
调用。
有两个公共属性:cwd
(当前工作目录)和csf
(当前选中文件)。我们使它们成为公共的,因为我们可能需要在模块外部使用它们。一开始,没有选中的文件,csf
的值是null
。
与在浏览器中的开发类似,我们需要一个入口点。我们的代码在 Chromium 中运行,所以使用window.onload
看起来是一个不错的选择。我们将把初始化代码放在onload
处理程序中,如下所示:
var FileBrowser;
window.onload = function() {
FileBrowser = Tree().init('.tree-area');
}
我们只需创建我们类的一个实例并调用init
方法。我们传递了.tree-area
参数,这是<section>
标签的选择器,它将显示文件。
显示和使用工作目录
在本节中,我们将介绍我们的文件浏览器的核心功能。最后,我们的应用程序将读取当前工作目录。它将显示其内容,用户可以在显示的文件夹之间进行导航。
显示当前工作目录
我们将 api.cwd
的值放入具有 currentLocation
类的 div 中。它由 currentLocationArea
私有变量表示。我们只需要一个函数来设置元素的 innerHTML
属性:
var updateCurrentLocation = function() {
currentLocationArea.innerHTML = api.cwd;
}
这可能是我们类中最简单的函数。我们将每次更改目录时都调用它,这可能会很频繁。将此调用委托给另一个方法是个好主意。除了更新当前位置区域外,我们还将刷新文件区域。因此,编写一个 render
函数是有意义的。目前,该方法仅调用 updateCurrentLocation
,但我们将稍后添加更多函数:
var render = function() {
updateCurrentLocation();
}
api.init = function(selector) {
...
render();
return api;
}
当然,我们应该在 init
方法中调用这个 render
函数,这将给我们以下结果:
注意,现在我们的文件浏览器显示了进程开始处的目录。
显示文件和文件夹
在本章的这一部分,我们将创建一个函数,用于显示当前工作目录内放置的所有文件和文件夹。这听起来可能是一个很棒的功能,但它也伴随着它自己的问题。主要问题是如果我们进入文件系统的根目录,我们不得不在屏幕上显示大量项目。因此,我们不会构建一个巨大的树,而会在第三层嵌套处停止。让我们添加两个新的私有变量:
var html = '';
var maxLevels = 3;
html
变量将保持我们应用于 treeArea
元素的 innerHTML
属性的字符串。
我们的浏览器将以不同的方式处理文件和目录。如果用户选择一个文件,那么应该显示有关该文件的信息,例如文件创建时间、大小等。此外,我们的程序将提供一些按钮用于操作,如复制、移动或删除文件。如果点击了一个文件夹,那么 api.cwd
变量应该改变,并且应该触发 render
方法。视觉表示也应该不同。以下函数将向树中添加一个新项目:
var addItem = function(itemPath, fullPath, isFile, indent) {
itemPath = path.normalize(itemPath).replace(root, '');
var calculateIndent = function() {
var tab = ' ', str = '';
for(var i=0; i<indent; i++) {
str += tab;
}
return str;
}
if(isFile) {
html += '<a href="#" class="file" data-path="' + fullPath + '">';
html += calculateIndent(indent) + '<i class="fa fa-file-o"></i> ' + itemPath + '</a>';
} else {
html += '<a href="#" class="dir" data-path="' + fullPath + '">';
html += calculateIndent(indent) + '<i class="fa fa-folder-o"></i> ' + itemPath + '</a>';
}
}
itemPath
参数仅包含文件或目录的名称,而 fullPath
显示项目的绝对路径。根据 isFile
参数,将正确选择附加链接的图标。需要最新的 indent
参数来定义树的视觉外观。如果没有这个参数,所有链接都将从窗口的左侧开始。请注意,我们在 data-path
属性中添加了文件或文件夹的完整路径。我们这样做是因为稍后任何链接都可以点击,我们需要知道选择了什么。
现在,我们需要一个函数,该函数使用addItem
函数,它接受一个路径并遍历所有文件和子目录。我们还需要某种递归调用方法,以便我们可以生成一个树。正如我们可以在以下代码中看到的那样,有一个检查,如果我们在读取目录,并且如果是的话,再次执行walk
函数:
var walk = function(dir, level, done) {
if(level === maxLevels) {
done();
return;
}
fs.readdir(dir, function(err, list) {
if (err) return done(err);
var i = 0;
(function next() {
var file = list[i++];
if(!file) return done();
var filePath = dir + '/' + file;
fs.stat(filePath, function(err, stat) {
if (stat && stat.isDirectory()) {
addItem(file, filePath, false, level);
walk(filePath, level + 1, function() {
next();
});
} else {
if(level === 0) {
addItem(file, filePath, true, level);
}
next();
}
});
})();
});
};
由于walk
函数将被反复调用,我们需要检查它是否达到了最大嵌套级别(在我们的情况下设置为3
);这就是前几行的作用。紧接着,调用fs.readdir
函数。这是一个异步的 Node.js 原生函数,它返回传递的目录中的内容。在接收数据的闭包中,我们将遍历每个结果并检查项目是文件还是文件夹。如果是文件夹,则再次调用walk
函数。请注意,我们传递了级别,并且每次调用都会增加。
最后,我们只需运行walk
方法,并用初始值填充html
变量,就像以下代码中所做的那样:
var updateFiles = function() {
html = '<a href="#" class="dir" data-path="' + path.normalize(api.cwd + '/../') + '"><i class="fa fa-level-up"></i> ..</a>';
walk(api.cwd, 0, function() {
treeArea.innerHTML = html;
});
}
在文件树的顶部,我们添加了一个指向父目录的链接。这就是用户如何在文件系统中向上移动的方式。
更新的渲染方法如下:
var render = function() {
updateCurrentLocation();
updateFiles();
}
如我们所见,updateFiles
方法被调用得相当频繁。这有点昂贵,因为它运行了walk
函数。这也是限制文件夹嵌套的原因之一。如果我们现在启动应用程序,我们应该在屏幕顶部看到当前目录,并在treeArea
元素中看到其内容。以下截图显示了屏幕上的样子:
更改当前目录
我们的文件浏览器成功显示了位于我们硬盘上的文件。接下来,我们想要做的是从一个文件夹跳转到另一个文件夹。因为我们精心设计了我们的类,所以实现这个功能很容易。以下两个步骤将更改目录:
-
更新
api.cwd
变量 -
调用
render
方法
这两个动作应该在用户点击树中的某些项目时执行。非常流行的方法是在每个链接上附加一个click
处理程序并监听用户交互。然而,这将导致一些问题。每次树更新时,我们必须重新分配监听器;这是因为监听器附加到的元素已经被替换并且不再在 DOM 中。一个更好的方法是在treeArea
元素上仅添加一个处理程序。当其子元素产生click
事件时,默认情况下,它会在 DOM 中向上冒泡。此外,因为我们没有捕获它,所以它达到了treeArea
元素的处理器。所以以下setEvents
函数监听在treeArea
对象中触发的点击事件:
var setEvents = function() {
treeArea.addEventListener('click', function(e) {
e.preventDefault();
if(e.target.nodeName !== 'A' && e.target.nodeName !== 'I') return;
var link = e.target.nodeName === 'A' ? e.target : e.target.parentNode;
var itemPath = path.normalize(link.getAttribute('data-path'));
var isFile = link.getAttribute('class') === 'file';
if(isFile) {
updateFileArea(itemPath);
} else {
api.cwd = itemPath;
render();
}
});
}
需要调用e.preventDefault
是因为我们不希望执行默认的链接行为。所有<a>
标签的href
属性被设置为#
。通常情况下,这将使页面滚动到顶部。然而,我们不想这样,所以我们调用e.preventDefault
。接下来的检查确保click
事件来自正确的元素。这实际上非常重要,因为用户可能会点击一些其他元素,而这些元素仍然是treeArea
的子元素。我们期望获取到<a>
或<i>
(链接内的图标)标签。文件或文件夹的路径来自data-path
属性。为了确定当前选中的项是否是文件,我们检查其class
属性的值。另一方面,如果用户点击文件夹,我们简单地触发render
方法;否则,调用一个新的函数updateFileArea
。
我们刚才讨论的函数(setEvents
)只被触发一次,而进行这一操作的正确位置是init
方法:
api.init = function(selector) {
...
setEvents();
return api;
}
复制、移动和删除文件
我们实现了文件夹切换,接下来要做的就是文件处理。我们已经提到了调用updateFileArea
函数。它应该接受文件路径。以下代码是该函数的主体:
var updateFileArea = function(itemPath) {
var html = '';
api.csf = itemPath;
if(itemPath) {
fs.stat(itemPath, function(err, stat) {
html += '<h3>' + path.basename(itemPath) + '</h3>';
html += '<p>path: ' + path.dirname(itemPath) + '</p>';
html += '<p class="small">size: ' + stat.size + ' bytes</p>';
html += '<p class="small">last modified: ' + stat.mtime + '</p>';
html += '<p class="small">created: ' + stat.ctime + '</p>';
html += '<a href="javascript:FileBrowser.copy()"><i class="fa fa-copy"></i> Copy</a>';
html += '<a href="javascript:FileBrowser.move()"><i class="fa fa-share"></i> Move</a>';
html += '<a href="javascript:FileBrowser.del()"><i class="fa fa-times"></i> Delete</a>';
fileArea.innerHTML = html;
});
} else {
fileArea.innerHTML = '';
}
}
该方法的功能是将fileArea
元素填充与文件相关的信息。当用户点击文件夹时,我们将使用相同的函数清除fileArea
元素。因此,如果updateFileArea
函数没有传递任何参数,信息块将变为空。文件大小、创建和修改时间可以通过原生的 Node.js 函数fs.stat
获取。在文件特性的下方,我们放置了三个按钮。每个按钮都会调用全局FileBrowser
对象的方法,该对象是我们Tree
类的一个实例。请注意,我们没有传递文件的路径。copy
、move
和del
函数将从我们之前填充的api.csf
变量中获取这些信息。以下方法将用于将文件从一个地方复制到另一个地方:
api.copy = function() {
if(!api.csf) return;
getFolder(function(dir) {
var file = path.basename(api.csf);
fs.createReadStream(api.csf).pipe(fs.createWriteStream(dir + '/' + file));
api.csf = null;
updateFileArea();
alert('File: ' + file + ' copied.');
});
}
因此,我们知道我们想要复制、移动或删除的文件及其绝对路径,它存储在api.csf
中。为了复制和移动,我们需要一个目标路径。用户应该能够从硬盘上选择一个目录,因为这个过程发生在两个位置,所以将这个过程封装在一个函数中——getFolder
——是个好主意。一旦这个方法返回目标路径,我们只需将内容作为流获取并保存到另一个地方。以下是getFolder
辅助函数的主体:
var getFolder = function(callback) {
var event = new MouseEvent('click', {
'view': window,
'bubbles': true,
'cancelable': true
});
var input = document.createElement('INPUT');
input.setAttribute('type', 'file');
input.setAttribute('webkitdirectory', 'webkitdirectory');
input.addEventListener('change', function (e) {
callback(this.value);
});
input.dispatchEvent(event);
}
通常,没有用户交互,无法打开选择目录的对话框。然而,在 node-webkit 中这是可能的。正如前述代码所示,我们创建一个新的MouseEvent
事件和一个新的<input>
元素来分发此事件。关键因素是webkitdirectory
属性,这是 node-webkit 特有的,它将元素从文件选择器转换为文件夹选择器。getFolder
函数接受一个callback
函数,当用户选择目录时,该函数会被调用。
删除文件的函数看起来像以下代码片段:
api.del = function() {
if(!api.csf) return;
fs.unlink(api.csf, function() {
alert('File: ' + path.basename(api.csf) + ' deleted.');
render();
api.csf = null;
});
}
删除文件的函数几乎相同,只是它使用fs.unlink
从操作系统中删除文件。最后,移动文件的方法结合了copy
和del
函数。
api.move = function() {
if(!api.csf) return;
getFolder(function(dir) {
var file = path.basename(api.csf);
fs.createReadStream(api.csf).pipe(fs.createWriteStream(dir + '/' + file));
fs.unlink(api.csf, function() {
alert('File: ' + file + ' moved.');
render();
api.csf = null;
});
});
}
我们需要复制文件,然后从原始位置删除它。有了这个最后的补充,我们的文件浏览器就完成了。以下截图显示了选择文件时的外观:
扩展应用程序
我们文件浏览器的样子看起来不错。我们可以看到机器上的文件夹和文件,并且可以复制、移动或删除它们。此外,我们只使用了 HTML、CSS 和 JavaScript 就完成了所有这些。让我们继续并添加一个新功能。我们编写的应用程序由 Chromium 运行。换句话说,我们的 HTML 和 CSS 由浏览器渲染,因此我们可以在其中轻松显示图像。在接下来的几页中,我们将创建一个程序图片查看器。
调整 updateFileArea 函数
首先要做的是确定当前选定的文件是否为图像。我们将显示 JPEG 和 PNG 文件,因此我们应该检查文件是否匹配这些扩展名之一。在将标记填充到html
变量之前,我们将提取文件的扩展名,如下面的代码所示:
var updateFileArea = function(itemPath) {
var html = '';
api.csf = itemPath;
if(itemPath) {
fs.stat(itemPath, function(err, stat) {
var ext = path.extname(itemPath).toLowerCase();
var isImage = ext === '.jpg' || ext === '.jpeg' || ext === '.png';
html += '<h3>' + path.basename(itemPath) + '</h3>';
html += '<p>path: ' + path.dirname(itemPath) + '</p>';
html += '<p class="small">size: ' + stat.size + ' bytes</p>';
html += '<p class="small">last modified: ' + stat.mtime + '</p>';
html += '<p class="small">created: ' + stat.ctime + '</p>';
if(isImage) {
html += '<a href="javascript:FileBrowser.viewImage()"><i class="fa fa-picture-o"></i> View image</a>';
}
html += '<a href="javascript:FileBrowser.copy()"><i class="fa fa-copy"></i> Copy</a>';
html += '<a href="javascript:FileBrowser.move()"><i class="fa fa-share"></i> Move</a>';
html += '<a href="javascript:FileBrowser.del()"><i class="fa fa-times"></i> Delete</a>';
fileArea.innerHTML = html;
});
} else {
fileArea.innerHTML = '';
}
}
函数的下一个补充是一个仅在选中图片时显示的按钮。到目前为止(当我们有四个按钮时),对布局做一些更改以使所有按钮都在一行中是很好的。到目前为止,链接是block
元素,将它们改为inline-block
解决了问题。以下截图显示了结果:
为选定的图像加载新页面
与其他三个链接类似,新的链接调用全局FileBrowser
对象的函数——FileBrowser.viewImage
:
api.viewImage = function() {
window.open('image.html?file=' + api.csf, '_blank', 'width=600,height=400');
}
最好在新窗口中打开图像。为此,请使用window.open
方法。此方法在所有浏览器中均可用。它只需在新创建的弹出窗口中加载特定的文件/URL。如前述代码所示,将要显示的页面存储在名为image.html
的文件中。同时,图片的路径作为GET
参数发送,我们稍后会读取它。以下是在新文件中的代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>FileBrowser</title>
<link rel="stylesheet" href="css/styles.css">
<script src="img/imageviewer.js"></script>
</head>
<body>
<div class="image-viewer">
<img src="" />
<div class="dimension"></div>
</div>
</body>
</html>
页面上只有两样东西。一个空的 <img>
标签和一个空的 <div>
标签,后者将显示图片的尺寸。我们应该指出,这个新页面与 index.html
文件和 Tree
类无关,这是我们迄今为止使用的。这是一个完全新的部分,由另一个 JavaScript 文件——imageviewer.js
控制。
显示图像及其尺寸
我们必须解决两个困难。它们如下:
-
图片的路径是通过页面的 URL 发送的,因此我们应该从那里获取它。
-
图片的尺寸可以从客户端 JavaScript 中读取,但前提是图片已完全加载。因此,我们将使用 Node.js。
imageviewer.js
文件将包含一个类似于 scripts.js
文件的类。
var sizeOf = require('image-size'),
fs = require('fs'),
path = require('path');
var ImageViewer = function() {
var api = {};
// ...
return api;
}
var Viewer;
window.onload = function() {
Viewer = ImageViewer();
}
在文件开头,我们定义了将要使用的 Node.js 模块,fs
和 path
,这些模块在本章中已讨论。然而,image-size
是一个新模块。它接受一个图像路径并返回其宽度和高度。它不是一个原生 Node.js 模块,因此我们必须将其包含在我们的 package.json
文件中。
{
"name": "FileBrowser",
"main": "index.html",
"window": {
"toolbar": true,
"width": 690,
"height": 900
},
"dependencies": {
"image-size": "0.2.3"
}
}
node-webkit 应用程序运行时使用相同的依赖格式,我们必须调用 npm install
来在本地 node_modules
目录中安装模块。同时,请记住,应用程序的最终打包应该包括 node_modules
文件夹。一旦一切准备就绪,我们就可以展示选定的图片。这可以通过以下代码实现:
var filePath = decodeURI(location.search.split('file=')[1]);
if(fs.existsSync(path.normalize(filePath))) {
var img = document.querySelector('.image-viewer img');
img.setAttribute('src', 'file://' + filePath);
var dimensions = sizeOf(filePath);
document.querySelector('.dimension').innerHTML = 'Dimension: ' + dimensions.width + 'x' + dimensions.height;
}
location.search
函数返回页面的当前 URL。我们知道只有一个名为 file
的参数,因此我们可以分割字符串并仅使用数组的第二个元素,即我们感兴趣的参数。我们必须使用 decodeURI
,因为路径是 URL 编码的,我们可能会收到错误值。例如,间隔通常被替换为 %20
。
我们检查文件是否实际存在并确定其尺寸。其余部分涉及显示图片并在 <img>
标签下方显示大小作为文本。以下截图显示了窗口可能的外观:
移除工具栏
我们要做的最后一件事是隐藏 node-webkit 工具栏。用户不应该能够看到当前打开的文件。我们可以通过使用以下代码更改 package.json
文件来实现:
{
"name": "FileBrowser",
"main": "index.html",
"window": {
"toolbar": false,
"width": 690,
"height": 900
},
"dependencies": {
"image-size": "0.2.3"
}
}
将 toolbar
属性设置为 false
改变了我们的应用程序,现在它看起来更像是一个桌面程序,如下面的截图所示:
摘要
在本书的最后一章,你学习了如何使用 Node.js 构建桌面文件浏览器。最有趣的地方在于,我们只使用了 HTML、CSS 和 JavaScript。这是因为,大多数情况下,Node.js 都用于后端开发。我们探索了这个神奇技术所能提供的可能性领域。它既可以作为命令行工具,也可以作为任务运行器,甚至是桌面应用程序的包装器。庞大的开源社区和精心制作的包管理器使得 Node.js 成为全球开发者强大的工具。