چرخهٔ عمر منابع؛ لغو، تایمر و پاکسازی رویدادها
در این درس ۵ بخش
یک صفحهٔ چت فقط تا وقتی دیده میشود کار نمیکند؛ ممکن است کاربر اتصال را قطع کند، دوباره وصل شود یا صفحه را ترک و بعد باز کند. اگر هر بار یک تایمر و یک شنوندهٔ تازه بسازیم و قبلیها را فراموش کنیم، برنامه رفتار تکراری و مصرف بیدلیل پیدا میکند. این درس از حذف یک رویداد ساده عبور میکند و مالکیت مجموعهای از منابع را بررسی میکند. منبع در اینجا چیزی است که پس از ساختن، مسئولیت پایاندادن به آن را داریم: اتصال، تایمر، شنونده یا یک عملیات در انتظار. هدف، ساختن چرخهای است که آغاز و پایانش بتواند چند بار بدون اثر ناخواسته تکرار شود.
هر منبع باید یک صاحب و یک نقطهٔ پایان داشته باشد
پیش از کدنویسی، روی کاغذ بنویسید چه چیزی در طول عمر صفحه باقی میماند و چه چیزی فقط متعلق به یک اتصال است. فرم ورود ممکن است تا پایان صفحه یک شنونده داشته باشد؛ اما تایمر تلاش دوباره متعلق به تصمیم فعلی اتصال است و هنگام خروج باید لغو شود. تایمر انتظار تأیید پیام نیز فقط تا روشنشدن نتیجهٔ همان پیام معنی دارد. پنهانکردن کارت در DOM، خود اتصال را نمیبندد. برنامه باید صریحاً بگوید هنگام «قطع ارتباط» چه چیزهایی آزاد میشوند و کدام دادهٔ متنی برای تصمیم بعدی کاربر حفظ میشود.
برای شنوندههایی که از signal پشتیبانی میکنند، AbortController میتواند چند عضویت را با یک تصمیم پایان دهد. نمونهٔ زیر به DOM نیاز ندارد؛ در Node.js ۲۴ یا Console مرورگر جدید مستقل اجرا میشود. EventTarget یک هدف رویداد کوچک برای آزمایش میسازد. اینجا بهجای دستکاری صفحهٔ اصلی، فقط تعداد دریافت رویداد را میشماریم. مدخل #event-listener پیشنیاز ثبت رویداد را مرور میکند؛ نکتهٔ تازه این است که لغو به یک دامنهٔ مالکیت تعلق دارد و بعد از آن نباید callback قدیمی دوباره رفتار فعال صفحه را تغییر دهد.
const target = new EventTarget();
function subscribe(target) {
const controller = new AbortController();
let count = 0;
target.addEventListener('sample', () => { count += 1; }, {
signal: controller.signal
});
return { count: () => count, dispose: () => controller.abort() };
}
const subscription = subscribe(target);
target.dispatchEvent(new Event('sample'));
subscription.dispose();
subscription.dispose();
target.dispatchEvent(new Event('sample'));
console.log(subscription.count()); // 1dispose نام قراردادی ما برای پایاندادن به این اشتراک است؛ کلمهٔ جادویی زبان نیست. دو بار فراخوانی آن نباید شمارنده را برگرداند یا عضویت تازه بسازد. بعد از abort، همین signal را برای یک اتصال تازه دوباره استفاده نمیکنیم؛ دامنهٔ تازه controller تازه میخواهد. همچنین AbortController فرمان عمومی «توقف هر چیز» نیست. API باید آن signal را پشتیبانی کند. این ابزار بهخودیخود یک WebSocket را نمیبندد یا setTimeout قبلی را پاک نمیکند؛ برای آنها به close و clearTimeout نیاز داریم.
پاکسازی را تکرارپذیر و کامل طراحی کنید
وقتی چند منبع متعلق به یک کار هستند، میتوانیم عمل آزادکردنشان را کنار هم ثبت کنیم. نمونهٔ زیر یک الگوی آموزشی مستقل است، نه جایگزین کامل کد کلاینت درس آخر. عملیات پاکسازی در ترتیب معکوس ثبت اجرا میشوند؛ مشابه جمعکردن ابزارهایی که آخرینبار باز کردهایم. اگر یکی شکست خورد، تلاش برای بقیه ادامه دارد و در پایان خطاهای جمعشده گزارش میشوند. این رفتار از catch خالی بهتر است، چون هم منابع دیگر فراموش نمیشوند و هم علت ناپدید نمیشود. #try-catch در اینجا باید به پایان درست کار کمک کند، نه فقط پیام خطا را مخفی کند.
function createScope() {
let closed = false;
const cleanups = [];
return {
use(cleanup) {
if (closed) cleanup();
else cleanups.push(cleanup);
},
close() {
if (closed) return;
closed = true;
const errors = [];
for (const cleanup of cleanups.reverse()) {
try { cleanup(); } catch (error) { errors.push(error); }
}
cleanups.length = 0;
if (errors.length) throw new AggregateError(errors, 'Cleanup failed');
}
};
}
const scope = createScope();
const log = [];
scope.use(() => log.push('first'));
scope.use(() => log.push('second'));
scope.close();
scope.close();
console.log(log); // ['second', 'first']در پروژهٔ کوچک ممکن است توابع روشن و جداگانهٔ پاکسازی از یک ابزار عمومی خواناتر باشند؛ هدف، افزودن یک لایهٔ پیچیده به هر دکمه نیست. مهم این است که بعد از آغاز دوباره، هنوز تنها یک اتصال فعال، یک برنامهٔ تلاش دوباره و مجموعهٔ مشخصی از انتظارها داشته باشیم. اگر تابع use را پس از بستهشدن دامنه صدا میزنیم، در مثال فوراً همان منبع را آزاد میکند؛ منبع دیررس نباید بیصاحب بماند. در بازبینی کد، کنار هر setTimeout و addEventListener، صاحب و فرمان آزادسازیاش را پیدا کنید.
لغو تایمر کافی نیست؛ نتیجهٔ قدیمی را هم مهار کنید
گاهی کار قبلی تمام شده یا callback آن در صف اجراست؛ بنابراین فقط پاککردن شناسهٔ تایمر برای توضیح همهٔ رفتارهای دیررس کافی نیست. یک شمارهٔ نسل میتواند مشخص کند نتیجه هنوز متعلق به نشست فعلی است یا نه. در مثال بعد، دو عملیات ساختگی شروع میشوند؛ نتیجهٔ دومی زودتر میرسد و اولی بعداً نباید برچسب تازه را بازنویسی کند. این همان اصل مهار نتیجهٔ قدیمی است، اما این بار آن را برای عمر نشست و اتصال به کار میبریم. لغو منبع و بیاثرکردن نتیجهٔ قدیمی، دو کنترل مکملاند.
let generation = 0;
const visible = [];
function startSample(label, delay) {
const mine = ++generation;
return new Promise(resolve => setTimeout(resolve, delay)).then(() => {
if (mine !== generation) return;
visible.push(label);
});
}
await Promise.all([startSample('old', 20), startSample('new', 0)]);
console.log(visible); // ['new']این کد، کار قدیمی را از نظر مصرف منابع متوقف نکرده است؛ هر دو تایمر اجرا شدهاند، ولی تنها نسل جاری اجازهٔ اثر دارد. همین مرز را در ادعاهای خود نگه دارید. در کلاینت WebSocket، میتوان هویت socket متعلق به handler را با اتصال فعلی مقایسه کرد تا close یا message اتصال قبلی وضعیت اتصال تازه را عوض نکند. عوضکردن یک متغیر سراسری بدون این بررسی، ممکن است با چند بار کلیک سریع باعث نمایش «قطع شد» روی اتصال سالم شود. برای آزمایش، عمداً آغاز و پایان را پشتسرهم تکرار کنید.
تلاش دوباره باید محدود و قابل لغو باشد
برای اتصال مجدد، setInterval بیوقفه انتخاب مناسبی نیست؛ تصمیم هر تلاش باید به نتیجهٔ قبلی و وضعیت کاربر وابسته باشد. تأخیر افزایشی به برنامه فرصت میدهد و jitter اندکی زمان تلاش کاربران را از هم جدا میکند. بااینحال، retry نامحدود یا ادامهٔ آن پس از خروج کاربر قابل قبول نیست. در پروژهٔ چت شمار تلاشهای خودکار محدود است و دکمهٔ اتصال دستی باقی میماند. تایمر retry تنها زمان شروع یک تلاش بعدی را تعیین میکند؛ زمانبندی JavaScript دقیق و تضمینشده نیست و تب پسزمینه ممکن است دیرتر callback را اجرا کند.
function retryDelay(attempt, random = Math.random) {
if (!Number.isInteger(attempt) || attempt < 0 || attempt >= 5) {
throw new RangeError('No automatic attempt left');
}
return Math.min(10000, 500 * 2 ** attempt + Math.floor(random() * 251));
}
console.log([0, 1, 2, 3, 4].map(n => retryDelay(n, () => 0)));
// [500, 1000, 2000, 4000, 8000]این تابع صرفاً محاسبهٔ تأخیر است و اتصال واقعی نمیسازد. تزریق random در آزمون کمک میکند نتیجهٔ نمونه قابل تکرار باشد. مقدار تأخیر را با شمار تلاش یا شناسهٔ تایمر اشتباه نگیرید. هنگام خروج کاربر، تایمر زمانبندیشده را پاک میکنیم و پرچم خواستِ اتصال را خاموش میکنیم تا رویداد close بعدی، برنامه را دوباره وصل نکند. هنگام ترک صفحه نیز منبع باید آزاد شود؛ pagehide برای این موضوع مفید است، اما همهٔ خروجهای ناگهانی سیستمعامل را تضمین نمیکند. پس طراحی سرور هم باید اتصال مرده را تشخیص دهد.
تمرین با پاسخ و مسیر مرور
فرض کنید پس از سه بار اتصال دستی، با یک پیام سه خط مشابه ظاهر میشود. نخست شمار عضویت handlerها و اتصالهای زنده را بررسی کنید، نه سرعت اینترنت را. تمرین دوم: کاربر «قطع ارتباط» زده ولی یک ثانیه بعد دوباره وصل شده است؛ تایمر retry و خواستِ اتصال را دنبال کنید. پاسخ راهنما این است که پایان کار، باید هم منبع موجود و هم اجازهٔ ساخت منبع بعدی را ببندد. معیار تحویل، اجرای رفتوبرگشت آغاز، پایان و آغاز با تنها یک رفتار فعال است؛ پنهانشدن کارت در صفحه این معیار را اثبات نمیکند.
برای پاکسازی رابط و ارجاع عناصر، این درس پیشنیاز است: https://www.darsavand.com/courses/javascript-project-lab?lesson=a59f2b3d-0042-48c8-9db8-e3cb10697e55
برای لغو درخواست و تشخیص نتیجهٔ قدیمی نیز این آموزش را مرور کنید: https://www.darsavand.com/courses/javascript-project-lab?lesson=d08ad69c-8701-447f-8f78-d44d9e14b30d
در درس حاضر، #timers را بهعنوان منبع متعلق به نشست شناختیم. در ادامه همین مالکیت را به پیامِ منتظر تأیید وصل میکنیم؛ پاکشدن تایمر، بهتنهایی ثابت نمیکند پیام به سرور رسیده یا نرسیده است.
منابع رسمی انگلیسی:
MDN addEventListener signal: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
MDN AbortController: https://developer.mozilla.org/en-US/docs/Web/API/AbortController
MDN clearTimeout: https://developer.mozilla.org/en-US/docs/Web/API/Window/clearTimeout
MDN pagehide: https://developer.mozilla.org/en-US/docs/Web/API/Window/pagehide_event
MDN WebSocket close: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close
تألیف، مثالها و تمرینها: تحریریه درسآوند. منابع رسمی انگلیسی برای راستیآزمایی رفتار ابزارها و مطالعهٔ بیشتر معرفی شدهاند.
تألیف و مثالهای اختصاصی: تحریریه درسآوند. فایلهای کامل پروژه و راهنمای اجرا و دیباگ:
https://www.darsavand.com/resources/simple-websocket-chat-project
متن درسهای عمومی را بدون ورود بخوانید.