为什么要手写 NodeMON?
在开发 Node.js 应用时,我们经常用 nodemon 来自动重启服务。但你有没有想过:
- 🤔 它是如何监听文件变化的?
- 🤔 它是如何优雅地杀掉进程并重启新进程的?
今天我们就从零开始,手写一个 Windows 专属的简易 NodeMON 工具,彻底搞懂背后的原理!
node index.js的执行过程
比如我使用express编写一个简单的服务代码
js
const express = require('express');
const app = express();
app.get('/', (req, res) => { res.send('Hello World!'); });
const server = app.listen(6666, () => {
console.log('Express 服务已启动,进程 ID:', process.pid); // 打印当前进程ID
console.log('服务监听 http://localhost:6666');
});
当我们执行 node index.js 时:
- 操作系统会在环境变量的目录里面逐个查找,并找到node的可执行程序,比如位置在:
C:\Program Files\nodejs
- 当操作系统找到这个可执行程序后,会安排一个进程去加载并执行这个node.exe程序,再把index.js文件作为参数交给node.exe去解析并运行
- 在这个新进程的内部,Nodejs会去执行index.js的代码,最终会启动一个Express服务,这个服务使用的是6666端口
- 只要这个进程不终止(比如你按
Ctrl+C、杀死进程、服务器重启),6666 端口就会一直被占用,服务就一直可用。
通俗易懂地类比理解
操作系统是 "工厂小老板",进程是驾驶机器的一个个的员工,而node.exe 则是 "一台机器",index.js 是 "机器要处理的任务单"
-
操作系统 = 工厂小老板负责统筹全局,决定要不要招新员工(创建进程)、给员工分配机器(运行可执行程序)、下发任务单(传递参数),还能随时监督员工工作状态(查看进程 PID、占用资源),或者让员工下班(终止进程)。
-
进程 = 驾驶机器的员工是老板(系统)专门招来的 "专人",有自己的唯一工号(PID),他的核心工作就是操作手里的机器,全程只围绕这个机器和任务转,不会同时干别的活。
-
node.exe = 员工驾驶的机器是员工的 "工具",本身有固定的功能(JavaScript 解释执行能力),没有员工(进程)操作的话,它就是一台闲置的机器,啥也干不了。
-
index.js = 机器要处理的任务单上面写着具体的工作内容(比如 "启动 Express 服务、监听 3000 端口"),员工(进程)操作机器(node.exe)时,就照着任务单的要求一步步执行。
至于线程,我们知道进程中可以创建多个线程(可以理解为一个操作机器的员工,可以长出多只手,两只手干活总会比一只手干活快)
员工常常用一只手干活(进程中的主线程干活)
说到这里,就要额外提一下Process这个变量了
process就相当于node.exe这个机器上面的控制显示屏,记录了一些信息,提供给外部方便使用
| 控制显示屏(process)的功能 | 对应工厂场景 | 代码示例 |
|---|---|---|
| 显示 "员工工号(PID)" | 屏幕上显示当前操作机器的员工编号 | process.pid → 输出 39900 |
| 显示 "机器运行参数" | 屏幕显示机器接收到的任务单(index.js)、启动指令 | process.argv → 输出 ['node', 'index.js'] |
| 显示 "机器资源占用" | 屏幕显示机器当前用了多少内存、CPU | process.memoryUsage() → 输出内存占用数据 |
| 提供 "关机按钮" | 屏幕上的 "停止运行" 按钮,按了员工就下班 | process.exit() → 终止当前进程 |
| 显示 "工厂环境" | 屏幕显示老板(系统)给的环境变量(比如 PATH) | process.env → 输出系统环境变量 |
- 比如,我可以打印进程id就是
- console.log('进程 ID:', process.pid) // 得到39900这个值 是40188当然每次一般都不一样
cmd命令:
tasklist | findstr 39900 查看进程39900在使用哪一台机器------使用node.exe这个机器
bash
C:\Users\lss13>tasklist | findstr 39900
node.exe 39900 Console 1 25,176 K
netstat -ano | findstr :6666 查看6666端口,被那个进程使用------被39900进程使用
bash
C:\Users\lss13>netstat -ano | findstr :6666
TCP 0.0.0.0:6666 0.0.0.0:0 LISTENING 39900
TCP [::]:6666 [::]:0 LISTENING 39900
一、原理讲解
有了前置的知识后,我们来梳理一下手写启动监控工具的思路
核心流程
markdown
启动监控工具
↓
监听文件变化(chokidar)
↓
检测到修改 → 计算文件 Hash
↓
Hash 变了?
├─ 是 → 杀掉旧进程(taskkill)→ 等待端口释放 → 启动新进程
└─ 否 → 忽略(避免无意义重启)
关键技术点
chokidar:www.npmjs.com/package/cho...
| 技术 | 作用 | Windows 特殊处理 |
|---|---|---|
| chokidar | 监听文件变化 | 可选轮询模式(更稳定) |
| spawn | 启动/管理子进程 | 需要处理进程树 |
| taskkill | 杀死进程 | Windows 专属命令 |
| crypto | 计算文件 Hash | 精确判断内容是否变化 |
二、代码实现
项目结构
bash
myNodeMon/
├── package.json # 项目配置
├── nmon.js # 监控工具(核心)
└── index.js # 业务代码(HTTP 服务器)
第一步:初始化项目
bash
# 创建项目目录
mkdir myNodeMon
cd myNodeMon
# 初始化 package.json
npm init -y
# 安装依赖
npm install chokidar
第二步:配置 package.json
json
{
"name": "mynodemon",
"version": "1.0.0",
"type": "module",
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"chokidar": "^5.0.0"
}
}
关键配置:
"type": "module":启用 ES Module 语法
第三步:编写业务代码(index.js)
这是我们要监控的目标文件index.js,一个简单的 HTTP 服务器:
javascript
import http from 'http';
// 创建http服务器
const server = http.createServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(`
当前时间:${new Date().toLocaleTimeString()}
`);
});
console.log(666)
// 启动服务器
server.listen(6666, () => {
console.log(`Node.js 服务已启动:http://localhost:6666`);
});
功能:
- 监听 6666 端口
- 返回当前时间
第四步:编写监控工具(nmon.js)
这是核心代码,我们逐块解析:
导入依赖
javascript
import chokidar from 'chokidar'; // 文件监听库
import { spawn } from 'child_process'; // 子进程管理
import path from 'path';
import crypto from 'crypto'; // Hash 计算
import fs from 'fs'; // 文件读取
配置项
javascript
// ===================== 配置项 =====================
const TARGET_FILE = 'index.js'; // 要监控的文件
// =================================================
const entryPath = path.resolve(process.cwd(), TARGET_FILE); // 获取绝对路径
process.cwd():当前工作目录(运行命令的位置)path.resolve():拼接成绝对路径,如C:\Users\xxx\myNodeMon\index.js
Hash 计算
javascript
let lastHash = null; // 保存上次的 hash
function getFileHash(filePath) {
const content = fs.readFileSync(filePath);
return crypto.createHash('md5').update(content).digest('hex');
}
为什么要用 Hash?
| 方案 | 问题 |
|---|---|
| 只检测修改时间 | Ctrl+S 不修改内容也会触发重启 ❌ |
| Hash 对比 | 只有内容真正变化才重启 ✅ |
当我们在编辑器里面 Ctrl+S 的时候,尽管没有修改文件,但是操作系统依旧认为这个文件变化了,也会触发文件变化回调函数,即文件的时间会变化
工作原理:
- 读取文件内容
- 计算 MD5 哈希值(如
a1b2c3d4...) - 对比上次的哈希值
- 不同才重启
进程引用管理
javascript
let childProcess = null; // 保存进程实例
// 启动或重启 index.js
function startApp() {
if (childProcess) {
console.log('🔄 正在终止旧进程...');
// Windows 下用 taskkill 强制杀死进程树
const killProcess = spawn('taskkill', ['/F', '/T', '/PID', childProcess.pid]);
killProcess.on('close', () => {
childProcess = null; // 清空引用
setTimeout(() => {
launchNewProcess();
}, 200); // 等待端口释放
});
return;
}
launchNewProcess();
}
| 参数 | 含义 |
|---|---|
/F |
强制终止 |
/T |
终止进程树(包括子进程) |
/PID |
按进程 ID 杀死 |
为什么要延迟 200ms?
markdown
taskkill 完成 → 进程退出 → 操作系统释放端口 → 新进程启动
↑
这里需要时间(约 50-150ms)
如果不等待,新进程会报错:EADDRINUSE: address already in use
启动新进程
javascript
function launchNewProcess() {
console.log('🚀 正在启动新进程...\n');
// 相当于执行:node index.js
childProcess = spawn('node', [entryPath], {
stdio: 'inherit' // 让子进程的日志直接显示在控制台
});
childProcess.on('error', (err) => {
console.error('❌ 启动失败:', err.message);
});
}
stdio: 'inherit':继承父进程的输入输出,让 index.js 的日志能显示出来
文件变化监听
javascript
// 创建文件监听器
const watcher = chokidar.watch(TARGET_FILE, {
ignoreInitial: true // 忽略初始化时的事件
});
watcher.on('change', () => {
const currentHash = getFileHash(TARGET_FILE);
if (currentHash !== lastHash) {
console.log('📝 检测到内容真的变了');
lastHash = currentHash;
startApp();
} else {
console.log('⏭️ 内容没变,忽略');
}
});
// 初始化时计算一次
lastHash = getFileHash(TARGET_FILE);
流程
- 监听
index.js的change事件 - 计算当前文件的 Hash
- 对比上次的 Hash
- 不同才调用
startApp()重启
启动监控
javascript
console.log(`🚀 Windows 监控工具已启动`);
console.log(`🎯 监控目标:${TARGET_FILE}\n`);
startApp();
优雅退出(Ctrl+C)
javascript
process.on('SIGINT', () => {
if (childProcess) {
console.log('\n🛑 正在清理子进程...');
spawn('taskkill', ['/F', '/T', '/PID', childProcess.pid]);
childProcess = null;
}
watcher.close();
console.log('👋 监控工具已退出');
process.exit(0);
});
- 按 Ctrl+C 时清理子进程
- 关闭文件监听器
- 避免端口残留占用
三、测试验证效果
测试场景 0:启动服务并访问 测试场景 1:修改文件内容 测试场景 2:不修改内容,只保存 测试场景 3:优雅退出
效果图

四、踩坑
为什么 Windows 需要特殊处理?
问题 1:文件监听不稳定
Linux/Mac:
- 使用
inotify/FSEvents(内核级别) - 高效、准确、实时
Windows:
- 使用
ReadDirectoryChangesW(基于目录扫描) - 容易丢失事件或重复触发
- 容易丢失事件或重复触发
- 容易丢失事件或重复触发
特别是快速Ctrl + S保存,可能会事件误触发...
解决方案可选:
javascript
// 可选:使用轮询模式(更稳定但耗资源)
const watcher = chokidar.watch(TARGET_FILE, {
ignoreInitial: true,
usePolling: true, // 强制轮询
interval: 500 // 每 500ms 检查一次
});
问题 2:进程杀不干净
Linux/Mac:
bash
kill -9 <PID> # 直接杀进程
Windows:
bash
taskkill /F /T /PID <PID> # 需要杀进程树
为什么要加 /T?
markdown
父进程(node nmon.js)
└─ 子进程(node index.js)
└─ 可能还有孙进程
不加 /T 只杀父进程,子进程会变成孤儿进程,继续占用端口。
为什么要保存进程实例?
javascript
let childProcess = null;
作用:
- 获取进程 PID(
childProcess.pid) - 在重启时杀掉旧进程
- 监听进程状态(退出、错误)
如果不保存:
javascript
// ❌ 错误示范
spawn('node', ['index.js']); // 启动了,但没人记住它
// 想重启时
spawn('taskkill', ['/PID', ???]); // 不知道 PID,无法杀进程
五、完整代码
GitHub
仓库地址:github.com/shuirongshu...
nmon.js
javascript
import chokidar from 'chokidar'; // 监控包
import { spawn } from 'child_process'; // 派发生系统命令来创建和终止子进程,实现启动和重启
import path from 'path';
import crypto from 'crypto';
import fs from 'fs';
// ===================== 配置项 =====================
const TARGET_FILE = 'index.js'; // 要监控并自动重启的文件
// =================================================
const entryPath = path.resolve(process.cwd(), TARGET_FILE); // 路径
let childProcess = null; // 保存 index.js 的进程实例对象的引用,便于后续清空重置
let lastHash = null; // 保存上次的 hash
function getFileHash(filePath) {
const content = fs.readFileSync(filePath);
return crypto.createHash('md5').update(content).digest('hex');
}
// 启动或重启 index.js
function startApp() {
// 当前有进程,就清除掉以后,再启动(重启功能)
if (childProcess) {
console.log('🔄 正在终止旧进程...');
// Windows 下用 taskkill命令 强制杀死进程树 // 比如类似 taskkill /f /t /im nginx.exe
const killProcess = spawn('taskkill', ['/F', '/T', '/PID', childProcess.pid]);
killProcess.on('close', () => {
childProcess = null; // 清空进程实例对象的引用
setTimeout(() => { // 等200毫秒足够操作系统释放端口了
launchNewProcess();
}, 200);
});
return;
}
// 当前没有进程就直接启动即可
launchNewProcess();
}
// 启动新进程
function launchNewProcess() {
console.log('🚀 正在启动新进程...\n');
// 相当于执行命令:node C:\Users\xxx\myNodeMon\index.js 简化就是 node index.js
childProcess = spawn('node', [entryPath], {
stdio: 'inherit' // 让子进程的输出日志,直接显示在当前控制台
});
// 比如文件不存在或者路径错误会报错,兜一下
childProcess.on('error', (err) => {
console.error('❌ 启动失败:', err.message);
});
}
// 创建文件监听器------不使用轮询
const watcher = chokidar.watch(TARGET_FILE, {
ignoreInitial: true // 忽略初始化时的事件
});
// // 创建文件监听器------使用轮询
// const watcher = chokidar.watch(TARGET_FILE, {
// ignoreInitial: true, // 忽略初始化时的事件
// usePolling: true, // Windows下用轮询更加稳妥(毕竟其文件管理没有Linux做得好)
// interval: 1000 // 每 100ms 检查一次文件变化
// });
watcher.on('change', () => {
const currentHash = getFileHash(TARGET_FILE);
if (currentHash !== lastHash) {
console.log('📝 检测到内容真的变了');
lastHash = currentHash;
startApp();
} else {
console.log('⏭️ 内容没变,忽略');
}
});
// 初始化时计算一次
lastHash = getFileHash(TARGET_FILE);
// 启动监控
console.log(`🚀 Windows 监控工具已启动`);
console.log(`🎯 监控目标:${TARGET_FILE}\n`);
startApp();
// Ctrl+C 退出时清理进程
process.on('SIGINT', () => {
if (childProcess) {
console.log('\n🛑 正在清理子进程...');
spawn('taskkill', ['/F', '/T', '/PID', childProcess.pid]);
childProcess = null;
}
watcher.close();
console.log('👋 监控工具已退出');
process.exit(0);
});
六、总结
学到了什么?
- 通俗易懂理解一些基础概念
- chokidar 的使用
- Windows 下文件系统还是差点意思(稳定性问题)
spawn()启动子进程taskkill杀死进程树- 通过文件hash精确判断文件内容是否变化
- 信号处理(
SIGINT)
与 nodemon 的对比
| 特性 | 我们的工具 | nodemon |
|---|---|---|
| 文件监听 | ✅ | ✅ |
| 自动重启 | ✅ | ✅ |
| Hash 对比 | ✅ | ❌ |
| 配置文件 | ❌ | ✅ |
| 跨平台 | ❌(仅 Windows) | ✅ |
| 日志记录 | ❌ | ✅ |
| 代码量 | ~100 行 | ~5000 行 |
七、拓展思考
🤔 如何监听多个文件?
提示:
javascript
const TARGET_FILES = ['index.js', 'config.js'];
const watcher = chokidar.watch(TARGET_FILES, { ... });
// 需要为每个文件保存 Hash
const fileHashes = new Map();
🤔 如何添加日志输出?
提示:
javascript
import fs from 'fs';
function log(message) {
const timestamp = new Date().toISOString();
const logMessage = `[${timestamp}] ${message}\n`;
fs.appendFileSync('nmon.log', logMessage);
console.log(message);
}
🤔 如何支持配置文件?
提示:
javascript
// nmon.config.json
{
"target": "index.js",
"port": 6666,
"delay": 200
}
// 读取配置
const config = JSON.parse(fs.readFileSync('nmon.config.json', 'utf8'));
🤔 如何实现热重载(不重启进程)?
提示:
- 使用
vm模块动态加载代码 - 或者使用 WebSocket 通知浏览器刷新