很多刚接触 Node.js 的朋友,第一个程序几乎都是从打印 Hello World 开始的,node xxx.js一敲,终端里蹦出一行字,成就感是有,但心里也发虚:这就完了?我告诉你,真正的第一个应用,至少得是一个能监听端口、能响应 HTTP 请求、能处理一点业务逻辑的服务。这篇文章就沿着这条主线走:从装环境、初始化项目,到写出第一个 HTTP 服务,再到手动加路由和静态资源、顺手引入 Express,最后聊几个新手必踩的坑。我自己当年就是这么一步步过来的,你照着来,一下午就能把地基打好,后面学 Express、写接口、连数据库都会顺很多。
1. 装环境这事,值得一开始就做对
1.1 用 nvm 管理版本,别直接装官方安装包
很多人图省事,直接去官网下安装包,双击下一步就完事。我不反对,但更推荐先用 nvm(Node Version Manager)来装。原因很实在:Node.js 的版本迭代太快,你今天用最新的 LTS 写项目没问题,明天接手一个旧项目可能就需要切到 14 甚至 12;团队里不同项目的运行版本也可能不一致。nvm 能让你在同一台机器上随时切换版本,省掉反复卸载安装的折腾。
Linux 和 macOS 下,一条脚本就能把 nvm 装上;Windows 下用 nvm-windows 这个变体,注意它的子命令和原版略有差异。装完以后,先看有哪些版本可选:
nvm ls-remote然后安装最新的 LTS 版本,并把它设为默认值:
nvm install --lts nvm alias default --lts node -v npm -vnvm alias default这步很多人会漏掉。它决定你新开终端时默认启用哪个版本,不设置的话,你下次打开终端可能又回到“找不到 node”的状态。版本选择有个原则:生产环境优先用 LTS,只有想尝鲜新语法特性时才用 Current。我实测下来,当前这个阶段用最新的 LTS 版本,对大多数教学项目和中小型业务都完全够用,没必要追新。
1.2 初始化项目:npm init 与 package.json 里的门道
环境就绪后,新建一个项目目录,进去初始化:
mkdir my-first-app cd my-first-app npm init -y-y是省事,一路默认。但初始化完建议打开 package.json 看一眼,把name和description改一改,因为默认的 name 就是目录名,而且如果你后面要把项目发布成 npm 包,name 里不能有大写字母和空格,这个坑我见过不止一次。
package.json 里最关键的两个字段,一个是main,它标记程序的入口文件;另一个是scripts,你可以在里面定义常用命令。比如我习惯先加一个启动脚本:
{ "name": "my-first-app", "version": "1.0.0", "main": "app.js", "scripts": { "start": "node app.js" } }这样后面统一用npm start启动服务,不用老是记着一长串命令。还有一个容易忽略的东西是 package-lock.json,npm 自动生成的锁文件,它锁定了每个依赖的具体版本。多人协作时一定要把它提交到代码仓库,否则别人装依赖的版本跟你不一样,很容易出现“我这儿好好的,你那儿就报错”的情况。另外,node_modules目录要写进 .gitignore,它只是依赖的本地落盘,没必要进仓库。
2. 第一个能跑的应用:HTTP 服务从零开始
2.1 最小可运行的 HTTP 服务器
读文件、写脚本都还谈不上“应用”,真正的第一个应用,我建议从这个最小 HTTP 服务开始。新建 app.js,写下面这段代码:
const http = require('http'); const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Hello, Node.js! 这是我的第一个应用'); }); server.listen(3000, '127.0.0.1', () => { console.log('服务已启动: http://127.0.0.1:3000'); });然后npm start,浏览器打开 http://127.0.0.1:3000,看到那行字就说明服务通了。别小看这段代码,它已经把 Node 最核心的几件事都包含了:http.createServer创建一个服务,回调函数里拿到请求对象 req 和响应对象 res,通过res.writeHead设置状态码和响应头,用res.end把内容返回给客户端,最后server.listen指定端口开始监听。
这里有两个细节值得说。一是charset=utf-8,如果你不写,返回中文在部分浏览器里会乱码。二是监听地址我写的是127.0.0.1而不是省略,省略时 Node 默认会监听所有网卡,包括外网,这在开发阶段有安全风险。只在本地调试时,明确绑到127.0.0.1更稳妥。
想确认服务真的正常,除了浏览器,更推荐用 curl 看响应头:
curl -i http://127.0.0.1:3000能看到 HTTP/1.1 200 和 Content-Type 相关的响应头信息,远比在浏览器里肉眼判断靠谱。
2.2 事件驱动与回调,先理解这几个关键概念
很多人写完了服务,却看不懂它为什么能一直在那里等着请求。这就得聊 Node.js 的看家本领:事件驱动和回调。
你用浏览器访问一个网站,传统的方式是“来一个请求,开一个线程,处理完关闭”。Node 不一样,它只有一个主线程在跑,所有请求过来以后,都会被放进一个事件队列里,轮到一个就执行一个。事件循环(event loop)就是这个不断“看队列里有没有新事件”的机制。你传给http.createServer的那个回调函数,本质上注册了一个“当请求到达时执行”的监听器,请求到了就触发,触发完就继续等下一个。
这样做的好处是:处理大量并发请求时,不需要为每个连接都创建一个线程,内存开销小得多。代价就是,你写的代码几乎全是回调函数,而且不能在回调里写同步阻塞的操作,比如用fs.readFileSync同步读一个大文件,那会把整个事件循环卡住,后面排队的请求全部得等着。
理解这一点不用去背术语,记住一个类比就行:Node 就像一个只有一个服务员的餐厅。服务员收下菜单就去做下一件事,而不是一直站在厨房门口等菜。菜做好了(I/O 完成),再按小票叫对应客人(触发回调)。只要别在服务员收菜单的瞬间把厨房门堵死(同步阻塞),这家店就能同时接很多桌客人。你后续写代码时,凡是看到 Sync 结尾的 API,都得先想一想是不是真的需要在主线程里同步做。
2.3 模块化拆分,别把什么逻辑都堆在 app.js 里
最小服务跑通后,第一件要做的事就是拆分。我再强调一遍:别什么都写在 app.js 里,不然你的“第一个应用”变成“第一坨代码”的时间不会太久。
模块化的思路很简单:把处理不同路径的代码拆出去。比如新建一个 router.js:
const handleHome = (req, res) => { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end('<h1>首页</h1>'); }; const handleAbout = (req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('关于页面'); }; module.exports = { handleHome, handleAbout };然后在 app.js 里引入:
const http = require('http'); const routes = require('./router'); const server = http.createServer((req, res) => { const url = req.url; if (url === '/' || url === '/index') { routes.handleHome(req, res); } else if (url === '/about') { routes.handleAbout(req, res); } else { res.writeHead(404); res.end('404 Not Found'); } }); server.listen(3000, '127.0.0.1', () => { console.log('服务已启动: http://127.0.0.1:3000'); });module.exports和require是 CommonJS 模块规范的核心,Node 默认用的就是这套。require 的模块会被缓存,也就是说同一个模块多次 require,只有第一次会真正执行代码,后面拿到的都是缓存里的同一份导出对象。这个特性有好有坏,好处是性能好,坏处是如果你在模块顶层维护了状态,多个文件 require 它时共享的其实是同一份状态,容易互相影响。
拆分的好处现在可能还不明显,但你等路由多到十几条的时候就会明白,一个文件里光是if else就乱到没法看。早期的拆分习惯,能帮你省下未来大量的重构成本。
3. 给应用配上路由和静态资源
3.1 手动处理路由与请求参数,先把原理摸透
前面用最简单的if判断区分了首页和关于页,但真实的业务里,你会遇到更复杂的请求:用户要查询某条数据、提交一个表单,这些信息都在请求的 URL 和请求体里。所以这一步,我带你手动解析一下,把原理摸透。
先看 GET 请求里的查询参数。比如访问/search?keyword=node&page=2,你想取出 keyword 和 page,可以这样:
const url = require('url'); // 在请求回调里 const parsedUrl = new URL(req.url, 'http://localhost:3000'); const pathname = parsedUrl.pathname; const keyword = parsedUrl.searchParams.get('keyword'); const page = parsedUrl.searchParams.get('page');Node 内置的 URL 类就能完成解析。早期教程里常见的url.parse现在已经不推荐了,新代码直接用new URL()就好。注意必须传入第二个参数 base,否则解析相对路径会直接抛异常,这个报错新手常常遇到。
再看 POST 请求体。表单提交、JSON 提交,数据都在请求体里,而且是以流的形式一片一片传过来的,你不能直接拿到完整字符串。需要手动收集:
let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { try { const data = JSON.parse(body); console.log('收到的数据:', data); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok' })); } catch (e) { res.writeHead(400); res.end('无效的 JSON'); } });这里有两个明显的坑。第一,req是一个可读流,你必须监听data事件手动拼数据,忘记监听end事件的话,回调永远不会在你的预期时机执行。第二,JSON 解析一定要 try catch,因为客户端传来的内容不可信,一个不完整的 JSON 就能让你的服务直接抛错崩溃。
为什么 Node 不直接把请求体解析好给你?因为它设计得比较底层,把选择权交给了开发者。你当然可以每次都自己拼,但写两次就会烦,这也就是框架存在的意义。
3.2 静态资源与 MIME 类型,一个常被忽略的细节
网页不能只有文本,还需要图片、CSS、JavaScript 文件。你如果照着前面的写法,把res.end换成文件的二进制内容,就会发现一个麻烦事:不同文件类型要设置不同的 Content-Type,否则浏览器可能会把 CSS 当文本显示,或者下载而不是渲染图片。
这个 Content-Type 头就是 MIME 类型。比如.html是text/html,.css是text/css,.png是image/png。于是你需要一个映射表:
const path = require('path'); const fs = require('fs'); const mimeMap = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.json': 'application/json; charset=utf-8' }; const staticDir = path.join(__dirname, 'public'); function serveStatic(req, res) { const parsedUrl = new URL(req.url, 'http://localhost:3000'); let filePath = path.join(staticDir, parsedUrl.pathname); const ext = path.extname(filePath); const contentType = mimeMap[ext] || 'application/octet-stream'; fs.readFile(filePath, (err, data) => { if (err) { res.writeHead(404); res.end('404 Not Found'); return; } res.writeHead(200, { 'Content-Type': contentType }); res.end(data); }); }这一步最大的坑是安全问题。如果你直接拿req.url拼文件路径,攻击者可以通过../跳到 public 目录之外,读取服务器上的任意文件,这就是经典的路径遍历漏洞。path.join只能部分防御,更稳妥的做法是再校验一下解析后的文件路径是否真的以staticDir开头:
if (!filePath.startsWith(staticDir)) { res.writeHead(403); res.end('Forbidden'); return; }为什么我建议第一个应用就手动实现一遍静态资源服务?不是为了让你重复造轮子,而是为了让你真正理解后面的静态资源中间件帮你做了什么。等你用了框架,可能一行代码就把整个目录开放了,但如果没经历过手动设置 MIME 的繁琐,你很难意识到框架背后还帮你处理了路径穿越这类安全问题。
3.3 顺势引入 Express,为什么这时候该上框架
手动写完路由和静态资源服务,你应该已经体会到痛了:每次都要写writeHead、拼 body、处理路径,重复代码太多,而且容易漏。这时候再引入 Express,你会理解得特别快,知道它在替你省什么。
安装:
npm install express然后用 Express 重写一遍前面的功能:
const express = require('express'); const app = express(); app.use(express.json()); app.use(express.static('public')); app.get('/', (req, res) => { res.send('<h1>首页</h1>'); }); app.get('/about', (req, res) => { res.send('关于页面'); }); app.get('/search', (req, res) => { const keyword = req.query.keyword; res.json({ keyword }); }); app.post('/submit', (req, res) => { res.json({ received: req.body }); }); app.listen(3000, '127.0.0.1', () => { console.log('Express 服务已启动: http://127.0.0.1:3000'); });看出差别了吗?res.send帮你处理了 Content-Type 和数据序列化,res.json一行搞定 JSON 响应,req.query和req.body直接就是解析好的数据,再也不用自己拼流了。express.static一个中间件就完成了静态资源服务,而且内置了安全处理。
那么问题来了:既然框架这么省事,前面为什么要花那么大功夫写原生模块?我的观点是,先原生后框架,顺序不能反。先经历手动拼解析的痛苦,你才会明白框架的价值,也才能看懂框架文档在说什么。一上来就用 Express,你只是会复制粘贴代码,代码稍微出点问题就抓瞎。这段手动实现的经验,会在你以后调试任何 Node 服务时反复帮到你。
4. 新手最容易踩的坑,一次说清
4.1 端口被占用与 EADDRINUSE
这个报错是我当年遇到的第一个拦路虎。server.listen(3000)一执行就报EADDRINUSE错误,英文全称是“Address already in use”,翻译过来就是端口已被占用。最常见的原因是:你自己之前启动的服务没关掉,又在这个终端里重新npm start。
排查方法很简单。在 Linux 或 macOS 下:
lsof -i :3000会列出占用 3000 端口的进程和 PID,然后杀掉它:
kill -9 PID如果你用了 Windows,对应命令是:
netstat -ano | findstr :3000 taskkill /F /PID 进程号这里有个特别容易犯的糊涂事:改了代码,想着重新启动,但忘了先停掉旧进程,结果新进程启动失败,而浏览器里访问的其实还是旧服务。判断服务是不是最新代码跑起来的,我习惯的做法是改一下响应内容,再刷新页面,内容变了才是真的重启成功。
4.2 改了代码不生效,热重载到底怎么配
紧接着上面的问题,很多人会想:能不能我改完代码自动重启?当然有方案。以前用 nodemon,现在 Node 从 18.11 版本开始内置了--watch模式,直接用就行:
node --watch app.js好处是官方内置,不用装额外依赖。注意它的行为是监听指定入口文件及其依赖的文件变化,发现变更就自动重启进程。我实际用下来的体验是:开发阶段小项目完全够用,但项目文件多了以后,监听的性能不如专业的 nodemon。如果你想要更快的全量监听,还是装 nodemon 更省心。
还有一个经典误区:改了路由文件,发现服务没重启。这可能是因为你开的终端窗口里跑的还是旧命令,也可能是因为 Node 的--watch只跟踪进入模块图里的文件,某些动态加载的文件不会被捕获。我的建议是,看到不生效先别怀疑热重载坏了,先手动重启一次,确认是代码问题还是热重载问题,再做下一步排查。
4.3 用 --inspect 和日志定位问题
纯靠console.log打点,小项目还行,一旦逻辑复杂就会非常低效。Node 提供了一整套调试工具,启动时加--inspect参数:
node --inspect app.js然后用 Chrome 浏览器的开发者工具,打开chrome://inspect,就能找到你的 Node 进程,点进去就是完整的调试器界面,可以打断点、逐行执行、查看变量。这一步对理解回调逻辑特别有用,尤其是当你不清楚某个回调到底有没有被触发时,在关键回调里打断点比打十行日志都直接。
关于日志,我建议一开始就养成区分console.log和console.error的习惯。前者输出到标准输出,后者输出到标准错误。它们默认都会在终端里打印,但在重定向日志文件时,两者会被分开,方便你将来排查线上问题。另外,临时调试的日志用完就删,别留一堆无意义的输出在代码里,否则真出问题时,关键信息会被淹没。
下面这张表是我整理的新手问题速查,建议截图存一下:
| 报错信息 | 原因 | 快速解法 |
|---|---|---|
| EADDRINUSE | 端口被占用 | lsof 找 PID,kill 后重启 |
| Cannot find module 'xxx' | 依赖没装或路径写错 | 重新 npm install,检查 require 路径 |
| res.end is not a function | 回调参数用错 | 确认回调签名是 (req, res) |
| listen EADDRNOTAVAIL | 监听地址不合法 | 确认 IP 没拼错,用 127.0.0.1 试试 |
| body-parser 相关报错 | Express 版本内置解析器 | 用 app.use(express.json()),别额外装旧包 |
5. 让第一个应用真正“活”起来
5.1 进程守护:为什么不能直接 node app.js
你的第一个应用在电脑上跑得好好的,但如果你想让它长期运行,比如部署到一台服务器上,问题就来了:直接在终端里敲node app.js,只要终端一关闭,进程就没了;即使不关,进程崩溃了也不会自动重启。所以,我们还需要一个“守护”的角色。
比较常见且轻量的方案是 pm2。安装之后,一条命令就能启动并守护你的服务:
npm install -g pm2 pm2 start app.js --name my-first-app它的好处是:进程意外退出会自动重启,多条日志汇总管理,还能设置开机自启。生产部署时,我个人比较推荐这种做法。
不过也要提醒一句:pm2 不是银弹,它负责进程管理,不负责解决你代码本身的问题。如果代码里有未捕获的异常,pm2 虽然能帮你重启进程,但频繁崩溃本身就说明代码写的质量有问题。所以基础的错误处理还得自己写好,比如给process挂上uncaughtException和unhandledRejection的监听,记录日志后再退出重启,至少能留下线索。
5.2 环境变量与配置分离,顺手养成好习惯
最后一个习惯,我认为应该在第一个应用里就养成:把端口、数据库地址这一类配置放到环境变量里,而不是硬编码在代码中。Node 内置支持读取环境变量:
PORT=3000 node app.js代码里用:
const port = process.env.PORT || 3000; server.listen(port, '127.0.0.1');这样做的直接好处是,同一个代码,开发环境跟生产环境用不同的端口和连接地址,不需要改代码。更重要的是,数据库密码这类敏感信息放进环境变量,可以避免写死在代码里不慎提交到仓库。
常见做法是准备一个.env文件存放环境变量,再装一个dotenv包来加载它。.env一定要写进 .gitignore,里面只放本地开发用的值,生产环境的实际值在部署平台上另行配置。这条规则值得从第一天就刻在脑子里,等你真吃过一次密码泄露的亏,就知道它有多值钱了。
我个人在实际操作中还有一个体会:第一个应用别急着追求完美,但一定要保持风格清爽。目录结构、命名、注释,从一开始就按你能看懂的标准来。当年我写完第一个服务,过了两个月回去看,自己都认不出那堆代码,从那以后我就养成了在小项目里也拆分文件、写干净注释的习惯。Node.js 的上手门槛真的不高,难的是后续的工程化意识和排错能力,而这些,从一个最小但五脏俱全的应用开始,就是你最好的练习场。