مقالات › جاوااسکریپت

آموزش Scope، Hoisting و Closure در جاوااسکریپت به زبان ساده

بفهمید جاوااسکریپت واقعاً متغیرها را چطور پیدا می‌کند: Scope سراسری، تابعی و بلوکی، تفاوت var و let و const، Hoisting، منطقه‌ی مرده‌ی زمانی و Closure با مثال.

Scope، Hoisting و Closure پایه‌ی نحوه‌ی کار جاوااسکریپت هستند. این سه مفهوم توضیح می‌دهند چرا یک متغیر به‌جای خطا undefined می‌شود، چرا یک حلقه پنج بار عدد یکسانی چاپ می‌کند و هوک‌های React چطور مقادیر را بین رندرها به خاطر می‌سپارند. وقتی این سه ایده را عمیق بفهمید، یک دسته‌ی کامل از باگ‌ها برای همیشه از کدتان حذف می‌شوند.

Scope؛ محدوده‌ی دیده شدن متغیر

Scope بخشی از کد است که یک متغیر در آن قابل دسترسی است. جاوااسکریپت سه نوع Scope دارد:

  1. Scope سراسری (Global) — بیرون از هر تابع یا بلوک تعریف می‌شود.
  2. Scope تابعی (Function) — داخل تابع تعریف می‌شود و در کل آن تابع دیده می‌شود.
  3. Scope بلوکی (Block) — با let یا const داخل { } تعریف می‌شود (if، for، while یا بلوک ساده).
const app = "Portfolio";           // سراسری

function greet() {
  const greeting = "Hello";         // Scope تابع
  if (true) {
    const name = "Ali";             // Scope بلوک
    console.log(`${greeting}, ${name} from ${app}`);
  }
  // console.log(name);  ❌ ReferenceError: name is not defined
}

Scope لغوی و زنجیره‌ی Scope

جاوااسکریپت از Scope لغوی (Lexical) استفاده می‌کند: اینکه یک تابع به چه چیزهایی دسترسی دارد بر اساس جایی که نوشته شده تعیین می‌شود، نه جایی که صدا زده می‌شود. وقتی متغیری را می‌خوانید، موتور اول در Scope فعلی، بعد در Scope بیرونی و همین‌طور تا Scope سراسری جستجو می‌کند. این مسیر جستجو زنجیره‌ی Scope (Scope Chain) نام دارد.

const level = "global";

function outer() {
  const level = "outer";
  function inner() {
    console.log(level); // "outer" — در نزدیک‌ترین Scope بیرونی پیدا شد
  }
  return inner;
}

outer()(); // "outer"

تفاوت var و let و const

var let const
Scope تابعی بلوکی بلوکی
Hoisting بله، با مقدار اولیه‌ی undefined بله، اما در TDZ بله، اما در TDZ
تعریف دوباره در همان Scope مجاز خطا خطا
مقداردهی دوباره مجاز مجاز غیرمجاز

نکته: به‌طور پیش‌فرض از const استفاده کنید و فقط وقتی واقعاً به مقداردهی دوباره نیاز دارید سراغ let بروید. در کد مدرن از var دوری کنید.

دقت کنید const جلوی مقداردهی دوباره را می‌گیرد، نه تغییر محتوا:

const user = { name: "Ali" };
user.name = "Sara";   // ✅ مجاز — محتوای شیء تغییر کرد
// user = {};          ❌ TypeError — خود متغیر قابل تغییر نیست

Hoisting

موتور جاوااسکریپت قبل از اجرای کد، هر Scope را بررسی و تعریف‌هایش را ثبت می‌کند. به این کار Hoisting می‌گویند؛ طوری رفتار می‌کند که انگار تعریف‌ها به بالای Scope منتقل شده‌اند.

console.log(a); // undefined — var با مقدار undefined بالا برده می‌شود
var a = 5;

sayHi();        // "Hi!" — تعریف تابع کامل بالا برده می‌شود
function sayHi() {
  console.log("Hi!");
}

عبارت‌های تابعی و Arrow Functionهایی که در متغیر ذخیره می‌شوند، از قوانین همان متغیر پیروی می‌کنند:

greet(); // ❌ TypeError (با var) یا ReferenceError (با let/const)
const greet = () => console.log("Hello");

منطقه‌ی مرده‌ی زمانی (TDZ)

let و const هم Hoist می‌شوند، اما مقداردهی اولیه نمی‌شوند. دسترسی به آن‌ها قبل از خط تعریف خطا می‌دهد:

console.log(total); // ❌ ReferenceError: Cannot access 'total' before initialization
let total = 10;

فاصله‌ی بین شروع Scope تا خط تعریف، منطقه‌ی مرده‌ی زمانی (Temporal Dead Zone) نام دارد. این یک ویژگی مفید است: باگ‌های بی‌صدای undefined را به خطاهای واضح تبدیل می‌کند.

Closure

Closure یعنی یک تابع به‌همراه متغیرهای Scopeی که در آن ساخته شده است. تابع داخلی به آن متغیرها دسترسی دارد، حتی بعد از اینکه تابع بیرونی تمام شده باشد.

function createCounter() {
  let count = 0;            // State خصوصی
  return {
    increment: () => ++count,
    decrement: () => --count,
    get value() { return count; },
  };
}

const counter = createCounter();
counter.increment();
counter.increment();
console.log(counter.value); // 2
// count از بیرون قابل دسترسی نیست — واقعاً خصوصی است

هر بار صدا زدن createCounter یک count جدید و مستقل می‌سازد. جاوااسکریپت State خصوصی را بدون کلاس این‌گونه پیاده می‌کند.

Closureهایی که هر روز استفاده می‌کنید

// ۱) Event Handlerها
function setupButton(button, message) {
  button.addEventListener("click", () => alert(message)); // message را به خاطر می‌سپارد
}

// ۲) کارخانه‌ی توابع (Function Factory)
const multiply = (factor) => (n) => n * factor;
const double = multiply(2);
double(21); // 42

// ۳) Debounce
function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

const search = debounce((q) => console.log("searching", q), 300);

هوک‌های React به‌شدت به Closure وابسته‌اند: هر رندر توابع جدیدی می‌سازد که Props و State همان رندر را «در بر می‌گیرند».

باگ کلاسیک حلقه

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

با var فقط یک متغیر i برای کل تابع وجود دارد. وقتی Callbackها اجرا می‌شوند، حلقه تمام شده و i برابر ۳ است.

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

با let هر تکرار حلقه یک متغیر جدید دارد و هر Closure، i مخصوص خودش را نگه می‌دارد.

Closure کهنه (Stale Closure) در React

همین مفهوم عامل باگ معروف «State کهنه» است:

function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setSeconds(seconds + 1); // ❌ همیشه 0 را از رندر اول می‌خواند
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <p>{seconds}</p>;
}

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

setSeconds((s) => s + 1); // ✅

Closure و حافظه

Closure تا وقتی خود تابع قابل دسترسی باشد، متغیرهایش را زنده نگه می‌دارد. معمولاً همین را می‌خواهیم، اما Closureهای بلندعمر (Event Listenerهای سراسری، تایمرها، کش‌ها) می‌توانند اشیای بزرگ را در حافظه نگه دارند. Listenerها و تایمرها را وقتی دیگر لازم نیستند پاک کنید.

جمع‌بندی

  • Scope تعیین می‌کند متغیرها کجا دیده می‌شوند؛ جاوااسکریپت آن‌ها را به‌صورت لغوی و در طول زنجیره‌ی Scope پیدا می‌کند.
  • const و let را ترجیح دهید؛ این دو بلوکی هستند و TDZ از آن‌ها محافظت می‌کند.
  • Hoisting تعریف‌ها را قبل از اجرا ثبت می‌کند: var مقدار undefined می‌گیرد، توابع کامل در دسترس‌اند و let/const مقداردهی نشده باقی می‌مانند.
  • Closure به توابع اجازه می‌دهد متغیرهای Scope محل ساختشان را به خاطر بسپارند؛ پایه‌ی State خصوصی، Factoryها، Callbackها و هوک‌ها.

پرسش‌های متداول

آیا Closure فقط با توابع تودرتو ساخته می‌شود؟

هر تابع در جاوااسکریپت روی Scope اطرافش یک Closure تشکیل می‌دهد. فقط وقتی تابع داخلی بیشتر از تابع بیرونی زنده بماند، اثرش بیشتر دیده می‌شود.

آیا Hoisting واقعاً کد را جابه‌جا می‌کند؟

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

چرا این مباحث در مصاحبه‌های کاری زیاد پرسیده می‌شوند؟

چون نشان می‌دهند فرد نحوه‌ی اجرای جاوااسکریپت را می‌فهمد، نه فقط نحو آن را؛ و دلیل بسیاری از باگ‌های واقعی را توضیح می‌دهند.