EventEmitter؛ قرارداد رویداد و پاکسازی شنونده
در این درس ۶ بخش
فرض کنید در چت آموزشی، پس از پذیرفتهشدن پیام باید دو کار انجام شود: افزودن یک سطر به گزارش و بهروزرسانی شمارنده. اگر تابع پذیرش، جزئیات تمام این کارها را بشناسد، با اضافهشدن هر قابلیت تغییر میکند. EventEmitter ابزاری درون فرایند Node.js است که اعلام یک اتفاق را از واکنش به آن جدا میکند. این ابزار پیام را به مرورگر نمیفرستد و جای اتصال شبکه یا پایگاه داده را نمیگیرد؛ در همین برنامه، چند تابع را به یک نام رویداد مرتبط میکند.
هدف این درس، حفظکردن چند متد نیست. میخواهیم بدانیم چه کسی شنونده را میسازد، چه موقع باید آن را بردارد و خطای یک واکنش چگونه دیده میشود. یک پوشهٔ آزمایشی جدا بسازید و فایلها را با پسوند mjs ذخیره کنید. مثالها فقط از کتابخانهٔ داخلی Node.js استفاده میکنند و نصب بسته لازم ندارند. در ترمینال همان پوشه، node --version را اجرا کنید؛ مبنای این دوره نسخهٔ ۲۴ یا بالاتر است. دستورها را داخل فایل جاوااسکریپت ننویسید.
رویداد یک قرارداد است؛ نام و شکل داده را مشخص کنید
رویداد accepted در این تمرین یعنی مدل، دادهای را پذیرفته است. شنونده یا listener تابعی است که برای آن نام ثبت میکنیم؛ انتشار یا emit فراخواندن واکنشهای همان نام است. دادهٔ رویداد در این مثال شیئی با id است. اگر یک بخش برنامه بهجای آن رشته بفرستد، نام درست رویداد جلوی خرابی قرارداد را نمیگیرد. پیش از افزودن شنونده، بنویسید رویداد در چه مرحلهای رخ میدهد و کدام فیلدها قطعی هستند. accepted نباید گاهی «دکمه زده شد» و گاهی «ذخیره تمام شد» معنی بدهد.
کد بعدی را در event-order.mjs بگذارید و node event-order.mjs را اجرا کنید. on برای دفعات بعدی باقی میماند؛ once تنها نخستین انتشار را میشنود. آرایهٔ trace ترتیب واقعی واکنشها را ثبت میکند. assert بخشی از ابزار آزمون داخلی است؛ اگر انتظار برقرار نباشد، اجرای برنامه با خطای آزمون متوقف میشود. بنابراین خروجی فقط یک نمایش ظاهری نیست و ترتیب مورد نظر نیز بررسی میشود.
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
const bus = new EventEmitter();
const trace = [];
bus.on('accepted', item => trace.push(`log:${item.id}`));
bus.once('accepted', item => trace.push(`first:${item.id}`));
assert.equal(bus.emit('accepted', { id: 'm1' }), true);
bus.emit('accepted', { id: 'm2' });
assert.deepEqual(trace, ['log:m1', 'first:m1', 'log:m2']);
assert.equal(bus.emit('missing'), false);
console.log(trace.join(' | '));خروجی باید log:m1 | first:m1 | log:m2 باشد. در این مثال شنوندهها هنگام emit و به ترتیب ثبت اجرا میشوند؛ نام رویداد کار را خودکار به صف جدا یا نخ دیگر منتقل نمیکند. مقدار true برگشتی به معنی وجود شنونده است، نه تأیید ذخیره در دیسک یا رسیدن پیام به کاربر. برای نام missing هیچ شنوندهای نداریم و false میگیریم. از این مقدار برای نوشتن برچسب «تحویل شد» در رابط چت استفاده نکنید؛ تأیید کاربردی به قرارداد جداگانه نیاز دارد.
مالک شنونده باید راه پاکسازی آن را هم داشته باشد
اکنون فرض کنید صفحهٔ گزارش صد بار باز و بسته میشود. اگر هر بار شنوندهٔ تازه ثبت شود ولی هنگام بستن برداشته نشود، واکنشهای تکراری و ارجاعهای بیمصرف باقی میمانند. این مشکل ممکن است ابتدا فقط به شکل دو بار چاپشدن یک پیام دیده شود. تعریف تابع تازه با همان متن، همان ارجاع تابع قبلی نیست؛ برای off باید همان تابع ثبتشده را بدهید. در واژهنامه، #function و #function-expression-callback به فهم همین تفاوت تابع و فراخوانی کمک میکنند.
فایل مستقل event-cleanup.mjs را با کد زیر بسازید. attachCounter یک شنونده نصب میکند و تابع dispose را به مالک برمیگرداند. dispose را در پایان چرخهٔ استفاده صدا میزنیم؛ اجرای دوبارهٔ آن آسیبی ندارد. در یک برنامهٔ واقعی این نقطه میتواند بستن اتصال، خروج از صفحه یا پایان کار آزمایشی باشد. وجود return برای واگذاری مسئولیت مهم است: مصرفکننده دقیقاً میداند کدام منبع را ساخته و چگونه باید آزادش کند.
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
const bus = new EventEmitter();
const seen = [];
function attachCounter(emitter, target) {
function onAccepted(item) { target.push(item.id); }
emitter.on('accepted', onAccepted);
let disposed = false;
return () => {
if (disposed) return;
disposed = true;
emitter.off('accepted', onAccepted);
};
}
const baseline = bus.listenerCount('accepted');
for (let i = 0; i < 100; i++) {
const dispose = attachCounter(bus, seen);
bus.emit('accepted', { id: `m${i}` });
dispose();
dispose();
}
bus.emit('accepted', { id: 'after-dispose' });
assert.equal(bus.listenerCount('accepted'), baseline);
assert.equal(seen.length, 100);
console.log(`listeners=${bus.listenerCount('accepted')}; seen=${seen.length}`);با node event-cleanup.mjs باید listeners=0; seen=100 ببینید. انتشار نهایی نباید مقدار تازهای به seen اضافه کند. این آزمون تنها شمارندهٔ نمایش را بررسی نمیکند؛ تعداد شنوندهها نیز به مقدار اولیه برمیگردد. اگر برنامهٔ میزبان از قبل شنوندهای داشته باشد، baseline ممکن است صفر نباشد و باید همان مقدار حفظ شود. استفاده از removeAllListeners برای راحتی، شنوندههای بخشهای دیگر را نیز حذف میکند. پاکسازی خوب فقط منبعی را برمیدارد که همان بخش مالک آن است.
اگر هشدار MaxListenersExceededWarning دیدید، نخست محل ثبت و حذف را بررسی کنید. این هشدار نشانهای برای بررسی رشد تعداد شنوندههاست و به معنی ممنوعبودن همهٔ طراحیهای دارای شنوندهٔ زیاد نیست. بالابردن سقف یا گذاشتن صفر، علتِ ثبت بیپایان را اصلاح نمیکند. برای عیبیابی، شمارش را پیش و پس از چند چرخه ثبت کنید و نام رویداد را نیز کنار آن بنویسید. در چت، حذف عضو و پاککردن تایمر هنگام close همین نگاه مالکیت را ادامه میدهد.
خطای همزمان و شکست Promise را با هم اشتباه نگیرید
رویداد error در EventEmitter رفتار ویژه دارد: انتشار آن بدون شنوندهٔ مناسب میتواند خطا پرتاب کند. از طرف دیگر، emit معمولی منتظر Promise برگشتی شنوندهٔ async نمیماند. پس try/catch دور emit بهتنهایی راه گرفتن تمام شکستهای آینده نیست. در قرارداد #promise و #throw-error زمان وقوع شکست اهمیت دارد. میتوان امکاناتی مانند captureRejections را آگاهانه انتخاب کرد؛ برای این مثال، مسیر Promise را صریح میگیریم تا معلوم باشد چه تابعی مسئول رسیدگی است.
کد بعدی را مستقل در event-async.mjs ذخیره کنید. تابع save عمداً با دادهٔ ناقص شکست میخورد. catch این شکست را به error تبدیل میکند و شنوندهٔ error یک ثبت ساده و همزمان انجام میدهد. علامت void نتیجه را جادویی امن نمیکند؛ اینجا رسیدگی واقعی همان catch است. از یک شنوندهٔ error که خودش بیرسیدگی Promise ردشده میسازد استفاده نکنید، چون مسیر گزارش خطا نیز به یک مسیر شکست تازه تبدیل خواهد شد.
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { setImmediate as nextTurn } from 'node:timers/promises';
const bus = new EventEmitter();
const trace = [];
bus.on('error', error => { trace.push(`error:${error.message}`); });
async function save(item) {
if (!item.id) throw new Error('Bad item');
return item.id;
}
bus.on('accepted', item => {
void save(item).catch(error => bus.emit('error', error));
});
bus.emit('accepted', {});
trace.push('after emit');
await nextTurn();
assert.deepEqual(trace, ['after emit', 'error:Bad item']);
console.log(trace.join(' | '));پس از node event-async.mjs باید after emit | error:Bad item را ببینید. این ترتیب نشان میدهد پایان emit برابر پایان کار ناهمزمان نیست. انتظار nextTurn فقط به آزمون فرصت مشاهدهٔ رسیدگی میدهد؛ در منطق واقعی، تأیید ذخیره را با حدس زمان یا چند میلیثانیه تأخیر نسازید. اگر فراخواننده باید نتیجهٔ عملیات را بداند، اغلب بازگرداندن مستقیم Promise از تابع سرویس روشنتر است. رویداد برای اعلام اتفاق و Promise برای دریافت نتیجهٔ یک عملیات، مسئولیتهای متفاوتی دارند.
مرزهای کوچک اما اثرگذار در بازبینی کد
دادهٔ مشترک رویداد را بیدلیل در شنونده تغییر ندهید. اگر شنوندهٔ اول item.id را عوض کند، شنوندهٔ بعدی ممکن است مقدار تازه را ببیند و گزارش شما به ترتیب ثبت وابسته شود. قرارداد را ترجیحاً فقطخواندنی نگه دارید و برای تغییر مدل، مسیر صریح داشته باشید. همچنین برداشتن شنونده در میانهٔ یک emit را معادل لغو واکنشهای همان انتشار فرض نکنید؛ حذف، ثبتهای آینده را مدیریت میکند. برای لغو کار طولانی، ابزار و قرارداد مستقل نیاز است.
برای هر شنونده سه پرسش ثبت کنید: چه کسی آن را ساخته، کدام داده را میخواند و چه رخدادی عمرش را تمام میکند؟ اگر پاسخ سوم «هیچوقت» است، بررسی کنید آیا واقعاً همعمر خود سرور است. شنوندهٔ سراسری عمر بلند دارد، ولی شنوندهای که به یک اتصال موقت اشاره میکند باید با پایان آن آزاد شود. نامگذاری واضح و یک تابع پاکسازی کوچک معمولاً از افزودن چند لایهٔ عمومی پیچیده برای همین مسئله مفیدتر است.
تمرین با پاسخ راهنما و معیار تحویل
attachCounter را تغییر دهید تا فقط شناسههایی با پیشوند m را بپذیرد و تعداد موارد ردشده را نیز در شیء جدا بشمارد. دو اتصال آزمایشی با target جدا بسازید، یکی را پاک کنید و دوباره رویداد بفرستید. انتظار را پیش از اجرا بنویسید: اتصال باقیمانده هنوز باید واکنش نشان دهد و اتصال بسته نباید دادهٔ تازه بگیرد. پاسخ راهنما این است که هر نصب، تابع onAccepted خودش را داشته باشد؛ از تابعی مشترک که وضعیت آخرین اتصال را بازنویسی میکند استفاده نکنید.
برای پاسخ کامل، آزمون مقدار، آزمون تعداد شنونده و آزمون اجرای دوبارهٔ dispose را نگه دارید. سپس خطای عمدی save را با دادهٔ معتبر جایگزین کنید و توضیح دهید چرا دیگر error ثبت نمیشود. معیار یادگیری این است که بتوانید عمر شنونده و مسیر شکست را روی کد نشان دهید. اگر فقط خروجی نمونه را کپی کردهاید ولی نمیدانید dispose کجا صدا زده میشود، بخش اصلی مسئله هنوز حل نشده است.
برای مرور پیشنیازها، درس «ماژول و سازماندهی فایلها» و درس کار ناهمزمان دورهٔ Node.js قبلی را بخوانید؛ اینجا نحو import یا مفهوم اولیهٔ Promise را از نو تدریس نمیکنیم.
ماژولهای Node.js: https://www.darsavand.com/courses/nodejs-windows-backend-start?lesson=95a58da2-0429-4299-a676-ed15589c1e4f
کار ناهمزمان: https://www.darsavand.com/courses/nodejs-windows-backend-start?lesson=852c12c3-e66d-4c16-91b3-166f3d5d6ae2
منابع رسمی و مطالعهٔ تکمیلی
Node.js Events: https://nodejs.org/api/events.html
Node.js Learn، EventEmitter: https://nodejs.org/en/learn/asynchronous-work/the-nodejs-event-emitter
تألیف و مثالهای اختصاصی: تحریریه درسآوند.
تألیف و مثالهای اختصاصی: تحریریه درسآوند. فایلهای کامل پروژه و راهنمای اجرا و دیباگ:
https://www.darsavand.com/resources/simple-websocket-chat-project
متن درسهای عمومی را بدون ورود بخوانید.