告别黑盒!手写Windows版简易NodeMON,学习文件监听代码修改与进程服务重启知识


为什么要手写 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 的时候,尽管没有修改文件,但是操作系统依旧认为这个文件变化了,也会触发文件变化回调函数,即文件的时间会变化

工作原理:

  1. 读取文件内容
  2. 计算 MD5 哈希值(如 a1b2c3d4...
  3. 对比上次的哈希值
  4. 不同才重启

进程引用管理

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);

流程

  1. 监听 index.jschange 事件
  2. 计算当前文件的 Hash
  3. 对比上次的 Hash
  4. 不同才调用 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;

作用:

  1. 获取进程 PID(childProcess.pid
  2. 在重启时杀掉旧进程
  3. 监听进程状态(退出、错误)

如果不保存:

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 通知浏览器刷新

八、参考资料


相关推荐
程序员爱钓鱼10 小时前
Node.js 编程实战:测试与调试 —— 调试技巧与性能分析
前端·后端·node.js
Mr -老鬼12 小时前
Node.js 打包二进制文件完全指南
node.js
xiaoxue..13 小时前
把大模型装进自己电脑:Ollama 本地部署大模型完全指南
javascript·面试·node.js·大模型·ollama
这就是佬们吗13 小时前
告别 Node.js 版本冲突:NVM 安装与使用全攻略
java·linux·前端·windows·node.js·mac·web
天意pt1 天前
Blog-SSR 系统操作手册(v1.0.0)
前端·vue.js·redis·mysql·docker·node.js·express
程序员iteng1 天前
AI一键图表生成、样式修改的绘图开源工具【easy-draw】
spring boot·开源·node.js
2301_818732061 天前
安装了node,但是cmd找不到node和npm,idea项目也运行失败 已解决
前端·npm·node.js
Benny的老巢1 天前
【n8n工作流入门02】macOS安装n8n保姆级教程:Homebrew与npm两种方式详解
macos·npm·node.js·n8n·n8n工作流·homwbrew·n8n安装
2301_818732062 天前
下载nvm后,通过nvm无法下载node,有文件夹但是为空 全局cmd,查不到node和npm 已解决
前端·npm·node.js