刚接触Node.js那会儿,我其实挺疑惑的:一个运行在服务器上的JavaScript,到底和浏览器里跑的JS有什么本质区别?后来在某个项目里用Node重写了一个内部工具的接口层,才真正体会到它的核心价值——不是"能用JS写后端"这么简单,而是它那种非阻塞的事件驱动模型,在处理大量轻量级并发请求时,确实有自己独特的一套打法。这篇内容不打算写成一章一节照本宣科的手册,而是把我自己从零上手到能独立写服务这段经历里,觉得最值得说清楚的几块东西掰开揉碎讲一遍:环境怎么配、模块机制到底是什么逻辑、异步代码怎么才叫"会写"、以及真实项目里最容易翻车的那些点。适合刚入门想系统过一遍Node基础的同学,也适合写过一点但总觉得有些概念模棱两可的人。
1. Node.js 到底解决的是什么问题:先搞清楚它为什么存在
很多新手学Node容易一上来就敲代码,结果写了两天就蒙了:为什么大家都在说事件循环?为什么一段代码一会儿同步一会儿异步?这些困惑的根源,其实是没搞明白Node在服务端技术栈里被发明出来的原因。
1.1 传统服务端模型和Node的差异:从"一个请求一个线程"说起
早些年的服务端应用,主流做法是:来一个请求,操作系统开一个线程(或者从线程池里捞一个)去处理它。处理期间如果遇到读数据库、读文件这种耗时操作,这个线程就干等着,什么都不做。这种模型在请求量不大、每个操作都很轻快的场景下没啥问题,可一旦并发上来,资源开销会迅速膨胀——线程本身占内存,频繁切换上下文也消耗CPU。
Node.js选择了一条完全不同的路:单线程+事件循环。主线程不负责等待某个操作完成,而是把读文件、查询数据库这类活儿交给底层(libuv)去调度,等结果出来了,再以事件的形式丢回主线程执行回调。这么做的直接结果就是:同样一台机器,Node能扛住的并发连接数往往比阻塞式模型高出一截,而且单个请求的内存开销更小。
为了理解这件事,可以想象一个餐厅:传统模型是每来一桌客人就安排一个专职服务员,一直陪着这桌客人直到他们吃完;Node模型是店里只有几个服务员,点菜、上菜、结账这些动作派出去之后,服务员立刻去招呼别的桌,等后厨喊"某桌菜好了",再回来处理。两种模式都能把饭馆开起来,但人效和承载力完全不同。
1.2 单线程到底会不会把CPU浪费掉:关于"不适合CPU密集"的真相
听到"单线程"三个字,很多人第一反应是:那只用一个CPU核心,岂不浪费?这里要区分两类任务:
- I/O密集型:比如读文件、访问数据库、发HTTP请求。这类操作绝大部分时间在等待,CPU闲着,Node的异步机制正好把闲着的CPU时间让给其他请求。
- CPU密集型的:比如大量计算、图片处理、复杂加密。这类操作会长时间占住主线程,导致后续任务排队,事件循环被卡住。
所以社区常说"Node不适合做CPU密集型的重计算",不是说完全不能做,而是默认场景下它确实不占优。如果真要处理重计算,常见的做法是用worker_threads开子线程,或者把计算任务独立成单独的服务。我自己的原则很简单:遇到CPU密集的活儿,先把任务队列化,丢给更适合的工具去算,Node只负责调度和转发。
提示:判断一个任务适不适合Node,就看它是"等得久"还是"算得久"。等得久的(网络请求、读写、数据库)非常契合;算得久的(循环计算、图像处理、音视频编码)要谨慎。
2. 从安装到第一个程序:开发环境的那些细节
环境配置是最没技术含量却最容易卡住人的环节。尤其新手在Windows和macOS之间换来换去,往往第一步就栽在版本和终端命令的差异上。
2.1 版本管理工具:为什么强烈建议用 nvm 而不是官网安装包
打开Node官网,下载页会给你推荐当前的LTS版本和Current版本。作为入门,LTS是稳的选择,但我不建议直接用安装包一把梭,更推荐用版本管理工具(Windows上推荐nvm-windows,macOS/Linux上用nvm)来装。
原因是实际项目里版本差异远比想象中大:老项目锁在Node 12,新项目已经用Node 20了,两者在语法支持和模块兼容性上都有差别。有了nvm这类工具,可以随时切换版本,避免"为了一个老项目把全局环境搞乱"的窘境。
安装好后的日常操作就三条:
nvm install 20.10.0 # 安装指定版本 nvm use 20.10.0 # 切换当前shell使用的版本 nvm ls # 看看本机装了哪些版本切换完可以验证一下:
node -v # 打印当前Node版本 npm -v # 打印当前npm版本2.2 第一个程序:从"Hello World"到理解 Node 的全局对象
装完之后,随便找个目录建一个app.js,写上:
console.log('Hello Node');然后终端里执行:
node app.js这一步能跑通,环境就算OK。但我想让你多关注一个点:Node默认提供了几个全局对象,和浏览器里的有区别。最有存在感的是process和Buffer。process可以拿到运行参数、环境变量、退出码等:
process.argv.forEach((arg, index) => { console.log(`参数${index}: ${arg}`); });再往深处一点,__dirname(当前文件所在目录的绝对路径)和__filename(当前文件的完整路径)也是Node里非常常用的全局变量,在定位资源文件、拼路径时会频繁碰到。注意它们是CommonJS模块体系下的特性,换成ES Module写法之后还有一套新行为,这个后面说模块的时候再展开。
环境配好之后,下一步理解任务就变成:这段JS代码是怎么被"包起来"运行的?这就引出了模块系统。
3. 模块系统:require 的本质和它背后的设计思想
Node.js里每个文件都被视为一个模块,这是Node几乎所有组织代码的方式,也是理解"导出""导入"的基础。
3.1 CommonJS的加载规则:index.js 为什么这么特殊
你肯定见过这样的目录结构:
my-project/ src/ utils/ index.js server.js然后server.js里写const utils = require('./utils')——注意,这里require的路径是一个目录,不是文件。Node的解析规则是:先找./utils目录下的package.json,看它main字段指定了哪个文件;如果没有package.json,默认找目录下的index.js。
这个默认机制带来一个习惯:很多项目喜欢把同模块的入口统一命名成index.js。好处是引用路径干净,坏处是目录名里一旦塞好几个index.js,编辑器里标签页根本分不清谁是谁。所以现在很多开发者会刻意用utils.js这种语义化文件名,或者干脆用ES Module的import,在组织上更清晰。
require的加载还有几个细节值得留意:
- 模块加载是同步的,所以顶层不能用require去加载一个需要异步准备的东西。
- 模块第一次被require后会被缓存,第二次require不会重新执行模块代码,拿到的是上次导出的对象。这也是为什么有时候单例模式在Node里天然成立。
- 模块加载时,整个模块代码会被包在一个函数里(
exports、require、module、__filename、__dirname都来自这个包装函数),所以顶层写的var不会变成真正的全局变量。
3.2 module.exports 和 exports:为什么容易二选一踩坑
初学者最常见的认知陷阱是:exports就是module.exports的简写。这话在一定条件下是成立的——exports一开始确实指向module.exports这个对象。但如果你直接给exports赋一个新对象:
// utils.js exports = { hello: () => console.log('hi') };外面的require('./utils')拿到的是原来的空对象,不是这个新对象。因为赋值改变的是exports这个局部变量的指向,而require返回的始终是module.exports。
正确写法应该是:
module.exports = { hello: () => console.log('hi') };或者往现有对象上挂属性:
exports.hello = () => console.log('hi');这条规则我建议直接记住,因为报错时的提示往往不够直观。等ES Module语法的成熟度在新项目里越来越高之后,这个问题会逐渐退场,但维护老项目几乎绕不开它。
3.3 从 CommonJS 到 ES Module:两个模块体系并存意味着什么
Node从12版本开始逐步支持ES Module(也就是import/export),但它和Node老牌的CommonJS体系共存的局面相当微妙。
- 以
.js结尾的文件默认走CommonJS;要用ES Module,要么把文件后缀改成.mjs,要么在package.json里加"type": "module"。 - ESM的
import是静态的,也就是说import语句不能嵌套在条件判断或函数体里,必须写在顶层;CommonJS的require则可以在任意位置调用。 __dirname在ESM里不存在,需要用import.meta.url配合fileURLToPath来模拟,这也是ESM刚落地时大家最不习惯的地方。
对基础期的同学,我建议先把CommonJS吃透。不是说ESM不好,而是既有Node生态里大量教程、示例还是CommonJS,另一个是理解CommonJS之后再看ESM的差异点,会更容易理清两种模块体系的边界。
4. 事件循环:Node.js 的引擎是怎么转动起来的
如果说有一个知识点能区分"会写Node"和"懂Node",可能就是事件循环。它看不见摸不着,但决定了你的代码什么时候执行、什么时候被推迟。
4.1 事件循环的几个阶段:别再背术语了,理解"排队"就好
Node启动后,主线程就开始经历一个又一个循环阶段。每个阶段维护一个对应的任务队列,粗略可以理解为:
- timers:执行
setTimeout、setInterval的回调。 - pending callbacks:执行一些系统级I/O回调,比如网络、文件的错误回调。
- poll:核心阶段,取新的I/O事件,处理文件读、网络请求等产生的结果回调。这个阶段里如果没有回调要处理,还会等待新的事件到来。
- check:执行
setImmediate的回调。 - close callbacks:处理一些关闭事件,比如socket关闭。
不用死记每个阶段的顺序,真正需要理解的是两条规则:
- 同步代码永远先于任何异步回调执行。即使
setTimeout的延迟是0,它的回调也要等到当前同步代码全部跑完才能执行。 - 不同异步API在事件循环里被调度的时机不同。
process.nextTick和Promise.then比较特殊,它们有自己的微任务队列,而且优先级很高,会在当前阶段结束后、进入下一阶段前被消化掉。
4.2 最常见的输出顺序问题:nextTick、Promise、setTimeout 谁先谁后
面试题里十有八九会出现这道:
setTimeout(() => { console.log('timeout'); }, 0); Promise.resolve().then(() => { console.log('promise'); }); process.nextTick(() => { console.log('nextTick'); });实际输出顺序:
nextTick promise timeout原因是process.nextTick和Promise都属于微任务,它们会在事件循环进入任何宏任务阶段之前被清空。而nextTick又排在了Promise之前,因为它关联的队列排在微任务队列的更前面。
这个知识点在实际开发里有什么价值?主要体现在:如果你不小心在一个高频调用链里用了process.nextTick来递归处理任务,它可以不断插队,导致其他I/O事件得不到处理。官方文档自己都提醒过了,process.nextTick"可以"被滥用,所以现在的一般建议是:能用queueMicrotask或Promise就尽量别用nextTick。Node里真正需要nextTick的场景多是为了在底层API上保证某种时序一致性,普通业务代码用不太到。
提示:理解事件循环不是为了让代码输出顺序好看,而是为了在排查线上问题时,能推断出"为什么这个回调没按我预期的顺序跑"。
5. 内置核心模块:fs、path、http 在真实场景里的用法
Node能被称为"开箱即用",很大程度上依赖它自带的那一批核心模块。它们不需要npm install,装完Node就有,是组成几乎所有Node应用的积木。
5.1 文件操作:用 fs 读文件,同步和异步到底选谁
fs模块的全称是File System,所有文件读写都通过它完成。最经典的问题:fs.readFileSync和fs.readFile该用哪个?
初学阶段很多人会无脑用同步写法,因为代码看起来像从上到下一行行执行,好理解。但服务端代码里,同步读文件会阻塞整个进程。如果在一次请求处理流程里同步读一个大文件,所有其他请求都会跟着卡一下。
正确的建议是:
- 在服务器运行时处理请求的分支里,优先用异步写法。
- 在脚本工具、项目启动阶段执行一次性的初始化读取,用同步写法完全没问题,代码还更清晰。
举个读JSON配置文件的场景:
const fs = require('fs'); // 异步读取 fs.readFile('/etc/my-app/config.json', 'utf8', (err, data) => { if (err) { console.error('读取失败:', err); return; } const config = JSON.parse(data); console.log('配置加载成功:', config.port); });注意回调的第一个参数永远是err,这是Node老牌API的统一约定,所谓"Error First"风格。所有回调式API都遵守这个约定,后面即便用fs.promisesAPI,错误也是以Promise的reject形式出现,逻辑上仍然一致。
Node也提供了基于Promise的写法,比如fs.promises.readFile,配合async/await会更顺手:
const fs = require('fs/promises'); async function loadConfig() { try { const data = await fs.readFile('/etc/my-app/config.json', 'utf8'); return JSON.parse(data); } catch (err) { console.error('读取失败:', err); } }这里有个细节容易踩坑:require('fs/promises')和require('fs').promises都能调到Promise版本,但写法不同。新版Node里前者是更加明确的官方路径,建议优先用。
5.2 路径处理:path 模块为什么能避免一堆反斜杠坑
path模块看起来很简单,但跨平台时它是最能救命的。Windows路径用\分隔,Linux/macOS用/分隔,如果你手动拼路径比如'./src/' + fileName + '.json',看起来能跑,一旦部署到Linux服务器上就可能出问题。
正确做法是用path.join来拼接路径:
const path = require('path'); const filePath = path.join(__dirname, 'data', 'config.json');path.join会按当前系统的分隔符来拼接,并且自动处理多余的分隔符。另一个常用API是path.resolve,它返回一个绝对路径:
const absolutePath = path.resolve(__dirname, '../../public/index.html');这两个API的区别很简单:path.join就是拼接,不一定生成绝对路径;path.resolve是从右往左解析,最终生成一个绝对路径。日常用的时候,文件读取推荐path.join(__dirname, ...),要生成URL定位或者需要绝对路径时用path.resolve。
5.3 从零写一个HTTP服务:http模块的经典套路与坑
用Node内置的http模块创建一个最基础的Web服务,代码其实非常短:
const http = require('http'); const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('Hello World\n'); }); server.listen(3000, () => { console.log('服务器已启动: http://localhost:3000'); });浏览器打开http://localhost:3000就能看到返回的文本。这个例子能跑,但真正写业务时建议注意三件事:
- 解析请求体需要自己收集数据流,因为
req是一个可读流,不是一次性把数据全给你。 - 处理路径和查询参数需要自己解析
req.url,一般配合new URL(req.url, 'http://xxx')来拿到规范的结构。 - 生产环境很少直接用
http模块裸写,更多是配合框架,或者用框架内置的服务器抽象。
下面是一段能回显JSON请求体的示例:
const http = require('http'); const server = http.createServer((req, res) => { let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', () => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ received: body })); }); }); server.listen(3000);注意chunk是Buffer,所以需要toString()转字符串。如果请求体很大,body += chunk.toString()这种写法在大并发场景里会有性能问题,更稳妥的做法是先把chunk收集到数组里,最后统一拼接或交给Buffer.concat处理。
5.4 events 模块:理解 Node 的"发布订阅"是怎么来的
还有一个不得不提的核心模块是events。Node里很多对象(比如fs.createReadStream返回的流、http的req/res)都继承自EventEmitter,它们可以通过.on()监听事件。
自定义一个事件发射器很简单:
const EventEmitter = require('events'); class MyEmitter extends EventEmitter {} const myEmitter = new MyEmitter(); myEmitter.on('greet', name => { console.log(`你好,${name}`); }); myEmitter.emit('greet', 'Node');这段代码里,on注册监听器,emit触发事件。这种设计模式叫发布订阅,Node整个异步生态里到处是它的影子。理解它,后面学流(stream)、理解请求对象的data事件、error事件,都会顺畅很多。
6. 异步编程:从回调地狱到 async/await 的演进和实战选择
Node的异步特性带来一个直接后果:如果你不管好异步流程,代码很快就会变成传说中一层套一层的"回调地狱"。
6.1 为什么回调地狱会让人崩溃,以及Promise如何解决问题
假设你要依次读三个文件,然后把内容合并输出,最原始的回调写法是这样的:
const fs = require('fs'); fs.readFile('a.txt', 'utf8', (err, dataA) => { if (err) throw err; fs.readFile('b.txt', 'utf8', (err, dataB) => { if (err) throw err; fs.readFile('c.txt', 'utf8', (err, dataC) => { if (err) throw err; console.log(dataA + dataB + dataC); }); }); });层级一深,代码就不谈可维护性了。Promise出现后,可以把回调嵌套改成链式调用:
const fs = require('fs/promises'); fs.readFile('a.txt', 'utf8') .then(dataA => fs.readFile('b.txt', 'utf8').then(dataB => [dataA, dataB])) .then(([dataA, dataB]) => fs.readFile('c.txt', 'utf8').then(dataC => dataA + dataB + dataC)) .then(result => { console.log(result); }) .catch(err => { console.error('处理失败:', err); });这一段看起来也没有清爽到哪去,只是说明了Promise在"消除嵌套"上的能力有限,真正让它好用的是async/await。
6.2 async/await 的完整演进:让异步代码长得像同步代码
async/await是建立在Promise之上的语法糖。用async标记一个函数,函数里就能用await来等待一个Promise完成:
const fs = require('fs/promises'); async function mergeFiles() { try { const dataA = await fs.readFile('a.txt', 'utf8'); const dataB = await fs.readFile('b.txt', 'utf8'); const dataC = await fs.readFile('c.txt', 'utf8'); console.log(dataA + dataB + dataC); } catch (err) { console.error('处理失败:', err); } } mergeFiles();高下立判。这里比较重要的是正确理解await的行为:await不是"暂停整个程序",而是让当前async函数的执行暂停到那个Promise有结果为止,这个过程不阻塞事件循环,其他请求照常能被执行。
写异步代码时有几条经验:
- 明明没依赖关系的两个
await,不要写成串行,应该用Promise.all并行执行,减少总耗时。 async函数内部如果忘了try/catch,reject会变成一个未处理的Promise,现象是进程直接报unhandledRejection。for循环里如果用await,要弄清楚意图是串行还是并行;串行就用for...of加await,并行就先构造Promise数组再Promise.all。
// 并行读取三个文件 const [dataA, dataB, dataC] = await Promise.all([ fs.readFile('a.txt', 'utf8'), fs.readFile('b.txt', 'utf8'), fs.readFile('c.txt', 'utf8') ]);这块内容熟练之后,处理业务里"多个接口并发请求""批量读取文件"这类场景就会顺手很多。
7. npm 与依赖管理:被忽视的工程化第一课
Node项目刚建好时几乎逃不掉一个文件:package.json。不理解它的作用,后面任何第三方的库都装不踏实。
7.1 package.json 的重要字段和语义化版本号
package.json保存了项目的元数据和依赖信息。必懂的字段有这些:
| 字段 | 作用 |
|---|---|
name | 项目名,发布到npm时需要,且不能有大写字母 |
version | 项目版本号,配合语义化版本规则管理 |
main | 入口文件,别人require你的包时加载的文件 |
scripts | 自定义命令集合,比如"start": "node app.js" |
dependencies | 运行时依赖,生产环境必需 |
devDependencies | 开发时依赖,比如测试框架、构建工具 |
版本号里的^和~也值得搞清楚:^1.2.3表示允许安装1.x.x里最新的版本;~1.2.3表示只允许安装1.2.x里的最新版本。这直接影响你安装的依赖会不会在下次npm install时自动升级到不兼容的新版本。
实际项目中我踩过一次很典型的坑:项目里锁了"express": "^4.17.1",过了几个月重新安装依赖,npm给装了4.18.x,结果某个中间件在新版本下行为变了,API响应多了一个字段。所以团队项目里建议要么把精确版本写死,要么认真提交package-lock.json。
7.2 语义化版本规则:主版本号.次版本号.修订号
语义化版本号用三部分组成:主版本号.次版本号.修订号。
- 主版本号:不兼容的API变更,从
1.x升到2.x通常意味着破坏性更新。 - 次版本号:向后兼容的新功能。
- 修订号:向后兼容的缺陷修复。
理解了这套规则,你在升级依赖时就能快速判断风险:修订号随便升,次版本号可以升但要留意新功能,主版本号升之前先看Changelog。
7.3 package-lock.json 和 node_modules:团队协作里必须理解的两件事
node_modules是npm install跑出来的依赖目录,动辄几百MB,一般不提交到代码仓库。package-lock.json则建议一定要提交,它是所有依赖的精确版本锁,保证任何人在任何时候执行npm install都会得到一模一样的依赖树。
还有一个容易被忽视的点:npm install安装新包时,默认会把包写入dependencies,如果用--save-dev则写入devDependencies。新版npm里不写--save也会自动写入dependencies,所以装依赖前先想清楚它是属于运行时还是开发环境。
提示:如果某次
npm install后项目跑不起来,先检查package-lock.json有没有被动过。删掉node_modules和package-lock.json后重新安装,通常能解决大部分依赖错乱问题。
8. 实战串讲:把零散知识点拧成一个完整的HTTP服务
讲了这么多概念,最终还是得落在一个具体的东西上。下面我用一个最简版的待办事项服务,把前面提到的模块、异步、HTTP和工程化串一遍。不引入任何框架,就用Node内置模块来做,方便理解底层链路。
8.1 设计目标与目录结构:先想清楚再动手
这个服务要能做的事很简单:
GET /todos:返回待办列表。POST /todos:新增一条待办。- 数据持久化到本地
todos.json文件。
目录结构:
todo-server/ package.json server.js todo-store.js todos.json8.2 分层拆解:store层负责数据读写,server层负责HTTP逻辑
先写todo-store.js,它负责所有文件读写操作,对外暴露两个异步方法:
const fs = require('fs/promises'); const path = require('path'); const dataFile = path.join(__dirname, 'todos.json'); async function getAllTodos() { try { const raw = await fs.readFile(dataFile, 'utf8'); return JSON.parse(raw); } catch (err) { if (err.code === 'ENOENT') { return []; } throw err; } } async function addTodo(text) { const todos = await getAllTodos(); const newTodo = { id: Date.now().toString(), text, done: false }; todos.push(newTodo); await fs.writeFile(dataFile, JSON.stringify(todos, null, 2), 'utf8'); return newTodo; } module.exports = { getAllTodos, addTodo };这段代码里有几个信息点值得注意:fs/promises让文件读写能用async/await;path.join(__dirname, 'todos.json')保证了路径在不同平台都不出错;当文件不存在时通过ENOENT错误码来做兜底,这是Node文件系统很常用的错误处理手法。
接下来是server.js,负责HTTP层:
const http = require('http'); const { getAllTodos, addTodo } = require('./todo-store'); const server = http.createServer(async (req, res) => { const url = new URL(req.url, 'http://localhost:3000'); const method = req.method; if (method === 'GET' && url.pathname === '/todos') { const todos = await getAllTodos(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(todos)); return; } if (method === 'POST' && url.pathname === '/todos') { let body = ''; for await (const chunk of req) { body += chunk.toString(); } const parsed = JSON.parse(body || '{}'); const newTodo = await addTodo(parsed.text || ''); res.writeHead(201, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(newTodo)); return; } res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not Found' })); }); server.listen(3000, () => { console.log('服务已启动: http://localhost:3000'); });这段代码把之前的知识点基本都串上了:async请求处理函数、await读取文件、URL解析请求路径、for await...of收集请求体流数据、通过require加载自定义模块。
这个不到100行的应用已经是一个完整可运行的服务了。你可以启动它,用curl测试:
curl http://localhost:3000/todos curl -X POST -H "Content-Type: application/json" -d '{"text":"学习Node.js"}' http://localhost:3000/todos8.3 从这个Demo能带走的工程化习惯
游戏打到这里,希望你能从这个小服务里带走几个习惯:
- 数据层和HTTP层尽量分开,哪怕只是一个很小的文件,这也让后续替换存储方式或加测试更容易。
- 任何从外部进入的数据(比如请求体)都要假设它是不安全的,
JSON.parse可能抛异常,body里的字段可能缺失。 - 日志输出要在关键节点打,比如服务启动、请求完成、写入失败。
真实项目里,这个Demo可以继续往框架方向演进:把http.createServer换成框架的路由系统,把JSON解析交给框架内置的body-parser,把文件存储换成数据库。但底层那一套异步机制,学的就是今天理解的这些东西。
9. 真实项目里最容易翻车的几个Node实战坑
前八节把主要知识点过了一遍,最后一节写几个我实际开发中频繁踩到的、也经常在社区看到别人踩的坑,算是给大家提个醒。
9.1 端口被占用:处理方式
Node服务启动时最常遇到的第一个错误就是EADDRINUSE,意思是端口已经被占用。排查命令:
lsof -i :3000 # macOS/Linux netstat -ano | findstr :3000 # Windows找到占用进程后要么杀掉,要么换端口。更工程化的做法是让服务支持从环境变量读取端口,本地默认用3000,部署时通过环境变量改掉。
9.2 异步回调里抛异常为什么抓不到
如果在fs.readFile的回调里直接throw new Error('xxx'),外层即便包了try/catch也抓不到这个错误,因为回调是在事件循环的其他阶段执行的,根本不在try所在的调用栈里。这类问题会直接导致进程崩溃。
所以养成一个习惯:回调式API一律通过err参数判断错误,Promise式API一律通过catch或try/catch处理,不要靠throw往全局抛。
9.3 环境变量与敏感信息:别把密钥写进代码
连接数据库的密码、第三方服务的密钥,不应该硬编码在源文件里。常规做法是从环境变量读取,配合.env文件管理:
PORT=3000 DB_PASSWORD=xxx然后在代码里:
const port = process.env.PORT || 3000;注意.env文件不要提交到代码仓库,记得加进.gitignore。
9.4 进程崩溃与守护:关于日志和重启的基础意识
一个Node进程跑在服务器上,如果抛了一个未捕获的异常,进程直接就退了。所以生产环境里至少要做到两件事:
- 全局监听
uncaughtException和unhandledRejection,把错误记录下来,再决定是否需要退出。 - 用进程守护工具(比如systemd或者相关进程管理器)让服务在崩溃后自动重启。
process.on('uncaughtException', err => { console.error('未捕获异常:', err); }); process.on('unhandledRejection', reason => { console.error('未处理的Promise拒绝:', reason); });提示:监听
uncaughtException只是为了记录现场,不建议在捕获后若无其事地继续跑。有些状态可能已经损坏,保守策略是记录日志后优雅退出,让守护进程拉起来。
9.5 不用框架裸写服务时,路由、中间件、错误处理全靠自己组织
用原生http写服务很好理解底层,但工程上,路由匹配、请求体解析、CORS、日志这些通用能力完全自己手写会很累,也容易遗漏。当你开始觉得"为什么别人都用框架而我在重复造轮子"时,就可以去学一个框架了。框架没有改变底层机制,它只是把设计模式沉淀成了现成的工具。
到这里,Node.js基础篇的核心知识也就梳理得差不多了。从我自己的学习路径回看,最有价值的不是背会了多少API,而是想清楚了事件循环的调度逻辑、模块系统的设计取舍、异步流程的演进思路——这三块通了,后面的框架、数据库、部署学习都会顺很多。如果你刚入门,建议照着第8节的Demo自己动手敲一遍,再试着加一个DELETE /todos/:id的接口,把文件写入、路径解析、请求体处理都过一遍。遇到问题别急着搜答案,先按我第9节讲的排查思路走一遍,这个"自己去定位"的过程,才是真正长经验的地方。