diff --git a/20.md b/20.md index 3fc1bdc..d812674 100644 --- a/20.md +++ b/20.md @@ -4,46 +4,26 @@ > > Master Yuan-Ma, The Book of Programming -到目前为止,你已经学习了JavaScript语言,并将其运用于单一的浏览器环境中。本章和下一章将会大致介绍Node.js,该程序可以让读者将你的JavaScirpt技能运用于浏览器之外。读者可以运用Node.js构建应用程序,实现简单的命令行工具和复杂动态HTTP服务器。 +到目前为止,我们已经使用了 JavaScript 语言,并将其运用于单一的浏览器环境中。本章和下一章将会大致介绍Node.js,该程序可以让读者将你的JavaScirpt技能运用于浏览器之外。读者可以运用Node.js构建应用程序,实现简单的命令行工具和复杂动态HTTP服务器。 -这些章节旨在告诉你建立Node.js的基础思想,并向你提供信息,使你可以采用Nodejs编写一些实用程序。 +这些章节旨在告诉你建立Node.js的主要概念,并向你提供信息,使你可以采用Nodejs编写一些实用程序。它们并不是这个平台的完整的介绍。 -若读者想要运行本章中的代码,可以先访问[http://nodejs.org/](http://nodejs.org/),并遵从你使用的操作系统的安装指南。也可以浏览该网站,获得Node及其内建模块的文档。 +如果你想要运行本章中的代码,需要安装 Node.js 10 或更高版本。 为此,请访问 [nodejs.org](https://nodejs.org),并按照用于你的操作系统的安装说明进行操作。 您也可以在那里找到 Node.js 的更多文档。 ### 20.1 背景 -编写通过网络通信的系统时,一个更困难的问题是管理输入输出,即通过网络、硬盘或其他设备读写数据。到处移动数据会耗费时间,而调度这些任务的技巧会使得系统在相应用户或网络请求时产生巨大的性能差异。 +编写通过网络通信的系统时,一个更困难的问题是管理输入输出,即向/从网络和硬盘读写数据。到处移动数据会耗费时间,而调度这些任务的技巧会使得系统在相应用户或网络请求时产生巨大的性能差异。 -处理输入输出的传统方式是使用类似于readFile之类的函数,开始读取文件,并当文件读取完全结束时返回。这被称为同步I/O(I/O,Input/Output,表示输入输出)。 +在这样的程序中,异步编程通常是有帮助的。 它允许程序同时向/从多个设备发送和接收数据,而无需复杂的线程管理和同步。 -Node最初是为简化异步I/O而设计的。我们前文中讨论过异步接口,比如第17章中讨论的浏览器XMLHttpRequest对象。异步接口允许脚本在读写设备的同时继续执行,并在任务完成时调用回调函数。Node中所有I/O都是以此种方式完成的。 - -JavaScript良好地借助了Node之类的系统。JavaScript是少数没有内建I/O处理方式的程序设计语言之一。因此JavaScript可以使用Node中古怪的I/O处理方式,而不需要使用两种不一致的接口。2009年设计Node时,人们常常在浏览器中进行基于回调的I/O处理,因此JavaScript社区习惯了这种异步编程风格。 - -### 20.2 异步性 - -我将会以一个简单示例演示同步和异步I/O,该程序需要从因特网中获取两个资源,并对获取结果进行简单处理。 - -在同步环境中,执行该任务的一种显而易见的方式是逐个请求资源。该方法的缺点在于第二个请求必须在第一个请求结束后方能开始。其时间总计至少等于两个响应时间之和。这将不利于有效利用机器,因为通过网络发送接收数据时机器大部分时间是空闲的。 - -在同步系统中,解决该问题的方案是启动一个额外的控制线程(请参考第14章中讨论线程的部分)。第二个线程可以启动第二个请求,这两个线程都会等待请求返回结果,随后两个线程再次同步以合并结果。 - -下图中的粗线表示程序正常运行花费时间,细线表示等待I/O的时间。在同步模型中,I/O消耗的实现是特定控制线程时间线的一部分。在异步模型中,启动一个I/O动作会导致概念上的时间线分割。初始化I/O操作的线程可以继续运行,I/O则会在一旁自行处理,最后结束时调用回调函数。 - -![](../Images/00605.jpeg) - -若用另一种方式来描述这种差别,可以说同步模式中隐含着等待I/O,而在异步模型中则明确处于我们的控制管理之下。但异步性有其利弊。我们可以用其更轻松地表示非直线控制模型,但在表现直线控制流程序时显得不太合适。 - -在第17章中,我已经提及了一个事实,即所有这类回调函数会使得程序变得混乱,不直接。异步风格是否是一个好的通用方法是会引起争论的。但无论如何,已经有许多程序员习惯了这个风格。 - -但对于基于JavaScript的系统而言,我认为基于回调风格的异步处理是一个明智的选择。JavaScript的关键之一是其简单性,而控制多线程会增加许多复杂性。虽然使用回调函数不一定能编写出简单的代码,但对编写高性能网络服务器而言,这已经足够惬意且强大了。 +Node最初是为了使异步编程简单方便而设计的。 JavaScript 很好地适应了像 Node 这样的系统。 它是少数几种没有内置输入和输出方式的编程语言之一。 因此,JavaScript 可以适应 Node 的相当古怪的输入和输出方法,而不会产生两个不一致的接口。 在 2009 年设计 Node 时,人们已经在浏览器中进行基于回调的编程,所以该语言的社区用于异步编程风格。 ### 20.3 node命令 在系统中安装完Node.js后,Node.js会提供一个名为node的程序,该程序用于执行JavaScript文件。假设你有一个文件hello.js,该文件会包含以下代码。 ```js -var message = "Hello world"; +let message = "Hello world"; console.log(message); ``` @@ -54,7 +34,7 @@ $ node hello.js Hello world ``` -Node中的console.log方法与浏览器中所做的类似,都用于打印文本片段。但在Node中,该方法不会将文本显示在浏览器的JavaScript控制台中,而显示在标准输出流中。 +Node中的console.log方法与浏览器中所做的类似,都用于打印文本片段。但在Node中,该方法不会将文本显示在浏览器的JavaScript控制台中,而显示在标准输出流中。从命令行运行`node`时,这意味着您会在终端中看到记录的值。 若你执行node时不附带任何参数,node会给出提示符,读者可以输入JavaScript代码并立即看到执行结果。 @@ -68,90 +48,103 @@ $ node $ ``` -变量process类似于console变量,是Node中的全局变量。该变量提供了多种方式来监听并操作当前程序。该变量中的exit方法可以结束进程并赋予一个退出状态码,告知启动node的程序(在本例中时命令行shell),当前程序是成功完成(代码为0),还是遇到了错误(其他代码)。 +绑定process类似于console绑定,是Node中的全局绑定。该绑定提供了多种方式来监听并操作当前程序。该绑定中的exit方法可以结束进程并赋予一个退出状态码,告知启动node的程序(在本例中时命令行shell),当前程序是成功完成(代码为0),还是遇到了错误(其他代码)。 -读者可以读取process.argv来获取传递给脚本的命令行参数,该变量是一个字符串数组。请注意该数组包括了node命令和脚本名称,因此实际的参数从索引2处开始。若showargv.js只包含一条console.log(process.argv)语句,你可以这样执行该脚本。 +读者可以读取process.argv来获取传递给脚本的命令行参数,该绑定是一个字符串数组。请注意该数组包括了node命令和脚本名称,因此实际的参数从索引2处开始。若showargv.js只包含一条console.log(process.argv)语句,你可以这样执行该脚本。 ``` $ node showargv.js one --and two -["node", "/home/marijn/showargv.js", "one", "--and", "two"] +["node", "/tmp/showargv.js", "one", "--and", "two"] ``` -所有标准JavaScript全局变量,比如Array、Math以及JSON也都存在于Node环境中。而与浏览器相关的功能,比如document与alert则不存在。 - -全局作用域对象在浏览器中名为window,而在Node中则名为global。 +所有标准JavaScript全局绑定,比如Array、Math以及JSON也都存在于Node环境中。而与浏览器相关的功能,比如document与alert则不存在。 ### 20.4 模块 -除了前文提到的一些变量,比如console和process,Node并没有在全局作用域中添加多少功能。如果你需要访问其他的内建功能,可以通过system模块获取。 +除了前文提到的一些绑定,比如console和process,Node在全局作用域中添加了很少绑定。如果你需要访问其他的内建功能,可以通过system模块获取。 -第10章中描述了基于require函数的CommonJS模块系统。该系统是Node的内建模块,用于在程序中装载任何东西,从内建模块,到下载的库,再到普通文件都可以。 +第10章中描述了基于require函数的CommonJS模块系统。该系统是Node的内建模块,用于在程序中装载任何东西,从内建模块,到下载的包,再到普通文件都可以。 -调用require时,Node会将给定的字符串解析为实际需要加载的文件。路径名若以“/”、“./”或“../”开头,则解析为相对于当前模块的路径,其中“./”表示当前路径,“../”表示当前路径的上一级路径,而“/”则表示文件系统根路径。因此若你访问通过文件/home/marijn/elife/run.js访问“./.world/world”,Node会尝试加载文件/home/marijn/elife/world/world.js,其中扩展名.js可以省略。 +调用require时,Node会将给定的字符串解析为可加载的实际文件。路径名若以“/”、“./”或“../”开头,则解析为相对于当前模块的路径,其中“./”表示当前路径,“../”表示当前路径的上一级路径,而“/”则表示文件系统根路径。因此若你访问从文件/tmp/robot/robot.js访问“./graph”,Node会尝试加载文件/tmp/robot/graph.js。 -若传递给require的字符串看起来并不是一个相对路径或绝对路径,则假定该字符串指的是一个内建模块或安装在node_modules目录中的模块。例如,require("fs")会返回Node的内建文件系统模块,而require("elife")则会尝试加载存在于node_modules/elife/中的库。安装这类库的通用方法是使用NPM,本书随后将会进行讨论。 +`.js`扩展名可能会被忽略,如果这样的文件存在,Node 会添加它。 如果所需的路径指向一个目录,则 Node 将尝试加载该目录中名为`index.js`的文件。 -为了展示require的使用方法,我们创建一个简单的项目,包含两个文件。第一个文件名为main.js,其中定义了一个脚本,我们可以通过命令行调用并截取字符串。 +当一个看起来不像是相对路径或绝对路径的字符串被赋给`require`时,按照假设,它引用了内置模块,或者安装在`node_modules`目录中模块。 例如,`require("fs")`会向你提供 Node 内置的文件系统模块。 而`require("robot")`可能会尝试加载`node_modules/robot/`中的库。 安装这种库的一种常见方法是使用 NPM,我们稍后讲讲它。 + +我们来建立由两个文件组成的小项目。 第一个称为`main.js`,并定义了一个脚本,可以从命令行调用来反转字符串。 ```js -var garble = require("./garble"); +const {reverse} = require("./reverse"); // Index 2 holds the first actual command-line argument -var argument = process.argv[2]; +let argument = process.argv[2]; -console.log(garble(argument)); +console.log(reverse(argument)); ``` -文件garble.js中定义了一个库,用于截取字符串,前文中的命令行工具或其他需要直接访问截取函数的脚本都可以调用该库。 +文件reverse.js中定义了一个库,用于截取字符串,这个命令行工具,以及其他需要直接访问字符串反转函数的脚本,都可以调用该库。 ```js -module.exports = function(string) { - return string.split("").map(function(ch) { - return String.fromCharCode(ch.charCodeAt(0) + 5); - }).join(""); +exports.reverse = function(string) { + return Array.from(string).reverse().join(""); }; ``` -请记住替换module.exports而不是向其中添加属性,这允许我们从模块中导出特定的值。在本例中,请求garble文件的结果是文本截取函数自身。 - -该函数会使用空字符串将给定字符串分割成单个字符,并将每个字符的代码加5,最后将所有字符合并成字符串。 +请记住,将属性添加到`exports`,会将它们添加到模块的接口。 由于 Node.js 将文件视为 CommonJS 模块,因此`main.js`可以从`reverse.js`获取导出的`reverse`函数。 我们可以看到我们的工具执行结果如下所示。 ``` $ node main.js JavaScript -Of{fXhwnuy +tpircSavaJ ``` ### 20.5 使用NPM安装 -第10章中简要地讨论过NPM,该工具是一个JavaScript模块的在线仓库,其中大部分模块是专门为Node编写的。当你在计算机上安装Node时,你就会获得一个名为npm的程序,提供了访问该仓库的简易接口。 +第十章中介绍的NPM,是一个JavaScript模块的在线仓库,其中大部分模块是专门为Node编写的。当你在计算机上安装Node时,你就会获得一个名为npm的程序,提供了访问该仓库的简易接口。 -例如,你可以在NPM上找到一个figlet模块,用于将文本转换成ASCII艺术字——根据文本字符进行绘制。下面的记录展示了如何安装并使用该模块。 +它的主要用途是下载软件包。 我们在第十章中看到了`ini`包。 我们可以使用 NPM 在我们的计算机上获取并安装该软件包。 ``` -$ npm install figlet -npm GET https://registry.npmjs.org/figlet -npm 200 https://registry.npmjs.org/figlet -npm GET https://registry.npmjs.org/figlet/-/figlet-1.0.9.tgz -npm 200 https://registry.npmjs.org/figlet/-/figlet-1.0.9.tgz -figlet@1.0.9 node_modules/figlet +$ npm install ini +npm WARN enoent ENOENT: no such file or directory, + open '/tmp/package.json' ++ ini@1.3.5 +added 1 package in 0.552s $ node -> var figlet = require("figlet"); -> figlet.text("Hello world!", function(error, data) { - if (error) - console.error(error); - else - console.log(data); - }); - _ _ _ _ _ _ _ - | | | | ___| | | ___ __ _____ _ __| | __| | | - | |_| |/ _ \ | |/ _ \ \ \ /\ / / _ \| '__| |/ _` | | - | _ | __/ | | (_) | \ V V / (_) | | | | (_| |_| - |_| |_|\___|_|_|\___/ \_/\_/ \___/|_| |_|\__,_(_) +> const {parse} = require("ini"); +> parse("x = 1\ny = 2"); +{ x: '1', y: '2' } ``` -执行完npm install后,NPM会创建一个名为node_modules的目录。在这个目录中会有一个figlet目录,包含了库。当我们执行node并调用require("figlet")时就会加载该库,然后我们可以调用该模块的text方法来绘制一些巨大的字母。 +运行`npm install`后,NPM 将创建一个名为`node_modules`的目录。 该目录内有一个包含库的`ini`目录。 您可以打开它并查看代码。 当我们调用`require("ini")`时,加载这个库,我们可以调用它的`parse`属性来解析配置文件。 + +默认情况下,NPM 在当前目录下安装软件包,而不是在中央位置。 如果您习惯于其他软件包管理器,这可能看起来很不寻常,但它具有优势 - 它使每个应用程序完全控制它所安装的软件包,并且使其在删除应用程序时,更易于管理版本和清理。 + +## 包文件 + +在`npm install`例子中,你可以看到`package.json`文件不存在的警告。 建议为每个项目创建一个文件,手动或通过运行`npm init`。 它包含该项目的一些信息,例如其名称和版本,并列出其依赖项。 + +来自第七章的机器人模拟,在第十章中模块化,它可能有一个`package.json`文件,如下所示: + +```json +{ + "author": "Marijn Haverbeke", + "name": "eloquent-javascript-robot", + "description": "Simulation of a package-delivery robot", + "version": "1.0.0", + "main": "run.js", + "dependencies": { + "dijkstrajs": "^1.0.1", + "random-item": "^1.0.0" + }, + "license": "ISC" +} +``` + +当你运行`npm install`而没有指定安装包时,NPM 将安装`package.json`中列出的依赖项。 当你安装一个没有列为依赖项的特定包时,NPM会将它添加到`package.json`中。 + +## 版本 也许有些出乎意料,figlet.text并不是简单地返回一个组成巨大字母的字符串,而会接受一个回调函数,并将结果传递给该函数。该函数也会向回调函数传递第二个参数即error,在发生一些错误时用该参数保存一个错误对象,若一切正常则为null。 @@ -234,7 +227,7 @@ server.listen(8000); 若你在自己的机器上执行该脚本,你可以打开网页浏览器,并访问[http://localhost:8000/hello](http://localhost%EF%BC%9A8000/hello),就会向你的服务器发出一个请求。服务器会响应一个简单的HTML页面。 -每次客户端尝试连接服务器时,服务器都会调用传递给createServer函数的参数。request和response变量都是对象,分别表示输入数据和输出数据。request包含请求信息,例如该对象的url属性表示请求的URL。 +每次客户端尝试连接服务器时,服务器都会调用传递给createServer函数的参数。request和response绑定都是对象,分别表示输入数据和输出数据。request包含请求信息,例如该对象的url属性表示请求的URL。 你需要调用response对象的方法以将一些数据发回客户端。第一个函数调用(writeHead)会输出响应头(参见第17章)。你需要向该函数传递状态码(本例中200表示成功)和一个对象,该对象包含头部信息的值。本例中我们告诉客户端我们送回的是HTML文档。 @@ -276,7 +269,7 @@ Node提供了名为https的包,用于发送安全的HTTP(HTTPS)请求, 我们也可以使用fs.createWriteStream建立一个指向本地文件的输出流。你可以调用该方法返回的结果对象的write方法,每次向文件中写入一段数据,而不是像fs.writeFile那样一次性写入所有数据。 -可读流则略为复杂。传递给HTTP服务器回调函数的request变量,以及传递给HTTP客户端回调函数的response对象都是可读流(服务器读取请求并写入响应,而客户端则先写入请求,然后读取响应)。读取流需要使用事件处理器,而不是方法。 +可读流则略为复杂。传递给HTTP服务器回调函数的request绑定,以及传递给HTTP客户端回调函数的response对象都是可读流(服务器读取请求并写入响应,而客户端则先写入请求,然后读取响应)。读取流需要使用事件处理器,而不是方法。 Node中发出的事件都有一个on方法,类似浏览器中的addEventListener方法。该方法接受一个事件名和一个函数,并将函数注册到事件上,接下来每当指定事件发生时,都会调用注册的函数。 @@ -297,7 +290,7 @@ http.createServer(function(request, response) { }).listen(8000); ``` -传递给data处理函数的chunk变量是一个二进制Buffer对象,我们可以使用toString将其转换成字符串,该方法会使用默认编码(UTF-8)解码二进制数据。 +传递给data处理函数的chunk绑定是一个二进制Buffer对象,我们可以使用toString将其转换成字符串,该方法会使用默认编码(UTF-8)解码二进制数据。 下面是另一段代码,当我们执行上面的服务(将字母转换成大写)时,这段代码会向服务器发送一个请求并输出获取到的响应数据: