什么是Node.js

JS是脚本语言,脚本语言都需要一个解析器才能运行。
对于写在HTML页面里的JS,浏览器充当了解析器的角色。而对于需要独立运行的JS,NodeJS就是一个解析器
每一种解析器都是一个运行环境,不但允许JS定义各种数据结构,进行各种计算,还允许JS使用运行环境提供的内置对象和方法做一些事情。
例如运行在浏览器中的JS的用途是操作DOM,浏览器就提供了document之类的内置对象。而运行在NodeJS中的JS的用途是操作磁盘文件或搭建HTTP服务器,NodeJS就相应提供了fshttp等内置对象。

简单的说 Node.js 就是运行在服务端的 JavaScript。
Node.js是一个基于Chrome JavaScript 运行时而建立的平台。
Node.js是一个事件驱动I/O服务端JavaScript环境,基于Google的V8引擎。

Node.js的用处

Ryan DahI创造NodeJS的目的是为了实现高性能Web服务器,他首先看重的是事件机制和异步IO模型的优越性。而JS没有自带IO功能,天生就用于处理浏览器中的DOM事件,很契合需求。

模块

NPM

文件操作

小文件操作

readFileSync() ——读
writeFileSync() ——写

  1. const fs = require("fs");
  2. function copy(src, dist) {
  3. // 使用fs.readFileSync从源路径读取文件内容,并使用fs.writeFileSync将文件内容写入目标路径。
  4. fs.writeFileSync(dist, fs.readFileSync(src));
  5. }
  6. function main(argv) {
  7. copy(argv[0], argv[1]);
  8. }
  9. // process是一个全局变量,可通过process.argv获得命令行参数。
  10. // 由于argv[0]固定等于NodeJS执行程序的绝对路径,argv[1]固定等于主模块的绝对路径,因此第一个命令行参数从argv[2]这个位置开始。
  11. console.log("node copy.js ./copy.js ./hello/copy_bak.js");
  12. console.log(process.argv);
  13. main(process.argv.slice(2));
  14. //$ node copy.js ./copy.js ./hello/copy_bak.js
  15. // [ '/usr/local/bin/node',
  16. // '/Users/bubu/Desktop/nodejs-learning/copy.js',
  17. // './copy.js',
  18. // './hello/copy_bak.js' ]

大文件操作

一次性把所有文件内容都读取到内存中后再一次性写入磁盘的方式不适合拷贝大文件,内存会爆仓。对于大文件,我们只能读一点写一点,直到完成拷贝。

createReadStream() —— 只读 流
createWriteStream() —— 只写 流

  1. var fs = require("fs");
  2. function copy(src, dst) {
  3. // 水顺着水管从一个桶流到了另一个桶
  4. fs.createReadStream(src).pipe(fs.createWriteStream(dst));
  5. }
  6. function main(argv) {
  7. copy(argv[0], argv[1]);
  8. }
  9. main(process.argv.slice(2));

使用 fs.createReadStream 创建了一个源文件的只读数据流,并使用 fs.createWriteStream 创建了一个目标文件的只写数据流,并且用pipe方法把两个数据流连接了起来。

File System

Node.js通过fs内置模块提供对文件的操作。fs模块提供的API基本上可以分为以下三类:

  • 文件属性读写
    其中常用的有fs.statfs.chmodfs.chown等等。
  • 文件内容读写
    其中常用的有fs.readFilefs.readdirfs.writeFilefs.mkdir等等。
  • 底层文件操作
    其中常用的有fs.openfs.readfs.writefs.close等等。

readFile

  1. fs.readFile(path, (err, data) => {
  2. if (err) {
  3. console.error(err);
  4. } else {
  5. console.log(data);
  6. }
  7. })

readFileSync

  1. try {
  2. const data = fs.readFileSync(path);
  3. console.log(data);
  4. } catch (err) {
  5. console.error(err);
  6. }

Buffer

全局构造函数Buffer来提供对二进制数据的操作。
可以用[index]方式直接修改某个位置的字节。
对.slice方法返回的Buffer的修改会作用于原Buffer。

转化:

  1. const bin = new Buffer('hello', 'utf-8');
  2. const str = bin.toString("utf-8");

Stream

当内存中无法一次装下需要处理的数据时,或者一边读取一边处理更加高效时,我们就需要用到数据流。NodeJS中通过各种Stream来提供对数据流的操作。

Stream基于事件机制工作,所有Stream的实例都继承于NodeJS提供的EventEmitter

基础版

  1. const fs = require("fs");
  2. const rs = fs.createReadStream("./input.txt");
  3. rs.on("data", chunk => {
  4. console.log(chunk);
  5. console.log(chunk.toString("utf-8").trim());
  6. });
  7. rs.on("end", () => {
  8. console.log("end...");
  9. });

pause/resume
在处理数据前暂停数据读取,并在处理数据后继续读取数据。

  1. const fs = require("fs");
  2. const rs = fs.createReadStream("./input.txt");
  3. displayContent = (chunk, cb) => {
  4. console.log(chunk);
  5. console.log(chunk.toString("utf-8").trim());
  6. cb && cb("resume");
  7. }
  8. rs.on("data", chunk => {
  9. rs.pause();
  10. displayContent(chunk, signal=>{
  11. console.log("signal:", signal);
  12. rs.resume();
  13. });
  14. });
  15. rs.on("end", () => {
  16. console.log("end...");
  17. });

最终版

  1. const fs = require("fs");
  2. const rs = fs.createReadStream("./input.txt");
  3. const ws = fs.createWriteStream("./hello/copy.txt");
  4. rs.on("data", chunk => {
  5. if(ws.write(chunk) === false){
  6. rs.pause();
  7. }
  8. });
  9. rs.on('end', function () {
  10. ws.end();
  11. });
  12. ws.on('drain', function () {
  13. rs.resume();
  14. });

数据从只读数据流到只写数据流的搬运,并包括了防爆仓控制。因为这种使用场景很多,例如上边的大文件拷贝程序,NodeJS直接提供了.pipe方法来做这件事情,其内部实现方式与上边的代码类似。

Path

path 内置模块来简化路径相关操作

path.normalize()

  1. const path = require("path");
  2. let cache = {};
  3. store = (key, value) => {
  4. cache[path.normalize(key)] = value;
  5. };
  6. store("foo/bar", 1);
  7. store("foo//baz//../bar", 2);
  8. console.log(cache); // => { "foo/bar": 2 }

标准化之后的路径里的斜杠在Windows系统下是\,而在Linux系统下是/。如果想保证任何系统下都使用/作为路径分隔符的话,需要用.replace(/\\/g, '/')再替换一下标准路径。

path.join()

将传入的多个路径拼接为标准路径。该方法可避免手工拼接路径字符串的繁琐,并且能在不同系统下正确使用相应的路径分隔符。以下是一个例子:

  1. path.join('foo/', 'baz/', '../bar'); // => "foo/bar"

path.extname()

当我们需要根据不同文件扩展名做不同操作时,该方法就显得很好用。以下是一个例子:

  1. path.extname('foo/bar.js'); // => ".js"

遍历目录

同步

  1. const fs = require("fs");
  2. const path = require("path");
  3. travel = (dir, cb) => {
  4. fs.readdirSync(dir).forEach(file => {
  5. console.info(file);
  6. const pathname = path.join(dir, file);
  7. if (fs.statSync(pathname).isDirectory()) {
  8. travel(pathname, cb);
  9. } else {
  10. cb(pathname);
  11. }
  12. });
  13. };
  14. travel("/Users/bubu/Desktop/nodejs-learning", pathname => {
  15. console.log(pathname);
  16. });

异步

  1. function travel(dir, callback, finish) {
  2. fs.readdir(dir, function (err, files) {
  3. (function next(i) {
  4. if (i < files.length) {
  5. var pathname = path.join(dir, files[i]);
  6. fs.stat(pathname, function (err, stats) {
  7. if (stats.isDirectory()) {
  8. travel(pathname, callback, function () {
  9. next(i + 1);
  10. });
  11. } else {
  12. callback(pathname, function () {
  13. next(i + 1);
  14. });
  15. }
  16. });
  17. } else {
  18. finish && finish();
  19. }
  20. }(0));
  21. });
  22. }

文本编码

使用NodeJS编写前端工具时,操作得最多的是文本文件,因此也就涉及到了文件编码的处理问题。
我们常用的文本编码有UTF8和GBK两种,并且UTF8文件还可能带有BOM。
在读取不同编码的文本文件时,需要将文件内容转换为JS使用的UTF8编码字符串后才能正常处理。

去除bom

  1. function readText(pathname) {
  2. var bin = fs.readFileSync(pathname);
  3. if (bin[0] === 0xEF && bin[1] === 0xBB && bin[2] === 0xBF) {
  4. bin = bin.slice(3);
  5. }
  6. return bin.toString('utf-8');
  7. }

gbk -> utf-8

  1. var iconv = require('iconv-lite');
  2. function readGBKText(pathname) {
  3. var bin = fs.readFileSync(pathname);
  4. return iconv.decode(bin, 'gbk');
  5. }

单字节编码

  1. function replace(pathname) {
  2. var str = fs.readFileSync(pathname, 'binary');
  3. str = str.replace('foo', 'bar');
  4. fs.writeFileSync(pathname, str, 'binary');
  5. }

网络操作

http/https/zlib

‘http’模块提供两种使用方式:

  • 作为服务端使用时,创建一个HTTP服务器,监听HTTP客户端请求并返回响应。
  • 作为客户端使用时,发起一个HTTP客户端请求,获取服务端响应。

server
options里加入rejectUnauthorized: false字段可以禁用对证书有效性的检查,从而允许https模块请求开发环境下使用自制证书的HTTPS服务器。

  1. // https
  2. const options = {
  3. key: fs.readFileSync('./ssl/default.key'),
  4. cert: fs.readFileSync('./ssl/default.cer')
  5. };
  6. const server = https.createServer(options, function (request, response) {
  7. // ...
  8. });
  9. // http
  10. http.createServer(function (request, response) {
  11. // response.writeHead(200, { 'Content-Type': 'text/plain' });
  12. if ((request.headers['accept-encoding'] || '').indexOf('gzip') !== -1) {
  13. zlib.gzip(data, function (err, data) {
  14. response.writeHead(200, {
  15. 'Content-Type': 'text/plain',
  16. 'Content-Encoding': 'gzip'
  17. });
  18. response.end(data);
  19. });
  20. } else {
  21. response.writeHead(200, {
  22. 'Content-Type': 'text/plain'
  23. });
  24. response.end(data);
  25. }
  26. request.on('data', function (chunk) {
  27. response.write(chunk);
  28. });
  29. request.on('end', function () {
  30. response.end();
  31. });
  32. }).listen(80);

client

  1. var options = {
  2. hostname: 'www.example.com',
  3. port: 80,
  4. path: '/upload', // '/'
  5. method: 'POST', // 'GET'
  6. headers: {
  7. 'Content-Type': 'application/x-www-form-urlencoded',
  8. 'Accept-Encoding': 'gzip, deflate'
  9. }
  10. };
  11. var request = http.request(options, function (response) {
  12. var body = [];
  13. response.on('data', function (chunk) {
  14. body.push(chunk);
  15. });
  16. response.on('end', function () {
  17. body = Buffer.concat(body);
  18. if (response.headers['content-encoding'] === 'gzip') {
  19. zlib.gunzip(body, function (err, data) {
  20. console.log(data.toString());
  21. });
  22. } else {
  23. console.log(data.toString());
  24. }
  25. });
  26. });
  27. // http.get('http://www.example.com/', function (response) {});
  28. request.write('Hello World');
  29. request.end();

url

  1. href
  2. -----------------------------------------------------------------
  3. host path
  4. --------------- ----------------------------
  5. http: // user:pass @ host.com : 8080 /p/a/t/h ?query=string #hash
  6. ----- --------- -------- ---- -------- ------------- -----
  7. protocol auth hostname port pathname search hash
  8. ------------
  9. query

解析URL

  1. const url = require("url");
  2. const data = url.parse(
  3. "https://juejin.im/search?query=node&utm_source=gold_browser_extension&utm_medium=search"
  4. );
  5. console.log(data);

.parse方法还支持第二个和第三个布尔类型可选参数。第二个参数等于true时,该方法返回的URL对象中,query字段不再是一个字符串,而是一个经过querystring模块转换后的参数对象。第三个参数等于true时,该方法可以正确解析不带协议头的URL,例如//www.example.com/foo/bar

生成URL
URL对象转换为URL字符串

  1. url.format({
  2. protocol: 'http:',
  3. host: 'www.example.com',
  4. pathname: '/p/a/t/h',
  5. search: 'query=string'
  6. });
  7. =>
  8. 'http://www.example.com/p/a/t/h?query=string'

拼接URL

  1. url.resolve('http://www.example.com/foo/bar', '../baz');
  2. =>
  3. 'http://www.example.com/baz'

QueryString

querystring模块用于实现URL参数字符串与参数对象的互相转换

  1. querystring.parse('foo=bar&baz=qux&baz=quux&corge');
  2. =>
  3. { foo: 'bar', baz: ['qux', 'quux'], corge: '' }
  4. querystring.stringify({ foo: 'bar', baz: ['qux', 'quux'], corge: '' });
  5. =>
  6. 'foo=bar&baz=qux&baz=quux&corge='

问: 为什么http模块创建的HTTP服务器返回的响应是chunked传输方式的? 答: 因为默认情况下,使用.writeHead方法写入响应头后,允许使用.write方法写入任意长度的响应体数据,并使用.end方法结束一个响应。由于响应体数据长度不确定,因此NodeJS自动在响应头里添加了Transfer-Encoding: chunked字段,并采用chunked传输方式。但是当响应体数据长度确定时,可使用.writeHead方法在响应头里加上Content-Length字段,这样做之后NodeJS就不会自动添加Transfer-Encoding字段和使用chunked传输方式。

进程管理

  1. var child_process = require('child_process');
  2. var util = require('util');
  3. function copy(source, target, callback) {
  4. child_process.exec(
  5. util.format('cp -r %s/* %s', source, target), callback);
  6. }
  7. copy('a', 'b', function (err) {
  8. // ...
  9. });

Process
任何一个进程都有启动进程时使用的命令行参数,有标准输入标准输出,有运行权限,有运行环境和运行状态。在NodeJS中,可以通过process对象感知和控制NodeJS自身进程的方方面面。另外需要注意的是,process不是内置模块,而是一个全局对象,因此在任何地方都可以直接使用。

Child Process
使用child_process模块可以创建和控制子进程。该模块提供的API中最核心的是.spawn,其余API都是针对特定使用场景对它的进一步封装,算是一种语法糖。

Cluster
cluster模块是对child_process模块的进一步封装,专用于解决单进程NodeJS Web服务器无法充分利用多核CPU的问题。使用该模块可以简化多进程服务器程序的开发,让每个核上运行一个工作进程,并统一通过主进程监听端口和分发请求。

如何获取命令行参数

在NodeJS中可以通过process.argv获取命令行参数。但是比较意外的是,node执行程序路径和主模块文件路径固定占据了argv[0]argv[1]两个位置,而第一个命令行参数从argv[2]开始。

  1. process.argv.slice(2)

如何退出程序

  1. try {
  2. // ...
  3. } catch (err) {
  4. // ...
  5. process.exit(1);
  6. }

如何控制输入输出

NodeJS程序的标准输入流(stdin)、一个标准输出流(stdout)、一个标准错误流(stderr)分别对应process.stdinprocess.stdoutprocess.stderr,第一个是只读数据流,后边两个是只写数据流,对它们的操作按照对数据流的操作方式即可。

如何降权

在Linux系统下,我们知道需要使用root权限才能监听1024以下端口。但是一旦完成端口监听后,继续让程序运行在root权限下存在安全隐患,因此最好能把权限降下来。以下是这样一个例子。

  1. http.createServer(callback).listen(80, function () {
  2. var env = process.env,
  3. uid = parseInt(env['SUDO_UID'] || process.getuid(), 10),
  4. gid = parseInt(env['SUDO_GID'] || process.getgid(), 10);
  5. process.setgid(gid);
  6. process.setuid(uid);
  7. });

上例中有几点需要注意:

  1. 如果是通过sudo获取root权限的,运行程序的用户的UID和GID保存在环境变量SUDO_UIDSUDO_GID里边。如果是通过chmod +s方式获取root权限的,运行程序的用户的UID和GID可直接通过process.getuidprocess.getgid方法获取。
  2. process.setuidprocess.setgid方法只接受number类型的参数。
  3. 降权时必须先降GID再降UID,否则顺序反过来的话就没权限更改程序的GID了。

如何创建子进程

以下是一个创建NodeJS子进程的例子。

  1. var child = child_process.spawn('node', [ 'xxx.js' ]);
  2. child.stdout.on('data', function (data) {
  3. console.log('stdout: ' + data);
  4. });
  5. child.stderr.on('data', function (data) {
  6. console.log('stderr: ' + data);
  7. });
  8. child.on('close', function (code) {
  9. console.log('child process exited with code ' + code);
  10. });

上例中使用了.spawn(exec, args, options)方法,该方法支持三个参数。
第一个参数是执行文件路径,可以是执行文件的相对或绝对路径,也可以是根据PATH环境变量能找到的执行文件名。
第二个参数中,数组中的每个成员都按顺序对应一个命令行参数。
第三个参数可选,用于配置子进程的执行环境与行为。
另外,上例中虽然通过子进程对象的.stdout.stderr访问子进程的输出,但通过options.stdio字段的不同配置,可以将子进程的输入输出重定向到任何数据流上,或者让子进程共享父进程的标准输入输出流,或者直接忽略子进程的输入输出。

进程间如何通讯

在Linux系统下,进程之间可以通过信号互相通信。以下是一个例子。

  1. /* parent.js */
  2. var child = child_process.spawn('node', [ 'child.js' ]);
  3. child.kill('SIGTERM');
  4. /* child.js */
  5. process.on('SIGTERM', function () {
  6. cleanUp();
  7. process.exit(0);
  8. });

在上例中,父进程通过.kill方法向子进程发送SIGTERM信号,子进程监听process对象的SIGTERM事件响应信号。不要被.kill方法的名称迷惑了,该方法本质上是用来给进程发送信号的,进程收到信号后具体要做啥,完全取决于信号的种类和进程自身的代码。
另外,如果父子进程都是NodeJS进程,就可以通过IPC(进程间通讯)双向传递数据。以下是一个例子。

  1. /* parent.js */
  2. var child = child_process.spawn('node', [ 'child.js' ], {
  3. stdio: [ 0, 1, 2, 'ipc' ]
  4. });
  5. child.on('message', function (msg) {
  6. console.log(msg);
  7. });
  8. child.send({ hello: 'hello' });
  9. /* child.js */
  10. process.on('message', function (msg) {
  11. msg.hello = msg.hello.toUpperCase();
  12. process.send(msg);
  13. });

可以看到,父进程在创建子进程时,在options.stdio字段中通过ipc开启了一条IPC通道,之后就可以监听子进程对象的message事件接收来自子进程的消息,并通过.send方法给子进程发送消息。在子进程这边,可以在process对象上监听message事件接收来自父进程的消息,并通过.send方法向父进程发送消息。数据在传递过程中,会先在发送端使用JSON.stringify方法序列化,再在接收端使用JSON.parse方法反序列化。

如何守护子进程

守护进程一般用于监控工作进程的运行状态,在工作进程不正常退出时重启工作进程,保障工作进程不间断运行。以下是一种实现方式。

  1. /* daemon.js */
  2. function spawn(mainModule) {
  3. var worker = child_process.spawn('node', [ mainModule ]);
  4. worker.on('exit', function (code) {
  5. if (code !== 0) {
  6. spawn(mainModule);
  7. }
  8. });
  9. }
  10. spawn('worker.js');

可以看到,工作进程非正常退出时,守护进程立即重启工作进程。

异步编程

NodeJS最大的卖点——事件机制和异步IO
创建一个平行线程后立即返回,让JS主进程可以接着执行后续代码,并在收到平行进程的通知后再执行回调函数。
即使平行线程完成工作了,通知JS主线程执行回调函数了,回调函数也要等到JS主线程空闲时才能开始执行。

  1. heavyCompute = n => {
  2. let count = 0,
  3. i, j;
  4. for (i = n; i > 0; --i) {
  5. for (j = n; j > 0; --j) {
  6. count += 1;
  7. }
  8. }
  9. }
  10. const t = new Date();
  11. setTimeout(() => {
  12. console.log(new Date() - t);
  13. }, 1000);
  14. heavyCompute(50000); // 2215
  15. // 本来应该在1秒后被调用的回调函数因为JS主线程忙于运行其它代码,实际执行时间被延迟。