جاوااسکریپتمتوسط

کلوژر در جاوااسکریپت: توابع چطور به خاطر می‌سپارند

کلوژر واقعاً چیست، چطور یک تابع جاوااسکریپت بعد از تمام شدن تابع بیرونی متغیرهایش را نگه می‌دارد و کلوژرها چه الگوها و باگ‌هایی می‌سازند.

تابع شمارنده درون محدودهٔ تمام‌شدهٔ makeCounter که هنوز به متغیر count اشاره می‌کند
در این صفحه

کلوژر (closure) شبیه یک مبحث پیشرفته به نظر می‌رسد، اما به احتمال زیاد تا حالا از آن استفاده کرده‌اید. هر event handler که متغیری از بیرون می‌خواند، هر callback در setTimeout و هر هوک ری‌اکت به کلوژرها تکیه دارد.

در این مقاله یاد می‌گیرید کلوژر واقعاً چیست، چرا یک تابع هنوز می‌تواند از متغیرهایی استفاده کند که تابعِ سازنده‌شان مدت‌هاست تمام شده، کلوژرها چه الگوهایی را ممکن می‌کنند و دو باگی که بیشتر از همه به خاطرشان پیش می‌آید کدام‌اند.

از محدوده (scope) شروع کنیم#

در جاوااسکریپت، اینکه یک متغیر کجا قابل استفاده است را جایی که در کد نوشته شده تعیین می‌کند. به این محدودهٔ واژگانی (lexical scope) می‌گویند. یک تابع داخلی متغیرهای همهٔ توابعی را که داخلشان قرار دارد می‌بیند:

JavaScript
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 یک متغیر می‌سازد، یک تابع داخلی برمی‌گرداند و بعد تمام می‌شود:

JavaScript
function makeCounter() {
  let count = 0;

  return function () {
    count += 1;
    return count;
  };
}

const counter = makeCounter();

counter(); // 1
counter(); // 2
counter(); // 3

وقتی counter() را صدا می‌زنیم، makeCounter مدت‌هاست برگشته. متغیر محلی‌اش، count، باید از بین رفته باشد، اما شمارنده همچنان آن را می‌خواند و تغییر می‌دهد.

این همان کلوژر است: تابعی که همراه با متغیرهای محدوده‌ای که در آن ساخته شده بسته‌بندی شده. وقتی جاوااسکریپت تابع داخلی را می‌سازد، یک ارجاع پنهان به محدودهٔ اطرافش به آن می‌دهد. تا وقتی آن تابع وجود دارد، آن محدوده هم زنده می‌ماند.

const counter = makeCounter(); makeCounter()⁧برگشته و تمام شده⁩ ⁧متغیرهایش⁩count: 3⁧هنوز زنده⁩ countercount += 1 ⁧ساخت⁩⁧ارجاع دارد⁩ ⁧کلوژر: ⁦counter⁩ ارجاعی به محدوده‌ای که در آن ساخته شده نگه می‌دارد، پس هنوز می‌تواند ⁦count⁩ را بخواند و تغییر دهد⁩
makeCounter برگشته، اما تابع شمارنده هنوز به متغیرهای آن ارجاع دارد؛ پس count باقی می‌ماند

هر تابعی یک کلوژر است

از نظر فنی، هر تابع جاوااسکریپت ارجاعی به محدوده‌ای که در آن ساخته شده نگه می‌دارد. معمولاً فقط وقتی اسمش را «کلوژر» می‌گذاریم که این موضوع اهمیت پیدا کند؛ یعنی وقتی تابع بعد از تمام شدن تابع بیرونی از متغیرهای آن استفاده می‌کند.

کلوژر متغیر را نگه می‌دارد، نه مقدار را#

کلوژر از مقدارها عکس فوری نمی‌گیرد. یک پیوند زنده به خودِ متغیر نگه می‌دارد، پس همیشه مقدار فعلی را می‌بیند:

JavaScript
let greeting = "Hello";

function greet(name) {
  return `${greeting}, ${name}!`;
}

greeting = "Salam";
greet("Sara"); // "Salam, Sara!"

به همین دلیل دو بار صدا زدن makeCounter() دو شمارندهٔ مستقل به شما می‌دهد. هر فراخوانی بدنهٔ تابع را دوباره اجرا می‌کند و یک متغیر count جدید می‌سازد و هر تابعِ برگشتی کلوژرِ متغیرِ خودش را دارد:

JavaScript
const a = makeCounter();
const b = makeCounter();

a(); // 1
a(); // 2
b(); // 1  (b has its own count)

الگوی ۱: وضعیت خصوصی#

کدی که بیرون از makeCounter است هیچ راهی برای دسترسی مستقیم به count ندارد. فقط توابعی که داخل آن ساخته شده‌اند می‌توانند به آن دست بزنند. این یعنی کلوژرها راهی ساده برای پنهان کردن داده‌اند:

JavaScript
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 هم نیازی ندارد.

الگوی ۲: کارخانهٔ توابع#

تابعی که تابع برمی‌گرداند می‌تواند آن‌ها را «از پیش تنظیم» کند. هر تابعِ برگشتی آرگومان‌هایی را که با آن ساخته شده به خاطر می‌سپارد:

JavaScript
function multiplier(factor) {
  return (n) => n * factor;
}

const double = multiplier(2);
const triple = multiplier(3);

double(5); // 10
triple(5); // 15

همین ایده ابزارهای کوچکی به شما می‌دهد که در غیر این صورت برایشان سراغ یک کتابخانه می‌رفتید. once یک تابع را فقط یک بار اجرا می‌کند و نتیجه را به خاطر می‌سپارد:

JavaScript
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 ذخیره می‌کند که فقط تابعِ برگشتی آن را می‌بیند:

JavaScript
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ها بعداً اجرا می‌شوند؛ مدت‌ها بعد از اینکه کدی که ثبتشان کرده تمام شده. کلوژرها همان چیزی‌اند که باعث می‌شوند هنوز بدانند روی چه چیزی باید کار کنند:

JavaScript
function setupLikeButton(button) {
  let likes = 0;

  button.addEventListener("click", () => {
    likes += 1;
    button.textContent = `Likes: ${likes}`;
  });
}

document.querySelectorAll(".like").forEach(setupLikeButton);

هر دکمه متغیر likes مخصوص خودش را می‌گیرد؛ بدون وضعیت سراسری و بدون اتریبیوت‌های data- که باید همگام نگهشان دارید.

باگ ۱: حلقهٔ کلاسیک با var#

این کد چه چیزی چاپ می‌کند؟

JavaScript
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 کلوژرِ نسخهٔ خودش را دارد:

JavaScript
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// 0, 1, 2

let و const را ترجیح دهید

باگ‌هایی مثل این دلیل اصلی دوری جاوااسکریپتِ مدرن از var هستند. به‌طور پیش‌فرض از const استفاده کنید و هر جا متغیر باید تغییر کند از let.

باگ ۲: کلوژرهای کهنه (stale closure)#

روی دیگرِ «هر فراخوانی متغیرهای جدید می‌سازد» این است که تابعی که در یک فراخوانی ساخته شده، همیشه متغیرهای همان فراخوانی را می‌بیند، حتی وقتی نسخه‌های جدیدتری وجود دارند. برنامه‌نویسان ری‌اکت مدام با این روبه‌رو می‌شوند:

Jsx
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 می‌گذارد.

راه‌حل این است که به مقدارِ گرفته‌شده تکیه نکنید. به‌جای آن یک تابع به‌روزرسانی بدهید تا ری‌اکت هر بار مقدار فعلی را به شما بدهد:

JavaScript
setSeconds((current) => current + 1);

کلوژرها و حافظه#

کلوژر هر چیزی را که استفاده می‌کند زنده نگه می‌دارد. معمولاً دقیقاً همین را می‌خواهید، اما ممکن است بیشتر از انتظارتان حافظه نگه دارد:

JavaScript
function attachReport(element) {
  const rows = loadHugeReport(); // say, 50 MB of data

  element.addEventListener("click", () => {
    console.log(`${rows.length} rows`);
  });
}

تا وقتی آن listener متصل است، rows هرگز آزاد نمی‌شود؛ در حالی که handler فقط به یک عدد نیاز دارد. موتورهایی مثل V8 فقط متغیرهایی را نگه می‌دارند که تابع داخلی واقعاً از آن‌ها استفاده می‌کند، پس به‌جای چیز بزرگ، چیز کوچک را بگیرید:

JavaScript
function attachReport(element) {
  const rowCount = loadHugeReport().length;

  element.addEventListener("click", () => {
    console.log(`${rowCount} rows`);
  });
}

و وقتی یک handler دیگر لازم نیست، حذفش کنید (با removeEventListener یا گزینهٔ signal همراه با یک AbortController) تا کلوژرش توسط garbage collector جمع شود.

جمع‌بندی#

  • محدودهٔ واژگانی: یک تابع می‌تواند از متغیرهای کدی که داخلش نوشته شده استفاده کند.
  • کلوژر یعنی یک تابع به‌همراه ارجاعی زنده به محدوده‌ای که در آن ساخته شده. آن محدوده تا وقتی تابع وجود دارد زنده می‌ماند.
  • کلوژرها متغیرها را نگه می‌دارند، نه مقدارها را و هر فراخوانی تابع بیرونی متغیرهای تازه می‌سازد.
  • از آن‌ها برای وضعیت خصوصی، کارخانهٔ توابع مثل once و memoize و callbackها استفاده کنید.
  • مراقب var در حلقه‌ها، کلوژرهای کهنه که مقدارهای قدیمی را می‌خوانند و کلوژرهایی که اشیای بزرگ را زنده نگه می‌دارند باشید.

وقتی توابع را «کد به‌همراه متغیرهایی که به خاطر می‌سپارد» ببینید، خیلی از کدهای جاوااسکریپت که شبیه جادو بودند بدیهی به نظر می‌رسند.