第2章 2.4 events 事件触发器

🎯 开场:门铃告诉我的道理

上一章我们折腾了 osprocess 这两个模块,学会了对文件夹搞事、获取系统信息这些骚操作。

你有没有遇到过这种情况——你盯着屏幕等一个结果,但不知道它什么时候来?比如:

  • 下载一个大文件,你不想每分钟手动点一下"下载完了没"
  • 爬虫抓了一堆网页,你想知道什么时候全部抓完了
  • 服务器收到了一个请求,你想知道处理完了没有

如果你还在用 while true + time.sleep 这种方式轮询,恭喜你,今天学的这个技能能让你从"不断敲门问"升级成"等门铃响"

events 模块,就是 Node.js 里的门铃系统


🧱 基础:EventEmitter 是什么

2.4.1 先记住这个名字:EventEmitter

EventEmitter 是 events 模块的核心类,你可以把它想象成一个定制化的门铃

  • 门铃上可以挂多个按钮(监听器)
  • 按下按钮,门铃就会响(触发事件)
  • 谁按谁响,互不干扰

2.4.2 生活类比:点餐叫号系统

你去奶茶店点单,流程是这样的:

  1. 你跟服务员说"我要一杯珍珠奶茶,做好了叫我"
  2. 服务员记下你的需求,把小票给你
  3. 你去坐着刷手机,不用一直问"好了没"
  4. 奶茶做好了,服务员喊"38号!"

这就是 EventEmitter 的工作方式:

你(监听器)  →  告诉店员你要什么(注册事件)  →  等着
店员(EventEmitter)  →  做好了  →  喊你(触发事件)

2.4.3 最小代码:3 行跑通事件机制

const EventEmitter = require('events');

// 1. 创建一个事件发射器(门铃实例)
const emitter = new EventEmitter();

// 2. 注册一个监听器(告诉门铃:有人按门铃就通知我)
emitter.on('ring', () => {
console.log('叮咚!有人来了!');
});

// 3. 触发事件(按门铃)
emitter.emit('ring');

运行结果:

叮咚!有人来了!

解释一下:
- emitter.on('ring', callback):注册一个监听器,等门铃响了就执行这个函数
- emitter.emit('ring')按门铃,会立即触发所有注册在 'ring' 上的监听器

2.4.4 带参数的事件:门铃还能报信

门铃不只能告诉你"有人来了",还能告诉你是谁来了、几点来的

const EventEmitter = require('events');
const emitter = new EventEmitter();

emitter.on('visitor', (name, time) => {
console.log(`${time},${name} 来访`);
});

emitter.emit('visitor', '小明', '10:30');
emitter.emit('visitor', '小红', '14:15');

运行结果:

10:30,小明 来访
14:15,小红 来访

看到了吗?emit 可以传任意多个参数,这些参数会传递给回调函数。

2.4.5 once:只通知一次的监听器

有时候你只需要知道"第一次发生就够了",比如游戏里的新手引导只出现一次

const EventEmitter = require('events');
const emitter = new EventEmitter();

emitter.once('firstLogin', () => {
console.log('欢迎新用户!送你一个新手礼包!');
});

emitter.emit('firstLogin');  // 触发一次
emitter.emit('firstLogin');  // 这行不会输出,因为 once 只生效一次
emitter.emit('firstLogin');  // 这行也不会输出

运行结果:

欢迎新用户!送你一个新手礼包!

once 就像烟花——只绽放一次,之后就没了

2.4.6 removeListener:移除监听器

监听器用完了要清理,不然会造成内存泄漏(就像手机后台开太多 app 会卡)。

const EventEmitter = require('events');
const emitter = new EventEmitter();

function handler() {
console.log('handler 被调用了');
}

emitter.on('test', handler);  // 注册
emitter.emit('test');          // 输出:handler 被调用了

emitter.removeListener('test', handler);  // 移除
emitter.emit('test');          // 什么都没发生,因为监听器已被移除

运行结果:

handler 被调用了

🔥 实战:3 个递进项目

项目 1(5 分钟):日志监听器

场景:你写了一个爬虫,想知道什么时候抓完了。

const EventEmitter = require('events');

class Crawler extends EventEmitter {
constructor() {
    super();
    this.urls = [];
    this.completed = 0;
}

addUrl(url) {
    this.urls.push(url);
    return this;  // 支持链式调用
}

async crawl() {
    console.log(`开始抓取 ${this.urls.length} 个页面...`);

    for (const url of this.urls) {
        // 模拟抓取(实际项目里这里会是真实的网络请求)
        await new Promise(resolve => setTimeout(resolve, 100));
        this.completed++;
        this.emit('progress', this.completed, this.urls.length);
    }

    this.emit('complete', this.completed);
}
}

// 使用
const crawler = new Crawler();
crawler.addUrl('https://example.com/1')
   .addUrl('https://example.com/2')
   .addUrl('https://example.com/3');

crawler.on('progress', (done, total) => {
console.log(`进度:${done}/${total}`);
});

crawler.on('complete', (count) => {
console.log(`抓取完成!共处理 ${count} 个页面`);
});

crawler.crawl();

运行结果:

开始抓取 3 个页面...
进度:1/3
进度:2/3
进度:3/3
抓取完成!共处理 3 个页面

一句话解释:我们让爬虫继承 EventEmitter,抓完一个页面就 emit 一个 'progress' 事件,抓完全部就 emit 'complete' 事件。

项目 2(15 分钟):文件监控小工具

场景:监控一个文件夹,有新文件就自动处理(比如移动到另一个目录)。

const EventEmitter = require('events');
const fs = require('fs');
const path = require('path');

class FileWatcher extends EventEmitter {
constructor(watchDir) {
    super();
    this.watchDir = watchDir;
    this.processedFiles = new Set();
}

start() {
    console.log(`监控文件夹:${this.watchDir}`);

    fs.readdir(this.watchDir, (err, files) => {
        if (err) {
            this.emit('error', err);
            return;
        }
        files.forEach(file => this.processFile(file));
    });

    // 每 2 秒检查一次新文件(简化版,实际用 chokidar 库更好)
    this.interval = setInterval(() => {
        fs.readdir(this.watchDir, (err, files) => {
            if (err) return;
            files.forEach(file => this.processFile(file));
        });
    }, 2000);
}

processFile(filename) {
    if (this.processedFiles.has(filename)) return;

    this.processedFiles.add(filename);
    const filePath = path.join(this.watchDir, filename);

    fs.stat(filePath, (err, stats) => {
        if (err) {
            this.emit('error', err);
            return;
        }

        if (stats.isFile()) {
            this.emit('newFile', {
                name: filename,
                path: filePath,
                size: stats.size,
                time: stats.mtime
            });
        }
    });
}

stop() {
    if (this.interval) {
        clearInterval(this.interval);
        console.log('停止监控');
    }
}
}

// 使用
const watcher = new FileWatcher('./test_folder');

// 确保测试文件夹存在
if (!fs.existsSync('./test_folder')) {
fs.mkdirSync('./test_folder');
}

watcher.on('newFile', (fileInfo) => {
console.log(`检测到新文件:${fileInfo.name}(${fileInfo.size} 字节)`);
});

watcher.on('error', (err) => {
console.error('出错了:', err.message);
});

watcher.start();

// 30 秒后自动停止
setTimeout(() => {
watcher.stop();
console.log('监控已结束');
process.exit(0);
}, 30000);

预期输出(当你手动往 ./test_folder 里放文件时):

监控文件夹:./test_folder
检测到新文件:report.csv(1024 字节)
检测到新文件:image.png(2048 字节)

一句话解释:用 EventEmitter 解耦了"监控"和"处理"逻辑,新增文件时自动触发事件,你只需要写处理逻辑,不用管监控细节

项目 3(15 分钟):待办事项 + 事件通知

场景:写一个命令行待办清单,新增、完成、删除操作都触发事件,方便以后扩展(比如完成时发邮件通知)。

const EventEmitter = require('events');

class TodoList extends EventEmitter {
constructor() {
    super();
    this.todos = [];
}

add(task) {
    const todo = {
        id: Date.now(),
        task: task,
        done: false,
        createdAt: new Date().toLocaleString()
    };
    this.todos.push(todo);
    this.emit('added', todo);
    return todo.id;
}

complete(id) {
    const todo = this.todos.find(t => t.id === id);
    if (todo) {
        todo.done = true;
        todo.completedAt = new Date().toLocaleString();
        this.emit('completed', todo);
    }
    return todo;
}

remove(id) {
    const index = this.todos.findIndex(t => t.id === id);
    if (index !== -1) {
        const removed = this.todos.splice(index, 1)[0];
        this.emit('removed', removed);
        return true;
    }
    return false;
}

list() {
    return this.todos;
}
}

// 使用
const todoList = new TodoList();

// 监听各种事件
todoList.on('added', (todo) => {
console.log(`✅ 新增任务:${todo.task}`);
});

todoList.on('completed', (todo) => {
console.log(`🎉 完成任务:${todo.task}`);
// 以后可以扩展:发邮件、推送到钉钉等
});

todoList.on('removed', (todo) => {
console.log(`🗑️  删除任务:${todo.task}`);
});

// 演示
console.log('--- 当前待办 ---');
const id1 = todoList.add('写周报');
const id2 = todoList.add('回复邮件');
const id3 = todoList.add('准备会议 PPT');

console.log('\n--- 完成一个 ---');
todoList.complete(id2);

console.log('\n--- 删除一个 ---');
todoList.remove(id3);

console.log('\n--- 剩余待办 ---');
console.log(todoList.list());

运行结果:

--- 当前待办 ---
✅ 新增任务:写周报
✅ 新增任务:回复邮件
✅ 新增任务:准备会议 PPT

--- 完成一个 ---
🎉 完成任务:回复邮件

--- 删除一个 ---
🗑️ 删除任务:准备会议 PPT

--- 剩余待办 ---
[
{ id: 1732502400000, task: '写周报', done: false, createdAt: '2024/11/25 10:00:00' },
{ id: 1732502400001, task: '回复邮件', done: true, completedAt: '2024/11/25 10:00:01' }
]

一句话解释:每个操作都触发事件,将来想加新功能(比如完成时发邮件),只需要加一个监听器,不用改核心代码


💪 进阶:常见坑 + 调试技巧

坑 1:监听器重复注册

// ❌ 错误示例:每次请求都注册新的监听器
app.get('/user', (req, res) => {
emitter.on('data', handler);  // 反复注册!
});

// ✅ 正确做法:只在初始化时注册一次
emitter.on('data', handler);

坑 2:this 指向丢失

// ❌ 错误示例:箭头函数以外的回调,this 可能不对
emitter.on('data', function(info) {
this.save(info);  // this 可能是 undefined
});

// ✅ 正确做法:用箭头函数或者 bind
emitter.on('data', (info) => {
this.save(info);  // this 指向类实例
});

坑 3:内存泄漏

// ❌ 错误示例:不断添加监听器从不移除
function handle() { /* ... */ }
emitter.on('event', handle);
emitter.on('event', handle);  // 添加了两次!
emitter.on('event', handle);  // 又添加了三次!

// ✅ 正确做法:确保只注册一次,用 removeListener 清理
emitter.removeListener('event', handle);
emitter.on('event', handle);

坑 4:事件名拼写错误

// ❌ 错误示例:emit 和 on 的事件名不一致
emitter.emit('data', data);     // 发送的是 'data'
emitter.on('dta', handler);     // 监听的是 'dta'(拼错了!)

// ✅ 正确做法:统一管理事件名
const EVENTS = {
DATA: 'data',
COMPLETE: 'complete'
};
emitter.emit(EVENTS.DATA, data);
emitter.on(EVENTS.DATA, handler);

坑 5:异步 emit 的顺序问题

// ❌ 错误示例:以为 emit 后监听器立即执行完
emitter.emit('async', data);
console.log('这里可能先执行!');

// ✅ 正确做法:如果需要等待,用 async/await 或回调
async function process() {
await new Promise(resolve => {
    emitter.once('done', resolve);
    emitter.emit('async');
});
console.log('emit 的监听器执行完了,这里才执行');
}

调试技巧:打印所有监听器

// 查看某个事件有多少个监听器
console.log('data 事件的监听器数量:', emitter.listenerCount('data'));

// 列出所有监听器
console.log('所有监听器:', emitter.eventNames());

// 调试时打印每次 emit
const originalEmit = emitter.emit;
emitter.emit = function(...args) {
console.log(`[DEBUG] 触发事件:${args[0]}`, args.slice(1));
return originalEmit.apply(this, args);
};

✏️ 练习题

练习 1(2 分钟):基础 - 单次触发
- 输入:运行 emitter.once('greet', () => console.log('你好!')),然后 emit 3 次 'greet'
- 预期输出:你好!(只输出一次)
- 提示:once 的特点是只生效一次

练习 2(2 分钟):基础 - 带参数的事件
- 输入:写一个 emitter,监听 'sum' 事件,回调接收两个数并打印它们的和,然后 emit 两次:第一次传 3 和 5,第二次传 10 和 20
- 预期输出:830
- 提示:emit 的参数会传递给回调函数

练习 3(3 分钟):进阶 - 文件大小统计
- 输入:用项目 2 的 FileWatcher 模式,监控一个文件夹,统计所有文件的总大小
- 预期输出:总大小:XXX 字节
- 提示:利用 'newFile' 事件的 fileInfo.size 进行累加

练习 4(5 分钟):进阶 - 待办 + 事件组合
- 输入:基于项目 3 的 TodoList,添加一个新功能:每当完成任务时,如果该任务是"紧急"的,触发一个 'urgent' 事件
- 预期输出:完成紧急任务时,除了触发 'completed',还要触发 'urgent'
- 提示:在 add 方法里给 todo 加一个 urgent 字段,在 complete 方法里检查这个字段

练习 5(3 分钟):挑战 - 报错分析
- 输入:下面的代码运行后会输出什么?为什么?

const emitter = new EventEmitter();
emitter.on('e', () => console.log('第一次'));
emitter.on('e', () => console.log('第二次'));
emitter.emit('e');
emitter.removeAllListeners('e');
emitter.emit('e');
  • 预期输出:需要你自己分析
  • 提示:第二次 emit 为什么没有输出?

作业:做一个「智能文件分类器」

  • 需求描述:监控一个文件夹,每当有新文件时,根据文件扩展名自动移动到对应分类文件夹
  • 功能点
    1. 图片(.jpg/.png/.gif)→ 移动到 images/ 文件夹
    2. 文档(.pdf/.docx/.txt)→ 移动到 documents/ 文件夹
    3. 其他文件 → 移动到 others/ 文件夹
  • 加分项
    1. 用 EventEmitter 的 'move' 事件记录每次移动操作
    2. 移动前检查目标文件夹是否存在,不存在则自动创建
  • 验收标准:能跑起来 + 把测试文件正确分类 + 控制台显示移动日志
  • 提交方式:评论区贴代码或 GitHub 链接

📚 总结

学完这一章,你掌握了:

  • EventEmitter:Node.js 的事件触发核心类
  • on / once / emit / removeListener:注册、触发、移除监听器的完整 API
  • 自定义事件类:让类具备发布-订阅能力,解耦业务逻辑

下一章我们要做一个文件批量重命名工具,你会发现 events 模块能帮我们把「监控文件变化」和「执行重命名」解耦开来,让代码更清晰。敬请期待!


你在项目里用过 events 模块吗?遇到过什么坑?评论区聊聊,老粉优先回复!

延伸学习:
- 官方文档:https://nodejs.org/api/events.html
- 深入阅读:《Node.js 实战:用事件驱动架构构建高性能应用》

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。