JavaScript-现代-Web-开发框架教程-二-

82 阅读27分钟

JavaScript 现代 Web 开发框架教程(二)

原文:JavaScript Frameworks for Modern Web Dev

协议:CC BY-NC-SA 4.0

四、PM2

不要等待;时间永远不会“恰到好处”。从你所站的地方开始,使用你所掌握的任何工具,随着你的前进,你会发现更好的工具。—乔治·赫伯特

本节的前几章已经介绍了各种有用的 web 开发工具,我们主要关注客户端开发。在这一章中,我们将通过把我们的焦点转移到服务器上来完善我们对开发工具的介绍。我们将探索 PM2,这是一个命令行工具,它简化了许多与运行节点应用、监控它们的状态以及高效地扩展它们以满足不断增长的需求相关的任务。涵盖的主题包括:

  • 使用流程
  • 监控日志
  • 监控资源使用情况
  • 高级流程管理
  • 跨多个处理器的负载平衡
  • 零停机部署

装置

PM2 的命令行工具pm2可以通过 npm 获得。如果您还没有安装 PM2,您应该在继续之前安装,如清单 4-1 所示。

Listing 4-1. Installing the pm2 Command-line Utility via npm

$ npm install -g pm2

$ pm2 --version

0.12.15

Note

Node 的软件包管理器(npm)允许用户在两种环境中安装软件包:本地或全局。在本例中,bower安装在全局上下文中,该上下文通常是为命令行工具保留的。

使用流程

清单 4-2 显示了一个简单节点应用的内容,它将构成我们与 PM2 最初几次交互的基础。当被访问时,它只显示消息“Hello,world”给用户。

Listing 4-2. Simple Express Application

// my-app/index.js

var express = require('express');

var morgan = require('morgan');

var app = express();

app.use(morgan('combined'));

app.get('/', function(req, res, next) {

res.send('Hello, world.\n');

});

app.listen(8000);

图 4-1 展示了我们在pm2命令行工具的帮助下启动该应用的过程。在这个例子中,我们指示 PM2 通过执行它的index.js脚本来启动我们的应用。我们还为我们的应用向 PM2 提供了一个(可选的)名称(my-app),以便于我们以后引用它。在这样做之前,确保通过运行$ npm install安装项目的依赖项。

A978-1-4842-0662-1_4_Fig1_HTML.jpg

图 4-1。

Launching the application shown in Listing 4-2 with PM2

在调用了 PM2 的start命令后,PM2 显示了一个表格,其中包含了它当前知道的每个节点应用的信息,然后才让我们返回到命令提示符。表 4-1 总结了我们在本例中看到的列的含义。

表 4-1。

Summary of Columns Shown in Figure 4-1

| 标题 | 描述 | | --- | --- | | `App name` | 进程的名称。默认为执行的脚本的名称。 | | `id` | 由 PM2 分配给进程的唯一 ID。可以通过名称或 ID 引用进程。 | | `mode` | 执行的方法(`fork`或`cluster`)。默认为`fork`。在本章的后面会有更详细的探讨。 | | `pid` | 操作系统分配给进程的唯一编号。 | | `status` | 流程的当前状态(如`online`、`stopped`等)。). | | `restart` | PM2 重新启动进程的次数。 | | `uptime` | 自上次重新启动以来,进程已经运行的时间长度。 | | `memory` | 进程消耗的内存量。 | | `watching` | 指示 PM2 在检测到项目文件结构中的更改时是否会自动重新启动该过程。在开发过程中特别有用。默认为`disabled`。 |

如清单 4-3 中 PM2 提供的输出所示,我们的应用现在已经上线,可以使用了。我们可以通过使用curl命令行工具调用我们的应用的唯一路由来验证这一点,如图 4-2 所示。

A978-1-4842-0662-1_4_Fig2_HTML.jpg

图 4-2。

Accessing the sole route defined by our Express application Note

图 4-2 假设在您的环境中存在curl命令行工具。如果您碰巧在没有该工具的环境中工作,您也可以通过在 web 浏览器中直接打开它来验证该应用的状态。

除了start命令,PM2 还提供了许多有用的命令,用于与 PM2 已经知道的进程进行交互,其中最常见的命令如表 4-2 所示。

表 4-2。

Frequently Used Commands for Interacting with PM2 Processes

| 命令 | 描述 | | --- | --- | | `list` | 显示清单 4-4 中所示表格的最新版本 | | `stop` | 停止进程,但不将其从 PM2 列表中删除 | | `restart` | 重新启动该过程 | | `delete` | 停止进程并将其从 PM2 列表中删除 | | `show` | 显示有关指定进程的详细信息 |

简单的命令如stopstartdelete不需要额外的注释。另一方面,图 4-3 显示了当通过show命令请求关于特定 PM2 过程的信息时,您可以期望收到的信息。

A978-1-4842-0662-1_4_Fig3_HTML.jpg

图 4-3。

Viewing details for a specific PM2 process

从错误中恢复

至此,您已经熟悉了与 PM2 互动的一些基本步骤。你已经学会了如何在 PM2 的start命令的帮助下创建新流程。您还发现了如何在诸如liststoprestartdeleteshow等命令的帮助下管理正在运行的进程。然而,我们还没有讨论 PM2 在管理节点流程方面带来的真正价值。我们将从发现 PM2 如何帮助节点应用从致命错误中自动恢复开始讨论。

清单 4-3 显示了我们最初在清单 4-2 中看到的应用的修改版本。然而,在这个版本中,一个未被捕获的异常被定期抛出。

Listing 4-3. Modified Version of Our Original Application That Throws an Uncaught Exception Every Four Seconds

// my-bad-app/index.js

var express = require('express');

var morgan = require('morgan');

var app = express();

app.use(morgan('combined'));

app.get('/', function(req, res, next) {

res.send('Hello, world.\n');

});

setInterval(function() {

throw new Error('Uh oh.');

}, 4000);

app.listen(8000);

如果我们在没有 PM2 的帮助下,通过将它直接传递给node可执行文件来启动这个应用,我们会很快发现自己在第一个错误被抛出的时候就已经不走运了。在将我们转储回命令提示符之前,Node 会简单地将错误消息打印到控制台,如图 4-4 所示。

A978-1-4842-0662-1_4_Fig4_HTML.jpg

图 4-4。

Output provided by Node after crashing from the error shown in Listing 4-3

这种行为在真实的使用场景中不会让我们走得太远。理想情况下,已经发布到生产环境中的应用应该经过彻底的测试,并且没有这种无法捕获的异常。然而,在这种崩溃的情况下,应用至少应该能够在不需要人工干预的情况下恢复在线。PM2 可以帮助我们实现这一目标。

在图 4-5 中,我们通过delete命令从 PM2 的列表中删除了我们现有的流程,并创建了一个清单 4-3 中所示的糟糕的应用的新实例。之后,我们等待几秒钟,然后向 PM2 请求最新的进程列表。

A978-1-4842-0662-1_4_Fig5_HTML.jpg

图 4-5。

PM2 helps Node applications recover from fatal errors

注意到这里有什么有趣的吗?基于statusrestartuptime列中的值,我们可以看到我们的应用已经崩溃了三次。每一次,PM2 都帮助我们重新启动它。最近的进程总共运行了两秒钟,这意味着从现在起两秒钟后我们可以预期另一个崩溃(和自动重启)。

PM2 帮助应用从生产环境中的致命错误中恢复的能力虽然有用,但只是该工具提供的几个有用功能之一。PM2 在开发环境中同样有用,我们很快就会看到。

响应文件更改

设想一个场景,您最近开始了一个新的节点项目。我们假设它是一个用 Express 构建的 web API。如果没有其他工具的帮助,您必须手动重启相关的节点流程,才能看到正在进行的工作的效果——这是一项令人沮丧的工作,很快就会过时。在这种情况下,PM2 可以通过自动监控项目的文件结构来帮助您。当检测到变化,PM2 可以自动重启你的应用,如果你指示它这样做。

图 4-6 展示了这一过程。在这个例子中,我们首先删除当前正在运行的实例my-bad-app。接下来,我们创建一个应用的新实例,如我们最初的例子所示(参见清单 4-2 )。然而,这一次,我们传递了一个额外的标志,--watch,它指示 PM2 监控我们的项目的变化并做出相应的响应。

A978-1-4842-0662-1_4_Fig6_HTML.jpg

图 4-6。

Creating a new PM2 process that will automatically restart itself as changes are detected

随着更改被保存到这个项目的文件中,对 PM2 的list命令的后续调用将显示 PM2 已经重新启动了这个应用多少次,如前面的例子所示。

监控日志

回头参考清单 4-2 ,注意这个应用使用了morgan,一个用于记录传入 HTTP 请求的模块。在本例中,morgan被配置为将此类信息打印到控制台。我们可以通过node可执行文件直接运行我们的应用来查看结果,如图 4-7 所示。

A978-1-4842-0662-1_4_Fig7_HTML.jpg

图 4-7。

Logging incoming requests to Express with morgan

我们最近探索了如何允许 PM2 通过start命令为我们管理这个应用的执行(见图 4-1 )。这样做为我们提供了一些好处,但是它也使我们失去了对应用向控制台生成的输出的即时洞察力。幸运的是,PM2 为我们提供了一个监控这种输出的简单机制。

在图 4-3 中,我们通过show命令向 PM2 请求有关其控制下的特定过程的信息。所提供的信息中包含了 PM2 为该流程自动创建的两个日志文件的路径,一个标记为“输出日志路径”,另一个标记为“错误日志路径”,PM2 会将该流程的标准输出和错误消息分别保存到这两个日志文件中。我们可以直接查看这些文件,但是 PM2 提供了一个更方便的方法来与它们交互,如图 4-8 所示。

A978-1-4842-0662-1_4_Fig8_HTML.jpg

图 4-8。

Monitoring the output from processes under PM2’s control

在这里,我们可以看到如何通过logs命令根据需要监控 PM2 控制下的流程的输出。在本例中,我们监控 PM2 控制下的所有进程的输出。请注意 PM2 是如何在每个条目前添加关于每行输出来源的信息的。当使用 PM2 管理多个流程时,这些信息特别有用,我们将在下一节开始这样做。或者,我们也可以通过将特定进程的名称(或 ID)传递给logs命令来监控该进程的输出(参见图 4-9 )。

A978-1-4842-0662-1_4_Fig9_HTML.jpg

图 4-9。

Monitoring the output from a specific process under PM2’s control

如果您希望随时清除 PM2 生成的日志文件的内容,可以通过调用 PM2 的flush命令来快速完成。该工具的logs命令的行为也可以通过使用两个可选参数进行微调,这两个参数在表 4-3 中列出。

表 4-3。

Arguments Accepted by PM2’s logs Command

| 争吵 | 描述 | | --- | --- | | `–raw` | 显示日志文件的原始内容,去掉进程中带前缀的进程标识符 | | `–lines <``N` | 指示 PM2 显示最后 N 行,而不是默认的 20 行 |

监控资源使用情况

在上一节中,您了解了 PM2 如何帮助您监控标准输出以及在其控制下的流程所生成的错误。同样,PM2 还提供了易于使用的工具来监控这些进程的健康状况,以及监控运行这些进程的服务器的整体健康状况。

监控本地资源

图 4-10 展示了调用 PM2 的monit命令时产生的输出。在这里,我们可以看到一个持续更新的视图,它允许我们跟踪 CPU 处理能力的大小以及由 PM2 管理的每个进程消耗的 RAM 的大小。

A978-1-4842-0662-1_4_Fig10_HTML.jpg

图 4-10。

Monitoring CPU and memory usage via PM2’s monit command

监控远程资源

PM2 的monit命令提供的信息为我们提供了一种快速简单的方法来监控其进程的健康状况。在开发过程中,当我们主要关注在我们自己的环境中消耗的资源时,这个功能特别有用。然而,当一个应用转移到一个远程的生产环境中时,它就没那么有用了,这个环境很容易由多台服务器组成,每台服务器都运行自己的 PM2 实例。

PM2 考虑到这一点,还提供了一个内置的 JSON API,可以通过端口 9615 访问。默认禁用,启用过程如图 4-11 所示。

A978-1-4842-0662-1_4_Fig11_HTML.jpg

图 4-11。

Enabling PM2’s JSON web API

在这个例子中,我们通过调用工具的web命令来启用 PM2 的 web 可访问的 JSON API。PM2 将此功能作为独立于 PM2 本身运行的独立应用的一部分来实现。结果,我们可以看到一个新的进程,pm2-http-interface,现在在 PM2 的控制之下。如果我们希望禁用 PM2 的 JSON API,我们可以像删除其他进程一样删除这个进程,将它的名字(或 ID)传递给delete(或stop)命令。

清单 4-4 显示了通过端口 9615 向运行 PM2 的服务器发出 GET 请求时所提供的输出摘录。正如您所看到的,PM2 为我们提供了许多关于当前在其控制下的每个进程的详细信息,以及运行它的系统。

Listing 4-4. Excerpt of the Information Provided by PM2’s JSON API

{

"system_info": {

"hostname": "iMac.local",

"uptime": 2186

},

"monit": {

"loadavg": [1.39794921875],

"total_mem": 8589934592,

"free_mem": 2832281600,

"cpu": [{

"model": "Intel(R) Core(TM) i5-4590 CPU @ 3.30GHz",

"speed": 3300,

"times": {

"user": 121680,

"nice": 0,

"sys": 176220,

"idle": 1888430,

"irq": 0

}

}],

"interfaces": {

"lo0": [{

"address": "::1",

"netmask": "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",

"family": "IPv6",

"mac": "00:00:00:00:00:00",

"scopeid": 0,

"internal": true

}],

"en0": [{

"address": "10.0.1.49",

"netmask": "255.255.255.0",

"family": "IPv4",

"mac": "ac:87:a3:35:9c:72",

"internal": false

}]

}

},

"processes": [{

"pid": 1163,

"name": "my-app",

"pm2_env": {

"name": "my-app",

"vizion": true,

"autorestart": true,

"exec_mode": "fork_mode",

"exec_interpreter": "node",

"pm_exec_path": "/opt/my-app/index.js",

"env": {

"_": "/usr/local/opt/nvm/versions/node/v0.12.4/bin/pm2",

"NVM_IOJS_ORG_MIRROR": "https://iojs.org/dist

"NVM_BIN": "/usr/local/opt/nvm/versions/node/v0.12.4/bin",

"LOGNAME": "user",

"ITERM_SESSION_ID": "w0t0p0",

"HOME": "/Users/user",

"COLORFGBG": "7;0",

"SHLVL": "1",

"XPC_SERVICE_NAME": "0",

"XPC_FLAGS": "0x0",

"ITERM_PROFILE": "Default",

"LANG": "en_US.UTF-8",

"PWD": "/opt/my-app",

"NVM_NODEJS_ORG_MIRROR": "https://nodejs.org/dist

"PATH": "/usr/local/opt/nvm/versions/node/v0.12.4/bin",

"__CF_USER_TEXT_ENCODING": "0x1F5:0x0:0x0",

"SSH_AUTH_SOCK": "/private/tmp/com.apple.launchd.kEqu8iouDS/Listeners",

"USER": "user",

"NVM_DIR": "/usr/local/opt/nvm",

"NVM_PATH": "/usr/local/opt/nvm/versions/node/v0.12.4/lib/node",

"TMPDIR": "/var/folders/y3/2fphz1fd6rg9l4cg2t8t7g840000gn/T/",

"TERM": "xterm",

"SHELL": "/bin/bash",

"TERM_PROGRAM": "iTerm.app",

"NVM_IOJS_ORG_VERSION_LISTING": "https://iojs.org/dist/index.tab

"pm_cwd": "/opt/my-app"

},

"versioning": {

"type": "git",

"url": "git@github.com:tkambler/pro-javascript-frameworks.git",

"revision": "18104d13d14673652ee7a522095fc06dcf87f8ba",

"update_time": "2015-05-25T20:53:50.000Z",

"comment": "Merge pull request #28 from tkambler/ordered-build",

"unstaged": true,

"branch": "pm2",

"remotes": ["origin"],

"remote": "origin",

"branch_exists_on_remote": false,

"ahead": false,

"next_rev": null,

"prev_rev": "b0e486adab79821d3093c6522eb8a24455bfb051",

"repo_path": "/Users/user/repos/pro-javascript-frameworks"

}

},

"pm_id": 0,

"monit": {

"memory": 32141312,

"cpu": 0

}

}]

}

高级流程管理

到目前为止,本章的大部分重点都围绕着主要通过命令行与 PM2 进行的交互。像startstoprestartdelete这样的命令本身为我们提供了简单的机制,以快速、一次性的方式管理流程。但是更复杂的场景呢?也许应用需要在运行时指定额外的参数,或者它期望设置一个或多个环境变量。

JSON 应用声明

为了满足这些需求,需要额外的配置,而实现这一点的最佳方式是借助 PM2 所说的“JSON 应用配置”文件。清单 4-5 中显示了一个示例配置文件,它展示了大多数可用的各种选项。

Listing 4-5. Sample of the Various Options Available Within a JSON Application Configuration File

{

"name"              : "my-app",

"cwd"               : "/opt/my-app",

"args"              : ["--argument1=value", "--flag", "value"],

"script"            : "index.js",

"node_args"         : ["--harmony"],

"log_date_format"   : "YYYY-MM-DD HH:mm Z",

"error_file"        : "/var/log/my-app/err.log",

"out_file"          : "/var/log/my-app/out.log",

"pid_file"          : "pids/my-app.pid",

"instances"         : 1, // or 0 => 'max'

"max_restarts"      : 10, // defaults to 15

"max_memory_restart": "1M", // 1 megabytes, e.g.: "2G", "10M", "100K"

"cron_restart"      : "1 0 * * *",

"watch"             : false,

"ignore_watch"      : ["node_modules"],

"merge_logs"        : true,

"exec_mode"         : "fork",

"autorestart"       : false,

"env": {

"NODE_ENV": "production"

}

}

JSON 应用配置文件为我们提供了一种标准格式,以一种易于重复和与他人共享的方式将高级设置传递给 PM2。根据之前的示例(例如,nameout_fileerror_filewatch等),您在这里看到的几个选项应该很熟悉。).其他的将在这一章的后面提到。表 4-4 中提供了每一个的描述。

表 4-4。

Descriptions of the Various Configuration Settings Shown in Listing 4-5

| 环境 | 描述 | | --- | --- | | `name` | 应用的名称。 | | `cwd` | 将从中启动应用的目录。 | | `args` | 要传递给应用的命令行参数。 | | `script` | PM2 启动应用的脚本路径(相对于`cwd`)。 | | `node_args` | 传递给`node`可执行文件的命令行参数。 | | `log_date_format` | 生成日志时间戳的格式。 | | `error_file` | 标准错误消息将被记录到的路径。 | | `out_file` | 将记录突出输出消息的路径。 | | `pid_file` | 应用的 PID(进程标识符)将被记录到的路径。 | | `instances` | 要启动的应用实例的数量。将在下一节详细讨论。 | | `max_restarts` | 在放弃之前,PM2 将尝试重新启动(连续)失败的应用的最大次数。 | | `max_memory_restart` | 如果应用消耗的内存量超过这个阈值,PM2 将自动重启应用。 | | `cron_restart` | PM2 将按照指定的计划自动重启应用。 | | `watch` | PM2 是否应该在检测到文件结构更改时自动重新启动应用。默认为`false`。 | | `ignore_watch` | 如果启用了监视,PM2 应该忽略文件更改的位置数组。 | | `merge_logs` | 如果创建了一个应用的多个实例,PM2 应该为所有实例使用一个输出和错误日志文件。 | | `exec_mode` | 执行方法。默认为`fork`。将在下一节详细讨论。 | | `autorestart` | 自动重启崩溃或退出的应用。默认为`true`。 | | `vizon` | 如果启用,PM2 将尝试从应用的版本控制文件中读取元数据(如果它们存在)。默认为`true`。 | | `env` | 包含要传递给应用的环境变量键/值的对象。 |

本章包括一个microservices项目,它提供了 JSON 配置文件的工作演示。这个项目包含两个应用:一个是带有 API 的weather应用,它返回指定邮政编码的随机温度信息;另一个是main应用,它每两秒钟向 API 发出一次请求,并将结果打印到控制台。清单 4-6 显示了每个应用的主要脚本。

Listing 4-6. Source Code for the main and weather Applications

// microservices/main/index.js

var request = require('request');

if (!process.env.WEATHER_API_URL) {

throw new Error('The WEATHER_API_URL environment variable must be set.');

}

setInterval(function() {

request({

'url': process.env.WEATHER_API_URL + '/api/weather/37204',

'json': true,

'method': 'GET'

}, function(err, res, result) {

if (err) throw new Error(err);

console.log('The temperature is: %s', result.temperature.fahrenheit);

});

}, 2000);

// microservices/weather/index.js

if (!process.env.PORT) {

throw new Error('The PORT environment variable must be set.');

}

var express = require('express');

var morgan = require('morgan');

var app = express();

app.use(morgan('combined'));

var random = function(min, max) {

return Math.floor(Math.random() * (max - min + 1) + min);

};

app.get('/api/weather/:postal_code', function(req, res, next) {

var fahr = random(70, 110);

res.send({

'temperature': {

'fahrenheit': fahr,

'celsius': (fahr - 32) * (5/9)

}

});

});

app.listen(process.env.PORT);

一个 JSON 应用配置文件也包含在microservices项目中,其内容如清单 4-7 所示。

Listing 4-7. JSON Application Configuration File for this Chapter’s microservices Projectmicroservices/pm2/development.json

[

{

"name"              : "main",

"cwd"               : "../microservices",

"script"            : "main/index.js",

"max_memory_restart": "60M",

"watch"             : true,

"env": {

"NODE_ENV": "development",

"WEATHER_API_URL": "``http://localhost:7010

}

},

{

"name"              : "weather-api",

"cwd"               : "../microservices",

"script"            : "weather/index.js",

"max_memory_restart": "60M",

"watch"             : true,

"env": {

"NODE_ENV": "development",

"PORT": 7010

}

}

]

此处显示的应用配置文件为 PM2 提供了如何启动该项目中包含的每个应用的说明。在本例中,如果检测到任何一个应用的文件结构发生变化,或者如果它们开始消耗超过 60MB 的内存,PM2 将被指示重新启动每个应用。该文件还为 PM2 提供了要传递给每个进程的单独的环境变量。

Note

在运行这个示例之前,您需要调整这个文件中的cwd设置的值,以便它们引用您计算机上的microservices文件夹的绝对路径。在做了适当的调整后,用一个对 PM2 的调用启动这两个应用,如图 4-12 所示。

A978-1-4842-0662-1_4_Fig12_HTML.jpg

图 4-12。

Launching the main and weather-api applications with PM2

不出所料,PM2 为我们创建了两个实例,配置文件中引用的每个应用都有一个。和前面的例子一样,我们可以在 PM2 的logs命令的帮助下监控生成的输出(见图 4-13 )。

A978-1-4842-0662-1_4_Fig13_HTML.jpg

图 4-13。

Excerpt of the output generated by PM2’s logs command

跨多个处理器的负载平衡

Node I/O 模型的单线程、非阻塞特性使开发人员能够相对轻松地创建能够处理数千个并发连接的应用。虽然 Node 处理传入请求的效率令人印象深刻,但它也带来了一项巨大的开销:无法将计算分散到多个 CPU 上。幸运的是,Node 的核心cluster模块提供了解决这一限制的方法。有了它,开发人员可以编写能够创建自己的子进程的应用——每个子进程运行在单独的处理器上,每个子进程都能够与其他子进程和启动它的父进程共享端口的使用。

在我们结束这一章之前,让我们看一下由 PM2 提供的 Node 的cluster模块的一个方便的抽象。有了这个功能,最初没有利用 Node 的cluster模块的应用可以以一种允许它们充分利用多处理器环境的方式启动。因此,开发人员可以快速扩展他们的应用以满足不断增长的需求,而不必立即被迫增加服务器。

清单 4-8 显示了一个简单的 Express 应用的源代码,我们将在 PM2 的帮助下跨多个处理器进行扩展,而清单 4-9 显示了附带的 JSON 应用配置文件。

Listing 4-8. Express Application to be Scaled Across Multiple CPUs

// multicore/index.js

if (!process.env.port) throw new Error('The port environment variable must be set');

var express = require('express');

var morgan = require('morgan');

var app = express();

app.use(morgan('combined'));

app.route('/')

.get(function(req, res, next) {

res.send('Hello, world.');

});

app.listen(process.env.port);

Listing 4-9. JSON Application Configuration File with Which Our Application Will Be Launched

// multicore/pm2/development.json

{

"name": "multicore",

"cwd": "../multicore",

"max_memory_restart": "60M",

"watch": false,

"script": "index.js",

"instances": 0, // max

"exec_mode": "cluster",

"autorestart": true,

"merge_logs": true,

"env": {

"port": 9000

}

}

清单 4-9 中所示的应用配置文件包含两个感兴趣的关键项目。首先是instances属性。在这个例子中,我们指定了一个值0,它指示 PM2 为它找到的每个 CPU 启动一个单独的进程。第二个是exec_mode地产。通过指定cluster的值,我们指示 PM2 启动它自己的父进程,它将在节点的cluster模块的帮助下依次为我们的应用启动单独的子进程。

在图 4-14 中,我们通过将应用配置文件的路径传递给 PM2 的start命令来启动应用。之后,PM2 显示了所有已知进程的列表,和前面的例子一样。在本例中,我们看到 PM2 为我们环境中的八个可用 CPU 分别启动了一个单独的进程。我们可以通过使用monit命令监控每个新进程的 CPU 使用情况来验证这一点,如图 4-15 所示。

A978-1-4842-0662-1_4_Fig15_HTML.jpg

图 4-15。

Monitoring CPU usage with PM2’s monit command

A978-1-4842-0662-1_4_Fig14_HTML.jpg

图 4-14。

Launching the application on cluster mode with PM2 Note

当以集群模式启动应用时,PM2 会向控制台打印一条消息,警告该功能仍是测试版功能。然而,据 PM2 的首席开发人员称,只要使用 Node v0.12.0 或更高版本,这种功能对于生产环境来说就足够稳定了。

在继续之前,您可以通过运行$ pm2 delete multicore快速删除本例启动的八个进程中的每一个。

零停机部署

在集群模式下启动应用后,PM2 将开始以循环方式将传入的请求转发给它所控制的八个进程中的每一个——为我们提供了巨大的性能提升。一个额外的好处是,让我们的应用分布在多个处理器上还允许我们发布更新,而不会导致任何停机,我们马上就会看到这一点。

想象一个场景,在 PM2 的控制下,一个应用运行在一个或多个服务器上。随着该应用的更新变得可用,向公众发布它们将涉及两个关键步骤:

  • 将更新的源代码复制到适当的服务器
  • 在 PM2 的控制下重新启动每一个进程

随着这些步骤的进行,将引入一段短暂的停机时间,在此期间,对应用的传入请求将被拒绝——除非采取特殊的预防措施。幸运的是,在集群模式下使用 PM2 启动应用为我们提供了采取这些预防措施所需的工具。

为了避免重新启动我们之前在清单 4-8 中看到的应用时的任何停机时间,我们首先需要对我们的应用的源代码和应用配置文件做一个小的调整。更新后的版本如清单 4-10 所示。

Listing 4-10. Application Designed to Take Advantage of PM2’s gracefulReload Command

// graceful/index.js

if (!process.env.port) throw new Error('The port environment variable must be set');

var server;

var express = require('express');

var morgan = require('morgan');

var app = express();

app.use(morgan('combined'));

app.route('/')

.get(function(req, res, next) {

res.send('Hello, world.');

});

process.on('message', function(msg) {

switch (msg) {

case 'shutdown':

server.close();

break;

}

});

server = app.listen(process.env.port, function() {

console.log('App is listening on port: %s', process.env.port);

});

// graceful/pm2/production.json

{

"name": "graceful",

"cwd": "../graceful",

"max_memory_restart": "60M",

"watch": false,

"script": "index.js",

"instances": 0, // max

"exec_mode": "cluster",

"autorestart": true,

"merge_logs": false,

"env": {

"port": 9000,

"PM2_GRACEFUL_TIMEOUT": 10000

}

}

前面的例子已经演示了 PM2 的restart命令的使用,它可以立即停止和启动指定的进程。虽然这种行为在非生产环境中通常不是问题,但是当我们考虑它对我们的应用在发出该命令时可能正在处理的任何活动请求的影响时,问题就开始出现了。当稳定至关重要时,PM2 的gracefulReload指令是一个更合适的选择。

当被调用时,gracefulReload首先向其控制下的每个进程发送一个shutdown消息,为它们提供采取任何必要预防措施的机会,以确保任何活动连接都不会受到干扰。只有在经过了一段可配置的时间(通过PM2_GRACEFUL_TIMEOUT环境变量指定)后,PM2 才会继续重启进程。

在这个例子中,在收到shutdown消息后,我们的应用通过调用 Express 为我们创建的 HTTP 服务器上的close()方法进行响应。这个方法指示我们的服务器停止接受新的连接,但是允许已经建立的连接完成。只有在十秒钟后(通过PM2_GRACEFUL_TIMEOUT指定),PM2 才会重新启动该进程,此时该进程管理的任何连接都应该已经完成。

图 4-16 展示了通过使用gracefulReload命令启动和随后重启该应用的过程。通过这样做,我们能够在不中断应用用户的情况下发布更新。

A978-1-4842-0662-1_4_Fig16_HTML.jpg

图 4-16。

Gracefully reloading each of the processes under PM2’s control

摘要

PM2 为开发人员提供了一个强大的管理节点应用的实用工具,无论是在生产环境还是非生产环境中,它都同样适用。一些简单的方面,例如当源代码发生变化时,该工具能够自动重启其控制下的进程,这在开发过程中可以节省大量时间。更高级的功能,如跨多个处理器负载平衡应用的能力,以及以不会对用户产生负面影响的方式正常重启这些应用的能力,也为使用大量节点提供了关键功能。

相关资源

五、RequireJS

思考我能控制的事情比担心和烦恼我控制不了的事情更有成效。担忧不是一种思考方式。——彼得·圣安德烈

虽然 JavaScript 现在在 web 应用中扮演着重要得多的角色,但 HTML5 规范(以及现代浏览器)并没有指定检测脚本间依赖关系的方法,也没有指定如何以特定的顺序加载脚本依赖关系。在最简单的场景中,脚本通常用简单的<script>标签在页面标记中引用。这些标签是按顺序计算、加载和执行的,这意味着通常首先包括公共库或模块,然后是应用脚本。(例如,一个页面可能会加载 jQuery,然后加载一个使用 jQuery 来操作文档对象模型[DOM]的应用脚本。)具有容易跟踪的依赖关系层次结构的简单网页非常适合这种模型,但是随着 web 应用的复杂性增加,应用脚本的数量将会增加,并且依赖关系的网络可能变得难以管理,如果不是不可能的话。

异步脚本使得整个过程更加混乱。如果一个<script>标签拥有一个async属性,脚本内容将在后台通过 HTTP 加载,并在可用时立即执行。加载脚本时,页面的其余部分,包括任何后续的脚本标记,将继续加载。当评估和执行应用脚本时,异步加载的大型依赖项(或由慢速源交付的依赖项)可能不可用。即使应用的<script>标签也拥有async属性,开发人员也无法控制所有异步脚本的加载顺序,因此无法确保依赖层次结构得到尊重。

Tip

HTML5 <script>标签属性defer类似于async,但是会延迟脚本的执行,直到页面解析完成。这两个属性都减少了页面呈现延迟,从而改善了用户体验和页面性能。这对于移动设备尤其重要。

RequireJS 就是为了解决这种依赖关系编排问题而创建的,它为开发人员提供了一种编写 JavaScript 模块(“脚本”)的标准方法,这些模块在执行任何模块之前声明它们自己的依赖关系。通过预先声明所有的依赖关系,RequireJS 可以确保在以正确的顺序执行模块的同时,异步加载整个依赖关系层次结构。这种模式称为异步模块定义(AMD),与 Node.js 和 Browserify 模块加载库采用的 CommonJS 模块加载模式形成对比。虽然在各种用例中使用这两种模式肯定有其优点,但开发 RequireJS 和 AMD 是为了解决特定于 web 浏览器和 DOM 缺点的问题。实际上,RequireJS 和 Browserify 在实现中做出的让步通常会被工作流和社区插件所缓解。

例如,RequireJS 可以为它必须加载的非 AMD 依赖项(通常是内容交付网络上的远程库或遗留代码)创建动态垫片。这一点很重要,因为 RequireJS 假设 web 应用中的脚本可能来自多个来源,并且不会全部直接在开发人员的控制之下。默认情况下,RequireJS 不会将所有应用脚本(“打包”)连接到一个文件中,而是选择为它加载的每个脚本发出 HTTP 请求。稍后讨论的 RequireJS 工具 r.js 为生产环境生成打包的包,但是仍然可以从其他位置加载远程的填充脚本。另一方面,Browserify 采取了“包优先”的方法。它假设所有内部脚本和依赖项将被打包到一个文件中,其他远程脚本将被单独加载。这将远程脚本置于 Browserify 的控制之外,但是像bromote这样的插件在 CommonJS 模型中工作,以便在打包过程中加载远程脚本。对于这两种方法,最终结果是相同的:应用在运行时可以使用远程资源。

运行示例

本章包含了许多可以在现代网络浏览器中运行的例子。Node.js 是安装代码依赖项和运行所有 web 服务器脚本所必需的。

要安装示例代码依赖项,请在终端中打开code/requirejs目录并执行命令npm install。这个命令将读取package.json文件,并下载运行每个示例所需的几个包。

本章中的示例代码块在顶部包含一个注释,以指示在哪个文件中可以找到源代码。例如,清单 5-1 中虚构的index.html文件可以在example-000/public目录中找到。(这个目录并不真的存在,找不到也不用担心。)

Listing 5-1. An Exciting HTML File

<!-- example-000/public/index.html -->

<html>

<head></head>

<body><h1>Hello world!</h1></body>

</html>

除非另有说明,否则假设所有示例代码目录都包含一个启动非常基本的 web 服务器的index.js文件。清单 5-2 展示了如何在终端中使用 Node.js 来运行虚构的 web 服务器脚本example-000/index.js

Listing 5-2. Launching an Exciting Web Server

example-000$ node index.js

>> mach web server started on node 0.12.0

>> Listening on :::8080, use CTRL+C to stop

命令输出显示 web 服务器在http://localhost:8080监听。在 web 浏览器中,导航到http://localhost:8080/index.html将呈现清单 5-1 中的 HTML 片段。

使用要求

在 web 应用中使用 RequireJS 的工作流通常包括一些常见步骤。首先,RequireJS 必须加载到一个带有<script>标签的 HTML 文件中。RequireJS 可以作为 web 服务器或 CDN 上的独立脚本引用,也可以与 Bower 和 npm 等包管理器一起安装,然后从本地 web 服务器提供服务。接下来,必须配置 RequireJS,以便它知道脚本和模块位于何处,如何填充不符合 AMD 的脚本,加载哪些插件,等等。一旦配置完成,RequireJS 将加载一个主应用模块,该模块负责加载主要的页面组件,实质上是“启动”页面的应用代码。此时,RequireJS 评估模块创建的依赖关系树,并开始在后台异步加载依赖关系脚本。一旦加载了所有模块,应用代码就开始做它权限内的任何事情。

在接下来的章节中,我们将详细考虑这一过程中的每一步。每一节中使用的示例代码代表了一个简单应用的发展,该应用将显示(半)名人的励志和幽默语录。

装置

RequireJS 脚本可以直接从 http://requirejs.org 下载。它有几种不同的风格:普通的 RequireJS 脚本、与 jQuery 预绑定的普通 RequireJS 脚本,以及包含 RequireJS 及其打包工具 r.js 的 Node.js 包。预绑定的 jQuery 脚本只是为了方便开发人员而提供的。如果您希望将 RequireJS 添加到已经使用 jQuery 的项目中,那么普通的 RequireJS 脚本可以适应现有的 jQuery 安装,不会有任何问题,尽管可能需要对旧版本的 jQuery 进行填充。(填补的脚本将在后面介绍。)

一旦获取了 RequireJS 脚本,就会在 web 应用中使用一个<script>标签引用它。因为 RequireJS 是一个模块加载器,它承担着加载应用可能需要的所有其他 JavaScript 文件和模块的责任。因此,RequireJS <script>标签很可能是唯一一个占据网页的<script>标签。清单 5-3 中给出了一个简化的例子。

Listing 5-3. Including the RequireJS Script on a Web Page

<!-- example-001/public/index.html -->

<body>

<header>

<h1>Ponderings</h1>

</header

<script src="/scripts/require.js"></script>

</body>

配置

在 RequireJS 脚本加载到页面上之后,它会寻找一个配置,这个配置将主要告诉 RequireJS 脚本和模块位于何处。在中,可以通过三种方式之一提供配置选项。

首先,可以在加载 RequireJS 脚本之前创建一个全局require对象。该对象可能包含所有的 RequireJS 配置选项以及一个“启动”回调,一旦 RequireJS 加载完所有的应用模块,就会执行该回调。

清单 5-4 中的脚本块显示了存储在全局require变量中的一个新生成的 RequireJS 配置对象。

Listing 5-4. Configuring RequireJS with a Global require Object

<!-- example-001/public/config01.html -->

<body>

<header>

<h1>Ponderings</h1>

</header>

<section id="quotes"></section>

<script>

/*

* Will be automatically attached to the

* global window object as window.require.

*/

var require = {

// configuration

baseUrl: '/scripts',

// kickoff

deps: ['quotes-view'],

callback: function (quotesView) {

quotesView.addQuote('Lorem ipsum dolor sit amet, consectetur adipiscing elit.');

quotesView.addQuote('Nunc non purus faucibus justo tristique porta.');

}

};

</script>

<script src="/scripts/require.js"></script>

</body>

这个对象上最重要的配置属性baseUrl标识了相对于应用根的路径,RequireJS 应该从该路径开始解析模块依赖关系。deps数组指定了配置后应该立即加载的模块,而callback函数的作用是在模块加载后接收这些模块。这个例子加载了一个模块quotes-view。一旦回调被调用,它就可以访问这个模块上的属性和方法。

清单 5-5 中的目录树显示了quotes-view.js文件相对于config01.html(正在查看的页面)和require.js的位置。

Listing 5-5. Application File Locations

■??]

■??]

◆θ★★★★★★★★★★★★★★★★★★★★★★

★★★★★★★★★★★★★★

ε──??″

ε──??″

注意在deps数组中省略了quotes-view模块的绝对路径和文件扩展名。默认情况下,RequireJS 假定任何给定的模块都是相对于正在查看的页面定位的,并且它包含在具有适当文件扩展名的单个 JavaScript 文件中。在这种情况下,后一个假设是正确的,但第一个不是,这就是为什么指定一个baseUrl属性是必要的。当 RequireJS 试图解析任何模块时,它将组合任何配置的baseUrl值和模块名,然后附加.js文件扩展名以产生相对于应用根的完整路径。

config01.html页面加载时,传递给quotesView.addQuote()方法的字符串将显示在页面上。

第二种配置方法与第一种类似,但在加载 RequireJS 脚本后使用 RequireJS API 来执行配置,如清单 5-6 所示。

Listing 5-6. Configuration with the RequireJS API

<!-- example-001/public/config02.html -->

<body>

<header>

<h1>Ponderings</h1>

</header>

<section id="quotes"></section>

<script src="/scripts/require.js"></script>

<script>

// configuration

requirejs.config({

baseUrl: '/scripts'

});

// kickoff

requirejs(['quotes-view'], function (quotesView) {

quotesView.addQuote('Lorem ipsum dolor sit amet, consectetur adipiscing elit.');

quotesView.addQuote('Nunc non purus faucibus justo tristique porta.');

});

</script>

</body>

在这个例子中,<script>块首先使用由require.js脚本创建的全局requirejs对象,通过调用它的config()方法来配置 RequireJS。然后它调用requirejs来启动应用。传递给config()方法的对象类似于清单 5-4 中的全局require对象,但是缺少其depscallback属性。requirejs函数接受一组应用依赖项和一个回调函数,这种模式在后面介绍模块设计时会变得非常熟悉。

最终效果是一样的:RequireJS 使用它的配置来加载quotes-view模块,一旦加载,回调函数就与它交互来影响页面。

第三种配置方法使用第二种方法的语法,但是将配置和启动代码移到它自己的脚本中。清单 5-7 中的 RequireJS <script>标签使用data-main属性告诉 RequireJS 它的配置和启动模块位于何处。

Listing 5-7. Configuring RequireJS with an External Script

<!-- example-001/public/config03.html -->

<body>

<header>

<h1>Ponderings</h1>

</header>

<section id="quotes"></section>

<script src="/scripts/require.js" data-main="/scripts/main.js"></script>

</body>

一旦加载了 RequireJS,它将寻找data-main属性,如果找到,异步加载属性中指定的脚本。清单 5-8 显示了main.js的内容,与清单 5-6 中的<script>块相同。

Listing 5-8. The RequireJS Main Module

// example-001/public/scripts/main.js

// configuration

requirejs.config({

baseUrl: '/scripts'

});

// kickoff

requirejs(['quotes-view'], function (quotesView) {

quotesView.addQuote('Lorem ipsum dolor sit amet, consectetur adipiscing elit.');

quotesView.addQuote('Nunc non purus faucibus justo tristique porta.');

});

Tip

因为data-main脚本是异步加载的,所以包含在 RequireJS 之后的脚本或<script>块可能会首先运行。如果 RequireJS 管理一个应用中的所有脚本,或者在 RequireJS 之后加载的脚本对应用本身没有影响(比如广告商脚本),就不会有冲突。

应用模块和依赖关系

RequireJS 模块由三部分定义:

A module name   A list of dependencies (modules)   A module closure that will accept the output from each dependency module as function arguments, set up module code, and potentially return something that other modules can use  

清单 5-9 展示了假模块定义中的每一点。当全局define()函数被调用时,模块被创建。这个函数有三个参数,对应于上面的三点。

Listing 5-9. Module Anatomy

define(``/*#1*/``'m1',``/*#2*/``['d1', 'd2'],``/*#3*/

/*

* Variables declared within the module closure

* are private to the module, and will not be

* exposed to other modules

*/

var privateModuleVariable = "can’t touch this";

/*

* The returned value (if any) will now be available

* to any other module if they specify m1 as a

* dependency.

*/

return {

getPrivateModuleVariable: function () {

return privateModuleVariable;

}

};

})

模块的名字是关键。在清单 5-9 中,明确声明了一个模块名m1。如果省略了模块名(将依赖项和模块闭包作为传递给define()的唯一参数),那么 RequireJS 将假设模块名是包含模块脚本的文件名,没有扩展名.js。这在实践中很常见,但是为了清楚起见,这里显示了模块名称。

Tip

给模块指定特定的名称会带来不必要的复杂性,因为需要依赖脚本 URL 路径来加载模块。如果一个模块被显式命名,而文件名与模块名不匹配,那么需要在 RequireJS 配置中定义一个模块别名,将模块名映射到一个实际的 JavaScript 文件。这将在下一节中介绍。

清单 5-9 中的依赖列表标识了 RequireJS 应该加载的另外两个模块。值d1d2是这些模块的名称,位于脚本文件d1.jsd2.js中。这些脚本看起来类似于清单 5-9 中的模块定义,但是它们将加载自己的依赖项。

最后,模块闭包接受每个依赖模块的输出作为函数参数。这个输出是从每个依赖模块的闭包函数返回的任何值。清单 5-9 中的闭包返回它自己的值,如果另一个模块将m1声明为依赖项,那么这个返回值将被传递给那个模块的闭包。

如果一个模块没有依赖关系,那么它的依赖数组将会是空的,并且它不会收到任何关于它的闭包的参数。

一旦模块被加载,它就存在于内存中,直到应用被终止。如果多个模块声明了同一个依赖项,则该依赖项只加载一次。它从闭包返回的任何值都将通过引用传递给两个模块。然后,给定模块的状态在使用它的所有其他模块之间共享。

一个模块可以返回任何有效的 JavaScript 值,或者根本不返回任何值,如果模块的存在只是为了操纵其他模块或者只是在应用中产生副作用。

清单 5-10 显示了example-002/public目录的结构。这看起来与example-001相似,但是添加了一些额外的模块,即data/quotes.js(一个用于获取报价数据的模块)和util/dom.js(一个为其他模块包装全局window对象以便它们不需要直接访问window的模块)。

Listing 5-10. Public Directory Structure for example-``002

public

■??]

■??]

◆θ★★★★★★★★★★★★★★★★★★★★★★

──★??∮

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

★★★★★★★★★★★★★★

★★★★★★★★★★★★★★

回想一下,模块的依赖关系是相对于 RequireJS baseUrl值而存在的。当一个模块指定依赖路径时,它是相对于baseUrl路径来指定的。在清单 5-11 中,main.js文件依赖于data/quotes模块(public/scripts/data/quotes.js),而quotes-view.js模块依赖于util/dom ( public/scripts/util/dom.js)。

Listing 5-11. Module Dependency Paths

// example-002/public/scripts/main.js

requirejs(['data/quotes', 'quotes-view'], function (quoteData, quotesView) {

// ...

});

// example-002/public/scripts/data/quotes.js

define([/*no dependencies*/], function () {

// ...

});

// example-002/public/scripts/quotes-view.js

define(['util/dom'], function (dom) {

// ...

});

// example-002/public/scripts/util/dom.js

define([/*no dependencies*/], function () {

// ...

});

图 5-1 显示了加载这些模块时创建的逻辑依赖树。

A978-1-4842-0662-1_5_Fig1_HTML.gif

图 5-1。

RequireJS dependency tree

随着应用依赖性的增加,模块路径会变得单调乏味,但是有两种方法可以减轻这种情况。

首先,模块可以使用前导点符号来指定相对于自身的依赖关系。例如,一个声明了依赖关系./foo的模块会将foo.js作为一个兄弟文件加载,与它自己位于同一个 URL 段上,而一个具有依赖关系../bar的模块会将bar.js从它自己“向上”加载一个 URL 段。这大大减少了依赖性的冗长。

第二,模块可以用路径别名命名,在 RequireJS 配置中定义,如下一节所述。

路径和别名

给一个模块分配一个别名允许其他模块使用该别名作为依赖名,而不是完整的模块路径名。由于各种原因,这可能是有用的,但通常用于简化供应商模块路径,从供应商模块名称中消除版本号,或者处理显式声明自己的模块名称的供应商库。

清单 5-12 中的模块依赖于供应商库 jQuery。如果jquery模块脚本位于/scripts/jquery.js处,则不需要模块别名来加载依赖关系;RequireJS 将根据已配置的baseUrl配置值来定位模块。

Listing 5-12. Specifying a jQuery Module Dependency

define(['jquery'], function ($) {

// ...

});

然而,jquery不太可能位于由baseUrl配置定义的模块根。更有可能的是,jquery脚本将存在于供应商目录中,例如/scripts/vendor/jquery,并且脚本名称将包含 jQuery 版本(例如jquery-2.1.3.min),因为 jQuery 脚本就是这样分发的。更复杂的是,jQuery 明确声明了自己的模块名jquery。如果模块试图使用 jQuery 脚本的完整路径/scripts/vendor/jquery/jquery-2.1.3.min加载jquery,RequireJS 将通过 HTTP 加载脚本,然后无法导入模块,因为它声明的名称是jquery,而不是jquery-2.1.3.min

Tip

显式命名模块被认为是不好的做法,因为应用模块必须使用模块声明的名称,并且包含该模块的脚本文件必须共享其名称或者在 RequireJS 配置中有别名。为 jQuery 做了一个特殊的让步,因为它是一个相当普遍的库。

别名在 RequireJS 配置散列中的paths属性下指定。在清单 5-13 中,别名jquery被分配给vendor/jquery/jquery-2.1.3.min,这是一个相对于baseUrl的路径。

Listing 5-13. Configuration Module Path Aliases

requirejs.config({

baseUrl: '/scripts',

// ... other options ...

paths: {

'jquery': 'vendor/jquery/jquery-2.1.3.min'

}

});

paths对象中,别名是键,它们映射到的脚本是值。一旦定义了模块别名,它就可以在任何其他模块的依赖列表中使用。清单 5-14 显示了正在使用的jquery别名。

Listing 5-14. Using a Module Alias in a Dependency List

// jquery alias points to vendor/jquery/jquery-2.1.3.min

define(['jquery'], function ($) {

// ...

});

因为模块别名优先于实际的模块位置,所以 RequireJS 将在试图在/scripts/jquery.js定位 jQuery 脚本之前解析它的位置。

Note

匿名模块(没有声明自己的模块名)可以用任何模块名作为别名,但是如果命名模块有别名(像jquery),它们必须用它们声明的模块名作为别名。

加载带有代理模块的插件

jQuery、下划线、Lodash、Handlebars 等库都有插件系统,允许开发人员扩展各自的功能。战略性地使用模块别名实际上可以帮助开发人员一次性加载这些库的扩展,而不必在每个使用它们的模块中指定这样的扩展。

在清单 5-15 中,为了简洁起见,jquery脚本位置用名称jquery作为别名,自定义模块util/jquery-all用名称jquery-all作为别名。所有的应用模块将通过指定jquery-all为依赖项来加载jquery。反过来,jquery-all模块加载普通的jquery模块,然后给它附加定制插件。

Listing 5-15. Using Module Aliases to Load jQuery Plugins

requirejs.config({

baseUrl: '/scripts',

// ... other options ...

paths: {

// vendor script

'jquery': 'vendor/jquery/jquery-2.1.3.min',

// custom extensions

'jquery-all': 'util/jquery-all'

}

});

// example-003/public/scripts/util/jquery-all

define(['jquery'], function ($) {

$.fn.addQuotes = function () {/*...*/};

return $;

// or

//return $.noConflict(true);

});

jquery-all代理模块返回 jQuery 对象本身,这允许依赖于jquery-all的模块使用加载的定制扩展访问jquery。默认情况下,jQuery 向全局window对象注册自己,即使它被用作 AMD 模块。如果所有的应用模块都通过jquery-all模块(或者甚至是普通的jquery模块,正如大多数供应商库所做的那样)访问 jQuery,那么就不需要 jQuery 全局变量。可以通过调用$.noConflict(true)将其移除。这将返回jquery对象,并且是清单 5-15 中jquery-all模块的替代返回值。

因为 jQuery 现在是示例应用的一部分,所以负责在 DOM 中呈现报价数据的quotes-view模块不再需要依赖于util/dom模块。它可以将jquery-all指定为依赖项,并一次性加载jquery和自定义的addQuotes()插件方法。清单 5-16 显示了对quotes-view模块所做的更改。

Listing 5-16. Loading jQuery and Custom Plugins in the quotes-view Module

// example-003/public/scripts/quotes-view.js

define(['jquery-all'], function ($) {

var $quotes = $('#quotes');

return {

render: function (groupedQuotes) {

for (var attribution in groupedQuotes) {

if (!groupedQuotes.hasOwnProperty(attribution)) continue;

$quotes.addQuotes(attribution, groupedQuotes[attribution]);

}

}

};

});

使用模块代理来加载jquery的优点是,它消除了在依赖于jquery和定制插件模块的其他模块中指定这两者的需要。例如,如果没有这种技术,应用模块将会有多个依赖项来确保在需要时加载适当的 jQuery 插件,如清单 5-17 所示。

Listing 5-17. Loading Plugins Without a Proxy Module

// scripts/util/jquery-plugin-1.js

define(['jquery'], function ($) {

$.fn.customPlugin1 = function () {/*...*/};

});

// scripts/util/jquery-plugin-2.js

define(['jquery'], function ($) {

$.fn.customPlugin2 = function () {/*...*/};

});

// scripts/*/module-that-uses-jquery.js

define(['jquery', 'util/jquery-plugins-1', 'util/jquery-plugins-2'], function ($) {

// ...

});

在这种情况下,即使jquery-plugin-1jquery-plugin-2没有返回值,它们仍然必须作为依赖项添加,这样它们的副作用——向jquery模块添加插件——仍然会发生。

垫片

支持 AMD 模块格式的库可以直接用于 RequireJS。通过配置 RequireJS 垫片或手动创建垫片模块,仍可使用非 AMD 库。

example-003中的data/quotes模块公开了一个groupByAttribution()方法,该方法迭代引用集合。它创建了一个散列,其中键是人名,值是属于他们的引号数组。这种分组功能可能对其他集合也很有用。

幸运的是,供应商库 undrln 可以提供该功能的通用版本,但它与 AMD 不兼容。对于其他 AMD 模块来说,使用 undrln 作为依赖项,需要一个垫片。Undrln 是作为函数闭包内的标准 JavaScript 模块编写的,如清单 5-18 所示。它将自己分配给全局window对象,页面上的其他脚本可以访问它。

Note

脚本公然模仿了 Lodash API 的一个子集,不兼容 AMD 模块,专门用于本章的例子。

Listing 5-18. The Completely Original Undrln Library

// example-004/public/scripts/vendor/undrln/undrln.js

/**

* undrln (c) 2015 l33th@x0r

* MIT license.

* v0.0.0.0.1-alpha-DEV-theta-r2

*/

(function () {

var undrln = window._ = {};

undrln.groupBy = function (collection, key) {

// ...

};

}());

要创建一个 shim,必须向 RequireJS 配置中添加一些东西。首先,必须在paths下创建一个模块别名,以便 RequireJS 知道填充的模块位于何处。其次,必须将一个垫片配置条目添加到shim部分。两者都被添加到清单 5-19 中的 RequireJS 配置中。

Listing 5-19. Configuration of a Module Shim

// example-004/public/scripts/main.js

requirejs.config({

baseUrl: '/scripts',

paths: {

jquery: 'vendor/jquery/jquery-2.1.3.min',

'jquery-all': 'util/jquery-all',

// giving undrln a module alias

undrln: 'vendor/undrln/undrln'

},

shim: {

// defining a shim for undrln

undrln: {

exports: '_'

}

}

});

shim部分下的每个键标识要填充的模块别名(或名称),分配给这些键的对象指定了关于填充程序如何工作的细节。在幕后,RequireJS 通过定义一个空的 AMD 模块来创建一个 shim,该模块返回由脚本或库创建的全局对象。un drn 创建了全局window._对象,因此名字_在 shim 配置中被指定为 un drn 的导出。最终生成的 RequireJS shim 将类似于清单 5-20 中的模块。请注意,这些垫片是在模块加载时动态创建的,并不作为“文件”实际存在于 web 服务器上。(这个规则的一个例外是 r.js 打包工具,稍后讨论,它将生成的 shim 输出写入一个包文件,作为一种优化措施。)

Listing 5-20. Example RequireJS Shim Module

define('undrln', [], function () {

return window._;

});

清单 5-21 中的quotes模块现在可以使用undrln垫片作为依赖项。

Listing 5-21. Using the Undrln Shim As a Dependency

// example-004/public/scripts/data/quotes.js

define(['undrln'], function (_) {

//...

return {

groupByAttribution: function () {

return _.groupBy(quoteData, 'attribution');

},

//...

}

});

通过填充非 AMD 脚本,当非 AMD 脚本依赖于其他 AMD 模块时,RequireJS 可以在后台使用其异步模块加载功能来加载非 AMD 脚本。如果没有这个功能,这些脚本将需要用标准的<script>标签包含在每个页面上,并同步加载以确保可用性。

example-004中运行 web 应用,然后浏览到http://localhost:8080/index.html将显示报价列表。图 5-2 显示了渲染页面和 Chrome 的网络面板,其中列出了所有加载的 JavaScript 模块。注意,Initiator 列清楚地显示了 RequireJS 负责加载所有模块,甚至非 AMD 的undrln.js模块也包含在列表中。

A978-1-4842-0662-1_5_Fig2_HTML.jpg

图 5-2。

RequireJS modules shown loaded in Chrome

填补依赖项

期望填充的脚本具有依赖性是合理的,例如全局范围内的对象。当 AMD 模块指定依赖项时,RequireJS 确保在执行模块代码之前,首先加载依赖项。填补脚本的依赖性在填补配置中以类似的方式指定。一个填充的脚本可能依赖于其他填充的脚本,或者甚至依赖于 AMD 模块,如果这些模块使内容在全局范围内可用的话(通常是一个坏主意,但有时是必要的)。

为了增强示例应用,在example-005的报价页面中添加了一个搜索字段。在搜索字段中输入的术语会在找到它们的任何报价文本中突出显示。到目前为止,所有示例都使用一个视图quotes-view来显示呈现的标记。因为应用的功能越来越多,所以将引入两个新模块来帮助管理功能:search-viewquotes-statesearch-view模块负责监控用户输入的文本字段。当这个字段改变时,视图通知quotes-state模块已经进行了搜索,并向其传递搜索词。quotes-state模块充当所有视图的单一状态源,当它接收到一个新的搜索词时,它触发一个视图可能订阅的事件。

挖掘一些遗留的源代码产生了文件public/scripts/util/jquery.highlight.js,这是一个非 AMD 的 jQuery 插件,突出显示了 DOM 中的文本。当quotes-view模块从quotes-state模块接收到搜索事件时,它使用这个插件根据存储在quotes-state中的搜索词高亮显示 DOM 中的文本。要使用这个遗留脚本,需要在main.js配置中添加一个路径和一个填充条目。highlight插件不导出任何值,但是它需要先加载 jQuery,否则插件在试图访问全局 jQuery 对象时会抛出一个错误。

依赖关系已经被添加到具有deps属性的highlight垫片中,如清单 5-22 所示。该属性包含一个模块名(或别名)数组,该数组应该在 shim 之前加载——在本例中是 jQuery。

Listing 5-22. The highlight Shim Depends on jQuery

// example-005/public/scripts/main.js

requirejs.config({

baseUrl: '/scripts',

paths: {

jquery: 'vendor/jquery/jquery-2.1.3.min',

'jquery-all': 'util/jquery-all',

undrln: 'vendor/undrln/undrln',

ventage: 'vendor/ventage/ventage',

highlight: 'util/jquery.highlight'

},

shim: {

undrln: {

exports: '_'

},

highlight: {

deps: ['jquery']

}

}

});

一旦highlight插件被填充,它可能作为另一个模块的依赖项被加载。既然jquery-all模块负责加载定制插件,那么在清单 5-23 中让highlight模块成为它的依赖项之一似乎是明智的。

填充脚本应该只有两种依赖关系:

  • 其他填充脚本立即执行,并可能在全局范围内创建一个或多个可重用的变量或名称空间
  • 作为副作用,AMD 模块还在全局范围内创建了可重用的变量或名称空间(如window.jQuery)

因为 AMD 模块通常根本不干涉全局范围,所以将它们用作填充脚本的依赖项实际上是无用的,因为填充脚本没有办法访问 AMD 模块的 API。如果一个 AMD 模块没有给全局范围增加任何东西,那么它对屏蔽脚本是没有用的。此外,AMD 模块是异步加载的,它们的闭包以特定的顺序执行(在下一节讨论),而填充的脚本将在加载后立即运行。(Rembmer:填充脚本是普通的脚本,一旦被引入 DOM 就运行。生成的 shim 模块简单地将非 AMD 脚本创建的全局导出作为依赖项传递给其他 AMD 模块。)即使经填补的脚本可以访问 AMD 模块的 API,也不能保证该模块在经填补的脚本实际运行时是可用的。

Listing 5-23. Loading the highlight Module As a Dependency of Another Module

// example-005/public/scripts/util/jquery-all.js

define(['jquery', 'highlight'], function ($) {

$.fn.addQuotes = function (attribution, quotes) {

// ...

};

return $;

});

在这种安排下,可能会立即想到两个问题:

Since both the highlight and jquery-all modules declare jquery as a dependency, when is jQuery actually loaded?   Why isn’t a second highlight parameter specified in the jquery-all module closure function?  

首先,RequireJS 在评估模块间的依赖关系时,会基于模块层次结构创建一个内部依赖树。通过这样做,它可以确定加载任何特定模块的最佳时间,从叶子开始并向主干移动。在这种情况下,“主干”是jquery-all模块,最远的叶子是highlight依赖的jquery模块。RequireJS 将按照以下顺序执行模块关闭:jqueryhighlightjquery-all。因为jquery也是jquery-all的依赖项,RequireJS 将简单地交付为highlight模块创建的同一个jquery实例。

第二,highlight模块不返回值,只是用于副作用——为 jQuery 对象添加插件。没有参数传递给jquery-all模块,因为highlight返回 none。出于这个原因,仅用于副作用的依赖项应该总是放在模块的依赖项列表的末尾。

加载程序插件

有几个非常有用的 RequireJS loader 插件,它们在大多数项目中都有一席之地。加载器插件是一个外部脚本,用于方便地加载,有时解析特定种类的资源,这些资源可以作为标准 AMD 依赖项导入,即使资源本身可能不是实际的 AMD 模块。

text.js

RequireJS text插件可以通过 HTTP 加载一个纯文本资源,将其序列化为一个字符串,并将其作为一个依赖项交付给 AMD 模块。这通常用于加载 HTML 模板,甚至是来自 HTTP 端点的原始 JSON 数据。要安装插件,必须从项目存储库中复制text.js脚本,并且按照惯例,将它放在与main.js配置文件相同的目录中。(可选的安装方法在插件项目的自述文件中列出。)

示例应用中的quotes-view模块使用 jQuery 插件构建引用列表,一次一个 DOM 元素。这不是很有效,很容易被模板解决方案取代。AMD 兼容的 Handlebars 模板库是这类任务的流行选择。在清单 5-24 中,库被添加到了example-006vendor目录中,并且在main.js配置中创建了一个方便的模块别名。

Listing 5-24. Handlebars Module Alias

// example-006/public/scripts/main.js

requirejs.config({

baseUrl: '/scripts',

paths: {

//...

Handlebars: 'vendor/handlebars/handlebars-v3.0.3'

},

//...

});

quotes-view模块呈现自己时,它使用对象散列中的报价数据,其中键是属性(即,每个报价的收款人),值是每个报价的数组。(给定的属性可以与一个或多个报价相关联。)清单 5-25 显示了将被绑定到这个数据结构的模板,位于public/scripts/templates/quotes.hbs文件中。

Listing 5-25. The quotes-view Handlebars Template

{{#each this as |quotes attribution|}}

<section class="multiquote">

<h2 class="attribution">{{attribution}}</h2>

{{#each quotes}}

<blockquote class="quote">

{{#explode text delim="\n"}}

<p>{{this}}</p>

{{/explode}}

</blockquote>

{{/each}}

</section>

{{/each}}

不需要完全熟悉 Handlebars 语法就能理解这个模板遍历数据对象,提取每个属性及其相关的引号。它为属性创建一个<h2>元素,然后为每个报价构建一个<blockquote>元素来保存报价文本。一个特殊的块助手,#explode,在新行(\n)分隔符处将引用文本分开,然后将引用文本的每一段包装在一个<p>标签中。

#explode辅助对象很重要,因为它不是手柄的原生属性。它在文件public/scripts/util/handlebars-all.js中被定义并注册为把手辅助对象,如清单 5-26 所示。

Listing 5-26. #explode Handlebars Helper

// example-006/public/scripts/util/handlebars-all.js

define(['Handlebars'], function (Handlebars) {

Handlebars.registerHelper('explode', function (context, options) {

var delimiter = options.hash.delim || '';

var parts = context.split(delimiter);

var processed = '';

while (parts.length) {

processed += options.fn(parts.shift().trim());

}

return processed;

});

return Handlebars;

});

因为这个模块添加了助手,然后返回 Handlebars 对象,quotes-view模块将把它作为依赖项导入,而不是普通的 Handlebars 模块,就像用jquery-all模块代替jquery一样。清单 5-27 中的配置中添加了适当的模块别名。

Listing 5-27. handlebars-all Module Alias

// example-006/public/scripts/main.js

requirejs.config({

baseUrl: '/scripts',

paths: {

//...

Handlebars: 'vendor/handlebars/handlebars-v3.0.3',

'handlebars-all': 'util/handlebars-all'

},

//...

});

在清单 5-28 中,quotes-view模块已经被修改为导入handlebars-allquotes.hbs模板。文本模板的模块名非常具体:它必须以前缀text!开头,后跟模板文件的路径,该路径相对于main.js中定义的baseUrl路径。

Listing 5-28. The quotes.hbs Template Imported As a Module Dependency

// example-006/public/scripts/quotes-view.js

define([

'jquery-all',

'quotes-state',

'handlebars-all',

'text!templates/quote.hbs'

],

function ($, quotesState, Handlebars, quotesTemplate) {

var bindTemplate = Handlebars.compile(quotesTemplate);

var view = {

// ...

render: function () {

view.$el.empty();

var groupedQuotes = quotesState.quotes;

view.$el.html(bindTemplate(groupedQuotes));

},

// ...

};

// ...

});

当 RequireJS 遇到带有text!前缀的依赖项名称时,它会自动尝试加载text.js插件脚本,然后该脚本会将指定的文件内容作为字符串加载并序列化。quotes-view闭包中的quotesTemplate函数参数将包含quotes.hbs文件的序列化内容,然后由 Handlebars 编译并用于在 DOM 中呈现模块。

页面加载

当一个网页完全加载时,它触发一个DOMContentLoaded事件(在现代浏览器中)。在浏览器完成 DOM 构建之前加载的脚本通常会监听该事件,以了解何时开始操作页面元素是安全的。如果脚本恰好在结束标签</body>之前被加载,它们可能会认为大部分 DOM 已经被加载了,并且它们不需要监听这个事件。然而,<body>元素中其他地方的脚本,或者更常见的<head>元素,就没有这样的奢侈了。

尽管在应用示例中,RequireJS 是在结束的</body>标记之前加载的,但是清单 5-29 中的main.js文件(配置省略)仍然将一个函数传递给 jQuery,一旦DOMContentLoaded触发,该函数将被执行。如果将 RequireJS <script>标签移动到文档<head>中,就不会破坏任何东西。

Listing 5-29. Using jQuery to Determine If the DOM Is Fully Loaded

// example-006/public/scripts/main.js

// ...

requirejs(['jquery-all', 'quotes-view', 'search-view'],

function ($, quotesView) {

$(function () {

quotesView.ready();

});

});

domReady插件是一种特殊的“加载器”,它只是暂停模块闭包的调用,直到 DOM 完全准备好。像文本插件一样,domReady.js文件必须可以被在main.js配置中定义的baseUrl路径内的 RequireJS 访问。按照惯例,它通常是main.js的兄弟。

清单 5-30 显示了main.js的修改版本(配置省略),其中jquery依赖项被移除,而domReady!插件被添加到依赖项列表中。后面的感叹号告诉 RequireJS,这个模块作为一个加载器插件,而不是一个标准模块。与text插件不同,domReady实际上什么也不加载,所以感叹号后不需要额外的信息。

Listing 5-30. Using the domReady Plugin to Determine If the DOM Is Fully Loaded

// example-007/public/scripts/main.js

// ...

requirejs(['quotes-view', 'search-view', 'domReady!'],

function (quotesView) {

quotesView.ready();

});

i18n

RequireJS 通过i18n加载器插件支持国际化。(i18n 是一个 numeronym,表示数字“18”代表“国际化”一词中“I”和“n”之间的 18 个字符。)国际化是指编写 web 应用,使其内容适应用户的语言和地区(也称为国家语言支持,或 NLS)的行为。i18n插件主要用于翻译网站控件和“chrome”中的文本:按钮标签、标题、超链接文本、字段集图例等等。为了展示这个插件的功能,示例应用中添加了两个新模板,一个用于页眉中的页面标题,另一个用于带有占位符文本的搜索字段。实际的报价数据不会被翻译,因为它可能来自负责提供适当翻译的应用服务器。不过,在这个应用中,为了简单起见,数据被硬编码在data/quotes模块中,并且总是以英文显示。

清单 5-31 中的search.hbs模板也已经从index.html文件中提取出来,现在接受搜索字段的占位符文本作为唯一的输入。search-view模块已经被修改为在 DOM 中呈现内容时使用这个模板。

Listing 5-31. The search.hbs Template Will Display the Placeholder Translation

<!-- example-008/public/scripts/templates/search.hbs -->

<form>

<fieldset>

<input type="text" name="search" placeholder="{{searchPlaceholder}}" />

</fieldset>

</form>

清单 5-32 显示了将由新的header-view模块呈现的新的header.hbs模板。该模板接受一个输入,即页面标题。

Listing 5-32. The header.hbs Template Will Display the Page Title Translation

<!-- example-008/public/scripts/templates/header.hbs -->

<h1>{{pageTitle}}</h1>

清单 5-33 中的header-view模块不仅演示了如何使用text插件导入模板依赖,还演示了如何使用i18n插件导入语言模块依赖。熟悉的加载器语法看起来几乎是一样的:插件名后面跟一个感叹号和一个相对于配置的模块路径baseUrl,在这里是nls/lang。当加载一个模板时,它的序列化字符串内容被传递给模块的闭包,但是i18n插件加载一个包含翻译文本数据的语言模块,并将该模块的对象传递给闭包。在清单 5-33 中,这个对象可以通过lang参数访问。

Listing 5-33. The header-view Module Depends on the i18n Language Object

// example-008/public/scripts/header-view.js

define([

'quotes-state',

'jquery-all',

'handlebars-all',

'text!templates/header.hbs',

'i18n!nls/lang'

], function (quotesState, $, Handlebars, headerTemplate, lang) {

// ...

});

language 模块是一个常规的 AMD 模块,但是它没有向define()传递依赖项列表和闭包,而是使用了一个简单的对象文字。这个对象文字遵循一个非常特殊的语法,如清单 5-34 所示。

Listing 5-34. Default English Language Module

// example-008/public/scripts/nls/lang.js

define({

root: {

pageTitle: 'Ponderings',

searchPlaceholder: 'search'

},

de: true

});

首先,root属性保存了当插件解析语言翻译时将用于获取翻译数据的键/值对。该对象中的键只是简单的键,通过这些键可以以编程方式访问翻译的文本。例如,在search模板中,当模板绑定到语言对象的关键字searchPlaceholder时,{{searchPlaceholder}}将被替换为字符串值。

其次,root属性的兄弟是各种 IETF 语言标签,用于活动和非活动的翻译,它们应该基于浏览器的语言设置来解析。在这个例子中,德语de语言标签被赋值为true。如果有西班牙语翻译,可以添加一个值为truees-es属性。对于法语翻译,可以添加一个fr-fr属性,对于其他语言也是如此。

当在默认语言模块中启用新的语言标签时,必须将对应于语言代码的目录作为模块文件的兄弟。目录可以在清单 5-35 中看到。

Listing 5-35. Directory Structure for NLS Modules

■??]

◆θ★★★★★★★★★★★★★★★★★★★★★★

──★??∮

★★★★★★★★★★★★★★

创建特定于语言的目录后,必须在其中创建与默认语言模块文件同名的语言模块文件。这个新的语言模块将只包含默认语言模块中的root属性的翻译内容。清单 5-36 显示了pageTitlesearchPlaceholder属性的德语(de)翻译。

Listing 5-36. German (de) Translation Module

// example-008/public/scripts/nls/de/lang.js

define({

pageTitle: 'Grübeleien',

searchPlaceholder: 'suche'

});

当默认语言模块用i18n插件加载时,它会检查浏览器的window.navigator.language属性,以确定应该使用什么语言环境和语言翻译。如果默认语言模块指定了一个兼容的、已启用的语言环境,i18n插件会加载特定于语言环境的模块,然后将其与默认语言模块的root对象合并。特定于区域设置的模块中缺少的翻译将用默认语言模块中的值填充。

图 5-3 显示了谷歌 Chrome 浏览器的语言设置为德语时报价页面的外观。

A978-1-4842-0662-1_5_Fig3_HTML.jpg

图 5-3。

Switching the browser language loads the German translation Note

window.navigator.language属性受不同浏览器中不同设置的影响。例如,在 Google Chrome 中,它只反映用户的语言设置,而在 Mozilla Firefox 中,它也会受到页面 HTTP 响应中的Accept-Language标题的影响。

缓存破坏

应用服务器通常缓存脚本文件、图像、样式表等资源,以消除在为自上次读取以来没有更改的资源提供服务时不必要的磁盘访问。缓存的资源通常存储在内存中,并与某个键相关联,通常是资源的 URL。当在指定的缓存期间内出现对给定 URL 的多个请求时,将使用键(URL)从内存中提取资源。这在生产环境中具有显著的性能优势,但是在开发或测试环境中,每次进行代码更改或引入新资源时使缓存失效会变得很繁琐。

当然,缓存可以在每个环境的基础上切换,但是一个更简单的解决方案,至少对于 JavaScript(或任何由 RequireJS 加载的资源),可能是利用 RequireJS 缓存破坏特性。缓存破坏是对每个资源请求的 URL 进行变异的行为,其方式是资源仍然可以被获取,但永远不会在缓存中找到,因为它的“键”总是不同的。这通常是通过包含一个查询字符串参数来实现的,该参数会在页面重新加载时发生变化。

清单 5-37 中的配置脚本添加了一个urlArgs属性。这将把查询字符串参数bust={timestamp}追加到 RequireJS 生成的所有请求中。每次页面加载时都会重新计算时间戳,以确保参数值发生变化,从而使 URL 变得唯一。

Listing 5-37. The urlArgs Configuration Property Can Be Used to Bust Cache

// example-009/public/scripts/main.js

requirejs.config({

baseUrl: '/scripts',

urlArgs: 'bust=' + (new Date().getTime()),

paths: {

// ...

},

shim: {

// ...

}

});

图 5-4 显示bust参数确实应用于 RequireJS 发起的每一个请求,甚至是像header.hbs这样的 XHR 对文本资源的请求。

A978-1-4842-0662-1_5_Fig4_HTML.jpg

图 5-4。

The bust parameter is appended to each RequireJS request

虽然这个特性的有用性是显而易见的,但是它也会产生一些问题。

首先,RequireJS 尊重 HTTP 缓存头,因此即使将urlArgs用作缓存破坏机制,RequireJS 仍然可以请求(并接收)资源的缓存版本,这取决于缓存是如何实现的。如果可能,在每个环境中始终提供适当的缓存头。

其次,要注意一些代理服务器会丢弃查询字符串参数。如果开发或登台环境包括模拟生产环境的代理,则破坏缓存的查询字符串参数可能无效。一些开发人员使用urlArgs来指定生产环境中的特定资源版本(例如version=v2),但是由于这个原因,通常不鼓励这样做。这是一种不可靠的版本控制技术。

最后,一些浏览器将具有不同 URL 的资源视为不同的、可调试的实体。例如,在 Chrome 和 Firefox 中,如果在源代码中为http://localhost:8080/scripts/quotes-state.js?bust=1432504595280设置了一个调试断点,当新的资源 URL 变为http://localhost:8080/scripts/quotes-state.js?bust=1432504694566时,如果页面被刷新,它将被删除。重置断点可能会变得繁琐,尽管debugger关键字可以用来通过强制浏览器暂停执行来规避这个问题,但它仍然需要勤奋的开发人员来确保在代码投入生产之前删除所有的debugger断点。

需要优化器

RequireJS 优化器 r.js 是一个用于 RequireJS 项目的构建工具。它可以用来将所有 RequireJS 模块连接成一个文件,缩小源代码,将构建输出复制到一个不同的目录,等等。本节介绍该工具及其基本配置。接下来将介绍几种常见场景的具体示例。

使用 r.js 最常见的方式是为 Node.js 安装 RequireJS npm 包,作为全局包或本地项目包。本节中的示例将使用在安装所有 npm 模块时创建的本地 RequireJS 安装。

配置 r.js

大量的参数可以作为参数传递给 r.js 工具来控制它的行为。幸运的是,这些参数也可以在常规的 JavaScript 配置文件中传递给 r.js,这使得终端命令明显更短。对于重要的项目,这是首选的配置方法,也是本章中唯一涉及的方法。

目录example-010中的代码文件已经被移动到一个标准的src目录中,一个新文件rjs-config.js已经被放置在根目录中。不出所料,这个文件包含 r.js 配置。其内容如清单 5-38 所示。

Listing 5-38. r.js Configuration

// example-010/rjs-config.js

({

// build input directory for application code

appDir: './src',

// build output directory for application code

dir: './build',

// path relative to build input directory where scripts live

baseUrl: 'public/scripts',

// predefined configuration file used to resolve dependencies

mainConfigFile: './src/public/scripts/main.js',

// include all text! references as inline modules

inlineText: true,

// do not copy files that were combined in build output

removeCombined: true,

// specific modules to be built

modules: [

{

name: 'main'

}

],

// uglify the output

optimize: 'uglify'

})

熟悉构建工具的开发人员会立即识别出配置中存在的输入/输出模式。

属性指定了相对于配置文件的项目“输入”目录,未编译的源代码就在这个目录中。

属性指定了相对于配置文件的项目“输出”目录,当 r.js 工具运行时,编译和缩小的输出将被写入该目录。

baseUrl属性告诉 r.js 项目脚本相对于 appDir 属性的位置。这不应该与main.js文件中的baseUrl属性混淆,后者告诉 RequireJS 模块相对于 web 应用根的位置。

mainConfigFile属性指向实际的 RequireJS(不是 r.js)配置。这有助于 r.js 理解模块是如何相互关联的,以及存在什么样的模块别名和垫片(如果有的话)。可以省略这个属性,在 r.js 配置中指定所有这些路径,尽管这超出了本例的范围。

inlineText属性设置为true可以确保所有引用了文本插件前缀text!的文本文件都将在最终的构建输出中用 RequireJS 模块进行编译。默认情况下启用该选项,但为了清楚起见,在该项目中明确设置了该选项。

默认情况下,r.js 会将所有脚本(打包和解包的)缩小并复制到输出目录中。removeCombined属性切换这种行为。在这种情况下,只有打包、编译的脚本以及打包输出中无法包含的任何其他脚本才会被复制到输出目录中。

modules数组列出了所有要编译的顶级模块。因为这是一个单页面应用,所以只需要编译实际的main模块。

最后,optimize属性指示 r.js 对所有脚本应用丑陋转换,从而最小化所有 JavaScript 代码。

运行 r.js 命令

构建项目只是在终端中运行r.js命令,通过它的-o标志将配置文件的路径传递给它,如清单 5-39 所示。

Listing 5-39. Running the r.js Command

example-010$ ../node_modules/.bin/r.js -o rjs-config.js

终端输出显示了 r.js 在构建过程中编译和复制了哪些文件。检查清单 5-40 中的构建输出文件显示了 r.js 到底优化和复制了什么。

Listing 5-40. Build Directory Content

example-010/build$ tree

.

■??]

■??]

ε──??″

■??]

■??]

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

──★??∮

──★??∮

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

──μ──??∮

──μ──??∮

──★??∮

★★★★★★★★★★★★★★

★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

★★★★★★★★★★★★★★

ε──??″

ε──??″

9 directories, 24 files

public/scripts目录中,有几样东西立即凸显出来。

首先,require.jsmain.js脚本都存在。由于这些脚本是在index.html中引用的唯一文件,它们的出现是意料之中的。其他脚本,如quotes-view.jsquotes-state.js脚本明显不存在,但检查main.js的内容会发现原因:它们已经根据 r.js 构建设置进行了打包和缩小。

第二,本地化文件nls/lang.js现在丢失了,因为它已经作为main.js的一部分被包含进来。nls/de/lang.js脚本仍然是构建输出的一部分,尽管它的内容已经被缩减了。任何在默认语言环境下浏览示例 web 页面的用户都将获得优化的体验,因为 RequireJS 不必进行外部 AJAX 调用来加载默认语言翻译。来自德国的用户将产生额外的 HTTP 请求,因为打包的输出中没有包括德语本地化文件。这是本地化插件的一个限制,r.js 必须尊重。

第三,把手模板,尽管在main.js中被编译为构建输出的一部分,也被复制到了public/scripts/templates目录中。发生这种情况是因为 RequireJS 插件目前无法看到构建过程,因此无法在 r.js 配置文件中使用removeCombined选项。幸运的是,因为这些模板已经被包装在 AMD 模块中,并与main.js连接在一起,所以 RequireJS 不会试图用 AJAX 请求加载它们。如果部署规模是这个项目的一个问题,如果需要,可以创建一个后期构建脚本或任务来删除templates目录。

第四,vendor / ventage目录已经被复制到build目录,尽管它的核心模块ventage.js已经与main.js连接在一起。虽然 RequireJS 可以在编译后自动删除单个模块文件(如ventage.js),但它不会清理与模块相关联的其他文件(在本例中,是单元测试和包定义文件,如package.jsonbower.json),因此它们必须手动删除,或者作为后期构建过程的一部分。

摘要

RequireJS 是一个非常实用的 JavaScript 模块加载器,在浏览器环境中运行良好。它异步加载和解析模块的能力意味着它不仅仅依靠捆绑或打包脚本来获得性能优势。不过,为了进一步优化,可以使用 r.js 优化工具将 RequireJS 模块合并到一个精简的脚本中,以最大限度地减少加载模块和其他资源所需的 HTTP 请求数量。

尽管 RequireJS 模块必须以 AMD 格式定义,但 RequireJS 可以填充非 AMD 脚本,以便在必要时 AMD 模块可以导入遗留代码。填补的模块还可能具有可由 RequireJS 自动加载的依赖项。

text插件允许模块将外部文本文件依赖项(如模板)作为字符串导入。这些文本文件像任何其他模块依赖项一样被加载,甚至可能被 r.js 优化器内联到构建输出中。

本地化由i18n模块加载器支持,它可以根据浏览器的区域设置动态加载文本翻译模块。虽然主要的语言环境翻译模块可以被优化并与 r.js 连接,但是额外的语言环境翻译模块总是会加载 HTTP 请求。

模块的执行可以被pageLoad插件推迟,这可以防止模块的闭包在 DOM 完全呈现之前执行。这可以有效地消除对 jQuery 的ready()函数的重复调用,或者手动搜索订阅DOMContentLoaded事件所需的跨浏览器代码。

最后,RequireJS 配置可以自动将查询字符串参数附加到所有 RequireJS HTTP 请求中,为开发环境提供了一个廉价但有效的缓存破坏特性。