架构概览
Node.js是由瑞安·达尔(Ryan Dahl)于2009年创建的开源、跨平台JavaScript运行时环境。它基于Google的V8引擎(Chrome浏览器的JavaScript引擎),将JavaScript的执行环境从浏览器扩展到了服务器端。Node.js的设计目标是构建可扩展的网络应用程序,其架构核心围绕事件驱动(Event-Driven)和非阻塞I/O(Non-blocking I/O)模型展开。
传统的服务端模型(如PHP、Java的传统Servlet模型)通常为每个请求分配一个线程,线程在等待I/O操作(如数据库查询、文件读取、网络请求)完成期间处于阻塞状态,占用系统资源却无法执行其他工作。当并发量增大时,线程数量随之增长,导致内存消耗激增和上下文切换开销加大。Node.js采用单线程事件循环模型,所有I/O操作都是异步、非阻塞的——当发起I/O请求后,线程不会等待,而是继续处理其他任务,待I/O完成后通过回调函数或事件通知应用程序。
Node.js的架构可以分为三层:最底层是V8引擎和libuv库。V8负责JavaScript代码的解析和执行;libuv是跨平台的异步I/O库,提供事件循环、线程池、异步文件系统和网络I/O等能力。中间层是Node.js的核心模块,包括文件系统(fs)、网络(net、http、https)、路径(path)、流(stream)、事件(events)等。最上层是用户代码和第三方模块(通过npm管理)。
Node.js的单线程指的是JavaScript执行线程是单一的,但底层I/O操作实际上由libuv的线程池或操作系统异步机制处理。这种设计使Node.js在处理高并发、I/O密集型的场景(如Web服务器、实时通信、API网关)时表现出色,但在CPU密集型任务(如复杂计算、图像处理、视频编码)中,长时间的计算会阻塞事件循环,影响整个应用的响应性。
事件循环
事件循环(Event Loop)是Node.js运行机制的核心,它使Node.js能够在执行JavaScript代码的同时,处理已完成的异步操作回调。理解事件循环的相位(Phases)对于编写高性能、无阻塞的Node.js应用至关重要。
Node.js的事件循环分为六个主要相位,按顺序执行:timers(执行setTimeout和setInterval的回调)、pending callbacks(执行系统操作的回调,如TCP错误)、idle/prepare(内部使用)、poll(检索新的I/O事件,执行I/O相关的回调,Node.js在此相位可能会阻塞等待)、check(执行setImmediate的回调)、close callbacks(执行close事件的回调,如socket.on('close', ...))。每个相位执行完毕后,会检查是否有微任务(Microtasks)需要处理,然后进入下一个相位。
setTimeout和setImmediate的区别是面试中的经典问题。setTimeout(fn, 0)将回调放入timers相位,setImmediate将回调放入check相位。当在主模块中直接调用时,setImmediate的回调通常会在setTimeout之前执行,因为事件循环需要先进入poll相位,然后才能到达check相位。但在I/O回调内部调用时,setImmediate总是在setTimeout之前执行,因为I/O回调执行后事件循环直接进入check相位。
process.nextTick是一个特殊的队列,其优先级高于所有其他微任务和事件循环相位。nextTick的回调会在当前操作完成后立即执行,无论事件循环处于哪个相位。过度使用nextTick可能导致I/O饥饿(Starvation),因为事件循环无法进入下一个相位。因此,官方推荐使用setImmediate替代process.nextTick,除非确实需要在当前操作后立即执行。
Promise的then/catch/finally回调和queueMicrotask加入的回调属于微任务队列,在Node.js中由V8引擎管理。每个事件循环相位结束后、进入下一个相位前,以及当前执行栈清空后,微任务队列会被清空。这意味着如果在微任务中递归地创建更多微任务,事件循环将被阻塞,无法进入下一个相位。
理解事件循环的执行顺序对于调试异步代码至关重要。例如,以下代码的输出顺序为:Sync Start、Promise 1、Sync End、setTimeout、setImmediate(在主模块中,setTimeout可能先于setImmediate,因为事件循环进入poll相位前可能先检查timers)。在I/O回调中,顺序则为:Sync Start、Promise 1、Sync End、setImmediate、setTimeout。
事件循环调试建议:使用async_hooks模块或诊断报告(Diagnostic Report)可以追踪异步资源的创建和销毁。在性能敏感的场景中,避免在递归函数中创建大量微任务,以免阻塞I/O操作。
模块系统
Node.js的模块系统经历了从CommonJS到ES Modules的演进。CommonJS(CJS)是Node.js长期使用的模块规范,通过require函数同步加载模块,通过module.exports或exports导出模块内容。require的加载过程包括:解析文件路径、读取文件内容、包装为函数、执行模块代码、缓存模块导出。这种同步加载方式适合服务器端环境,因为文件读取是本地I/O,速度较快。
ES Modules(ESM)是ECMAScript标准的模块系统,使用import和export语法。Node.js从v12开始实验性支持ESM,v14后进入稳定状态。ESM与CJS的主要区别包括:ESM是静态的(导入导出在编译时确定,支持Tree Shaking),CJS是动态的(可以在运行时条件导入);ESM的导入是异步的,CJS的导入是同步的;ESM的顶层this为undefined,CJS的顶层this指向module.exports。
在Node.js中使用ESM有两种方式:将文件后缀改为.mjs,或在package.json中设置"type": "module"。混合使用CJS和ESM时需要注意:CJS可以通过动态import()加载ESM模块,但ESM不能直接使用require(除非通过createRequire创建)。package.json的exports字段可以精确控制模块的导出映射,支持条件导出(如根据import或require条件返回不同入口)。
Node.js内置了大量核心模块,无需安装即可使用。常用的核心模块包括:path(路径处理,提供join、resolve、dirname、basename、extname等方法)、fs(文件系统,提供同步和异步API,以及基于Promise的fs/promises子模块)、http/https(创建HTTP/HTTPS服务器和客户端)、url(URL解析与格式化,推荐使用WHATWG URL API)、querystring(查询字符串解析,已被URLSearchParams替代)、events(EventEmitter实现)、stream(流处理抽象)、crypto(加密功能)、os(操作系统信息)、process(进程信息与环境变量)等。
流与缓冲
流(Stream)是Node.js中处理数据的核心抽象,它使得数据可以分块处理,无需一次性加载到内存中。这对于处理大文件或网络数据至关重要。Node.js中有四种基本的流类型:Readable(可读流,如fs.createReadStream)、Writable(可写流,如fs.createWriteStream)、Duplex(双工流,同时可读可写,如TCP socket)和Transform(转换流,在读写过程中修改数据,如zlib压缩流)。
流基于EventEmitter实现,通过事件驱动的方式处理数据。可读流的主要事件包括:data(有数据可读时触发,数据以Buffer或字符串形式提供)、end(数据读取完毕)、error(发生错误)、close(流关闭)。可写流的主要事件包括:drain(写入缓冲区清空,可以继续写入)、finish(所有数据写入完毕)、error、close。通过pipe方法可以将可读流直接导向可写流,自动处理背压(Backpressure)——当可写流处理速度跟不上可读流时,自动暂停可读流。
Buffer是Node.js中用于处理二进制数据的类。由于JavaScript字符串基于UTF-16编码,不适合直接处理原始二进制数据,Buffer提供了固定大小的原始内存分配,可以存储任意字节序列。Buffer与字符串之间可以相互转换,支持多种编码(utf8、ascii、base64、hex等)。Buffer的静态方法(如Buffer.from、Buffer.alloc、Buffer.concat)和实例方法(如slice、toString、write)是处理二进制数据的基础工具。
在Node.js v15之后,stream模块引入了pipeline和finished工具函数,作为pipe和手动事件监听的更安全的替代方案。pipeline自动处理错误传播和资源清理,finished在流完成时调用回调(无论成功或失败)。
文件系统
fs模块是Node.js与文件系统交互的接口,提供了丰富的文件操作方法。API风格分为三类:同步方法(如readFileSync,阻塞执行,返回结果)、回调式异步方法(如readFile,非阻塞,通过回调函数返回结果)和基于Promise的异步方法(通过fs/promises子模块提供,支持async/await)。在服务器端代码中,应优先使用异步方法,避免阻塞事件循环。
文件路径处理应始终使用path模块,而非字符串拼接,以确保跨平台兼容性(Windows使用反斜杠,Unix使用正斜杠)。path.join用于拼接相对路径,path.resolve用于解析为绝对路径,path.relative用于计算相对路径。__dirname表示当前模块所在目录,__filename表示当前模块的完整路径(在ESM中,需要使用import.meta.url和fileURLToPath转换)。
文件描述符(File Descriptor)是操作系统分配给打开文件的数字标识。fs.open打开文件并返回描述符,后续操作(如read、write、fstat)使用该描述符而非文件路径。使用完毕后必须调用fs.close释放描述符,否则会导致资源泄漏。fs/promises的FileHandle对象提供了更现代的接口,支持自动资源管理(with语法,通过Symbol.asyncDispose实现)。
监视文件变化可以通过fs.watch(底层使用操作系统提供的文件监视机制,高效但行为因平台而异)或fs.watchFile(轮询方式,跨平台一致但效率较低)实现。在需要跨平台一致性的场景,或使用轮询策略的场景,watchFile更为可靠。
网络编程
Node.js的net模块提供了基于TCP的套接字编程接口,http模块在net之上构建了HTTP协议支持。创建HTTP服务器通过http.createServer方法,它返回一个Server实例,监听request事件处理客户端请求。每个请求触发回调函数,传入req(IncomingMessage,可读流)和res(ServerResponse,可写流)对象。
req对象包含请求的详细信息:method(HTTP方法)、url(请求路径和查询字符串)、headers(请求头对象)、httpVersion等。通过监听data和end事件,可以读取请求体数据。res对象用于构造响应:writeHead设置状态码和响应头,write写入响应体,end结束响应。响应头应在写入响应体之前设置,一旦开始发送响应体,响应头将被锁定。
Express是Node.js生态中最流行的Web应用框架,它封装了HTTP服务器的底层细节,提供路由系统、中间件机制、模板引擎集成等高级功能。中间件(Middleware)是Express的核心概念——一个中间件函数可以访问请求对象、响应对象和next函数。中间件按注册顺序执行,通过next()将控制权传递给下一个中间件。这种洋葱模型使横切关注点(如日志记录、身份验证、错误处理)能够与业务逻辑解耦。
Cluster模块允许创建多个工作进程共享同一端口,充分利用多核CPU。主进程(Master)负责管理工作进程(Worker),通过轮询或操作系统调度将连接分发给工作进程。当工作进程崩溃时,主进程可以自动重启新的工作进程,提升应用的可用性。PM2是生产环境中常用的进程管理器,它封装了Cluster模块的复杂性,提供负载均衡、日志管理、自动重启、零停机部署等企业级功能。
部署与运维
Node.js应用的部署方式多样,从传统的服务器部署到现代的容器化和无服务器架构。在服务器部署中,通常使用PM2或systemd管理Node.js进程,配合Nginx作为反向代理和静态文件服务器。Nginx可以处理SSL终止、负载均衡、请求限流和静态资源缓存,将动态请求转发给Node.js应用服务器。
Docker容器化是现代化的部署方案。通过编写Dockerfile定义应用环境(基础镜像、依赖安装、启动命令),构建为镜像后在任何支持Docker的平台上运行。多阶段构建(Multi-stage Build)可以显著减小最终镜像体积——在一个阶段中编译和构建,在另一个更轻量的阶段中仅复制产物运行。
环境变量管理通过process.env访问。开发环境使用.env文件(通过dotenv包加载),生产环境通过容器编排工具或云平台的密钥管理服务注入。敏感信息(数据库密码、API密钥)绝不应硬编码在源码中,而应通过环境变量或专用密钥管理服务提供。
日志管理是运维的关键环节。console.log适合开发调试,生产环境应使用结构化日志库(如Winston、Pino),支持日志级别控制、多传输目标(文件、控制台、远程服务)、日志轮转和JSON格式输出。ELK Stack(Elasticsearch、Logstash、Kibana)或云原生的日志服务(如AWS CloudWatch、阿里云SLS)可以集中收集、索引和可视化日志数据。