توسعه فرانت‌اند

JavaScript Closures: درک عمیق و کاربردها

تفاوت Closure و Snapshot، محیط حلقه، Factory، Cache و Cleanup؛ نمونه کامل دو شمارنده مستقل با Listener و روش اجرا.

1404/12/09 3 دقیقه مطالعه 1,300 بازدید

Closure رابطه یک تابع با محیط واژگانی محل تعریف آن است. تابع می‌تواند به متغیرهای آن محیط دسترسی داشته باشد، حتی وقتی اجرای تابع بیرونی تمام شده باشد. این دسترسی را نباید یک عکس ثابت از همه مقدارها فرض کرد. اگر همان Binding تغییر کند، تابع هنگام خواندن آن مقدار جاری را می‌بیند. فهم این تفاوت برای Event Handler، Timer و State محلی مهم است و بسیاری از رفتارهای ظاهراً عجیب را با یک مدل روشن توضیح می‌دهد.

برای مثال فرضی، تابع سازنده شمارنده یک متغیر عددی و دو فرمان می‌سازد. فرمان افزایش مقدار را عوض می‌کند و فرمان خواندن همان Binding را می‌خواند. فراخوانی دوباره سازنده، محیط دیگری می‌سازد؛ دو شمارنده نباید ناخواسته یک مقدار مشترک داشته باشند. Closure به این جداسازی کمک می‌کند، اما هر استفاده از آن به معنی طراحی خوب یا حافظه کمتر نیست. محل ساخت تابع و منابعی که به آن متصل‌اند باید معلوم باشند.

Scope به محل تعریف مربوط است

راهنمای Closure در MDN محیط واژگانی و رابطه تابع با Scope بیرونی را توضیح می‌دهد. تابع برای پیدا کردن نام‌ها به محیط تعریف خود وابسته است، نه به این که چه کسی آن را صدا زده است. اگر تابعی را به ماژول دیگری بدهید، صرف فراخوانی در آن ماژول متغیرهای محلی فراخواننده را در اختیارش نمی‌گذارد. این تفاوت با تصور «هر تابع متغیرهای نزدیک هنگام اجرا را می‌بیند» مهم است.

نام یکسان در Scope داخلی می‌تواند نام بیرونی را بپوشاند. برای خوانایی، از Shadowing بی‌دلیل در تابع‌های تودرتو پرهیز کنید؛ چند count یا data نزدیک به هم پیدا کردن مقدار واقعی را سخت می‌کنند. Closure محدود به تابع برگشتی نیست. Event Handler یا Callbackی که در محیطی تعریف شده هم می‌تواند همان رابطه را داشته باشد. اصطلاح «ذخیره شدن متغیر پس از پایان» فقط یک اثر قابل مشاهده از این مدل است و تعریف کامل آن نیست.

Binding زنده را از مقدار ثبت‌شده جدا کنید

اگر Handler از متغیر مشترک label می‌خواند، تغییر بعدی label در همان محیط روی خواندن بعدی اثر دارد. اگر در زمان ساخت Handler یک مقدار را در متغیر مستقل ثبت کنید، رابطه متفاوتی می‌سازید. هر دو رفتار ممکن است لازم باشند: نمایش وضعیت جاری یا حفظ تنظیمات هنگام شروع کار. تصمیم را با نیاز مشخص کنید. نام «snapshot» برای متغیری که همچنان از Binding مشترک می‌خواند گمراه‌کننده است و تحلیل خطا را به مسیر نادرست می‌برد.

در درخواست async، Callback ممکن است دیرتر اجرا شود و متغیر بیرونی تا آن زمان تغییر کرده باشد. اگر عملیات باید با شناسه شروع مرتبط بماند، همان شناسه را در محیط مناسب ثبت کنید. اگر باید وضعیت تازه را بخواند، حفظ Binding جاری می‌تواند درست باشد. این تفاوت به Race Condition نزدیک است، اما Closure به تنهایی تمام مسئله ترتیب پاسخ‌ها را حل نمی‌کند. مقاله Event Loop درباره زمان اجرای Callbackها زمینه تکمیلی دارد.

حلقه و محیط هر تکرار را بشناسید

در حلقه دارای var، Callbackها ممکن است یک Binding مشترک را بخوانند و پس از پایان حلقه مقدار نهایی را ببینند. let در ساختار حلقه مناسب می‌تواند برای تکرارها Bindingهای جدا فراهم کند. این رفتار را با «let همیشه مقدار را کپی می‌کند» توضیح ندهید؛ قاعده به Scope و ساختار حلقه مربوط است. قبل از اصلاح، بپرسید Callback باید شماره همان تکرار را ببیند یا شمارنده جاری کل حلقه را. پاسخ، محیط مناسب را تعیین می‌کند.

زمان اجرای Callback نیز مهم است. تابعی که همان لحظه در حلقه اجرا می‌شود با تابعی که بعداً روی کلیک اجرا می‌شود رفتار زمانی یکسانی ندارد. برای خطایابی، محل تعریف، Binding خوانده‌شده و زمان تغییر آن را کنار هم بنویسید. افزودن Timer جدید یا مقدار پیش‌فرض به امید حل مشکل، رابطه را روشن نمی‌کند. گاهی انتقال مقدار لازم به پارامتر سازنده Handler از تودرتویی بیشتر خواناتر است و نقش داده ثبت‌شده را صریح می‌کند.

نمونه کامل دو شمارنده مستقل

فایل زیر را closure-demo.html ذخیره و مستقیم در مرورگر باز کنید. همه HTML و JavaScript لازم در همان فایل هستند و وابستگی خارجی وجود ندارد. دو پنل از createCounter ساخته می‌شوند؛ اولی از صفر و دومی از ده شروع می‌کند. برای هر پنل، دکمه افزایش و Reset به همان محیط متصل‌اند. دکمه توقف، Listenerهای هر دو پنل را برمی‌دارد. این نمونه داده‌ای را به Server نمی‌فرستد و عددها فقط مقدار آموزشی اولیه هستند.

<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>دو Closure مستقل</title><style>body{font:1rem/1.8 Tahoma,sans-serif;max-width:40rem;margin:2rem auto;padding:1rem}section{border:2px solid #111;margin:1rem 0;padding:1rem}button{font:inherit;padding:.5rem;margin:.3rem}button:focus-visible{outline:3px solid #111;outline-offset:3px}</style></head><body><h1>دو شمارنده</h1><section id="first"><h2>شروع از صفر</h2><p data-output role="status"></p><button data-increment type="button">افزایش</button><button data-reset type="button">Reset</button></section><section id="second"><h2>شروع از ده</h2><p data-output role="status"></p><button data-increment type="button">افزایش</button><button data-reset type="button">Reset</button></section><button id="stop" type="button">توقف هر دو پنل</button><p id="notice" role="status"></p><script>
function createCounter(initial){let value=initial;return {read:()=>value,increment:()=>++value,reset:()=>{value=initial}}}
function mountPanel(panel,initial){const counter=createCounter(initial),output=panel.querySelector('[data-output]'),increment=panel.querySelector('[data-increment]'),reset=panel.querySelector('[data-reset]');const render=()=>{output.textContent='مقدار جاری: '+counter.read()};const increase=()=>{counter.increment();render()};const restart=()=>{counter.reset();render()};increment.addEventListener('click',increase);reset.addEventListener('click',restart);render();return ()=>{increment.removeEventListener('click',increase);reset.removeEventListener('click',restart);increment.disabled=true;reset.disabled=true}}
const disposeFirst=mountPanel(document.getElementById('first'),0),disposeSecond=mountPanel(document.getElementById('second'),10);
document.getElementById('stop').onclick=event=>{disposeFirst();disposeSecond();event.currentTarget.disabled=true;document.getElementById('notice').textContent='Listenerها برداشته شدند؛ برای شروع دوباره صفحه را تازه کنید.'};
</script></body></html>

createCounter متغیر value را در محیط خود نگه می‌دارد و object دارای فرمان برمی‌گرداند. این object خود value را به عنوان Property عمومی ارائه نمی‌کند. read مقدار جاری را می‌خواند، increment آن را تغییر می‌دهد و reset به initial همان نمونه برمی‌گرداند. با ساخت نمونه دوم، محیط جدا ایجاد می‌شود. افزایش پنل اول نباید مقدار پنل دوم را تغییر دهد. این رابطه از محل ساخت متغیر داخل تابع می‌آید، نه از متفاوت بودن نام دکمه‌ها.

mountPanel، عنصرهای همان پنل را می‌گیرد و دو Handler می‌سازد. Handlerها هم Counter و هم عنصر خروجی را از محیط خود می‌خوانند. تابع dispose همان ارجاع Handlerها را برای removeEventListener استفاده می‌کند و دکمه‌ها را غیرفعال می‌کند. ساخت یک تابع تازه با ظاهر مشابه برای Remove، Listener قبلی را حذف نمی‌کند؛ هویت تابع مهم است. دکمه توقف همچنین متن وضعیت را تغییر می‌دهد تا کاربر بداند کنترل‌ها عمداً متوقف شده‌اند.

Data Privacy مرز امنیت کامل نیست

قرار ندادن مقدار در object عمومی می‌تواند دسترسی اتفاقی را محدود و قرارداد را کوچک کند. مصرف‌کننده به جای تغییر مستقیم عدد از فرمان استفاده می‌کند. اما کد داخل مرورگر محیط مخفی قابل اعتماد برای نگه داشتن رمز نیست. مقدار و کد Client می‌توانند برای کاربر قابل بررسی باشند. Closure نباید به عنوان راه حفاظت Secret سرویس معرفی شود. مقاله طراحی API درباره مرز داده و مجوز Server به همین محدودیت مربوط است.

فرمان‌ها باید قواعد دامنه را اجرا کنند. اگر شمارنده حد مجاز دارد، فقط disabled کردن دکمه کافی نیست؛ increment هم باید آن حد را بررسی کند. در طراحی ماژول، خروجی فقط امکانات لازم را ارائه کند. برگرداندن همه متغیرها برای راحتی Debug می‌تواند قرارداد را ناخواسته بزرگ کند. TypeScript می‌تواند شکل این خروجی را روشن کند، اما همچنان رفتار فرمان باید درست باشد. محدود کردن نام‌های عمومی با معتبر کردن عملیات یک کار واحد نیست.

Factory، Currying و Memoization هدف متفاوت دارند

Function Factory با تنظیمات اولیه تابع دیگری می‌سازد؛ مثلاً Formatterی که واحد یا زبان مشخص دارد. Currying ورودی‌ها را در چند مرحله دریافت می‌کند. این ابزارها می‌توانند قرارداد خوانا بسازند، ولی چند لایه تابع برای دو پارامتر ساده الزاماً مفید نیست. مصرف‌کننده باید بفهمد کدام مقدار هنگام ساخت ثبت شده و کدام هنگام فراخوانی وارد می‌شود. اگر تنظیمات بعداً تغییر می‌کنند، درباره ساخت تابع تازه یا خواندن منبع جاری تصمیم روشن لازم است.

Memoization می‌تواند Cache را در Closure نگه دارد. کلید، محدودیت اندازه و زمان پاک شدن Cache باید تعریف شوند. ذخیره بی‌حد همه ورودی‌ها به امید سرعت بیشتر، رشد حافظه می‌سازد. اگر نتیجه به داده دیگری وابسته است، کلید ناقص ممکن است خروجی قدیمی بدهد. مقاله عملکرد React درباره تفاوت Cache و درستی برنامه توضیح دارد. Closure ابزار نگهداری محیط است و صحت سیاست Cache را تضمین نمی‌کند.

عمر Listener و حافظه را مدیریت کنید

تا وقتی تابع قابل دسترس است، محیط مورد نیاز آن ممکن است قابل دسترس بماند. Listener طولانی‌عمر که به داده بزرگ یا عنصر قدیمی اشاره دارد می‌تواند عمر آن منابع را بیشتر کند. وجود Closure به خودی خود Memory Leak نیست؛ مشکل از ارجاع‌هایی می‌آید که بیش از نیاز باقی مانده‌اند. Cleanup را در قرارداد ماژول بگذارید و پس از حذف بخش رابط، Listener یا Timer مربوط را متوقف کنید. آزاد شدن واقعی حافظه با سیاست Garbage Collector انجام می‌شود.

در Frameworkها، چرخه Render می‌تواند محیط‌های تازه بسازد. Handlerی که از محیط قدیمی استفاده می‌کند ممکن است State قدیمی را ببیند؛ راه حل باید با مدل Framework هماهنگ باشد. مقاله TypeScript با React برای مدل State و Composition API برای Cleanup زمینه بیشتری دارند. اصطلاح Stale Closure را به همه تأخیرها نسبت ندهید؛ ابتدا ثابت کنید تابع واقعاً از کدام محیط و کدام مقدار استفاده می‌کند.

رفتار را با سه پرسش توضیح دهید

برای هر Callback بنویسید کجا تعریف شده، چه Bindingهایی را می‌خواند و چه کسی عمر آن را مدیریت می‌کند. سپس زمان تغییر آن Bindingها را کنار زمان اجرای تابع بگذارید. این روش برای Event، Timer و درخواست شبکه قابل استفاده است. توضیح دقیق به جای عبارت «تابع همه چیز را به خاطر می‌سپارد» کمک می‌کند تفاوت مقدار ثابت و متغیر جاری دیده شود و اصلاح به همان رابطه محدود بماند.

در تحویل ماژول، ورودی Factory، امکانات خروجی و روش Cleanup را ثبت کنید. اگر State مشترک است، آن را صریح بگویید؛ اگر مستقل است، محل ساختش باید همان انتظار را پشتیبانی کند. Closure زمانی مفید است که قرارداد کوچکی برای داده و رفتار بسازد. نیازی نیست برای هر متغیر یک لایه تابع اضافه شود؛ همان محدوده‌ای را انتخاب کنید که مصرف‌کننده بدون حدس درباره زمان و مالکیت، رفتار آن را بفهمد.

واکنش به این مقاله

اشتراک‌گذاری این مقاله:
دیدگاه‌ها 0
۰ / ۲۰۰۰

هنوز دیدگاهی ثبت نشده

اولین نفری باشید که نظر می‌دهد!