这篇文章主要是想记录一下关于Node.js相关的面试题,有的地方可能会有重复就当加深记忆了
1. Node.js是什么?
Node.js(简称Node)是一个基于Chrome V8引擎的JavaScript运行时环境。它允许开发者使用JavaScript语言在服务器端运行代码,实现了在服务器端构建高性能和可扩展性的网络应用程序。
Node.js的特点和功能包括:
-
非阻塞式、异步编程模型:Node.js采用事件驱动、非阻塞式I/O模型,使得在处理高并发请求时表现出色。它能够通过回调函数处理异步操作,而不会阻塞后续代码的执行,提高了应用程序的性能和响应速度。
-
跨平台:Node.js可在多种操作系统上运行,包括Windows、macOS、Linux等,这使得开发者可以在不同平台上构建和部署应用程序。
-
轻量高效:Node.js的设计目标是轻量高效,它采用了事件循环机制和单线程模型,不会为每个请求创建新的线程,相比传统的多线程服务器模型,更节省资源,适用于处理大量并发请求。
-
npm包管理器:Node.js附带npm(Node Package Manager)工具,是世界上最大的开源库生态系统之一。npm允许开发者轻松地安装、管理和共享JavaScript模块,加快了开发过程并促进了社区合作。
-
构建网络应用:Node.js的主要用途之一是构建高性能的网络应用,如Web服务器、实时聊天应用、API服务等。
2. 什么是事件驱动编程和非阻塞I/O?Node.js如何实现非阻塞I/O?
事件驱动编程(Event-driven programming)是一种编程范式,它的核心思想是程序通过监听事件的发生并采取相应的操作来进行工作。在事件驱动编程中,程序不是按照线性顺序执行,而是通过事件循环(Event Loop)不断地等待事件的触发,并响应相应的事件处理程序。
在事件驱动编程中,程序通常包含以下组成部分:
-
事件:指程序中某些特定的行为或状态变化,可以是用户的输入、网络请求、文件读写等。
-
事件监听器:是响应事件发生的回调函数,当事件触发时,相关的事件监听器被调用来处理事件。
-
事件循环:是事件驱动编程的核心机制。它是一个持续运行的循环,不断地等待事件的发生。当事件发生时,事件循环会找到相应的事件监听器,并执行相应的处理函数。
非阻塞I/O(Non-blocking I/O)是指在进行输入输出操作时,程序可以继续执行其他任务,而不必等待I/O操作的完成。这意味着在进行I/O操作的同时,程序可以处理其他任务,不会因为I/O操作而阻塞整个程序的执行。
Node.js实现非阻塞I/O的关键在于它的单线程和事件驱动架构。Node.js使用libuv作为其事件循环库,libuv提供了跨平台的异步I/O能力,使得Node.js能够以非阻塞的方式进行I/O操作。当Node.js需要进行I/O操作时,它会将I/O请求交给libuv,并注册一个回调函数。在I/O操作完成后,libuv会调用相应的回调函数,让Node.js继续处理完成的结果,而不会等待I/O操作的完成。
这种非阻塞I/O的机制使得Node.js能够高效地处理大量并发连接,以及实现高性能的网络应用和实时应用,而不会因为I/O操作的等待而导致性能下降。同时,事件驱动的特性使得Node.js代码编写更加简洁和灵活,能够处理异步操作和并发请求,提高了开发效率和用户体验。
3. 解释一下Node.js的事件循环(Event Loop)机制。
Node.js的事件循环(Event Loop)是其核心机制之一,它是实现非阻塞I/O和事件驱动编程的关键。事件循环是一个持续运行的循环,用于等待事件的发生并处理相应的事件。
Node.js的事件循环机制包含以下几个关键组件:
-
事件队列(Event Queue):所有的异步操作和事件监听器都会被放入事件队列中。当一个异步操作完成或一个事件触发时,相应的回调函数将会被放入事件队列。
-
事件循环(Event Loop):事件循环是一个不断运行的循环,它会从事件队列中取出事件,并执行相应的回调函数。事件循环是Node.js的主线程,负责不断地检查事件队列,处理事件和回调函数。
-
触发器(Triggers):触发器是指导致事件循环运行的事件,这些事件可以是I/O操作完成、定时器到期、网络请求、用户输入等。
-
宏任务(Macro Tasks):宏任务是指在事件循环中执行的一组任务,它们来源于不同的触发器。常见的宏任务包括setTimeout、setInterval、I/O操作、网络请求等。
-
微任务(Micro Tasks):微任务是指在事件循环的一个阶段结束后执行的任务。微任务通常是由宏任务产生的回调函数,如Promise的resolve、reject和then回调等。
事件循环的执行过程如下:
-
事件循环从事件队列中取出一个宏任务,执行其回调函数。如果宏任务中产生了微任务,那么这些微任务也会被依次执行。
-
当宏任务的回调函数执行完成后,事件循环会检查是否有微任务需要执行。如果有,它会依次执行所有微任务。
-
事件循环进入下一个循环,重复以上过程。
事件循环中的宏任务和微任务的执行顺序是固定的:一个宏任务执行完成后,会立即执行所有微任务,然后再执行下一个宏任务。
4. Node.js中宏任务和微任务的分类和执行顺序
宏任务(Macro Tasks):
-
timers:包括setTimeout和setInterval产生的回调函数。在计时器到期后,回调函数会被放入timers队列。
-
I/O callbacks:包括处理网络请求、文件I/O等异步I/O操作的回调函数。当异步I/O操作完成时,回调函数会被放入I/O callbacks队列。
-
idle, prepare:这两个阶段仅供内部使用,不太常见,我们可以忽略。
-
poll:这是处理除timers和I/O callbacks之外的其他回调函数的阶段。在进入poll阶段时,Node.js会检查是否有可执行的回调函数,如果有,则会执行这些回调函数。
-
check:这是处理setImmediate产生的回调函数的阶段。在poll阶段结束后,会立即执行check阶段的回调函数。
-
close callbacks:包括处理socket连接的close事件的回调函数。在socket连接关闭时,对应的回调函数会被放入close callbacks队列。
微任务(Micro Tasks):
- process.nextTick:这是Node.js的一个特殊的微任务类型。它本质上属于MicroTask,但它的执行时机比其他MicroTask更早。process.nextTick产生的回调函数会在事件循环的每个阶段结束后立即执行。
在事件循环的执行过程中,每个阶段的宏任务会被依次执行,而每个阶段执行完成后,会立即执行其对应的微任务。这确保了微任务在不同类型的宏任务切换后立即执行,优先级高于宏任务。
5. 事件循环中的生产者/消费者模型
在Node.js中,事件循环充当消费者的角色,它负责从观察者那里获取事件并处理。而异步I/O、网络请求等则是事件的生产者,它们不断地为Node.js提供不同类型的事件,这些事件被传递到对应的观察者那里。
通过观察者模式,Node.js可以对不同类型的事件进行分类和处理,以确保适时地响应不同类型的事件。
关于在不同操作系统下的事件循环实现:
-
在Windows下,Node.js使用IOCP(Input/Output Completion Ports)创建事件循环。IOCP是一种高效的I/O模型,能够处理大量的I/O请求。
-
在类Unix操作系统下(如Linux、macOS等),Node.js基于多线程创建事件循环。这意味着Node.js会使用线程池来处理I/O操作和网络请求,充分利用多核处理器的优势。
6. Node.js中的模块是什么?如何导入和导出模块?
在Node.js中,模块是用于组织和封装代码的基本单元。每个文件都被视为一个独立的模块,模块内部的变量、函数、类等默认是私有的,如果需要在其他模块中使用,就需要使用导入和导出的机制。
导入模块:
在Node.js中,可以使用require
函数来导入模块。require
函数接受模块的路径作为参数,并返回该模块导出的内容。模块路径可以是相对路径(相对于当前文件)或绝对路径。
导出模块:
为了在模块中将某些内容暴露给其他模块使用,我们需要在模块中使用module.exports
对象或exports
对象。module.exports
是Node.js默认提供的导出对象,而exports
实际上是module.exports
的一个引用。
7. Node.js三种模块及require()加载原理
在Node.js中,模块有三种类型:核心模块、第三方模块和自定义模块。
-
核心模块:Node.js内置了一些核心模块,它们在Node.js安装时就已经存在,
-
第三方模块:这些模块是由其他开发者或组织创建并发布到npm(Node Package Manager)上的,可以通过npm安装并使用。
-
自定义模块:自定义模块是开发者自己编写的模块。
require()加载原理:
当调用require()
时,Node.js会按照以下步骤来查找和加载模块:
-
核心模块:首先,Node.js会检查要加载的模块是否是核心模块。如果是核心模块,Node.js会直接加载并返回该模块,不再继续查找。
-
第三方模块 :如果要加载的模块不是核心模块,Node.js会继续查找是否有同名的第三方模块。Node.js会从当前目录的
node_modules
文件夹开始,逐级向上查找,直到找到对应的第三方模块或达到根目录。 -
自定义模块:如果找不到同名的第三方模块,Node.js会认为要加载的模块是一个自定义模块。它会根据模块路径进行查找,按照以下顺序查找模块文件:
a. 按模块文件名查找:例如
require('./mymodule')
会先查找mymodule.js
或mymodule.json
,如果找不到再尝试查找mymodule.node
。b. 按目录查找:如果要加载的模块是一个目录(例如
require('./mymodule')
),Node.js会查找目录下的package.json
文件,读取main
字段指定的入口文件名,如果没有package.json
或没有指定main
字段,会默认查找index.js
或index.json
。 -
加载和缓存 :在找到模块文件后,Node.js会加载并执行该模块的代码。加载后的模块会被缓存,以便后续再次使用
require()
加载该模块时,直接从缓存中取得,避免重复加载。
8. require和import的区别?
require
是Node.js中的模块加载关键字,用于动态加载模块,适用于Node.js环境和一些构建工具中。import
是ES6中的模块加载关键字,用于静态加载模块,适用于支持ES6模块的环境,需要通过构建工具转换以在不支持ES6模块的环境中使用。
加载时机:
require
是动态导入,在运行时进行模块加载,它可以放在代码的任意位置,根据需要动态加载模块。import
是静态导入,在编译时进行模块加载,它必须放在代码的顶部,并且不能放在条件语句或函数中。
9. 什么是中间件(Middleware)?在Express中如何使用中间件?
中间件(Middleware)是在Web应用程序中处理HTTP请求的一种机制。它可以在请求到达路由处理之前或者在响应发送给客户端之前执行一些处理。中间件通常用于执行一些公共的逻辑,例如日志记录、身份验证、数据解析等,以便将这些公共功能从路由处理器中分离出来。
在Express中,中间件是一个简单的JavaScript函数,它接收三个参数:request
(请求对象)、response
(响应对象)和next
(一个回调函数)。中间件函数可以在处理请求时对请求和响应对象进行操作,并决定是否继续向下执行请求处理链或终止请求处理。
注意:如果在中间件中没有调用
next()
,请求处理链将会在该中间件中终止,后续的中间件和路由处理函数将不会被执行。因此,在使用中间件时,确保在适当的时机调用next()
以继续请求处理链的执行。
10. 介绍一下Koa的洋葱模型
Koa的洋葱模型(Onion Model)是其核心设计之一,用于实现中间件的执行流程。洋葱模型的名字源于中间件的执行顺序,中间件形成了像洋葱一样层层包裹的结构,请求和响应在中间件层层传递,直到达到最内层的中间件,然后再回到外层。
洋葱模型的原理如下:
-
当有请求到达Koa应用时,Koa会依次按照注册的中间件顺序执行它们。
-
每个中间件都可以通过调用
next()
方法将控制权交给下一个中间件。 -
在执行中间件的过程中,如果中间件没有调用
next()
,那么后续的中间件将不会被执行,而是开始逆序执行之前所有的中间件的回调函数,直到达到最外层的中间件。 -
当最外层的中间件的回调函数执行完毕后,控制权会逆序传递回去,继续执行之前没有执行完毕的中间件的
next()
后面的代码。
这样,就形成了一个类似洋葱的结构,请求和响应在中间件之间层层传递,直到达到最内层,然后再回到外层,这就是Koa洋葱模型的核心原理。
例如,假设有三个中间件:middlewareA、middlewareB和middlewareC:
javascript
const Koa = require('koa');
const app = new Koa();
app.use(async (ctx, next) => {
console.log('middlewareA before');
await next();
console.log('middlewareA after');
});
app.use(async (ctx, next) => {
console.log('middlewareB before');
await next();
console.log('middlewareB after');
});
app.use(async (ctx, next) => {
console.log('middlewareC before');
await next();
console.log('middlewareC after');
});
app.listen(3000);
当有请求到达时,输出的执行顺序将是:
erlang
middlewareA before
middlewareB before
middlewareC before
middlewareC after
middlewareB after
middlewareA after
11. 实现koa的compose函数
compose
函数在Koa中被用于组合多个中间件函数,将它们按照洋葱模型的顺序执行。compose
函数接收一个由中间件函数组成的数组,返回一个新的中间件函数,用于将请求和响应对象在这些中间件函数之间传递,实现洋葱模型的执行流程。
下面是一个简单的实现compose
函数的例子:
javascript
function compose(middleware) {
// 检查传入的中间件数组是否合法
if (!Array.isArray(middleware)) {
throw new TypeError('Middleware stack must be an array!');
}
// 检查中间件数组的每一项是否为函数
for (const fn of middleware) {
if (typeof fn !== 'function') {
throw new TypeError('Middleware must be composed of functions!');
}
}
// 返回组合后的新的中间件函数
return function (context, next) {
// 初始索引设置为-1
let index = -1;
// 定义递归函数dispatch,用于逐个执行中间件函数
function dispatch(i) {
// 若i<=index,说明有中间件多次调用了next(),需要抛出错误
if (i <= index) {
return Promise.reject(new Error('next() called multiple times!'));
}
// 更新index为当前i
index = i;
// 取出当前要执行的中间件函数
const fn = middleware[i];
// 如果i等于中间件数组的长度,说明已经执行到最后一个中间件了,将next设置为一个空函数
if (i === middleware.length) {
fn = next;
}
// 如果没有中间件了,直接返回一个空的Promise
if (!fn) {
return Promise.resolve();
}
try {
// 调用当前中间件函数,并将dispatch(i+1)作为next函数传递
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err);
}
}
// 启动第一个中间件
return dispatch(0);
};
}
使用这个compose
函数可以将多个中间件函数组合在一起,按照洋葱模型的顺序执行,示例代码如下:
javascript
const middleware1 = async (ctx, next) => {
console.log('middleware1 before');
await next();
console.log('middleware1 after');
};
const middleware2 = async (ctx, next) => {
console.log('middleware2 before');
await next();
console.log('middleware2 after');
};
const middleware3 = async (ctx, next) => {
console.log('middleware3 before');
await next();
console.log('middleware3 after');
};
const composedMiddleware = compose([middleware1, middleware2, middleware3]);
const ctx = {};
composedMiddleware(ctx, () => {
console.log('End of composed middleware.');
});
以上示例中,compose
函数将middleware1
、middleware2
和middleware3
组合在一起,然后调用composedMiddleware
来执行它们。按照洋葱模型的顺序,将依次输出每个中间件的执行结果。
12. 什么是Cluster模块?如何在Node.js中实现多进程集群?
Cluster模块是Node.js的一个内置模块,它用于创建多进程集群,以充分利用多核CPU的计算资源,提高应用程序的性能和稳定性。通过Cluster模块,Node.js应用可以创建多个子进程,每个子进程可以独立处理请求,共同组成一个进程集群,从而实现负载均衡和并发处理。
在Node.js中实现多进程集群的步骤如下:
- 导入Cluster模块:首先需要在Node.js应用中导入Cluster模块。
javascript
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length; // 获取CPU核心数
- 判断是否为主进程:Cluster模块启动后,会默认创建一个主进程和多个子进程。我们需要判断当前进程是主进程还是子进程。
javascript
if (cluster.isMaster) {
// 主进程
console.log(`Master ${process.pid} is running`);
// Fork workers
for (let i = 0; i < numCPUs; i++) {
cluster.fork(); // 创建子进程
}
} else {
// 子进程
console.log(`Worker ${process.pid} started`);
// 在这里处理请求和其他业务逻辑
http
.createServer((req, res) => {
res.writeHead(200);
res.end('Hello World');
})
.listen(8000);
}
-
启动子进程 :在主进程中使用
cluster.fork()
方法创建多个子进程。每个子进程将执行在else
分支中定义的代码块,通常用于处理请求和其他业务逻辑。 -
请求分发:当有请求到达主进程时,Cluster模块会自动将请求分发给子进程,实现负载均衡。不同的子进程会独立地处理各自的请求,从而提高了应用程序的并发处理能力。
注意:在使用Cluster模块时,需要小心处理共享状态和资源,避免出现竞争条件和内存泄漏等问题。一般来说,Cluster模块适用于CPU密集型的应用,而对于IO密集型的应用,可以考虑使用其他方式,如使用多线程池或异步非阻塞的方式来提高并发能力。