Node学习笔记(一)——Node简介与模块化

以下博客是本人阅读朴灵大神的《深入浅出nodejs》所做的笔记和总结,以便日后的回顾和学习。

一. Node简介

Ryan Dahl在创造node之前希望能够设计高性能的web服务器,他总结出来的要点就是:事件驱动,非阻塞I/O。在综合考量之下,他选择了JavaScript作为node的语言。

node的出现使得JavaScript成为能够在服务器上运行的语言,而不仅仅是在浏览器的沙箱中。在node中,JavaScript可以随心所欲的访问本地文件,可以搭建WebSocket服务器,可以连接数据库等等,总之,JavaScript可以运行在不同的地方,不再局限于与CSS样式表,DOM树打交道。

node的结构与chrome十分类似,它们都是基于事件驱动模型的异步架构,浏览器通过事件驱动来服务界面上的交互,node通过事件驱动来服务I/O。node只是不处理UI,但是使用与浏览器相同的机制和原理运行。

node作为后端JavaScript的运行平台,它保留了前端浏览器JavaScript中那些熟悉的接口,没有改写语言本身的任何特性,依旧是基于作用域和原型链,区别在于它将前端中广泛运用的思想迁移到了服务器端。

node的特点:

1.异步I/O

对于前端工程师来说,异步的场景应当是相当熟悉的,比如以下ajax异步请求:

$.post(
	"/url",
	{
		title: "深入浅出nodejs"
	},
	function(data){
		console.log("收到响应");
	}
);
console.log("ajax请求发送完成");

以上ajax请求是异步执行的,所以接收到响应数据的时间并不确定(注重结果而非过程),因此“ajax请求发送完成”是在“收到响应”之前打印出来的。异步调用中对于结果值的捕获是符合“Dont call me, I will call you”原则的。

而在node中,异步IO也是十分常见的,以读取文件为例:

const fs = require("fs");

fs.readFile("/path", function(err, file){
	console.log("读取文件完成");
});

console.log("发起读取文件");

同样这里读取文件的过程也是异步的,node主线程(也是单线程)并不会被读取文件这一操作阻塞,当他下达读取文件的命令后就会执行后续的代码,而不会等到读取文件完成后再继续执行。所以“发起读取文件”也是在“读取文件完成”之前打印的。“读取文件完成”的打印时间点也取决于读取文件的异步操作调用何时结束。

(注:实际上,node也提供了同步读取文件的方法——fs.readFileSync()。在node中,所有需要同步执行的方法,都会在方法名的后面添加“Sync”标识。)

在node中,绝大多数的操作都是以异步的方式进行调用。这样做的好处就是不同的I/O操作可以并行执行,无须相互等待,在编程模型上极大的提升了效率。例如以下的两个文件读取任务所需时间主要取决于最慢的那个:

fs.readFile("/path1", function(err, file){
	console.log("读取文件1完成");
});

fs.readFile("/path2", function(err, file){
	console.log("读取文件2完成");
});

而如果是同步IO操作,它们的耗时将会是两者之和。异步的性能优势是十分明显的。

2.事件与回调函数

node将前端浏览器中应用广泛且成熟的事件引入到后端,配合异步IO,将事件点暴露给业务逻辑。例如,以下例子中是对于Ajax异步提交的服务器端的处理过程,node创建了一个HTTP服务器,并侦听8080端口。

const http = require("http");

http.createServer((req, res) => {
	let postDate = "";
	
	//req和res均是stream类型的对象,通过以下设置能够让返回的数据是字符串形式,而不是Buffer对象
	req.setEncoding("utf-8");
	
	//在req stream对象上监听data事件,即是否有数据发送过来,发送过来的数据以chunk参数传入回调函数中
	req.on("data",(chunk) => {
		postDate += chunk;
	})
	
	//在req stream对象上监听end事件,如果数据发送完毕,则执行回调函数,比如此处是将拼接好的接收数据作为响应返回
	req.on("end", () => res.end(postData));
}).listen(8080);

3.单线程

node保持了JavaScript在浏览器中是单线程的特点,而且在node中,JavaScript与其余线程是无法共享任何状态的。单线程最大的好处就是不用像多线程编程那样处处在意状态的同步问题,没有死锁的存在,也没有线程上下文切换所带来的性能开销。

但是单线程也有着以下缺点:
(1)无法利用多核CPU;
(2)单线程中的错误会引起整个程序退出,应用的健壮性值得考验;
(3)如果存在大量占用CPU的计算,将导致无法继续调用异步IO;

这些缺点与浏览器中单线程JavaScript一样,JavaScript脚本的长时间执行将会导致UI的渲染和响应被中断(JavaScript和UI共用一个线程)。

在node中,长时间的CPU占用导致CPU时间片无法被释放,从而使得后续的异步IO发不出调用或者是已完成的异步IO回调函数无法及时的执行。

在浏览器中,解决的办法就是采用HTML5提出的Web Worker(工作线程)的机制,将大计算量的脚本执行任务放到一个新开的线程中,通过事件监听和消息传递来与JavaScript主线程进行交互(当然为了避免出现同步的问题,工作线程是没有权力访问或操作主线程中的UI)。

node中则同样采用这种思路来解决单线程中大计算量的问题:child_process。

4.跨平台

node现在可以运行在*nix 和 windows系统上。

node的应用场景主要是IO密集型,针对CPU密集型业务,实际上可以通过合理调度来高效的利用CPU。

二. 模块机制

JavaScript的代码组织一直是一个广为诟病的问题,相比于其他的高级语言,比如Java有类文件,Python有import机制,Ruby有require,PHP有include和require。而JavaScript使用<script>标签的形式引入代码显得杂乱无章。所以,为了避免全局变量的污染问题,之前的做法都是使用对象命名空间的方式人为地约束代码。

现在,CommonJS规范为JavaScript提供了专门的模块规范。在正式介绍模块规范之前,让我们先了解一下CommonJS规范。

1.CommonJS规范

CommonJS规范主要制定的目的是能够让JavaScript这门图灵完备的语言能够在任何地方运行。

相对于早先前端的JavaScript规范——ECMASript规范,该规范涵盖的范围比较小,主要集中在语言层面。在实际应用中,JavaScript的表现能力取决于宿主环境中API的支持程度(DOM,BOM,HTML5)。但是后端JavaScript规范却远远落后。而CommonJS规范就是用于填补这一空白的规范。

CommonJS规范提供了以下规范:模块,二进制,Buffer,字符集编码,io流,进程环境,文件系统,套接字,单元测试,web服务器网关接口,包管理等等。

有了CommonJS规范的定义,开发者可以使用JavaScript编写具备跨宿主环境执行能力的应用。比如:富客户端,服务器端JavaScript应用程序,命令行工具,桌面图形界面应用程序,混合应用等。

2.CommonJS的模块规范

CommonJS对模块的定义分为三个部分:
(1)模块引用;
(2)模块定义;
(3)模块标识;

(1)模块引用

在CommonJS规范中,存在require方法,该方法接受模块标识,以此引入一个模块的API到当前的上下文中,例如:

let math = require("math");

(2)模块定义

在自定义模块时,需要定义导出部分,CommonJS规范中的模块导出是通过module对象(module对象代表模块本身)的属性exports对象来完成的,我们只要把当前文件中需要导出的方法或变量挂载到exports对象上作为属性即可定义导出的方式。例如在math.js文件中:

exports.add = function(){ 
	//...
}

如果在其他文件中想要导入当前模块使用,然后再次导出自己的模块,可以:

const math = require("math");

exports.increment = function(val){
	return math.add(val, 1);
}

(3)模块标识

模块标识其实就是传递给require()方法的参数,它必须是符合小驼峰命名的字符串,或者是以.或..开头的相对路径,或者是绝对路径。(它可以省略后缀js)

CommonJS规范的这套模块导出和引入机制使得用户完全不必考虑变量污染的问题。

2.1 node的模块实现

在node中引入模块需要经历如下三个步骤:
(1)路径分析;
(2)文件定位;
(3)编译执行;

首先需要了解的是,在node中,模块分为两类,一类是node原生提供的模块,称为核心模块;另一类是用户编写的模块,称为文件模块

核心模块部分在node源码的编译过程中就已经编译进了二进制执行文件。而在node启动时,部分核心模块会被直接加载进内存中,所以这部分核心模块在引入时,可以省略掉文件定位和编译执行两个阶段,并且在第一个步骤路径分析中也会优先判断,所以它的加载速度是最快的(核心模块加载的优先级仅次于缓存加载)。

而文件模块是用户编写的模块,需要在运行时动态加载,需要经过以上三个完整的过程,所以速度比核心模块慢。

在node中为了能够提高定位和加载模块的速度,也会采用缓存的方式。node会对引入过的模块都进行缓存,以减少二次引用时所产生的开销。node缓存的是编译和执行后的对象。

(1)路径分析

require方法接受一个标识符作为参数,在node中正是基于该标识符进行模块查找的。模块标识符在node中分为以下几类:
1. 核心模块,如http,fs,path等;
2. .或..开头的相对路径文件模块;
3. 以/开头的绝对路径文件模块;
4. 非路径形式的文件模块,如自定义的connect模块(一种特殊的模块,可能是一个文件或包的形式);

如果试图加载一个与核心模块标识符同名的自定义模块是不会成功的!比如自定义的http模块只能选择一个不同的标识符或者换用路径的方式加载。

在分析文件模块时(相对路径or绝对路径),require方法会把路径转换为真实路径,并以真实路径为索引,将编译执行后的结果放在缓存中,使得二次加载更加快速。

模块路径:是node在定位文件模块的具体文件时制定的查找策略,具体表现为一个由路径组成的数组。模块路径的生成规则如下:
1.当前文件目录下的node_modules目录;
2.父目录下的node_modules目录;
3.父目录的父目录下的node_modules目录;
4.沿路径向上逐级递归,知道根目录下的node_modules目录;

第四种非路径形式的文件模块采用的就是这种方式进行模块的查找,这类模块的查找是最费时的。

(2)文件定位

在文件定位的过程中,包括对于文件扩展名的分析以及目录和包的处理。

文件扩展名分析:

require方法在分析标识符的过程中,会出现标识符中不包含文件扩展名的情况。CommonJS模块也允许这一行为,针对这种情况,node会按.js、.json、.node的次序补足扩展名然后依次尝试。在尝试的过程中,需要调用fs模块同步阻塞式的判断文件是否存在。前面提到过,node是单线程的,所以这可能会导致性能问题。所以建议在引入.json、.node文件时,最好不要省略文件扩展名

目录分析和包处理:

require方法在分析文件扩展名之后可能没有查找到对应的文件,但得到一个目录,此时node会将该目录作为一个包处理。

node对CommonJS包规范进行了一定程度的支持。node会在当前目录下查找package.json(CommonJS包规范定义的包描述文件),通过JSON.parse()解析出包描述对象,从中取出main属性指定的文件名进行定位。

若main属性指定的文件名错误或者没有package.json文件,node会将index作为默认的文件名,然后依次查找index.js、index.json、index.node。

如果目录分析的过程中没有定位任何文件,则抛出查找失败的异常。

(3)模块编译执行

在node中,每个文件模块都是一个对象。

在node定位到具体的文件后,它就会新建一个模块对象,然后根据路径载入并编译。对于不同的文件扩展名,载入方法也有所不同:
1.js文件:通过fs模块同步读取文件后编译执行;
2.node文件:通过C/C++编写的扩展文件,通过dlopen()方法加载最后编译生成的文件;
3.json文件:通过fs模块同步读取文件后,用JSON.parse()解析为JSON对象的形式并返回;
4.其余扩展名文件:均被当做js类型的文件载入;

每一个编译成功的模块都会将其文件路径作为索引缓存到Module._cache对象上,以提高二次引入的性能。

接下来将简要介绍JavaScript模块的编译过程:

每个模块文件都可以直接使用require,exports,module,__filename(完整文件路径),__dirname(文件目录)这五个变量,但是它们在模块文件中并没有定义,为何我们能够直接使用呢?

这是因为在编译js文件的过程中,node对于获取到的js文件的内容进行了头尾包装:

(function(exports, require, module, __filename, __dirname){
	const math = require("math");
	
	exports.area = function(radius){
		return Math.PI * radius * radius;
	}
})

这样每个模块文件间都进行了作用域隔离。这也是这些变量没有定义在每个模块文件中却存在的原因。

在执行之后,模块的exports属性被返回给调用方。exports属性上的任何方法和属性都可以被外部调用到,但是模块中的其余变量或属性则不可直接被调用。

三. 包与NPM

node已经部分实现了CommonJS的模块规范,使得开发者可以在本地有序地编写和使用模块化文件。但是如何引用第三方模块,依旧是个问题,这个时候NPM“模块超市”应运而生,NPM允许我们每个人上传自定义的模块,任何其他的开发人员都可以通过NPM获取自己需要的模块,就像去超市购物一样。只要你知道模块的名称,就能直接在命令行中通过npm install命令把模块下载到本地并使用。

不过在上传模块之前,还需要明确一点,就是你上传的模块并不是单一的模块文件,而得是一个包结构的文件。因为,单一的模块文件显得太过于单薄,还需要其他的文件比如说明文档和测试用例等文件以保证他人能正确使用该模块。这里就不得不提到CommonJS规范定义的包规范了。

包规范的定义由包结构包描述文件(package.json)两个部分构成,前者用于组织包中的各种文件,后者用于描述包的相关信息,以供外部读取分析。

包实际上是一个存档文件,即一个目录直接打包为.zip或tar.gz格式的文件,安装后解压还原为目录。包目录中应当包含以下文件:
(1)package.json:包描述文件;
(2)bin:用于存放可执行二进制文件的目录;
(3)lib:用于存放JavaScript代码的目录;
(4)doc:用于存放文档的目录;
(6)test:存放单元测试用例的代码;

包描述文件(package.json)用于表达非代码相关的信息,它是一个json格式的文件,位于包的根目录下,是包的重要组成部分。

包描述文件存在的一个重要的原因就是,自定义的模块往往需要引用其他模块,而如果开发人员在上传自定义模块同时也上传这些它所引用的模块就会造成云端资源的浪费。实际上可以通过package.json来指明当前模块所需要的依赖(分为开发时依赖——devDependencies和发布时依赖——dependencies),然后当其他用户在使用当前包模块时,npm会解析package.json文件,然后把依赖的文件下载到本地。当然,在package.json中还可以保存其他与当前模块相关的信息,比如作者和版本号等。

CommonJS包规范为package.json定义了以下必须的字段:
(1)name:包名;
(2)description:包简介;
(3)version:版本号;
(4)keywords:关键词数组;(重要,npm“模块超市”需要通过你提供的这些当前模块的关键字建立分类搜索,从而方便“顾客”更快速,更准确的找到他们想要的模块)
(5)maintainers:维护者列表(每个维护者由name,email,web3个属性组成);
(6)contributors:贡献者列表;
(7)bugs:一个可以反馈bug的网页地址或邮箱地址;
(8)licenses:当前包所使用的许可证列表;
(9)repositories:托管源代码的位置列表,表明可以通过哪些方式和地址访问包的源代码;
(10)dependencies:使用当前包所需要依赖的包列表(npm会通过该属性自动加载所依赖的包);

以下为可选字段:
(11)homepage:包的网站地址;
(12)os:支持的os;
(13)cpu:支持的cpu架构;
(14)engine:支持的js引擎列表;
(15)builtin:标志当前包是否内建在底层系统的标准组件;
(16)directories:包目录说明;
(17)implements:标志当前包实现了哪些CommonJS规范;
(18)scripts:脚本说明对象(重要,它主要被包管理器用来安装,编译,测试和卸载包)。例如:

"scripts": {
	"install": "install.js",
	"uninstall": "uninstall.js",
	"build": "build.js",
	"doc": "make-doc.js",
	"test": "test.js"
}

node在CommonJS规范的基础上额外添加了以下四个字段:
(19)author:包作者;
(20)bin:一些包作者希望包可以作为命令行工具使用。配置好bin字段后,通过npm install -g package_name命令可以把脚本添加到执行路径中(全局模式),之后便可以直接在命令行中执行;
(21)main:模块通过require方法引入包时,会优先检查该字段,并将其作为包中其余模块的入口。若不存在该字段,require方法会查找包目录下的index.js、index.node、index.json文件作为默认入口文件;
(22)devDenpendencies:当前模块的一些依赖只在开发时需要,可以定义在该字段中。

npm的常用功能(命令):

(1)npm -v:查看当前NPM版本;
(2)npm:查看帮助引导说明;
(3)npm help <command>:查看具体的命令说明;
(4)npm install package_name:安装包依赖,值得注意的是有三个常用参数:- -save表示安装依赖的同时将依赖写入pacakge.json的dependencies字段中;- -save-dev表示安装依赖的同时将依赖写入pacakge.json的dependencies字段和devDenpendencies字段中;-g表示全局模式下安装,这并不表示安装一个可以从任何地方require的包,而是将一个包安装为全局可用的可执行命令;全局模式必须配合package中的bin字段使用,例如:

"bin": {
	"learnyounode": "./bin/learnyounode"
}

(5)npm install <tarball file>|<tarball url>|<folder>:本地安装,当由于网络问题无法在线安装包时,可以下载到本地,然后本地安装。本地安装只需要为NPM指明package.json文件所在的位置即可。
1.tarball file:包含package.json的存档文件;
2.tarball url:一个URL地址;
3.folder:目录下有package.json文件的目录位置;
(6)npm install package_name - -registry=http://registry.url:如果某些不可抗拒因素导致无法从官方源安装,可以通过镜像源安装;如果使用过程希望默认采用镜像源安装可以执行以下命令指定默认源:npm config set registry http://registry.url
(7)npm init:在当前命令行所处的文件夹下自动生成package.json文件;
(8)npm adduser:注册NPM仓库的账号以方便上传发布包文件;
(9)npm publish <folder>:上传包文件;
(10)npm owner ls package_name:列出包的所有者;
(11)npm owner add user package_name:增加一个包的所有者;
(12)npm owner rm user package_name:删除一个包的所有者;
(13)npm ls:分析当前路径下能否通过模块路径找到所有的包,并生成依赖树;

发表评论

电子邮件地址不会被公开。 必填项已用*标注