کلوژر در جاوااسکریپت: توابع چطور به خاطر میسپارند
کلوژر واقعاً چیست، چطور یک تابع جاوااسکریپت بعد از تمام شدن تابع بیرونی متغیرهایش را نگه میدارد و کلوژرها چه الگوها و باگهایی میسازند.
در این صفحه
کلوژر (closure) شبیه یک مبحث پیشرفته به نظر میرسد، اما به احتمال زیاد تا حالا از آن استفاده کردهاید. هر event handler که متغیری از بیرون میخواند، هر callback در setTimeout و هر هوک ریاکت به کلوژرها تکیه دارد.
در این مقاله یاد میگیرید کلوژر واقعاً چیست، چرا یک تابع هنوز میتواند از متغیرهایی استفاده کند که تابعِ سازندهشان مدتهاست تمام شده، کلوژرها چه الگوهایی را ممکن میکنند و دو باگی که بیشتر از همه به خاطرشان پیش میآید کداماند.
از محدوده (scope) شروع کنیم#
در جاوااسکریپت، اینکه یک متغیر کجا قابل استفاده است را جایی که در کد نوشته شده تعیین میکند. به این محدودهٔ واژگانی (lexical scope) میگویند. یک تابع داخلی متغیرهای همهٔ توابعی را که داخلشان قرار دارد میبیند:
function outer() {
const message = "Hello from outer";
function inner() {
console.log(message); // inner can see outer's variables
}
inner();
}
outer(); // "Hello from outer"هنوز چیز عجیبی نیست: inner وقتی اجرا میشود که outer هنوز در حال اجراست، پس طبیعی است که message وجود داشته باشد. سؤال جالب این است که اگر تابع داخلی بیشتر از تابع بیرونی عمر کند، چه اتفاقی میافتد.
تابعی که به خاطر میسپارد#
اینجا makeCounter یک متغیر میسازد، یک تابع داخلی برمیگرداند و بعد تمام میشود:
function makeCounter() {
let count = 0;
return function () {
count += 1;
return count;
};
}
const counter = makeCounter();
counter(); // 1
counter(); // 2
counter(); // 3وقتی counter() را صدا میزنیم، makeCounter مدتهاست برگشته. متغیر محلیاش، count، باید از بین رفته باشد، اما شمارنده همچنان آن را میخواند و تغییر میدهد.
این همان کلوژر است: تابعی که همراه با متغیرهای محدودهای که در آن ساخته شده بستهبندی شده. وقتی جاوااسکریپت تابع داخلی را میسازد، یک ارجاع پنهان به محدودهٔ اطرافش به آن میدهد. تا وقتی آن تابع وجود دارد، آن محدوده هم زنده میماند.
هر تابعی یک کلوژر است
از نظر فنی، هر تابع جاوااسکریپت ارجاعی به محدودهای که در آن ساخته شده نگه میدارد. معمولاً فقط وقتی اسمش را «کلوژر» میگذاریم که این موضوع اهمیت پیدا کند؛ یعنی وقتی تابع بعد از تمام شدن تابع بیرونی از متغیرهای آن استفاده میکند.
کلوژر متغیر را نگه میدارد، نه مقدار را#
کلوژر از مقدارها عکس فوری نمیگیرد. یک پیوند زنده به خودِ متغیر نگه میدارد، پس همیشه مقدار فعلی را میبیند:
let greeting = "Hello";
function greet(name) {
return `${greeting}, ${name}!`;
}
greeting = "Salam";
greet("Sara"); // "Salam, Sara!"به همین دلیل دو بار صدا زدن makeCounter() دو شمارندهٔ مستقل به شما میدهد. هر فراخوانی بدنهٔ تابع را دوباره اجرا میکند و یک متغیر count جدید میسازد و هر تابعِ برگشتی کلوژرِ متغیرِ خودش را دارد:
const a = makeCounter();
const b = makeCounter();
a(); // 1
a(); // 2
b(); // 1 (b has its own count)الگوی ۱: وضعیت خصوصی#
کدی که بیرون از makeCounter است هیچ راهی برای دسترسی مستقیم به count ندارد. فقط توابعی که داخل آن ساخته شدهاند میتوانند به آن دست بزنند. این یعنی کلوژرها راهی ساده برای پنهان کردن دادهاند:
function createAccount(initialBalance = 0) {
let balance = initialBalance;
return {
deposit(amount) {
balance += amount;
return balance;
},
withdraw(amount) {
if (amount > balance) throw new Error("Insufficient funds");
balance -= amount;
return balance;
},
getBalance() {
return balance;
},
};
}
const account = createAccount(100);
account.deposit(50); // 150
account.withdraw(30); // 120
account.balance; // undefined: the variable is not a propertyتنها راه تغییر balance از طریق deposit و withdraw است، پس قانون «بیشتر از موجودی نمیتوان برداشت کرد» دور زده نمیشود. کلاسهای مدرن برای همین کار فیلدهای #private دارند، اما نسخهٔ کلوژری همهجا کار میکند و به this هم نیازی ندارد.
الگوی ۲: کارخانهٔ توابع#
تابعی که تابع برمیگرداند میتواند آنها را «از پیش تنظیم» کند. هر تابعِ برگشتی آرگومانهایی را که با آن ساخته شده به خاطر میسپارد:
function multiplier(factor) {
return (n) => n * factor;
}
const double = multiplier(2);
const triple = multiplier(3);
double(5); // 10
triple(5); // 15همین ایده ابزارهای کوچکی به شما میدهد که در غیر این صورت برایشان سراغ یک کتابخانه میرفتید. once یک تابع را فقط یک بار اجرا میکند و نتیجه را به خاطر میسپارد:
function once(fn) {
let called = false;
let result;
return function (...args) {
if (!called) {
called = true;
result = fn.apply(this, args);
}
return result;
};
}
const init = once(() => console.log("Connecting…"));
init(); // "Connecting…"
init(); // nothing: already doneو memoize نتیجهها را در یک Map ذخیره میکند که فقط تابعِ برگشتی آن را میبیند:
function memoize(fn) {
const cache = new Map();
return (arg) => {
if (!cache.has(arg)) cache.set(arg, fn(arg));
return cache.get(arg);
};
}
const slowSquare = (n) => { /* imagine heavy work */ return n * n; };
const fastSquare = memoize(slowSquare);
fastSquare(9); // computed
fastSquare(9); // from the cacheالگوی ۳: event handlerها و callbackها#
callbackها بعداً اجرا میشوند؛ مدتها بعد از اینکه کدی که ثبتشان کرده تمام شده. کلوژرها همان چیزیاند که باعث میشوند هنوز بدانند روی چه چیزی باید کار کنند:
function setupLikeButton(button) {
let likes = 0;
button.addEventListener("click", () => {
likes += 1;
button.textContent = `Likes: ${likes}`;
});
}
document.querySelectorAll(".like").forEach(setupLikeButton);هر دکمه متغیر likes مخصوص خودش را میگیرد؛ بدون وضعیت سراسری و بدون اتریبیوتهای data- که باید همگام نگهشان دارید.
باگ ۱: حلقهٔ کلاسیک با var#
این کد چه چیزی چاپ میکند؟
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 3, 3, 3نه 0, 1, 2. محدودهٔ متغیری که با var تعریف شود کل تابع است، پس فقط یک i وجود دارد که هر سه callback در آن شریکاند. تا وقتی آنها اجرا شوند، حلقه تمام شده و i برابر 3 است.
راهحل یک کلمه است. با let، حلقه برای هر دور یک نسخهٔ جدید از i میسازد و هر callback کلوژرِ نسخهٔ خودش را دارد:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 0, 1, 2let و const را ترجیح دهید
باگهایی مثل این دلیل اصلی دوری جاوااسکریپتِ مدرن از var هستند. بهطور پیشفرض از const استفاده کنید و هر جا متغیر باید تغییر کند از let.
باگ ۲: کلوژرهای کهنه (stale closure)#
روی دیگرِ «هر فراخوانی متغیرهای جدید میسازد» این است که تابعی که در یک فراخوانی ساخته شده، همیشه متغیرهای همان فراخوانی را میبیند، حتی وقتی نسخههای جدیدتری وجود دارند. برنامهنویسان ریاکت مدام با این روبهرو میشوند:
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setSeconds(seconds + 1); // always reads seconds from the first render: 0
}, 1000);
return () => clearInterval(id);
}, []);
return <p>{seconds}</p>;
}تایمر 1 را نشان میدهد و بعد دیگر تغییر نمیکند. هر بار رندر شدن Timer یک فراخوانی جدید از تابع است با ثابت seconds مخصوص خودش، اما callbackِ interval در اولین رندر ساخته شده؛ پس برای همیشه 0 را میخواند و مقدار را روی 0 + 1 میگذارد.
راهحل این است که به مقدارِ گرفتهشده تکیه نکنید. بهجای آن یک تابع بهروزرسانی بدهید تا ریاکت هر بار مقدار فعلی را به شما بدهد:
setSeconds((current) => current + 1);کلوژرها و حافظه#
کلوژر هر چیزی را که استفاده میکند زنده نگه میدارد. معمولاً دقیقاً همین را میخواهید، اما ممکن است بیشتر از انتظارتان حافظه نگه دارد:
function attachReport(element) {
const rows = loadHugeReport(); // say, 50 MB of data
element.addEventListener("click", () => {
console.log(`${rows.length} rows`);
});
}تا وقتی آن listener متصل است، rows هرگز آزاد نمیشود؛ در حالی که handler فقط به یک عدد نیاز دارد. موتورهایی مثل V8 فقط متغیرهایی را نگه میدارند که تابع داخلی واقعاً از آنها استفاده میکند، پس بهجای چیز بزرگ، چیز کوچک را بگیرید:
function attachReport(element) {
const rowCount = loadHugeReport().length;
element.addEventListener("click", () => {
console.log(`${rowCount} rows`);
});
}و وقتی یک handler دیگر لازم نیست، حذفش کنید (با removeEventListener یا گزینهٔ signal همراه با یک AbortController) تا کلوژرش توسط garbage collector جمع شود.
جمعبندی#
- محدودهٔ واژگانی: یک تابع میتواند از متغیرهای کدی که داخلش نوشته شده استفاده کند.
- کلوژر یعنی یک تابع بههمراه ارجاعی زنده به محدودهای که در آن ساخته شده. آن محدوده تا وقتی تابع وجود دارد زنده میماند.
- کلوژرها متغیرها را نگه میدارند، نه مقدارها را و هر فراخوانی تابع بیرونی متغیرهای تازه میسازد.
- از آنها برای وضعیت خصوصی، کارخانهٔ توابع مثل
onceوmemoizeو callbackها استفاده کنید. - مراقب
varدر حلقهها، کلوژرهای کهنه که مقدارهای قدیمی را میخوانند و کلوژرهایی که اشیای بزرگ را زنده نگه میدارند باشید.
وقتی توابع را «کد بههمراه متغیرهایی که به خاطر میسپارد» ببینید، خیلی از کدهای جاوااسکریپت که شبیه جادو بودند بدیهی به نظر میرسند.