最近面试了一些 Nodejs 的工作,总结了一些 Node 原理。这里分享一下。

0. Node 是什么?优缺点是什么?

开源的,基于 V8 引擎的,非阻塞式异步 I/O 的 JavaScript 运行环境。

回答点:非阻塞 I/O (回调函数) + 事件队列

优点:

  • 高并发场景
  • IO 密集型应用 (内存读取)

缺点:

  • 不适合 CPU 密集型应用,只在单核 CPU 上运行
  • 代码有问题,整个系统会发生崩溃

1. Node 的应用场景

  • 后台管理系统,实时交互,高并发
  • canvas 绘图,联网 web 游戏,实时交互,高并发
  • webSocket, 聊天室
  • 利用 db,搭建 json api
  • 单页面浏览器应用及创建服务

2. Node 的全局对象

全局对象是指在任何模块中都可以直接访问到的对象,而不需要通过 require 或其他方式进行导入。

global

最基础的全局对象,类似于浏览器环境中的 window 对象。在 Node.js 中,所有全局变量(除了 global 本身以外)都是 global 对象的属性。

process

这是一个提供了与当前 Node.js 进程互动接口的全局对象。通过 process 对象,我们可以获取到当前进程的信息,如 PID(进程 ID)、运行环境等。同时,它也提供了一些方法,如 process.exit()用于退出当前进程。

console

Buffer

在 Node.js 中,Buffer 类是一个全局可用的类型,用于处理二进制数据。它可以用于在 TCP 流、文件系统操作、以及其他上下文中与八位字节流进行交互。

setTimeout/setInterval/clearTimeout/clearInterval

这些全局函数用于处理定时器。例如,setTimeout()函数用于在指定的毫秒数后执行一个函数,而 setInterval()函数则用于定期执行一个函数。

filename

这是一个包含当前正在执行的脚本的文件名的全局变量。它返回的是文件所在位置的绝对路径。

dirname

这是一个包含当前执行脚本所在目录的全局变量。它返回的是目录的绝对路径。

3. 模块加载机制 require()

require 加载模块的机制

  1. CommonJS 规范:require 是基于 CommonJS 规范的模块加载方式,主要在 Node.js 环境中使用。
  2. 动态加载:require 可以在代码的任何地方进行调用,允许动态地加载和执行模块。
  3. 同步加载:require 是同步的,意味着在模块加载完成之前,代码执行会暂停。
  4. 缓存机制:当使用 require 加载一个模块时,Node.js 会缓存该模块。如果再次尝试加载同一个模块,Node.js 会直接从缓存中取出,而不会重新执行模块的代码。
  5. 导出方式:使用 module.exports 或 exports 来导出模块成员。

import 模块加载机制

  1. ES6 (ECMAScript 2015) 规范:import 是基于 ES6 模块的加载方式,旨在提供静态的模块结构。
  2. 静态加载:import 语句必须位于文件的顶部,不能在代码块内(如 if 语句或函数内)使用。
  3. 异步与同步:虽然 import 主要是同步的,但 ES6 也引入了动态 import() 函数,它返回一个 Promise,允许异步加载模块。
  4. 不缓存(默认情况下):与 require 不同,每次使用 import 加载模块时,都会重新执行模块的代码(除非使用了特定的缓存策略或打包工具进行了优化)。
  5. 导出方式:使用 export 关键字来导出模块成员。

4. 基础模块

基于事件处理,node 内置了不同的模块,如 fshttppathosutileventsstream 等。下面,对相应的基础模块进行举例:

fs 文件读取和写入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const fs = require("fs");

// 读取文件异步:
fs.readFile("example.txt", "utf8", (err, data) => {
if (err) throw err;
console.log(data);
});

// 写入文件
const content = "Hello, Node.js!";
fs.writeFile("output.txt", content, (err) => {
if (err) throw err;
console.log("The file has been saved!");
});

path 文件路径处理

1
2
3
4
5
6
7
const path = require("path");

const fullPath = path.resolve("/foo/bar", "./baz");
console.log(fullPath); // 输出: /foo/bar/baz

const extname = path.extname("index.html");
console.log(extname); // 输出: .html

events 事件模块处理

1
2
3
4
5
6
7
8
const EventEmitter = require("events");
const emitter = new EventEmitter();

emitter.on("myEvent", (arg1, arg2) => {
console.log(`Event triggered with ${arg1} and ${arg2}`);
});

emitter.emit("myEvent", "Hello", "World"); // 输出: Event triggered with Hello and World

http HTTP 请求和响应

1
2
3
4
5
6
7
8
9
10
11
const http = require("http");

const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Hello, World!\n");
});

const port = 3000;
server.listen(port, () => {
console.log(`Server running at http://localhost:${port}/`);
});

url 处理

1
2
3
4
5
6
7
8
9
const url = require("url");

const myURL = new URL("https://example.com/path?name=value#hash");

console.log(myURL.protocol); // 输出: https:
console.log(myURL.hostname); // 输出: example.com
console.log(myURL.pathname); // 输出: /path
console.log(myURL.search); // 输出: ?name=value
console.log(myURL.hash); // 输出: #hash

querystring 处理

这个模块配合上面的 url 使用

1
2
3
4
5
6
7
8
const querystring = require("querystring");

const params = querystring.parse("name=John&age=30");
console.log(params.name); // 输出: John
console.log(params.age); // 输出: 30

const stringified = querystring.stringify({ name: "Jane", age: 25 });
console.log(stringified); // 输出: name=Jane&age=25

events 事件处理

1
2
3
4
5
6
7
8
const EventEmitter = require("events");
const emitter = new EventEmitter();

emitter.on("myEvent", (arg1, arg2) => {
console.log(`Event triggered with ${arg1} and ${arg2}`);
});

emitter.emit("myEvent", "Hello", "World"); // 输出: Event triggered with Hello and World

os 操作系统模块

1
2
3
4
const os = require("os");

console.log(os.homedir()); // 输出用户的主目录路径
console.log(os.totalmem()); // 输出系统的总内存(以字节为单位)

5. Node 有哪些网络模块

以 demo 代码来说明问题:

http 模块

1
2
3
4
5
6
7
8
9
10
11
const http = require("http");

const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Hello, World!\n");
});

const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});

https 模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const https = require("https");
const fs = require("fs");

// 需要SSL证书的配置:
const options = {
key: fs.readFileSync("path/to/private.key"),
cert: fs.readFileSync("path/to/certificate.pem"),
ca: fs.readFileSync("path/to/ca.pem"),
};

const server = https.createServer(options, (req, res) => {
res.writeHead(200);
res.end("Hello, Secure World!\n");
});

const PORT = process.env.PORT || 443;
server.listen(PORT, () => {
console.log(`Secure server is running on port ${PORT}`);
});

net 模块

TCP 模块可以用来创建 TCP 服务器。比起 HTTP 底层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const net = require("net");

const server = net.createServer((socket) => {
socket.write("Welcome to the TCP server!\n");
socket.on("data", (data) => {
console.log(`Received from client: ${data.toString()}`);
socket.write(`Server received: ${data.toString()}`);
});
socket.on("end", () => {
console.log("Client connection ended.");
});
});

const PORT = process.env.PORT || 12345;
server.listen(PORT, () => {
console.log(`TCP server is listening on port ${PORT}`);
});

6. Node 处理高并发任务的原理

非阻塞 I/O:

Node.js 中的 I/O 操作(如文件读写、网络通信等)是非阻塞的,这意味着当一个 I/O 操作被发起时,Node.js 不会等待这个操作完成,而是继续执行后面的代码。当 I/O 操作完成时,会通过回调函数或者 Promise 的方式通知 Node.js,从而进行后续处理。这种方式避免了线程阻塞,使得 Node.js 可以同时处理多个请求。

事件驱动:

Node.js 采用事件驱动模型,通过监听事件来处理各种任务。例如,当一个 HTTP 请求到达时,Node.js 会触发一个 ‘request’ 事件,并将请求对象作为参数传递给事件处理函数。这种模型使得 Node.js 可以轻松地处理大量并发的网络连接。

异步编程:

Node.js 鼓励使用异步编程模式,通过回调函数、Promise、async/await 等技术来处理异步操作。这使得 Node.js 能够在处理一个请求的同时,继续接收和处理其他请求,从而实现高并发。

单线程的优势:

虽然 Node.js 是单线程的,但这意味着它不需要像多线程环境那样处理复杂的线程同步问题,从而降低了编程的复杂性。此外,单线程模型也使得 Node.js 在处理大量并发连接时具有较低的内存占用和上下文切换开销。

利用多核处理器:

尽管 Node.js 本身是单线程的,但你可以通过创建多个 Node.js 进程来利用多核处理器的能力。例如,使用 Node.js 的 cluster 模块可以创建多个工作进程,每个进程运行在不同的 CPU 核心上,从而实现并行处理。此外,还可以使用 PM2 等工具来管理和负载均衡多个 Node.js 进程。

线程池处理 CPU 密集型任务:

对于 CPU 密集型任务(如大量的数学计算或数据处理),Node.js 的单线程模型可能不是最高效的。在这种情况下,可以使用 Node.js 的 worker_threads 模块或其他第三方线程池库(如前面提到的 node-worker-threads-pool)来创建多线程环境,从而提高 CPU 密集型任务的处理能力。

7. Node 的多线程处理模块

Node.js 本身是单线程的,通过事件循环和非阻塞 I/O 操作实现了高并发。然而,有些 CPU 密集型任务可能会阻塞事件循环,这时候可以考虑使用线程池来处理这些任务。

在 Node.js 中,可以使用 worker_threads 模块来创建多线程,但这个模块相对底层,直接使用可能会比较复杂。为了简化线程池的使用,可以使用第三方库,比如 fast-poolnode-worker-threads-pool

1
npm install node-worker-threads-pool

下面是一个使用 node-worker-threads-pool 库创建线程池的简单示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const { WorkerPool } = require("node-worker-threads-pool");

// 创建一个具有4个工作线程的线程池
const pool = new WorkerPool({ max: 4 });

// 定义一个要在工作线程中执行的任务
const task = (n) => {
let sum = 0;
for (let i = 0; i < n; i++) {
sum += i;
}
return sum;
};

// 将任务提交到线程池执行,并处理返回的结果
pool
.exec(task, [100000000])
.then((result) => {
console.log("Result:", result);
})
.catch((err) => {
console.error("Error:", err);
});

// 当不再需要线程池时,应该关闭它
// 注意:在实际应用中,通常会在应用程序终止时关闭线程池。
// 这里为了示例简单,直接在后面关闭了。
setTimeout(() => {
pool.terminate().then(() => {
console.log("Worker pool terminated");
});
}, 5000); // 等待5秒后关闭线程池

8. I/O 模型的分类

Node 里经常提到非阻塞 I/O, 那么对于 I/O 模型,也是有多重分类。在电脑中,IO 是处理输入输出操作的不同方式。这些模型主要关注于如何有效地管理数据的读写,特别是在涉及磁盘、网络或其他外部设备时。

阻塞

在这种模型中,当用户空间的应用程序执行一个系统调用进行 IO 操作时,如果该操作不能立即完成,那么应用程序会被阻塞,直到该 IO 操作完成为止。在此期间,应用程序无法执行其他任务。

非阻塞

与阻塞 IO 相反,非阻塞 IO 模型中的系统调用会立即返回,无论 IO 操作是否完成。如果数据还未准备好,系统调用会返回一个错误或者表示数据未准备好的状态。这样,应用程序可以继续执行其他任务,而不会被阻塞。

多路复用

这种模型允许应用程序同时监控多个 IO 通道,例如多个网络连接或文件描述符。通过使用 select、poll 或 epoll 等系统调用,应用程序可以等待多个 IO 事件,而无需为每个通道都使用一个单独的线程或进程。当某个通道准备好进行 IO 操作时,应用程序会收到通知并进行相应的处理。

异步

在这种模型中,当应用程序发起一个 IO 操作后,它不需要等待该操作的完成。相反,当数据准备好时,操作系统会通过某种机制(如回调函数、事件或信号)通知应用程序。这种模型允许应用程序继续执行其他任务,而不需要轮询或等待 IO 操作的完成。

9. Node 的事件循环机制

Node.js 的事件循环是基于 libuv 跨平台的异步 I/O 库(multi-platform async IO)实现的。这个库关联到一个EVENT_QUEUE先进先出(FIFO)的事件队列,并且该事件循环负责处理所有事件EVENT_LOOP,包括定时器、文件系统操作等。

  1. timers 阶段:此阶段执行定时器(setTimeout/setInterval)的回调函数。
  2. I/O callbacks 阶段:执行除了定时器和 setImmediate 的回调外的 I/O 回调。
  3. idle 和 prepare 阶段:这两个阶段主要为系统内部使用,可以认为是闲置时间。
  4. poll 阶段:轮询可用的 I/O 事件,执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,由计时器和 setImmediate 调度的回调函数)。
  5. check 阶段:在此阶段执行 setImmediate 的回调函数。
  6. close callbacks 阶段:执行关闭的回调函数,如 socket.on(‘close’, …)。

微任务:
包括process.nextTick()
其他微任务队列(例如 Promise 的回调函数)。

宏任务:
timer queue:setTimeout/setInterval
poll queue: IO
check queue: setImmediate
close queue: socket.on(‘end’)

process orders:

  1. nextTick micro task queue
  2. other micro task queue
  3. timer queue
  4. poll queue
  5. check queue
  6. close queue

here is the demo code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const fs = require("fs");

console.log("Start");

// 异步文件系统操作
fs.readFile(__filename, "utf8", (err, data) => {
if (err) throw err;
console.log("File read complete:", data.length);
});

// 使用setTimeout来延迟执行一个函数
setTimeout(() => {
console.log("Timeout callback executed!");
}, 0); // 设置为0毫秒并不意味着会立即执行,而是将其放入事件队列等待执行

// 使用setImmediate来在当前事件循环之后执行一个函数
setImmediate(() => {
console.log("setImmediate callback executed!");
});

// 使用process.nextTick在当前操作完成后立即执行一个函数
process.nextTick(() => {
console.log("Next tick callback executed!");
});

console.log("Scheduled all callbacks");
// Start
// Scheduled all callbacks
// Next tick callback executed!
// setImmediate callback executed!
// Timeout callback executed!
// File read complete: 1036

再一个 demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
async function async1() {
console.log("async1 start");
await async2();
console.log("async1 end");
}
async function async2() {
console.log("async2");
}

console.log("start");

setTimeout(function () {
console.log("setTimeout1");
}, 0); // setTimeout 队列

setTimeout(function () {
console.log("setTimeout2");
}, 200); // check 队列

setImmediate(() => console.log("setImmediate"));
process.nextTick(() => console.log("nextTick1"));

async1();

process.nextTick(() => console.log("nextTick2"));

new Promise((resolve) => {
console.log("promise1");
resolve("");
console.log("promise2");
}).then(() => {
console.log("promise3");
});

console.log("script end");

// start
// async1 start
// async2
// promise1
// promise2
// script end
// nextTick1
// nextTick2
// async1 end
// promise3
// setTimeout1
// setImmediate
// setTimeout2

10. Node 的全局异常处理

process 对象来捕获全局异常。process 对象是一个全局变量,提供了与当前 Node.js 进程互动的接口。
最佳实践是尽量避免依赖全局异常处理,而是在代码中尽可能明确地使用 try-catch 语句和 Promise 的错误处理机制来捕获和处理这些异常。
process 里,可以监听以下事件来捕获全局异常:

uncaughtException

a. 这种异常通常指的是在同步代码中未被 try-catch 语句捕获的错误,或者在异步回调中未被正确处理的错误。
b. 常见的 JavaScript 错误类型,如 ReferenceError,SyntaxError 等
c. 还包括系统级错误,如尝试打开不存在的文件时抛出的错误。

1
2
3
4
5
6
process.on("uncaughtException", (err) => {
console.error("捕获到未处理的异常:", err);
// 在这里你可以记录异常信息,或者执行其他必要的操作
});

throw new Error("这是一个未捕获的异常");

unhandledRejection

这种异常特指与 Promise 相关的错误。当一个 Promise 被拒绝(rejected),且没有提供 .catch() 处理程序或在 async function 中没有使用 try…catch 语句来捕获错误时,就会触发 unhandledRejection 事件。

1
2
3
4
5
6
7
process.on("unhandledRejection", (reason, promise) => {
console.error("捕获到未处理的 Promise 拒绝:", reason);
// 在这里你可以记录异常信息,或者执行其他必要的操作
});

// 抛出一个未处理的 Promise 拒绝来测试全局异常捕获
Promise.reject(new Error("这是一个未处理的 Promise 拒绝"));

11. Node 多核处理

需要注意的是,开启多核处理并不是为了解决高并发问题,而主要是为了充分利用多核 CPU 的性能,提高应用程序的处理能力和响应速度。

cluster 模块

demo 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const cluster = require("cluster");
const os = require("os");
const http = require("http");

if (cluster.isMaster) {
// 获取CPU核心数
const cpuCount = os.cpus().length;

console.log(`主进程 ${process.pid} 正在运行`);

// 根据CPU核心数创建相应数量的工作进程
for (let i = 0; i < cpuCount; i++) {
cluster.fork();
}

cluster.on("exit", (worker, code, signal) => {
console.log(`工作进程 ${worker.process.pid} 已退出`);
});
} else {
// 工作进程中运行的代码
http
.createServer((req, res) => {
res.writeHead(200);
res.end("你好,这个响应来自进程 " + process.pid);
})
.listen(8000);

console.log(`工作进程 ${process.pid} 已启动`);
}

child_process 模块

childScript.js 文件:

1
2
3
4
5
6
7
//
process.on("message", (message) => {
console.log("Received message in child:", message);

// 处理消息后发送回主进程
process.send({ response: "Message received" });
});

主进程文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const { fork } = require("child_process");

// 创建子进程
const child = fork("./childScript.js");

// 发送消息给子进程
child.send({ hello: "world" });

// 监听来自子进程的消息
child.on("message", (message) => {
console.log("Received message from child:", message);
});

// 监听子进程的退出事件
child.on("exit", (code, signal) => {
console.log(`Child process exited with code ${code} and signal ${signal}`);
});

第三方模块 workerpool

线程池(Thread Pool)是一种多线程处理形式,它包含了一定数量的线程,这些线程都是处于等待状态,准备接收任务并执行。线程池的主要目的是复用线程,以减少在线程创建和销毁上的开销,并提高系统资源的利用率和响应速度。

1
npm install workerpool
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const WorkerPool = require("workerpool");
const pool = new WorkerPool();

// 定义一个将在工作线程中执行的函数
pool
.exec("fibonacci", ["10"], {
fibonacci: function (n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
},
})
.then((result) => {
console.log("Fibonacci result:", result);
})
.catch((err) => {
console.error(err);
})
.finally(() => {
pool.terminate(); // 终止所有工作线程
});

// 注意:在实际应用中,计算 Fibonacci 数列的更有效方法是使用动态规划来避免重复计算。

12. Node 如何处理监听内存的使用情况

process.memoryUsage() 定期监听:

1
2
3
4
5
6
7
8
9
10
11
12
13
const { performance: any } = require("perf_hooks");

function monitorMemoryUsage() {
const memoryUsage = process.memoryUsage();
const now = performance.now();
console.log(
`Time: ${now.toFixed(3)}ms - RSS: ${(memoryUsage.rss / 1024 / 1024).toFixed(
2
)} MB`
);
}

setInterval(monitorMemoryUsage, 1000); // 每秒监控一次

perf_hooks 模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
const { performance } = require("perf_hooks");

function monitorMemoryUsage() {
const memoryUsage = process.memoryUsage();
const now = performance.now();
console.log(
`Time: ${now.toFixed(3)}ms - RSS: ${(memoryUsage.rss / 1024 / 1024).toFixed(
2
)} MB`
);
}

setInterval(monitorMemoryUsage, 1000); // 每秒监控一次

13. Node 的垃圾回收机制

Node.js 的垃圾回收机制主要基于 V8 引擎实现,而 V8 引擎的垃圾回收机制又主要基于分代式垃圾回收策略。这种策略根据对象的存活时间将内存分为不同的“代”,主要针对新生代(Young Generation)和老生代(Old Generation)进行不同的垃圾回收处理。

  1. 新生代(Young Generation):

    • 新生代是内存管理中的一块区域,专门用于存储生命周期较短的新创建的对象。

    • 新生代内部通常采用复制(Copying)算法进行垃圾回收,如 Scavenge 算法,该算法将新生代内存一分为二,包括 From 空间和 To 空间。

    • 当 From 空间快满时,垃圾回收器会遍历所有对象,将活跃对象从 From 空间复制到 To 空间,然后交换 From 和 To 的角色,准备下一轮的垃圾回收。

    • 如果一个对象在新生代中经历了多次垃圾回收仍然存活,它将被认为是“长寿”对象,并被晋升到老生代中。

  2. 老生代(Old Generation):

    • 老生代是内存堆中的另一块区域,用于存储那些在多次新生代垃圾回收后仍然存活的对象,即生命周期较长的对象。
    • 老生代内的垃圾回收相对不那么频繁,并且采用了不同的垃圾回收算法,如标记-清除(Mark-Sweep)算法。
    • 在标记-清除算法中,垃圾回收器会从执行栈和全局对象上找到所有能访问到的对象,将它们标记为活跃对象。标记完成后,进入清除阶段,将没有被标记的对象清除,释放其占用的内存空间。

14. v8 引擎的内存泄露

这个问题和浏览器端 JS 的内存泄露一样,都是由于对象没有被回收导致的。下面举例一些 demo 代码说明:

  1. 定时器设置后要及时释放:
1
2
3
4
5
6
7
8
9
10
function leakyTimer() {
const timerId = setInterval(() => {
console.log("This runs every second.");
}, 1000);

// 清除:
clearInterval(timerId);
}

leakyTimer();
  1. 闭包
1
2
3
4
5
6
7
8
9
10
11
12
13
function createLeakyClosure() {
let largeArray = new Array(1000000).fill("data");

return function leakyFunction() {
console.log("This closure keeps the largeArray alive.");
// 使用完后直接释放
largeArray = null;
};
}

const leakyClosure = createLeakyClosure();
// 或者这里释放:
// leakyClosure = null;
  1. 循环引用
1
2
3
4
5
6
7
8
9
function createCircularReference() {
const objectA = { name: "Object A" };
const objectB = { name: "Object B", referenceToA: objectA };
objectA.referenceToB = objectB; // 创建循环引用

// 即使没有其他代码引用这两个对象,由于它们之间存在循环引用,垃圾回收器可能无法正确释放它们
}

createCircularReference();
  1. 移除 DOM 节点的监听函数
1
2
3
4
5
6
7
8
9
10
11
12
13
const leakyButton = document.createElement("button");
leakyButton.textContent = "Click me";
document.body.appendChild(leakyButton);

function eventHandler() {
console.log("Button clicked!");
}

// 添加事件监听器
leakyButton.addEventListener("click", eventHandler);

leakyButton.removeEventListener("click", eventHandler);
document.body.removeChild(leakyButton);
  1. 全局对象未释放
1
2
// 全局对象是指不用const 或 let 声明的变量,而又不释放他
globalLargeObject = new Array(1000000).fill("unused data");